EXTENSION for TreeOS
breath
The tree breathes. A single adaptive rhythm replaces every extension's individual timer. Activity speeds it up. Silence slows it down. Dormancy stops it. Each tree has its own breathing cycle. Extensions listen to breath:exhale instead of running setInterval. Active tree = frequent exhales. Quiet tree = slow exhales. Sleeping tree = no exhales, zero cost. Resource usage is proportional to actual activity, not installed extension count.
v1.0.1 by TreeOS Site 0 downloads 3 files 399 lines 9.6 KB published 38d ago
treeos ext install breath
View changelog

Manifest

Provides

  • jobs
  • 1 custom hooks

Requires

  • services: hooks
  • models: Node

Optional

  • extensions: heartbeat
SHA256: c130f5feaa01719117d55f9221e729c14909de734eb5f7e21016df48039164ae

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

Hooks

Listens To

  • afterNote
  • afterNodeCreate
  • afterToolCall
  • afterNavigate
  • enrichContext

Fires

HookDataDescription
breath:exhale

Source Code

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

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 breath

Comments

Loading comments...

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