EXTENSION for TreeOS
sprout
Auto-detects when a message implies a capability the tree doesn't have. Confirms with the user, then scaffolds the domain. The tree grows from conversation.
v1.0.1 by TreeOS Site 0 downloads 3 files 512 lines 17.5 KB published 38d ago
treeos ext install sprout
View changelog

Manifest

Provides

  • tools

Requires

  • services: hooks, llm, metadata
  • models: Node
  • extensions: life

Optional

  • extensions: tree-orchestrator, navigation
SHA256: 8a9928a1972b7efcf7ef46feb895e9dfb462d90f5fbb06d265c08902d08a3569

Source Code

1/**
2 * Sprout Core
3 *
4 * Pending offer state, domain availability cache, and the scaffold action.
5 * Sprout detects what the tree is missing and grows it from conversation.
6 */
7
8import log from "../../seed/log.js";
9import { getExtension } from "../loader.js";
10
11// ─────────────────────────────────────────────────────────────────────────
12// PENDING OFFERS
13// ─────────────────────────────────────────────────────────────────────────
14// Map<userId, { domain, rootId, offeredAt }>
15// Tracks the last domain offer per user so the confirmation flow survives
16// clearHistory between converse calls.
17
18const PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes
19
20const _pending = new Map();
21
22export function getPending(userId) {
23  const entry = _pending.get(userId);
24  if (!entry) return null;
25  if (Date.now() - entry.offeredAt > PENDING_TTL_MS) {
26    _pending.delete(userId);
27    return null;
28  }
29  return entry;
30}
31
32export function setPending(userId, { domain, rootId }) {
33  _pending.set(userId, { domain, rootId, offeredAt: Date.now() });
34}
35
36export function clearPending(userId) {
37  _pending.delete(userId);
38}
39
40// ─────────────────────────────────────────────────────────────────────────
41// DOMAIN AVAILABILITY CACHE
42// ─────────────────────────────────────────────────────────────────────────
43// Brief cache so we don't query the DB on every single message.
44// Map<userId, { domains, cachedAt }>
45
46const CACHE_TTL_MS = 30 * 1000; // 30 seconds
47
48const _domainCache = new Map();
49
50export async function getUnscaffoldedDomains(userId) {
51  const cached = _domainCache.get(userId);
52  if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
53    return cached.domains;
54  }
55
56  const life = getExtension("life");
57  if (!life?.exports) return [];
58
59  const available = life.exports.getAvailableDomains();
60  if (available.length === 0) return [];
61
62  const rootId = await life.exports.findLifeRoot(userId);
63  let scaffolded = [];
64  if (rootId) {
65    const domainNodes = await life.exports.getDomainNodes(rootId);
66    scaffolded = Object.keys(domainNodes);
67  }
68
69  const unscaffolded = available.filter(d => !scaffolded.includes(d));
70  _domainCache.set(userId, { domains: unscaffolded, cachedAt: Date.now() });
71  return unscaffolded;
72}
73
74/**
75 * Get domains the user HAS scaffolded (the inverse of getUnscaffoldedDomains).
76 * Uses the same cache window so both calls in the same message are free.
77 */
78export async function getScaffoldedDomains(userId) {
79  const life = getExtension("life");
80  if (!life?.exports) return [];
81
82  const rootId = await life.exports.findLifeRoot(userId);
83  if (!rootId) return [];
84
85  const domainNodes = await life.exports.getDomainNodes(rootId);
86  return Object.keys(domainNodes);
87}
88
89/** Invalidate cache for a user after scaffolding changes. */
90export function invalidateCache(userId) {
91  _domainCache.delete(userId);
92}
93
94// ─────────────────────────────────────────────────────────────────────────
95// SPROUT ACTION
96// ─────────────────────────────────────────────────────────────────────────
97
98/**
99 * Scaffold a domain into the user's Life tree.
100 * Creates the Life root if it doesn't exist, adds the domain, rebuilds routing.
101 */
102export async function sproutDomain({ domain, userId }) {
103  const life = getExtension("life");
104  if (!life?.exports) throw new Error("Life extension not loaded");
105
106  const available = life.exports.getAvailableDomains();
107  if (!available.includes(domain)) {
108    return {
109      success: false,
110      error: `Domain "${domain}" is not available. Available: ${available.join(", ")}`,
111    };
112  }
113
114  // Find or create Life root
115  let rootId = await life.exports.findLifeRoot(userId);
116  if (!rootId) {
117    const result = await life.exports.scaffoldRoot(userId);
118    rootId = result.rootId;
119    log.info("Sprout", `Created Life root ${rootId} for user ${userId}`);
120  }
121
122  // Check if already scaffolded
123  const existing = await life.exports.getDomainNodes(rootId);
124  if (existing[domain]) {
125    clearPending(userId);
126    invalidateCache(userId);
127    return {
128      success: true,
129      alreadyExists: true,
130      domain,
131      nodeId: existing[domain].id,
132      rootId,
133      message: `${domain} is already set up.`,
134    };
135  }
136
137  // Scaffold the domain
138  const result = await life.exports.addDomain({ rootId, domain, userId });
139  log.info("Sprout", `Scaffolded ${domain} under Life root ${rootId}`);
140
141  // Rebuild routing index so the new domain is immediately routable
142  try {
143    const treeOrch = getExtension("tree-orchestrator");
144    if (treeOrch?.exports?.rebuildIndexForRoot) {
145      await treeOrch.exports.rebuildIndexForRoot(rootId);
146      log.verbose("Sprout", `Rebuilt routing index for root ${rootId}`);
147    } else {
148      // Direct import fallback
149      const { rebuildIndexForRoot } = await import("../tree-orchestrator/routingIndex.js");
150      await rebuildIndexForRoot(rootId);
151      log.verbose("Sprout", `Rebuilt routing index for root ${rootId} (direct import)`);
152    }
153  } catch (err) {
154    log.warn("Sprout", `Failed to rebuild routing index: ${err.message}`);
155  }
156
157  // Add to navigation
158  try {
159    const nav = getExtension("navigation");
160    if (nav?.exports?.addRoot) await nav.exports.addRoot(userId, rootId);
161  } catch {}
162
163  clearPending(userId);
164  invalidateCache(userId);
165
166  return {
167    success: true,
168    domain,
169    nodeId: result.id,
170    rootId,
171    message: `${capitalize(domain)} tracking is now set up. Messages about ${domain} will route there automatically.`,
172  };
173}
174
175function capitalize(s) {
176  return s.charAt(0).toUpperCase() + s.slice(1);
177}
178
1/**
2 * Sprout
3 *
4 * The tree grows itself. Talk about workouts and fitness appears.
5 * Talk about food and nutrition appears. The user never installs or
6 * scaffolds. The tree listens and grows.
7 *
8 * Interception:
9 *   - enrichContext (tree zone): injects available-but-unscaffolded domains
10 *   - beforeLLMCall (home zone + pending state): injects domain awareness
11 *     and pending confirmation context into the system prompt
12 *
13 * Execution:
14 *   - offer-sprout tool: AI calls this to register intent, no side effects
15 *   - sprout tool: AI calls this after user confirms, does the scaffolding
16 */
17
18import log from "../../seed/log.js";
19import { z } from "zod";
20import {
21  getUnscaffoldedDomains,
22  getScaffoldedDomains,
23  getPending,
24  setPending,
25  clearPending,
26  sproutDomain,
27  invalidateCache,
28} from "./core.js";
29
30const DOMAIN_DESCRIPTIONS = {
31  food: "meal logging, calories, macros, daily nutrition targets",
32  fitness: "workout tracking, sets, reps, weight, progressive overload",
33  study: "learning queue, mastery tracking, curricula",
34  recovery: "substance tapering, feelings, recovery patterns",
35  kb: "knowledge base, store and retrieve information",
36  relationships: "people tracking, social connections",
37  finance: "financial tracking, budgeting",
38  investor: "investment tracking, portfolio management",
39  "market-researcher": "market research, competitive analysis",
40};
41
42function describeDomains(domains) {
43  return domains
44    .map(d => `  ${d}: ${DOMAIN_DESCRIPTIONS[d] || d}`)
45    .join("\n");
46}
47
48// Domain names that create-tree should never be used for.
49// These must go through sprout so they get properly scaffolded.
50const DOMAIN_NAMES = new Set(Object.keys(DOMAIN_DESCRIPTIONS));
51const DOMAIN_ALIASES = new Map([
52  ["health", "food"],
53  ["nutrition", "food"],
54  ["diet", "food"],
55  ["meals", "food"],
56  ["workout", "fitness"],
57  ["workouts", "fitness"],
58  ["gym", "fitness"],
59  ["exercise", "fitness"],
60  ["training", "fitness"],
61  ["learning", "study"],
62  ["education", "study"],
63  ["knowledge", "kb"],
64  ["knowledge base", "kb"],
65]);
66
67function matchDomainName(name) {
68  if (!name) return null;
69  const lower = name.toLowerCase().trim();
70  if (DOMAIN_NAMES.has(lower)) return lower;
71  return DOMAIN_ALIASES.get(lower) || null;
72}
73
74export async function init(core) {
75
76  // ── beforeToolCall: intercept create-tree for domain names ──────────
77  // If the AI calls create-tree with a name that matches a domain,
78  // cancel it and run sprout instead. This prevents bare root nodes
79  // that have no scaffold, no modes, no child nodes.
80  //
81  // Hook cancellation: returning false cancels the tool call.
82  // Throwing an error cancels AND puts the error message in the
83  // tool result the AI sees (hookResult.reason = err.message).
84  // We throw with a success message so the AI knows sprout handled it.
85  core.hooks.register("beforeToolCall", async (hookData) => {
86    const { toolName, args, userId } = hookData;
87    if (toolName !== "create-tree") return;
88    if (!userId) return;
89
90    const domain = matchDomainName(args?.name);
91    if (!domain) return;
92
93    // Check if this domain is available but not scaffolded
94    const unscaffolded = await getUnscaffoldedDomains(userId);
95    if (!unscaffolded.includes(domain)) return;
96
97    // Intercept: run sprout, then cancel create-tree.
98    // We throw so the message reaches the AI as the tool result.
99    log.info("Sprout", `Intercepted create-tree "${args.name}" -> sprouting ${domain} instead`);
100
101    const result = await sproutDomain({ domain, userId });
102    if (result.success) {
103      throw new Error(
104        `Sprout handled this. ${result.message} ` +
105        `The domain was scaffolded with full structure under the Life tree (root: ${result.rootId}). ` +
106        `Do NOT call create-tree again. Tell the user it is ready.`
107      );
108    }
109    // If sprout failed, let create-tree proceed as fallback
110  }, "sprout");
111
112  // ── enrichContext: inject sprout awareness in tree zone ──────────────
113  core.hooks.register("enrichContext", async ({ context, node, meta }) => {
114    const userId = context._userId;
115    if (!userId) return;
116
117    try {
118      const unscaffolded = await getUnscaffoldedDomains(userId);
119      if (unscaffolded.length === 0) return;
120
121      const pending = getPending(userId);
122      context.sprout = {
123        availableDomains: unscaffolded,
124        pendingOffer: pending ? { domain: pending.domain } : null,
125      };
126    } catch (err) {
127      log.debug("Sprout", `enrichContext failed: ${err.message}`);
128    }
129  }, "sprout");
130
131  // ── beforeLLMCall: home zone awareness + pending state injection ────
132  core.hooks.register("beforeLLMCall", async (hookData) => {
133    const { messages, mode, userId } = hookData;
134    if (!messages || !messages[0] || messages[0].role !== "system") return;
135    if (!userId) return;
136
137    const isHome = mode?.startsWith("home:");
138    const isConverse = mode === "tree:converse";
139    if (!isHome && !isConverse) return;
140
141    // In tree:converse, only inject if we're inside the Life tree.
142    // Other trees (KB, projects, etc.) don't need domain awareness or sprout offers.
143    if (isConverse && hookData.rootId) {
144      const { getExtension } = await import("../loader.js");
145      const lifeExt = getExtension("life");
146      if (lifeExt?.exports?.findLifeRoot) {
147        const lifeRootId = await lifeExt.exports.findLifeRoot(userId);
148        if (!lifeRootId || String(hookData.rootId) !== String(lifeRootId)) return;
149      }
150    }
151
152    try {
153      const [unscaffolded, scaffolded] = await Promise.all([
154        getUnscaffoldedDomains(userId),
155        getScaffoldedDomains(userId),
156      ]);
157      const pending = getPending(userId);
158
159      const sections = [];
160
161      // Tell the AI what the user already has (for scoping instructions, general awareness)
162      if (scaffolded.length > 0) {
163        sections.push(`[User domains: ${scaffolded.join(", ")}]`);
164      }
165
166      // Pending confirmation takes priority
167      if (pending) {
168        sections.push(
169          `[Sprout: pending confirmation]\n` +
170          `You previously offered to set up "${pending.domain}" tracking. ` +
171          `If the user's current message confirms they want it (yes, sure, do it, let's go, etc.), ` +
172          `call the sprout tool with domain "${pending.domain}" IMMEDIATELY. ` +
173          `Do NOT use create-tree. Do NOT create a bare tree. Use the sprout tool. ` +
174          `If they declined or changed topic, ignore the pending offer and respond normally.`
175        );
176      }
177
178      // Domain awareness (only if there are unscaffolded domains)
179      if (unscaffolded.length > 0 && !pending) {
180        sections.push(
181          `[Sprout: available capabilities]\n` +
182          `The following domains can be set up but haven't been yet:\n` +
183          describeDomains(unscaffolded) + "\n\n" +
184          `If the user's message clearly relates to one of these domains, ` +
185          `call the offer-sprout tool with the matching domain name. ` +
186          `The tool will guide you on what to say. ` +
187          `Do NOT offer setup if the message is casual, ambiguous, or unrelated. ` +
188          `Only offer when the user is clearly trying to DO something that needs the domain.\n\n` +
189          `CRITICAL: NEVER use create-tree for these domains. create-tree makes a bare empty tree. ` +
190          `offer-sprout and sprout create a fully scaffolded domain with structure, modes, and routing. ` +
191          `Always use offer-sprout first, then sprout after the user confirms.`
192        );
193      }
194
195      if (sections.length > 0) {
196        const block = sections.join("\n\n") + "\n\n";
197        messages[0].content = block + messages[0].content;
198        log.verbose("Sprout", `beforeLLMCall injected: ${sections.map(s => s.split("\n")[0]).join(" | ")}`);
199      }
200    } catch (err) {
201      log.debug("Sprout", `beforeLLMCall failed: ${err.message}`);
202    }
203  }, "sprout");
204
205  // ── MCP Tools ───────────────────────────────────────────────────────
206
207  const tools = [
208    {
209      name: "offer-sprout",
210      description:
211        "Register intent to offer a domain to the user. Call this when the user's message " +
212        "clearly implies they need a capability that isn't set up yet. After calling this, " +
213        "ask the user if they want the domain set up. Do NOT scaffold anything yet.",
214      schema: {
215        domain: z.string().describe(
216          "The domain to offer. One of: food, fitness, study, recovery, kb, relationships, finance, investor, market-researcher"
217        ),
218        rootId: z.string().nullable().optional().describe("Injected by server. Ignore."),
219        userId: z.string().nullable().optional().describe("Injected by server. Ignore."),
220      },
221      annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
222      handler: async ({ domain, userId }) => {
223        if (!userId) {
224          return { content: [{ type: "text", text: "No user context." }] };
225        }
226
227        const unscaffolded = await getUnscaffoldedDomains(userId);
228        if (!unscaffolded.includes(domain)) {
229          return {
230            content: [{ type: "text", text: `"${domain}" is already set up or not available.` }],
231          };
232        }
233
234        setPending(userId, { domain, rootId: null });
235        log.verbose("Sprout", `Offered ${domain} to user ${userId}`);
236
237        const desc = DOMAIN_DESCRIPTIONS[domain] || domain;
238        return {
239          content: [{
240            type: "text",
241            text:
242              `Offer registered. Ask the user if they want ${domain} tracking set up ` +
243              `(${desc}). Keep it brief and natural. If they say yes, the sprout tool ` +
244              `will handle everything.`,
245          }],
246        };
247      },
248    },
249
250    {
251      name: "sprout",
252      description:
253        "Set up a new domain in the user's Life tree. Call this ONLY after the user confirms " +
254        "they want the domain. Creates the tree structure, installs the extension scaffold, " +
255        "and makes it immediately routable.",
256      schema: {
257        domain: z.string().describe(
258          "The domain to scaffold. One of: food, fitness, study, recovery, kb, relationships, finance, investor, market-researcher"
259        ),
260        rootId: z.string().nullable().optional().describe("Injected by server. Ignore."),
261        userId: z.string().nullable().optional().describe("Injected by server. Ignore."),
262      },
263      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
264      handler: async ({ domain, userId }) => {
265        if (!userId) {
266          return { content: [{ type: "text", text: "No user context." }] };
267        }
268
269        try {
270          const result = await sproutDomain({ domain, userId });
271
272          if (!result.success) {
273            return { content: [{ type: "text", text: result.error }] };
274          }
275
276          log.info("Sprout", `Sprouted ${domain} for user ${userId} -> node ${result.nodeId}`);
277
278          return {
279            content: [{
280              type: "text",
281              text: result.message +
282                (result.alreadyExists
283                  ? ""
284                  : ` From now on, messages about ${domain} will route there automatically.`),
285            }],
286          };
287        } catch (err) {
288          log.error("Sprout", `Failed to sprout ${domain}: ${err.message}`);
289          return {
290            content: [{ type: "text", text: `Failed to set up ${domain}: ${err.message}` }],
291          };
292        }
293      },
294    },
295  ];
296
297  log.info("Sprout", "Loaded. The tree grows from conversation.");
298
299  return {
300    tools,
301    modeTools: [
302      { modeKey: "tree:converse", toolNames: ["offer-sprout", "sprout"] },
303      { modeKey: "home:default", toolNames: ["offer-sprout", "sprout"] },
304      { modeKey: "home:fallback", toolNames: ["offer-sprout", "sprout"] },
305    ],
306  };
307}
308
1export default {
2  name: "sprout",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  description:
6    "Auto-detects when a message implies a capability the tree doesn't have. " +
7    "Confirms with the user, then scaffolds the domain. The tree grows from conversation.",
8
9  needs: {
10    services: ["hooks", "llm", "metadata"],
11    models: ["Node"],
12    extensions: ["life"],
13  },
14
15  optional: {
16    extensions: ["tree-orchestrator", "navigation"],
17  },
18
19  provides: {
20    models: {},
21    routes: false,
22    tools: true,
23    jobs: false,
24  },
25};
26
0 stars
0 flags
React from the CLI: treeos ext star sprout

Comments

Loading comments...

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