EXTENSION for treeos-intelligence
narrative
Layers 4, 5, and 6 of the inner monologue. The tree's sense of self, its voice, and its initiative. From the comparisons, a running narrative emerges. 'This tree is health-focused but imbalanced. The user builds fitness diligently but avoids recovery and delays learning. The tree has been noticing this for three weeks.' Not a summary. An identity. Built from months of compressed observation. Updates monthly. Each update reads the previous narrative and evolves it. The tree doesn't just notice patterns. It knows who it is. Layer 5 (voice): The narrative feeds persona. The tree's inner life shapes how it talks. A tree that's been noticing recovery avoidance for three weeks doesn't just answer questions. Its voice carries that awareness. 'You're asking about leg day but you haven't touched recovery in three weeks. I've been noticing.' Not because someone programmed that prompt. Because the narrative said 'this tree watches the user avoid recovery' and the persona absorbed it. Writes metadata.narrative.voice on the tree root. Not replacing the operator-defined persona. Layering under it. Layer 6 (initiative): The narrative feeds intent. A deeper intent that comes from the tree's own observations across weeks. 'Study queue has been stagnant for three weeks. The user keeps saying they'll start but doesn't. Stop suggesting. Start asking why.' The narrative doesn't generate tool calls. It generates behavioral shifts. The AI's approach changes. It stops pushing study and starts exploring the resistance. That shift comes from three weeks of inner observations compressed into a narrative that says 'pushing isn't working.' Writes metadata.narrative.initiative on the tree root.
v1.0.2 by TreeOS Site 0 downloads 2 files 349 lines 13.9 KB published 38d ago
treeos ext install narrative
View changelog

Manifest

Requires

  • services: hooks, llm
  • models: Node, Note

Optional

  • extensions: breath, inner, reflect-inner, compare-inner, persona
SHA256: 352b14fc6fde2eaf26584c24a5cc18e192458b1bdb21e557f39a1acded4bf19d

Dependents

1 package depend on this

PackageTypeRelationship
treeos-intelligence v1.0.2bundleincludes

Hooks

Listens To

  • breath:exhale
  • enrichContext

Source Code

1/**
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}
294
1export 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

Versions

Version Published Downloads
1.0.2 38d ago 0
1.0.1 46d ago 0
1.0.0 47d ago 0
0 stars
0 flags
React from the CLI: treeos ext star narrative

Comments

Loading comments...

Post comments from the CLI: treeos ext comment narrative "your comment"
Max 3 comments per extension. One star and one flag per user.