1/**
2 * Scout Core
3 *
4 * Five search strategies running in parallel. Triangulation scoring
5 * across semantic, structural, memory, codebook, and profile dimensions.
6 * AI synthesis of converged findings.
7 *
8 * Uses OrchestratorRuntime for abort, locking, and step tracking.
9 */
10
11import log from "../../seed/log.js";
12import Node from "../../seed/models/node.js";
13import Note from "../../seed/models/note.js";
14import { CONTENT_TYPE } from "../../seed/protocol.js";
15import { OrchestratorRuntime } from "../../seed/orchestrators/runtime.js";
16import { getDescendantIds, resolveRootNode } from "../../seed/tree/treeFetch.js";
17
18let LLM_PRIORITY;
19try {
20 ({ LLM_PRIORITY } = await import("../../seed/llm/conversation.js"));
21} catch {
22 LLM_PRIORITY = { INTERACTIVE: 2 };
23}
24
25let _getExtension = null;
26let _metadata = null;
27
28export function setServices(core) {
29 // Lazy import of extension loader to avoid circular deps at load time
30 import("../loader.js").then(m => { _getExtension = m.getExtension; }).catch(() => {});
31 _metadata = core.metadata;
32}
33
34function getExtension(name) {
35 return _getExtension ? _getExtension(name) : null;
36}
37
38// ─────────────────────────────────────────────────────────────────────────
39// STRATEGY 1: SEMANTIC SEARCH
40// ─────────────────────────────────────────────────────────────────────────
41
42async function searchSemantic(query, rootId, userId, opts) {
43 const embed = getExtension("embed");
44 if (!embed?.exports?.findSimilar || !embed?.exports?.generateEmbedding) {
45 return { strategy: "semantic", findings: [], skipped: true, reason: "embed not installed" };
46 }
47
48 let queryVector;
49 try {
50 queryVector = await embed.exports.generateEmbedding(query, userId);
51 } catch {
52 return { strategy: "semantic", findings: [], skipped: true, reason: "embedding failed" };
53 }
54 if (!queryVector) return { strategy: "semantic", findings: [], skipped: true, reason: "no vector returned" };
55
56 try {
57 const similar = await embed.exports.findSimilar(queryVector, rootId, {
58 similarityThreshold: opts.similarityThreshold || 0.7,
59 maxResults: opts.maxFindingsPerStrategy || 10,
60 });
61
62 return {
63 strategy: "semantic",
64 findings: (similar || []).map(s => ({
65 noteId: s.noteId || null,
66 nodeId: s.nodeId,
67 nodeName: s.nodeName || "",
68 snippet: (s.snippet || s.content || "").slice(0, 200),
69 score: s.similarity || s.score || 0,
70 })),
71 };
72 } catch (err) {
73 log.debug("Scout", `Semantic search failed: ${err.message}`);
74 return { strategy: "semantic", findings: [], skipped: true, reason: err.message };
75 }
76}
77
78// ─────────────────────────────────────────────────────────────────────────
79// STRATEGY 2: STRUCTURAL SEARCH
80// ─────────────────────────────────────────────────────────────────────────
81
82async function searchStructural(query, nodeId, rootId, opts) {
83 try {
84 // Walk the full tree from the root to get every descendant
85 const allIds = await getDescendantIds(rootId, { maxResults: 500 });
86 if (!allIds.length) return { strategy: "structural", findings: [] };
87
88 // Build node name lookup
89 const nodeNames = new Map();
90 const nodeDocs = await Node.find({ _id: { $in: allIds } }).select("_id name").lean();
91 for (const n of nodeDocs) nodeNames.set(n._id.toString(), n.name);
92
93 const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
94 if (queryWords.length === 0) return { strategy: "structural", findings: [] };
95
96 // Pre-filter in MongoDB with regex so we don't pull every note in the tree
97 const escaped = queryWords.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
98 const regexPattern = escaped.join("|");
99
100 const notes = await Note.find({
101 nodeId: { $in: allIds },
102 contentType: CONTENT_TYPE.TEXT,
103 content: { $regex: regexPattern, $options: "i" },
104 }).sort({ createdAt: -1 }).limit(50).select("_id content nodeId createdAt").lean();
105
106 return {
107 strategy: "structural",
108 findings: notes
109 .map(n => {
110 const content = (n.content || "").toLowerCase();
111 const matches = queryWords.filter(w => content.includes(w)).length;
112 return {
113 noteId: String(n._id),
114 nodeId: String(n.nodeId),
115 nodeName: nodeNames.get(String(n.nodeId)) || "",
116 snippet: (n.content || "").slice(0, 200),
117 score: matches / queryWords.length,
118 };
119 })
120 .sort((a, b) => b.score - a.score)
121 .slice(0, opts.maxFindingsPerStrategy || 10),
122 };
123 } catch (err) {
124 log.debug("Scout", `Structural search failed: ${err.message}`);
125 return { strategy: "structural", findings: [] };
126 }
127}
128
129// ─────────────────────────────────────────────────────────────────────────
130// STRATEGY 3: MEMORY SEARCH
131// ─────────────────────────────────────────────────────────────────────────
132
133async function searchMemory(nodeId, query, opts) {
134 const longMemory = getExtension("long-memory");
135 if (!longMemory?.exports?.getConnections) {
136 return { strategy: "memory", findings: [], skipped: true, reason: "long-memory not installed" };
137 }
138
139 try {
140 const connections = await longMemory.exports.getConnections(nodeId);
141 if (!connections || connections.length === 0) {
142 return { strategy: "memory", findings: [] };
143 }
144
145 const connectedIds = connections.slice(0, 10).map(c => c.nodeId || c.targetId || c);
146 const notes = await Note.find({
147 nodeId: { $in: connectedIds },
148 contentType: CONTENT_TYPE.TEXT,
149 }).sort({ createdAt: -1 }).limit(opts.maxFindingsPerStrategy || 10).select("_id content nodeId").lean();
150
151 // Node name lookup
152 const nodeNames = new Map();
153 const nodeDocs = await Node.find({ _id: { $in: connectedIds } }).select("_id name").lean();
154 for (const n of nodeDocs) nodeNames.set(n._id.toString(), n.name);
155
156 const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
157
158 return {
159 strategy: "memory",
160 findings: notes
161 .map(n => {
162 const content = (n.content || "").toLowerCase();
163 const matches = queryWords.filter(w => content.includes(w)).length;
164 return {
165 noteId: String(n._id),
166 nodeId: String(n.nodeId),
167 nodeName: nodeNames.get(String(n.nodeId)) || "",
168 snippet: (n.content || "").slice(0, 200),
169 score: matches > 0 ? (matches / queryWords.length) * 0.8 : 0.1,
170 };
171 })
172 .filter(f => f.score > 0),
173 };
174 } catch (err) {
175 log.debug("Scout", `Memory search failed: ${err.message}`);
176 return { strategy: "memory", findings: [], skipped: true, reason: err.message };
177 }
178}
179
180// ─────────────────────────────────────────────────────────────────────────
181// STRATEGY 4: CODEBOOK SEARCH
182// ─────────────────────────────────────────────────────────────────────────
183
184async function searchCodebook(nodeId, userId, query, opts) {
185 const codebook = getExtension("codebook");
186 if (!codebook?.exports?.getDictionary) {
187 return { strategy: "codebook", findings: [], skipped: true, reason: "codebook not installed" };
188 }
189
190 try {
191 const dictionary = await codebook.exports.getDictionary(nodeId, userId);
192 if (!dictionary || Object.keys(dictionary).length === 0) {
193 return { strategy: "codebook", findings: [] };
194 }
195
196 const queryWords = query.toLowerCase().split(/\s+/);
197 const expansions = [];
198
199 for (const [shorthand, meaning] of Object.entries(dictionary)) {
200 const meaningLower = (meaning || "").toLowerCase();
201 const shortLower = shorthand.toLowerCase();
202 if (queryWords.some(w => meaningLower.includes(w) || shortLower.includes(w))) {
203 expansions.push({ shorthand, meaning, score: 0.6 });
204 }
205 }
206
207 return {
208 strategy: "codebook",
209 findings: expansions.map(e => ({
210 noteId: null,
211 nodeId: String(nodeId),
212 nodeName: "",
213 snippet: `Codebook: "${e.shorthand}" = "${e.meaning}"`,
214 score: e.score,
215 })),
216 };
217 } catch (err) {
218 log.debug("Scout", `Codebook search failed: ${err.message}`);
219 return { strategy: "codebook", findings: [], skipped: true, reason: err.message };
220 }
221}
222
223// ─────────────────────────────────────────────────────────────────────────
224// STRATEGY 5: PROFILE SEARCH
225// ─────────────────────────────────────────────────────────────────────────
226
227async function searchProfile(userId, query, opts) {
228 const inverseTree = getExtension("inverse-tree");
229 if (!inverseTree?.exports?.getInverseData) {
230 return { strategy: "profile", findings: [], skipped: true, reason: "inverse-tree not installed", profileWeights: {} };
231 }
232
233 try {
234 const data = await inverseTree.exports.getInverseData(userId);
235 const profile = data?.profile;
236 if (!profile) return { strategy: "profile", findings: [], profileWeights: {} };
237
238 return {
239 strategy: "profile",
240 findings: [],
241 profileWeights: profile.topics || profile.interests || {},
242 activeHours: profile.activeHours || null,
243 };
244 } catch (err) {
245 log.debug("Scout", `Profile search failed: ${err.message}`);
246 return { strategy: "profile", findings: [], skipped: true, reason: err.message, profileWeights: {} };
247 }
248}
249
250// ─────────────────────────────────────────────────────────────────────────
251// TRIANGULATION + SYNTHESIS
252// ─────────────────────────────────────────────────────────────────────────
253
254function emptyResult(query) {
255 return {
256 query,
257 angles: [],
258 strategiesUsed: [],
259 strategiesSkipped: [],
260 findings: [],
261 synthesis: "No findings. The tree has no data matching this query.",
262 confidence: 0,
263 citations: [],
264 gaps: ["No data found for this query"],
265 scoutedAt: new Date().toISOString(),
266 };
267}
268
269/**
270 * Run a full scout from a starting node.
271 *
272 * @param {string} nodeId - starting position
273 * @param {string} query - what to find
274 * @param {string} userId
275 * @param {string} username
276 * @param {object} opts - { rootId, similarityThreshold, maxFindingsPerAngle, maxScoutHistory }
277 */
278export async function runScout(nodeId, query, userId, username, opts = {}) {
279 const rt = new OrchestratorRuntime({
280 rootId: opts.rootId || nodeId,
281 userId,
282 username: username || "system",
283 visitorId: `scout:${userId}:${nodeId}:${Date.now()}`,
284 sessionType: "SCOUT",
285 description: `Scouting: ${query}`,
286 modeKeyForLlm: "tree:scout",
287 lockNamespace: "scout",
288 lockKey: `scout:${nodeId}`,
289 llmPriority: LLM_PRIORITY?.INTERACTIVE || 2,
290 });
291
292 const ok = await rt.init(query);
293 if (!ok) {
294 return { error: "Scout already running at this node" };
295 }
296
297 try {
298 // Step 1: Decompose query into search angles
299 let angles = null;
300 try {
301 const result = await rt.runStep("tree:scout", {
302 prompt: `Decompose this research question into 3-5 independent search angles. Each angle should find different relevant information.
303
304Query: "${query}"
305
306Return ONLY JSON:
307{ "angles": ["angle 1 description", "angle 2", ...] }`,
308 });
309 angles = result?.parsed;
310 } catch (err) {
311 log.debug("Scout", `Angle decomposition failed: ${err.message}`);
312 }
313
314 if (!angles?.angles) {
315 // Fallback: use query as the single angle
316 angles = { angles: [query] };
317 }
318
319 rt.trackStep("tree:scout", {
320 input: { phase: "decompose", query },
321 output: { angleCount: angles.angles.length },
322 startTime: Date.now(),
323 endTime: Date.now(),
324 });
325
326 if (rt.aborted) {
327 rt.setError("Scout cancelled", "tree:scout");
328 return { error: "Scout cancelled" };
329 }
330
331 // Step 2: Run all five strategies in parallel
332 let rootId = opts.rootId;
333 if (!rootId) {
334 try {
335 const rootNode = await resolveRootNode(nodeId);
336 rootId = String(rootNode._id);
337 } catch { rootId = nodeId; }
338 }
339 const strategyOpts = {
340 similarityThreshold: opts.similarityThreshold || 0.7,
341 maxFindingsPerStrategy: opts.maxFindingsPerAngle || 10,
342 };
343
344 const startStrategies = Date.now();
345 const [semantic, structural, memory, codebook, profile] = await Promise.all([
346 searchSemantic(query, rootId, userId, strategyOpts),
347 searchStructural(query, nodeId, rootId, strategyOpts),
348 searchMemory(nodeId, query, strategyOpts),
349 searchCodebook(nodeId, userId, query, strategyOpts),
350 searchProfile(userId, query, strategyOpts),
351 ]);
352
353 const allStrategies = [semantic, structural, memory, codebook, profile];
354
355 rt.trackStep("tree:scout", {
356 input: { phase: "strategies", strategiesRun: 5 },
357 output: {
358 semantic: semantic.findings.length,
359 structural: structural.findings.length,
360 memory: memory.findings.length,
361 codebook: codebook.findings.length,
362 skipped: allStrategies.filter(s => s.skipped).map(s => s.strategy),
363 },
364 startTime: startStrategies,
365 endTime: Date.now(),
366 });
367
368 if (rt.aborted) {
369 rt.setError("Scout cancelled", "tree:scout");
370 return { error: "Scout cancelled" };
371 }
372
373 // Step 3: Convergence scoring
374 // Merge all findings, deduplicate by noteId, merge strategy lists
375 const allFindings = [
376 ...semantic.findings.map(f => ({ ...f, strategies: ["semantic"] })),
377 ...structural.findings.map(f => ({ ...f, strategies: ["structural"] })),
378 ...memory.findings.map(f => ({ ...f, strategies: ["memory"] })),
379 ...codebook.findings.map(f => ({ ...f, strategies: ["codebook"] })),
380 ];
381
382 const findingMap = new Map();
383 for (const f of allFindings) {
384 const key = f.noteId || `${f.nodeId}:${(f.snippet || "").slice(0, 50)}`;
385 if (findingMap.has(key)) {
386 const existing = findingMap.get(key);
387 existing.strategies = [...new Set([...existing.strategies, ...f.strategies])];
388 existing.score = Math.max(existing.score, f.score);
389 } else {
390 findingMap.set(key, { ...f });
391 }
392 }
393
394 // Apply convergence: more strategies = higher score
395 const contentStrategyCount = 4; // semantic, structural, memory, codebook
396 const scored = [...findingMap.values()].map(f => {
397 const convergence = f.strategies.length / contentStrategyCount;
398 f.finalScore = convergence * 0.4 + f.score * 0.6;
399 return f;
400 }).sort((a, b) => b.finalScore - a.finalScore);
401
402 // Apply profile weights if available
403 const profileWeights = profile.profileWeights || {};
404 if (Object.keys(profileWeights).length > 0) {
405 for (const f of scored) {
406 const snippet = (f.snippet || "").toLowerCase();
407 for (const [topic, weight] of Object.entries(profileWeights)) {
408 if (snippet.includes(topic.toLowerCase())) {
409 f.finalScore *= (1 + (weight || 0) * 0.1);
410 }
411 }
412 }
413 scored.sort((a, b) => b.finalScore - a.finalScore);
414 }
415
416 if (rt.aborted) {
417 rt.setError("Scout cancelled", "tree:scout");
418 return { error: "Scout cancelled" };
419 }
420
421 // Step 4: AI synthesis
422 const top = scored.slice(0, opts.maxFindingsPerAngle || 10);
423
424 const usedStrategies = allStrategies.filter(s => !s.skipped).map(s => s.strategy);
425 const synthesisPrompt = `Original query: "${query}"
426
427Search angles: ${JSON.stringify(angles.angles)}
428
429Findings from ${usedStrategies.length} search strategies (${scored.length} total, showing top ${top.length}):
430
431${top.map(f => `[${f.strategies.join("+")}] score=${f.finalScore.toFixed(2)} node="${f.nodeName}" snippet="${f.snippet}"`).join("\n")}
432
433${codebook.findings.length > 0 ? `\nCodebook terms found: ${codebook.findings.map(f => f.snippet).join(", ")}` : ""}
434
435Synthesize findings into a direct answer. Cite specific node names. Name any gaps.
436
437Return JSON:
438{ "synthesis": "your answer here", "confidence": 0.0-1.0, "citations": ["nodeName1", "nodeName2"], "gaps": ["what is missing"] }`;
439
440 let synthesis = null;
441 let rawSynthesis = null;
442 try {
443 const result = await rt.runStep("tree:scout", {
444 prompt: synthesisPrompt,
445 });
446 synthesis = result?.parsed;
447 rawSynthesis = result?.raw?.content || result?.raw || null;
448 } catch (err) {
449 log.debug("Scout", `Synthesis failed: ${err.message}`);
450 }
451
452 // Step 5: Build result
453 const answer = synthesis?.synthesis
454 || (typeof rawSynthesis === "string" && rawSynthesis.length > 0 ? rawSynthesis : null)
455 || (top.length > 0
456 ? `Found ${top.length} results but synthesis failed. Top match: "${top[0].snippet}" (${top[0].nodeName})`
457 : "No findings. The tree has no data matching this query.");
458
459 const result = {
460 query,
461 answer,
462 angles: angles.angles,
463 strategiesUsed: usedStrategies,
464 strategiesSkipped: allStrategies
465 .filter(s => s.skipped)
466 .map(s => ({ strategy: s.strategy, reason: s.reason })),
467 findings: top,
468 synthesis: answer,
469 confidence: synthesis?.confidence || 0,
470 citations: synthesis?.citations || [],
471 gaps: synthesis?.gaps || [],
472 scoutedAt: new Date().toISOString(),
473 };
474
475 // Write to metadata for history and gap accumulation
476 try {
477 const node = await Node.findById(nodeId);
478 if (node) {
479 const meta = _metadata.getExtMeta(node, "scout") || {};
480 const history = meta.history || [];
481 history.unshift({
482 query,
483 confidence: result.confidence,
484 gapCount: result.gaps.length,
485 strategiesUsed: result.strategiesUsed.length,
486 findingsCount: top.length,
487 scoutedAt: result.scoutedAt,
488 });
489 meta.history = history.slice(0, opts.maxScoutHistory || 20);
490 // Accumulate gaps (deduped)
491 const existingGaps = new Set(meta.gaps || []);
492 for (const g of result.gaps) existingGaps.add(g);
493 meta.gaps = [...existingGaps].slice(0, 50);
494 await _metadata.setExtMeta(node, "scout", meta);
495 }
496 } catch (err) {
497 log.debug("Scout", `Failed to write scout metadata: ${err.message}`);
498 }
499
500 rt.setResult(answer, "tree:scout");
501 return result;
502
503 } catch (err) {
504 rt.setError(err.message, "tree:scout");
505 throw err;
506 } finally {
507 await rt.cleanup();
508 }
509}
510
511// ─────────────────────────────────────────────────────────────────────────
512// HISTORY + GAPS ACCESS
513// ─────────────────────────────────────────────────────────────────────────
514
515export async function getScoutHistory(nodeId) {
516 const node = await Node.findById(nodeId).select("metadata").lean();
517 if (!node) return [];
518 const meta = node.metadata instanceof Map
519 ? node.metadata.get("scout") || {}
520 : node.metadata?.scout || {};
521 return meta.history || [];
522}
523
524export async function getScoutGaps(nodeId) {
525 const node = await Node.findById(nodeId).select("metadata").lean();
526 if (!node) return [];
527 const meta = node.metadata instanceof Map
528 ? node.metadata.get("scout") || {}
529 : node.metadata?.scout || {};
530 return meta.gaps || [];
531}
532
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import scoutMode from "./modes/scout.js";
4import { setServices, runScout, getScoutHistory, getScoutGaps } from "./core.js";
5
6export async function init(core) {
7 setServices(core);
8
9 // Register the scout mode. Hidden from mode bar. Used internally by
10 // the OrchestratorRuntime during angle decomposition and synthesis steps.
11 core.modes.registerMode("tree:scout", scoutMode, "scout");
12
13 // enrichContext: surface recent scout results and accumulated gaps
14 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
15 const scoutMeta = meta?.scout;
16 if (!scoutMeta) return;
17
18 if (scoutMeta.history?.length > 0) {
19 context.recentScout = scoutMeta.history[0];
20 }
21 if (scoutMeta.gaps?.length > 0) {
22 context.scoutGaps = scoutMeta.gaps.slice(0, 5);
23 }
24 }, "scout");
25
26 const { default: router } = await import("./routes.js");
27
28 log.verbose("Scout", "Scout loaded (triangulation search)");
29
30 return {
31 router,
32 tools,
33 exports: {
34 runScout,
35 getScoutHistory,
36 getScoutGaps,
37 },
38 };
39}
40
1export default {
2 name: "scout",
3 version: "1.0.1",
4 builtFor: "treeos-intelligence",
5 description:
6 "Triangulate across the tree. Five search strategies run in parallel: semantic, structural, " +
7 "memory, codebook, and profile. Findings that appear in multiple strategies score higher " +
8 "(convergence scoring). The AI synthesizes all results into an answer with citations. " +
9 "Scout is peripheral vision while explore is focused gaze. Scout asks 'what does the tree " +
10 "know about X' across the whole branch. Explore asks 'what's under this node about X' going " +
11 "downward. Scout gaps feed intent: the tree notices what it doesn't know and acts on it.",
12
13 needs: {
14 services: ["hooks", "llm", "metadata", "session"],
15 models: ["Node", "Note"],
16 },
17
18 optional: {
19 extensions: [
20 "embed",
21 "long-memory",
22 "codebook",
23 "inverse-tree",
24 "contradiction",
25 "gap-detection",
26 "intent",
27 "explore",
28 ],
29 },
30
31 provides: {
32 models: {},
33 routes: "./routes.js",
34 tools: true,
35 jobs: false,
36 orchestrator: false,
37 energyActions: {},
38 sessionTypes: {
39 SCOUT: "scout",
40 },
41
42 hooks: {
43 fires: [],
44 listens: ["enrichContext"],
45 },
46
47 cli: [
48 {
49 command: "scout [query...]", scope: ["tree"],
50 description: "Triangulate across the tree",
51 method: "POST",
52 endpoint: "/node/:nodeId/scout",
53 bodyMap: { query: 0 },
54 subcommands: {
55 history: {
56 method: "GET",
57 endpoint: "/node/:nodeId/scout/history",
58 description: "Previous scout runs at this position",
59 },
60 gaps: {
61 method: "GET",
62 endpoint: "/node/:nodeId/scout/gaps",
63 description: "Accumulated knowledge gaps",
64 },
65 },
66 },
67 ],
68 },
69};
70
1export default {
2 emoji: "🔭",
3 label: "Scout",
4 bigMode: "tree",
5 hidden: true,
6 toolNames: ["scout-query", "scout-history", "scout-gaps"],
7 buildSystemPrompt({ username }) {
8 return `You are a research agent triangulating across a tree. You receive findings from multiple search strategies and synthesize them into an answer.
9
10Return ONLY JSON:
11{
12 "synthesis": "your answer based on all findings",
13 "confidence": 0.0-1.0,
14 "citations": [{ "noteId": "...", "nodeId": "...", "nodeName": "...", "usedInSynthesis": true }],
15 "gaps": ["what the tree doesn't know that would help answer this"]
16}
17
18Findings that appear in multiple strategies are more trustworthy. Note where strategies agree and where they disagree. Be explicit about what the tree does NOT have information on.`;
19 },
20};
21
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { resolveRootNode } from "../../seed/tree/treeFetch.js";
5import { runScout, getScoutHistory, getScoutGaps } from "./core.js";
6
7const router = express.Router();
8
9// POST /node/:nodeId/scout - run a scout
10router.post("/node/:nodeId/scout", authenticate, async (req, res) => {
11 try {
12 const { query } = req.body || {};
13 if (!query || typeof query !== "string") {
14 return sendError(res, 400, ERR.INVALID_INPUT, "query is required");
15 }
16
17 // Walk up to tree root so strategies search the whole tree
18 const rootNode = await resolveRootNode(req.params.nodeId);
19 const rootId = String(rootNode._id);
20
21 const result = await runScout(req.params.nodeId, query, req.userId, req.username || "system", { rootId });
22 if (result.error) {
23 return sendError(res, 409, ERR.RESOURCE_CONFLICT, result.error);
24 }
25 sendOk(res, result);
26 } catch (err) {
27 sendError(res, 500, ERR.INTERNAL, err.message);
28 }
29});
30
31// GET /node/:nodeId/scout/history - previous scout runs
32router.get("/node/:nodeId/scout/history", authenticate, async (req, res) => {
33 try {
34 const history = await getScoutHistory(req.params.nodeId);
35 sendOk(res, { history });
36 } catch (err) {
37 sendError(res, 500, ERR.INTERNAL, err.message);
38 }
39});
40
41// GET /node/:nodeId/scout/gaps - accumulated knowledge gaps
42router.get("/node/:nodeId/scout/gaps", authenticate, async (req, res) => {
43 try {
44 const gaps = await getScoutGaps(req.params.nodeId);
45 sendOk(res, { gaps });
46 } catch (err) {
47 sendError(res, 500, ERR.INTERNAL, err.message);
48 }
49});
50
51export default router;
52
1import { z } from "zod";
2import { resolveRootNode } from "../../seed/tree/treeFetch.js";
3import { runScout, getScoutHistory, getScoutGaps } from "./core.js";
4
5export default [
6 {
7 name: "scout-query",
8 description:
9 "Triangulate across the tree to answer a question. Runs five parallel search strategies " +
10 "(semantic, structural, memory, codebook, profile), scores by convergence, and synthesizes " +
11 "an answer with citations. Returns what the tree knows and what it doesn't.",
12 schema: {
13 nodeId: z.string().describe("The node to scout from."),
14 query: z.string().describe("What to find. Natural language."),
15 userId: z.string().describe("Injected by server. Ignore."),
16 username: z.string().optional().describe("Injected by server. Ignore."),
17 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
18 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
19 },
20 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
21 handler: async ({ nodeId, query, userId, username }) => {
22 try {
23 // Walk up to tree root so strategies search the whole tree
24 const rootNode = await resolveRootNode(nodeId);
25 const rootId = String(rootNode._id);
26
27 const result = await runScout(nodeId, query, userId, username || "system", { rootId });
28 if (result.error) {
29 return { content: [{ type: "text", text: result.error }] };
30 }
31
32 // Return human-readable answer as primary content, structured data as context
33 const parts = [result.answer];
34 if (result.citations?.length > 0) {
35 parts.push(`\nCitations: ${result.citations.map(c => typeof c === "string" ? c : c.nodeName || c.nodeId).join(", ")}`);
36 }
37 if (result.gaps?.length > 0) {
38 parts.push(`\nGaps: ${result.gaps.join("; ")}`);
39 }
40 parts.push(`\n(${result.findings.length} findings from ${result.strategiesUsed.length} strategies, confidence: ${result.confidence})`);
41
42 return {
43 content: [{ type: "text", text: parts.join("") }],
44 };
45 } catch (err) {
46 return { content: [{ type: "text", text: `Scout failed: ${err.message}` }] };
47 }
48 },
49 },
50 {
51 name: "scout-history",
52 description: "Previous scout runs at this position. Shows what was searched and what was found.",
53 schema: {
54 nodeId: z.string().describe("The node to check."),
55 userId: z.string().describe("Injected by server. Ignore."),
56 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
57 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
58 },
59 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
60 handler: async ({ nodeId }) => {
61 try {
62 const history = await getScoutHistory(nodeId);
63 if (history.length === 0) {
64 return { content: [{ type: "text", text: "No scout history at this position." }] };
65 }
66 return { content: [{ type: "text", text: JSON.stringify(history, null, 2) }] };
67 } catch (err) {
68 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
69 }
70 },
71 },
72 {
73 name: "scout-gaps",
74 description: "Accumulated knowledge gaps from all scout runs at this position. What the tree doesn't know.",
75 schema: {
76 nodeId: z.string().describe("The node to check."),
77 userId: z.string().describe("Injected by server. Ignore."),
78 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
79 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
80 },
81 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
82 handler: async ({ nodeId }) => {
83 try {
84 const gaps = await getScoutGaps(nodeId);
85 if (gaps.length === 0) {
86 return { content: [{ type: "text", text: "No knowledge gaps recorded at this position." }] };
87 }
88 return { content: [{ type: "text", text: JSON.stringify(gaps, null, 2) }] };
89 } catch (err) {
90 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
91 }
92 },
93 },
94];
95
Loading comments...