1// Split Core
2//
3// Detect when a branch has outgrown its tree and execute mitosis.
4// Analysis reads signals from every installed intelligence extension.
5// Execution moves the branch into a new root tree with all metadata intact.
6
7import log from "../../seed/log.js";
8import { getDescendantIds } from "../../seed/tree/treeFetch.js";
9import { updateParentRelationship } from "../../seed/tree/treeManagement.js";
10import { invalidateAll } from "../../seed/tree/ancestorCache.js";
11import { getExtension } from "../loader.js";
12import { v4 as uuidv4 } from "uuid";
13
14let Node = null;
15let Note = null;
16let logContribution = async () => {};
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// DIMENSION SCORERS
32// Each scorer reads one intelligence extension's data and returns
33// { score: 0-1, detail: string, available: true } or { available: false }
34// Score = how strongly this dimension suggests the branch should split.
35// 1.0 = definitely should split. 0.0 = no reason to split.
36// ─────────────────────────────────────────────────────────────────────────
37
38async function scoreActivity(branchId, rootId, branchNodeIds) {
39 const ext = getExtension("evolution");
40 if (!ext?.exports?.getEvolutionReport) return { available: false };
41
42 try {
43 const report = await ext.exports.getEvolutionReport(rootId);
44 if (!report?.fitness) return { available: false };
45
46 // Count activity in branch vs rest of tree
47 const branchSet = new Set(branchNodeIds);
48 let branchActivity = 0;
49 let totalActivity = 0;
50 for (const [nodeId, fitness] of Object.entries(report.fitness)) {
51 const activity = fitness.activity || fitness.score || 0;
52 totalActivity += activity;
53 if (branchSet.has(nodeId)) branchActivity += activity;
54 }
55
56 if (totalActivity === 0) return { available: false };
57 const ratio = branchActivity / totalActivity;
58 return {
59 available: true,
60 score: Math.min(1, ratio * 1.2), // > 83% activity = score 1.0
61 detail: `${Math.round(ratio * 100)}% of tree activity is in this branch`,
62 };
63 } catch { return { available: false }; }
64}
65
66async function scoreBoundary(branchId, rootId) {
67 const ext = getExtension("boundary");
68 if (!ext?.exports?.getBoundaryReport) return { available: false };
69
70 try {
71 const report = await ext.exports.getBoundaryReport(rootId);
72 if (!report?.branches?.[branchId]) return { available: false };
73
74 const branchData = report.branches[branchId];
75 // Low similarity to siblings = high split score
76 // Find this branch's average similarity to all other branches
77 const blurred = (report.findings || []).filter(
78 f => f.type === "blurred" && f.branches?.includes(branchId),
79 );
80 const avgSimilarity = blurred.length > 0
81 ? blurred.reduce((s, f) => s + (f.similarity || 0), 0) / blurred.length
82 : 0;
83
84 // Invert: low similarity to siblings = high split score
85 const score = 1 - avgSimilarity;
86 return {
87 available: true,
88 score: Math.max(0, Math.min(1, score)),
89 detail: `${(avgSimilarity * 100).toFixed(0)}% average similarity to sibling branches`,
90 };
91 } catch { return { available: false }; }
92}
93
94async function scoreCoherence(branchId, rootId) {
95 const ext = getExtension("purpose");
96 if (!ext?.exports?.getThesis) return { available: false };
97
98 try {
99 const thesis = await ext.exports.getThesis(rootId);
100 if (!thesis?.coherence) return { available: false };
101
102 // Check if the branch has its own purpose that diverges from root
103 const branchNode = await Node.findById(branchId).select("metadata").lean();
104 if (!branchNode) return { available: false };
105 const branchPurpose = _metadata.getExtMeta(branchNode, "purpose");
106
107 if (branchPurpose?.coherence != null) {
108 // Low coherence against root thesis = high split score
109 const score = 1 - branchPurpose.coherence;
110 return {
111 available: true,
112 score: Math.max(0, Math.min(1, score)),
113 detail: `Coherence against root thesis: ${(branchPurpose.coherence * 100).toFixed(0)}%`,
114 };
115 }
116
117 return { available: false };
118 } catch { return { available: false }; }
119}
120
121async function scorePersona(branchId, rootId) {
122 const branchNode = await Node.findById(branchId).select("metadata").lean();
123 const rootNode = await Node.findById(rootId).select("metadata").lean();
124 if (!branchNode || !rootNode) return { available: false };
125
126 const branchPersona = _metadata.getExtMeta(branchNode, "persona");
127 const rootPersona = _metadata.getExtMeta(rootNode, "persona");
128
129 if (!branchPersona?.name) return { available: false };
130
131 // Has its own persona different from root
132 const diverged = !rootPersona?.name || branchPersona.name !== rootPersona.name;
133 return {
134 available: true,
135 score: diverged ? 0.8 : 0.2,
136 detail: diverged
137 ? `Has its own persona ("${branchPersona.name}") different from root ("${rootPersona?.name || "none"}")`
138 : `Same persona as root ("${branchPersona.name}")`,
139 };
140}
141
142async function scoreCodebook(branchId, rootId, branchNodeIds) {
143 const ext = getExtension("codebook");
144 if (!ext?.exports?.getCodebookStats) return { available: false };
145
146 try {
147 const stats = await ext.exports.getCodebookStats(rootId);
148 if (!stats?.entries) return { available: false };
149
150 const branchSet = new Set(branchNodeIds);
151 let branchEntries = 0;
152 let sharedEntries = 0;
153 let totalEntries = stats.totalEntries || 0;
154
155 if (stats.byNode) {
156 for (const [nodeId, count] of Object.entries(stats.byNode)) {
157 if (branchSet.has(nodeId)) branchEntries += count;
158 }
159 }
160 // Estimate shared as entries referenced both inside and outside branch
161 sharedEntries = Math.max(0, totalEntries - branchEntries);
162
163 if (totalEntries === 0) return { available: false };
164
165 // High isolation (few shared terms) = high split score
166 const isolation = branchEntries > 0 ? 1 - (sharedEntries / (branchEntries + sharedEntries)) : 0;
167 return {
168 available: true,
169 score: Math.max(0, Math.min(1, isolation)),
170 detail: `${branchEntries} unique entries, ${sharedEntries} shared with parent`,
171 };
172 } catch { return { available: false }; }
173}
174
175async function scoreCascade(branchId, branchNodeIds) {
176 // Check what % of cascade signals originate or terminate in this branch
177 const branchSet = new Set(branchNodeIds);
178 let branchSignals = 0;
179 let totalSignals = 0;
180
181 // Read cascade config from branch nodes
182 for (const nodeId of branchNodeIds.slice(0, 100)) {
183 const node = await Node.findById(nodeId).select("metadata").lean();
184 if (!node) continue;
185 const cascadeMeta = _metadata.getExtMeta(node, "cascade");
186 if (cascadeMeta?.enabled) branchSignals++;
187 totalSignals++;
188 }
189
190 if (totalSignals === 0) return { available: false };
191
192 const containment = branchSignals / totalSignals;
193 return {
194 available: true,
195 score: Math.max(0, Math.min(1, containment)),
196 detail: `${Math.round(containment * 100)}% of branch nodes are cascade-enabled`,
197 };
198}
199
200// ─────────────────────────────────────────────────────────────────────────
201// ANALYZE
202// ─────────────────────────────────────────────────────────────────────────
203
204export async function analyze(rootId, userId) {
205 await useEnergy({ userId, action: "splitAnalyze" });
206
207 const root = await Node.findById(rootId).select("_id name children rootOwner").lean();
208 if (!root) throw new Error("Tree root not found");
209 if (!root.rootOwner) throw new Error("Node is not a tree root");
210
211 // Get direct children (branches to analyze)
212 const branches = [];
213 for (const childId of root.children || []) {
214 const child = await Node.findById(childId).select("_id name systemRole").lean();
215 if (!child || child.systemRole) continue;
216
217 const descendantIds = await getDescendantIds(childId.toString(), { maxResults: 10000 });
218
219 const dimensions = {};
220 dimensions.activity = await scoreActivity(childId.toString(), rootId, descendantIds);
221 dimensions.boundary = await scoreBoundary(childId.toString(), rootId);
222 dimensions.coherence = await scoreCoherence(childId.toString(), rootId);
223 dimensions.persona = await scorePersona(childId.toString(), rootId);
224 dimensions.codebook = await scoreCodebook(childId.toString(), rootId, descendantIds);
225 dimensions.cascade = await scoreCascade(childId.toString(), descendantIds);
226
227 const available = Object.values(dimensions).filter(d => d.available);
228 const avgScore = available.length > 0
229 ? available.reduce((s, d) => s + d.score, 0) / available.length
230 : 0;
231
232 branches.push({
233 branchId: childId.toString(),
234 branchName: child.name,
235 nodeCount: descendantIds.length,
236 dimensions,
237 availableDimensions: available.length,
238 averageScore: Math.round(avgScore * 100) / 100,
239 recommendation: avgScore >= 0.7 ? "strong candidate for split"
240 : avgScore >= 0.5 ? "possible candidate"
241 : "no split recommended",
242 });
243 }
244
245 // Sort by score descending
246 branches.sort((a, b) => b.averageScore - a.averageScore);
247
248 log.verbose("Split", `Analyzed ${branches.length} branches of ${root.name}`);
249
250 return {
251 rootId,
252 rootName: root.name,
253 branches,
254 topCandidate: branches.length > 0 && branches[0].averageScore >= 0.5
255 ? branches[0] : null,
256 };
257}
258
259// ─────────────────────────────────────────────────────────────────────────
260// PREVIEW
261// ─────────────────────────────────────────────────────────────────────────
262
263export async function preview(branchId, userId) {
264 const branch = await Node.findById(branchId).select("_id name parent rootOwner").lean();
265 if (!branch) throw new Error("Branch not found");
266 if (branch.rootOwner) throw new Error("This node is already a tree root");
267
268 const descendantIds = await getDescendantIds(branchId, { maxResults: 10000 });
269
270 // Count metadata namespaces that will travel with the branch
271 const metadataNamespaces = new Set();
272 const sampleNodes = descendantIds.slice(0, 50);
273 for (const nodeId of sampleNodes) {
274 const node = await Node.findById(nodeId).select("metadata").lean();
275 if (!node) continue;
276 const meta = node.metadata instanceof Map
277 ? [...node.metadata.keys()]
278 : Object.keys(node.metadata || {});
279 for (const ns of meta) metadataNamespaces.add(ns);
280 }
281
282 return {
283 branchId,
284 branchName: branch.name,
285 parentId: branch.parent?.toString(),
286 nodeCount: descendantIds.length,
287 metadataCarried: [...metadataNamespaces].sort(),
288 willCreate: `New root tree "${branch.name}"`,
289 willMove: `${descendantIds.length} nodes preserving hierarchy`,
290 willLeave: `Split note on parent node`,
291 willConnect: `Channel between parent and new root`,
292 };
293}
294
295// ─────────────────────────────────────────────────────────────────────────
296// EXECUTE
297// ─────────────────────────────────────────────────────────────────────────
298
299export async function execute(branchId, userId, username) {
300 await useEnergy({ userId, action: "splitExecute" });
301
302 const branch = await Node.findById(branchId).select("_id name parent rootOwner").lean();
303 if (!branch) throw new Error("Branch not found");
304 if (branch.rootOwner) throw new Error("This node is already a tree root");
305
306 const parentId = branch.parent?.toString();
307 if (!parentId) throw new Error("Branch has no parent");
308
309 // Find the current tree root
310 let currentRootId = null;
311 let cursor = await Node.findById(parentId).select("_id parent rootOwner").lean();
312 while (cursor) {
313 if (cursor.rootOwner) { currentRootId = cursor._id.toString(); break; }
314 if (!cursor.parent) break;
315 cursor = await Node.findById(cursor.parent).select("_id parent rootOwner").lean();
316 }
317
318 // Detach from parent and promote to root
319 // 1. Remove from parent's children
320 await Node.updateOne({ _id: parentId }, { $pull: { children: branchId } });
321
322 // 2. Set branch as root (rootOwner = userId, parent = null)
323 await Node.updateOne({ _id: branchId }, {
324 $set: { rootOwner: userId, parent: null },
325 });
326
327 // 3. Update all descendants: rootOwner stays as-is for delegated branches,
328 // but non-delegated nodes need rootOwner cleared (they inherit from the new root).
329 // Actually: rootOwner on descendants that pointed to the OLD tree root
330 // should now point to the NEW root (the branch itself).
331 const descendantIds = await getDescendantIds(branchId, { maxResults: 10000 });
332 // Only update descendants that had rootOwner = old tree root
333 if (currentRootId) {
334 await Node.updateMany(
335 { _id: { $in: descendantIds }, rootOwner: currentRootId },
336 { $set: { rootOwner: branchId } },
337 );
338 }
339
340 // Invalidate cache since we changed the tree topology
341 invalidateAll();
342
343 // 4. Leave a note on the old parent
344 try {
345 const { createNote } = await import("../../seed/tree/notes.js");
346 await createNote({
347 contentType: "text",
348 content: `${branch.name} split into its own tree on ${new Date().toISOString().slice(0, 10)}. ` +
349 `${descendantIds.length} nodes moved.`,
350 userId,
351 nodeId: parentId,
352 });
353 } catch (err) {
354 log.debug("Split", `Failed to leave split note on parent: ${err.message}`);
355 }
356
357 // 5. Create a channel between old parent and new root (if channels extension installed)
358 let channelCreated = false;
359 const channelsExt = getExtension("channels");
360 if (channelsExt?.exports?.createChannel) {
361 try {
362 await channelsExt.exports.createChannel({
363 sourceNodeId: parentId,
364 targetNodeId: branchId,
365 channelName: `split-${branch.name.toLowerCase().replace(/[^a-z0-9]/g, "-").slice(0, 30)}`,
366 direction: "bidirectional",
367 userId,
368 });
369 channelCreated = true;
370 } catch (err) {
371 log.debug("Split", `Failed to create post-split channel: ${err.message}`);
372 }
373 }
374
375 // 6. Record split in history on the old tree root
376 if (currentRootId) {
377 try {
378 const oldRoot = await Node.findById(currentRootId);
379 if (oldRoot) {
380 const splitMeta = _metadata.getExtMeta(oldRoot, "split");
381 if (!splitMeta.history) splitMeta.history = [];
382 splitMeta.history.push({
383 branchId,
384 branchName: branch.name,
385 nodeCount: descendantIds.length,
386 splitAt: new Date().toISOString(),
387 splitBy: userId,
388 newRootId: branchId,
389 });
390 await _metadata.setExtMeta(oldRoot, "split", splitMeta);
391 }
392 } catch (err) {
393 log.debug("Split", `Failed to record split history: ${err.message}`);
394 }
395 }
396
397 // 7. Add user's roots list (navigation extension)
398 const navExt = getExtension("navigation");
399 if (navExt?.exports?.addRoot) {
400 try {
401 await navExt.exports.addRoot(userId, branchId);
402 } catch (err) {
403 log.debug("Split", `Failed to add new root to navigation: ${err.message}`);
404 }
405 }
406
407 // Log contribution
408 await logContribution({
409 userId,
410 nodeId: branchId,
411 wasAi: false,
412 action: "split:executed",
413 extensionData: {
414 split: {
415 fromTreeId: currentRootId,
416 branchName: branch.name,
417 nodeCount: descendantIds.length,
418 channelCreated,
419 },
420 },
421 });
422
423 log.info("Split", `Branch "${branch.name}" split from tree ${currentRootId} into new root (${descendantIds.length} nodes)`);
424
425 return {
426 newRootId: branchId,
427 newRootName: branch.name,
428 nodeCount: descendantIds.length,
429 fromTreeId: currentRootId,
430 channelCreated,
431 splitAt: new Date().toISOString(),
432 };
433}
434
435// ─────────────────────────────────────────────────────────────────────────
436// HISTORY
437// ─────────────────────────────────────────────────────────────────────────
438
439export async function getHistory(rootId) {
440 const root = await Node.findById(rootId).select("metadata").lean();
441 if (!root) throw new Error("Tree root not found");
442 const meta = _metadata.getExtMeta(root, "split");
443 return { history: meta.history || [] };
444}
445
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setServices, analyze, preview, execute, getHistory } from "./core.js";
4
5export async function init(core) {
6 core.llm.registerRootLlmSlot?.("split");
7
8 setServices({
9 models: core.models,
10 contributions: core.contributions,
11 llm: core.llm,
12 energy: core.energy || null,
13 metadata: core.metadata,
14 });
15
16 const { default: router } = await import("./routes.js");
17
18 log.info("Split", "Branch mitosis loaded");
19
20 return {
21 router,
22 tools,
23 exports: {
24 analyze,
25 preview,
26 execute,
27 getHistory,
28 },
29 };
30}
31
1export default {
2 name: "split",
3 version: "1.0.1",
4 builtFor: "seed",
5 description:
6 "The tree detects when a branch has outgrown its parent. Evolution metrics show " +
7 "the branch has more activity than the rest of the tree combined. Boundary analysis " +
8 "shows it has low cohesion with its siblings. Purpose coherence is dropping because " +
9 "the branch is pulling the thesis in a direction the rest of the tree does not follow. " +
10 "The branch has its own identity through persona. Its own cascade topology. Its own " +
11 "codebook with the user." +
12 "\n\n" +
13 "Split reads all of these signals and proposes: this branch should be its own tree." +
14 "\n\n" +
15 "This is not export. Export strips accumulated data and reproduces form. This is " +
16 "mitosis. The branch takes everything it earned with it. Evolution patterns, codebook " +
17 "dictionaries, memory connections, explore maps, persona definitions, cascade topology. " +
18 "Nothing is stripped. The branch is not starting over. It is graduating." +
19 "\n\n" +
20 "Analysis scores each direct child branch on six dimensions: activity ratio from " +
21 "evolution, similarity score from boundary, coherence against root thesis from purpose, " +
22 "persona divergence, codebook isolation (unique vs shared terms), and cascade self-containment " +
23 "(what percentage of signals originate or terminate within the branch). Each installed " +
24 "intelligence extension adds one dimension. Without any, split has nothing to analyze." +
25 "\n\n" +
26 "Execution creates a new root tree from the branch. Moves all descendant nodes " +
27 "preserving hierarchy via updateParentRelationship. Carries all metadata (structural " +
28 "and accumulated). Sets rootOwner to the current user. Leaves a note on the old parent " +
29 "recording the split. Creates a channel between the old parent and the new root so " +
30 "signals can still flow. The connection remains until the user cuts it." +
31 "\n\n" +
32 "Intent integration: if a branch scores above configurable thresholds on every " +
33 "available metric, intent can propose the split autonomously. The tree notices it has " +
34 "outgrown its own structure. The user reviews. The user decides. But the tree said " +
35 "something." +
36 "\n\n" +
37 "The full lifecycle: a seed is planted, a tree grows, branches form, one branch " +
38 "outgrows the tree, the tree splits, the branch becomes a new tree, the new tree " +
39 "grows, eventually it drops a seed through seed-export, the seed is planted on " +
40 "another land. Birth, growth, mitosis, reproduction, teaching. The biology is complete.",
41
42 needs: {
43 services: ["hooks", "llm", "contributions"],
44 models: ["Node", "Note"],
45 },
46
47 optional: {
48 services: ["energy"],
49 extensions: [
50 "evolution",
51 "boundary",
52 "purpose",
53 "persona",
54 "codebook",
55 "long-memory",
56 "channels",
57 "inverse-tree",
58 "phase",
59 "intent",
60 ],
61 },
62
63 provides: {
64 models: {},
65 routes: "./routes.js",
66 tools: true,
67 jobs: false,
68 orchestrator: false,
69 energyActions: {
70 splitAnalyze: { cost: 2 },
71 splitExecute: { cost: 3 },
72 },
73 sessionTypes: {},
74
75 hooks: {
76 fires: [],
77 listens: [],
78 },
79
80 cli: [
81 {
82 command: "split [action]", scope: ["tree"],
83 description: "Branch mitosis. Actions: preview, execute, history.",
84 method: "POST",
85 endpoint: "/root/:rootId/split/analyze",
86 subcommands: {
87 preview: {
88 method: "POST",
89 endpoint: "/node/:nodeId/split/preview",
90 description: "Show what would happen if this branch splits",
91 },
92 execute: {
93 method: "POST",
94 endpoint: "/node/:nodeId/split/execute",
95 description: "Split this branch into its own tree",
96 },
97 history: {
98 method: "GET",
99 endpoint: "/root/:rootId/split/history",
100 description: "Past splits from this tree",
101 },
102 },
103 },
104 ],
105 },
106};
107
1import express from "express";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { analyze, preview, execute, getHistory } from "./core.js";
5
6const router = express.Router();
7
8// POST /root/:rootId/split/analyze - Analyze all branches for split candidates
9router.post("/root/:rootId/split/analyze", authenticate, async (req, res) => {
10 try {
11 const result = await analyze(req.params.rootId, req.userId);
12 sendOk(res, result);
13 } catch (err) {
14 sendError(res, 400, ERR.INVALID_INPUT, err.message);
15 }
16});
17
18// POST /node/:nodeId/split/preview - Preview what a split would do
19router.post("/node/:nodeId/split/preview", authenticate, async (req, res) => {
20 try {
21 const result = await preview(req.params.nodeId, req.userId);
22 sendOk(res, result);
23 } catch (err) {
24 sendError(res, 400, ERR.INVALID_INPUT, err.message);
25 }
26});
27
28// POST /node/:nodeId/split/execute - Execute the split
29router.post("/node/:nodeId/split/execute", authenticate, async (req, res) => {
30 try {
31 const result = await execute(req.params.nodeId, req.userId, req.username);
32 sendOk(res, result, 201);
33 } catch (err) {
34 sendError(res, 400, ERR.INVALID_INPUT, err.message);
35 }
36});
37
38// GET /root/:rootId/split/history - Past splits from this tree
39router.get("/root/:rootId/split/history", authenticate, async (req, res) => {
40 try {
41 const result = await getHistory(req.params.rootId);
42 sendOk(res, result);
43 } catch (err) {
44 sendError(res, 400, ERR.INVALID_INPUT, err.message);
45 }
46});
47
48export default router;
49
1import { z } from "zod";
2import { analyze, preview, execute, getHistory } from "./core.js";
3
4export default [
5 {
6 name: "split-analyze",
7 description:
8 "Analyze all branches of the current tree to find split candidates. Scores each " +
9 "branch on activity, boundary cohesion, purpose coherence, persona divergence, " +
10 "codebook isolation, and cascade containment.",
11 schema: {
12 rootId: z.string().describe("Tree root to analyze."),
13 userId: z.string().describe("Injected by server. Ignore."),
14 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
15 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
16 },
17 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
18 handler: async ({ rootId, userId }) => {
19 try {
20 const result = await analyze(rootId, userId);
21 const summary = {
22 rootName: result.rootName,
23 branchesAnalyzed: result.branches.length,
24 topCandidate: result.topCandidate ? {
25 name: result.topCandidate.branchName,
26 score: result.topCandidate.averageScore,
27 nodeCount: result.topCandidate.nodeCount,
28 recommendation: result.topCandidate.recommendation,
29 } : null,
30 allBranches: result.branches.map(b => ({
31 name: b.branchName,
32 score: b.averageScore,
33 nodeCount: b.nodeCount,
34 recommendation: b.recommendation,
35 })),
36 };
37 return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
38 } catch (err) {
39 return { content: [{ type: "text", text: `Analysis failed: ${err.message}` }] };
40 }
41 },
42 },
43 {
44 name: "split-preview",
45 description:
46 "Preview what would happen if a specific branch splits into its own tree. " +
47 "Shows node count, metadata carried, and connections created.",
48 schema: {
49 nodeId: z.string().describe("Branch node to preview splitting."),
50 userId: z.string().describe("Injected by server. Ignore."),
51 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
52 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
53 },
54 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
55 handler: async ({ nodeId, userId }) => {
56 try {
57 const result = await preview(nodeId, userId);
58 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
59 } catch (err) {
60 return { content: [{ type: "text", text: `Preview failed: ${err.message}` }] };
61 }
62 },
63 },
64 {
65 name: "split-execute",
66 description:
67 "Execute a branch split. The branch becomes its own root tree. All nodes and " +
68 "metadata move with it. A channel is created back to the parent.",
69 schema: {
70 nodeId: z.string().describe("Branch node to split into a new tree."),
71 userId: z.string().describe("Injected by server. Ignore."),
72 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
73 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
74 },
75 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
76 handler: async ({ nodeId, userId }) => {
77 try {
78 const User = (await import("../../seed/models/user.js")).default;
79 const user = await User.findById(userId).select("username").lean();
80 const result = await execute(nodeId, userId, user?.username || "system");
81 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
82 } catch (err) {
83 return { content: [{ type: "text", text: `Split failed: ${err.message}` }] };
84 }
85 },
86 },
87 {
88 name: "split-history",
89 description: "Show past splits from this tree.",
90 schema: {
91 rootId: z.string().describe("Tree root to check."),
92 userId: z.string().describe("Injected by server. Ignore."),
93 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
94 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
95 },
96 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
97 handler: async ({ rootId }) => {
98 try {
99 const result = await getHistory(rootId);
100 if (result.history.length === 0) {
101 return { content: [{ type: "text", text: "No splits have occurred from this tree." }] };
102 }
103 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
104 } catch (err) {
105 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
106 }
107 },
108 },
109];
110
Loading comments...