EXTENSION for treeos-intelligence
tree-compress
A tree outgrows its usefulness. Too many nodes. Too much metadata. The AI context window cannot hold the summary. The circuit breaker is approaching threshold. The tree needs to shrink without dying. tree-compress walks from leaves to root, compressing content at each level, carrying the essential meaning upward, and trimming what has been absorbed. The tree gets smaller. The knowledge survives. Three triggers: manual via CLI or tool, automatic via onDocumentPressure when a tree approaches size limits, automatic via onTreeTripped as a revival strategy when the circuit breaker fires. The algorithm starts at leaf nodes, sends notes to the AI with a compression prompt, writes summary and fact dictionary to metadata.compress.essence, marks the leaf as trimmed. Moves up one level. Parent absorbs children essences plus its own content into one merged compression. Continues until compressionCeiling (default 2 levels from root). Size budget mode compresses until the tree fits under targetSizeBytes then stops. The tree crown stays readable. The branches are trimmed. Notes on trimmed nodes stay in the database. tree-compress does not delete data. It compresses the active context while preserving the raw data. Each compression pass is a prestige. The tree carries more meaning in less space.
v1.0.1 by TreeOS Site 0 downloads 5 files 948 lines 33.3 KB published 38d ago
treeos ext install tree-compress
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: llm, hooks, tree
  • models: Node

Optional

  • extensions: long-memory, codebook
SHA256: 3e53ce0e25478f50df22f4e71705589b680033736821376c45d713f79e5fc021

Dependents

1 package depend on this

PackageTypeRelationship
treeos-intelligence v1.0.2bundleincludes

CLI Commands

CommandMethodDescription
compressPOSTTree compression. Actions: branch, status, undo, budget. No action compresses full tree.
compress branchPOSTCompress from current node down
compress statusGETCompression state of tree
compress undoPOSTDecompress current node
compress budgetPOSTCompress until under target size

Hooks

Listens To

  • onTreeTripped
  • onDocumentPressure
  • enrichContext
  • beforeNote

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star tree-compress

Comments

Loading comments...

Post comments from the CLI: treeos ext comment tree-compress "your comment"
Max 3 comments per extension. One star and one flag per user.