1/**
2 * Tree Compress Core
3 *
4 * Walks from leaves to root. Compresses content at each level.
5 * Carries essential meaning upward. Trims what's been absorbed.
6 *
7 * Two modes:
8 * - Full: compress everything up to compressionCeiling
9 * - Budget: compress until tree fits under targetSizeBytes
10 */
11
12import log from "../../seed/log.js";
13import Node from "../../seed/models/node.js";
14import Note from "../../seed/models/note.js";
15import { SYSTEM_ROLE, NODE_STATUS, CONTENT_TYPE } from "../../seed/protocol.js";
16import { getDescendantIds } from "../../seed/tree/treeFetch.js";
17import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
18
19let _runChat = null;
20let _editStatus = null;
21let _metadata = null;
22export function setServices(services) {
23 _runChat = services.runChat;
24 _editStatus = services.editStatus;
25 _metadata = services.metadata;
26}
27
28// ─────────────────────────────────────────────────────────────────────────
29// CONFIG
30// ─────────────────────────────────────────────────────────────────────────
31
32const DEFAULTS = {
33 compressionCeiling: 2,
34 targetSizeBytes: null,
35 maxNotesPerCompression: 100,
36 maxEssenceBytes: 4096,
37 autoReviveOnTrip: false,
38 compressionModel: null,
39};
40
41export async function getCompressConfig() {
42 const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
43 if (!configNode) return { ...DEFAULTS };
44 const meta = configNode.metadata instanceof Map
45 ? configNode.metadata.get("tree-compress") || {}
46 : configNode.metadata?.["tree-compress"] || {};
47 return { ...DEFAULTS, ...meta };
48}
49
50// ─────────────────────────────────────────────────────────────────────────
51// TREE ANALYSIS
52// ─────────────────────────────────────────────────────────────────────────
53
54/**
55 * Build a depth map of all nodes in a tree. Returns Map<nodeId, { depth, node }>.
56 * Walks BFS from the root.
57 */
58async function buildDepthMap(rootId) {
59 const map = new Map();
60 const queue = [{ id: rootId, depth: 0 }];
61
62 while (queue.length > 0) {
63 const { id, depth } = queue.shift();
64 if (map.has(id)) continue;
65
66 const node = await Node.findById(id)
67 .select("_id name type status children metadata")
68 .lean();
69 if (!node) continue;
70
71 map.set(id, { depth, node });
72
73 if (node.children) {
74 for (const childId of node.children) {
75 if (!map.has(childId.toString())) {
76 queue.push({ id: childId.toString(), depth: depth + 1 });
77 }
78 }
79 }
80 }
81
82 return map;
83}
84
85/**
86 * Get nodes grouped by depth, deepest first.
87 * Excludes nodes above the ceiling (they stay uncompressed).
88 */
89function getCompressionOrder(depthMap, ceiling) {
90 const byDepth = new Map();
91 let maxDepth = 0;
92
93 for (const [nodeId, { depth }] of depthMap) {
94 if (depth < ceiling) continue; // above ceiling, skip
95 if (!byDepth.has(depth)) byDepth.set(depth, []);
96 byDepth.get(depth).push(nodeId);
97 if (depth > maxDepth) maxDepth = depth;
98 }
99
100 // Return levels deepest first
101 const levels = [];
102 for (let d = maxDepth; d >= ceiling; d--) {
103 if (byDepth.has(d)) levels.push({ depth: d, nodeIds: byDepth.get(d) });
104 }
105 return levels;
106}
107
108/**
109 * Estimate metadata size for a tree.
110 */
111async function estimateTreeSize(rootId) {
112 const ids = await getDescendantIds(rootId);
113 let totalBytes = 0;
114 for (const id of ids) {
115 const node = await Node.findById(id).select("metadata").lean();
116 if (!node) continue;
117 try {
118 totalBytes += Buffer.byteLength(JSON.stringify(node.metadata || {}), "utf8");
119 } catch { /* non-serializable, skip */ }
120 }
121 return totalBytes;
122}
123
124// ─────────────────────────────────────────────────────────────────────────
125// COMPRESSION PROMPTS
126// ─────────────────────────────────────────────────────────────────────────
127
128function buildLeafPrompt(node, notes, codebookDict) {
129 const codebookSection = codebookDict
130 ? `\nCodebook (known shorthand, do not expand these):\n${JSON.stringify(codebookDict)}\n`
131 : "";
132
133 return (
134 `You are compressing a tree node's content for long-term storage.\n` +
135 `Node: "${node.name}"${node.type ? ` (type: ${node.type})` : ""}\n` +
136 codebookSection +
137 `Notes (${notes.length}):\n${notes.map((n, i) => `[${i + 1}] ${n.content}`).join("\n\n")}\n\n` +
138 `Produce JSON:\n` +
139 `{\n` +
140 ` "summary": "max 500 chars, preserves all actionable meaning",\n` +
141 ` "facts": { "key": "value" pairs of extracted structured data },\n` +
142 ` "tags": ["topic tags for perspective filtering"]\n` +
143 `}\n\n` +
144 `Discard opinions, timestamps, conversational filler.\n` +
145 `Keep decisions, outcomes, measurements, references.`
146 );
147}
148
149function buildParentPrompt(node, notes, childEssences, codebookDict) {
150 const codebookSection = codebookDict
151 ? `\nCodebook (known shorthand, do not expand these):\n${JSON.stringify(codebookDict)}\n`
152 : "";
153
154 const childSection = childEssences
155 .map((c) => ` "${c.name}": ${JSON.stringify(c.essence)}`)
156 .join("\n");
157
158 return (
159 `You are merging a node's own content with compressed summaries from its children.\n` +
160 `Node: "${node.name}"${node.type ? ` (type: ${node.type})` : ""}\n` +
161 codebookSection +
162 (notes.length > 0
163 ? `\nOwn notes (${notes.length}):\n${notes.map((n, i) => `[${i + 1}] ${n.content}`).join("\n\n")}\n`
164 : "\nNo own notes.\n") +
165 `\nChild summaries:\n${childSection}\n\n` +
166 `Produce JSON:\n` +
167 `{\n` +
168 ` "summary": "max 800 chars, unified view of this branch",\n` +
169 ` "facts": { merged key-value pairs, deduplicated },\n` +
170 ` "tags": ["union of relevant tags"],\n` +
171 ` "absorbed": ["child node names whose content is fully represented here"]\n` +
172 `}\n\n` +
173 `If a child's facts are redundant with your own content, absorb them.\n` +
174 `If a child's facts add new information, preserve them.\n` +
175 `The goal is one coherent summary of this entire branch.`
176 );
177}
178
179// ─────────────────────────────────────────────────────────────────────────
180// SINGLE NODE COMPRESSION
181// ─────────────────────────────────────────────────────────────────────────
182
183/**
184 * Compress a single node. Returns the essence or null on failure.
185 */
186async function compressNode(nodeId, depthMap, userId, username, rootId, config) {
187 const entry = depthMap.get(nodeId);
188 if (!entry) return null;
189 const { node } = entry;
190
191 // Already compressed?
192 const meta = node.metadata instanceof Map
193 ? Object.fromEntries(node.metadata)
194 : (node.metadata || {});
195 if (meta.compress?.status === "compressed" || meta.compress?.status === "absorbed") return meta.compress?.essence;
196
197 // Load notes
198 const notes = await Note.find({ nodeId, contentType: CONTENT_TYPE.TEXT })
199 .sort({ createdAt: 1 })
200 .limit(config.maxNotesPerCompression)
201 .select("content")
202 .lean();
203
204 // Check codebook
205 let codebookDict = null;
206 try {
207 const { getExtension } = await import("../loader.js");
208 const codebookExt = getExtension("codebook");
209 if (codebookExt?.exports?.getCodebook) {
210 // Get any codebook on this node (take first available)
211 const cb = meta.codebook;
212 if (cb) {
213 for (const [uid, data] of Object.entries(cb)) {
214 if (data?.dictionary && Object.keys(data.dictionary).length > 0) {
215 codebookDict = data.dictionary;
216 break;
217 }
218 }
219 }
220 }
221 } catch (err) {
222 log.debug("TreeCompress", "Codebook lookup failed:", err.message);
223 }
224
225 // Determine if leaf or parent
226 const hasChildren = node.children && node.children.length > 0;
227 const childEssences = [];
228
229 if (hasChildren) {
230 for (const childId of node.children) {
231 const cid = childId.toString();
232 const childEntry = depthMap.get(cid);
233 if (!childEntry) continue;
234 const childMeta = childEntry.node.metadata instanceof Map
235 ? Object.fromEntries(childEntry.node.metadata)
236 : (childEntry.node.metadata || {});
237 if (childMeta.compress?.essence) {
238 childEssences.push({ name: childEntry.node.name, essence: childMeta.compress.essence });
239 }
240 }
241 }
242
243 // Build prompt
244 let prompt;
245 if (!hasChildren || childEssences.length === 0) {
246 if (notes.length === 0) return null; // nothing to compress
247 prompt = buildLeafPrompt(node, notes, codebookDict);
248 } else {
249 prompt = buildParentPrompt(node, notes, childEssences, codebookDict);
250 }
251
252 // Call AI
253 if (!_runChat) {
254 log.warn("TreeCompress", "runChat not available");
255 return null;
256 }
257
258 try {
259 const { answer } = await _runChat({
260 userId,
261 username: username || "system",
262 message: prompt,
263 mode: "tree:respond",
264 rootId,
265 slot: "compress",
266 });
267
268 if (!answer) return null;
269
270 const essence = parseJsonSafe(answer);
271 if (!essence || typeof essence !== "object") return null;
272
273 // Validate and cap
274 if (typeof essence.summary !== "string") essence.summary = "";
275 if (essence.summary.length > 800) essence.summary = essence.summary.slice(0, 800);
276 if (typeof essence.facts !== "object" || Array.isArray(essence.facts)) essence.facts = {};
277 if (!Array.isArray(essence.tags)) essence.tags = [];
278 if (!Array.isArray(essence.absorbed)) essence.absorbed = [];
279
280 // Cap total essence size
281 const essenceStr = JSON.stringify(essence);
282 if (Buffer.byteLength(essenceStr, "utf8") > config.maxEssenceBytes) {
283 // Trim facts to fit
284 const keys = Object.keys(essence.facts);
285 while (keys.length > 0 && Buffer.byteLength(JSON.stringify(essence), "utf8") > config.maxEssenceBytes) {
286 delete essence.facts[keys.pop()];
287 }
288 }
289
290 // Calculate sizes for history
291 const noteBytes = notes.reduce((sum, n) => sum + Buffer.byteLength(n.content || "", "utf8"), 0);
292 const essenceBytes = Buffer.byteLength(JSON.stringify(essence), "utf8");
293
294 // Write to metadata
295 await Node.findByIdAndUpdate(nodeId, {
296 $set: {
297 "metadata.compress.essence": essence,
298 "metadata.compress.status": "compressed",
299 },
300 $push: {
301 "metadata.compress.history": {
302 $each: [{
303 timestamp: new Date().toISOString(),
304 notesBefore: notes.length,
305 sizeBeforeBytes: noteBytes,
306 sizeAfterBytes: essenceBytes,
307 level: entry.depth,
308 }],
309 $slice: -20,
310 },
311 },
312 });
313
314 // Mark node as trimmed
315 if (_editStatus) {
316 try {
317 await _editStatus({
318 nodeId,
319 status: NODE_STATUS.TRIMMED,
320 userId,
321 wasAi: true,
322 isInherited: false,
323 });
324 } catch (err) {
325 log.debug("TreeCompress", `Failed to set trimmed status on ${nodeId}: ${err.message}`);
326 }
327 }
328
329 // Update in-memory depth map
330 if (entry.node.metadata instanceof Map) {
331 entry.node.metadata.set("compress", { essence, status: "compressed" });
332 }
333
334 return essence;
335 } catch (err) {
336 log.error("TreeCompress", `Compression failed for "${node.name}": ${err.message}`);
337 return null;
338 }
339}
340
341// ─────────────────────────────────────────────────────────────────────────
342// COMPRESSION RUNS
343// ─────────────────────────────────────────────────────────────────────────
344
345/**
346 * Full compression: compress everything from leaves to ceiling.
347 */
348export async function compressTree(rootId, userId, username) {
349 const config = await getCompressConfig();
350 const depthMap = await buildDepthMap(rootId);
351 const levels = getCompressionOrder(depthMap, config.compressionCeiling);
352
353 let nodesCompressed = 0;
354
355 for (const level of levels) {
356 for (const nodeId of level.nodeIds) {
357 const result = await compressNode(nodeId, depthMap, userId, username, rootId, config);
358 if (result) nodesCompressed++;
359 }
360 }
361
362 log.verbose("TreeCompress", `Compressed ${nodesCompressed} nodes in tree ${rootId}`);
363 return { nodesCompressed, levels: levels.length };
364}
365
366/**
367 * Branch compression: compress from a specific node downward.
368 */
369export async function compressBranch(nodeId, userId, username) {
370 // Find root for this node
371 let rootId;
372 try {
373 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
374 const root = await resolveRootNode(nodeId);
375 rootId = root?._id;
376 } catch {
377 rootId = nodeId;
378 }
379
380 const config = await getCompressConfig();
381 const depthMap = await buildDepthMap(nodeId);
382
383 // For branch compression, ceiling is 0 (compress the starting node too)
384 const levels = getCompressionOrder(depthMap, 0);
385 let nodesCompressed = 0;
386
387 for (const level of levels) {
388 for (const nid of level.nodeIds) {
389 const result = await compressNode(nid, depthMap, userId, username, rootId, config);
390 if (result) nodesCompressed++;
391 }
392 }
393
394 log.verbose("TreeCompress", `Compressed ${nodesCompressed} nodes in branch ${nodeId}`);
395 return { nodesCompressed, levels: levels.length };
396}
397
398/**
399 * Size-budget compression: compress until tree is under targetSizeBytes.
400 */
401export async function compressToBudget(rootId, userId, username, targetSizeBytes) {
402 const config = await getCompressConfig();
403 const target = targetSizeBytes || config.targetSizeBytes;
404 if (!target) return { nodesCompressed: 0, message: "No target size configured" };
405
406 let currentSize = await estimateTreeSize(rootId);
407 if (currentSize <= target) {
408 return { nodesCompressed: 0, currentSize, message: "Already under budget" };
409 }
410
411 const depthMap = await buildDepthMap(rootId);
412 const levels = getCompressionOrder(depthMap, config.compressionCeiling);
413 let nodesCompressed = 0;
414
415 for (const level of levels) {
416 if (currentSize <= target) break;
417
418 for (const nodeId of level.nodeIds) {
419 if (currentSize <= target) break;
420 const result = await compressNode(nodeId, depthMap, userId, username, rootId, config);
421 if (result) {
422 nodesCompressed++;
423 currentSize = await estimateTreeSize(rootId);
424 }
425 }
426 }
427
428 log.verbose("TreeCompress", `Budget compression: ${nodesCompressed} nodes, ${currentSize} bytes (target: ${target})`);
429 return { nodesCompressed, currentSize, targetSize: target, underBudget: currentSize <= target };
430}
431
432// ─────────────────────────────────────────────────────────────────────────
433// DECOMPRESSION
434// ─────────────────────────────────────────────────────────────────────────
435
436/**
437 * Decompress a node: restore to active. Notes become visible again.
438 * Essence stays as a bonus.
439 */
440export async function decompressNode(nodeId, userId) {
441 const node = await Node.findById(nodeId).select("status metadata").lean();
442 if (!node) throw new Error("Node not found");
443
444 if (node.status !== NODE_STATUS.TRIMMED) {
445 return { message: "Node is not compressed" };
446 }
447
448 // Restore status to active
449 if (_editStatus) {
450 await _editStatus({
451 nodeId,
452 status: NODE_STATUS.ACTIVE,
453 userId,
454 wasAi: false,
455 isInherited: false,
456 });
457 }
458
459 // Update compress status but keep essence
460 await _metadata.batchSetExtMeta(nodeId, "compress", { status: "decompressed" });
461
462 return { message: "Node decompressed. Notes visible again. Essence preserved." };
463}
464
465// ─────────────────────────────────────────────────────────────────────────
466// STATUS
467// ─────────────────────────────────────────────────────────────────────────
468
469/**
470 * Get compression status for a tree.
471 */
472export async function getCompressStatus(rootId) {
473 const depthMap = await buildDepthMap(rootId);
474
475 let compressed = 0;
476 let absorbed = 0;
477 let uncompressed = 0;
478 let totalNodes = 0;
479 let totalEssenceBytes = 0;
480 const history = [];
481
482 for (const [nodeId, { node }] of depthMap) {
483 totalNodes++;
484 const meta = node.metadata instanceof Map
485 ? Object.fromEntries(node.metadata)
486 : (node.metadata || {});
487
488 const status = meta.compress?.status || "uncompressed";
489 if (status === "compressed") compressed++;
490 else if (status === "absorbed") absorbed++;
491 else uncompressed++;
492
493 if (meta.compress?.essence) {
494 try {
495 totalEssenceBytes += Buffer.byteLength(JSON.stringify(meta.compress.essence), "utf8");
496 } catch (err) {
497 log.debug("TreeCompress", "Essence size calculation failed:", err.message);
498 }
499 }
500
501 if (meta.compress?.history) {
502 for (const h of meta.compress.history) {
503 history.push({ nodeId, nodeName: node.name, ...h });
504 }
505 }
506 }
507
508 // Sort history newest first
509 history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
510
511 return {
512 totalNodes,
513 compressed,
514 absorbed,
515 uncompressed,
516 totalEssenceBytes,
517 recentHistory: history.slice(0, 20),
518 };
519}
520
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setServices, compressToBudget, compressTree, getCompressConfig, getCompressStatus } from "./core.js";
4
5export async function init(core) {
6 // Wire services
7 const { editStatus } = await import("../../seed/tree/statuses.js");
8 core.llm.registerRootLlmSlot("compress");
9 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
10 setServices({
11 runChat: async (opts) => {
12 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
13 return core.llm.runChat({ ...opts, llmPriority: BG });
14 },
15 editStatus,
16 metadata: core.metadata,
17 });
18
19 // ── onTreeTripped: auto-compress as revival strategy ───────────────
20 // Read the trip scores and decide whether compression can actually help.
21 // The health score has three variables: nodeCount, metadataDensity, errorRate.
22 // Compression only reduces metadataDensity. If the tree tripped because of
23 // errorRate, compressing won't help. Tell the operator what's actually wrong.
24 core.hooks.register("onTreeTripped", async ({ rootId, reason, scores }) => {
25 const config = await getCompressConfig();
26 if (!config.autoReviveOnTrip) return;
27
28 // Read the actual score breakdown
29 const nodeScore = scores?.nodeCount || 0;
30 const densityScore = scores?.metadataDensity || 0;
31 const errorScore = scores?.errorRate || 0;
32 const dominant = densityScore >= nodeScore && densityScore >= errorScore ? "density"
33 : errorScore >= nodeScore && errorScore >= densityScore ? "error"
34 : "nodes";
35
36 if (dominant === "error") {
37 log.warn("TreeCompress",
38 `Tree ${rootId} tripped (${reason}) but dominant factor is error rate ` +
39 `(error: ${errorScore.toFixed(2)}, density: ${densityScore.toFixed(2)}, nodes: ${nodeScore.toFixed(2)}). ` +
40 `Compression cannot help. Investigate the errors.`,
41 );
42 return;
43 }
44
45 if (dominant === "nodes") {
46 log.info("TreeCompress",
47 `Tree ${rootId} tripped (${reason}). Dominant factor is node count ` +
48 `(nodes: ${nodeScore.toFixed(2)}, density: ${densityScore.toFixed(2)}, error: ${errorScore.toFixed(2)}). ` +
49 `Compression helps indirectly but the real fix is pruning dead branches. Attempting compression.`,
50 );
51 } else {
52 log.info("TreeCompress",
53 `Tree ${rootId} tripped (${reason}). Dominant factor is metadata density ` +
54 `(density: ${densityScore.toFixed(2)}, nodes: ${nodeScore.toFixed(2)}, error: ${errorScore.toFixed(2)}). ` +
55 `Starting auto-compression.`,
56 );
57 }
58
59 try {
60 const Node = core.models.Node;
61 const root = await Node.findById(rootId).select("rootOwner metadata").lean();
62 if (!root?.rootOwner) return;
63
64 const User = core.models.User;
65 const user = await User.findById(root.rootOwner).select("username").lean();
66 if (!user) return;
67
68 // Read the actual max metadata bytes from the circuit config, not a hardcoded guess
69 const { getLandConfigValue } = await import("../../seed/landConfig.js");
70 const maxMetaBytes = parseInt(getLandConfigValue("maxTreeMetadataBytes") || "1073741824", 10);
71
72 // Target: reduce density contribution below its weight threshold.
73 // densityScore = (currentDensity / maxMetaBytes) * densityWeight.
74 // We need densityScore low enough that total < 1.0 given the other scores.
75 // Target density: (1.0 - nodeScore - errorScore) / densityWeight * maxMetaBytes
76 // But simpler: compress until checkTreeHealth says total < 1.0.
77 const { checkTreeHealth } = await import("../../seed/tree/treeCircuit.js");
78
79 const result = await compressTree(rootId, root.rootOwner, user.username);
80
81 // After compression, check if health dropped below 1.0
82 const postHealth = await checkTreeHealth(rootId);
83
84 if (postHealth.total < 1.0) {
85 try {
86 await core.tree.reviveTree(rootId, root.rootOwner);
87 log.info("TreeCompress",
88 `Tree ${rootId} auto-revived after compression. ` +
89 `${result.nodesCompressed} nodes compressed. Health: ${postHealth.total.toFixed(2)}`,
90 );
91 } catch (err) {
92 log.warn("TreeCompress", `Tree ${rootId} health is ${postHealth.total.toFixed(2)} but revival failed: ${err.message}`);
93 }
94 } else {
95 log.warn("TreeCompress",
96 `Tree ${rootId} compressed ${result.nodesCompressed} nodes but health still ${postHealth.total.toFixed(2)}. ` +
97 `Scores: density=${postHealth.metadataDensity.toFixed(2)}, nodes=${postHealth.nodeCount.toFixed(2)}, error=${postHealth.errorRate.toFixed(2)}. ` +
98 `Manual intervention needed.`,
99 );
100 }
101 } catch (err) {
102 log.error("TreeCompress", `Auto-compression failed for tree ${rootId}: ${err.message}`);
103 }
104 }, "tree-compress");
105
106 // ── onDocumentPressure: compress deepest branches when tree approaches limits ──
107 core.hooks.register("onDocumentPressure", async ({ documentType, documentId, currentSize, maxSize, percent }) => {
108 if (documentType !== "node") return;
109
110 try {
111 // Find the tree root for this node
112 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
113 let root;
114 try { root = await resolveRootNode(documentId); } catch { return; }
115 if (!root?._id) return;
116
117 const Node = core.models.Node;
118 const User = core.models.User;
119 const user = await User.findById(root.rootOwner).select("username").lean();
120 if (!user) return;
121
122 log.info("TreeCompress", `Document pressure (${percent}%) on node ${documentId}. Starting targeted compression.`);
123
124 // Compress the pressured node's branch
125 const { compressBranch } = await import("./core.js");
126 await compressBranch(documentId, root.rootOwner, user.username);
127 } catch (err) {
128 log.error("TreeCompress", `Pressure-triggered compression failed: ${err.message}`);
129 }
130 }, "tree-compress");
131
132 // ── enrichContext: inject essence at compressed nodes ───────────────
133 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
134 const compress = meta.compress || meta["tree-compress"];
135 if (compress?.essence) {
136 context.compressedEssence = compress.essence;
137 }
138 }, "tree-compress");
139
140 // ── beforeNote: warn on trimmed nodes ──────────────────────────────
141 core.hooks.register("beforeNote", async (hookData) => {
142 const Node = core.models.Node;
143 const node = await Node.findById(hookData.nodeId).select("status").lean();
144 if (node?.status === "trimmed") {
145 // Don't block, just annotate. New notes on trimmed nodes will be compressed next run.
146 hookData.metadata = hookData.metadata || {};
147 hookData.metadata._writtenWhileTrimmed = true;
148 }
149 }, "tree-compress");
150
151 const { default: router } = await import("./routes.js");
152
153 return {
154 router,
155 tools,
156 exports: {
157 compressTree,
158 compressToBudget,
159 getCompressStatus,
160 },
161 };
162}
163
1export default {
2 name: "tree-compress",
3 version: "1.0.1",
4 builtFor: "treeos-intelligence",
5 description:
6 "A tree outgrows its usefulness. Too many nodes. Too much metadata. The AI context window " +
7 "cannot hold the summary. The circuit breaker is approaching threshold. The tree needs to " +
8 "shrink without dying. tree-compress walks from leaves to root, compressing content at each " +
9 "level, carrying the essential meaning upward, and trimming what has been absorbed. The tree " +
10 "gets smaller. The knowledge survives. Three triggers: manual via CLI or tool, automatic via " +
11 "onDocumentPressure when a tree approaches size limits, automatic via onTreeTripped as a " +
12 "revival strategy when the circuit breaker fires. The algorithm starts at leaf nodes, sends " +
13 "notes to the AI with a compression prompt, writes summary and fact dictionary to " +
14 "metadata.compress.essence, marks the leaf as trimmed. Moves up one level. Parent absorbs " +
15 "children essences plus its own content into one merged compression. Continues until " +
16 "compressionCeiling (default 2 levels from root). Size budget mode compresses until the tree " +
17 "fits under targetSizeBytes then stops. The tree crown stays readable. The branches are " +
18 "trimmed. Notes on trimmed nodes stay in the database. tree-compress does not delete data. " +
19 "It compresses the active context while preserving the raw data. Each compression pass is a " +
20 "prestige. The tree carries more meaning in less space.",
21
22 needs: {
23 services: ["llm", "hooks", "tree"],
24 models: ["Node"],
25 },
26
27 optional: {
28 extensions: ["long-memory", "codebook"],
29 },
30
31 provides: {
32 models: {},
33 routes: "./routes.js",
34 tools: true,
35 jobs: false,
36 orchestrator: false,
37 energyActions: {},
38 sessionTypes: {},
39 env: [],
40
41 cli: [
42 {
43 command: "compress [action] [args...]", scope: ["tree"],
44 description: "Tree compression. Actions: branch, status, undo, budget. No action compresses full tree.",
45 method: "POST",
46 endpoint: "/root/:rootId/compress",
47 subcommands: {
48 "branch": { method: "POST", endpoint: "/node/:nodeId/compress", description: "Compress from current node down" },
49 "status": { method: "GET", endpoint: "/root/:rootId/compress", description: "Compression state of tree" },
50 "undo": { method: "POST", endpoint: "/node/:nodeId/decompress", description: "Decompress current node" },
51 "budget": { method: "POST", endpoint: "/root/:rootId/compress/budget", args: ["size"], description: "Compress until under target size" },
52 },
53 },
54 ],
55
56 hooks: {
57 fires: [],
58 listens: ["onTreeTripped", "onDocumentPressure", "enrichContext", "beforeNote"],
59 },
60 },
61};
62
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import User from "../../seed/models/user.js";
5import { compressTree, compressBranch, compressToBudget, decompressNode, getCompressStatus } from "./core.js";
6
7const router = express.Router();
8
9// GET /root/:rootId/compress - compression status
10router.get("/root/:rootId/compress", authenticate, async (req, res) => {
11 try {
12 const status = await getCompressStatus(req.params.rootId);
13 sendOk(res, status);
14 } catch (err) {
15 sendError(res, 500, ERR.INTERNAL, err.message);
16 }
17});
18
19// POST /root/:rootId/compress - full tree compression
20router.post("/root/:rootId/compress", authenticate, async (req, res) => {
21 try {
22 const user = await User.findById(req.userId).select("username").lean();
23 const { targetSizeBytes } = req.body || {};
24
25 let result;
26 if (targetSizeBytes) {
27 result = await compressToBudget(req.params.rootId, req.userId, user?.username, targetSizeBytes);
28 } else {
29 result = await compressTree(req.params.rootId, req.userId, user?.username);
30 }
31
32 sendOk(res, result);
33 } catch (err) {
34 sendError(res, 500, ERR.INTERNAL, err.message);
35 }
36});
37
38// POST /node/:nodeId/compress - branch compression
39router.post("/node/:nodeId/compress", authenticate, async (req, res) => {
40 try {
41 const user = await User.findById(req.userId).select("username").lean();
42 const result = await compressBranch(req.params.nodeId, req.userId, user?.username);
43 sendOk(res, result);
44 } catch (err) {
45 sendError(res, 500, ERR.INTERNAL, err.message);
46 }
47});
48
49// POST /node/:nodeId/decompress - restore a trimmed node
50router.post("/node/:nodeId/decompress", authenticate, async (req, res) => {
51 try {
52 const result = await decompressNode(req.params.nodeId, req.userId);
53 sendOk(res, result);
54 } catch (err) {
55 sendError(res, 500, ERR.INTERNAL, err.message);
56 }
57});
58
59// POST /root/:rootId/compress/budget - budget-targeted compression
60router.post("/root/:rootId/compress/budget", authenticate, async (req, res) => {
61 try {
62 const size = parseInt(req.body.size, 10);
63 if (!size || size <= 0) return sendError(res, 400, ERR.INVALID_INPUT, "size must be a positive number in bytes");
64 const user = await User.findById(req.userId).select("username").lean();
65 const result = await compressToBudget(req.params.rootId, req.userId, user?.username, size);
66 sendOk(res, result);
67 } catch (err) {
68 sendError(res, 500, ERR.INTERNAL, err.message);
69 }
70});
71
72export default router;
73
1import { z } from "zod";
2import log from "../../seed/log.js";
3import { compressTree, compressBranch, compressToBudget, decompressNode, getCompressStatus } from "./core.js";
4
5export default [
6 {
7 name: "compress-tree",
8 description:
9 "Start a full compression run on the current tree. Walks from leaves to root, compressing " +
10 "notes into essence summaries and fact dictionaries. Nodes are marked trimmed. Crown stays readable.",
11 schema: {
12 rootId: z.string().describe("The tree root to compress."),
13 targetSizeBytes: z.number().optional().describe("If set, stop when tree is under this size (budget mode)."),
14 userId: z.string().describe("Injected by server. Ignore."),
15 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
16 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
17 },
18 annotations: {
19 readOnlyHint: false,
20 destructiveHint: false,
21 idempotentHint: false,
22 openWorldHint: true,
23 },
24 handler: async ({ rootId, targetSizeBytes, userId }) => {
25 try {
26 let username = null;
27 try {
28 const User = (await import("../../seed/models/user.js")).default;
29 const user = await User.findById(userId).select("username").lean();
30 username = user?.username;
31 } catch (err) {
32 log.debug("TreeCompress", "Username lookup failed:", err.message);
33 }
34
35 let result;
36 if (targetSizeBytes) {
37 result = await compressToBudget(rootId, userId, username, targetSizeBytes);
38 } else {
39 result = await compressTree(rootId, userId, username);
40 }
41
42 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
43 } catch (err) {
44 return { content: [{ type: "text", text: `Compression failed: ${err.message}` }] };
45 }
46 },
47 },
48 {
49 name: "compress-branch",
50 description: "Compress from this node downward only. Does not affect the rest of the tree.",
51 schema: {
52 nodeId: z.string().describe("The node to start compressing from."),
53 userId: z.string().describe("Injected by server. Ignore."),
54 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
55 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
56 },
57 annotations: {
58 readOnlyHint: false,
59 destructiveHint: false,
60 idempotentHint: false,
61 openWorldHint: true,
62 },
63 handler: async ({ nodeId, userId }) => {
64 try {
65 let username = null;
66 try {
67 const User = (await import("../../seed/models/user.js")).default;
68 const user = await User.findById(userId).select("username").lean();
69 username = user?.username;
70 } catch (err) {
71 log.debug("TreeCompress", "Username lookup failed:", err.message);
72 }
73
74 const result = await compressBranch(nodeId, userId, username);
75 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
76 } catch (err) {
77 return { content: [{ type: "text", text: `Branch compression failed: ${err.message}` }] };
78 }
79 },
80 },
81 {
82 name: "compress-status",
83 description: "Show compression state of a tree. Compressed, absorbed, uncompressed counts. History of runs.",
84 schema: {
85 rootId: z.string().describe("The tree root to check."),
86 userId: z.string().describe("Injected by server. Ignore."),
87 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
88 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
89 },
90 annotations: {
91 readOnlyHint: true,
92 destructiveHint: false,
93 idempotentHint: true,
94 openWorldHint: false,
95 },
96 handler: async ({ rootId }) => {
97 try {
98 const status = await getCompressStatus(rootId);
99 return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
100 } catch (err) {
101 return { content: [{ type: "text", text: `Status check failed: ${err.message}` }] };
102 }
103 },
104 },
105 {
106 name: "decompress-node",
107 description: "Restore a trimmed node to active. Its notes become visible again. Its essence stays as a bonus.",
108 schema: {
109 nodeId: z.string().describe("The node to decompress."),
110 userId: z.string().describe("Injected by server. Ignore."),
111 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
112 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
113 },
114 annotations: {
115 readOnlyHint: false,
116 destructiveHint: false,
117 idempotentHint: true,
118 openWorldHint: false,
119 },
120 handler: async ({ nodeId, userId }) => {
121 try {
122 const result = await decompressNode(nodeId, userId);
123 return { content: [{ type: "text", text: result.message }] };
124 } catch (err) {
125 return { content: [{ type: "text", text: `Decompress failed: ${err.message}` }] };
126 }
127 },
128 },
129];
130
Loading comments...