EXTENSION for TreeOS
rings
The tree remembers every age it has been. Once per month, rings takes a cross-section of the entire tree: structure, vitals, and an AI-generated character portrait. Monthly rings keep full detail for two years. Then they compress into annual rings. The annual ring absorbs twelve monthlies into one denser summary. Annual rings persist forever. Each ring captures what every installed extension knows: evolution metrics, thesis coherence, contradiction resolution, codebook vocabulary, prune history, phase ratios, cascade signal counts, and topic clusters. One LLM call synthesizes a character portrait and a one-sentence essence. The AI at any position knows the tree's age, its current character, and what came before. The further back, the less detail, but the character persists. Like tree rings. Like human memory. Yesterday is vivid. Last month is a summary. Five years ago is a feeling and a few key moments. But each period shaped who the tree is.
v1.0.1 by TreeOS Site 0 downloads 4 files 796 lines 26.9 KB published 38d ago
treeos ext install rings
View changelog

Manifest

Provides

  • routes
  • tools
  • 2 CLI commands

Requires

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

Optional

  • extensions: evolution, purpose, contradiction, codebook, prune, remember, changelog, phase, inverse-tree, embed, explore, breath
SHA256: ed60aa3d0096e3f736a4b79fc0504fa9dc8f6f64c75a8a5fb16cbe65b9fa5558

Dependents

1 package depend on this

PackageTypeRelationship
treeos-intelligence v1.0.2bundleincludes

CLI Commands

CommandMethodDescription
ringsGETShow tree ring history (annual + recent monthly)
rings currentGETCurrent month character assembled from live data

Source Code

