EXTENSION for TreeOS
tree-orchestrator
Position determines reality. Every message that enters a tree passes through this orchestrator. It routes to the right extension or to tree:converse. The routing index maps extensions to positions in the tree. If the current node or a nearby node has a mode override (modes.respond), and the extension's classifier hints match the message, the orchestrator routes directly to that extension's mode. One mode, focused tools, domain-specific system prompt. No LLM call for routing. When multiple extensions match (classifier hints from two or more extensions in the routing index fire on the same message), the orchestrator chains them. Each extension runs as a focused agent in its own mode. Results pass from one step to the next. Browser-bridge reads a page, KB saves the key points, gateway posts to Reddit. Each step has only its own tools. When no extension claims the message, tree:converse handles it. Converse reads the node's notes, children, and path, then talks from that position's perspective. Every node has a voice. No extension needed. This is the reference tree orchestrator. Replace it entirely by registering a custom orchestrator for bigMode tree.
v1.0.5 by TreeOS Site 0 downloads 5 files 1,787 lines 67.8 KB published 37d ago
treeos ext install tree-orchestrator
View changelog

Manifest

Provides

  • orchestrator

Requires

  • services: llm, session, chat, mcp, websocket, hooks, orchestrator
  • models: Node
  • extensions: treeos-base

Optional

  • extensions: competence, explore, contradiction, purpose, evolution, remember, understanding
SHA256: cb264ce8e7294d8310c3e75ccddabc0c64c05581d662877fa2956ff78678a2c5

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

Hooks

Listens To

  • afterBoot
  • afterMetadataWrite
  • beforeNodeDelete
  • afterNodeMove

Source Code

1import { 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}
83
1export 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};
48
1// 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 */
1339
1/**
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}
245
1import 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

Versions

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
0 stars
0 flags
React from the CLI: treeos ext star tree-orchestrator

Comments

Loading comments...

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