EXTENSION seed
automate
Flows. The tree acts on its own. Any node becomes a flow. Enable it, give it children, each child is a step. Each step has a mode and a prompt. On every breath cycle, the extension runs them in order. Step 1 result feeds step 2. Step 2 result feeds step 3. Any extension's mode works as a step. Browser-bridge reads a page. KB saves the key points. Browser-bridge navigates to Reddit. Browser-bridge posts a comment using what KB saved. Four steps. Four focused agents. One flow. Every 10 minutes. A fitness tree could have a flow that checks a nutrition API and logs meals. A study tree could read documentation pages and create quiz questions. An evangelist tree reads its own website, learns the talking points, browses communities, and engages. The tree is the definition. The children are the steps. The breath is the clock. Each run logs a summary note on the flow node. Capped at 30. The tree remembers what it did. Cadence is configurable per flow. Default 5 minutes. Set metadata.automate.enabled = true on any node to activate.
v1.0.1 by TreeOS Site 0 downloads 2 files 278 lines 9.4 KB published 38d ago
treeos ext install automate
View changelog

Manifest

Provides

  • 1 CLI commands

Requires

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

Optional

  • extensions: breath, treeos-base
SHA256: 069b59c51a12318c221f0e55e4f896bc387007e34cc20bc9a917ccb083e1534d

CLI Commands

CommandMethodDescription
automate [action]GETEnable/disable automation on the current node. Actions: enable, disable, status, run.

Hooks

Listens To

  • breath:exhale

Source Code

1import 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}
219
1export 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
0 stars
0 flags
React from the CLI: treeos ext star automate

Comments

Loading comments...

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