EXTENSION for treeos-maintenance
prune
The tree sheds dead weight. Not compression where meaning is preserved in smaller form. Actual removal of content that has no value anymore. Prune listens to evolution's dormancy metrics and long-memory's interaction history. It identifies nodes that are truly dead. Not sleeping. Dead. No visits in 90 days. No cascade signals received or originated. No codebook entries. No contradictions referencing them. No other nodes linking to them in metadata. Zero connections to anything alive. It marks them for pruning. Writes candidates to metadata.prune.candidates on the tree root. The intent extension picks these up and presents them to the user or, if autoPrune is enabled, trims them directly. Before trimming, it runs one final check: sends the node's content to the AI with 'is there anything here worth preserving?' If yes, the essential fact gets absorbed into the parent's metadata.prune.absorbed dictionary. Then the node is trimmed. Trimmed is the same status tree-compress uses. The node stops appearing in tree summaries, navigation, and enrichContext. The data stays in the database. The AI can't see it. The user can't navigate to it unless they explicitly look. The tree shed the leaf. The leaf is on the ground. It's not in the canopy anymore but it's still on the land. Someone can pick it up later. The difference from tree-compress: compress keeps everything in denser form. Prune actually lets go. The node is gone from the canopy. The branch is lighter. The tree shed a leaf because autumn came. Restoration uses the same path as any trimmed node. decompress-node or prune undo <nodeId> sets the status back to active. One status. One recovery mechanism. The leaf goes back on the branch. If an operator wants true permanent removal, that's a separate purge operation that runs on nodes already in trimmed status past a configurable grace period. Prune decides what to shed. Purge decides what to destroy. Two different decisions. Two different risk levels. Prune is reversible. Purge is not. Seasonal cycles. pruneInterval configurable. Some operators run it monthly. Some quarterly. Some manually after a big project ends.
v1.0.1 by TreeOS Site 0 downloads 5 files 750 lines 24.6 KB published 38d ago
treeos ext install prune
View changelog

Manifest

Provides

  • jobs
  • 1 CLI commands
  • 2 energy actions

Requires

  • services: llm, hooks, contributions
  • models: Node, Contribution, Note

Optional

  • services: energy
  • extensions: evolution, long-memory, codebook, contradiction, pulse
SHA256: 2774414644ad59b70f70459aec9e870c4ede0401fdbb9a1c0bb8a3f6ce006268

Dependents

1 package depend on this

PackageTypeRelationship
treeos-maintenance v1.0.1bundleincludes

CLI Commands

CommandMethodDescription
pruneGETPruning candidates for current tree. Actions: confirm, undo, history.
prune confirmPOSTExecute pruning
prune undoPOSTRestore trimmed node
prune historyGETWhat was shed and absorbed

Hooks

Listens To

  • afterBoot

Source Code

