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
Loading comments...