EXTENSION for treeos-intelligence
scout
Triangulate across the tree. Five search strategies run in parallel: semantic, structural, memory, codebook, and profile. Findings that appear in multiple strategies score higher (convergence scoring). The AI synthesizes all results into an answer with citations. Scout is peripheral vision while explore is focused gaze. Scout asks 'what does the tree know about X' across the whole branch. Explore asks 'what's under this node about X' going downward. Scout gaps feed intent: the tree notices what it doesn't know and acts on it.
v1.0.1 by TreeOS Site 0 downloads 6 files 810 lines 28.1 KB published 38d ago
treeos ext install scout
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

  • services: hooks, llm, metadata, session
  • models: Node, Note

Optional

  • extensions: embed, long-memory, codebook, inverse-tree, contradiction, gap-detection, intent, explore
SHA256: 03865faa079a19360c4d683abe1c9d9dd162c0ab030f67c248b28fa2e59f8de9

Dependents

1 package depend on this

PackageTypeRelationship
treeos-intelligence v1.0.2bundleincludes

CLI Commands

CommandMethodDescription
scoutPOSTTriangulate across the tree
scout historyGETPrevious scout runs at this position
scout gapsGETAccumulated knowledge gaps

Hooks

Listens To

  • enrichContext

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star scout

Comments

Loading comments...

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