cb264ce8e7294d8310c3e75ccddabc0c64c05581d662877fa2956ff78678a2c5afterBootafterMetadataWritebeforeNodeDeleteafterNodeMove1import { orchestrateTreeRequest, clearMemory } from "./orchestrator.js";
2import { setClearMemoryFn } from "../../seed/ws/websocket.js";
3import { rebuildAll, rebuildIndexForRoot, invalidateRoot, getIndexForRoot, getAllIndexedRoots } from "./routingIndex.js";
4import { resolveRootNode } from "../../seed/tree/treeFetch.js";
5import log from "../../seed/log.js";
6
7export async function init(core) {
8 // Wire orchestrator memory cleanup into the WebSocket disconnect/clear path
9 setClearMemoryFn(clearMemory);
10
11 // ── Routing index: rebuild on boot ──
12 core.hooks.register("afterBoot", async () => {
13 try {
14 await rebuildAll();
15 } catch (err) {
16 log.debug("TreeOrchestrator", `Routing index build failed: ${err.message}`);
17 }
18 }, "tree-orchestrator");
19
20 // ── Routing index: rebuild when modes change on a node ──
21 core.hooks.register("afterMetadataWrite", async ({ nodeId, extName }) => {
22 if (extName !== "modes") return;
23 try {
24 const root = await resolveRootNode(nodeId);
25 if (root?._id) await rebuildIndexForRoot(root._id);
26 } catch {}
27 }, "tree-orchestrator");
28
29 // ── Routing index: invalidate on node deletion ──
30 core.hooks.register("beforeNodeDelete", async ({ node }) => {
31 try {
32 if (node.rootOwner) {
33 invalidateRoot(node._id);
34 } else if (node.parent) {
35 const root = await resolveRootNode(node.parent);
36 if (root?._id) await rebuildIndexForRoot(root._id);
37 }
38 } catch {}
39 // Never cancel deletion. Return undefined.
40 }, "tree-orchestrator");
41
42 // ── Routing index: new nodes might get modes set via afterMetadataWrite above ──
43 // afterNodeCreate itself doesn't need a handler since modes are set
44 // after creation via setExtMeta, which fires afterMetadataWrite.
45
46 // ── Routing index: rebuild when extension scoping changes (ext-allow, ext-block) ──
47 core.hooks.register("afterScopeChange", async ({ nodeId }) => {
48 try {
49 const root = await resolveRootNode(nodeId);
50 if (root?._id) await rebuildIndexForRoot(root._id);
51 } catch {}
52 }, "tree-orchestrator");
53
54 core.hooks.register("afterNodeMove", async ({ nodeId, oldParentId, newParentId }) => {
55 try {
56 const oldRoot = await resolveRootNode(oldParentId);
57 const newRoot = await resolveRootNode(newParentId);
58 if (oldRoot?._id) await rebuildIndexForRoot(oldRoot._id);
59 if (newRoot?._id && String(newRoot._id) !== String(oldRoot?._id)) {
60 await rebuildIndexForRoot(newRoot._id);
61 }
62 } catch {}
63 }, "tree-orchestrator");
64
65 // Register LLM slots for semantic routing
66 // Operators assign cheap/fast models to these for routing decisions
67 core.llm.registerRootLlmSlot?.("departure");
68 core.llm.registerRootLlmSlot?.("territory");
69
70 return {
71 orchestrator: {
72 bigMode: "tree",
73 handle: orchestrateTreeRequest,
74 },
75 exports: {
76 orchestrateTreeRequest,
77 getIndexForRoot,
78 getAllIndexedRoots,
79 rebuildIndexForRoot,
80 },
81 };
82}
831export default {
2 name: "tree-orchestrator",
3 version: "1.0.5",
4 builtFor: "TreeOS",
5 description:
6 "Position determines reality. Every message that enters a tree passes through this " +
7 "orchestrator. It routes to the right extension or to tree:converse. " +
8 "\n\n" +
9 "The routing index maps extensions to positions in the tree. If the current node or a " +
10 "nearby node has a mode override (modes.respond), and the extension's classifier hints " +
11 "match the message, the orchestrator routes directly to that extension's mode. One mode, " +
12 "focused tools, domain-specific system prompt. No LLM call for routing. " +
13 "\n\n" +
14 "When multiple extensions match (classifier hints from two or more extensions in the " +
15 "routing index fire on the same message), the orchestrator chains them. Each extension " +
16 "runs as a focused agent in its own mode. Results pass from one step to the next. " +
17 "Browser-bridge reads a page, KB saves the key points, gateway posts to Reddit. Each " +
18 "step has only its own tools. " +
19 "\n\n" +
20 "When no extension claims the message, tree:converse handles it. Converse reads the " +
21 "node's notes, children, and path, then talks from that position's perspective. Every " +
22 "node has a voice. No extension needed. " +
23 "\n\n" +
24 "This is the reference tree orchestrator. Replace it entirely by registering a custom " +
25 "orchestrator for bigMode tree.",
26
27 needs: {
28 services: ["llm", "session", "chat", "mcp", "websocket", "hooks", "orchestrator"],
29 models: ["Node"],
30 extensions: ["treeos-base"],
31 },
32
33 optional: {
34 extensions: ["competence", "explore", "contradiction", "purpose", "evolution", "remember", "understanding"],
35 },
36
37 provides: {
38 routes: false,
39 tools: false,
40 jobs: false,
41 orchestrator: { bigMode: "tree" },
42 hooks: {
43 fires: [],
44 listens: ["afterBoot", "afterMetadataWrite", "beforeNodeDelete", "afterNodeMove"],
45 },
46 },
47};
481// orchestrators/tree.js
2// Position determines reality.
3// Extension at this position? Route to its mode.
4// No extension? tree:converse. The AI reads what's here and talks.
5
6import log from "../../seed/log.js";
7import { WS } from "../../seed/protocol.js";
8import {
9 switchMode,
10 processMessage,
11 getRootId,
12 getCurrentNodeId,
13 setCurrentNodeId,
14 getClientForUser,
15 resolveRootLlmForMode,
16} from "../../seed/llm/conversation.js";
17import { classify } from "./translator.js";
18import { getLandConfigValue } from "../../seed/landConfig.js";
19
20/**
21 * Local intent classification. Zero LLM calls.
22 *
23 * Two jobs:
24 * 1. Extension routing: if a mode override is set at the current node AND
25 * the message matches that extension's classifierHints, route directly
26 * to the extension mode. One LLM call. No librarian.
27 * 2. Intent classification: greetings/questions → query, destructive → destructive,
28 * action commands → place, everything else → place (librarian decides).
29 */
30async function localClassify(message, currentNodeId, rootId) {
31 const lower = message.toLowerCase().trim();
32 const base = { summary: message.slice(0, 100), responseHint: "" };
33
34 // ── Routing index (fast path) ──
35 // One Map scan. No DB queries. Catches deep extensions that Level 1-3 miss.
36 if (rootId && currentNodeId) {
37 try {
38 const { queryIndex } = await import("./routingIndex.js");
39 const currentPath = await _buildCurrentPath(currentNodeId);
40 const match = queryIndex(rootId, message, currentPath);
41 if (match) {
42 return { intent: "extension", mode: match.mode, targetNodeId: match.targetNodeId, confidence: match.confidence, ...base };
43 }
44 } catch {}
45 }
46
47 // ── Extension routing (Path 2, fallback) ──
48 // Level-by-level DB walk. Kept as backup for unindexed trees.
49 if (currentNodeId) {
50 try {
51 const { getClassifierHintsForMode } = await import("../loader.js");
52 const currentNode = await Node.findById(currentNodeId).select("metadata children").lean();
53
54 // Level 1: current node has a mode override.
55 // If hints match, route with high confidence. If not, still route to the
56 // extension but the mode must handle generic messages (status, review, etc).
57 const modes = currentNode?.metadata instanceof Map
58 ? currentNode.metadata.get("modes")
59 : currentNode?.metadata?.modes;
60 if (modes?.respond) {
61 const hints = getClassifierHintsForMode(modes.respond);
62 if (!hints || hints.some(re => re.test(message))) {
63 return { intent: "extension", mode: modes.respond, targetNodeId: String(currentNodeId), confidence: 0.95, ...base };
64 }
65 // No hint match but we're at an extension node. Still route here
66 // because the librarian doesn't understand extension data models.
67 return { intent: "extension", mode: modes.respond, targetNodeId: String(currentNodeId), confidence: 0.8, ...base };
68 }
69
70 // Level 2: direct children (do any of my children claim this message?)
71 if (currentNode?.children?.length > 0) {
72 const children = await Node.find({ _id: { $in: currentNode.children } })
73 .select("_id name metadata").lean();
74 for (const child of children) {
75 const childModes = child.metadata instanceof Map
76 ? child.metadata.get("modes")
77 : child.metadata?.modes;
78 if (!childModes?.respond) continue;
79 const hints = getClassifierHintsForMode(childModes.respond);
80 if (hints?.some(re => re.test(message))) {
81 return {
82 intent: "extension",
83 mode: childModes.respond,
84 targetNodeId: String(child._id),
85 confidence: 0.85,
86 ...base,
87 };
88 }
89 }
90 }
91
92 // Level 3: siblings (does a sibling of the current node claim this message?)
93 if (currentNode?.parent) {
94 const parentNode = await Node.findById(currentNode.parent).select("children").lean();
95 if (parentNode?.children?.length > 1) {
96 const siblingIds = parentNode.children
97 .map(id => String(id))
98 .filter(id => id !== String(currentNodeId));
99 if (siblingIds.length > 0) {
100 const siblings = await Node.find({ _id: { $in: siblingIds } })
101 .select("_id name metadata").lean();
102 for (const sib of siblings) {
103 const sibModes = sib.metadata instanceof Map
104 ? sib.metadata.get("modes")
105 : sib.metadata?.modes;
106 if (!sibModes?.respond) continue;
107 const hints = getClassifierHintsForMode(sibModes.respond);
108 if (hints?.some(re => re.test(message))) {
109 return {
110 intent: "extension",
111 mode: sibModes.respond,
112 targetNodeId: String(sib._id),
113 confidence: 0.8,
114 ...base,
115 };
116 }
117 }
118 }
119 }
120 }
121 } catch {}
122 }
123
124 // ── No extension claimed this message ──
125 // Position determines reality. The AI at this position has all the tools
126 // it needs (read, write, navigate, delete). Let it decide what to do.
127 // No regex. No guessing. Just converse.
128 return { intent: "converse", confidence: 0.8, ...base };
129}
130
131/**
132 * Extract the behavioral constraint from the source type.
133 * Four commands constrain what happens at any position.
134 *
135 * query → tools: read-only response: full writes: blocked
136 * place → tools: all response: minimal writes: allowed
137 * chat → tools: all response: full writes: allowed
138 * be → tools: all response: guided writes: allowed
139 */
140function extractBehavioral(sourceType) {
141 if (sourceType === "query" || sourceType.endsWith("-query")) return "query";
142 if (sourceType === "place" || sourceType.endsWith("-place")) return "place";
143 if (sourceType === "be" || sourceType.endsWith("-be")) return "be";
144 return "chat"; // default
145}
146import { setChatContext } from "../../seed/llm/chatTracker.js";
147import { isActiveNavigator } from "../../seed/ws/sessionRegistry.js";
148
149import {
150 getContextForAi,
151 getNavigationContext,
152 buildDeepTreeSummary,
153} from "../../seed/tree/treeFetch.js";
154import mongoose from "mongoose";
155import Node from "../../seed/models/node.js";
156import { OrchestratorRuntime } from "../../seed/orchestrators/runtime.js";
157import { resolveMode } from "../../seed/modes/registry.js";
158
159// ─────────────────────────────────────────────────────────────────────────
160// PATH RESOLUTION (for routing index scope check)
161// ─────────────────────────────────────────────────────────────────────────
162
163const _pathCache = new Map(); // nodeId -> { path, ts }
164const PATH_TTL = 30000;
165
166async function _buildCurrentPath(nodeId) {
167 const cached = _pathCache.get(String(nodeId));
168 if (cached && Date.now() - cached.ts < PATH_TTL) return cached.path;
169
170 const parts = [];
171 let current = await Node.findById(nodeId).select("name parent rootOwner").lean();
172 let depth = 0;
173 while (current && depth < 20) {
174 parts.unshift(current.name || String(current._id));
175 if (current.rootOwner || !current.parent) break;
176 current = await Node.findById(current.parent).select("name parent rootOwner").lean();
177 depth++;
178 }
179 const path = "/" + parts.join("/");
180
181 _pathCache.set(String(nodeId), { path, ts: Date.now() });
182 // Cap cache
183 if (_pathCache.size > 500) {
184 const oldest = [..._pathCache.entries()].sort((a, b) => a[1].ts - b[1].ts);
185 for (let i = 0; i < 100; i++) _pathCache.delete(oldest[i][0]);
186 }
187 return path;
188}
189
190// ─────────────────────────────────────────────────────────────────────────
191// INTELLIGENCE BRIEF (cached per tree, 60s TTL)
192// Collects signals from installed extensions so the librarian sees the
193// tree's living state, not just its skeleton.
194// ─────────────────────────────────────────────────────────────────────────
195
196const briefCache = new Map(); // rootId -> { brief, timestamp }
197const BRIEF_TTL = 60000;
198const BRIEF_CACHE_MAX = 100;
199
200async function getIntelligenceBrief(rootId, userId) {
201 const cached = briefCache.get(rootId);
202 if (cached && Date.now() - cached.timestamp < BRIEF_TTL) return cached.brief;
203
204 const brief = await buildIntelligenceBrief(rootId, userId);
205
206 // Evict oldest if at capacity
207 if (briefCache.size >= BRIEF_CACHE_MAX && !briefCache.has(rootId)) {
208 const oldest = briefCache.keys().next().value;
209 briefCache.delete(oldest);
210 }
211 briefCache.set(rootId, { brief, timestamp: Date.now() });
212 return brief;
213}
214
215async function buildIntelligenceBrief(rootId, userId) {
216 let getExtension;
217 try {
218 ({ getExtension } = await import("../loader.js"));
219 } catch { return null; }
220
221 const sections = [];
222
223 // Competence: what the tree knows and doesn't know
224 try {
225 const comp = getExtension("competence");
226 if (comp?.exports?.getCompetence) {
227 const data = await comp.exports.getCompetence(rootId);
228 if (data?.totalQueries >= 10) {
229 const strong = (data.strongTopics || []).slice(0, 5).join(", ");
230 const weak = (data.weakTopics || []).slice(0, 5).join(", ");
231 if (strong || weak) {
232 sections.push(`Competence: answers well on [${strong || "unknown"}]. Weak on [${weak || "unknown"}]. Answer rate: ${Math.round((data.answerRate || 0) * 100)}%.`);
233 }
234 }
235 }
236 } catch {}
237
238 // Explore: last exploration map at root
239 try {
240 const exp = getExtension("explore");
241 if (exp?.exports?.getExploreMap) {
242 const map = await exp.exports.getExploreMap(rootId);
243 if (map && map.confidence > 0) {
244 const findings = (map.map || []).slice(0, 3).map(f => f.nodeName || f.nodeId).join(", ");
245 const gaps = (map.gaps || []).slice(0, 2).join("; ");
246 sections.push(`Explored: ${map.coverage} coverage, ${map.nodesExplored} nodes checked. Key areas: ${findings || "none"}.${gaps ? " Gaps: " + gaps : ""}`);
247 }
248 }
249 } catch {}
250
251 // Contradiction: unresolved conflicts
252 try {
253 const con = getExtension("contradiction");
254 if (con?.exports?.getUnresolved) {
255 const unresolved = await con.exports.getUnresolved(rootId);
256 if (Array.isArray(unresolved) && unresolved.length > 0) {
257 const top = unresolved.slice(0, 2).map(c => `"${c.claim}" vs "${c.conflictsWith}"`).join("; ");
258 sections.push(`Contradictions: ${unresolved.length} unresolved. ${top}`);
259 }
260 }
261 } catch {}
262
263 // Purpose: thesis and coherence
264 try {
265 const pur = getExtension("purpose");
266 if (pur) {
267 const root = await Node.findById(rootId).select("metadata").lean();
268 const meta = root?.metadata instanceof Map ? root.metadata.get("purpose") : root?.metadata?.purpose;
269 if (meta?.thesis) {
270 const coherence = meta.recentCoherence != null ? ` Coherence: ${Math.round(meta.recentCoherence * 100)}%.` : "";
271 sections.push(`Purpose: "${meta.thesis}"${coherence}`);
272 }
273 }
274 } catch {}
275
276 // Evolution: dormant branches
277 try {
278 const evo = getExtension("evolution");
279 if (evo?.exports?.getDormant) {
280 const dormant = await evo.exports.getDormant(rootId);
281 if (Array.isArray(dormant) && dormant.length > 0) {
282 const names = dormant.slice(0, 3).map(d => d.name || d.nodeName).join(", ");
283 sections.push(`Dormant: ${dormant.length} branch${dormant.length > 1 ? "es" : ""}. ${names}.`);
284 }
285 }
286 } catch {}
287
288 // Remember: recent departures
289 try {
290 const rem = getExtension("remember");
291 if (rem) {
292 const root = await Node.findById(rootId).select("metadata").lean();
293 const meta = root?.metadata instanceof Map ? root.metadata.get("remember") : root?.metadata?.remember;
294 if (meta?.departed?.length > 0) {
295 const recent = meta.departed.slice(-3).map(d => `${d.name} (${d.note})`).join("; ");
296 sections.push(`Departed: ${recent}`);
297 }
298 }
299 } catch {}
300
301 if (sections.length === 0) return null;
302 return "Intelligence:\n" + sections.map(s => " " + s).join("\n");
303}
304
305// ─────────────────────────────────────────────────────────────────────────
306// MODE RESOLUTION HELPER
307// ─────────────────────────────────────────────────────────────────────────
308
309/**
310 * Resolve mode key for an intent at a node. Checks per-node overrides.
311 * Falls back to default tree:{intent} mode.
312 */
313async function resolveModeForNode(intent, nodeId) {
314 if (!nodeId) return `tree:${intent}`;
315 try {
316 const node = await Node.findById(nodeId).select("metadata").lean();
317 return resolveMode(intent, "tree", node?.metadata);
318 } catch {
319 return `tree:${intent}`;
320 }
321}
322
323// ─────────────────────────────────────────────────────────────────────────
324// PENDING OPERATIONS (confirmation flow)
325// ─────────────────────────────────────────────────────────────────────────
326// CONVERSATION MEMORY (survives mode switches)
327// ─────────────────────────────────────────────────────────────────────────
328
329// visitorId → [{ role: "user"|"assistant", content }]
330const orchestratorMemory = new Map();
331const MAX_MEMORY_TURNS = 10; // 5 exchanges (user + assistant each)
332
333function getMemory(visitorId) {
334 return orchestratorMemory.get(visitorId) || [];
335}
336
337function pushMemory(visitorId, userMessage, assistantResponse) {
338 const mem = getMemory(visitorId);
339 mem.push(
340 { role: "user", content: userMessage },
341 { role: "assistant", content: assistantResponse },
342 );
343 // Keep only the last N turns
344 while (mem.length > MAX_MEMORY_TURNS) mem.shift();
345 orchestratorMemory.set(visitorId, mem);
346}
347
348function clearMemory(visitorId) {
349 orchestratorMemory.delete(visitorId);
350}
351
352export { clearMemory };
353
354/**
355 * Format memory as context string for injection into mode messages.
356 */
357function formatMemoryContext(visitorId) {
358 const mem = getMemory(visitorId);
359 if (mem.length === 0) return "";
360 const lines = mem.map((m) =>
361 m.role === "user" ? `User: ${m.content}` : `Assistant: ${m.content}`,
362 );
363 return `\n\nRecent conversation:\n${lines.join("\n")}`;
364}
365
366// ─────────────────────────────────────────────────────────────────────────
367// ORCHESTRATOR
368// ─────────────────────────────────────────────────────────────────────────
369
370/**
371 * Emit a status event to the frontend.
372 */
373function emitStatus(socket, phase, text) {
374 socket.emit("executionStatus", { phase, text });
375}
376
377/**
378 * Emit an internal mode result to the chat so the user can see what's happening.
379 */
380function emitModeResult(socket, modeKey, result) {
381 // Strip internal tracking fields before sending to client
382 let sanitized = result;
383 if (result && typeof result === "object") {
384 const { _llmProvider, _raw, ...rest } = result;
385 sanitized = rest;
386 }
387 socket.emit("orchestratorStep", {
388 modeKey,
389 result:
390 typeof sanitized === "string"
391 ? sanitized
392 : JSON.stringify(sanitized, null, 2),
393 timestamp: Date.now(),
394 });
395}
396
397// ─────────────────────────────────────────────────────────────────────────
398// SHARED: RESOLVE LLM PROVIDER
399// ─────────────────────────────────────────────────────────────────────────
400
401async function resolveLlmProvider(userId, rootId, modeKey, slot) {
402 try {
403 const modeConnectionId = await resolveRootLlmForMode(rootId, modeKey);
404 const clientInfo = await getClientForUser(userId, slot, modeConnectionId);
405 return {
406 isCustom: clientInfo.isCustom,
407 model: clientInfo.model,
408 connectionId: clientInfo.connectionId || null,
409 };
410 } catch {
411 return { isCustom: false, model: null, connectionId: null };
412 }
413}
414
415// ─────────────────────────────────────────────────────────────────────────
416// SUFFIX CONVENTION ROUTING (one function, one place)
417// ─────────────────────────────────────────────────────────────────────────
418
419const REVIEW_PATTERN = /\b(how am i|progress|status|review|daily|stats|streak|history|so far|pattern|doing)\b/;
420const PLAN_PATTERN = /\b(plan|build|create|structure|organize|add|modify|remove|restructure|program|taper|schedule|adjust|set.*goal|change|curriculum)\b/;
421
422/**
423 * Resolve which of an extension's modes to use based on message content
424 * and behavioral constraint. Called ONCE per message after classification.
425 *
426 * :coach = guided (be), :review/:ask = backward analysis,
427 * :plan = forward building, :log/:tell = default action.
428 *
429 * If baseMode is already plan or coach (e.g. setup phase, guided session),
430 * don't override with log/tell on generic messages.
431 */
432async function resolveSuffixMode(baseMode, message, behavioral) {
433 try {
434 const { getModeOwner, getModesOwnedBy } = await import("../../seed/tree/extensionScope.js");
435 const extName = getModeOwner(baseMode);
436 if (!extName) return baseMode;
437
438 const extModes = getModesOwnedBy(extName);
439 if (extModes.length <= 1) return baseMode;
440
441 const find = (...suffixes) => {
442 for (const s of suffixes) {
443 const match = extModes.find(m => m.endsWith(`-${s}`));
444 if (match) return match;
445 }
446 return null;
447 };
448 const lower = message.toLowerCase().trim();
449
450 if (behavioral === "be" || lower === "be") return find("coach") || baseMode;
451 if (REVIEW_PATTERN.test(lower)) return find("review", "ask") || baseMode;
452 if (PLAN_PATTERN.test(lower)) return find("plan") || baseMode;
453
454 // Default: route to the action receiver. The extension is the territory,
455 // the mode is the intent. Setup-locked extensions should override via
456 // their handleMessage export, not by relying on the orchestrator to
457 // respect the locked mode.
458 return find("log", "tell") || baseMode;
459 } catch {
460 return baseMode;
461 }
462}
463
464// ─────────────────────────────────────────────────────────────────────────
465// RUN MODE AND RETURN (eliminates copy-pasted switchMode/processMessage)
466// ─────────────────────────────────────────────────────────────────────────
467
468/**
469 * Switch to a mode, run processMessage, handle memory and status events,
470 * return the standard response shape. Every exit path that runs a mode
471 * should call this instead of inlining the same 20 lines.
472 */
473async function runModeAndReturn(visitorId, mode, message, {
474 socket, username, userId, rootId, signal, slot,
475 currentNodeId, readOnly = false, clearHistory = false,
476 onToolLoopCheckpoint, modesUsed,
477 targetNodeId = null,
478 treeCapabilities = null,
479}) {
480 modesUsed.push(mode);
481 emitStatus(socket, "intent", "");
482
483 // Build conversation memory + extension boundary injection.
484 // When routing to an extension-owned mode, tell the AI its scope so it
485 // doesn't improvise capabilities it doesn't have. The boundary includes
486 // what other domains exist so the AI can redirect the user.
487 let memory = formatMemoryContext(visitorId);
488 try {
489 const { getModeOwner } = await import("../../seed/tree/extensionScope.js");
490 const extOwner = getModeOwner(mode);
491 // Only inject boundary for extension-owned modes (not kernel modes like tree:converse)
492 if (extOwner && !mode.startsWith("tree:converse") && !mode.startsWith("tree:fallback")) {
493 const { getIndexForRoot } = await import("./routingIndex.js");
494 const index = rootId ? getIndexForRoot(rootId) : null;
495 const otherDomains = [];
496 if (index) {
497 for (const [ext, entry] of index) {
498 if (ext !== extOwner) otherDomains.push(`${ext} (${entry.path})`);
499 }
500 }
501 const boundary = `[Boundary] You are the ${extOwner} extension. You ONLY handle ${extOwner}. ` +
502 `Do not offer to set up, manage, or advise on other domains. ` +
503 `You have only ${extOwner}-specific tools.` +
504 (otherDomains.length > 0
505 ? ` Other domains in this tree: ${otherDomains.join(", ")}. ` +
506 `For those, tell the user to navigate there or talk about it at the tree root.`
507 : "");
508 memory = (memory ? memory + "\n\n" : "") + boundary;
509 }
510 } catch {}
511
512 await switchMode(visitorId, mode, {
513 username, userId, rootId,
514 currentNodeId: currentNodeId || targetNodeId,
515 conversationMemory: memory,
516 clearHistory,
517 treeCapabilities,
518 });
519
520 const result = await processMessage(visitorId, message, {
521 username, userId, rootId, signal, slot,
522 readOnly,
523 onToolLoopCheckpoint,
524 meta: { internal: false },
525 onToolResults(results) {
526 if (signal?.aborted) return;
527 for (const r of results) socket.emit(WS.TOOL_RESULT, r);
528 },
529 });
530
531 emitStatus(socket, "done", "");
532 const answer = result?.content || result?.answer || null;
533 if (answer) pushMemory(visitorId, message, answer);
534 return { success: true, answer, modeKey: mode, modesUsed, rootId, targetNodeId: targetNodeId || currentNodeId };
535}
536
537// ─────────────────────────────────────────────────────────────────────────
538// RUN CHAIN (eliminates duplicated chain execution logic)
539// ─────────────────────────────────────────────────────────────────────────
540
541/**
542 * Execute a multi-extension chain. Each step runs in its own mode,
543 * results pass forward as context.
544 */
545async function runChain(chain, message, visitorId, {
546 socket, username, userId, rootId, signal, slot,
547 onToolLoopCheckpoint, modesUsed,
548}) {
549 emitStatus(socket, "intent", "Chaining extensions...");
550
551 let context = message;
552 const chainModes = [];
553
554 for (let i = 0; i < chain.length; i++) {
555 const step = chain[i];
556 const isLast = i === chain.length - 1;
557
558 const stepNodeId = step.targetNodeId || getCurrentNodeId(visitorId) || rootId;
559 await switchMode(visitorId, step.mode, {
560 username, userId, rootId,
561 currentNodeId: stepNodeId,
562 conversationMemory: context,
563 clearHistory: true,
564 });
565
566 const stepResult = await processMessage(visitorId,
567 isLast ? context : `${context}\n\nDo this step and return what you produced.`, {
568 username, userId, rootId, signal, slot,
569 onToolLoopCheckpoint,
570 onToolResults(results) {
571 if (signal?.aborted) return;
572 for (const r of results) socket.emit(WS.TOOL_RESULT, r);
573 },
574 });
575
576 if (signal?.aborted) return null;
577
578 const stepAnswer = stepResult?.content || stepResult?.answer || "";
579 chainModes.push(step.mode);
580
581 if (!isLast) {
582 context = `Original request: ${message}\n\nPrevious step (${step.extName}) result:\n${stepAnswer}`;
583 } else {
584 context = stepAnswer;
585 }
586 }
587
588 emitStatus(socket, "done", "");
589 if (context) pushMemory(visitorId, message, context);
590 return { success: true, answer: context, modeKey: chainModes[chainModes.length - 1], modesUsed: [...modesUsed, ...chainModes], rootId };
591}
592
593// ─────────────────────────────────────────────────────────────────────────
594// ORCHESTRATE TREE REQUEST
595// ─────────────────────────────────────────────────────────────────────────
596
597// NOTE: respondToCompletion, executePlanSteps, runQueryFlow, runLibrarianFlow,
598// executePendingOperation, scoutExistingStructure, fetchMoveCounterparts
599// were removed. The orchestrator now routes to tree:converse for all
600// non-extension messages. The AI has all tools at its position.
601
602export async function orchestrateTreeRequest({
603 visitorId,
604 message,
605 socket,
606 username,
607 userId,
608 signal,
609 sessionId,
610 rootId: rootIdParam,
611 skipRespond = false,
612 forceQueryOnly = false,
613 slot,
614 rootChatId = null,
615 sourceType = null,
616 sourceId = null,
617 onToolLoopCheckpoint = null,
618}) {
619 if (signal?.aborted) return null;
620
621 const rootId = rootIdParam ?? getRootId(visitorId);
622
623 // Create an attached runtime (reuses the websocket's session, MCP, Chat)
624 const rt = new OrchestratorRuntime({
625 rootId,
626 userId,
627 username,
628 visitorId,
629 sessionType: "tree-chat",
630 description: message,
631 modeKeyForLlm: "tree:librarian",
632 slot,
633 });
634
635 const llmProvider = await resolveLlmProvider(userId, rootId, "tree:librarian", slot);
636
637 // Attach to the existing websocket session
638 rt.attach({ sessionId, mainChatId: rootChatId, llmProvider, signal, chainIndex: 1 });
639
640 // Ensure AI contribution context is set so MCP tool calls get chatId/sessionId
641 if (rootChatId) {
642 setChatContext(visitorId, sessionId, rootChatId);
643 }
644
645 const meta = { username, userId, rootId, slot, llmProvider };
646 const modesUsed = []; // Track full chain for Chat
647
648 // ────────────────────────────────────────────────────────
649 // QUERY FAST PATH — converse in read-only mode
650 // ────────────────────────────────────────────────────────
651
652 if (forceQueryOnly) {
653 return runModeAndReturn(visitorId, "tree:converse", message, {
654 socket, username, userId, rootId, signal, slot,
655 readOnly: true, clearHistory: true, onToolLoopCheckpoint, modesUsed,
656 });
657 }
658
659 // ────────────────────────────────────────────────────────
660 // CONTINUATION CHECK — short replies continue the previous mode
661 // "ok", "yes", "do it", "go ahead" etc. continue the conversation
662 // instead of re-classifying and switching modes.
663 // ────────────────────────────────────────────────────────
664
665 const CONTINUE_WORDS = /^(ok|okay|yes|yeah|yep|y|go|do it|go ahead|sure|continue|proceed|next|keep going|and|then)\s*[.!?]?$/i;
666 if (CONTINUE_WORDS.test(message.trim())) {
667 const { getCurrentMode } = await import("../../seed/llm/conversation.js");
668 const currentMode = getCurrentMode(visitorId);
669 if (currentMode && currentMode !== "tree:converse" && currentMode !== "tree:fallback") {
670 log.verbose("Tree Orchestrator", ` Continuation in ${currentMode}: "${message}"`);
671 // Don't switchMode. Stay in current mode, just process.
672 modesUsed.push(currentMode);
673 emitStatus(socket, "intent", "");
674 const result = await processMessage(visitorId, message, {
675 username, userId, rootId, signal, slot, onToolLoopCheckpoint,
676 onToolResults(results) { if (signal?.aborted) return; for (const r of results) socket.emit(WS.TOOL_RESULT, r); },
677 });
678 emitStatus(socket, "done", "");
679 const answer = result?.content || result?.answer || null;
680 if (answer) pushMemory(visitorId, message, answer);
681 return { success: true, answer, modeKey: currentMode, modesUsed, rootId };
682 }
683 }
684
685 // ────────────────────────────────────────────────────────
686 // FAST PATH: Position hold. If the current node is an extension node,
687 // route directly. No tree summary, no routing index scan, no classification.
688 // This is the common case for follow-up messages in a conversation.
689 // ────────────────────────────────────────────────────────
690
691 const currentNodeId = getCurrentNodeId(visitorId) || rootId;
692 let classification;
693 let treeSummary = null;
694 let classifyStart = new Date();
695 let departed = false;
696
697 // Check if current position has a mode override (extension node)
698 {
699 const posNode = await Node.findById(currentNodeId).select("metadata").lean();
700 const posModes = posNode?.metadata instanceof Map
701 ? posNode.metadata.get("modes")
702 : posNode?.metadata?.modes;
703 if (posModes?.respond) {
704 // Check for departure: does the message match a DIFFERENT extension's hints
705 // but NOT the current extension's hints? If so, skip position hold.
706 let isDeparture = false;
707 try {
708 const { getClassifierHintsForMode } = await import("../loader.js");
709 const { getModeOwner } = await import("../../seed/tree/extensionScope.js");
710 const currentExt = getModeOwner(posModes.respond);
711 const currentHints = getClassifierHintsForMode(posModes.respond);
712 const matchesCurrent = currentHints?.some(re => re.test(message));
713
714 // Only check departure if the message doesn't match current extension
715 if (!matchesCurrent && rootId) {
716 const { queryAllMatches } = await import("./routingIndex.js");
717 const otherMatches = queryAllMatches(rootId, message, null)
718 .filter(m => m.extName !== currentExt);
719 if (otherMatches.length > 0) {
720 isDeparture = true;
721 departed = true;
722 log.verbose("Tree Orchestrator",
723 `🎯 Departure from ${currentExt}: message matches ${otherMatches.map(m => m.extName).join(", ")}`);
724 }
725 }
726 } catch (err) {
727 log.debug("Tree Orchestrator", `Departure check error: ${err.message}`);
728 }
729
730 if (!isDeparture) {
731 // Stay at this extension node. No suffix routing here.
732 // The extension routing path (below) handles suffix resolution once.
733 classification = {
734 intent: "extension",
735 mode: posModes.respond,
736 targetNodeId: String(currentNodeId),
737 confidence: 0.95,
738 summary: message.slice(0, 100),
739 responseHint: "",
740 };
741 log.verbose("Tree Orchestrator",
742 `🎯 Position hold: ${classification.mode} | "${classification.summary}"`);
743 }
744 }
745 }
746
747 // ────────────────────────────────────────────────────────
748 // STEP 1: CLASSIFY (only if position hold didn't match)
749 // ────────────────────────────────────────────────────────
750
751 if (!classification) {
752 emitStatus(socket, "intent", "Understanding request…");
753
754 const classificationMode = getLandConfigValue("classificationMode") || "local";
755
756 // Only build tree summary for LLM classification (local classification doesn't use it)
757 if (classificationMode === "llm" && rootId) {
758 try {
759 let encodingMap = null;
760 try {
761 const { getExtension } = await import("../loader.js");
762 const uExt = getExtension("understanding");
763 if (uExt?.exports?.getEncodingMap) encodingMap = await uExt.exports.getEncodingMap(rootId);
764 } catch {}
765 treeSummary = await buildDeepTreeSummary(rootId, { encodingMap });
766
767 const brief = await getIntelligenceBrief(rootId, userId);
768 if (brief) treeSummary += "\n\n" + brief;
769
770 log.verbose("Tree Orchestrator", " treeSummary for librarian:\n", treeSummary);
771 } catch (err) {
772 log.error("Tree Orchestrator", " Pre-fetch tree summary failed:", err.message);
773 }
774 }
775
776 if (classificationMode === "llm") {
777 // Opt-in LLM classification (old behavior)
778 try {
779 classification = await classify({
780 message,
781 userId,
782 conversationMemory: formatMemoryContext(visitorId),
783 treeSummary,
784 signal,
785 slot,
786 rootId,
787 });
788 } catch (err) {
789 if (signal?.aborted) return null;
790 if (err.message === "NO_LLM") {
791 throw new Error(
792 "No LLM connection configured. Set one up at /setup or assign one to this tree.",
793 );
794 }
795 log.error("Tree Orchestrator", " Classification failed:", err.message);
796 classification = await localClassify(message, departed ? rootId : (getCurrentNodeId(visitorId) || rootId), rootId);
797 }
798 } else {
799 // Default: local classification. Zero LLM calls.
800 classification = await localClassify(message, departed ? rootId : (getCurrentNodeId(visitorId) || rootId), rootId);
801 }
802 }
803 const classifyEnd = new Date();
804
805 if (signal?.aborted) return null;
806
807 const confidence = classification.confidence ?? 0.5;
808
809 log.verbose("Tree Orchestrator",
810 `🎯 Classified: ${classification.intent} | confidence: ${confidence} | "${classification.summary}"`,
811 );
812 emitModeResult(socket, "intent", {
813 intent: classification.intent,
814 responseHint: classification.responseHint,
815 summary: classification.summary,
816 confidence,
817 });
818
819 // Track classification step (after override so logs reflect actual intent used)
820 modesUsed.push("classifier");
821 rt.trackStep("classifier", {
822 input: message,
823 output: (({ llmProvider: _, ...rest }) => rest)(classification),
824 startTime: classifyStart,
825 endTime: classifyEnd,
826 llmProvider: classification.llmProvider || llmProvider,
827 });
828
829 // ────────────────────────────────────────────────────────
830 // NO_FIT CHECK — tree rejects this idea
831 // ────────────────────────────────────────────────────────
832
833 if (classification.intent === "no_fit") {
834 let reason = classification.summary || "Idea does not fit this tree.";
835
836 // Suggest go if the message might match an extension in another tree
837 try {
838 const { getExtension } = await import("../loader.js");
839 const goExt = getExtension("go");
840 if (goExt?.exports?.findDestination) {
841 const goResult = await goExt.exports.findDestination(message, userId);
842 if (goResult?.found && !goResult.ambiguous && goResult.destination) {
843 reason += ` Try: go ${goResult.destination.name || goResult.destination.path}`;
844 }
845 }
846 } catch {}
847
848 log.verbose("Tree Orchestrator", ` No fit: ${reason}`);
849
850 emitStatus(socket, "done", "");
851
852 return {
853 success: false,
854 noFit: true,
855 confidence,
856 reason,
857 summary: classification.summary,
858 modeKey: "classifier",
859 rootId,
860 modesUsed,
861 };
862 }
863
864 // ────────────────────────────────────────────────────────
865 // SHORT-MEMORY CHECK — explicit defer or vague placements
866 // ────────────────────────────────────────────────────────
867
868 // Only explicit "defer" intent triggers deferral (user said "hold this"/"park this").
869 // Normal "place" intents always flow to the librarian.
870 let deferDecision = { defer: false };
871 if (classification.intent === "defer") {
872 deferDecision = { defer: true, reason: "User explicitly requested deferral" };
873 classification.intent = "place"; // treat as place for the defer path
874 }
875 if (deferDecision.defer) {
876 log.verbose("Tree Orchestrator", ` Deferred to short memory: ${deferDecision.reason}`);
877
878 const ShortMemory = mongoose.models.ShortMemory;
879 if (!ShortMemory) throw new Error("Dreams extension required for short memory deferral");
880 const memoryItem = await ShortMemory.create({
881 rootId,
882 userId,
883 content: message,
884 deferReason: deferDecision.reason,
885 classificationAxes: classification.placementAxes,
886 sourceType: sourceType || "tree-chat",
887 sourceId: sourceId || null,
888 sessionId,
889 });
890
891 rt.trackStep("short-memory:defer", {
892 input: message,
893 output: {
894 deferReason: deferDecision.reason,
895 memoryItemId: memoryItem._id,
896 },
897 llmProvider,
898 });
899
900 if (!skipRespond) {
901 const response = await runRespond({
902 visitorId,
903 socket,
904 signal,
905 username,
906 userId,
907 rootId,
908 originalMessage: message,
909 responseHint:
910 classification.responseHint ||
911 "Acknowledge the idea naturally. Do not mention deferral, memory, or holding.",
912 stepSummaries: [],
913 slot,
914 });
915
916 return {
917 ...response,
918 success: true,
919 deferred: true,
920 memoryItemId: memoryItem._id,
921 modeKey: "short-memory:defer",
922 modesUsed: [...modesUsed, "short-memory"],
923 };
924 }
925
926 return {
927 success: true,
928 deferred: true,
929 memoryItemId: memoryItem._id,
930 modeKey: "short-memory:defer",
931 modesUsed,
932 rootId,
933 };
934 }
935
936 // ────────────────────────────────────────────────────────
937 // BEHAVIORAL CONSTRAINT (chat/place/query)
938 // ────────────────────────────────────────────────────────
939
940 const behavioral = extractBehavioral(sourceType);
941
942 // ────────────────────────────────────────────────────────
943 // BE: GUIDED MODE — the tree leads, the user follows
944 // Skip classification. Find the guided mode at this position.
945 // ────────────────────────────────────────────────────────
946
947 if (behavioral === "be") {
948 // Tier 1: Current node has an extension. Delegate to its handleMessage or coach mode.
949 let beHandled = false;
950 try {
951 const { getLoadedExtensionNames, getExtension } = await import("../loader.js");
952 const { getModesOwnedBy } = await import("../../seed/tree/extensionScope.js");
953 const nodeDoc = currentNodeId ? await Node.findById(currentNodeId).select("metadata").lean() : null;
954 if (nodeDoc) {
955 const meta = nodeDoc.metadata instanceof Map ? Object.fromEntries(nodeDoc.metadata) : (nodeDoc.metadata || {});
956 for (const extName of getLoadedExtensionNames()) {
957 if (meta[extName]?.role || meta[extName]?.initialized) {
958 const ext = getExtension(extName);
959 if (ext?.exports?.handleMessage) {
960 log.verbose("Tree Orchestrator", ` BE mode: delegating to ${extName}.handleMessage`);
961 emitStatus(socket, "intent", "");
962 const decision = await ext.exports.handleMessage("be", {
963 userId, username, rootId, targetNodeId: String(currentNodeId),
964 });
965 const resolvedMode = decision?.mode || `tree:${extName}-coach`;
966 modesUsed.push(resolvedMode);
967
968 if (decision?.answer) {
969 emitStatus(socket, "done", "");
970 pushMemory(visitorId, message, decision.answer);
971 return { success: true, answer: decision.answer, modeKey: resolvedMode, modesUsed, rootId, targetNodeId: String(currentNodeId) };
972 }
973
974 await switchMode(visitorId, resolvedMode, { username, userId, rootId, currentNodeId: String(currentNodeId), conversationMemory: formatMemoryContext(visitorId), clearHistory: decision?.setup || false });
975 const result = await processMessage(visitorId, decision?.message || message, { username, userId, rootId, signal, slot, onToolLoopCheckpoint, onToolResults(results) { if (signal?.aborted) return; for (const r of results) socket.emit(WS.TOOL_RESULT, r); } });
976 emitStatus(socket, "done", "");
977 const answer = result?.content || result?.answer || null;
978 if (answer) pushMemory(visitorId, message, answer);
979 return { success: true, answer, modeKey: resolvedMode, modesUsed, rootId, targetNodeId: String(currentNodeId) };
980 }
981 const extModes = getModesOwnedBy(extName);
982 const coachMode = extModes.find(m => m.endsWith("-coach")) || null;
983 if (coachMode) {
984 log.verbose("Tree Orchestrator", ` BE mode: switching to ${coachMode}`);
985 await switchMode(visitorId, coachMode, { username, userId, rootId, conversationMemory: formatMemoryContext(visitorId), clearHistory: true });
986 const result = await processMessage(visitorId, message, { username, userId, rootId, signal, socket, sessionId });
987 modesUsed.push(coachMode);
988 return { success: true, answer: result?.content || "", modeKey: coachMode, modesUsed, rootId };
989 }
990 break;
991 }
992 }
993 }
994 } catch (err) {
995 log.debug("Tree Orchestrator", `BE Tier 1 failed: ${err.message}`);
996 }
997
998 // Tier 2: Not at an extension node. Find closest extension via routing index.
999 // If the message matches an extension's hints, route there. Otherwise pick the first.
1000 if (!beHandled && rootId) {
1001 try {
1002 const { getExtension } = await import("../loader.js");
1003 const { getModesOwnedBy } = await import("../../seed/tree/extensionScope.js");
1004 const { queryAllMatches, getIndexForRoot } = await import("./routingIndex.js");
1005 const index = getIndexForRoot(rootId);
1006 if (index && index.size > 0) {
1007 // Check if the message matches any extension's hints
1008 const hintMatches = queryAllMatches(rootId, message, null);
1009 // Use hint match if found, otherwise fall through to first extension
1010 const entries = hintMatches.length > 0
1011 ? hintMatches.map(m => [m.extName, index.get(m.extName)]).filter(([, e]) => e)
1012 : [...index.entries()];
1013
1014 for (const [extName, entry] of entries) {
1015 const ext = getExtension(extName);
1016 if (!ext?.exports?.handleMessage) continue;
1017 const extModes = getModesOwnedBy(extName);
1018 if (!extModes.some(m => m.endsWith("-coach"))) continue;
1019
1020 const targetId = entry.nodeId || entry.nodes?.[0]?.nodeId;
1021 log.verbose("Tree Orchestrator", ` BE mode: routing to closest extension ${extName} at ${targetId}`);
1022 setCurrentNodeId(visitorId, targetId);
1023 emitStatus(socket, "intent", "");
1024 try {
1025 const decision = await ext.exports.handleMessage("be", {
1026 userId, username, rootId, targetNodeId: targetId,
1027 });
1028 const resolvedMode = decision?.mode || `tree:${extName}-coach`;
1029 modesUsed.push(resolvedMode);
1030
1031 if (decision?.answer) {
1032 emitStatus(socket, "done", "");
1033 pushMemory(visitorId, message, decision.answer);
1034 return { success: true, answer: decision.answer, modeKey: resolvedMode, modesUsed, rootId, targetNodeId: targetId };
1035 }
1036
1037 await switchMode(visitorId, resolvedMode, { username, userId, rootId, currentNodeId: targetId, conversationMemory: formatMemoryContext(visitorId), clearHistory: decision?.setup || false });
1038 const result = await processMessage(visitorId, decision?.message || message, { username, userId, rootId, signal, slot, onToolLoopCheckpoint, onToolResults(results) { if (signal?.aborted) return; for (const r of results) socket.emit(WS.TOOL_RESULT, r); } });
1039 emitStatus(socket, "done", "");
1040 const answer = result?.content || result?.answer || null;
1041 if (answer) pushMemory(visitorId, message, answer);
1042 return { success: true, answer, modeKey: resolvedMode, modesUsed, rootId, targetNodeId: targetId };
1043 } catch (err) {
1044 log.error("Tree Orchestrator", `BE routing failed for ${extName}: ${err.message}`);
1045 }
1046 }
1047 }
1048 } catch {}
1049 }
1050
1051 // Tier 3: No extensions found. Generic tree:be.
1052 log.verbose("Tree Orchestrator", ` BE mode: switching to tree:be`);
1053 await switchMode(visitorId, "tree:be", { username, userId, rootId, conversationMemory: formatMemoryContext(visitorId), clearHistory: true });
1054 const result = await processMessage(visitorId, message, { username, userId, rootId, signal, socket, sessionId });
1055 modesUsed.push("tree:be");
1056 return { success: true, answer: result?.content || "", modeKey: "tree:be", modesUsed, rootId };
1057 }
1058
1059 // ────────────────────────────────────────────────────────
1060 // PATH 2: EXTENSION DETECTED — hand off to the extension
1061 //
1062 // Three tiers:
1063 // 1. handleMessage override: extension exports a full handler. It decides everything.
1064 // 2. Suffix convention: orchestrator resolves mode by naming convention.
1065 // :coach (be), :review (questions), :plan (building), :log (default).
1066 // 3. modes.respond fallback: whatever the node declared.
1067 // ────────────────────────────────────────────────────────
1068
1069 if (classification.intent === "extension" && classification.mode) {
1070 const { getModeOwner } = await import("../../seed/tree/extensionScope.js");
1071 const { getExtension, getExtensionManifest } = await import("../loader.js");
1072
1073 // ── Chain check: does the message match 2+ extensions? ──
1074 try {
1075 const primaryExt = getModeOwner(classification.mode);
1076 const { queryAllMatches } = await import("./routingIndex.js");
1077 const allTreeMatches = queryAllMatches(rootId, message, null);
1078 const seenExts = new Set([primaryExt]);
1079 const otherMatches = [];
1080
1081 let primaryPos = 0;
1082 const primaryManifest = getExtensionManifest(primaryExt);
1083 if (Array.isArray(primaryManifest?.classifierHints)) {
1084 for (const re of primaryManifest.classifierHints) {
1085 const m = re.exec(message);
1086 if (m) { primaryPos = m.index; break; }
1087 }
1088 }
1089
1090 for (const match of allTreeMatches) {
1091 if (seenExts.has(match.extName)) continue;
1092 seenExts.add(match.extName);
1093 const manifest = getExtensionManifest(match.extName);
1094 let matchPos = -1;
1095 if (Array.isArray(manifest?.classifierHints)) {
1096 for (const re of manifest.classifierHints) {
1097 const m = re.exec(message);
1098 if (m) { matchPos = matchPos === -1 ? m.index : Math.min(matchPos, m.index); }
1099 }
1100 }
1101 if (matchPos === -1) matchPos = message.length;
1102 otherMatches.push({ mode: match.mode, targetNodeId: match.targetNodeId, extName: match.extName, pos: matchPos });
1103 }
1104
1105 log.verbose("Tree Orchestrator", ` Chain: ${otherMatches.length} other matches: ${otherMatches.map(m => m.extName).join(", ") || "none"}`);
1106
1107 if (otherMatches.length > 0) {
1108 const chain = [
1109 { mode: classification.mode, targetNodeId: classification.targetNodeId || currentNodeId, extName: primaryExt, pos: primaryPos },
1110 ...otherMatches,
1111 ].sort((a, b) => a.pos - b.pos);
1112 log.verbose("Tree Orchestrator", ` Chain detected: ${chain.map(m => m.extName).join(" -> ")}`);
1113 return runChain(chain, message, visitorId, { socket, username, userId, rootId, signal, slot, onToolLoopCheckpoint, modesUsed });
1114 }
1115 } catch (err) {
1116 log.debug("Tree Orchestrator", `Chain check failed: ${err.message}`);
1117 }
1118
1119 const extName = getModeOwner(classification.mode);
1120 const ext = extName ? getExtension(extName) : null;
1121
1122 log.verbose("Tree Orchestrator",
1123 ` Extension route: ${classification.mode} (ext: ${extName || "?"}, behavioral: ${behavioral})`);
1124
1125 // ── Data handler: extension pre-processing ──
1126 // Extensions can return:
1127 // { answer } - short-circuit, send this answer directly
1128 // { mode } - force a specific mode, skip suffix routing
1129 // { answer, mode } - short-circuit with mode tagging
1130 // null/undefined - proceed to normal suffix routing
1131 let forcedMode = null;
1132 if (ext?.exports?.handleMessage) {
1133 if (classification.targetNodeId) setCurrentNodeId(visitorId, classification.targetNodeId);
1134 try {
1135 const decision = await ext.exports.handleMessage(message, {
1136 userId, username, rootId, targetNodeId: classification.targetNodeId,
1137 });
1138 if (decision?.answer) {
1139 emitStatus(socket, "done", "");
1140 pushMemory(visitorId, message, decision.answer);
1141 modesUsed.push(decision.mode || classification.mode);
1142 return { success: true, answer: decision.answer, modeKey: decision.mode || classification.mode, modesUsed, rootId, targetNodeId: classification.targetNodeId };
1143 }
1144 if (decision?.mode) {
1145 forcedMode = decision.mode;
1146 log.verbose("Tree Orchestrator", ` handleMessage forced mode: ${forcedMode}`);
1147 }
1148 } catch (err) {
1149 log.error("Tree Orchestrator", `Extension handleMessage failed: ${err.message}`);
1150 }
1151 }
1152
1153 // ── Suffix convention routing (ONE call), unless extension forced a mode ──
1154 const resolvedMode = forcedMode || await resolveSuffixMode(classification.mode, message, behavioral);
1155 if (resolvedMode !== classification.mode) {
1156 log.verbose("Tree Orchestrator", ` Suffix routed: ${classification.mode} -> ${resolvedMode}`);
1157 }
1158
1159 return runModeAndReturn(visitorId, resolvedMode, message, {
1160 socket, username, userId, rootId, signal, slot,
1161 currentNodeId: classification.targetNodeId || currentNodeId,
1162 readOnly: behavioral === "query",
1163 onToolLoopCheckpoint, modesUsed,
1164 targetNodeId: classification.targetNodeId,
1165 });
1166 }
1167
1168
1169 // ────────────────────────────────────────────────────────
1170 // CONVERSE PATH — check routing index for implicit matches
1171 // ────────────────────────────────────────────────────────
1172
1173 if (rootId && classification.intent === "converse") {
1174 try {
1175 const { queryAllMatches } = await import("./routingIndex.js");
1176 const indexMatches = queryAllMatches(rootId, message, null);
1177
1178 log.verbose("Tree Orchestrator", ` Converse check: ${indexMatches.length} matches: ${indexMatches.map(m => m.extName).join(", ") || "none"}`);
1179
1180 if (indexMatches.length === 1) {
1181 const single = indexMatches[0];
1182 log.verbose("Tree Orchestrator", ` Single extension in converse: routing to ${single.extName}`);
1183 const resolvedMode = await resolveSuffixMode(single.mode, message, behavioral);
1184 return runModeAndReturn(visitorId, resolvedMode, message, {
1185 socket, username, userId, rootId, signal, slot,
1186 currentNodeId: single.targetNodeId, clearHistory: true,
1187 onToolLoopCheckpoint, modesUsed, targetNodeId: single.targetNodeId,
1188 });
1189 }
1190
1191 if (indexMatches.length > 1) {
1192 log.verbose("Tree Orchestrator", ` Chain detected: ${indexMatches.map(m => m.extName).join(" -> ")}`);
1193 return runChain(indexMatches, message, visitorId, { socket, username, userId, rootId, signal, slot, onToolLoopCheckpoint, modesUsed });
1194 }
1195 } catch (err) {
1196 log.debug("Tree Orchestrator", `Converse check failed: ${err.message}`);
1197 }
1198 }
1199
1200 // ────────────────────────────────────────────────────────
1201 // FALLBACK — tree:converse
1202 // Build tree capabilities from the routing index so converse
1203 // knows what extensions exist in this tree even when nothing matched.
1204 // ────────────────────────────────────────────────────────
1205
1206 let treeCapabilities = null;
1207 if (rootId) {
1208 try {
1209 const { getIndexForRoot } = await import("./routingIndex.js");
1210 const { getExtensionManifest } = await import("../loader.js");
1211 const index = getIndexForRoot(rootId);
1212 if (index && index.size > 0) {
1213 const lines = [];
1214 for (const [extName, entry] of index) {
1215 const manifest = getExtensionManifest(extName);
1216 const territory = manifest?.territory || extName;
1217 lines.push(` ${extName}: ${entry.path} (${territory})`);
1218 }
1219 treeCapabilities = lines.join("\n");
1220 }
1221 } catch {}
1222 }
1223
1224 return runModeAndReturn(visitorId, "tree:converse", message, {
1225 socket, username, userId, rootId, signal, slot,
1226 currentNodeId, clearHistory: true,
1227 onToolLoopCheckpoint, modesUsed,
1228 treeCapabilities,
1229 });
1230}
1231// ─────────────────────────────────────────────────────────────────────────
1232// RESPOND (final user-facing output)
1233// ─────────────────────────────────────────────────────────────────────────
1234
1235async function runRespond({
1236 visitorId,
1237 socket,
1238 signal,
1239 username,
1240 userId,
1241 rootId,
1242 nodeContext,
1243 operationContext,
1244 confirmNeeded = false,
1245 originalMessage = null,
1246 responseHint = "",
1247 stepSummaries = [],
1248 librarianContext = null,
1249 slot,
1250}) {
1251 emitStatus(socket, "respond", "");
1252
1253 // Include conversation memory so respond can reference prior exchanges
1254 const memCtx = formatMemoryContext(visitorId);
1255
1256 // Build a combined context: memory + step summaries + operation details
1257 const summaryCtx = formatStepSummaries(stepSummaries);
1258
1259 // Strip librarianContext to only the fields respond needs (skip plan array, nodeIds, etc.)
1260 let strippedLibCtx = null;
1261 if (librarianContext) {
1262 strippedLibCtx = {
1263 summary: librarianContext.summary || null,
1264 responseHint: librarianContext.responseHint || null,
1265 confidence: librarianContext.confidence ?? null,
1266 };
1267 }
1268
1269 const respondMode = await resolveModeForNode("respond", getCurrentNodeId(visitorId) || rootId);
1270 await switchMode(visitorId, respondMode, {
1271 username,
1272 userId,
1273 rootId,
1274 nodeContext: nodeContext || null,
1275 operationContext: operationContext || null,
1276 conversationMemory: memCtx || null,
1277 stepSummaries: !operationContext ? summaryCtx || null : null,
1278 responseHint: responseHint || null,
1279 confirmNeeded,
1280 librarianContext: strippedLibCtx,
1281 clearHistory: true,
1282 });
1283
1284 // Build trigger with responseHint for tone/content guidance
1285 let trigger;
1286 if (confirmNeeded) {
1287 trigger = "Present the pending operation and ask for confirmation.";
1288 } else if (librarianContext) {
1289 trigger = responseHint
1290 ? `Respond naturally based on what you know. Guidance: ${responseHint}`
1291 : "Respond naturally based on the context provided.";
1292 } else if (operationContext) {
1293 trigger = responseHint
1294 ? `Summarize what was done. Tone guidance: ${responseHint}`
1295 : "Summarize what was done.";
1296 } else {
1297 trigger = responseHint
1298 ? `Respond to the user. Guidance: ${responseHint}`
1299 : "Respond to the user based on the provided context.";
1300 }
1301
1302 const response = await processMessage(visitorId, trigger, {
1303 username,
1304 userId,
1305 rootId,
1306 slot,
1307 signal,
1308 onToolResults(results) {
1309 if (signal?.aborted) return;
1310 for (const r of results) {
1311 socket.emit(WS.TOOL_RESULT, r);
1312 }
1313 },
1314 });
1315
1316 emitStatus(socket, "done", "");
1317
1318 // Save this exchange to memory for future turns
1319 if (originalMessage && response?.answer) {
1320 pushMemory(visitorId, originalMessage, response.answer);
1321 }
1322
1323 return response;
1324}
1325
1326// ─────────────────────────────────────────────────────────────────────────
1327// SHORT-MEMORY DECISION
1328// ─────────────────────────────────────────────────────────────────────────
1329
1330/**
1331 * CURRENTLY UNUSED. No classifier (local or LLM) provides placementAxes.
1332 * The defer decision is handled inline: only explicit "defer" intent triggers
1333 * deferral. This function is preserved for a future classifier that returns
1334 * { placementAxes: { pathConfidence, domainNovelty, relationalComplexity } }.
1335 * Until then, it always returns { defer: false } and should not be wired in.
1336 *
1337 * @returns {{ defer: boolean, reason?: string }}
1338 */
13391/**
2 * Routing Index
3 *
4 * In-memory cache of all nodes with metadata.modes.respond set.
5 * Replaces per-message DB queries in localClassify with one Map scan.
6 * Rebuilt on boot and after structural/mode changes.
7 */
8
9import log from "../../seed/log.js";
10import Node from "../../seed/models/node.js";
11import { getModeOwner } from "../../seed/tree/extensionScope.js";
12import { getClassifierHintsForMode } from "../loader.js";
13
14// Map<rootId, Map<extName, { nodeId, name, path, mode, hints }>>
15const _indices = new Map();
16
17// ─────────────────────────────────────────────────────────────────────────
18// PATH RESOLUTION
19// ─────────────────────────────────────────────────────────────────────────
20
21async function buildPathString(nodeId) {
22 const parts = [];
23 let current = await Node.findById(nodeId).select("name parent rootOwner").lean();
24 let depth = 0;
25 while (current && depth < 20) {
26 parts.unshift(current.name || String(current._id));
27 if (current.rootOwner || !current.parent) break;
28 current = await Node.findById(current.parent).select("name parent rootOwner").lean();
29 depth++;
30 }
31 return "/" + parts.join("/");
32}
33
34// ─────────────────────────────────────────────────────────────────────────
35// REBUILD
36// ─────────────────────────────────────────────────────────────────────────
37
38export async function rebuildIndexForRoot(rootId) {
39 const rid = String(rootId);
40 try {
41 // Find all nodes in this tree with modes set (including root itself)
42 const nodes = await Node.find({
43 $or: [
44 { _id: rootId },
45 { rootOwner: rootId }, // root's direct info
46 { parent: { $exists: true } }, // children (filtered below)
47 ],
48 "metadata.modes": { $exists: true },
49 }).select("_id name parent rootOwner metadata.modes").lean();
50
51 // Filter to only nodes that belong to this tree
52 // Root node: _id === rootId
53 // Other nodes: walk parent chain to verify (expensive, so use a cheaper approach)
54 // Actually, rootOwner is only on root nodes. For children, we need a different query.
55 // Let's use a simpler approach: get all descendants of rootId.
56
57 const index = new Map();
58
59 // Check root itself
60 const root = await Node.findById(rootId).select("_id name metadata.modes").lean();
61 if (root) {
62 const modes = root.metadata instanceof Map ? root.metadata.get("modes") : root.metadata?.modes;
63 if (modes?.respond) {
64 const owner = getModeOwner(modes.respond);
65 if (owner) {
66 const hints = getClassifierHintsForMode(modes.respond) || [];
67 index.set(owner, {
68 nodeId: rid,
69 name: root.name,
70 path: "/" + (root.name || rid),
71 mode: modes.respond,
72 hints,
73 });
74 }
75 }
76 }
77
78 // Find all descendants with modes set (BFS from root children)
79 const descendants = await findDescendantsWithModes(rootId);
80 for (const node of descendants) {
81 const modes = node.metadata instanceof Map ? node.metadata.get("modes") : node.metadata?.modes;
82 const modeKey = modes?.respond;
83 if (!modeKey) continue;
84
85 const owner = getModeOwner(modeKey);
86 if (!owner) continue;
87 if (index.has(owner)) continue; // first (shallowest) wins
88
89 const hints = getClassifierHintsForMode(modeKey) || [];
90 const path = await buildPathString(node._id);
91
92 index.set(owner, {
93 nodeId: String(node._id),
94 name: node.name,
95 path,
96 mode: modeKey,
97 hints,
98 });
99 }
100
101 // Also include confined extensions that are ext-allowed at the tree root.
102 // These don't scaffold nodes or set modes.respond, but they're active here.
103 try {
104 const { getConfinedExtensions, isExtensionBlockedAtNode, getModesOwnedBy } = await import("../../seed/tree/extensionScope.js");
105 const { getExtensionManifest } = await import("../loader.js");
106 for (const extName of getConfinedExtensions()) {
107 if (index.has(extName)) continue;
108 if (await isExtensionBlockedAtNode(extName, rid)) continue;
109 const manifest = getExtensionManifest(extName);
110 const hints = Array.isArray(manifest?.classifierHints) ? manifest.classifierHints : [];
111 if (hints.length === 0) continue;
112 const modes = getModesOwnedBy(extName);
113 const defaultMode = modes.find(m => m.endsWith("-agent") || m.endsWith("-tell") || m.endsWith("-log") || m.endsWith("-browse")) || modes[0];
114 if (!defaultMode) continue;
115 index.set(extName, {
116 nodeId: rid,
117 name: extName,
118 path: "/" + (root?.name || rid),
119 mode: defaultMode,
120 hints,
121 });
122 }
123 } catch {}
124
125 _indices.set(rid, index);
126 if (index.size > 0) {
127 log.debug("RoutingIndex", `Built index for ${root?.name || rid}: ${index.size} extensions`);
128 }
129 } catch (err) {
130 log.debug("RoutingIndex", `Failed to build index for ${rid}: ${err.message}`);
131 }
132}
133
134async function findDescendantsWithModes(rootId) {
135 // BFS: walk children level by level, collect nodes with modes set
136 const results = [];
137 let currentLevel = [String(rootId)];
138 let depth = 0;
139
140 while (currentLevel.length > 0 && depth < 10) {
141 const children = await Node.find({
142 parent: { $in: currentLevel },
143 }).select("_id name parent metadata.modes children").lean();
144
145 const nextLevel = [];
146 for (const child of children) {
147 const modes = child.metadata instanceof Map ? child.metadata.get("modes") : child.metadata?.modes;
148 if (modes?.respond) results.push(child);
149 if (child.children?.length > 0) nextLevel.push(String(child._id));
150 }
151 currentLevel = nextLevel;
152 depth++;
153 }
154
155 return results;
156}
157
158export async function rebuildAll() {
159 // Tree roots = direct children of land root, excluding system nodes
160 const landRoot = await Node.findOne({ systemRole: "land-root" }).select("_id").lean();
161 if (!landRoot) return;
162 const roots = await Node.find({
163 parent: landRoot._id,
164 systemRole: null,
165 }).select("_id").lean();
166
167 for (const root of roots) {
168 await rebuildIndexForRoot(root._id);
169 }
170 log.debug("RoutingIndex", `Indexed ${_indices.size} trees`);
171}
172
173// ─────────────────────────────────────────────────────────────────────────
174// QUERY
175// ─────────────────────────────────────────────────────────────────────────
176
177/**
178 * Query the routing index for a message at a position.
179 * Returns { mode, targetNodeId, confidence } or null.
180 */
181export function queryIndex(rootId, message, currentPath) {
182 const index = _indices.get(String(rootId));
183 if (!index || index.size === 0) return null;
184
185 for (const [, entry] of index) {
186 // Only match descendants of current position (or the current position itself)
187 if (currentPath && entry.path !== currentPath && !entry.path.startsWith(currentPath + "/")) {
188 continue;
189 }
190
191 // Test hints
192 if (entry.hints.length > 0 && entry.hints.some(re => re.test(message))) {
193 return {
194 mode: entry.mode,
195 targetNodeId: entry.nodeId,
196 confidence: 0.9,
197 };
198 }
199 }
200
201 return null;
202}
203
204/**
205 * Query the routing index for ALL extensions that match a message.
206 * Used by the orchestrator to detect multi-extension chains.
207 * Returns [{ mode, targetNodeId, extName }] (all matches, not just first).
208 */
209export function queryAllMatches(rootId, message, currentPath) {
210 const index = _indices.get(String(rootId));
211 if (!index || index.size === 0) return [];
212
213 const matches = [];
214 for (const [extName, entry] of index) {
215 if (currentPath && entry.path !== currentPath && !entry.path.startsWith(currentPath + "/")) {
216 continue;
217 }
218 if (entry.hints.length > 0 && entry.hints.some(re => re.test(message))) {
219 matches.push({ mode: entry.mode, targetNodeId: entry.nodeId, extName });
220 }
221 }
222 return matches;
223}
224
225/**
226 * Get the raw index for a root. Used by the go extension for cross-tree search.
227 */
228export function getIndexForRoot(rootId) {
229 return _indices.get(String(rootId)) || null;
230}
231
232/**
233 * Get all indexed roots.
234 */
235export function getAllIndexedRoots() {
236 return [..._indices.keys()];
237}
238
239/**
240 * Invalidate a root's index. Called on structural changes.
241 */
242export function invalidateRoot(rootId) {
243 _indices.delete(String(rootId));
244}
2451import log from "../../seed/log.js";
2import { parseJsonSafe } from "../../seed/orchestrators/helpers.js";
3
4let resolveRootLlmForMode, getClientForUser;
5
6export function setServices({ llm }) {
7 resolveRootLlmForMode = llm.resolveRootLlmForMode;
8 getClientForUser = llm.getClientForUser;
9}
10
11async function getLlm(userId, rootId, modeKey, slot) {
12 const overrideId = rootId ? await resolveRootLlmForMode(rootId, modeKey) : null;
13 const { client, model, isCustom, connectionId, noLlm } = await getClientForUser(userId, slot, overrideId);
14 if (noLlm) throw new Error("NO_LLM");
15 return { client, model, llmProvider: { isCustom, model, connectionId: connectionId || null } };
16}
17
18// ── CLASSIFIER (opt-in LLM classification) ──
19
20const CLASSIFY_PROMPT = `Classify this message for a tree-structured knowledge system.
21
22Return ONLY JSON:
23{
24 "intent": "extension" | "converse" | "defer" | "no_fit",
25 "confidence": 0.0-1.0,
26 "responseHint": "tone guidance",
27 "summary": "one line for logs"
28}
29
30extension: message clearly targets a specific extension's domain (food, fitness, kb, browser, etc.)
31converse: general conversation, questions, thoughts, actions. The default.
32defer: user explicitly says hold/park/save for later.
33no_fit: zero connection to this tree's domain.
34
35Lean toward converse. The AI at the position will figure out what to do.
36no_fit means genuinely unrelated, not just tangential.
37Match confidence to domain fit. 0.85+ for obvious. 0.3 for stretch. 0.0 for no_fit.`;
38
39export async function classify({ message, userId, conversationMemory, treeSummary, signal, slot, rootId }) {
40 const { client, model, llmProvider } = await getLlm(userId, rootId, "tree:librarian", slot);
41
42 let userContent = "";
43 if (treeSummary) userContent += `Tree:\n${treeSummary}\n\n`;
44 if (conversationMemory) userContent += `Recent:\n${conversationMemory}\n\n`;
45 userContent += `Message: ${message}`;
46
47 const response = await client.chat.completions.create(
48 { model, messages: [
49 { role: "system", content: CLASSIFY_PROMPT },
50 { role: "user", content: userContent },
51 ]},
52 signal ? { signal } : {},
53 );
54
55 const raw = response.choices?.[0]?.message?.content;
56 if (!raw) throw new Error("Empty classifier response");
57
58 try {
59 const r = parseJsonSafe(raw);
60 if (!r) throw new Error("No JSON");
61 if (!r.intent || !["extension", "converse", "defer", "no_fit"].includes(r.intent)) r.intent = "converse";
62 r.confidence = Math.max(0, Math.min(1, r.confidence ?? 0.5));
63 r.responseHint = r.responseHint || "";
64 r.summary = r.summary || message;
65 r.llmProvider = llmProvider;
66 return r;
67 } catch (err) {
68 log.error("Translator", "Classify failed:", err.message);
69 return { intent: "converse", confidence: 0.5, responseHint: "", summary: message, llmProvider };
70 }
71}
72
| Version | Published | Downloads |
|---|---|---|
| 1.0.5 | 37d ago | 0 |
| 1.0.4 | 38d ago | 0 |
| 1.0.3 | 46d ago | 0 |
| 1.0.2 | 47d ago | 0 |
| 1.0.0 | 48d ago | 0 |
treeos ext star tree-orchestrator
Post comments from the CLI: treeos ext comment tree-orchestrator "your comment"
Max 3 comments per extension. One star and one flag per user.
Loading comments...