069b59c51a12318c221f0e55e4f896bc387007e34cc20bc9a917ccb083e1534d| Command | Method | Description |
|---|---|---|
automate [action] | GET | Enable/disable automation on the current node. Actions: enable, disable, status, run. |
breath:exhale1import log from "../../seed/log.js";
2import Node from "../../seed/models/node.js";
3import { getExtMeta } from "../../seed/tree/extensionMetadata.js";
4import { createNote } from "../../seed/tree/notes.js";
5
6// In-flight guard: one flow per tree at a time
7const _running = new Set();
8
9// Cooldown per flow node: don't run the same flow faster than its cadence
10const _lastRun = new Map();
11
12// Default cadence: 5 minutes between runs
13const DEFAULT_CADENCE_MS = 5 * 60 * 1000;
14
15export async function init(core) {
16 const BG = core.llm.LLM_PRIORITY.BACKGROUND;
17 const { runChat: _runChatDirect } = await import("../../seed/llm/conversation.js");
18 const runChat = async (opts) => _runChatDirect({ ...opts, llmPriority: BG });
19
20 core.llm.registerRootLlmSlot?.("automate");
21
22 // Keep trees with enabled flows alive. Poke breath so it never goes dormant.
23 // Resolved in afterBoot when all extensions are guaranteed loaded.
24 let _recordActivity = null;
25
26 // On boot, resolve breath's recordActivity and wake trees with enabled flows.
27 core.hooks.register("afterBoot", async () => {
28 try {
29 const { getExtension } = await import("../loader.js");
30 const breathExt = getExtension("breath");
31 if (breathExt?.exports?.recordActivity) {
32 _recordActivity = breathExt.exports.recordActivity;
33 log.verbose("Automate", "Linked to breath.recordActivity");
34 } else {
35 log.warn("Automate", "Breath extension not found. Trees with flows may go dormant.");
36 }
37 } catch {}
38
39 if (!_recordActivity) return;
40 try {
41 const { getLandRootId } = await import("../../seed/landRoot.js");
42 const landRootId = getLandRootId();
43 if (!landRootId) return;
44 const roots = await Node.find({ parent: landRootId, systemRole: null }).select("_id children").lean();
45 for (const root of roots) {
46 if (!root.children?.length) continue;
47 const children = await Node.find({ _id: { $in: root.children } }).select("metadata").lean();
48 const hasFlows = children.some(c => {
49 const meta = c.metadata instanceof Map ? c.metadata.get("automate") : c.metadata?.automate;
50 return meta?.enabled;
51 });
52 if (hasFlows) {
53 _recordActivity(String(root._id));
54 log.verbose("Automate", `Woke tree ${String(root._id).slice(0, 8)} (has enabled flows)`);
55 }
56 }
57 } catch {}
58 }, "automate");
59
60 core.hooks.register("breath:exhale", async ({ rootId, breathRate }) => {
61 if (breathRate === "dormant") return;
62
63 // Fire and forget. Pass _recordActivity so the flow can poke breath when done.
64 runFlows(rootId, runChat, core, _recordActivity).catch(err =>
65 log.debug("Automate", `Flow failed: ${err.message}`)
66 );
67 }, "automate");
68
69 log.info("Automate", "Loaded. Trees can run flows on repeat.");
70 return {};
71}
72
73/**
74 * Find and run all enabled flows in a tree.
75 */
76async function runFlows(rootId, runChat, core, recordActivity) {
77 const rid = String(rootId);
78 if (_running.has(rid)) return;
79 _running.add(rid);
80
81 try {
82 // Get tree owner for LLM access
83 const { isUserRoot } = await import("../../seed/landRoot.js");
84 const rootNode = await Node.findById(rootId).select("rootOwner systemRole parent children").lean();
85 if (!isUserRoot(rootNode)) return;
86 const ownerId = String(rootNode.rootOwner);
87
88 // Find flow nodes: direct children of root with metadata.automate.enabled
89 const children = await Node.find({ parent: rootId })
90 .select("_id name metadata children")
91 .lean();
92
93 for (const child of children) {
94 const meta = getExtMeta(child, "automate");
95 if (!meta?.enabled) continue;
96
97 // This tree has an enabled flow. Keep it alive regardless of whether
98 // the flow runs this cycle. The tree has work to do.
99 if (recordActivity) recordActivity(rootId);
100
101 const flowId = String(child._id);
102 const cadence = meta.cadenceMs || DEFAULT_CADENCE_MS;
103 const lastTime = _lastRun.get(flowId) || 0;
104 if (Date.now() - lastTime < cadence) continue;
105
106 // Run this flow
107 try {
108 await runFlow(child, rootId, ownerId, runChat, core);
109 _lastRun.set(flowId, Date.now());
110 } catch (err) {
111 log.debug("Automate", `Flow "${child.name}" failed: ${err.message}`);
112 }
113 }
114 } finally {
115 _running.delete(rid);
116 }
117}
118
119/**
120 * Run a single flow. Children of the flow node are the steps.
121 * Each step has metadata.automate with: { mode, prompt }
122 * Steps execute in order. Each step's result feeds the next step's context.
123 */
124async function runFlow(flowNode, rootId, ownerId, runChat, core) {
125 const flowName = flowNode.name || String(flowNode._id);
126
127 // Get steps: children of the flow node, sorted by name (1. 2. 3. or alphabetical)
128 if (!flowNode.children?.length) {
129 log.debug("Automate", `Flow "${flowName}" has no steps`);
130 return;
131 }
132
133 const stepNodes = await Node.find({ _id: { $in: flowNode.children } })
134 .select("_id name metadata")
135 .sort({ name: 1 })
136 .lean();
137
138 if (stepNodes.length === 0) return;
139
140 log.verbose("Automate", `Running flow "${flowName}": ${stepNodes.length} steps`);
141
142 const stepResults = []; // accumulate ALL step results
143 const results = [];
144
145 for (const step of stepNodes) {
146 const stepMeta = getExtMeta(step, "automate");
147 const mode = stepMeta?.mode || "tree:converse";
148 const prompt = stepMeta?.prompt || "";
149
150 if (!prompt) {
151 log.debug("Automate", ` Step "${step.name}" has no prompt, skipping`);
152 continue;
153 }
154
155 // Build the message with accumulated context from ALL previous steps.
156 // Each step sees the full chain, not just the previous step.
157 let message = prompt;
158 if (stepResults.length > 0) {
159 const contextBlock = stepResults
160 .map(r => `[${r.step}]\n${r.result}`)
161 .join("\n\n");
162 message = `${prompt}\n\nContext from previous steps:\n${contextBlock}`;
163 }
164
165 try {
166 const { answer } = await runChat({
167 userId: ownerId,
168 username: "automate",
169 message,
170 mode,
171 rootId,
172 slot: "automate",
173 });
174
175 const result = answer || "";
176 // Keep results capped so context doesn't explode
177 stepResults.push({ step: step.name, result: result.slice(0, 1000) });
178 results.push({ step: step.name, result: result.slice(0, 500) });
179
180 log.verbose("Automate", ` Step "${step.name}" (${mode}): "${result.slice(0, 80)}"`);
181 } catch (err) {
182 log.debug("Automate", ` Step "${step.name}" failed: ${err.message}`);
183 results.push({ step: step.name, error: err.message });
184 // Continue to next step even if one fails
185 }
186 }
187
188 // Log the flow run as a note on the flow node
189 const summary = results.map(r =>
190 r.error ? `${r.step}: FAILED (${r.error})` : `${r.step}: ${r.result.slice(0, 200)}`
191 ).join("\n\n");
192
193 try {
194 await createNote({
195 contentType: "text",
196 content: `Flow run at ${new Date().toISOString()}\n\n${summary}`,
197 userId: ownerId,
198 nodeId: String(flowNode._id),
199 wasAi: true,
200 });
201 } catch {}
202
203 // Cap notes on flow node at 30
204 const Note = (await import("../../seed/models/note.js")).default;
205 const noteCount = await Note.countDocuments({ nodeId: String(flowNode._id) });
206 if (noteCount > 30) {
207 const oldest = await Note.find({ nodeId: String(flowNode._id) })
208 .sort({ createdAt: 1 })
209 .limit(noteCount - 30)
210 .select("_id")
211 .lean();
212 if (oldest.length > 0) {
213 await Note.deleteMany({ _id: { $in: oldest.map(n => n._id) } });
214 }
215 }
216
217 log.verbose("Automate", `Flow "${flowName}" completed: ${results.length} steps`);
218}
2191export default {
2 name: "automate",
3 version: "1.0.1",
4 builtFor: "seed",
5 description:
6 "Flows. The tree acts on its own. " +
7 "\n\n" +
8 "Any node becomes a flow. Enable it, give it children, each child is a step. " +
9 "Each step has a mode and a prompt. On every breath cycle, the extension runs them " +
10 "in order. Step 1 result feeds step 2. Step 2 result feeds step 3. " +
11 "\n\n" +
12 "Any extension's mode works as a step. Browser-bridge reads a page. KB saves the key " +
13 "points. Browser-bridge navigates to Reddit. Browser-bridge posts a comment using what " +
14 "KB saved. Four steps. Four focused agents. One flow. Every 10 minutes. " +
15 "\n\n" +
16 "A fitness tree could have a flow that checks a nutrition API and logs meals. A study " +
17 "tree could read documentation pages and create quiz questions. An evangelist tree " +
18 "reads its own website, learns the talking points, browses communities, and engages. " +
19 "The tree is the definition. The children are the steps. The breath is the clock. " +
20 "\n\n" +
21 "Each run logs a summary note on the flow node. Capped at 30. The tree remembers " +
22 "what it did. Cadence is configurable per flow. Default 5 minutes. " +
23 "Set metadata.automate.enabled = true on any node to activate.",
24
25 needs: {
26 services: ["hooks", "llm", "metadata", "tree"],
27 models: ["Node", "Note"],
28 },
29
30 optional: {
31 extensions: ["breath", "treeos-base"],
32 },
33
34 provides: {
35 models: {},
36 routes: false,
37 tools: false,
38 jobs: false,
39 orchestrator: false,
40 energyActions: {},
41 sessionTypes: {},
42 env: [],
43 cli: [
44 {
45 command: "automate [action]",
46 scope: ["tree"],
47 description: "Enable/disable automation on the current node. Actions: enable, disable, status, run.",
48 method: "GET",
49 endpoint: "/automate?nodeId=:nodeId",
50 },
51 ],
52
53 hooks: {
54 fires: [],
55 listens: ["breath:exhale"],
56 },
57 },
58};
59
treeos ext star automate
Post comments from the CLI: treeos ext comment automate "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...