1/**
2 * Rings Core
3 *
4 * The ring forms from the inside out, driven by the tree's own rhythm.
5 * Not by the calendar. By the life.
6 *
7 * Four phases: growth, peak, hardening, dormant.
8 * Phase detection is rate-based, not threshold-based.
9 * The ring solidifies when the tree completes a full cycle.
10 *
11 * Activity rate rising for 2+ weeks:     growth
12 * Activity rate high and stable:         peak
13 * Activity rate declining for 2+ weeks:  hardening
14 * Activity rate near zero for 1+ week:   dormant
15 */
16
17import log from "../../seed/log.js";
18import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
19
20let _Node = null;
21let _Note = null;
22let _runChat = null;
23let _metadata = null;
24let _getExtension = null;
25
26export function configure({ Node, Note, runChat, metadata, getExtension }) {
27  _Node = Node;
28  _Note = Note;
29  _runChat = runChat;
30  _metadata = metadata;
31  _getExtension = getExtension;
32}
33
34const RINGS_NODE_NAME = ".rings";
35const PHASE_WINDOW_DAYS = 14;        // rate measured over 2 weeks
36const DORMANT_THRESHOLD_DAYS = 7;    // near-zero activity for 1 week
37const MAX_RING_AGE_MONTHS = 12;      // force hardening after 12 months without dormancy
38const RATE_SAMPLES = 4;              // compare last 4 rate windows
39
40// ── Phase detection ──
41
42/**
43 * Detect phase from activity rate history.
44 * rates: array of recent activity counts per window (newest first)
45 */
46export function detectPhase(rates) {
47  if (!rates || rates.length < 2) return "growth"; // not enough data
48
49  const current = rates[0];
50  const previous = rates[1];
51  const avg = rates.reduce((a, b) => a + b, 0) / rates.length;
52
53  // Dormant: current rate near zero
54  if (current <= 1) return "dormant";
55
56  // Growth: rate increasing
57  if (current > previous * 1.2 && current > avg * 0.8) return "growth";
58
59  // Hardening: rate declining
60  if (current < previous * 0.8 && current < avg) return "hardening";
61
62  // Peak: rate high and stable
63  return "peak";
64}
65
66// ── Ring state management ──
67
68export function getDefaultRingState() {
69  return {
70    started: new Date().toISOString(),
71    phase: "growth",
72    accumulator: {
73      notesWritten: 0,
74      nodesCreated: 0,
75      nodesLost: 0,
76      cascadeSignals: 0,
77      contradictions: 0,
78      topicMentions: {},
79    },
80    rateSamples: [],   // activity counts per window, newest first
81    phaseHistory: [
82      { phase: "growth", started: new Date().toISOString(), duration: null },
83    ],
84    hardeningProgress: 0,  // 0-4 (weeks of hardening work)
85    character: null,
86    essence: null,
87  };
88}
89
90// ── Find or create .rings node ──
91
92async function getRingsNode(rootId, userId) {
93  const children = await _Node.find({ parent: rootId }).select("_id name systemRole").lean();
94  let ringsNode = children.find(c => c.name === RINGS_NODE_NAME);
95  if (!ringsNode) {
96    const { createSystemNode } = await import("../../seed/tree/treeManagement.js");
97    ringsNode = await createSystemNode({ name: RINGS_NODE_NAME, parentId: rootId });
98    log.verbose("Rings", `Created ${RINGS_NODE_NAME} node under ${String(rootId).slice(0, 8)}`);
99  }
100  return ringsNode;
101}
102
103// ── Read completed rings ──
104
105export async function getRings(rootId) {
106  const children = await _Node.find({ parent: rootId }).select("_id name").lean();
107  const ringsNode = children.find(c => c.name === RINGS_NODE_NAME);
108  if (!ringsNode) return { rings: [], annual: [] };
109
110  const notes = await _Note.find({ nodeId: ringsNode._id, contentType: "text" })
111    .sort({ createdAt: -1 }).lean();
112
113  const rings = [];
114  const annual = [];
115
116  for (const note of notes) {
117    try {
118      const ring = JSON.parse(note.content);
119      if (ring.period === "year") annual.push(ring);
120      else if (ring.period === "ring") rings.push(ring);
121    } catch {}
122  }
123
124  return { rings, annual };
125}
126
127// ── Increment accumulator (called from hooks) ──
128
129export async function incrementAccumulator(rootId, field, amount = 1) {
130  if (!_metadata) return;
131  try {
132    const root = await _Node.findById(rootId).select("metadata").lean();
133    if (!root) return;
134    const ringState = _metadata.getExtMeta(root, "rings");
135    if (!ringState?.accumulator) return;
136    await _metadata.incExtMeta(rootId, "rings", `accumulator.${field}`, amount);
137  } catch {}
138}
139
140export async function addTopicMention(rootId, word) {
141  if (!_metadata || !word) return;
142  try {
143    const root = await _Node.findById(rootId).select("metadata").lean();
144    if (!root) return;
145    const ringState = _metadata.getExtMeta(root, "rings");
146    if (!ringState?.accumulator) return;
147    const mentions = ringState.accumulator.topicMentions || {};
148    mentions[word] = (mentions[word] || 0) + 1;
149    await _metadata.mergeExtMeta(root, "rings", { accumulator: { ...ringState.accumulator, topicMentions: mentions } });
150  } catch {}
151}
152
153// ── Exhale tick: update rate, detect phase, progress hardening ──
154
155export async function onExhale(rootId, userId, username) {
156  if (!_Node || !_metadata) return;
157
158  const root = await _Node.findById(rootId).select("metadata dateCreated children").lean();
159  if (!root) return;
160
161  let ringState = _metadata.getExtMeta(root, "rings");
162
163  // Initialize ring state if missing
164  if (!ringState || !ringState.started) {
165    ringState = getDefaultRingState();
166    await _metadata.setExtMeta(root, "rings", ringState);
167    return;
168  }
169
170  // Calculate current activity rate (total accumulator activity)
171  const acc = ringState.accumulator || {};
172  const currentRate = (acc.notesWritten || 0) + (acc.nodesCreated || 0) + (acc.cascadeSignals || 0);
173
174  // Add rate sample (keep last RATE_SAMPLES)
175  const rates = [currentRate, ...(ringState.rateSamples || [])].slice(0, RATE_SAMPLES);
176
177  // Detect phase
178  const newPhase = detectPhase(rates);
179  const oldPhase = ringState.phase;
180
181  // Phase transition
182  if (newPhase !== oldPhase) {
183    const history = ringState.phaseHistory || [];
184    // Close previous phase
185    if (history.length > 0) {
186      const last = history[history.length - 1];
187      if (!last.duration) {
188        const started = new Date(last.started);
189        last.duration = `${Math.round((Date.now() - started.getTime()) / (24 * 60 * 60 * 1000))} days`;
190      }
191    }
192    // Open new phase
193    history.push({ phase: newPhase, started: new Date().toISOString(), duration: null });
194    ringState.phaseHistory = history.slice(-20); // cap history
195    ringState.phase = newPhase;
196
197    log.verbose("Rings", `${String(rootId).slice(0, 8)} phase: ${oldPhase} -> ${newPhase}`);
198  }
199
200  ringState.rateSamples = rates;
201
202  // ── Hardening work ──
203  if (newPhase === "hardening" && ringState.hardeningProgress < 4) {
204    ringState.hardeningProgress = (ringState.hardeningProgress || 0) + 1;
205
206    if (ringState.hardeningProgress === 3 && !ringState.character) {
207      // Week 3: synthesize character
208      try {
209        const charData = await buildCharacterData(rootId, ringState);
210        const synthesized = await synthesizeCharacter(charData, rootId, userId, username);
211        ringState.character = synthesized.character;
212        ringState.essence = synthesized.essence;
213      } catch (err) {
214        log.warn("Rings", `Character synthesis failed: ${err.message}`);
215      }
216    }
217  }
218
219  // ── Dormancy: solidify ring ──
220  if (newPhase === "dormant" && ringState.character) {
221    await solidifyRing(rootId, userId, ringState);
222    // Reset for new ring
223    const fresh = getDefaultRingState();
224    await _metadata.setExtMeta(root, "rings", fresh);
225    return;
226  }
227
228  // ── Force ring after MAX_RING_AGE_MONTHS ──
229  const started = new Date(ringState.started);
230  const ageMs = Date.now() - started.getTime();
231  if (ageMs > MAX_RING_AGE_MONTHS * 30 * 24 * 60 * 60 * 1000) {
232    log.info("Rings", `${String(rootId).slice(0, 8)} hasn't rested in ${MAX_RING_AGE_MONTHS} months. Forcing ring.`);
233    if (!ringState.character) {
234      try {
235        const charData = await buildCharacterData(rootId, ringState);
236        const synthesized = await synthesizeCharacter(charData, rootId, userId, username);
237        ringState.character = synthesized.character;
238        ringState.essence = synthesized.essence;
239      } catch {}
240    }
241    await solidifyRing(rootId, userId, ringState);
242    const fresh = getDefaultRingState();
243    await _metadata.setExtMeta(root, "rings", fresh);
244    return;
245  }
246
247  await _metadata.setExtMeta(root, "rings", ringState);
248}
249
250// ── Build character data from tree state ──
251
252async function buildCharacterData(rootId, ringState) {
253  const root = await _Node.findById(rootId).select("_id name metadata dateCreated children").lean();
254  const meta = root.metadata instanceof Map ? Object.fromEntries(root.metadata) : (root.metadata || {});
255
256  // Tree age
257  const createdAt = root.dateCreated ? new Date(root.dateCreated) : new Date();
258  const ageMs = Date.now() - createdAt.getTime();
259  const ageMonths = Math.floor(ageMs / (30 * 24 * 60 * 60 * 1000));
260  const treeAge = ageMonths < 12 ? `${ageMonths} months` : `${Math.floor(ageMonths / 12)} years, ${ageMonths % 12} months`;
261
262  // Structure
263  const topChildren = await _Node.find({ parent: rootId }).select("name systemRole").lean();
264  const branches = topChildren.filter(c => !c.systemRole && !c.name.startsWith(".")).map(c => c.name);
265  const totalNodes = await _Node.countDocuments({ $or: [{ _id: rootId }] }); // simplified count
266
267  // Read from other extensions
268  const acc = ringState.accumulator || {};
269  const topTopics = Object.entries(acc.topicMentions || {})
270    .sort((a, b) => b[1] - a[1])
271    .slice(0, 5)
272    .map(([word]) => word);
273
274  return {
275    treeAge,
276    branches,
277    totalNodes,
278    accumulator: acc,
279    topTopics,
280    thesis: meta.purpose?.thesis || null,
281    coherence: meta.purpose?.coherence || null,
282    patterns: (meta.evolution?.patterns || []).slice(0, 3).map(p => p.pattern || p),
283    phaseHistory: ringState.phaseHistory || [],
284    ringDuration: `${Math.round((Date.now() - new Date(ringState.started).getTime()) / (24 * 60 * 60 * 1000))} days`,
285  };
286}
287
288// ── Public: assemble current ring data from live state ──
289
290export async function assembleRingData(rootId) {
291  if (!_Node || !_metadata) return null;
292  const root = await _Node.findById(rootId).select("metadata").lean();
293  if (!root) return null;
294  const ringState = _metadata.getExtMeta(root, "rings");
295  return buildCharacterData(rootId, ringState || getDefaultRingState());
296}
297
298// ── LLM character synthesis ──
299
300async function synthesizeCharacter(data, rootId, userId, username) {
301  if (!_runChat) return { character: "Character synthesis unavailable.", essence: "Data collected." };
302
303  const prompt = `You are analyzing a tree's growth ring. This ring formed over ${data.ringDuration} through natural activity phases. Based on the data, write:
304
3051. CHARACTER: 2-3 sentences. What the tree focused on. How it grew. What changed. Third person.
3062. ESSENCE: One sentence. The ring in one breath.
307
308Tree age: ${data.treeAge}
309Branches: ${data.branches.join(", ")}
310Notes written: ${data.accumulator.notesWritten}, Nodes created: ${data.accumulator.nodesCreated}
311Cascade signals: ${data.accumulator.cascadeSignals}
312Dominant topics: ${data.topTopics.join(", ") || "unknown"}
313Thesis: ${data.thesis || "none set"}
314Thesis coherence: ${data.coherence ?? "unknown"}
315Patterns discovered: ${data.patterns.join("; ") || "none"}
316Phase history: ${data.phaseHistory.map(p => `${p.phase} (${p.duration || "current"})`).join(" -> ")}
317
318Respond with JSON only: { "character": "...", "essence": "..." }`;
319
320  try {
321    const { answer } = await _runChat({
322      userId, username, message: prompt,
323      mode: "tree:respond", rootId,
324      slot: "rings", llmPriority: 4,
325    });
326    const parsed = parseJsonSafe(answer);
327    return {
328      character: parsed?.character || "Character synthesis failed.",
329      essence: parsed?.essence || "Ring formed.",
330    };
331  } catch (err) {
332    log.warn("Rings", `Synthesis failed: ${err.message}`);
333    return { character: "Character synthesis unavailable.", essence: "Data collected." };
334  }
335}
336
337// ── Solidify a completed ring ──
338
339async function solidifyRing(rootId, userId, ringState) {
340  const data = await buildCharacterData(rootId, ringState);
341  const ringsNode = await getRingsNode(rootId, userId);
342  const { createNote } = await import("../../seed/tree/notes.js");
343
344  const ring = {
345    period: "ring",
346    started: ringState.started,
347    ended: new Date().toISOString(),
348    duration: data.ringDuration,
349    treeAge: data.treeAge,
350    structure: { branches: data.branches, totalNodes: data.totalNodes },
351    accumulator: ringState.accumulator,
352    phaseHistory: ringState.phaseHistory,
353    dominantTopics: data.topTopics,
354    character: ringState.character,
355    essence: ringState.essence,
356  };
357
358  await createNote({
359    nodeId: String(ringsNode._id),
360    content: JSON.stringify(ring, null, 2),
361    contentType: "text",
362    userId,
363  });
364
365  log.info("Rings", `Ring solidified for ${String(rootId).slice(0, 8)}: ${ring.started} to ${ring.ended} (${ring.duration})`);
366
367  // Check for annual compression
368  const year = new Date().getFullYear() - 1;
369  const { rings } = await getRings(rootId);
370  const yearRings = rings.filter(r => {
371    const d = new Date(r.ended || r.started);
372    return d.getFullYear() === year;
373  });
374  if (yearRings.length >= 2) {
375    await compressAnnual(rootId, userId, ringState, yearRings, year);
376  }
377}
378
379// ── Annual compression ──
380
381async function compressAnnual(rootId, userId, ringState, yearRings, year) {
382  const summaries = yearRings.map(r =>
383    `${r.started} to ${r.ended} (${r.duration}): ${r.character || "no character"}`
384  ).join("\n");
385
386  let character = `Year ${year}: ${yearRings.length} rings completed.`;
387  let essence = `Year ${year}.`;
388
389  if (_runChat) {
390    try {
391      const owner = await _Node.findById(rootId).select("rootOwner").lean();
392      const user = owner?.rootOwner ? await (await import("../../seed/models/user.js")).default.findById(owner.rootOwner).select("username").lean() : null;
393
394      const { answer } = await _runChat({
395        userId, username: user?.username || "unknown",
396        message: `Compress these ${yearRings.length} tree rings into one annual ring for year ${year}.\n\nRings:\n${summaries}\n\nWrite:\n1. CHARACTER: 3-4 sentences. The year's arc.\n2. ESSENCE: One sentence. The year in one breath.\n\nJSON only: { "character": "...", "essence": "..." }`,
397        mode: "tree:respond", rootId, slot: "rings", llmPriority: 4,
398      });
399      const parsed = parseJsonSafe(answer);
400      if (parsed?.character) character = parsed.character;
401      if (parsed?.essence) essence = parsed.essence;
402    } catch {}
403  }
404
405  const annualRing = {
406    period: "year",
407    year,
408    ringsCount: yearRings.length,
409    character,
410    essence,
411  };
412
413  const ringsNode = await getRingsNode(rootId, userId);
414  const { createNote } = await import("../../seed/tree/notes.js");
415  await createNote({
416    nodeId: String(ringsNode._id),
417    content: JSON.stringify(annualRing, null, 2),
418    contentType: "text",
419    userId,
420  });
421
422  // Delete individual rings for this year
423  const allNotes = await _Note.find({ nodeId: ringsNode._id, contentType: "text" }).lean();
424  for (const note of allNotes) {
425    try {
426      const r = JSON.parse(note.content);
427      if (r.period === "ring") {
428        const d = new Date(r.ended || r.started);
429        if (d.getFullYear() === year) {
430          await _Note.findByIdAndDelete(note._id);
431        }
432      }
433    } catch {}
434  }
435
436  log.info("Rings", `Annual ring for ${year}: ${yearRings.length} rings compressed.`);
437}
438
1/**
2 * Rings Extension
3 *
4 * The tree remembers every age it has been.
5 * Rings form from activity phases, not calendar dates.
6 * Growth, peak, hardening, dormancy. The ring solidifies
7 * when the tree completes a full cycle.
8 *
9 * Three temporal layers:
10 *   Phase (seconds):  awareness/attention in conversation
11 *   Breath (minutes): activity-driven metabolism
12 *   Rings (months):   growth -> peak -> hardening -> dormant
13 */
14
15import log from "../../seed/log.js";
16import {
17  configure,
18  getRings,
19  incrementAccumulator,
20  onExhale,
21  getDefaultRingState,
22} from "./core.js";
23import { getExtension } from "../loader.js";
24
25export async function init(core) {
26  const { runChat } = await import("../../seed/llm/conversation.js");
27
28  configure({
29    Node: core.models.Node,
30    Note: core.models.Note,
31    runChat,
32    metadata: core.metadata,
33    getExtension,
34  });
35
36  // Register LLM slot
37  core.llm.registerRootLlmSlot("rings");
38
39  // ── Initialize ring state on trees that don't have one ──
40  core.hooks.register("afterBoot", async () => {
41    try {
42      const roots = await core.models.Node.find({
43        rootOwner: { $exists: true, $ne: null },
44        systemRole: null,
45      }).select("_id metadata").lean();
46
47      for (const root of roots) {
48        const ringState = core.metadata.getExtMeta(root, "rings");
49        if (!ringState || !ringState.started) {
50          await core.metadata.setExtMeta(root, "rings", getDefaultRingState());
51        }
52      }
53    } catch {}
54  }, "rings");
55
56  // ── Accumulate activity via hooks ──
57
58  core.hooks.register("afterNote", async ({ nodeId }) => {
59    if (!nodeId) return;
60    // Walk to root
61    let cursor = await core.models.Node.findById(nodeId).select("_id rootOwner parent").lean();
62    while (cursor && !cursor.rootOwner) {
63      cursor = await core.models.Node.findById(cursor.parent).select("_id rootOwner parent").lean();
64    }
65    if (cursor?.rootOwner) {
66      await incrementAccumulator(cursor._id, "notesWritten");
67    }
68  }, "rings");
69
70  core.hooks.register("afterNodeCreate", async ({ node }) => {
71    if (!node) return;
72    let cursor = node;
73    while (cursor && !cursor.rootOwner) {
74      cursor = await core.models.Node.findById(cursor.parent).select("_id rootOwner parent").lean();
75    }
76    if (cursor?.rootOwner) {
77      await incrementAccumulator(cursor._id, "nodesCreated");
78    }
79  }, "rings");
80
81  core.hooks.register("beforeNodeDelete", async ({ node }) => {
82    if (!node) return;
83    let cursor = node;
84    while (cursor && !cursor.rootOwner) {
85      cursor = await core.models.Node.findById(cursor.parent).select("_id rootOwner parent").lean();
86    }
87    if (cursor?.rootOwner) {
88      await incrementAccumulator(cursor._id, "nodesLost");
89    }
90  }, "rings");
91
92  // Cascade signals
93  core.hooks.register("onCascade", async (hookData) => {
94    const nodeId = hookData?.nodeId;
95    if (!nodeId) return;
96    const node = await core.models.Node.findById(nodeId).select("rootOwner").lean();
97    if (node?.rootOwner) {
98      await incrementAccumulator(node.rootOwner === node._id?.toString() ? node._id : node.rootOwner, "cascadeSignals");
99    }
100  }, "rings");
101
102  // ── Breath exhale: phase detection + hardening progress ──
103  // Listens to breath:exhale via a periodic check
104  let _exhaustTimer = null;
105  const EXHALE_CHECK_INTERVAL = 10 * 60 * 1000; // 10 min fallback
106
107  async function runExhaleCheck() {
108    try {
109      const roots = await core.models.Node.find({
110        rootOwner: { $exists: true, $ne: null },
111        systemRole: null,
112      }).select("_id rootOwner metadata").lean();
113
114      for (const root of roots) {
115        const ringState = core.metadata.getExtMeta(root, "rings");
116        if (!ringState?.started) continue;
117
118        const owner = await core.models.User.findById(root.rootOwner).select("username").lean();
119        if (!owner) continue;
120
121        await onExhale(root._id, root.rootOwner, owner.username);
122      }
123    } catch (err) {
124      log.debug("Rings", `Exhale check error: ${err.message}`);
125    }
126  }
127
128  // Try to sync to breath:exhale. Fallback to timer.
129  core.hooks.register("afterBoot", async () => {
130    const breath = getExtension("breath");
131    if (breath?.exports?.onExhale) {
132      // Breath extension fires exhale events. Listen.
133      core.hooks.register("afterToolCall", async () => {
134        // Piggyback on activity. onExhale handles rate limiting internally.
135      }, "rings");
136    }
137    // Always run periodic check as safety net
138    _exhaustTimer = setInterval(runExhaleCheck, EXHALE_CHECK_INTERVAL);
139    if (_exhaustTimer.unref) _exhaustTimer.unref();
140
141    // Initial check
142    await runExhaleCheck();
143  }, "rings");
144
145  // ── enrichContext: inject ring awareness at any position ──
146  core.hooks.register("enrichContext", async ({ context, node }) => {
147    if (!node) return;
148
149    // Resolve tree root from any position via ancestor walk
150    let rootId = null;
151    let rootNode = null;
152    if (node.rootOwner && String(node.rootOwner) !== "system") {
153      // This node has rootOwner: it IS the root, or rootOwner points to the root
154      const ownerIdStr = String(node.rootOwner);
155      if (ownerIdStr === String(node._id)) {
156        rootId = String(node._id);
157        rootNode = node;
158      } else {
159        // rootOwner is a userId, not the root node. Walk up.
160        let cursor = node;
161        while (cursor) {
162          if (cursor.rootOwner && String(cursor._id) !== String(cursor.parent)) {
163            // Check if this node owns itself (is a tree root)
164            const parent = cursor.parent ? await core.models.Node.findById(cursor.parent).select("_id rootOwner systemRole").lean() : null;
165            if (!parent || parent.systemRole) {
166              rootId = String(cursor._id);
167              rootNode = cursor;
168              break;
169            }
170          }
171          if (!cursor.parent) break;
172          cursor = await core.models.Node.findById(cursor.parent).select("_id rootOwner parent systemRole").lean();
173          if (cursor?.systemRole) break;
174        }
175      }
176    }
177
178    if (!rootId) return;
179
180    // Load root with metadata if we don't have it
181    if (!rootNode?.metadata) {
182      rootNode = await core.models.Node.findById(rootId).select("metadata dateCreated").lean();
183    }
184    if (!rootNode) return;
185
186    const ringState = core.metadata.getExtMeta(rootNode, "rings");
187    if (!ringState?.started) return;
188
189    const { rings, annual } = await getRings(rootId);
190
191    context.rings = {
192      currentPhase: ringState.phase || "unknown",
193      ringsCompleted: rings.length + annual.length,
194    };
195
196    if (ringState.character) {
197      context.rings.currentCharacter = ringState.character;
198    }
199
200    const prevRing = rings[0] || annual[0];
201    if (prevRing?.essence) {
202      context.rings.previousRingEssence = prevRing.essence;
203    }
204
205    if (rootNode.dateCreated) {
206      const ageMs = Date.now() - new Date(rootNode.dateCreated).getTime();
207      const months = Math.floor(ageMs / (30 * 24 * 60 * 60 * 1000));
208      context.rings.treeAge = months < 12 ? `${months} months` : `${Math.floor(months / 12)} years, ${months % 12} months`;
209    }
210  }, "rings");
211
212  // Routes
213  const { default: router, resolveHtmlAuth } = await import("./routes.js");
214  resolveHtmlAuth();
215
216  return {
217    router,
218    exports: {
219      getRings,
220      onExhale,
221    },
222    jobs: [
223      {
224        name: "rings-exhale",
225        start: () => {}, // timer started in afterBoot
226        stop: () => { if (_exhaustTimer) { clearInterval(_exhaustTimer); _exhaustTimer = null; } },
227      },
228    ],
229  };
230}
231
1export default {
2  name: "rings",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  description:
6    "The tree remembers every age it has been. Once per month, rings takes a cross-section " +
7    "of the entire tree: structure, vitals, and an AI-generated character portrait. Monthly " +
8    "rings keep full detail for two years. Then they compress into annual rings. The annual " +
9    "ring absorbs twelve monthlies into one denser summary. Annual rings persist forever.\n\n" +
10    "Each ring captures what every installed extension knows: evolution metrics, thesis " +
11    "coherence, contradiction resolution, codebook vocabulary, prune history, phase ratios, " +
12    "cascade signal counts, and topic clusters. One LLM call synthesizes a character portrait " +
13    "and a one-sentence essence. The AI at any position knows the tree's age, its current " +
14    "character, and what came before.\n\n" +
15    "The further back, the less detail, but the character persists. Like tree rings. Like " +
16    "human memory. Yesterday is vivid. Last month is a summary. Five years ago is a feeling " +
17    "and a few key moments. But each period shaped who the tree is.",
18
19  needs: {
20    services: ["hooks", "llm", "metadata"],
21    models: ["Node", "Note"],
22  },
23
24  optional: {
25    extensions: [
26      "evolution", "purpose", "contradiction", "codebook",
27      "prune", "remember", "changelog", "phase",
28      "inverse-tree", "embed", "explore", "breath",
29    ],
30  },
31
32  provides: {
33    tools: true,
34    routes: "./routes.js",
35    llmSlots: ["rings"],
36    cli: [
37      { command: "rings", scope: ["tree"], description: "Show tree ring history (annual + recent monthly)", method: "GET", endpoint: "/root/:rootId/rings" },
38      { command: "rings current", scope: ["tree"], description: "Current month character assembled from live data", method: "GET", endpoint: "/root/:rootId/rings/current" },
39    ],
40  },
41};
42
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getRings, assembleRingData } from "./core.js";
5import { getExtension } from "../loader.js";
6
7let htmlAuth = authenticate;
8export function resolveHtmlAuth() {
9  const htmlExt = getExtension("html-rendering");
10  if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
11}
12
13const router = express.Router();
14
15// GET /root/:rootId/rings - all rings (annual + recent monthly)
16router.get("/root/:rootId/rings", htmlAuth, async (req, res) => {
17  try {
18    const { rootId } = req.params;
19    const { rings, annual } = await getRings(rootId);
20
21    sendOk(res, {
22      rootId,
23      annual: (annual || []).map(r => ({
24        date: r.ringDate,
25        treeAge: r.treeAge,
26        character: r.character,
27        essence: r.essence,
28        structure: r.structure,
29        vitals: r.vitals,
30      })),
31      monthly: (rings || []).map(r => ({
32        date: r.ringDate,
33        treeAge: r.treeAge,
34        character: r.character,
35        essence: r.essence,
36        structure: r.structure,
37        vitals: r.vitals,
38      })),
39    });
40  } catch (err) {
41    sendError(res, 500, ERR.INTERNAL, err.message);
42  }
43});
44
45// GET /root/:rootId/rings/current - live assembly, no storage
46router.get("/root/:rootId/rings/current", htmlAuth, async (req, res) => {
47  try {
48    const { rootId } = req.params;
49    const ringData = await assembleRingData(rootId);
50    if (!ringData) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
51
52    sendOk(res, ringData);
53  } catch (err) {
54    sendError(res, 500, ERR.INTERNAL, err.message);
55  }
56});
57
58// GET /root/:rootId/rings/:period - specific ring (year like "2027" or month like "2027-03")
59router.get("/root/:rootId/rings/:period", htmlAuth, async (req, res) => {
60  try {
61    const { rootId, period } = req.params;
62    const { monthly, annual } = await getRings(rootId);
63
64    // Try annual first (4-digit year)
65    if (/^\d{4}$/.test(period)) {
66      const ring = annual.find(r => r.ringDate?.startsWith(period));
67      if (ring) return sendOk(res, ring);
68      return sendError(res, 404, ERR.NODE_NOT_FOUND, `No annual ring for ${period}`);
69    }
70
71    // Try monthly (YYYY-MM)
72    if (/^\d{4}-\d{2}$/.test(period)) {
73      const ring = monthly.find(r => r.ringDate?.startsWith(period));
74      if (ring) return sendOk(res, ring);
75      return sendError(res, 404, ERR.NODE_NOT_FOUND, `No monthly ring for ${period}`);
76    }
77
78    sendError(res, 400, ERR.INVALID_INPUT, "Period must be a year (2027) or month (2027-03)");
79  } catch (err) {
80    sendError(res, 500, ERR.INTERNAL, err.message);
81  }
82});
83
84export default router;
85

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star rings

Comments

Loading comments...

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