1/**
2 * Purpose Core
3 *
4 * One thing. It holds the root purpose of the tree
5 * and measures everything against it.
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, CONTENT_TYPE } from "../../seed/protocol.js";
12import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
13
14let _runChat = null;
15let _metadata = null;
16export function setRunChat(fn) { _runChat = fn; }
17export function setMetadata(m) { _metadata = m; }
18
19// ─────────────────────────────────────────────────────────────────────────
20// CONFIG
21// ─────────────────────────────────────────────────────────────────────────
22
23const DEFAULTS = {
24 rederiveInterval: 100,
25 minNotesBetweenChecks: 3,
26 coherenceThreshold: {
27 high: 0.8,
28 medium: 0.4,
29 },
30};
31
32export async function getPurposeConfig() {
33 const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
34 if (!configNode) return { ...DEFAULTS };
35 const meta = configNode.metadata instanceof Map
36 ? configNode.metadata.get("purpose") || {}
37 : configNode.metadata?.purpose || {};
38 return { ...DEFAULTS, ...meta };
39}
40
41// ─────────────────────────────────────────────────────────────────────────
42// THESIS DERIVATION
43// ─────────────────────────────────────────────────────────────────────────
44
45/**
46 * Derive the thesis for a tree from its root node and early notes.
47 * The thesis is one sentence. The core purpose everything should serve.
48 */
49export async function deriveThesis(rootId, userId) {
50 if (!_runChat) return null;
51
52 const root = await Node.findById(rootId).select("name type metadata").lean();
53 if (!root) return null;
54
55 const rootNotes = await Note.find({
56 nodeId: rootId,
57 contentType: CONTENT_TYPE.TEXT,
58 })
59 .sort({ createdAt: 1 })
60 .limit(10)
61 .select("content")
62 .lean();
63
64 const children = await Node.find({ parent: rootId })
65 .select("name type")
66 .limit(20)
67 .lean();
68
69 const childNames = children.map(c => `${c.name}${c.type ? ` (${c.type})` : ""}`).join(", ");
70
71 const existingMeta = root.metadata instanceof Map
72 ? root.metadata.get("purpose") || {}
73 : root.metadata?.purpose || {};
74 const previousThesis = existingMeta.thesis || null;
75
76 const notesText = rootNotes.length > 0
77 ? rootNotes.map((n, i) => `[${i + 1}] ${n.content.slice(0, 300)}`).join("\n")
78 : "(no notes yet)";
79
80 const previousSection = previousThesis
81 ? `\n\nPrevious thesis (refine, do not discard unless the tree has genuinely changed purpose):\n"${previousThesis}"`
82 : "";
83
84 const prompt =
85 `You are deriving the core purpose of a tree.\n\n` +
86 `Tree name: "${root.name}"${root.type ? ` (type: ${root.type})` : ""}\n` +
87 `Branches: ${childNames || "(none yet)"}\n` +
88 `Root notes:\n${notesText}` +
89 previousSection +
90 `\n\nWhat is this tree's core purpose? State it in one sentence. ` +
91 `This is the thesis everything in this tree should serve. ` +
92 `Be specific. Not "organize information." What specific domain, goal, or intention ` +
93 `does this tree exist to hold? The thesis should expand as the tree grows but never scatter.\n\n` +
94 `Return ONLY the thesis sentence. No explanation. No quotes.`;
95
96 try {
97 const { answer } = await _runChat({
98 userId,
99 username: "system",
100 message: prompt,
101 mode: "tree:respond",
102 rootId,
103 slot: "purpose",
104 });
105
106 if (!answer) return null;
107
108 let thesis = answer.trim().replace(/^["']|["']$/g, "");
109 const firstPeriod = thesis.indexOf(".");
110 if (firstPeriod > 0 && firstPeriod < thesis.length - 1) {
111 thesis = thesis.slice(0, firstPeriod + 1);
112 }
113
114 await Node.findByIdAndUpdate(rootId, {
115 $set: {
116 "metadata.purpose.thesis": thesis,
117 "metadata.purpose.derivedAt": new Date().toISOString(),
118 "metadata.purpose.notesSinceDerivation": 0,
119 },
120 });
121
122 log.verbose("Purpose", `Thesis derived for "${root.name}": ${thesis}`);
123 return thesis;
124 } catch (err) {
125 log.warn("Purpose", `Thesis derivation failed for ${rootId}: ${err.message}`);
126 return null;
127 }
128}
129
130// ─────────────────────────────────────────────────────────────────────────
131// COHERENCE CHECK
132// ─────────────────────────────────────────────────────────────────────────
133
134/**
135 * Check how well a note serves the tree's thesis.
136 * Lightweight AI call. Returns a score 0 to 1.
137 */
138export async function checkCoherence(noteContent, rootId, userId) {
139 if (!_runChat || !noteContent) return null;
140
141 const root = await Node.findById(rootId).select("name metadata").lean();
142 if (!root) return null;
143
144 const meta = root.metadata instanceof Map
145 ? root.metadata.get("purpose") || {}
146 : root.metadata?.purpose || {};
147
148 const thesis = meta.thesis;
149 if (!thesis) return null;
150
151 const prompt =
152 `Tree thesis: "${thesis}"\n\n` +
153 `New note: "${noteContent.slice(0, 500)}"\n\n` +
154 `Does this note serve the thesis? Score 0.0 to 1.0.\n` +
155 `1.0 = directly advances the core purpose.\n` +
156 `0.5 = adjacent, related but not central.\n` +
157 `0.0 = completely unrelated tangent.\n\n` +
158 `Return ONLY a JSON object: { "score": 0.0, "reason": "brief explanation" }`;
159
160 try {
161 const { answer } = await _runChat({
162 userId,
163 username: "system",
164 message: prompt,
165 mode: "tree:respond",
166 rootId,
167 slot: "purpose",
168 });
169
170 if (!answer) return null;
171
172 const result = parseJsonSafe(answer);
173 if (!result || typeof result.score !== "number") return null;
174
175 return {
176 score: Math.max(0, Math.min(1, result.score)),
177 reason: result.reason || null,
178 };
179 } catch (err) {
180 log.debug("Purpose", `Coherence check failed: ${err.message}`);
181 return null;
182 }
183}
184
185/**
186 * Batch coherence check. Scores multiple notes in one LLM call.
187 * Returns array of { noteId, score, reason }.
188 */
189export async function checkCoherenceBatch(notes, rootId, userId) {
190 if (!_runChat || notes.length === 0) return [];
191
192 const root = await Node.findById(rootId).select("name metadata").lean();
193 if (!root) return [];
194
195 const meta = root.metadata instanceof Map
196 ? root.metadata.get("purpose") || {}
197 : root.metadata?.purpose || {};
198
199 const thesis = meta.thesis;
200 if (!thesis) return [];
201
202 const notesList = notes
203 .map((n, i) => `[${i + 1}] (id: ${n.noteId}) "${(n.content || "").slice(0, 300)}"`)
204 .join("\n");
205
206 const prompt =
207 `Tree thesis: "${thesis}"\n\n` +
208 `Score each note against the thesis. 1.0 = directly serves it. 0.0 = unrelated tangent.\n\n` +
209 `Notes:\n${notesList}\n\n` +
210 `Return ONLY a JSON array: [{ "noteId": "...", "score": 0.0, "reason": "brief" }]`;
211
212 try {
213 const { answer } = await _runChat({
214 userId,
215 username: "system",
216 message: prompt,
217 mode: "tree:respond",
218 rootId,
219 slot: "purpose",
220 });
221
222 if (!answer) return [];
223
224 const results = parseJsonSafe(answer);
225 if (!Array.isArray(results)) return [];
226
227 return results
228 .filter(r => r && typeof r.score === "number")
229 .map(r => ({
230 noteId: r.noteId || null,
231 score: Math.max(0, Math.min(1, r.score)),
232 reason: r.reason || null,
233 }));
234 } catch (err) {
235 log.debug("Purpose", `Batch coherence check failed: ${err.message}`);
236 return [];
237 }
238}
239
240// ─────────────────────────────────────────────────────────────────────────
241// THESIS ACCESS
242// ─────────────────────────────────────────────────────────────────────────
243
244export async function getThesis(rootId) {
245 const root = await Node.findById(rootId).select("name metadata").lean();
246 if (!root) return null;
247
248 const meta = root.metadata instanceof Map
249 ? root.metadata.get("purpose") || {}
250 : root.metadata?.purpose || {};
251
252 return {
253 treeName: root.name,
254 thesis: meta.thesis || null,
255 derivedAt: meta.derivedAt || null,
256 notesSinceDerivation: meta.notesSinceDerivation || 0,
257 };
258}
259
260export async function incrementNoteCount(rootId) {
261 await _metadata.incExtMeta(rootId, "purpose", "notesSinceDerivation", 1);
262 // Read back the new count
263 const node = await Node.findById(rootId).select("metadata").lean();
264 if (!node) return 0;
265 const meta = _metadata.getExtMeta(node, "purpose");
266 return meta.notesSinceDerivation || 0;
267}
268
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import {
4 setRunChat,
5 setMetadata,
6 getPurposeConfig,
7 deriveThesis,
8 checkCoherence,
9 checkCoherenceBatch,
10 getThesis,
11 incrementNoteCount,
12} from "./core.js";
13import { CONTENT_TYPE } from "../../seed/protocol.js";
14
15// Per-root pending notes buffer for batched coherence checks.
16// rootId -> [{ noteId, content }]
17const _pending = new Map();
18
19export async function init(core) {
20 core.llm.registerRootLlmSlot("purpose");
21 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
22 setRunChat(async (opts) => {
23 if (opts.userId && opts.userId !== "SYSTEM" && !await core.llm.userHasLlm(opts.userId)) return { answer: null };
24 return core.llm.runChat({ ...opts, llmPriority: BG });
25 });
26 setMetadata(core.metadata);
27
28 const config = await getPurposeConfig();
29
30 // ── afterNote: coherence check + thesis re-derivation counter ──────
31 core.hooks.register("afterNote", async ({ note, nodeId, userId, contentType, action }) => {
32 if (contentType !== CONTENT_TYPE.TEXT) return;
33 if (action !== "create") return;
34 if (!userId || userId === "SYSTEM") return;
35
36 // Find the tree root for this node
37 let rootId;
38 try {
39 const Node = core.models.Node;
40 const node = await Node.findById(nodeId).select("systemRole rootOwner parent").lean();
41 if (!node || node.systemRole) return;
42
43 if (node.rootOwner) {
44 rootId = nodeId;
45 } else {
46 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
47 const root = await resolveRootNode(nodeId);
48 rootId = root?._id;
49 }
50 } catch { return; }
51 if (!rootId) return;
52
53 // Check if thesis exists. If not and we have enough notes, derive it.
54 const thesis = await getThesis(rootId);
55 if (!thesis?.thesis) {
56 const Note = core.models.Note;
57 const noteCount = await Note.countDocuments({ nodeId: rootId, contentType: CONTENT_TYPE.TEXT });
58 if (noteCount >= 3) {
59 deriveThesis(rootId, userId).catch(err =>
60 log.debug("Purpose", `Auto-derivation failed: ${err.message}`)
61 );
62 }
63 return;
64 }
65
66 // Increment counter, re-derive if threshold hit
67 const count = await incrementNoteCount(rootId);
68 if (count >= config.rederiveInterval) {
69 deriveThesis(rootId, userId).catch(err =>
70 log.debug("Purpose", `Re-derivation failed: ${err.message}`)
71 );
72 }
73
74 // Batch coherence: accumulate notes, check every minNotesBetweenChecks.
75 // One LLM call scores all pending notes instead of one call per note.
76 const content = note.content || "";
77 if (content.length < 20) return;
78
79 const noteId = (note._id || note.id).toString();
80 if (!_pending.has(rootId)) _pending.set(rootId, []);
81 _pending.get(rootId).push({ noteId, content });
82
83 if (_pending.get(rootId).length < config.minNotesBetweenChecks) return;
84
85 // Flush the batch
86 const batch = _pending.get(rootId).splice(0);
87 _pending.delete(rootId);
88
89 checkCoherenceBatch(batch, rootId, userId)
90 .then(async (results) => {
91 const Note = core.models.Note;
92 for (const r of results) {
93 if (!r.noteId) continue;
94 await Note.findByIdAndUpdate(r.noteId, {
95 $set: {
96 "metadata.purpose.coherence": r.score,
97 "metadata.purpose.reason": r.reason,
98 },
99 });
100 }
101 })
102 .catch(err => log.debug("Purpose", `Batch coherence check failed: ${err.message}`));
103 }, "purpose");
104
105 // ── afterNodeMove: coherence scores are against the old tree's thesis ─
106 core.hooks.register("afterNodeMove", async ({ nodeId, oldParentId, newParentId }) => {
107 try {
108 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
109 const oldRoot = await resolveRootNode(oldParentId);
110 const newRoot = await resolveRootNode(newParentId);
111 // Same tree move: no thesis change
112 if (oldRoot?._id?.toString() === newRoot?._id?.toString()) return;
113
114 // Cross-tree move: clear stale coherence scores on moved node's notes
115 const Note = core.models.Note;
116 await Note.updateMany(
117 { nodeId, "metadata.purpose.coherence": { $exists: true } },
118 { $unset: { "metadata.purpose.coherence": "", "metadata.purpose.reason": "" } },
119 );
120 } catch {}
121 }, "purpose");
122
123 // ── enrichContext: surface coherence signals ────────────────────────
124 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
125 // At tree root: show the thesis
126 if (node.rootOwner) {
127 const purpose = meta.purpose;
128 if (purpose?.thesis) {
129 context.treeThesis = purpose.thesis;
130 }
131 return;
132 }
133
134 // At any node: if the most recent note has a low coherence score, signal it
135 const Note = core.models.Note;
136 const recentNote = await Note.findOne({
137 nodeId: node._id,
138 contentType: CONTENT_TYPE.TEXT,
139 "metadata.purpose.coherence": { $exists: true },
140 })
141 .sort({ createdAt: -1 })
142 .select("metadata")
143 .lean();
144
145 if (!recentNote) return;
146
147 const coherence = recentNote.metadata instanceof Map
148 ? recentNote.metadata.get("purpose")?.coherence
149 : recentNote.metadata?.purpose?.coherence;
150
151 if (coherence === undefined || coherence === null) return;
152
153 // Also get the thesis for context
154 let rootId;
155 try {
156 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
157 const root = await resolveRootNode(node._id);
158 rootId = root?._id;
159 } catch { return; }
160
161 const thesis = await getThesis(rootId);
162
163 if (coherence >= config.coherenceThreshold.high) {
164 // On-thesis. No signal needed.
165 return;
166 }
167
168 if (coherence >= config.coherenceThreshold.medium) {
169 context.purposeSignal = {
170 coherence,
171 thesis: thesis?.thesis,
172 message: "Recent content here is loosely connected to the tree's core purpose. It might belong here or it might be the seed of a new tree.",
173 };
174 } else {
175 context.purposeSignal = {
176 coherence,
177 thesis: thesis?.thesis,
178 message: "Recent content here does not align with this tree's purpose. Consider moving it or starting a new tree for this topic.",
179 };
180 }
181 }, "purpose");
182
183 const { default: router } = await import("./routes.js");
184
185 return {
186 router,
187 tools,
188 exports: {
189 deriveThesis,
190 checkCoherence,
191 getThesis,
192 },
193 };
194}
195
1export default {
2 name: "purpose",
3 version: "1.0.2",
4 builtFor: "TreeOS",
5 description:
6 "Holds the root purpose of the tree and measures everything against it. Every other " +
7 "extension makes the tree smarter, faster, more aware, more autonomous. None of them " +
8 "ask the question that matters most: is this tree still about what it was planted to be. " +
9 "\n\n" +
10 "When the tree is new and accumulates its first few notes, purpose reads the root node " +
11 "name, type, branch names, and early note content. An LLM call derives a thesis: the " +
12 "core purpose of this tree in one sentence. Not 'organize information.' What specific " +
13 "domain, goal, or intention does this tree exist to hold. The thesis writes to " +
14 "metadata.purpose.thesis on the tree root. Every 100 notes (configurable via " +
15 "rederiveInterval), the thesis re-derives from the current state. It reads the existing " +
16 "thesis and refines it. The tree might grow from 'track my fitness' to 'holistic health " +
17 "management' but it never drifts to 'random notes about everything.' The thesis expands. " +
18 "It does not scatter. " +
19 "\n\n" +
20 "afterNote fires a coherence check on every new text note. Notes are batched " +
21 "(configurable via minNotesBetweenChecks, default 3) and scored in a single LLM call " +
22 "against the thesis. Each note gets a score from 0 to 1 and a reason, written to " +
23 "note metadata. High coherence (0.8+): on-thesis, no signal needed. Medium (0.4 to 0.8): " +
24 "adjacent, drifting. enrichContext injects a gentle signal: recent content here is " +
25 "loosely connected to the tree's core purpose. It might belong here or it might be the " +
26 "seed of a new tree. Low (below 0.4): tangent. The AI suggests moving it or starting " +
27 "a new tree for this topic. Never blocking. Never restricting. Just holding the mirror. " +
28 "\n\n" +
29 "Three MCP tools. tree-thesis shows the current thesis and derivation stats. " +
30 "rederive-thesis forces re-derivation from the current tree state. check-coherence " +
31 "scores arbitrary text against the thesis before the user even writes the note. " +
32 "Two CLI commands mirror the first two tools. Two HTTP endpoints at " +
33 "/root/:rootId/thesis for reading and /root/:rootId/thesis/rederive for forcing. " +
34 "\n\n" +
35 "enrichContext works at two levels. At the tree root, it surfaces the thesis itself " +
36 "so the AI can reference it in conversation. At any child node, it finds the most " +
37 "recent note with a coherence score and, if the score is below the high threshold, " +
38 "injects the purpose signal with the thesis and the drift observation. The AI sees " +
39 "it and can surface it naturally. " +
40 "\n\n" +
41 "Purpose prevents the slow death of drift. It holds. Gently. Persistently. " +
42 "Without letting go.",
43
44 needs: {
45 services: ["llm", "hooks"],
46 models: ["Node", "Note"],
47 },
48
49 optional: {
50 services: ["energy"],
51 },
52
53 provides: {
54 models: {},
55 routes: "./routes.js",
56 tools: true,
57 jobs: false,
58 orchestrator: false,
59 energyActions: {},
60 sessionTypes: {},
61 env: [],
62
63 cli: [
64 {
65 command: "thesis", scope: ["tree"],
66 description: "Show this tree's root thesis and coherence stats",
67 method: "GET",
68 endpoint: "/root/:rootId/thesis",
69 },
70 {
71 command: "thesis-rederive",
72 description: "Force re-derivation of the thesis from current tree state",
73 method: "POST",
74 endpoint: "/root/:rootId/thesis/rederive",
75 },
76 ],
77
78 hooks: {
79 fires: [],
80 listens: ["afterNote", "enrichContext"],
81 },
82 },
83};
84
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getThesis, deriveThesis } from "./core.js";
5import User from "../../seed/models/user.js";
6
7const router = express.Router();
8
9// GET /root/:rootId/thesis
10router.get("/root/:rootId/thesis", authenticate, async (req, res) => {
11 try {
12 const data = await getThesis(req.params.rootId);
13 if (!data) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
14 sendOk(res, data);
15 } catch (err) {
16 sendError(res, 500, ERR.INTERNAL, err.message);
17 }
18});
19
20// POST /root/:rootId/thesis/rederive
21router.post("/root/:rootId/thesis/rederive", authenticate, async (req, res) => {
22 try {
23 const thesis = await deriveThesis(req.params.rootId, req.userId);
24 if (!thesis) return sendOk(res, { message: "Could not derive thesis" });
25 sendOk(res, { thesis });
26 } catch (err) {
27 sendError(res, 500, ERR.INTERNAL, err.message);
28 }
29});
30
31export default router;
32
1import { z } from "zod";
2import { getThesis, deriveThesis, checkCoherence } from "./core.js";
3
4export default [
5 {
6 name: "tree-thesis",
7 description: "Show this tree's root thesis and coherence stats. The one sentence everything in this tree should serve.",
8 schema: {
9 rootId: z.string().describe("The tree root."),
10 userId: z.string().describe("Injected by server. Ignore."),
11 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
12 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
13 },
14 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
15 handler: async ({ rootId }) => {
16 try {
17 const data = await getThesis(rootId);
18 if (!data || !data.thesis) {
19 return { content: [{ type: "text", text: "No thesis derived yet. Write some notes at the root and the thesis will emerge." }] };
20 }
21 return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
22 } catch (err) {
23 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
24 }
25 },
26 },
27 {
28 name: "rederive-thesis",
29 description: "Force re-derivation of the thesis from the current tree state. The thesis evolves but always connects to the root.",
30 schema: {
31 rootId: z.string().describe("The tree root."),
32 userId: z.string().describe("Injected by server. Ignore."),
33 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
34 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
35 },
36 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
37 handler: async ({ rootId, userId }) => {
38 try {
39 const thesis = await deriveThesis(rootId, userId);
40 if (!thesis) return { content: [{ type: "text", text: "Could not derive thesis. Check that notes exist at the root." }] };
41 return { content: [{ type: "text", text: `Thesis: ${thesis}` }] };
42 } catch (err) {
43 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
44 }
45 },
46 },
47 {
48 name: "check-coherence",
49 description: "Check how well specific text serves this tree's thesis. Returns a score 0 to 1.",
50 schema: {
51 rootId: z.string().describe("The tree root."),
52 text: z.string().describe("The text to check against the thesis."),
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: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
58 handler: async ({ rootId, text, userId }) => {
59 try {
60 const result = await checkCoherence(text, rootId, userId);
61 if (!result) return { content: [{ type: "text", text: "No thesis available. Derive one first." }] };
62 return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
63 } catch (err) {
64 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
65 }
66 },
67 },
68];
69
Loading comments...