1// Reroot Core
2//
3// Three phases:
4// 1. Analyze: build a semantic snapshot of every node, ask the AI to find misplacements
5// 2. Preview: the proposal lives on metadata.reroot.proposal until accepted or rejected
6// 3. Apply: execute each move via updateParentRelationship (kernel function)
7
8import log from "../../seed/log.js";
9import { updateParentRelationship } from "../../seed/tree/treeManagement.js";
10import { invalidateAll } from "../../seed/tree/ancestorCache.js";
11import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
12import { getExtension } from "../loader.js";
13
14let Node = null;
15let Note = null;
16let logContribution = null;
17let runChat = null;
18let useEnergy = async () => ({ energyUsed: 0 });
19let _metadata = null;
20
21export function setServices({ models, contributions, llm, energy, metadata }) {
22 Node = models.Node;
23 Note = models.Note;
24 logContribution = contributions.logContribution;
25 runChat = llm.runChat;
26 if (energy?.useEnergy) useEnergy = energy.useEnergy;
27 if (metadata) _metadata = metadata;
28}
29
30// ─────────────────────────────────────────────────────────────────────────
31// ANALYZE
32// ─────────────────────────────────────────────────────────────────────────
33
34const ANALYSIS_PROMPT = `You are analyzing a tree's structure to find nodes that are in the wrong place. The tree grew organically. Some nodes ended up far from where they semantically belong.
35
36Below is a snapshot of every node: its ID, name, current parent, depth, and a content summary. Some nodes also have codebook relationships, cascade connections, or evolution data.
37
38Rules:
39- Only propose moves that are clearly justified by semantic similarity
40- Never move nodes with rootOwner set (they own their subtree)
41- Never move system nodes (names starting with .)
42- Preserve cascade configurations (don't break cascade.enabled chains)
43- Each move must name: nodeId, nodeName, currentParentId, proposedParentId, proposedParentName, reason
44- Maximum 10 moves per proposal
45- If the tree is well-organized, return an empty array
46
47Return a JSON array of proposed moves:
48[
49 {
50 "nodeId": "...",
51 "nodeName": "...",
52 "currentParentId": "...",
53 "proposedParentId": "...",
54 "proposedParentName": "...",
55 "reason": "why this node belongs under the proposed parent"
56 }
57]
58
59If no reorganization is needed, return: []
60
61Tree snapshot:
62{snapshot}`;
63
64/**
65 * Analyze a tree and generate a reorganization proposal.
66 */
67export async function analyze(rootId, userId, username) {
68 await useEnergy({ userId, action: "rerootAnalyze" });
69
70 // Build the tree snapshot
71 const snapshot = await buildTreeSnapshot(rootId);
72 if (!snapshot || snapshot.nodes.length === 0) {
73 throw new Error("Tree has no nodes to analyze");
74 }
75
76 // Format for the prompt
77 const snapshotText = formatSnapshot(snapshot);
78
79 // Ask the AI
80 const prompt = ANALYSIS_PROMPT.replace("{snapshot}", snapshotText);
81
82 const result = await runChat({
83 userId,
84 username,
85 message: prompt,
86 mode: "tree:respond",
87 rootId,
88 slot: "reroot",
89 });
90
91 if (!result?.answer) {
92 throw new Error("Analysis produced no result");
93 }
94
95 // Parse the proposed moves
96 const parsed = parseJsonSafe(result.answer);
97 if (!Array.isArray(parsed)) {
98 throw new Error("Analysis did not return a valid move list");
99 }
100
101 const moves = parsed
102 .filter(m => m && m.nodeId && m.proposedParentId && m.reason)
103 .slice(0, 10);
104
105 // Validate each move
106 const validMoves = [];
107 for (const move of moves) {
108 const node = await Node.findById(move.nodeId).select("_id name rootOwner systemRole parent").lean();
109 if (!node) continue;
110 if (node.rootOwner && node.rootOwner !== "SYSTEM") continue; // can't move roots
111 if (node.systemRole) continue; // can't move system nodes
112 if (node.parent?.toString() === move.proposedParentId) continue; // already there
113
114 const newParent = await Node.findById(move.proposedParentId).select("_id name").lean();
115 if (!newParent) continue;
116
117 validMoves.push({
118 nodeId: move.nodeId,
119 nodeName: node.name,
120 currentParentId: node.parent?.toString(),
121 proposedParentId: move.proposedParentId,
122 proposedParentName: newParent.name,
123 reason: move.reason,
124 });
125 }
126
127 // Write proposal to root metadata
128 const root = await Node.findById(rootId);
129 if (!root) throw new Error("Tree root not found");
130
131 const rerootMeta = _metadata.getExtMeta(root, "reroot");
132 rerootMeta.proposal = {
133 moves: validMoves,
134 generatedAt: new Date().toISOString(),
135 generatedBy: userId,
136 status: "pending",
137 };
138 await _metadata.setExtMeta(root, "reroot", rerootMeta);
139
140 log.verbose("Reroot", `Analysis complete for tree ${rootId}: ${validMoves.length} move(s) proposed`);
141
142 return {
143 moves: validMoves,
144 count: validMoves.length,
145 };
146}
147
148// ─────────────────────────────────────────────────────────────────────────
149// PREVIEW
150// ─────────────────────────────────────────────────────────────────────────
151
152export async function getProposal(rootId) {
153 const root = await Node.findById(rootId).select("metadata").lean();
154 if (!root) throw new Error("Tree not found");
155
156 const rerootMeta = _metadata.getExtMeta(root, "reroot");
157 return rerootMeta.proposal || null;
158}
159
160// ─────────────────────────────────────────────────────────────────────────
161// APPLY
162// ─────────────────────────────────────────────────────────────────────────
163
164export async function applyProposal(rootId, userId) {
165 const root = await Node.findById(rootId);
166 if (!root) throw new Error("Tree not found");
167
168 const rerootMeta = _metadata.getExtMeta(root, "reroot");
169 const proposal = rerootMeta.proposal;
170 if (!proposal || proposal.status !== "pending") {
171 throw new Error("No pending proposal to apply");
172 }
173
174 const moves = proposal.moves || [];
175 let applied = 0;
176 let failed = 0;
177 const results = [];
178
179 // Skip cache invalidation on each move. Intermediate cache states between
180 // moves don't matter because no other operation reads during the batch.
181 // One invalidateAll() after the batch is sufficient and avoids ten full clears.
182 for (const move of moves) {
183 try {
184 await updateParentRelationship(
185 move.nodeId,
186 move.proposedParentId,
187 userId,
188 true, // wasAi
189 null, null,
190 { skipCacheInvalidation: true },
191 );
192
193 results.push({ nodeId: move.nodeId, nodeName: move.nodeName, status: "moved", to: move.proposedParentName });
194 applied++;
195 } catch (err) {
196 results.push({ nodeId: move.nodeId, nodeName: move.nodeName, status: "failed", error: err.message });
197 failed++;
198 log.debug("Reroot", `Move failed for ${move.nodeName}: ${err.message}`);
199 }
200 }
201
202 // Single cache clear after all moves complete
203 if (applied > 0) {
204 invalidateAll();
205 }
206
207 // Update proposal status
208 proposal.status = "applied";
209 proposal.appliedAt = new Date().toISOString();
210 proposal.appliedBy = userId;
211 proposal.results = results;
212
213 // Add to history
214 if (!rerootMeta.history) rerootMeta.history = [];
215 rerootMeta.history.push({
216 date: proposal.appliedAt,
217 moves: applied,
218 failed,
219 });
220 if (rerootMeta.history.length > 20) {
221 rerootMeta.history = rerootMeta.history.slice(-20);
222 }
223
224 await _metadata.setExtMeta(root, "reroot", rerootMeta);
225
226 // Log contribution
227 await logContribution({
228 userId,
229 nodeId: rootId,
230 wasAi: true,
231 action: "reroot:applied",
232 extensionData: {
233 reroot: { applied, failed, moves: results },
234 },
235 });
236
237 log.info("Reroot", `Applied reorganization to tree ${rootId}: ${applied} moved, ${failed} failed`);
238
239 return { applied, failed, results };
240}
241
242// ─────────────────────────────────────────────────────────────────────────
243// REJECT
244// ─────────────────────────────────────────────────────────────────────────
245
246export async function rejectProposal(rootId, userId) {
247 const root = await Node.findById(rootId);
248 if (!root) throw new Error("Tree not found");
249
250 const rerootMeta = _metadata.getExtMeta(root, "reroot");
251 if (!rerootMeta.proposal || rerootMeta.proposal.status !== "pending") {
252 throw new Error("No pending proposal to reject");
253 }
254
255 rerootMeta.proposal.status = "rejected";
256 rerootMeta.proposal.rejectedAt = new Date().toISOString();
257 rerootMeta.proposal.rejectedBy = userId;
258 await _metadata.setExtMeta(root, "reroot", rerootMeta);
259
260 log.verbose("Reroot", `Proposal rejected for tree ${rootId}`);
261 return { rejected: true };
262}
263
264// ─────────────────────────────────────────────────────────────────────────
265// SNAPSHOT BUILDER
266// ─────────────────────────────────────────────────────────────────────────
267
268async function buildTreeSnapshot(rootId) {
269 const nodes = await Node.find({
270 rootOwner: rootId,
271 status: { $ne: "trimmed" },
272 })
273 .select("_id name parent children systemRole rootOwner metadata type")
274 .lean();
275
276 if (nodes.length === 0) return { nodes: [] };
277
278 const nodeMap = new Map();
279 for (const n of nodes) nodeMap.set(n._id.toString(), n);
280
281 // Calculate depth for each node
282 function getDepth(nodeId, visited = new Set()) {
283 if (!nodeId || visited.has(nodeId)) return 0;
284 visited.add(nodeId);
285 const node = nodeMap.get(nodeId);
286 if (!node || !node.parent) return 0;
287 return 1 + getDepth(node.parent.toString(), visited);
288 }
289
290 // Get content summary for each node (first note, truncated)
291 const nodeIds = nodes.map(n => n._id.toString());
292 const recentNotes = await Note.find({ nodeId: { $in: nodeIds } })
293 .sort({ dateCreated: -1 })
294 .select("nodeId content")
295 .lean();
296
297 const notesByNode = new Map();
298 for (const note of recentNotes) {
299 const id = note.nodeId.toString();
300 if (!notesByNode.has(id)) {
301 notesByNode.set(id, note.content?.slice(0, 200) || "");
302 }
303 }
304
305 // Get codebook relationships if available
306 let codebookRelations = null;
307 const codebookExt = getExtension("codebook");
308 if (codebookExt?.exports?.getRelationships) {
309 try {
310 codebookRelations = await codebookExt.exports.getRelationships(rootId);
311 } catch (err) {
312 log.debug("Reroot", "Codebook relationships unavailable:", err.message);
313 }
314 }
315
316 // Build snapshot entries
317 const snapshotNodes = nodes
318 .filter(n => !n.systemRole)
319 .map(n => {
320 const id = n._id.toString();
321 return {
322 id,
323 name: n.name,
324 type: n.type || null,
325 parentId: n.parent?.toString() || null,
326 parentName: n.parent ? nodeMap.get(n.parent.toString())?.name || null : null,
327 depth: getDepth(id),
328 childCount: (n.children || []).length,
329 hasRootOwner: !!(n.rootOwner && n.rootOwner !== "SYSTEM"),
330 contentPreview: notesByNode.get(id) || null,
331 };
332 });
333
334 return {
335 nodes: snapshotNodes,
336 codebookRelations,
337 };
338}
339
340function formatSnapshot(snapshot) {
341 const lines = snapshot.nodes.map(n => {
342 let line = `${n.id} | "${n.name}"`;
343 if (n.type) line += ` [${n.type}]`;
344 line += ` | parent: "${n.parentName || "root"}" (${n.parentId || "root"})`;
345 line += ` | depth: ${n.depth} | children: ${n.childCount}`;
346 if (n.hasRootOwner) line += " | HAS_OWNER (do not move)";
347 if (n.contentPreview) line += ` | content: "${n.contentPreview}"`;
348 return line;
349 });
350
351 let text = lines.join("\n");
352
353 if (snapshot.codebookRelations) {
354 text += "\n\nCodebook relationships:\n" + JSON.stringify(snapshot.codebookRelations);
355 }
356
357 return text;
358}
359
1import log from "../../seed/log.js";
2import { setServices } from "./core.js";
3
4export async function init(core) {
5 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
6
7 core.llm.registerRootLlmSlot("reroot");
8
9 setServices({
10 models: core.models,
11 contributions: core.contributions,
12 llm: { ...core.llm, runChat: async (opts) => {
13 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
14 return core.llm.runChat({ ...opts, llmPriority: BG });
15 } },
16 energy: core.energy || null,
17 metadata: core.metadata,
18 });
19
20 const { default: router } = await import("./routes.js");
21
22 log.verbose("Reroot", "Tree reorganization engine loaded");
23
24 return { router };
25}
26
1export default {
2 name: "reroot",
3 version: "1.0.1",
4 builtFor: "treeos-maintenance",
5 description:
6 "The tree reorganizes itself. Nodes end up in the wrong place over time. " +
7 "A task that started under Work actually belongs under Side Projects. A note " +
8 "about nutrition is buried three levels deep in a fitness branch when it should " +
9 "be a sibling of the food node. The tree grew organically and the organic " +
10 "structure doesn't match the logical structure anymore. " +
11 "\n\n" +
12 "Reroot runs an analysis pass. It reads every node's content, its enrichContext " +
13 "snapshot, its codebook relationships, its cascade connections, its evolution " +
14 "patterns. It builds a similarity graph: which nodes reference similar concepts, " +
15 "share codebook entries, exchange cascade signals frequently, or have overlapping " +
16 "perspective filters. " +
17 "\n\n" +
18 "Then it compares the similarity graph against the actual tree structure. Nodes " +
19 "that are semantically close but structurally far apart are candidates for " +
20 "reorganization. Nodes that are structurally adjacent but semantically unrelated " +
21 "are candidates for separation. " +
22 "\n\n" +
23 "It generates a reorganization plan. Not a flat 'move X to Y' list. A proposed " +
24 "tree structure showing where every misplaced node would fit better. The AI " +
25 "produces it with constraints: do not break ownership boundaries, do not move " +
26 "nodes with rootOwner set, preserve cascade configurations. " +
27 "\n\n" +
28 "The plan writes to metadata.reroot.proposal on the tree root. The user reviews " +
29 "it. reroot preview shows the proposed changes with before/after. reroot apply " +
30 "executes the moves. reroot reject discards the proposal. " +
31 "\n\n" +
32 "The tree rebuilt itself. Not by growing new branches. By rearranging the ones " +
33 "it has so the structure matches the meaning.",
34
35 needs: {
36 services: ["llm", "hooks", "contributions"],
37 models: ["Node", "Note"],
38 },
39
40 optional: {
41 services: ["energy"],
42 extensions: [
43 "codebook",
44 "evolution",
45 "understanding",
46 ],
47 },
48
49 provides: {
50 models: {},
51 routes: false,
52 tools: false,
53 jobs: false,
54 orchestrator: false,
55 energyActions: {
56 rerootAnalyze: { cost: 3 },
57 },
58 sessionTypes: {},
59
60 cli: [
61 {
62 command: "reroot [action]", scope: ["tree"],
63 description: "Analyze and propose reorganization. Actions: preview, apply, reject.",
64 method: "POST",
65 endpoint: "/root/:rootId/reroot/analyze",
66 subcommands: {
67 "preview": { method: "GET", endpoint: "/root/:rootId/reroot", description: "Show proposed moves with reasons" },
68 "apply": { method: "POST", endpoint: "/root/:rootId/reroot/apply", description: "Execute the reorganization" },
69 "reject": { method: "POST", endpoint: "/root/:rootId/reroot/reject", description: "Discard proposal" },
70 },
71 },
72 ],
73 },
74};
75
1import express from "express";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { analyze, getProposal, applyProposal, rejectProposal } from "./core.js";
5
6function validateRootId(req, res) {
7 const rootId = req.params.rootId;
8 if (!rootId || rootId === "undefined" || rootId === "null") {
9 sendError(res, 400, ERR.INVALID_INPUT, "rootId is required");
10 return null;
11 }
12 return rootId;
13}
14
15const router = express.Router();
16
17// POST /root/:rootId/reroot/analyze - Run analysis and generate proposal
18router.post("/root/:rootId/reroot/analyze", authenticate, async (req, res) => {
19 try {
20 const rootId = validateRootId(req, res);
21 if (!rootId) return;
22 const result = await analyze(rootId, req.userId, req.username);
23 sendOk(res, result);
24 } catch (err) {
25 sendError(res, 400, ERR.INVALID_INPUT, err.message);
26 }
27});
28
29// GET /root/:rootId/reroot - Show current proposal
30router.get("/root/:rootId/reroot", authenticate, async (req, res) => {
31 try {
32 const rootId = validateRootId(req, res);
33 if (!rootId) return;
34 const proposal = await getProposal(rootId);
35 if (!proposal) {
36 return sendOk(res, { proposal: null, message: "No proposal. Run reroot to analyze." });
37 }
38 sendOk(res, { proposal });
39 } catch (err) {
40 sendError(res, 500, ERR.INTERNAL, err.message);
41 }
42});
43
44// POST /root/:rootId/reroot/apply - Execute the proposal
45router.post("/root/:rootId/reroot/apply", authenticate, async (req, res) => {
46 try {
47 const rootId = validateRootId(req, res);
48 if (!rootId) return;
49 const result = await applyProposal(rootId, req.userId);
50 sendOk(res, result);
51 } catch (err) {
52 sendError(res, 400, ERR.INVALID_INPUT, err.message);
53 }
54});
55
56// POST /root/:rootId/reroot/reject - Discard the proposal
57router.post("/root/:rootId/reroot/reject", authenticate, async (req, res) => {
58 try {
59 const rootId = validateRootId(req, res);
60 if (!rootId) return;
61 const result = await rejectProposal(rootId, req.userId);
62 sendOk(res, result);
63 } catch (err) {
64 sendError(res, 400, ERR.INVALID_INPUT, err.message);
65 }
66});
67
68export default router;
69
Loading comments...