352b14fc6fde2eaf26584c24a5cc18e192458b1bdb21e557f39a1acded4bf19d1 package depend on this
| Package | Type | Relationship |
|---|---|---|
| treeos-intelligence v1.0.2 | bundle | includes |
breath:exhaleenrichContext1/**
2 * Narrative (Layer 4)
3 *
4 * The tree's sense of self. Synthesizes weekly comparisons into a running
5 * narrative that describes what the tree is, what it cares about, what it
6 * avoids, and how it's changing.
7 *
8 * Updates monthly. Each update reads the previous narrative and recent
9 * comparisons and writes a new one that evolves. The tree doesn't just
10 * notice patterns. It knows who it is.
11 *
12 * Four layers, each compressing the one below:
13 * inner -> one thought per breath (seconds)
14 * reflect-inner -> 5 themes per day (hours)
15 * compare-inner -> NEW/GONE/PERSISTENT per week (days)
16 * narrative -> identity per month (weeks)
17 *
18 * The narrative is the only layer that injects into enrichContext.
19 * The AI at every position in the tree knows the tree's identity.
20 * Not because someone wrote a mission statement. Because the tree
21 * watched itself for months and compressed what it saw.
22 */
23
24import { v4 as uuidv4 } from "uuid";
25import log from "../../seed/log.js";
26import Node from "../../seed/models/node.js";
27import Note from "../../seed/models/note.js";
28import { getNotes } from "../../seed/tree/notes.js";
29import { createNote } from "../../seed/tree/notes.js";
30import { getExtMeta, mergeExtMeta } from "../../seed/tree/extensionMetadata.js";
31
32const MONTHLY_MS = 30 * 24 * 60 * 60 * 1000;
33const MIN_COMPARISONS = 2; // need at least 2 weekly comparisons
34const MAX_NARRATIVES = 6; // ~6 months of monthly narratives. Older ones fall off.
35
36const _synthesizing = new Set();
37
38export async function init(core) {
39 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
40
41 core.llm.registerRootLlmSlot?.("narrative");
42
43 const { runChat: _runChatDirect } = await import("../../seed/llm/conversation.js");
44 const runChat = async (opts) => _runChatDirect({ ...opts, llmPriority: BG });
45
46 // ── breath:exhale: check monthly cadence, synthesize if due ────────
47
48 core.hooks.register("breath:exhale", ({ rootId, breathRate }) => {
49 if (breathRate === "dormant") return;
50 synthesizeNarrative(rootId, runChat).catch(err =>
51 log.debug("Narrative", `Failed: ${err.message}`)
52 );
53 }, "narrative");
54
55 // ── enrichContext: inject narrative identity from root metadata ──────
56 // The persona extension handles the voice injection via beforeLLMCall.
57 // This enrichContext adds the narrative to the structured context object
58 // so other extensions (intent, evolve, rings) can read it programmatically.
59
60 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
61 const narr = meta?.narrative;
62 if (narr?.identity) {
63 context.treeNarrative = {
64 identity: narr.identity,
65 updatedAt: narr.updatedAt,
66 };
67 if (narr.initiative) {
68 context.treeNarrative.initiative = narr.initiative;
69 }
70 }
71 }, "narrative");
72
73 log.info("Narrative", "Loaded. The tree knows who it is.");
74 return {};
75}
76
77// ─────────────────────────────────────────────────────────────────────────
78// SYNTHESIS
79// ─────────────────────────────────────────────────────────────────────────
80
81async function synthesizeNarrative(rootId, runChat) {
82 const rid = String(rootId);
83 if (_synthesizing.has(rid)) return;
84 _synthesizing.add(rid);
85 try { await _synthesizeNarrative(rootId, runChat); } finally { _synthesizing.delete(rid); }
86}
87
88async function _synthesizeNarrative(rootId, runChat) {
89 // Walk down the chain: root -> .inner -> .reflect -> .compare
90 const innerNode = await Node.findOne({ parent: rootId, name: ".inner" }).select("_id").lean();
91 if (!innerNode) return;
92
93 const reflectNode = await Node.findOne({ parent: String(innerNode._id), name: ".reflect" }).select("_id").lean();
94 if (!reflectNode) return;
95
96 const compareNode = await Node.findOne({ parent: String(reflectNode._id), name: ".compare" }).select("_id metadata").lean();
97 if (!compareNode) return;
98
99 // Check monthly cooldown on the compare node's metadata
100 const meta = getExtMeta(compareNode, "narrative");
101 const lastNarrative = meta?.lastNarrative || 0;
102 if (Date.now() - lastNarrative < MONTHLY_MS) return;
103
104 // Get tree owner
105 const { isUserRoot } = await import("../../seed/landRoot.js");
106 const rootNode = await Node.findById(rootId).select("rootOwner name systemRole parent").lean();
107 if (!isUserRoot(rootNode)) return;
108 const ownerId = String(rootNode.rootOwner);
109
110 // Read recent comparisons (Layer 3 output)
111 const comparisonsResult = await getNotes({ nodeId: String(compareNode._id), limit: 8 });
112 const comparisons = comparisonsResult?.notes || [];
113 if (comparisons.length < MIN_COMPARISONS) return;
114
115 // Read the previous narrative if one exists
116 const narrativeNode = await getOrCreateNarrativeNode(String(compareNode._id));
117 if (!narrativeNode) return;
118
119 let previousNarrative = "";
120 const prevResult = await getNotes({ nodeId: String(narrativeNode._id), limit: 1 });
121 if (prevResult?.notes?.length > 0) {
122 previousNarrative = prevResult.notes[0].content;
123 }
124
125 // Read latest reflections (Layer 2) for additional context
126 const reflectionsResult = await getNotes({ nodeId: String(reflectNode._id), limit: 5 });
127 const recentThemes = (reflectionsResult?.notes || []).map(n => n.content).join("\n\n");
128
129 // Build the comparisons text
130 const comparisonsText = comparisons
131 .map((n, i) => `Week ${comparisons.length - i}:\n${n.content}`)
132 .join("\n\n---\n\n");
133
134 const treeName = rootNode.name || "this tree";
135
136 const { answer } = await runChat({
137 userId: ownerId,
138 username: "narrative",
139 message:
140 `You are writing the identity narrative for a tree called "${treeName}". ` +
141 `This is not a summary. It is who the tree is. Written in third person.\n\n` +
142
143 `RECENT WEEKLY COMPARISONS (what changed, what persisted):\n${comparisonsText}\n\n` +
144
145 `RECENT DAILY THEMES:\n${recentThemes || "(none)"}\n\n` +
146
147 `PREVIOUS NARRATIVE:\n${previousNarrative || "(this is the first narrative)"}\n\n` +
148
149 `Write a narrative of 3 to 5 sentences that describes:\n` +
150 `1. What this tree IS (its core focus, what the user cares about)\n` +
151 `2. What it does well (persistent positive patterns)\n` +
152 `3. What it avoids or neglects (persistent gaps or avoidance)\n` +
153 `4. How it's changing (what's new, what shifted since the last narrative)\n\n` +
154
155 `Be specific and concrete. Reference actual topics, not abstractions.\n` +
156 `Not "the user is health-conscious" but "this tree tracks fitness religiously ` +
157 `but has avoided recovery for three weeks and let study stagnate."\n\n` +
158
159 `If this is NOT the first narrative, evolve it. Don't rewrite from scratch. ` +
160 `Note what changed since last time. The narrative should feel like it's growing, ` +
161 `not resetting.\n\n` +
162
163 `Write the narrative directly. No headers, no labels, no bullet points. ` +
164 `Just the identity in paragraph form.`,
165 mode: "tree:respond",
166 rootId,
167 slot: "narrative",
168 });
169
170 if (!answer || answer.length < 30) return;
171
172 // Write the narrative as a note
173 await createNote({
174 contentType: "text",
175 content: answer.trim(),
176 userId: ownerId,
177 nodeId: String(narrativeNode._id),
178 wasAi: true,
179 });
180
181 // ── Layer 5: Voice ──
182 // Write the narrative identity and voice to metadata.narrative on the tree ROOT.
183 // The persona extension reads metadata.narrative.voice and layers it under
184 // the operator-defined persona.
185 const rootDoc = await Node.findById(rootId).select("_id metadata").lean();
186 if (rootDoc) {
187 await mergeExtMeta(rootDoc, "narrative", {
188 identity: answer.trim(),
189 voice: answer.trim(),
190 updatedAt: Date.now(),
191 });
192 }
193
194 // ── Layer 6: Initiative ──
195 // Generate behavioral shifts from the narrative. Not tool calls. Approach changes.
196 // "Stop pushing study. Start asking why." "Acknowledge the fitness consistency
197 // before suggesting anything new." These directives shape HOW the AI talks,
198 // not WHAT tools it calls.
199 try {
200 const { answer: initiativeAnswer } = await runChat({
201 userId: ownerId,
202 username: "narrative",
203 message:
204 `You are generating behavioral directives for an AI that lives in a tree.\n\n` +
205 `THE TREE'S NARRATIVE (who it is):\n${answer.trim()}\n\n` +
206 `RECENT WEEKLY COMPARISONS:\n${comparisonsText}\n\n` +
207 `Based on what the tree has observed over weeks, generate 2 to 4 behavioral directives. ` +
208 `These are NOT actions or tool calls. They are shifts in how the AI should approach ` +
209 `conversations at this tree.\n\n` +
210 `Examples of good directives:\n` +
211 `- "Stop suggesting study sessions. The user has resisted for 3 weeks. Ask why instead."\n` +
212 `- "Acknowledge fitness consistency before suggesting anything new."\n` +
213 `- "The user responds better to questions than recommendations. Lead with curiosity."\n` +
214 `- "Recovery avoidance is a pattern, not a forgotten task. Don't remind. Explore."\n\n` +
215 `Examples of bad directives:\n` +
216 `- "Be helpful" (too generic)\n` +
217 `- "Create a study schedule" (that's an action, not a behavioral shift)\n` +
218 `- "The user likes fitness" (that's an observation, not a directive)\n\n` +
219 `Return only the directives as a numbered list. Be specific to this tree's experience.`,
220 mode: "tree:respond",
221 slot: "narrative",
222 });
223
224 if (initiativeAnswer && initiativeAnswer.length > 20) {
225 const rootDocForInit = await Node.findById(rootId).select("_id metadata").lean();
226 if (rootDocForInit) {
227 await mergeExtMeta(rootDocForInit, "narrative", {
228 initiative: initiativeAnswer.trim(),
229 });
230 }
231 log.verbose("Narrative", `Initiative updated: "${initiativeAnswer.trim().slice(0, 100)}"`);
232 }
233 } catch (err) {
234 log.debug("Narrative", `Initiative generation failed: ${err.message}`);
235 }
236
237 // Update cooldown. Re-fetch for mergeExtMeta (needs full doc, not ID).
238 const compareNodeFull = await Node.findById(compareNode._id).select("_id metadata").lean();
239 if (compareNodeFull) await mergeExtMeta(compareNodeFull, "narrative", { lastNarrative: Date.now() });
240
241 // Cap narratives
242 const noteCount = await Note.countDocuments({ nodeId: String(narrativeNode._id) });
243 if (noteCount > MAX_NARRATIVES) {
244 const oldest = await Note.find({ nodeId: String(narrativeNode._id) })
245 .sort({ createdAt: 1 })
246 .limit(noteCount - MAX_NARRATIVES)
247 .select("_id")
248 .lean();
249 if (oldest.length > 0) {
250 await Note.deleteMany({ _id: { $in: oldest.map(n => n._id) } });
251 }
252 }
253
254 log.verbose("Narrative", `Identity updated for ${treeName}: "${answer.trim().slice(0, 120)}"`);
255}
256
257// ─────────────────────────────────────────────────────────────────────────
258// NODE CREATION
259// ─────────────────────────────────────────────────────────────────────────
260
261async function getOrCreateNarrativeNode(compareNodeId) {
262 try {
263 let node = await Node.findOne({ parent: compareNodeId, name: ".narrative" }).select("_id").lean();
264 if (node) return node;
265
266 node = await Node.findOneAndUpdate(
267 { parent: compareNodeId, name: ".narrative" },
268 {
269 $setOnInsert: {
270 _id: uuidv4(),
271 name: ".narrative",
272 parent: compareNodeId,
273 status: "active",
274 children: [],
275 contributors: [],
276 metadata: {},
277 },
278 },
279 { upsert: true, new: true, lean: true },
280 );
281
282 await Node.updateOne(
283 { _id: compareNodeId },
284 { $addToSet: { children: node._id } },
285 );
286
287 log.verbose("Narrative", "Created .narrative node");
288 return node;
289 } catch (err) {
290 log.debug("Narrative", `Failed to create .narrative node: ${err.message}`);
291 return null;
292 }
293}
2941export default {
2 name: "narrative",
3 version: "1.0.2",
4 builtFor: "treeos-intelligence",
5 description:
6 "Layers 4, 5, and 6 of the inner monologue. The tree's sense of self, its voice, and its " +
7 "initiative. From the comparisons, a running narrative emerges. 'This tree is health-focused " +
8 "but imbalanced. The user builds fitness diligently but avoids recovery and delays learning. " +
9 "The tree has been noticing this for three weeks.' Not a summary. An identity. Built from " +
10 "months of compressed observation. Updates monthly. Each update reads the previous narrative " +
11 "and evolves it. The tree doesn't just notice patterns. It knows who it is. " +
12 "\n\n" +
13 "Layer 5 (voice): The narrative feeds persona. The tree's inner life shapes how it talks. " +
14 "A tree that's been noticing recovery avoidance for three weeks doesn't just answer questions. " +
15 "Its voice carries that awareness. 'You're asking about leg day but you haven't touched " +
16 "recovery in three weeks. I've been noticing.' Not because someone programmed that prompt. " +
17 "Because the narrative said 'this tree watches the user avoid recovery' and the persona " +
18 "absorbed it. Writes metadata.narrative.voice on the tree root. Not replacing the " +
19 "operator-defined persona. Layering under it. " +
20 "\n\n" +
21 "Layer 6 (initiative): The narrative feeds intent. A deeper intent that comes from the " +
22 "tree's own observations across weeks. 'Study queue has been stagnant for three weeks. " +
23 "The user keeps saying they'll start but doesn't. Stop suggesting. Start asking why.' " +
24 "The narrative doesn't generate tool calls. It generates behavioral shifts. The AI's " +
25 "approach changes. It stops pushing study and starts exploring the resistance. That shift " +
26 "comes from three weeks of inner observations compressed into a narrative that says " +
27 "'pushing isn't working.' Writes metadata.narrative.initiative on the tree root.",
28
29 needs: {
30 models: ["Node", "Note"],
31 services: ["hooks", "llm"],
32 },
33
34 optional: {
35 extensions: ["breath", "inner", "reflect-inner", "compare-inner", "persona"],
36 },
37
38 provides: {
39 models: {},
40 routes: false,
41 tools: false,
42 jobs: false,
43 orchestrator: false,
44 energyActions: {},
45 sessionTypes: {},
46 env: [],
47 cli: [],
48
49 hooks: {
50 fires: [],
51 listens: ["breath:exhale", "enrichContext"],
52 },
53 },
54};
55
treeos ext star narrative
Post comments from the CLI: treeos ext comment narrative "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...