8a9928a1972b7efcf7ef46feb895e9dfb462d90f5fbb06d265c08902d08a35691/**
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}
1781/**
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}
3081export 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
treeos ext star sprout
Post comments from the CLI: treeos ext comment sprout "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...