1/**
2 * Breath Core
3 *
4 * Per-tree adaptive breathing. Each tree has its own rhythm.
5 * Activity speeds it up. Silence slows it down. Dormancy stops it.
6 *
7 * The exhale is the only moment work happens. Extensions listen to
8 * breath:exhale and decide whether to act based on activityLevel
9 * and their own thresholds.
10 */
11
12import log from "../../seed/log.js";
13
14// ── Tuning constants ──
15
16const MIN_BREATH = 15000; // fastest: 15 seconds
17const MAX_BREATH = 600000; // slowest: 10 minutes
18const DEFAULT_BREATH = 60000; // startup / wake from dormancy
19const DORMANT_AFTER = 3; // consecutive zero-activity cycles before dormancy
20
21// ── Per-tree state ──
22
23const states = new Map(); // rootId -> BreathState
24
25// ── nodeId -> rootId cache ──
26
27const rootCache = new Map();
28const ROOT_CACHE_MAX = 5000;
29
30// ── Dependency references (set by configure) ──
31
32let _fireHook = null;
33let _Node = null;
34
35/**
36 * Set references to core services. Called once from init().
37 */
38export function configure({ hooks, Node }) {
39 _fireHook = (name, data) => hooks.run(name, data);
40 _Node = Node;
41}
42
43// ── Root resolution ──
44
45/**
46 * Resolve rootId from a nodeId. Walks parent chain, caches result.
47 * Returns null if the node doesn't exist or has no root.
48 */
49export async function resolveRootId(nodeId) {
50 if (!nodeId || !_Node) return null;
51 const key = String(nodeId);
52 if (rootCache.has(key)) return rootCache.get(key);
53
54 let current = key;
55 const visited = [current];
56
57 for (let depth = 0; depth < 50; depth++) {
58 const node = await _Node.findById(current).select("parent").lean();
59 if (!node) return null;
60 if (!node.parent) {
61 // Found root. Cache every node in the path.
62 if (rootCache.size >= ROOT_CACHE_MAX) {
63 const oldest = rootCache.keys().next().value;
64 rootCache.delete(oldest);
65 }
66 for (const id of visited) rootCache.set(id, current);
67 return current;
68 }
69 current = String(node.parent);
70 visited.push(current);
71 }
72
73 return null;
74}
75
76// ── Activity tracking ──
77
78/**
79 * Record an activity event on a tree. Wakes from dormancy if needed.
80 */
81export function recordActivity(rootId) {
82 if (!rootId) return;
83
84 let state = states.get(rootId);
85 if (!state) {
86 state = {
87 interval: DEFAULT_BREATH,
88 timer: null,
89 activity: 0,
90 zeros: 0,
91 lastExhale: null,
92 running: false,
93 };
94 states.set(rootId, state);
95 }
96
97 state.activity++;
98
99 // Wake from dormancy on first event
100 if (!state.running) {
101 state.interval = DEFAULT_BREATH;
102 state.zeros = 0;
103 startBreathing(rootId);
104 }
105}
106
107// ── Breathing loop ──
108
109function startBreathing(rootId) {
110 const state = states.get(rootId);
111 if (!state || state.running) return;
112
113 state.running = true;
114 log.verbose("Breath", `${rootId.slice(0, 8)}... waking. Cycle: ${state.interval}ms`);
115 scheduleNext(rootId);
116}
117
118function stopBreathing(rootId) {
119 const state = states.get(rootId);
120 if (!state) return;
121
122 if (state.timer) {
123 clearTimeout(state.timer);
124 state.timer = null;
125 }
126 state.running = false;
127 log.verbose("Breath", `${rootId.slice(0, 8)}... dormant after ${DORMANT_AFTER} empty cycles`);
128}
129
130function scheduleNext(rootId) {
131 const state = states.get(rootId);
132 if (!state || !state.running) return;
133
134 state.timer = setTimeout(async () => {
135 await exhale(rootId);
136 if (state.running) scheduleNext(rootId);
137 }, state.interval);
138
139 // Don't hold the process open for breath timers
140 if (state.timer.unref) state.timer.unref();
141}
142
143/**
144 * One exhale. Read activity, adjust rate, fire hook.
145 */
146async function exhale(rootId) {
147 const state = states.get(rootId);
148 if (!state) return;
149
150 const activityLevel = state.activity;
151 state.activity = 0;
152
153 // Adjust breathing rate based on activity since last exhale
154 if (activityLevel === 0) {
155 state.zeros++;
156 if (state.zeros >= DORMANT_AFTER) {
157 stopBreathing(rootId);
158 return;
159 }
160 // Slow down
161 state.interval = Math.min(Math.round(state.interval * 1.3), MAX_BREATH);
162 } else if (activityLevel >= 20) {
163 // High activity: breathe faster
164 state.zeros = 0;
165 state.interval = Math.max(Math.round(state.interval * 0.7), MIN_BREATH);
166 } else if (activityLevel >= 6) {
167 // Moderate activity: speed up gently
168 state.zeros = 0;
169 state.interval = Math.max(Math.round(state.interval * 0.85), MIN_BREATH);
170 } else {
171 // Low activity (1-5): slow down gently
172 state.zeros = 0;
173 state.interval = Math.min(Math.round(state.interval * 1.3), MAX_BREATH);
174 }
175
176 state.lastExhale = Date.now();
177
178 // Fire the exhale hook for all listening extensions
179 if (_fireHook) {
180 try {
181 await _fireHook("breath:exhale", {
182 rootId,
183 breathRate: getRate(state),
184 activityLevel,
185 cycleMs: state.interval,
186 });
187 } catch (err) {
188 log.warn("Breath", `Exhale hook error for ${rootId.slice(0, 8)}...: ${err.message}`);
189 }
190 }
191}
192
193// ── Rate labels ──
194
195function getRate(state) {
196 if (!state || !state.running) return "dormant";
197 if (state.interval <= 30000) return "active";
198 if (state.interval <= 120000) return "steady";
199 return "resting";
200}
201
202// ── Public getters ──
203
204/**
205 * Get the enrichContext-ready data for a tree.
206 */
207export function getBreathContext(rootId) {
208 const state = states.get(rootId);
209 if (!state) return null;
210
211 return {
212 rate: getRate(state),
213 cycleMs: state.interval,
214 lastExhale: state.lastExhale,
215 activitySinceLastBreath: state.activity,
216 };
217}
218
219/**
220 * Get raw state for a tree (for diagnostics).
221 */
222export function getState(rootId) {
223 return states.get(rootId) || null;
224}
225
226/**
227 * Get all tree breathing states (for diagnostics).
228 */
229export function getAllStates() {
230 const result = {};
231 for (const [rootId, state] of states) {
232 result[rootId] = {
233 rate: getRate(state),
234 interval: state.interval,
235 activity: state.activity,
236 zeros: state.zeros,
237 running: state.running,
238 lastExhale: state.lastExhale,
239 };
240 }
241 return result;
242}
243
244// ── Shutdown ──
245
246/**
247 * Stop all breathing cycles. Called on extension shutdown.
248 */
249export function stopAll() {
250 for (const [, state] of states) {
251 if (state.timer) {
252 clearTimeout(state.timer);
253 state.timer = null;
254 }
255 state.running = false;
256 }
257 states.clear();
258 rootCache.clear();
259 log.info("Breath", "All breathing stopped");
260}
261
1/**
2 * Breath
3 *
4 * The tree breathes. Not a metaphor. A literal rhythm that drives
5 * all background work. Activity speeds it up. Silence slows it down.
6 * Dormancy stops it. Each tree has its own breathing cycle.
7 *
8 * Extensions listen to breath:exhale instead of running their own
9 * setInterval. The tree's metabolism is unified. One rhythm.
10 * Every extension feels it.
11 */
12
13import log from "../../seed/log.js";
14import {
15 configure,
16 recordActivity,
17 resolveRootId,
18 getBreathContext,
19 getState,
20 getAllStates,
21 stopAll,
22} from "./core.js";
23
24export async function init(core) {
25 // Wire core dependencies
26 configure({
27 hooks: core.hooks,
28 Node: core.models.Node,
29 });
30
31 // ── Activity hooks ──
32 // Every event increments the activity counter for its tree.
33 // Trees with no activity slow down and eventually go dormant.
34 // First event on a dormant tree wakes it.
35
36 core.hooks.register("afterNote", async ({ nodeId }) => {
37 if (!nodeId) return;
38 const rootId = await resolveRootId(String(nodeId));
39 if (rootId) recordActivity(rootId);
40 }, "breath");
41
42 core.hooks.register("afterNodeCreate", async ({ node }) => {
43 if (!node) return;
44 // Root nodes have no parent. Their _id IS the rootId.
45 if (!node.parent) {
46 recordActivity(String(node._id));
47 } else {
48 const rootId = await resolveRootId(String(node._id));
49 if (rootId) recordActivity(rootId);
50 }
51 }, "breath");
52
53 core.hooks.register("afterToolCall", async ({ rootId }) => {
54 if (rootId) recordActivity(String(rootId));
55 }, "breath");
56
57 core.hooks.register("afterNavigate", async ({ rootId }) => {
58 if (rootId) recordActivity(String(rootId));
59 }, "breath");
60
61 // ── enrichContext ──
62 // Inject breathing state so the AI knows the tree's rhythm.
63
64 core.hooks.register("enrichContext", async ({ context, node }) => {
65 if (!node?._id) return;
66 const rootId = !node.parent
67 ? String(node._id)
68 : await resolveRootId(String(node._id));
69 if (!rootId) return;
70
71 const breath = getBreathContext(rootId);
72 if (breath) {
73 context.breath = breath;
74 }
75 }, "breath");
76
77 log.info("Breath", "Loaded. Trees breathe.");
78
79 return {
80 jobs: [
81 {
82 name: "breath-cycle",
83 start: () => {},
84 stop: stopAll,
85 },
86 ],
87 exports: {
88 getBreathContext,
89 getState,
90 getAllStates,
91 recordActivity,
92 },
93 };
94}
95
1export default {
2 name: "breath",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "The tree breathes. A single adaptive rhythm replaces every extension's individual " +
7 "timer. Activity speeds it up. Silence slows it down. Dormancy stops it. Each tree " +
8 "has its own breathing cycle. Extensions listen to breath:exhale instead of running " +
9 "setInterval. Active tree = frequent exhales. Quiet tree = slow exhales. Sleeping " +
10 "tree = no exhales, zero cost. Resource usage is proportional to actual activity, " +
11 "not installed extension count.",
12
13 needs: {
14 services: ["hooks"],
15 models: ["Node"],
16 },
17
18 optional: {
19 extensions: ["heartbeat"],
20 },
21
22 provides: {
23 models: {},
24 routes: false,
25 tools: false,
26 jobs: true,
27 orchestrator: false,
28 energyActions: {},
29 sessionTypes: {},
30 hooks: {
31 fires: ["breath:exhale"],
32 listens: [
33 "afterNote",
34 "afterNodeCreate",
35 "afterToolCall",
36 "afterNavigate",
37 "enrichContext",
38 ],
39 },
40 cli: [],
41 },
42};
43
Loading comments...