1// Prune Core
2//
3// Identifies dead nodes, absorbs their essence, trims them.
4//
5// A node is a prune candidate when ALL of these are true:
6// - No visits in dormancyThresholdDays (default 90)
7// - No cascade signals received or originated
8// - No codebook entries referencing it
9// - No contradictions referencing it
10// - No other nodes linking to it in metadata
11// - Not a system node
12// - Not the tree root
13// - Status is "active" (already trimmed/completed nodes are not candidates)
14//
15// The scan writes candidates to metadata.prune.candidates on the tree root.
16// Confirmation trims each one after an optional AI absorption pass.
17
18import log from "../../seed/log.js";
19import { getExtension } from "../loader.js";
20
21let Node = null;
22let Contribution = null;
23let Note = null;
24let logContribution = null;
25let runChat = null;
26let useEnergy = async () => ({ energyUsed: 0 });
27let _metadata = null;
28
29export function setServices({ models, contributions, llm, energy, metadata }) {
30  Node = models.Node;
31  Contribution = models.Contribution;
32  Note = models.Note;
33  logContribution = contributions.logContribution;
34  runChat = llm.runChat;
35  if (energy?.useEnergy) useEnergy = energy.useEnergy;
36  if (metadata) _metadata = metadata;
37}
38
39async function getDormancyDays() {
40  try {
41    const { getLandConfigValue } = await import("../../seed/landConfig.js");
42    return Number(getLandConfigValue("pruneDormancyDays")) || 90;
43  } catch {
44    return 90;
45  }
46}
47
48// ─────────────────────────────────────────────────────────────────────────
49// SCAN: identify dead nodes
50// ─────────────────────────────────────────────────────────────────────────
51
52/**
53 * Scan a tree for prune candidates.
54 * Returns the list and writes it to metadata.prune.candidates on the root.
55 */
56export async function scanForCandidates(rootId, userId) {
57  await useEnergy({ userId, action: "pruneScan" });
58
59  const dormancyMs = (await getDormancyDays()) * 24 * 60 * 60 * 1000;
60  const cutoff = new Date(Date.now() - dormancyMs);
61
62  // Get all nodes in this tree (walk from root, not rootOwner which is a userId)
63  const { getDescendantIds } = await import("../../seed/tree/treeFetch.js");
64  const allIds = await getDescendantIds(rootId, { maxResults: 10000 });
65  const nodes = await Node.find({ _id: { $in: allIds }, status: "active" })
66    .select("_id name parent children systemRole metadata dateCreated")
67    .lean();
68
69  if (nodes.length === 0) return [];
70
71  // Get recent contributions for all nodes in this tree
72  const nodeIds = nodes.map(n => n._id.toString());
73  const recentContribs = await Contribution.find({
74    nodeId: { $in: nodeIds },
75    date: { $gte: cutoff },
76  }).select("nodeId").lean();
77
78  const activeNodeIds = new Set(recentContribs.map(c => c.nodeId.toString()));
79
80  // Get recent notes
81  const recentNotes = await Note.find({
82    nodeId: { $in: nodeIds },
83    dateCreated: { $gte: cutoff },
84  }).select("nodeId").lean();
85
86  for (const n of recentNotes) activeNodeIds.add(n.nodeId.toString());
87
88  // Check optional signal sources
89  const cascadeActivity = await getCascadeActivity(rootId, nodeIds, cutoff);
90  const codebookRefs = await getCodebookReferences(rootId, nodeIds);
91  const contradictionRefs = await getContradictionReferences(rootId, nodeIds);
92
93  // Build candidates list
94  const candidates = [];
95
96  for (const node of nodes) {
97    const id = node._id.toString();
98
99    // Skip root, system nodes
100    if (id === rootId) continue;
101    if (node.systemRole) continue;
102
103    // Skip nodes with recent activity
104    if (activeNodeIds.has(id)) continue;
105
106    // Skip nodes with cascade activity
107    if (cascadeActivity.has(id)) continue;
108
109    // Skip nodes referenced by codebook
110    if (codebookRefs.has(id)) continue;
111
112    // Skip nodes referenced by contradictions
113    if (contradictionRefs.has(id)) continue;
114
115    // Skip nodes with children that are still active
116    const hasActiveChild = (node.children || []).some(childId =>
117      activeNodeIds.has(childId.toString())
118    );
119    if (hasActiveChild) continue;
120
121    candidates.push({
122      nodeId: id,
123      name: node.name,
124      parentId: node.parent?.toString() || null,
125      createdAt: node.dateCreated,
126      childCount: (node.children || []).length,
127    });
128  }
129
130  // Write candidates to root metadata
131  const root = await Node.findById(rootId);
132  if (root) {
133    const pruneMeta = _metadata.getExtMeta(root, "prune");
134    pruneMeta.candidates = candidates;
135    pruneMeta.lastScanAt = new Date().toISOString();
136    pruneMeta.dormancyDays = (await getDormancyDays());
137    await _metadata.setExtMeta(root, "prune", pruneMeta);
138  }
139
140  log.verbose("Prune", `Scanned tree ${rootId}: ${candidates.length} candidate(s) from ${nodes.length} nodes`);
141  return candidates;
142}
143
144// ─────────────────────────────────────────────────────────────────────────
145// CONFIRM: absorb and trim
146// ─────────────────────────────────────────────────────────────────────────
147
148/**
149 * Execute pruning on all candidates. For each:
150 * 1. Ask the AI if anything is worth preserving
151 * 2. If yes, absorb the fact into parent's metadata
152 * 3. Set status to "trimmed"
153 * 4. Log as contribution
154 */
155export async function confirmPrune(rootId, userId, username) {
156  const root = await Node.findById(rootId);
157  if (!root) throw new Error("Tree not found");
158
159  const pruneMeta = _metadata.getExtMeta(root, "prune");
160  const candidates = pruneMeta.candidates || [];
161  if (candidates.length === 0) return { pruned: 0, absorbed: 0 };
162
163  let pruned = 0;
164  let absorbed = 0;
165
166  for (const candidate of candidates) {
167    try {
168      const result = await pruneNode(candidate, rootId, userId, username);
169      pruned++;
170      if (result.absorbed) absorbed++;
171    } catch (err) {
172      log.warn("Prune", `Failed to prune ${candidate.name} (${candidate.nodeId}): ${err.message}`);
173    }
174  }
175
176  // Clear candidates
177  pruneMeta.candidates = [];
178  pruneMeta.lastPruneAt = new Date().toISOString();
179  if (!pruneMeta.history) pruneMeta.history = [];
180  pruneMeta.history.push({
181    date: new Date().toISOString(),
182    pruned,
183    absorbed,
184    userId,
185  });
186  // Cap history
187  if (pruneMeta.history.length > 50) {
188    pruneMeta.history = pruneMeta.history.slice(-50);
189  }
190  await _metadata.setExtMeta(root, "prune", pruneMeta);
191
192  log.info("Prune", `Pruned ${pruned} node(s) from tree ${rootId} (${absorbed} absorbed)`);
193  return { pruned, absorbed };
194}
195
196async function pruneNode(candidate, rootId, userId, username) {
197  const node = await Node.findById(candidate.nodeId)
198    .select("_id name parent status metadata")
199    .lean();
200
201  if (!node || node.status === "trimmed") return { absorbed: false };
202
203  // Energy for absorption check
204  try {
205    await useEnergy({ userId, action: "pruneAbsorb" });
206  } catch {
207    // No energy, skip absorption, just trim
208    await trimNode(candidate.nodeId, userId);
209    return { absorbed: false };
210  }
211
212  // Get the node's content for the AI to evaluate
213  const notes = await Note.find({ nodeId: candidate.nodeId })
214    .select("content")
215    .sort({ dateCreated: -1 })
216    .limit(5)
217    .lean();
218
219  const contentSummary = notes.map(n => n.content?.slice(0, 500)).filter(Boolean).join("\n---\n");
220  let didAbsorb = false;
221
222  if (contentSummary) {
223    // Ask the AI: is anything here worth preserving?
224    try {
225      const result = await runChat({
226        userId,
227        username,
228        message:
229          `This node "${node.name}" is being pruned (no activity in ${(await getDormancyDays())} days). ` +
230          `Here is its content:\n\n${contentSummary.slice(0, 2000)}\n\n` +
231          `Is there one essential fact or insight worth preserving? ` +
232          `If yes, respond with just that fact in one sentence. If no, respond with "nothing".`,
233        mode: "tree:respond",
234        rootId,
235        slot: "prune",
236      });
237
238      const answer = result?.answer?.trim();
239      if (answer && answer.toLowerCase() !== "nothing" && answer.length < 500) {
240        // Absorb into parent
241        const parent = await Node.findById(node.parent);
242        if (parent) {
243          const parentPrune = _metadata.getExtMeta(parent, "prune");
244          if (!parentPrune.absorbed) parentPrune.absorbed = {};
245          parentPrune.absorbed[node.name || candidate.nodeId] = {
246            fact: answer,
247            absorbedAt: new Date().toISOString(),
248            originalNodeId: candidate.nodeId,
249          };
250          await _metadata.setExtMeta(parent, "prune", parentPrune);
251          didAbsorb = true;
252        }
253      }
254    } catch (err) {
255      log.debug("Prune", `Absorption check failed for ${node.name}: ${err.message}`);
256    }
257  }
258
259  // Trim the node
260  await trimNode(candidate.nodeId, userId);
261
262  // Log contribution
263  await logContribution({
264    userId,
265    nodeId: candidate.nodeId,
266    wasAi: true,
267    action: "prune:trimmed",
268    extensionData: {
269      prune: {
270        nodeName: node.name,
271        absorbed: didAbsorb,
272        dormancyDays: (await getDormancyDays()),
273      },
274    },
275  });
276
277  return { absorbed: didAbsorb };
278}
279
280async function trimNode(nodeId, userId) {
281  await Node.updateOne({ _id: nodeId }, { $set: { status: "trimmed" } });
282}
283
284// ─────────────────────────────────────────────────────────────────────────
285// UNDO: restore a pruned node
286// ─────────────────────────────────────────────────────────────────────────
287
288export async function undoPrune(nodeId, userId) {
289  const node = await Node.findById(nodeId).select("status name").lean();
290  if (!node) throw new Error("Node not found");
291  if (node.status !== "trimmed") throw new Error("Node is not trimmed");
292
293  await Node.updateOne({ _id: nodeId }, { $set: { status: "active" } });
294
295  await logContribution({
296    userId,
297    nodeId,
298    wasAi: false,
299    action: "prune:restored",
300  });
301
302  log.verbose("Prune", `Restored pruned node ${node.name} (${nodeId})`);
303  return { restored: true, name: node.name };
304}
305
306// ─────────────────────────────────────────────────────────────────────────
307// PURGE: permanent removal of long-trimmed nodes
308// ─────────────────────────────────────────────────────────────────────────
309
310/**
311 * Purge nodes that have been trimmed past the grace period.
312 * This is permanent. The data is deleted from the database.
313 * Only runs if purgeGraceDays is set in land config (default: off).
314 */
315export async function purge(rootId, userId) {
316  let graceDays;
317  try {
318    const { getLandConfigValue } = await import("../../seed/landConfig.js");
319    graceDays = Number(getLandConfigValue("purgeGraceDays"));
320  } catch (err) {
321    log.debug("Prune", "Failed to read purgeGraceDays config:", err.message);
322  }
323
324  if (!graceDays || graceDays <= 0) {
325    throw new Error("Purge is disabled. Set purgeGraceDays in land config to enable.");
326  }
327
328  const graceMs = graceDays * 24 * 60 * 60 * 1000;
329  const cutoff = new Date(Date.now() - graceMs);
330
331  // Find trimmed nodes in this tree older than the grace period
332  const trimmed = await Node.find({
333    rootOwner: rootId,
334    status: "trimmed",
335    dateCreated: { $lt: cutoff },
336  }).select("_id name").lean();
337
338  if (trimmed.length === 0) return { purged: 0 };
339
340  // Delete notes, contributions, then nodes
341  const ids = trimmed.map(n => n._id);
342  await Note.deleteMany({ nodeId: { $in: ids } });
343  await Contribution.deleteMany({ nodeId: { $in: ids } });
344  await Node.deleteMany({ _id: { $in: ids } });
345
346  // Remove from parent children arrays
347  for (const t of trimmed) {
348    await Node.updateMany(
349      { children: t._id },
350      { $pull: { children: t._id } },
351    );
352  }
353
354  log.info("Prune", `Purged ${trimmed.length} node(s) from tree ${rootId} (past ${graceDays}-day grace period)`);
355
356  return { purged: trimmed.length, names: trimmed.map(n => n.name) };
357}
358
359// ─────────────────────────────────────────────────────────────────────────
360// SIGNAL SOURCE HELPERS (optional extensions)
361// ─────────────────────────────────────────────────────────────────────────
362
363async function getCascadeActivity(rootId, nodeIds, cutoff) {
364  const active = new Set();
365  try {
366    // Check .flow for recent cascade results involving these nodes
367    const flowNode = await Node.findOne({ systemRole: "flow" }).select("_id").lean();
368    if (!flowNode) return active;
369
370    const cutoffDate = cutoff.toISOString().slice(0, 10);
371    const partitions = await Node.find({
372      parent: flowNode._id,
373      name: { $gte: cutoffDate },
374    }).select("metadata").lean();
375
376    for (const p of partitions) {
377      const results = p.metadata instanceof Map
378        ? p.metadata.get("results") || {}
379        : p.metadata?.results || {};
380      for (const entries of Object.values(results)) {
381        const arr = Array.isArray(entries) ? entries : [entries];
382        for (const r of arr) {
383          if (r.source && nodeIds.includes(r.source)) active.add(r.source);
384        }
385      }
386    }
387  } catch (err) {
388    log.debug("Prune", "Cascade activity check failed:", err.message);
389  }
390  return active;
391}
392
393async function getCodebookReferences(rootId, nodeIds) {
394  const refs = new Set();
395  const codebookExt = getExtension("codebook");
396  if (!codebookExt?.exports?.getReferencedNodes) return refs;
397  try {
398    const referenced = await codebookExt.exports.getReferencedNodes(rootId);
399    for (const id of referenced) {
400      if (nodeIds.includes(id)) refs.add(id);
401    }
402  } catch (err) {
403    log.debug("Prune", "Codebook reference check failed:", err.message);
404  }
405  return refs;
406}
407
408async function getContradictionReferences(rootId, nodeIds) {
409  const refs = new Set();
410  const contradictionExt = getExtension("contradiction");
411  if (!contradictionExt?.exports?.getReferencedNodes) return refs;
412  try {
413    const referenced = await contradictionExt.exports.getReferencedNodes(rootId);
414    for (const id of referenced) {
415      if (nodeIds.includes(id)) refs.add(id);
416    }
417  } catch (err) {
418    log.debug("Prune", "Contradiction reference check failed:", err.message);
419  }
420  return refs;
421}
422
1import log from "../../seed/log.js";
2import { setServices } from "./core.js";
3import { setModels as setJobModels, setMetadata as setJobMetadata, startPruneJob, stopPruneJob } from "./pruneJob.js";
4
5export async function init(core) {
6  core.llm.registerRootLlmSlot("prune");
7  const BG = core.llm.LLM_PRIORITY.BACKGROUND;
8  setServices({
9    models: core.models,
10    contributions: core.contributions,
11    llm: { ...core.llm, 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    energy: core.energy || null,
16    metadata: core.metadata,
17  });
18
19  setJobModels(core.models);
20  setJobMetadata(core.metadata);
21
22  const { default: router, setModels, setMetadata: setRouteMetadata } = await import("./routes.js");
23  setModels(core.models);
24  setRouteMetadata(core.metadata);
25
26  log.info("Prune", "Tree pruning engine loaded");
27
28  return {
29    router,
30    jobs: [
31      {
32        name: "prune-cycle",
33        start: () => startPruneJob(),
34        stop: () => stopPruneJob(),
35      },
36    ],
37  };
38}
39
1export default {
2  name: "prune",
3  version: "1.0.1",
4  builtFor: "treeos-maintenance",
5  description:
6    "The tree sheds dead weight. Not compression where meaning is preserved in " +
7    "smaller form. Actual removal of content that has no value anymore. " +
8    "\n\n" +
9    "Prune listens to evolution's dormancy metrics and long-memory's interaction " +
10    "history. It identifies nodes that are truly dead. Not sleeping. Dead. No " +
11    "visits in 90 days. No cascade signals received or originated. No codebook " +
12    "entries. No contradictions referencing them. No other nodes linking to them " +
13    "in metadata. Zero connections to anything alive. " +
14    "\n\n" +
15    "It marks them for pruning. Writes candidates to metadata.prune.candidates on " +
16    "the tree root. The intent extension picks these up and presents them to the " +
17    "user or, if autoPrune is enabled, trims them directly. Before trimming, it " +
18    "runs one final check: sends the node's content to the AI with 'is there " +
19    "anything here worth preserving?' If yes, the essential fact gets absorbed " +
20    "into the parent's metadata.prune.absorbed dictionary. Then the node is trimmed. " +
21    "\n\n" +
22    "Trimmed is the same status tree-compress uses. The node stops appearing in " +
23    "tree summaries, navigation, and enrichContext. The data stays in the database. " +
24    "The AI can't see it. The user can't navigate to it unless they explicitly look. " +
25    "The tree shed the leaf. The leaf is on the ground. It's not in the canopy " +
26    "anymore but it's still on the land. Someone can pick it up later. " +
27    "\n\n" +
28    "The difference from tree-compress: compress keeps everything in denser form. " +
29    "Prune actually lets go. The node is gone from the canopy. The branch is lighter. " +
30    "The tree shed a leaf because autumn came. " +
31    "\n\n" +
32    "Restoration uses the same path as any trimmed node. decompress-node or " +
33    "prune undo <nodeId> sets the status back to active. One status. One recovery " +
34    "mechanism. The leaf goes back on the branch. " +
35    "\n\n" +
36    "If an operator wants true permanent removal, that's a separate purge operation " +
37    "that runs on nodes already in trimmed status past a configurable grace period. " +
38    "Prune decides what to shed. Purge decides what to destroy. Two different " +
39    "decisions. Two different risk levels. Prune is reversible. Purge is not. " +
40    "\n\n" +
41    "Seasonal cycles. pruneInterval configurable. Some operators run it monthly. " +
42    "Some quarterly. Some manually after a big project ends.",
43
44  needs: {
45    services: ["llm", "hooks", "contributions"],
46    models: ["Node", "Contribution", "Note"],
47  },
48
49  optional: {
50    services: ["energy"],
51    extensions: [
52      "evolution",
53      "long-memory",
54      "codebook",
55      "contradiction",
56      "pulse",
57    ],
58  },
59
60  provides: {
61    models: {},
62    routes: false,
63    tools: false,
64    jobs: true,
65    orchestrator: false,
66    energyActions: {
67      pruneScan: { cost: 2 },
68      pruneAbsorb: { cost: 1 },
69    },
70    sessionTypes: {},
71
72    hooks: {
73      fires: [],
74      listens: ["afterBoot"],
75    },
76
77    cli: [
78      {
79        command: "prune [action] [args...]", scope: ["tree"],
80        description: "Pruning candidates for current tree. Actions: confirm, undo, history.",
81        method: "GET",
82        endpoint: "/root/:rootId/prune",
83        subcommands: {
84          "confirm": { method: "POST", endpoint: "/root/:rootId/prune/confirm", description: "Execute pruning" },
85          "undo": { method: "POST", endpoint: "/root/:rootId/prune/undo", args: ["nodeId"], description: "Restore trimmed node" },
86          "history": { method: "GET", endpoint: "/root/:rootId/prune/history", description: "What was shed and absorbed" },
87        },
88      },
89    ],
90  },
91};
92
1// Prune Job
2//
3// Runs on a configurable interval. For each tree with autoPrune enabled,
4// scans for dead nodes and trims them. The seasonal cycle.
5//
6// Trees opt in via metadata.prune.autoPrune = true on the root.
7// Default off. The tree doesn't shed leaves unless the operator says so.
8
9import log from "../../seed/log.js";
10import { scanForCandidates, confirmPrune } from "./core.js";
11
12let Node = null;
13let User = null;
14let _metadata = null;
15export function setModels(models) { Node = models.Node; User = models.User; }
16export function setMetadata(metadata) { _metadata = metadata; }
17
18let _timer = null;
19
20async function getIntervalMs() {
21  try {
22    const { getLandConfigValue } = await import("../../seed/landConfig.js");
23    return Number(getLandConfigValue("pruneIntervalMs")) || 7 * 24 * 60 * 60 * 1000; // weekly default
24  } catch {
25    return 7 * 24 * 60 * 60 * 1000;
26  }
27}
28
29export async function startPruneJob() {
30  if (_timer) return;
31  const interval = await getIntervalMs();
32  _timer = setInterval(runPruneCycle, interval);
33  if (_timer.unref) _timer.unref();
34  log.info("Prune", `Prune job started (checking every ${Math.round(interval / (24 * 60 * 60 * 1000))}d)`);
35}
36
37export function stopPruneJob() {
38  if (_timer) {
39    clearInterval(_timer);
40    _timer = null;
41  }
42}
43
44async function runPruneCycle() {
45  try {
46    // Find trees with autoPrune enabled
47    const roots = await Node.find({
48      rootOwner: { $nin: [null, "SYSTEM"] },
49      "metadata.prune.autoPrune": true,
50    }).select("_id name rootOwner metadata").lean();
51
52    if (roots.length === 0) return;
53
54    log.verbose("Prune", `Prune cycle: ${roots.length} tree(s) opted in for auto-prune`);
55
56    for (const root of roots) {
57      try {
58        const pruneMeta = _metadata.getExtMeta(root, "prune");
59        if (pruneMeta.paused) continue;
60
61        const userId = root.rootOwner?.toString();
62        if (!userId) continue;
63
64        const user = await User.findById(userId).select("username").lean();
65        if (!user) continue;
66
67        // Scan
68        const candidates = await scanForCandidates(root._id.toString(), userId);
69        if (candidates.length === 0) continue;
70
71        log.verbose("Prune", `Auto-pruning ${candidates.length} node(s) from ${root.name}`);
72
73        // Confirm (absorb + trim)
74        await confirmPrune(root._id.toString(), userId, user.username);
75      } catch (err) {
76        log.warn("Prune", `Auto-prune failed for tree ${root.name}: ${err.message}`);
77      }
78    }
79  } catch (err) {
80    log.error("Prune", `Prune cycle error: ${err.message}`);
81  }
82}
83
1import log from "../../seed/log.js";
2import express from "express";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5import { scanForCandidates, confirmPrune, undoPrune, purge } from "./core.js";
6
7let Node = null;
8let _metadata = null;
9export function setModels(models) { Node = models.Node; }
10export function setMetadata(metadata) { _metadata = metadata; }
11
12function validateRootId(req, res) {
13  const rootId = req.params.rootId;
14  if (!rootId || rootId === "undefined" || rootId === "null") {
15    sendError(res, 400, ERR.INVALID_INPUT, "rootId is required");
16    return null;
17  }
18  return rootId;
19}
20
21const router = express.Router();
22
23// GET /root/:rootId/prune - Show candidates
24router.get("/root/:rootId/prune", authenticate, async (req, res) => {
25  try {
26    const rootId = validateRootId(req, res);
27    if (!rootId) return;
28    const root = await Node.findById(rootId).select("metadata name").lean();
29    if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
30
31    const pruneMeta = _metadata.getExtMeta(root, "prune");
32
33    sendOk(res, {
34      rootId,
35      candidates: pruneMeta.candidates || [],
36      lastScanAt: pruneMeta.lastScanAt || null,
37      lastPruneAt: pruneMeta.lastPruneAt || null,
38      dormancyDays: pruneMeta.dormancyDays || 90,
39    });
40  } catch (err) {
41    sendError(res, 500, ERR.INTERNAL, err.message);
42  }
43});
44
45// POST /root/:rootId/prune/scan - Run a fresh scan
46router.post("/root/:rootId/prune/scan", authenticate, async (req, res) => {
47  try {
48    const rootId = validateRootId(req, res);
49    if (!rootId) return;
50    const candidates = await scanForCandidates(rootId, req.userId);
51    sendOk(res, { candidates, count: candidates.length });
52  } catch (err) {
53    sendError(res, 500, ERR.INTERNAL, err.message);
54  }
55});
56
57// POST /root/:rootId/prune/confirm - Execute pruning
58router.post("/root/:rootId/prune/confirm", authenticate, async (req, res) => {
59  try {
60    const rootId = validateRootId(req, res);
61    if (!rootId) return;
62    const result = await confirmPrune(rootId, req.userId, req.username);
63    sendOk(res, result);
64  } catch (err) {
65    sendError(res, 500, ERR.INTERNAL, err.message);
66  }
67});
68
69// POST /root/:rootId/prune/undo - Restore a pruned node
70router.post("/root/:rootId/prune/undo", authenticate, async (req, res) => {
71  try {
72    const { nodeId } = req.body;
73    if (!nodeId) return sendError(res, 400, ERR.INVALID_INPUT, "nodeId is required");
74    const result = await undoPrune(nodeId, req.userId);
75    sendOk(res, result);
76  } catch (err) {
77    sendError(res, 500, ERR.INTERNAL, err.message);
78  }
79});
80
81// GET /root/:rootId/prune/history - What was shed and absorbed
82router.get("/root/:rootId/prune/history", authenticate, async (req, res) => {
83  try {
84    const rootId = validateRootId(req, res);
85    if (!rootId) return;
86    const root = await Node.findById(rootId).select("metadata").lean();
87    if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
88
89    const pruneMeta = _metadata.getExtMeta(root, "prune");
90
91    sendOk(res, {
92      history: pruneMeta.history || [],
93      totalPruned: (pruneMeta.history || []).reduce((sum, h) => sum + (h.pruned || 0), 0),
94      totalAbsorbed: (pruneMeta.history || []).reduce((sum, h) => sum + (h.absorbed || 0), 0),
95    });
96  } catch (err) {
97    sendError(res, 500, ERR.INTERNAL, err.message);
98  }
99});
100
101// POST /root/:rootId/prune/purge - Permanent removal (irreversible)
102router.post("/root/:rootId/prune/purge", authenticate, async (req, res) => {
103  try {
104    const rootId = validateRootId(req, res);
105    if (!rootId) return;
106    const result = await purge(rootId, req.userId);
107    sendOk(res, result);
108  } catch (err) {
109    sendError(res, 500, ERR.INTERNAL, err.message);
110  }
111});
112
113export default router;
114

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 prune

Comments

Loading comments...

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