1import log from "../../seed/log.js";
2import { setServices, startIntentJob, stopIntentJob } from "./intentJob.js";
3export async function init(core) {
4 core.llm.registerRootLlmSlot("intent");
5
6 setServices({
7 models: core.models,
8 contributions: core.contributions,
9 energy: core.energy || null,
10 metadata: core.metadata,
11 });
12
13 const { setMetadata: setCollectorMetadata } = await import("./stateCollector.js");
14 setCollectorMetadata(core.metadata);
15
16 // enrichContext: surface intent data so the AI knows what the tree did autonomously
17 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
18 const intentMeta = meta?.intent;
19 if (!intentMeta) return;
20
21 const injected = {};
22
23 // Show recent executions so the AI knows what the tree did autonomously
24 if (intentMeta.recentExecutions?.length > 0) {
25 injected.recentIntents = intentMeta.recentExecutions.slice(0, 5).map(e => ({
26 action: e.action,
27 reason: e.reason,
28 result: e.result,
29 executedAt: e.executedAt,
30 }));
31 }
32
33 // Show pending queue so the AI knows what's coming
34 if (intentMeta.queue?.length > 0) {
35 injected.pendingIntents = intentMeta.queue.length;
36 }
37
38 // Show rejected intents so the AI doesn't suggest what the user already rejected
39 if (intentMeta.rejected?.length > 0) {
40 injected.rejectedIntents = intentMeta.rejected.map(r => r.action || r.pattern || r.description).filter(Boolean);
41 }
42
43 if (Object.keys(injected).length > 0) {
44 context.intent = injected;
45 }
46 }, "intent");
47
48 const { default: router, setModels, setMetadata: setRouteMetadata } = await import("./routes.js");
49 setModels(core.models);
50 setRouteMetadata(core.metadata);
51
52 log.info("Intent", "Autonomous intent engine loaded");
53
54 return {
55 router,
56 jobs: [
57 {
58 name: "intent-cycle",
59 start: () => startIntentJob(),
60 stop: () => stopIntentJob(),
61 },
62 ],
63 };
64}
65
1// Intent Job
2//
3// The background job that makes the tree autonomous. Runs on a configurable
4// interval. For each opted-in tree, collects state, generates intents
5// through the AI, then executes each intent as a real AI interaction
6// at the target node.
7//
8// Uses OrchestratorRuntime for session lifecycle, lock management,
9// abort support, and step tracking. Lock prevents two intent cycles
10// from racing on the same tree.
11//
12// The intent cycle:
13// 1. Find trees with metadata.intent.enabled = true
14// 2. For each tree, collect state from all installed signal sources
15// 3. Send the state to the AI via rt.runStep with the intent generation prompt
16// 4. Parse the returned intents (JSON array)
17// 5. For each intent, rt.runStep at the target node
18// 6. Log each execution as a contribution (action: "intent:executed")
19// 7. Write results to .intent node as notes
20
21import log from "../../seed/log.js";
22import { collectTreeState, formatStateForPrompt } from "./stateCollector.js";
23import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
24import { OrchestratorRuntime } from "../../seed/orchestrators/runtime.js";
25import { getLandConfigValue } from "../../seed/landConfig.js";
26
27let LLM_PRIORITY;
28try {
29 ({ LLM_PRIORITY } = await import("../../seed/llm/conversation.js"));
30} catch {
31 LLM_PRIORITY = { BACKGROUND: 4 };
32}
33
34let Node = null;
35let User = null;
36let logContribution = null;
37let useEnergy = async () => ({ energyUsed: 0 });
38let _metadata = null;
39
40export function setServices({ models, contributions, energy, metadata }) {
41 Node = models.Node;
42 User = models.User;
43 logContribution = contributions.logContribution;
44 if (energy?.useEnergy) useEnergy = energy.useEnergy;
45 if (metadata) _metadata = metadata;
46}
47
48let _timer = null;
49let _running = false;
50
51function getIntervalMs() {
52 return Number(getLandConfigValue("intentIntervalMs")) || 30 * 60 * 1000; // 30 min default
53}
54
55function getMaxIntentsPerCycle() {
56 return Number(getLandConfigValue("intentMaxPerCycle")) || 5;
57}
58
59export function startIntentJob() {
60 if (_timer) return;
61 const interval = getIntervalMs();
62 _timer = setInterval(runCycle, interval);
63 if (_timer.unref) _timer.unref();
64 log.info("Intent", `Intent job started (checking every ${Math.round(interval / 60000)}m)`);
65}
66
67export function stopIntentJob() {
68 if (_timer) {
69 clearInterval(_timer);
70 _timer = null;
71 }
72}
73
74// ─────────────────────────────────────────────────────────────────────────
75// INTENT GENERATION PROMPT
76// ─────────────────────────────────────────────────────────────────────────
77
78const GENERATION_PROMPT = `You are the intent engine for this tree. You observe its health, patterns, contradictions, gaps, and user behavior. Generate actions the tree should take on its own.
79
80Rules:
81- Only generate intents justified by the data below
82- Never generate intents matching the rejected patterns listed
83- Priority: health > contradictions > maintenance > nudges
84- Max {maxIntents} intents per cycle
85- Each intent must name a specific nodeId and specific tools to use
86- Never generate delete actions. The tree can create, write, compress, alert. It cannot delete.
87- Do not invent work. If the state looks healthy with no issues, return an empty array.
88
89Return a JSON array of intents. Each intent:
90{
91 "action": "short description of what to do",
92 "reason": "why this is justified by the current state",
93 "targetNodeId": "the node ID to act on",
94 "priority": "high" | "medium" | "low",
95 "tools": ["tool-names", "to-use"],
96 "mode": "tree:respond" (or whichever mode is appropriate)
97}
98
99If nothing needs attention, return: []
100
101Current tree state:
102- {stateText}`;
103
104// ─────────────────────────────────────────────────────────────────────────
105// CYCLE
106// ─────────────────────────────────────────────────────────────────────────
107
108async function runCycle() {
109 if (_running) return; // prevent overlap
110 _running = true;
111
112 try {
113 // Find all trees opted in for autonomous intent
114 const roots = await Node.find({
115 rootOwner: { $nin: [null, "SYSTEM"] },
116 "metadata.intent.enabled": true,
117 }).select("_id name rootOwner metadata").lean();
118
119 if (roots.length === 0) {
120 _running = false;
121 return;
122 }
123
124 log.verbose("Intent", `Intent cycle: ${roots.length} tree(s) opted in`);
125
126 for (const root of roots) {
127 try {
128 // Check if paused
129 const intentMeta = _metadata.getExtMeta(root, "intent");
130 if (intentMeta.paused) continue;
131
132 await processTree(root);
133 } catch (err) {
134 log.warn("Intent", `Intent cycle failed for tree ${root.name} (${root._id}): ${err.message}`);
135 }
136 }
137 } catch (err) {
138 log.error("Intent", `Intent cycle error: ${err.message}`);
139 } finally {
140 _running = false;
141 }
142}
143
144async function processTree(root) {
145 const rootId = root._id.toString();
146 const userId = root.rootOwner?.toString();
147 if (!userId) return;
148
149 const user = await User.findById(userId).select("username").lean();
150 if (!user) return;
151
152 // Create runtime. Intent is a standalone background pipeline.
153 const rt = new OrchestratorRuntime({
154 rootId,
155 userId,
156 username: user.username,
157 visitorId: `intent:${rootId}:${Date.now()}`,
158 sessionType: "INTENT",
159 description: `Intent cycle for tree ${root.name}`,
160 modeKeyForLlm: "tree:respond",
161 slot: "intent",
162 lockNamespace: "intent",
163 lockKey: `intent:${rootId}`,
164 llmPriority: LLM_PRIORITY?.BACKGROUND || 4,
165 });
166
167 const ok = await rt.init("Starting intent cycle");
168 if (!ok) {
169 log.debug("Intent", `Intent cycle already running for ${root.name}. Skipping.`);
170 return;
171 }
172
173 try {
174 // 1. Collect state
175 const state = await collectTreeState(rootId, root, { Node });
176 const stateText = formatStateForPrompt(state);
177
178 if (!stateText) {
179 log.debug("Intent", `No observable state for ${root.name}. Skipping.`);
180 rt.setResult("No observable state", "tree:respond");
181 return;
182 }
183
184 rt.trackStep("tree:respond", {
185 input: { phase: "state-collection", rootId },
186 output: { sectionsCollected: stateText.split("\n").length },
187 startTime: Date.now(),
188 endTime: Date.now(),
189 });
190
191 if (rt.aborted) {
192 rt.setError("Intent cycle cancelled", "tree:respond");
193 return;
194 }
195
196 // 2. Energy check for generation
197 try {
198 await useEnergy({ userId, action: "intentGenerate" });
199 } catch {
200 log.debug("Intent", `Insufficient energy for intent generation on ${root.name}`);
201 rt.setResult("Insufficient energy", "tree:respond");
202 return;
203 }
204
205 // 3. Generate intents via AI (through runStep)
206 const maxIntents = getMaxIntentsPerCycle();
207 const prompt = GENERATION_PROMPT
208 .replace("{maxIntents}", String(maxIntents))
209 .replace("{stateText}", stateText);
210
211 let parsed = null;
212 try {
213 const result = await rt.runStep("tree:respond", {
214 prompt,
215 });
216 parsed = result?.parsed;
217 } catch (err) {
218 log.warn("Intent", `Intent generation LLM call failed for ${root.name}: ${err.message}`);
219 rt.setError(err.message, "tree:respond");
220 return;
221 }
222
223 if (!Array.isArray(parsed)) {
224 log.debug("Intent", `Intent generation returned non-array for ${root.name}`);
225 rt.setResult("No intents generated (non-array response)", "tree:respond");
226 return;
227 }
228
229 const intents = parsed
230 .filter(i => i && typeof i === "object" && i.action && i.targetNodeId)
231 .slice(0, maxIntents);
232
233 if (intents.length === 0) {
234 log.debug("Intent", `No intents generated for ${root.name}. Tree is healthy.`);
235 rt.setResult("No intents needed", "tree:respond");
236 return;
237 }
238
239 log.verbose("Intent", `Generated ${intents.length} intent(s) for ${root.name}`);
240
241 // 4. Ensure .intent node exists
242 const intentNodeId = await ensureIntentNode(rootId);
243
244 // 5. Execute each intent (each is a runStep at the target node)
245 const recentExecutions = [];
246
247 for (const intent of intents) {
248 if (rt.aborted) {
249 log.debug("Intent", "Intent cycle aborted between executions");
250 break;
251 }
252
253 try {
254 const execResult = await executeIntent(rt, intent, rootId, userId, user.username, intentNodeId);
255 recentExecutions.push({
256 action: intent.action,
257 reason: intent.reason,
258 result: execResult?.slice(0, 200) || "completed",
259 executedAt: new Date().toISOString(),
260 });
261 } catch (err) {
262 log.warn("Intent", `Intent execution failed: ${intent.action}: ${err.message}`);
263 await writeIntentResult(intentNodeId, intent, null, err.message);
264 }
265 }
266
267 // 6. Write recent executions to metadata for enrichContext
268 try {
269 const rootNode = await Node.findById(rootId);
270 if (rootNode) {
271 const meta = _metadata.getExtMeta(rootNode, "intent") || {};
272 meta.recentExecutions = [
273 ...recentExecutions,
274 ...(meta.recentExecutions || []),
275 ].slice(0, 20);
276 await _metadata.setExtMeta(rootNode, "intent", meta);
277 }
278 } catch (err) {
279 log.debug("Intent", `Failed to write recent executions to metadata: ${err.message}`);
280 }
281
282 rt.setResult(`Executed ${recentExecutions.length} intent(s)`, "tree:respond");
283 } catch (err) {
284 rt.setError(err.message, "tree:respond");
285 throw err;
286 } finally {
287 await rt.cleanup();
288 }
289}
290
291// ─────────────────────────────────────────────────────────────────────────
292// EXECUTION
293// ─────────────────────────────────────────────────────────────────────────
294
295async function executeIntent(rt, intent, rootId, userId, username, intentNodeId) {
296 // Energy check per execution
297 try {
298 await useEnergy({ userId, action: "intentExecute" });
299 } catch {
300 log.debug("Intent", `Insufficient energy for intent execution: ${intent.action}`);
301 return null;
302 }
303
304 // Verify target node exists and is in this tree
305 const targetNode = await Node.findById(intent.targetNodeId).select("_id name rootOwner").lean();
306 if (!targetNode) {
307 log.debug("Intent", `Intent target node not found: ${intent.targetNodeId}`);
308 return null;
309 }
310
311 // Build the intent message. This becomes the AI's instruction.
312 const message =
313 `[Autonomous intent, priority: ${intent.priority || "medium"}] ` +
314 `${intent.action}. ` +
315 `Reason: ${intent.reason || "observed state change"}. ` +
316 `Use tools: ${(intent.tools || []).join(", ") || "as needed"}.`;
317
318 log.verbose("Intent", `Executing: "${intent.action}" at node ${targetNode.name || intent.targetNodeId}`);
319
320 const mode = intent.mode || "tree:respond";
321
322 // Execute through the runtime's runStep
323 let resultText = null;
324 try {
325 const { raw } = await rt.runStep(mode, {
326 prompt: message,
327 treeContext: { nodeId: intent.targetNodeId },
328 });
329 resultText = raw || "completed";
330 } catch (err) {
331 await writeIntentResult(intentNodeId, intent, null, err.message);
332 throw err;
333 }
334
335 // Log as contribution
336 await logContribution({
337 userId,
338 nodeId: intent.targetNodeId,
339 wasAi: true,
340 action: "intent:executed",
341 extensionData: {
342 intent: {
343 action: intent.action,
344 reason: intent.reason,
345 priority: intent.priority,
346 targetNodeId: intent.targetNodeId,
347 tools: intent.tools,
348 result: resultText?.slice(0, 500) || null,
349 },
350 },
351 });
352
353 // Write result to .intent node
354 await writeIntentResult(intentNodeId, intent, resultText, null);
355 return resultText;
356}
357
358// ─────────────────────────────────────────────────────────────────────────
359// .INTENT NODE
360// ─────────────────────────────────────────────────────────────────────────
361
362async function ensureIntentNode(rootId) {
363 // Find or create the .intent node under the tree root
364 let intentNode = await Node.findOne({
365 parent: rootId,
366 name: ".intent",
367 }).select("_id").lean();
368
369 if (!intentNode) {
370 const { createSystemNode } = await import("../../seed/tree/treeManagement.js");
371 intentNode = await createSystemNode({ name: ".intent", parentId: rootId });
372 log.verbose("Intent", `Created .intent node for tree ${rootId}`);
373 }
374
375 return intentNode._id.toString();
376}
377
378async function writeIntentResult(intentNodeId, intent, result, error) {
379 try {
380 const { createNote } = await import("../../seed/tree/notes.js");
381 const content = error
382 ? `[FAILED] ${intent.action}\nReason: ${intent.reason}\nError: ${error}`
383 : `[${intent.priority || "medium"}] ${intent.action}\nReason: ${intent.reason}\nResult: ${(result || "completed").slice(0, 2000)}`;
384
385 await createNote({
386 nodeId: intentNodeId,
387 content,
388 contentType: "text",
389 userId: "SYSTEM",
390 });
391 } catch (err) {
392 log.debug("Intent", `Failed to write intent result note: ${err.message}`);
393 }
394}
395
1export default {
2 name: "intent",
3 version: "1.0.1",
4 builtFor: "treeos-intelligence",
5 description:
6 "The tree acts without being asked. " +
7 "\n\n" +
8 "Right now every interaction starts with a human. You type. The AI responds. " +
9 "You navigate. The AI resolves. Even cascade is triggered by you writing a note. " +
10 "Remove the human and the tree sits there. Dormant. Waiting. A real tree doesn't " +
11 "wait for someone to tell it to grow toward sunlight. It just grows. " +
12 "\n\n" +
13 "Intent gives the digital tree the same property. " +
14 "\n\n" +
15 "A background job runs on a configurable interval. It reads the state of the tree " +
16 "from every source available through enrichContext. Pulse says the failure rate is " +
17 "climbing. Evolution says a branch went dormant 45 days ago. Contradiction detected " +
18 "two conflicting targets three days ago and nobody resolved them. The codebook between " +
19 "two nodes hasn't compressed in 200 interactions. A cascade signal arrived carrying " +
20 "extension metadata this land doesn't have. " +
21 "\n\n" +
22 "Intent synthesizes all of that into a queue of actions the tree should take on its own. " +
23 "Each intent becomes a real AI interaction. The job calls runChat at the target node with " +
24 "the intent as the message. The AI activates in whatever mode is configured at that position. " +
25 "It has all the tools available at that node. It does the work. Creates notes. Fires cascade " +
26 "signals. Resolves contradictions. Compresses codebooks. Prunes dormant branches. Alerts the " +
27 "operator through gateway channels. " +
28 "\n\n" +
29 "The intent queue lives on a .intent system node under the tree root. Each processed intent " +
30 "writes its result as a note. The user wakes up and checks .intent to see what the tree did " +
31 "overnight. Or they don't check. The tree handled it. " +
32 "\n\n" +
33 "What makes this different from a cron job: cron runs the same action on a schedule. Intent " +
34 "generates novel actions from observed state. Two trees with identical schedules would generate " +
35 "completely different intents because their states are different. The intent generation itself " +
36 "goes through the AI. A prompt receives the full state summary and asks: what should this tree " +
37 "do next that nobody has asked for? " +
38 "\n\n" +
39 "Safety: intent respects spatial scoping, ownership (metadata.intent.enabled must be true on " +
40 "the tree root), energy budgets (intentMaxTokensPerCycle), and never deletes. It can create, " +
41 "write, compress, alert. Destructive actions require a human. Every processed intent is logged " +
42 "as a contribution with action: intent:executed. Full audit trail. " +
43 "\n\n" +
44 "The user can talk back. Navigate to .intent. Chat: stop nudging me about running. The " +
45 "contradiction extension marks that as an intentional gap. The inverse-tree records the " +
46 "correction. Intent stops generating that nudge. The tree learned. " +
47 "\n\n" +
48 "Dependencies are all optional. Each one that's installed adds signal to the intent generation. " +
49 "Without any of them, intent has nothing to observe and generates nothing. With all of them, " +
50 "the tree is fully autonomous between human interactions. " +
51 "\n\n" +
52 "You planted the seed. The seed grew a tree. The tree learned to think. And now the tree " +
53 "learned to want.",
54
55 needs: {
56 services: ["llm", "hooks", "contributions", "session", "chat", "orchestrator"],
57 models: ["Node", "User"],
58 },
59
60 optional: {
61 services: ["energy"],
62 extensions: [
63 "pulse",
64 "evolution",
65 "contradiction",
66 "codebook",
67 "gap-detection",
68 "inverse-tree",
69 "long-memory",
70 "treeos-cascade",
71 "gateway",
72 ],
73 },
74
75 provides: {
76 models: {},
77 routes: false,
78 tools: false,
79 jobs: true,
80 orchestrator: false,
81 energyActions: {
82 intentGenerate: { cost: 3 },
83 intentExecute: { cost: 2 },
84 },
85 sessionTypes: {
86 INTENT_CYCLE: "intent-cycle",
87 },
88
89 hooks: {
90 fires: [],
91 listens: ["afterBoot", "enrichContext"],
92 },
93
94 cli: [
95 {
96 command: "intent [action] [args...]", scope: ["tree"],
97 description: "Autonomous intent queue and recent executions. Actions: pause, resume, history, reject.",
98 method: "GET",
99 endpoint: "/root/:rootId/intent",
100 subcommands: {
101 "pause": { method: "POST", endpoint: "/root/:rootId/intent/pause", description: "Pause autonomous behavior" },
102 "resume": { method: "POST", endpoint: "/root/:rootId/intent/resume", description: "Resume autonomous behavior" },
103 "history": { method: "GET", endpoint: "/root/:rootId/intent/history", description: "What the tree did on its own" },
104 "reject": { method: "POST", endpoint: "/root/:rootId/intent/reject", args: ["id"], description: "Tell the tree not to do that again" },
105 },
106 },
107 ],
108 },
109};
110
1import log from "../../seed/log.js";
2import express from "express";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5
6let Node = null;
7let Note = null;
8let Contribution = null;
9let _metadata = null;
10export function setModels(models) {
11 Node = models.Node;
12 Note = models.Note;
13 Contribution = models.Contribution;
14}
15export function setMetadata(metadata) { _metadata = metadata; }
16
17function validateRootId(req, res) {
18 const rootId = req.params.rootId;
19 if (!rootId || rootId === "undefined" || rootId === "null") {
20 sendError(res, 400, ERR.INVALID_INPUT, "rootId is required");
21 return null;
22 }
23 return rootId;
24}
25
26const router = express.Router();
27
28// GET /root/:rootId/intent - Show current queue and recent executions
29router.get("/root/:rootId/intent", authenticate, async (req, res) => {
30 try {
31 const rootId = validateRootId(req, res);
32 if (!rootId) return;
33 const root = await Node.findById(rootId).select("metadata name").lean();
34 if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
35
36 const intentMeta = _metadata.getExtMeta(root, "intent");
37
38 // Find .intent node and its recent notes
39 const intentNode = await Node.findOne({ parent: rootId, name: ".intent" }).select("_id").lean();
40 let recentExecutions = [];
41 if (intentNode) {
42 recentExecutions = await Note.find({ nodeId: intentNode._id })
43 .sort({ dateCreated: -1 })
44 .limit(20)
45 .select("content dateCreated")
46 .lean();
47 }
48
49 sendOk(res, {
50 rootId,
51 enabled: !!intentMeta.enabled,
52 paused: !!intentMeta.paused,
53 rejections: intentMeta.rejections || [],
54 recentExecutions: recentExecutions.map(n => ({
55 content: n.content,
56 executedAt: n.dateCreated,
57 })),
58 });
59 } catch (err) {
60 sendError(res, 500, ERR.INTERNAL, err.message);
61 }
62});
63
64// POST /root/:rootId/intent/pause - Pause autonomous behavior
65router.post("/root/:rootId/intent/pause", authenticate, async (req, res) => {
66 try {
67 const rootId = validateRootId(req, res);
68 if (!rootId) return;
69 const root = await Node.findById(rootId);
70 if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
71
72 const meta = _metadata.getExtMeta(root, "intent");
73 meta.paused = true;
74 await _metadata.setExtMeta(root, "intent", meta);
75
76 log.verbose("Intent", `Autonomous intent paused for tree ${root.name}`);
77 sendOk(res, { paused: true });
78 } catch (err) {
79 sendError(res, 500, ERR.INTERNAL, err.message);
80 }
81});
82
83// POST /root/:rootId/intent/resume - Resume autonomous behavior
84router.post("/root/:rootId/intent/resume", authenticate, async (req, res) => {
85 try {
86 const rootId = validateRootId(req, res);
87 if (!rootId) return;
88 const root = await Node.findById(rootId);
89 if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
90
91 const meta = _metadata.getExtMeta(root, "intent");
92 meta.paused = false;
93 await _metadata.setExtMeta(root, "intent", meta);
94
95 log.verbose("Intent", `Autonomous intent resumed for tree ${root.name}`);
96 sendOk(res, { paused: false });
97 } catch (err) {
98 sendError(res, 500, ERR.INTERNAL, err.message);
99 }
100});
101
102// GET /root/:rootId/intent/history - Full history of autonomous actions
103router.get("/root/:rootId/intent/history", authenticate, async (req, res) => {
104 try {
105 const rootId = req.params.rootId;
106
107 // Get contributions logged by intent
108 const contributions = await Contribution.find({
109 action: "intent:executed",
110 })
111 .sort({ date: -1 })
112 .limit(50)
113 .lean();
114
115 // Filter to this tree's nodes (walk from root, not rootOwner which is a userId)
116 const { getDescendantIds } = await import("../../seed/tree/treeFetch.js");
117 const allIds = await getDescendantIds(rootId, { maxResults: 10000 });
118 const nodeIds = new Set(allIds.map(id => String(id)));
119
120 const treeContributions = contributions
121 .filter(c => nodeIds.has(c.nodeId?.toString()))
122 .map(c => ({
123 action: c.extensionData?.intent?.action,
124 reason: c.extensionData?.intent?.reason,
125 priority: c.extensionData?.intent?.priority,
126 targetNodeId: c.extensionData?.intent?.targetNodeId,
127 result: c.extensionData?.intent?.result,
128 executedAt: c.date,
129 }));
130
131 sendOk(res, { history: treeContributions });
132 } catch (err) {
133 sendError(res, 500, ERR.INTERNAL, err.message);
134 }
135});
136
137// POST /root/:rootId/intent/reject - Tell the tree not to do that again
138router.post("/root/:rootId/intent/reject", authenticate, async (req, res) => {
139 try {
140 const { id, description } = req.body;
141 if (!id && !description) {
142 return sendError(res, 400, ERR.INVALID_INPUT, "Provide an intent id or description to reject");
143 }
144
145 const rootId = validateRootId(req, res);
146 if (!rootId) return;
147 const root = await Node.findById(rootId);
148 if (!root) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Tree not found");
149
150 const meta = _metadata.getExtMeta(root, "intent");
151 if (!meta.rejections) meta.rejections = [];
152
153 // Cap rejections list to prevent unbounded growth
154 if (meta.rejections.length >= 100) {
155 meta.rejections = meta.rejections.slice(-50);
156 }
157
158 meta.rejections.push({
159 pattern: description || id,
160 rejectedAt: new Date().toISOString(),
161 rejectedBy: req.userId,
162 });
163
164 await _metadata.setExtMeta(root, "intent", meta);
165
166 log.verbose("Intent", `Intent rejected for tree ${root.name}: ${description || id}`);
167 sendOk(res, { rejected: true, totalRejections: meta.rejections.length });
168 } catch (err) {
169 sendError(res, 500, ERR.INTERNAL, err.message);
170 }
171});
172
173export default router;
174
1// State Collector
2//
3// Reads every available signal source for a tree. Each source is optional.
4// If the extension is installed, its data contributes to the state snapshot.
5// If not, that section is empty. Intent generates from whatever is available.
6
7import log from "../../seed/log.js";
8import { getExtension } from "../loader.js";
9
10let _metadata = null;
11export function setMetadata(metadata) { _metadata = metadata; }
12
13/**
14 * Collect the full observable state of a tree for intent generation.
15 *
16 * @param {string} rootId
17 * @param {object} rootNode - the tree root node document
18 * @param {object} models - { Node, User }
19 * @returns {object} state snapshot with sections for each signal source
20 */
21export async function collectTreeState(rootId, rootNode, models) {
22 const { Node } = models;
23 const state = {
24 rootId,
25 rootName: rootNode.name,
26 collectedAt: new Date().toISOString(),
27 pulse: null,
28 evolution: null,
29 contradictions: null,
30 codebook: null,
31 gaps: null,
32 userProfile: null,
33 cascade: null,
34 circuit: null,
35 rejections: [],
36 };
37
38 // Pulse: health snapshots
39 const pulseExt = getExtension("pulse");
40 if (pulseExt?.exports?.getHealthSnapshot) {
41 try {
42 state.pulse = await pulseExt.exports.getHealthSnapshot(rootId);
43 } catch (err) {
44 log.debug("Intent", `Pulse data unavailable for ${rootId}: ${err.message}`);
45 }
46 }
47
48 // Evolution: dormant branches, fitness metrics
49 const evolutionExt = getExtension("evolution");
50 if (evolutionExt?.exports?.getEvolutionState) {
51 try {
52 state.evolution = await evolutionExt.exports.getEvolutionState(rootId);
53 } catch (err) {
54 log.debug("Intent", `Evolution data unavailable for ${rootId}: ${err.message}`);
55 }
56 }
57
58 // Contradiction: unresolved conflicts
59 const contradictionExt = getExtension("contradiction");
60 if (contradictionExt?.exports?.getUnresolved) {
61 try {
62 state.contradictions = await contradictionExt.exports.getUnresolved(rootId);
63 } catch (err) {
64 log.debug("Intent", `Contradiction data unavailable for ${rootId}: ${err.message}`);
65 }
66 }
67
68 // Codebook: compression status
69 const codebookExt = getExtension("codebook");
70 if (codebookExt?.exports?.getCompressionStatus) {
71 try {
72 state.codebook = await codebookExt.exports.getCompressionStatus(rootId);
73 } catch (err) {
74 log.debug("Intent", `Codebook data unavailable for ${rootId}: ${err.message}`);
75 }
76 }
77
78 // Gap detection: missing extensions
79 const gapExt = getExtension("gap-detection");
80 if (gapExt?.exports?.getGaps) {
81 try {
82 state.gaps = await gapExt.exports.getGaps(rootId);
83 } catch (err) {
84 log.debug("Intent", `Gap data unavailable for ${rootId}: ${err.message}`);
85 }
86 }
87
88 // Inverse-tree: user profile, goals vs actions
89 const inverseExt = getExtension("inverse-tree");
90 if (inverseExt?.exports?.getProfile) {
91 try {
92 state.userProfile = await inverseExt.exports.getProfile(rootNode.rootOwner);
93 } catch (err) {
94 log.debug("Intent", `Inverse-tree data unavailable for ${rootId}: ${err.message}`);
95 }
96 }
97
98 // Cascade flow: stuck nodes, flow rates
99 try {
100 const flowMeta = _metadata.getExtMeta(rootNode, "flow");
101 if (flowMeta && Object.keys(flowMeta).length > 0) {
102 state.cascade = flowMeta;
103 }
104 } catch (err) {
105 log.debug("Intent", "Cascade flow metadata read failed:", err.message);
106 }
107
108 // Circuit breaker: approaching thresholds
109 try {
110 const circuitMeta = _metadata.getExtMeta(rootNode, "circuit");
111 if (circuitMeta) {
112 state.circuit = circuitMeta;
113 }
114 } catch (err) {
115 log.debug("Intent", "Circuit breaker metadata read failed:", err.message);
116 }
117
118 // Load rejections (intents the user said "don't do that again")
119 try {
120 const intentMeta = _metadata.getExtMeta(rootNode, "intent");
121 if (intentMeta?.rejections) {
122 state.rejections = intentMeta.rejections;
123 }
124 } catch (err) {
125 log.debug("Intent", "Intent metadata read failed:", err.message);
126 }
127
128 return state;
129}
130
131/**
132 * Format the collected state into a prompt-friendly string.
133 * Only includes sections that have data.
134 */
135export function formatStateForPrompt(state) {
136 const sections = [];
137
138 if (state.pulse) {
139 sections.push(`Pulse health: ${JSON.stringify(state.pulse)}`);
140 }
141 if (state.evolution) {
142 sections.push(`Evolution: ${JSON.stringify(state.evolution)}`);
143 }
144 if (state.contradictions && state.contradictions.length > 0) {
145 sections.push(`Unresolved contradictions (${state.contradictions.length}): ${JSON.stringify(state.contradictions.slice(0, 10))}`);
146 }
147 if (state.codebook) {
148 sections.push(`Codebook compression status: ${JSON.stringify(state.codebook)}`);
149 }
150 if (state.gaps && state.gaps.length > 0) {
151 sections.push(`Detected gaps (${state.gaps.length}): ${JSON.stringify(state.gaps.slice(0, 10))}`);
152 }
153 if (state.userProfile) {
154 sections.push(`User profile: ${JSON.stringify(state.userProfile)}`);
155 }
156 if (state.cascade) {
157 sections.push(`Cascade flow: ${JSON.stringify(state.cascade)}`);
158 }
159 if (state.circuit) {
160 sections.push(`Circuit breaker: ${JSON.stringify(state.circuit)}`);
161 }
162
163 if (sections.length === 0) {
164 return null; // Nothing to observe. No intents to generate.
165 }
166
167 let prompt = sections.join("\n- ");
168
169 if (state.rejections.length > 0) {
170 const rejectSummary = state.rejections
171 .slice(-20)
172 .map(r => r.pattern || r.description || r.action)
173 .filter(Boolean)
174 .join("; ");
175 if (rejectSummary) {
176 prompt += `\n\nRejected intents (do not regenerate these): ${rejectSummary}`;
177 }
178 }
179
180 return prompt;
181}
182
Loading comments...