1/**
2 * Evolution Core
3 *
4 * Tracks structural fitness metrics per node. Runs periodic analysis
5 * to discover patterns in how the tree grows and which shapes succeed.
6 */
7
8import log from "../../seed/log.js";
9import Node from "../../seed/models/node.js";
10import Note from "../../seed/models/note.js";
11import { SYSTEM_ROLE, NODE_STATUS, CONTENT_TYPE } from "../../seed/protocol.js";
12import { getDescendantIds } from "../../seed/tree/treeFetch.js";
13import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
14
15let _runChat = null;
16let _metadata = null;
17export function setRunChat(fn) { _runChat = fn; }
18export function setMetadata(m) { _metadata = m; }
19
20// ─────────────────────────────────────────────────────────────────────────
21// CONFIG
22// ─────────────────────────────────────────────────────────────────────────
23
24const DEFAULTS = {
25 analysisIntervalMs: 6 * 60 * 60 * 1000, // 6 hours
26 dormancyThresholdDays: 30,
27 maxPatternsPerTree: 20,
28 minActivityForAnalysis: 10,
29};
30
31export async function getEvolutionConfig() {
32 const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
33 if (!configNode) return { ...DEFAULTS };
34 const meta = configNode.metadata instanceof Map
35 ? configNode.metadata.get("evolution") || {}
36 : configNode.metadata?.evolution || {};
37 return { ...DEFAULTS, ...meta };
38}
39
40// ─────────────────────────────────────────────────────────────────────────
41// METRIC RECORDING (lightweight, per-hook)
42// ─────────────────────────────────────────────────────────────────────────
43
44/**
45 * Bump a metric counter on a node. Atomic $inc.
46 */
47export async function bumpMetric(nodeId, metric, amount = 1) {
48 await _metadata.incExtMeta(nodeId, "evolution", metric, amount);
49 await _metadata.batchSetExtMeta(nodeId, "evolution", { lastActivity: new Date().toISOString() });
50}
51
52/**
53 * Record a navigation visit.
54 */
55export async function recordVisit(nodeId) {
56 await _metadata.incExtMeta(nodeId, "evolution", "visits", 1);
57 await _metadata.batchSetExtMeta(nodeId, "evolution", { lastVisited: new Date().toISOString() });
58}
59
60// ─────────────────────────────────────────────────────────────────────────
61// FITNESS CALCULATION
62// ─────────────────────────────────────────────────────────────────────────
63
64/**
65 * Calculate fitness metrics for a node.
66 * Reads raw counters from metadata.evolution plus note/child counts.
67 */
68export async function calculateFitness(nodeId) {
69 const node = await Node.findById(nodeId)
70 .select("name type status children dateCreated metadata")
71 .lean();
72 if (!node) return null;
73
74 const meta = node.metadata instanceof Map
75 ? Object.fromEntries(node.metadata)
76 : (node.metadata || {});
77 const evo = meta.evolution || {};
78
79 const ageMs = Date.now() - new Date(node.dateCreated).getTime();
80 const ageWeeks = Math.max(1, ageMs / (7 * 24 * 60 * 60 * 1000));
81 const ageDays = Math.max(1, ageMs / (24 * 60 * 60 * 1000));
82
83 // Note count
84 const noteCount = await Note.countDocuments({ nodeId, contentType: CONTENT_TYPE.TEXT });
85
86 // Dormancy
87 const lastActivity = evo.lastActivity ? new Date(evo.lastActivity).getTime() : new Date(node.dateCreated).getTime();
88 const dormancyDays = Math.round((Date.now() - lastActivity) / (24 * 60 * 60 * 1000));
89
90 // Codebook density (if codebook data exists)
91 let codebookScore = 0;
92 if (meta.codebook) {
93 for (const [uid, data] of Object.entries(meta.codebook)) {
94 if (data?.dictionary) codebookScore += Object.keys(data.dictionary).length;
95 }
96 }
97
98 return {
99 nodeId,
100 nodeName: node.name,
101 nodeType: node.type,
102 status: node.status,
103 ageWeeks: Math.round(ageWeeks * 10) / 10,
104 activityScore: Math.round((noteCount / ageWeeks) * 10) / 10,
105 cascadeScore: (evo.cascadesOriginated || 0) + (evo.cascadesReceived || 0),
106 revisitScore: evo.visits || 0,
107 growthScore: (node.children || []).length,
108 codebookScore,
109 dormancyDays,
110 noteCount,
111 childCount: (node.children || []).length,
112 depth: evo.depth || null,
113 };
114}
115
116// ─────────────────────────────────────────────────────────────────────────
117// TREE-WIDE ANALYSIS
118// ─────────────────────────────────────────────────────────────────────────
119
120/**
121 * Run a full analysis pass on a tree. Calculates fitness for every node,
122 * identifies dormant branches, and asks the AI to discover structural patterns.
123 */
124export async function analyzeTree(rootId, userId, username) {
125 const nodeIds = await getDescendantIds(rootId);
126 const config = await getEvolutionConfig();
127
128 // Calculate fitness for every node
129 const fitnessMap = [];
130 const dormant = [];
131
132 for (const nid of nodeIds) {
133 const fitness = await calculateFitness(nid);
134 if (!fitness) continue;
135 fitnessMap.push(fitness);
136
137 if (fitness.dormancyDays >= config.dormancyThresholdDays && fitness.status === NODE_STATUS.ACTIVE) {
138 dormant.push(fitness);
139 }
140 }
141
142 // Build a structural summary for the AI
143 const typeCounts = {};
144 const depthBuckets = {};
145 const statusCounts = {};
146 let totalNotes = 0;
147 let totalCascade = 0;
148 let totalVisits = 0;
149 let avgChildren = 0;
150
151 for (const f of fitnessMap) {
152 typeCounts[f.nodeType || "untyped"] = (typeCounts[f.nodeType || "untyped"] || 0) + 1;
153 statusCounts[f.status || "active"] = (statusCounts[f.status || "active"] || 0) + 1;
154 totalNotes += f.noteCount;
155 totalCascade += f.cascadeScore;
156 totalVisits += f.revisitScore;
157 avgChildren += f.childCount;
158 }
159 avgChildren = fitnessMap.length > 0 ? Math.round((avgChildren / fitnessMap.length) * 10) / 10 : 0;
160
161 // Top performers and worst performers
162 const byActivity = [...fitnessMap].sort((a, b) => b.activityScore - a.activityScore);
163 const topPerformers = byActivity.slice(0, 5).map((f) =>
164 `"${f.nodeName}" (type: ${f.nodeType || "none"}, activity: ${f.activityScore}/wk, children: ${f.childCount}, cascade: ${f.cascadeScore})`,
165 );
166 const bottomPerformers = byActivity.filter((f) => f.status === NODE_STATUS.ACTIVE).slice(-5).map((f) =>
167 `"${f.nodeName}" (type: ${f.nodeType || "none"}, activity: ${f.activityScore}/wk, dormant: ${f.dormancyDays}d)`,
168 );
169
170 // Ask AI to discover patterns
171 let patterns = [];
172 if (_runChat && fitnessMap.length >= 5) {
173 try {
174 const prompt =
175 `You are analyzing a tree's structural evolution to discover patterns.\n\n` +
176 `Tree stats: ${fitnessMap.length} nodes, ${totalNotes} total notes, ${totalCascade} cascade events, ${totalVisits} visits\n` +
177 `Node types: ${JSON.stringify(typeCounts)}\n` +
178 `Status distribution: ${JSON.stringify(statusCounts)}\n` +
179 `Average children per node: ${avgChildren}\n` +
180 `Dormant nodes (${config.dormancyThresholdDays}+ days inactive): ${dormant.length}\n\n` +
181 `Top active nodes:\n${topPerformers.join("\n")}\n\n` +
182 `Least active nodes:\n${bottomPerformers.join("\n")}\n\n` +
183 `Full node fitness data (${Math.min(fitnessMap.length, 50)} nodes):\n` +
184 `${JSON.stringify(fitnessMap.slice(0, 50).map((f) => ({
185 name: f.nodeName, type: f.nodeType, status: f.status,
186 activity: f.activityScore, cascade: f.cascadeScore, visits: f.revisitScore,
187 children: f.childCount, dormancy: f.dormancyDays, codebook: f.codebookScore,
188 })), null, 0)}\n\n` +
189 `Discover structural patterns. What node types, branching factors, depths, and configurations ` +
190 `correlate with high activity? What structures go dormant? What works for this specific tree?\n\n` +
191 `Return JSON array of pattern objects:\n` +
192 `[{ "pattern": "description of what works or fails", "evidence": "the data that supports it", "suggestion": "actionable recommendation" }]\n` +
193 `Maximum ${config.maxPatternsPerTree} patterns. Be specific. Use numbers.`;
194
195 const { answer } = await _runChat({
196 userId,
197 username: username || "system",
198 message: prompt,
199 mode: "tree:respond",
200 rootId,
201 slot: "evolution",
202 });
203
204 if (answer) {
205 const parsed = parseJsonSafe(answer);
206 if (Array.isArray(parsed)) {
207 patterns = parsed
208 .filter((p) => p && typeof p.pattern === "string")
209 .slice(0, config.maxPatternsPerTree)
210 .map((p) => ({
211 pattern: p.pattern,
212 evidence: p.evidence || null,
213 suggestion: p.suggestion || null,
214 discoveredAt: new Date().toISOString(),
215 }));
216 }
217 }
218 } catch (err) {
219 log.warn("Evolution", `Pattern analysis failed: ${err.message}`);
220 }
221 }
222
223 // Write patterns to the tree root
224 if (patterns.length > 0) {
225 await Node.findByIdAndUpdate(rootId, {
226 $set: {
227 "metadata.evolution.patterns": patterns,
228 "metadata.evolution.lastAnalysis": new Date().toISOString(),
229 },
230 });
231 }
232
233 return {
234 totalNodes: fitnessMap.length,
235 dormantCount: dormant.length,
236 patternsDiscovered: patterns.length,
237 patterns,
238 };
239}
240
241// ─────────────────────────────────────────────────────────────────────────
242// READERS
243// ─────────────────────────────────────────────────────────────────────────
244
245/**
246 * Get evolution patterns for a tree root.
247 */
248export async function getPatterns(rootId) {
249 const root = await Node.findById(rootId).select("metadata").lean();
250 if (!root) return [];
251 const meta = root.metadata instanceof Map
252 ? root.metadata.get("evolution") || {}
253 : root.metadata?.evolution || {};
254 return meta.patterns || [];
255}
256
257/**
258 * Get dormant branches for a tree.
259 */
260export async function getDormant(rootId) {
261 const config = await getEvolutionConfig();
262 const nodeIds = await getDescendantIds(rootId);
263 const dormant = [];
264
265 for (const nid of nodeIds) {
266 const fitness = await calculateFitness(nid);
267 if (!fitness) continue;
268 if (fitness.dormancyDays >= config.dormancyThresholdDays && fitness.status === NODE_STATUS.ACTIVE) {
269 dormant.push(fitness);
270 }
271 }
272
273 return dormant.sort((a, b) => b.dormancyDays - a.dormancyDays);
274}
275
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { setRunChat, setMetadata, bumpMetric, recordVisit, getPatterns } from "./core.js";
4import { startAnalysisJob, stopAnalysisJob } from "./job.js";
5
6export async function init(core) {
7 core.llm.registerRootLlmSlot("evolution");
8 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
9 setRunChat(async (opts) => {
10 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
11 return core.llm.runChat({ ...opts, llmPriority: BG });
12 });
13 setMetadata(core.metadata);
14
15 // ── afterNote: track activity ──────────────────────────────────────
16 core.hooks.register("afterNote", async ({ nodeId, userId, contentType, action }) => {
17 if (contentType !== "text") return;
18 if (action !== "create") return;
19 if (!userId || userId === "SYSTEM") return;
20
21 try {
22 await bumpMetric(nodeId, "notesWritten");
23 } catch (err) {
24 log.debug("Evolution", "bumpMetric notesWritten failed:", err.message);
25 }
26 }, "evolution");
27
28 // ── afterNodeCreate: track growth ──────────────────────────────────
29 core.hooks.register("afterNodeCreate", async ({ node, userId }) => {
30 if (!node?.parent || !userId) return;
31
32 try {
33 // Bump growth score on the parent (a child was added)
34 await bumpMetric(node.parent.toString(), "childrenCreated");
35 } catch (err) {
36 log.debug("Evolution", "bumpMetric childrenCreated failed:", err.message);
37 }
38 }, "evolution");
39
40 // ── afterNavigate: track revisits ──────────────────────────────────
41 core.hooks.register("afterNavigate", async ({ userId, rootId, nodeId }) => {
42 if (!rootId) return;
43
44 try {
45 await recordVisit(rootId);
46 } catch (err) {
47 log.debug("Evolution", "recordVisit failed:", err.message);
48 }
49 }, "evolution");
50
51 // ── onCascade: track cascade involvement ───────────────────────────
52 core.hooks.register("onCascade", async (hookData) => {
53 const { nodeId, source, depth } = hookData;
54 if (!nodeId) return;
55
56 try {
57 if (depth === 0) {
58 // This node originated a cascade
59 await bumpMetric(nodeId, "cascadesOriginated");
60 } else {
61 // This node received a cascade
62 await bumpMetric(nodeId, "cascadesReceived");
63 }
64 } catch (err) {
65 log.debug("Evolution", "cascade metric bump failed:", err.message);
66 }
67 }, "evolution");
68
69 // ── afterNodeMove: structural reorganization is an evolution signal ───
70 core.hooks.register("afterNodeMove", async ({ nodeId, oldParentId, newParentId }) => {
71 try {
72 await bumpMetric(nodeId, "timesMoved");
73 await bumpMetric(oldParentId, "childrenLost");
74 await bumpMetric(newParentId, "childrenGained");
75 } catch (err) {
76 log.debug("Evolution", "afterNodeMove metric bump failed:", err.message);
77 }
78 }, "evolution");
79
80 // ── enrichContext: inject relevant patterns ─────────────────────────
81 // When the user is at a node, inject patterns from the tree root
82 // so the AI can recommend structure based on what actually works.
83 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
84 // Find tree root for patterns
85 if (!node.rootOwner && !node.parent) return; // land root or orphan
86 if (node.systemRole) return;
87
88 let rootId;
89 if (node.rootOwner) {
90 rootId = node._id;
91 } else {
92 try {
93 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
94 const root = await resolveRootNode(node._id);
95 rootId = root?._id;
96 } catch (err) {
97 log.debug("Evolution", "resolveRootNode failed:", err.message);
98 return;
99 }
100 }
101
102 if (!rootId) return;
103
104 try {
105 const patterns = await getPatterns(rootId);
106 if (patterns.length > 0) {
107 // Inject top 5 most relevant patterns (keep context lean)
108 context.structuralPatterns = patterns.slice(0, 5).map((p) => p.pattern);
109 }
110 } catch (err) {
111 log.debug("Evolution", "pattern injection failed:", err.message);
112 }
113
114 // Inject this node's fitness summary if it has evolution data
115 const evo = meta.evolution;
116 if (evo && (evo.notesWritten || evo.visits || evo.cascadesOriginated)) {
117 context.nodeFitness = {
118 notesWritten: evo.notesWritten || 0,
119 visits: evo.visits || 0,
120 cascades: (evo.cascadesOriginated || 0) + (evo.cascadesReceived || 0),
121 dormant: evo.lastActivity
122 ? Math.round((Date.now() - new Date(evo.lastActivity).getTime()) / (24 * 60 * 60 * 1000))
123 : null,
124 };
125 }
126 }, "evolution");
127
128 const { default: router } = await import("./routes.js");
129
130 return {
131 router,
132 tools,
133 jobs: [
134 {
135 name: "evolution-analysis",
136 start: () => { startAnalysisJob(); },
137 stop: () => { stopAnalysisJob(); },
138 },
139 ],
140 exports: {
141 getPatterns,
142 bumpMetric,
143 recordVisit,
144 },
145 };
146}
147
1/**
2 * Evolution Analysis Job
3 *
4 * Periodically checks all trees. Only analyzes trees that have
5 * meaningfully changed since last analysis. A dormant tree doesn't
6 * need pattern discovery every 6 hours. It needs it once after a
7 * burst of activity.
8 */
9
10import log from "../../seed/log.js";
11import Node from "../../seed/models/node.js";
12import User from "../../seed/models/user.js";
13import { analyzeTree, getEvolutionConfig } from "./core.js";
14import { getDescendantIds } from "../../seed/tree/treeFetch.js";
15
16let jobTimer = null;
17
18/**
19 * Count activity events across a tree since a given timestamp.
20 * Sums the atomic counters that hooks increment on every event.
21 * Only loads metadata, no full documents.
22 */
23async function countActivitySince(rootId, sinceMs) {
24 const nodeIds = await getDescendantIds(rootId);
25 let activity = 0;
26
27 // Sample up to 200 nodes to avoid loading the entire tree on huge lands
28 const sample = nodeIds.length > 200
29 ? nodeIds.sort(() => Math.random() - 0.5).slice(0, 200)
30 : nodeIds;
31
32 for (const nid of sample) {
33 const node = await Node.findById(nid).select("metadata").lean();
34 if (!node) continue;
35
36 const evo = node.metadata instanceof Map
37 ? node.metadata.get("evolution") || {}
38 : node.metadata?.evolution || {};
39
40 // If this node had activity after the analysis cutoff, count it
41 if (evo.lastActivity && new Date(evo.lastActivity).getTime() > sinceMs) {
42 activity += (evo.notesWritten || 0) + (evo.visits || 0) +
43 (evo.cascadesOriginated || 0) + (evo.cascadesReceived || 0) +
44 (evo.childrenCreated || 0);
45 }
46 }
47
48 // Scale up if we sampled
49 if (nodeIds.length > 200) {
50 activity = Math.round(activity * (nodeIds.length / 200));
51 }
52
53 return activity;
54}
55
56async function run() {
57 try {
58 const roots = await Node.find({
59 rootOwner: { $ne: null },
60 systemRole: null,
61 }).select("_id rootOwner name metadata").lean();
62
63 if (roots.length === 0) return;
64
65 const config = await getEvolutionConfig();
66 let analyzed = 0;
67 let skipped = 0;
68
69 for (const root of roots) {
70 const meta = root.metadata instanceof Map
71 ? root.metadata.get("evolution") || {}
72 : root.metadata?.evolution || {};
73
74 const lastAnalysis = meta.lastAnalysis
75 ? new Date(meta.lastAnalysis).getTime()
76 : 0;
77
78 // Skip if analyzed recently
79 if (lastAnalysis > 0 && Date.now() - lastAnalysis < config.analysisIntervalMs) {
80 skipped++;
81 continue;
82 }
83
84 // Skip if not enough activity since last analysis
85 const activitySince = await countActivitySince(root._id, lastAnalysis);
86 if (activitySince < config.minActivityForAnalysis) {
87 skipped++;
88 continue;
89 }
90
91 const user = await User.findById(root.rootOwner).select("username").lean();
92 if (!user) continue;
93
94 try {
95 await analyzeTree(root._id, root.rootOwner, user.username);
96 analyzed++;
97 } catch (err) {
98 log.debug("Evolution", `Analysis failed for tree "${root.name}": ${err.message}`);
99 }
100 }
101
102 if (analyzed > 0 || skipped > 0) {
103 log.verbose("Evolution", `Analysis sweep: ${analyzed} analyzed, ${skipped} skipped (${roots.length} total trees)`);
104 }
105 } catch (err) {
106 log.error("Evolution", `Analysis job error: ${err.message}`);
107 }
108}
109
110export async function startAnalysisJob() {
111 if (jobTimer) clearInterval(jobTimer);
112 const config = await getEvolutionConfig();
113 jobTimer = setInterval(run, config.analysisIntervalMs);
114 log.info("Evolution", `Analysis job started (interval: ${config.analysisIntervalMs / 3600000}h, min activity: ${config.minActivityForAnalysis})`);
115}
116
117export function stopAnalysisJob() {
118 if (jobTimer) {
119 clearInterval(jobTimer);
120 jobTimer = null;
121 log.info("Evolution", "Analysis job stopped");
122 }
123}
124
1export default {
2 name: "evolution",
3 version: "1.0.1",
4 builtFor: "treeos-intelligence",
5 description:
6 "The tree learns which structures work. A branch that grows, accumulates notes, generates " +
7 "cascade signals, builds codebook entries, gets revisited frequently is a successful pattern. " +
8 "A branch that was created, received two notes, and went dormant for three months is a failed " +
9 "pattern. Tracks structural fitness metrics per node: activityScore, cascadeScore, " +
10 "revisitScore, growthScore, codebookScore, dormancyDays. Periodically runs an analysis pass " +
11 "on the full tree. Identifies patterns. Nodes of type goal that have exactly three children " +
12 "of type task have a 4x higher completion rate than goals with more than ten tasks. Branches " +
13 "deeper than 5 levels have 90% dormancy rates. These patterns are structural DNA. Written to " +
14 "metadata.evolution.patterns on the tree root. When the user creates a new branch, enrichContext " +
15 "injects relevant patterns. The AI says: based on how your other goals evolved, breaking this " +
16 "into three specific tasks works better than listing everything. The tree teaches itself how to " +
17 "grow. Past structure informs future structure. Cross-land evolution through cascade is the long " +
18 "game. When two lands peer and share cascade signals, evolution patterns travel with them. The " +
19 "ecosystem learns collectively which tree shapes work for which purposes.",
20
21 needs: {
22 services: ["llm"],
23 models: ["Node"],
24 },
25
26 optional: {
27 extensions: ["codebook", "long-memory", "propagation"],
28 },
29
30 provides: {
31 models: {},
32 routes: "./routes.js",
33 tools: true,
34 jobs: true,
35 orchestrator: false,
36 energyActions: {},
37 sessionTypes: {},
38 env: [],
39
40 cli: [
41 {
42 command: "evolution [action]", scope: ["tree"],
43 description: "Fitness metrics at current position. Actions: patterns, dormant.",
44 method: "GET",
45 endpoint: "/node/:nodeId/evolution",
46 subcommands: {
47 "patterns": { method: "GET", endpoint: "/root/:rootId/evolution/patterns", description: "Discovered structural patterns" },
48 "dormant": { method: "GET", endpoint: "/root/:rootId/evolution/dormant", description: "Branches that stopped growing" },
49 },
50 },
51 ],
52
53 hooks: {
54 fires: [],
55 listens: ["afterNote", "afterNodeCreate", "afterNavigate", "onCascade", "enrichContext"],
56 },
57 },
58};
59
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { calculateFitness, getPatterns, getDormant } from "./core.js";
5
6const router = express.Router();
7
8// GET /node/:nodeId/evolution - fitness metrics
9router.get("/node/:nodeId/evolution", authenticate, async (req, res) => {
10 try {
11 const fitness = await calculateFitness(req.params.nodeId);
12 if (!fitness) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Node not found");
13 sendOk(res, fitness);
14 } catch (err) {
15 sendError(res, 500, ERR.INTERNAL, err.message);
16 }
17});
18
19// GET /root/:rootId/evolution/patterns - discovered patterns
20router.get("/root/:rootId/evolution/patterns", authenticate, async (req, res) => {
21 try {
22 const patterns = await getPatterns(req.params.rootId);
23 sendOk(res, { count: patterns.length, patterns });
24 } catch (err) {
25 sendError(res, 500, ERR.INTERNAL, err.message);
26 }
27});
28
29// GET /root/:rootId/evolution/dormant - dormant branches
30router.get("/root/:rootId/evolution/dormant", authenticate, async (req, res) => {
31 try {
32 const dormant = await getDormant(req.params.rootId);
33 sendOk(res, { dormantCount: dormant.length, branches: dormant });
34 } catch (err) {
35 sendError(res, 500, ERR.INTERNAL, err.message);
36 }
37});
38
39export default router;
40
1import { z } from "zod";
2import log from "../../seed/log.js";
3import { calculateFitness, getPatterns, getDormant, analyzeTree } from "./core.js";
4
5export default [
6 {
7 name: "node-evolution",
8 description: "Show structural fitness metrics for a node. Activity, cascade, revisit, growth, codebook, and dormancy scores.",
9 schema: {
10 nodeId: z.string().describe("The node to check."),
11 userId: z.string().describe("Injected by server. Ignore."),
12 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
13 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
14 },
15 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
16 handler: async ({ nodeId }) => {
17 try {
18 const fitness = await calculateFitness(nodeId);
19 if (!fitness) return { content: [{ type: "text", text: "Node not found." }] };
20 return { content: [{ type: "text", text: JSON.stringify(fitness, null, 2) }] };
21 } catch (err) {
22 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
23 }
24 },
25 },
26 {
27 name: "evolution-patterns",
28 description: "Show discovered structural patterns for this tree. What configurations correlate with high activity? What structures go dormant?",
29 schema: {
30 rootId: z.string().describe("The tree root."),
31 userId: z.string().describe("Injected by server. Ignore."),
32 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
33 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
34 },
35 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
36 handler: async ({ rootId }) => {
37 try {
38 const patterns = await getPatterns(rootId);
39 if (patterns.length === 0) {
40 return { content: [{ type: "text", text: "No patterns discovered yet. Run evolution-analyze or wait for the periodic analysis." }] };
41 }
42 return { content: [{ type: "text", text: JSON.stringify({ count: patterns.length, patterns }, null, 2) }] };
43 } catch (err) {
44 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
45 }
46 },
47 },
48 {
49 name: "evolution-analyze",
50 description: "Force a full structural analysis of this tree. Calculates fitness for every node and asks the AI to discover patterns. Token-intensive.",
51 schema: {
52 rootId: z.string().describe("The tree root to analyze."),
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: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
58 handler: async ({ rootId, userId }) => {
59 try {
60 let username = null;
61 try {
62 const User = (await import("../../seed/models/user.js")).default;
63 const user = await User.findById(userId).select("username").lean();
64 username = user?.username;
65 } catch (err) {
66 log.debug("Evolution", "username lookup failed:", err.message);
67 }
68 const result = await analyzeTree(rootId, userId, username);
69 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
70 } catch (err) {
71 return { content: [{ type: "text", text: `Analysis failed: ${err.message}` }] };
72 }
73 },
74 },
75 {
76 name: "evolution-suggest",
77 description: "Ask the AI to recommend structural changes based on discovered patterns and the current node's fitness.",
78 schema: {
79 nodeId: z.string().describe("The node to get suggestions for."),
80 rootId: z.string().describe("The tree root (for pattern context)."),
81 userId: z.string().describe("Injected by server. Ignore."),
82 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
83 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
84 },
85 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
86 handler: async ({ nodeId, rootId, userId }) => {
87 try {
88 const fitness = await calculateFitness(nodeId);
89 const patterns = await getPatterns(rootId);
90 if (!fitness) return { content: [{ type: "text", text: "Node not found." }] };
91
92 return {
93 content: [{
94 type: "text",
95 text: JSON.stringify({
96 message: "Here is this node's fitness and the tree's structural patterns. Use these to suggest improvements.",
97 fitness,
98 patterns: patterns.slice(0, 10),
99 }, null, 2),
100 }],
101 };
102 } catch (err) {
103 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
104 }
105 },
106 },
107 {
108 name: "evolution-dormant",
109 description: "List dormant branches that stopped growing and might need pruning or compression.",
110 schema: {
111 rootId: z.string().describe("The tree root."),
112 userId: z.string().describe("Injected by server. Ignore."),
113 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
114 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
115 },
116 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
117 handler: async ({ rootId }) => {
118 try {
119 const dormant = await getDormant(rootId);
120 if (dormant.length === 0) {
121 return { content: [{ type: "text", text: "No dormant branches found. Everything is active." }] };
122 }
123 return {
124 content: [{
125 type: "text",
126 text: JSON.stringify({
127 dormantCount: dormant.length,
128 branches: dormant.map((d) => ({
129 name: d.nodeName,
130 type: d.nodeType,
131 dormantDays: d.dormancyDays,
132 noteCount: d.noteCount,
133 children: d.childCount,
134 })),
135 }, null, 2),
136 }],
137 };
138 } catch (err) {
139 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
140 }
141 },
142 },
143];
144
Loading comments...