1/**
2 * Explore Core
3 *
4 * Five phases:
5 * 1. Structure scan: tree skeleton, no notes
6 * 2. Metadata probe: scores from evolution, memory, codebook, embed, contradiction
7 * 3. Targeted note sampling: read only top candidates
8 * 4. Iterative drill: deepen or backtrack based on confidence
9 * 5. Map assembly: what's where, what was found, what wasn't
10 *
11 * Uses OrchestratorRuntime for abort support, locking, and step tracking.
12 * Follows understanding's pipeline pattern: init(), trackStep(), runStep(), cleanup().
13 */
14
15import log from "../../seed/log.js";
16import Node from "../../seed/models/node.js";
17import Note from "../../seed/models/note.js";
18import { CONTENT_TYPE, SYSTEM_ROLE } from "../../seed/protocol.js";
19import { OrchestratorRuntime } from "../../seed/orchestrators/runtime.js";
20
21let LLM_PRIORITY;
22try {
23 ({ LLM_PRIORITY } = await import("../../seed/llm/conversation.js"));
24} catch {
25 LLM_PRIORITY = { INTERACTIVE: 2 };
26}
27
28// ─────────────────────────────────────────────────────────────────────────
29// CONFIG
30// ─────────────────────────────────────────────────────────────────────────
31
32const DEFAULTS = {
33 maxIterations: 5,
34 maxNotesPerSample: 5,
35 confidenceThreshold: 0.8,
36 structureScanDepth: 6,
37 maxCandidatesPerIteration: 5,
38 maxTokensPerExplore: 5000,
39};
40
41export async function getExploreConfig() {
42 const configNode = await Node.findOne({ systemRole: SYSTEM_ROLE.CONFIG }).select("metadata").lean();
43 if (!configNode) return { ...DEFAULTS };
44 const meta = configNode.metadata instanceof Map
45 ? configNode.metadata.get("explore") || {}
46 : configNode.metadata?.explore || {};
47 return { ...DEFAULTS, ...meta };
48}
49
50// ─────────────────────────────────────────────────────────────────────────
51// PHASE 1: STRUCTURE SCAN
52// ─────────────────────────────────────────────────────────────────────────
53
54/**
55 * Build a lightweight tree skeleton. Names, types, child counts, depths.
56 * No note content. No metadata beyond what's needed for scoring.
57 */
58async function structureScan(nodeId, maxDepth) {
59 const nodes = [];
60
61 async function walk(id, depth, path) {
62 if (depth > maxDepth) return;
63
64 const node = await Node.findById(id)
65 .select("_id name type status children metadata")
66 .lean();
67 if (!node || node.status === "trimmed") return;
68
69 const meta = node.metadata instanceof Map
70 ? Object.fromEntries(node.metadata)
71 : (node.metadata || {});
72
73 nodes.push({
74 nodeId: node._id,
75 name: node.name,
76 type: node.type || null,
77 status: node.status,
78 childCount: (node.children || []).length,
79 depth,
80 path,
81 // Quick metadata signals (no DB calls, just read what's already loaded)
82 hasEvolution: !!(meta.evolution?.notesWritten || meta.evolution?.visits),
83 hasMemory: !!(meta.memory?.totalInteractions),
84 hasCodebook: !!meta.codebook,
85 hasEmbed: !!meta.embed,
86 hasContradictions: Array.isArray(meta.contradictions) && meta.contradictions.some(c => c.status === "active"),
87 hasCascade: !!meta.cascade?.enabled,
88 dormancyDays: meta.evolution?.lastActivity
89 ? Math.round((Date.now() - new Date(meta.evolution.lastActivity).getTime()) / 86400000)
90 : null,
91 });
92
93 if (node.children) {
94 for (const childId of node.children) {
95 await walk(childId.toString(), depth + 1, `${path}/${node.name}`);
96 }
97 }
98 }
99
100 await walk(nodeId, 0, "");
101 return nodes;
102}
103
104// ─────────────────────────────────────────────────────────────────────────
105// PHASE 2: METADATA PROBE + SCORING
106// ─────────────────────────────────────────────────────────────────────────
107
108/**
109 * Score candidates based on query relevance using structure + metadata signals.
110 * No LLM calls. No note reads. Just signal analysis.
111 */
112async function scoreCandidate(candidate, query, queryVector) {
113 let score = 0;
114 const signals = [];
115
116 // Name match (strong structural hint, not dominant)
117 // When embed is installed, semantic similarity is the strongest signal.
118 // When embed is absent, name match naturally becomes dominant because embed contributes zero.
119 const nameLower = (candidate.name || "").toLowerCase();
120 const queryWords = query.toLowerCase().split(/\s+/);
121 const nameMatches = queryWords.filter(w => nameLower.includes(w)).length;
122 if (nameMatches > 0) {
123 const nameScore = nameMatches / queryWords.length;
124 score += nameScore * 0.25;
125 signals.push(`name match (${nameMatches}/${queryWords.length} words)`);
126 }
127
128 // Activity signal from evolution
129 if (candidate.hasEvolution && candidate.dormancyDays !== null) {
130 if (candidate.dormancyDays < 7) {
131 score += 0.1;
132 signals.push("active (last 7 days)");
133 } else if (candidate.dormancyDays < 30) {
134 score += 0.05;
135 signals.push("recent (last 30 days)");
136 }
137 // Dormant nodes get slightly deprioritized but not excluded
138 }
139
140 // Memory connections (node is connected to other relevant nodes)
141 if (candidate.hasMemory) {
142 score += 0.08;
143 signals.push("has cascade connections");
144 }
145
146 // Codebook presence (rich interaction history)
147 if (candidate.hasCodebook) {
148 score += 0.1;
149 signals.push("has codebook");
150 }
151
152 // Embed similarity (if both query and candidate have vectors)
153 if (candidate.hasEmbed && queryVector) {
154 try {
155 const { getExtension } = await import("../loader.js");
156 const embedExt = getExtension("embed");
157 if (embedExt?.exports?.findSimilar) {
158 // Get the note vector for this candidate
159 const note = await Note.findOne({
160 nodeId: candidate.nodeId,
161 contentType: CONTENT_TYPE.TEXT,
162 "metadata.embed.vector": { $exists: true },
163 }).sort({ createdAt: -1 }).select("metadata").lean();
164
165 if (note) {
166 const vec = note.metadata instanceof Map
167 ? note.metadata.get("embed")?.vector
168 : note.metadata?.embed?.vector;
169 if (vec && queryVector) {
170 let dot = 0, normA = 0, normB = 0;
171 for (let i = 0; i < Math.min(vec.length, queryVector.length); i++) {
172 dot += vec[i] * queryVector[i];
173 normA += vec[i] * vec[i];
174 normB += queryVector[i] * queryVector[i];
175 }
176 const sim = Math.sqrt(normA) * Math.sqrt(normB) > 0
177 ? dot / (Math.sqrt(normA) * Math.sqrt(normB)) : 0;
178 if (sim > 0.7) {
179 score += sim * 0.3;
180 signals.push(`embed similarity ${(sim * 100).toFixed(0)}%`);
181 }
182 }
183 }
184 }
185 } catch (err) {
186 log.debug("Explore", "Embed similarity lookup failed:", err.message);
187 }
188 }
189
190 // Contradictions (signals active debate)
191 if (candidate.hasContradictions) {
192 score += 0.05;
193 signals.push("has active contradictions");
194 }
195
196 // Child count (branches with more structure are more likely to contain what you need)
197 if (candidate.childCount > 0) {
198 score += Math.min(candidate.childCount / 20, 0.05);
199 }
200
201 return { ...candidate, score: Math.min(score, 1.0), signals };
202}
203
204// ─────────────────────────────────────────────────────────────────────────
205// PHASE 3: TARGETED NOTE SAMPLING
206// ─────────────────────────────────────────────────────────────────────────
207
208/**
209 * Read recent notes from top candidates. Capped per node.
210 * explored map is checked to skip already-sampled nodes.
211 */
212async function sampleNotes(candidates, explored, maxPerNode) {
213 const samples = [];
214
215 for (const candidate of candidates) {
216 if (explored.has(candidate.nodeId)) continue;
217
218 const notes = await Note.find({
219 nodeId: candidate.nodeId,
220 contentType: CONTENT_TYPE.TEXT,
221 })
222 .sort({ createdAt: -1 })
223 .limit(maxPerNode)
224 .select("_id content createdAt")
225 .lean();
226
227 if (notes.length > 0) {
228 samples.push({
229 nodeId: candidate.nodeId,
230 nodeName: candidate.name,
231 path: candidate.path,
232 score: candidate.score,
233 signals: candidate.signals,
234 notes: notes.map(n => ({
235 content: n.content.slice(0, 500),
236 date: n.createdAt,
237 })),
238 });
239 }
240 }
241
242 return samples;
243}
244
245// ─────────────────────────────────────────────────────────────────────────
246// EVAL PROMPT BUILDER
247// ─────────────────────────────────────────────────────────────────────────
248
249function buildEvalPrompt(query, samples, previousFindings) {
250 let prompt = `Query: "${query}"\n\nSampled notes:\n`;
251 for (const s of samples) {
252 prompt += `\n--- ${s.nodeName} (${s.nodeId}) ---\n`;
253 prompt += `Path: ${s.path}, Score: ${s.score}\n`;
254 prompt += `Signals: ${s.signals.join(", ")}\n`;
255 for (const note of s.notes) {
256 prompt += `${note.content}\n`;
257 }
258 }
259 if (previousFindings.length > 0) {
260 prompt += `\nPrevious findings (do not repeat, build on these):\n`;
261 prompt += JSON.stringify(previousFindings.map(f => ({ nodeId: f.nodeId, summary: f.summary })), null, 2);
262 }
263 prompt += `\n\nEvaluate these notes against the query. Return JSON with findings, confidence, drillInto, gaps.`;
264 return prompt;
265}
266
267// ─────────────────────────────────────────────────────────────────────────
268// MAIN EXPLORATION LOOP
269// ─────────────────────────────────────────────────────────────────────────
270
271/**
272 * Run a full exploration from a starting node.
273 * Creates its own OrchestratorRuntime for abort, locking, and step tracking.
274 *
275 * @param {string} nodeId - starting position
276 * @param {string} query - what to find
277 * @param {string} userId
278 * @param {string} username
279 * @param {object} opts - { deep, rootId }
280 */
281export async function runExplore(nodeId, query, userId, username, opts = {}) {
282 const config = await getExploreConfig();
283 const maxIterations = opts.deep ? config.maxIterations * 2 : config.maxIterations;
284 const threshold = opts.deep ? config.confidenceThreshold * 0.7 : config.confidenceThreshold;
285
286 // Find root for the runtime
287 let rootId = opts.rootId || null;
288 if (!rootId) {
289 try {
290 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
291 const root = await resolveRootNode(nodeId);
292 rootId = root?._id;
293 } catch (err) {
294 log.debug("Explore", "Root resolution failed:", err.message);
295 }
296 }
297
298 // Create runtime. Explore is a standalone pipeline, not part of the user's session.
299 const rt = new OrchestratorRuntime({
300 rootId: rootId || nodeId,
301 userId,
302 username: username || "system",
303 visitorId: `explore:${userId}:${nodeId}:${Date.now()}`,
304 sessionType: "EXPLORE",
305 description: `Exploring: ${query}`,
306 modeKeyForLlm: "tree:explore",
307 lockNamespace: "explore",
308 lockKey: `explore:${nodeId}`,
309 llmPriority: LLM_PRIORITY?.INTERACTIVE || 2,
310 });
311
312 const ok = await rt.init(query);
313 if (!ok) {
314 return { error: "Exploration already in progress at this node" };
315 }
316
317 try {
318 // Get query vector if embed is available
319 let queryVector = null;
320 try {
321 const { getExtension } = await import("../loader.js");
322 const embedExt = getExtension("embed");
323 if (embedExt?.exports?.generateEmbedding) {
324 queryVector = await embedExt.exports.generateEmbedding(query, userId);
325 }
326 } catch (err) {
327 log.debug("Explore", "Query vector generation failed:", err.message);
328 }
329
330 // ── Phase 1: Structure scan (no LLM) ─────────────────────────────────
331 const startScan = Date.now();
332 const allNodes = await structureScan(nodeId, config.structureScanDepth);
333 rt.trackStep("tree:explore", {
334 input: { phase: "structure-scan", nodeId, depth: config.structureScanDepth },
335 output: { candidateCount: allNodes.length },
336 startTime: startScan,
337 endTime: Date.now(),
338 });
339
340 if (allNodes.length === 0) {
341 rt.setResult("No nodes found", "tree:explore");
342 return emptyMap(nodeId, query);
343 }
344
345 if (rt.aborted) {
346 rt.setError("Exploration cancelled", "tree:explore");
347 return { error: "Exploration cancelled" };
348 }
349
350 // ── Phase 2: Score all candidates (no LLM) ──────────────────────────
351 const startProbe = Date.now();
352 const scored = [];
353 for (const node of allNodes) {
354 if (node.depth === 0) continue; // skip the root itself
355 const result = await scoreCandidate(node, query, queryVector);
356 scored.push(result);
357 }
358 scored.sort((a, b) => b.score - a.score);
359
360 rt.trackStep("tree:explore", {
361 input: { phase: "metadata-probe", candidates: scored.length },
362 output: { topScore: scored[0]?.score || 0 },
363 startTime: startProbe,
364 endTime: Date.now(),
365 });
366
367 if (rt.aborted) {
368 rt.setError("Exploration cancelled", "tree:explore");
369 return { error: "Exploration cancelled" };
370 }
371
372 // ── Phase 3-4: Sample + Evaluate loop ────────────────────────────────
373 const explored = new Map();
374 const allFindings = [];
375 let allGaps = [];
376 let confidence = 0;
377 let totalNotesRead = 0;
378 let iteration = 0;
379 let candidates = scored.slice(0, config.maxCandidatesPerIteration);
380
381 while (iteration < maxIterations && confidence < threshold && candidates.length > 0) {
382 if (rt.aborted) {
383 rt.setError("Exploration cancelled", "tree:explore");
384 return { error: "Exploration cancelled" };
385 }
386 iteration++;
387
388 // Phase 3: Sample notes (no LLM)
389 const samples = await sampleNotes(candidates, explored, config.maxNotesPerSample);
390
391 for (const s of samples) {
392 explored.set(s.nodeId, true);
393 totalNotesRead += s.notes.length;
394 }
395
396 if (samples.length === 0) break;
397
398 // Phase 4: Evaluate (LLM call through runStep)
399 const evalPrompt = buildEvalPrompt(query, samples, allFindings);
400
401 let parsed = null;
402 try {
403 const result = await rt.runStep("tree:explore", {
404 prompt: evalPrompt,
405 });
406 parsed = result?.parsed || null;
407 } catch (err) {
408 log.debug("Explore", `Evaluation step failed: ${err.message}`);
409 break;
410 }
411
412 if (!parsed || !parsed.findings) {
413 // LLM returned unparseable response, use what we have
414 break;
415 }
416
417 // Collect findings
418 if (Array.isArray(parsed.findings)) {
419 for (const f of parsed.findings) {
420 if (f && f.nodeId) allFindings.push(f);
421 }
422 }
423
424 confidence = parsed.confidence || 0;
425 if (Array.isArray(parsed.gaps)) {
426 allGaps = [...allGaps, ...parsed.gaps];
427 }
428
429 // Prepare next iteration candidates from drillInto
430 if (Array.isArray(parsed.drillInto) && parsed.drillInto.length > 0 && confidence < threshold) {
431 candidates = [];
432 for (const drillId of parsed.drillInto) {
433 const drillNode = await Node.findById(drillId).select("children").lean();
434 if (!drillNode?.children) continue;
435 for (const childId of drillNode.children) {
436 const childStr = childId.toString();
437 if (explored.has(childStr)) continue;
438 const child = scored.find(s => s.nodeId === childStr);
439 if (child) {
440 candidates.push(child);
441 } else {
442 // Node not in initial scan, add with base score
443 const node = await Node.findById(childStr).select("_id name type status children").lean();
444 if (node) {
445 candidates.push({
446 nodeId: childStr, name: node.name, type: node.type,
447 score: 0.3, signals: ["drill target"], path: "",
448 childCount: (node.children || []).length, depth: 0,
449 });
450 }
451 }
452 }
453 }
454 } else {
455 break;
456 }
457 }
458
459 // ── Phase 5: Assemble map ────────────────────────────────────────────
460 const totalNotes = await Note.countDocuments({
461 nodeId: { $in: allNodes.map(n => n.nodeId) },
462 contentType: CONTENT_TYPE.TEXT,
463 });
464
465 const coverageStr = totalNotes > 0 ? `${((totalNotesRead / totalNotes) * 100).toFixed(2)}%` : "0%";
466 const map = {
467 query,
468 answer: `Explored ${explored.size} nodes under ${allNodes[0]?.name || nodeId}, read ${totalNotesRead} of ${totalNotes} notes (${coverageStr} coverage). Found ${allFindings.length} relevant items.${allGaps.length > 0 ? ` Gaps: ${allGaps.slice(0, 3).join("; ")}.` : ""}`,
469 rootNode: allNodes[0]?.name || nodeId,
470 nodesExplored: explored.size,
471 notesRead: totalNotesRead,
472 totalNotesInBranch: totalNotes,
473 coverage: coverageStr,
474 iterations: iteration,
475 map: allFindings.sort((a, b) => (b.relevance || 0) - (a.relevance || 0)),
476 unexplored: scored
477 .filter(s => !explored.has(s.nodeId) && s.score > 0.1)
478 .slice(0, 10)
479 .map(s => ({ nodeId: s.nodeId, name: s.name, score: s.score, reason: s.signals.join(", ") || "Low relevance" })),
480 gaps: allGaps.length > 0 ? allGaps : [],
481 confidence,
482 };
483
484 // Write map to metadata for working memory
485 await Node.findByIdAndUpdate(nodeId, {
486 $set: {
487 "metadata.explore.lastMap": map,
488 "metadata.explore.lastQuery": query,
489 "metadata.explore.lastExplored": new Date().toISOString(),
490 },
491 });
492
493 rt.setResult(`Explored ${explored.size} nodes, read ${totalNotesRead} notes. Coverage: ${map.coverage}. ${map.gaps.length > 0 ? `Gaps: ${map.gaps.slice(0, 3).join("; ")}` : "No gaps found."}`, "tree:explore");
494 return map;
495
496 } catch (err) {
497 rt.setError(err.message, "tree:explore");
498 throw err;
499 } finally {
500 await rt.cleanup();
501 }
502}
503
504function emptyMap(nodeId, query) {
505 return {
506 query,
507 rootNode: nodeId,
508 nodesExplored: 0,
509 notesRead: 0,
510 totalNotesInBranch: 0,
511 coverage: "0%",
512 iterations: 0,
513 map: [],
514 unexplored: [],
515 gaps: ["No nodes found below this position"],
516 confidence: 0,
517 };
518}
519
520// ─────────────────────────────────────────────────────────────────────────
521// MAP ACCESS
522// ─────────────────────────────────────────────────────────────────────────
523
524export async function getExploreMap(nodeId) {
525 const node = await Node.findById(nodeId).select("metadata").lean();
526 if (!node) return null;
527 const meta = node.metadata instanceof Map
528 ? node.metadata.get("explore") || {}
529 : node.metadata?.explore || {};
530 return meta.lastMap || null;
531}
532
533export async function getExploreGaps(nodeId) {
534 const map = await getExploreMap(nodeId);
535 if (!map) return { gaps: [], unexplored: [] };
536 return { gaps: map.gaps || [], unexplored: map.unexplored || [] };
537}
538
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import exploreMode from "./modes/explore.js";
4import { runExplore, getExploreMap, getExploreGaps } from "./core.js";
5
6export async function init(core) {
7 // Register the explore mode. Hidden from mode bar. Used internally by the
8 // OrchestratorRuntime during the evaluation step of the exploration pipeline.
9 core.modes.registerMode("tree:explore", exploreMode, "explore");
10
11 // Inject last explore map into AI context at positions that have been explored.
12 // The AI sees not just the tree summary but also "last time someone explored
13 // this branch, here's what they found."
14 core.hooks.register("enrichContext", async ({ context, node, meta }) => {
15 const explore = meta.explore;
16 if (!explore?.lastMap) return;
17
18 // Only inject if the map is reasonably fresh (last 7 days)
19 if (explore.lastExplored) {
20 const age = Date.now() - new Date(explore.lastExplored).getTime();
21 if (age > 7 * 24 * 60 * 60 * 1000) return;
22 }
23
24 const map = explore.lastMap;
25 context.exploreMap = {
26 query: map.query,
27 coverage: map.coverage,
28 confidence: map.confidence,
29 findings: (map.map || []).slice(0, 5).map(f => ({
30 nodeName: f.nodeName,
31 relevance: f.relevance,
32 summary: f.summary,
33 })),
34 gaps: (map.gaps || []).slice(0, 3),
35 };
36 }, "explore");
37
38 const { default: router } = await import("./routes.js");
39
40 return {
41 router,
42 tools,
43 exports: {
44 runExplore,
45 getExploreMap,
46 getExploreGaps,
47 },
48 };
49}
50
1export default {
2 name: "explore",
3 version: "1.0.1",
4 builtFor: "treeos-intelligence",
5 description:
6 "The AI navigates a tree branch the way Claude Code navigates a codebase. It does not read " +
7 "everything. It reads the structure first. Names, types, child counts, depths. No note content. " +
8 "Just the skeleton. Then it probes metadata: evolution fitness, long-memory connections, codebook " +
9 "entries, embed vectors, contradiction state. Each signal produces a score. Candidates re-rank. " +
10 "Then it reads notes only from the top candidates. Recent notes first. Capped per node. If " +
11 "confidence is below threshold, it drills deeper. If a branch is a dead end, it backtracks. " +
12 "The loop runs until confidence exceeds threshold or max iterations reached. The final output " +
13 "is a navigation map: what is where, what was found, what was not explored, what gaps remain. " +
14 "Explored 1.15% of the branch. Found the answer. Did not read the other 98.85%. Each explore " +
15 "writes its map to metadata so the next explore at the same position starts where the last one " +
16 "stopped. The tree is too big for the AI to see all at once. Explore gives the AI eyes that " +
17 "move through the tree the way yours move through code.",
18
19 needs: {
20 services: ["hooks", "llm", "session"],
21 models: ["Node", "Note"],
22 },
23
24 optional: {
25 extensions: [
26 "embed",
27 "long-memory",
28 "codebook",
29 "evolution",
30 "contradiction",
31 "inverse-tree",
32 "scout",
33 "intent",
34 ],
35 },
36
37 provides: {
38 models: {},
39 routes: "./routes.js",
40 tools: true,
41 jobs: false,
42 orchestrator: false,
43 energyActions: {},
44 sessionTypes: {},
45 env: [],
46
47 cli: [
48 {
49 command: "explore [action] [args...]", scope: ["tree"],
50 description: "Explore branch below current position. Actions: map, gaps. No action starts exploration.",
51 method: "POST",
52 endpoint: "/node/:nodeId/explore",
53 subcommands: {
54 "deep": {
55 method: "POST",
56 endpoint: "/node/:nodeId/explore/deep",
57 args: ["query"],
58 description: "More iterations, lower threshold",
59 },
60 "map": {
61 method: "GET",
62 endpoint: "/node/:nodeId/explore/map",
63 description: "Show last explore map",
64 },
65 "gaps": {
66 method: "GET",
67 endpoint: "/node/:nodeId/explore/gaps",
68 description: "Unexplored areas from last map",
69 },
70 },
71 },
72 ],
73
74 hooks: {
75 fires: [],
76 listens: ["enrichContext"],
77 },
78 },
79};
80
1export default {
2 emoji: "🔍",
3 label: "Explore",
4 bigMode: "tree",
5 hidden: true,
6 toolNames: ["explore-branch", "explore-map", "explore-drill"],
7 buildSystemPrompt({ username }) {
8 return `You are exploring a branch. Your job is to evaluate sampled notes against a query and return structured findings.
9
10Return ONLY JSON:
11{
12 "findings": [{ "nodeId": "...", "relevance": 0.0-1.0, "summary": "...", "keyFindings": ["..."] }],
13 "confidence": 0.0-1.0,
14 "drillInto": ["nodeId", ...],
15 "gaps": ["..."]
16}
17
18Be precise. High relevance means the notes directly answer the query. Low relevance means tangential. drillInto lists unexplored children that look promising based on what you read. gaps lists what you expected to find but didn't.`;
19 },
20};
21
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { runExplore, getExploreMap, getExploreGaps } from "./core.js";
5
6const router = express.Router();
7
8// POST /node/:nodeId/explore - run exploration
9router.post("/node/:nodeId/explore", authenticate, async (req, res) => {
10 try {
11 const { query, deep } = req.body || {};
12 if (!query || typeof query !== "string") {
13 return sendError(res, 400, ERR.INVALID_INPUT, "query is required");
14 }
15 const map = await runExplore(req.params.nodeId, query, req.userId, req.username || "system", { deep: !!deep });
16 sendOk(res, map);
17 } catch (err) {
18 sendError(res, 500, ERR.INTERNAL, err.message);
19 }
20});
21
22// GET /node/:nodeId/explore/map - last exploration map
23router.get("/node/:nodeId/explore/map", authenticate, async (req, res) => {
24 try {
25 const map = await getExploreMap(req.params.nodeId);
26 if (!map) return sendOk(res, { message: "No exploration map at this position." });
27 sendOk(res, map);
28 } catch (err) {
29 sendError(res, 500, ERR.INTERNAL, err.message);
30 }
31});
32
33// GET /node/:nodeId/explore/gaps - unexplored areas
34router.get("/node/:nodeId/explore/gaps", authenticate, async (req, res) => {
35 try {
36 const result = await getExploreGaps(req.params.nodeId);
37 sendOk(res, result);
38 } catch (err) {
39 sendError(res, 500, ERR.INTERNAL, err.message);
40 }
41});
42
43export default router;
44
1import { z } from "zod";
2import { runExplore, getExploreMap, getExploreGaps } from "./core.js";
3
4export default [
5 {
6 name: "explore-branch",
7 description:
8 "Explore the branch below a node to find specific information. Scans structure first, " +
9 "probes metadata signals, samples notes from top candidates, drills deeper if needed. " +
10 "Returns a navigation map showing what was found and what wasn't.",
11 schema: {
12 nodeId: z.string().describe("The node to explore from."),
13 query: z.string().describe("What to find. Natural language."),
14 deep: z.boolean().optional().default(false).describe("More iterations, lower confidence threshold."),
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, deep, userId, username }) => {
22 try {
23 const map = await runExplore(nodeId, query, userId, username || "system", { deep });
24 if (map.error) {
25 return { content: [{ type: "text", text: map.error }] };
26 }
27
28 // Human-readable output for AI to relay
29 const parts = [`Explored ${map.nodesExplored} nodes, read ${map.notesRead}/${map.totalNotesInBranch} notes (${map.coverage} coverage).`];
30 if (map.map?.length > 0) {
31 parts.push(`\nFindings:`);
32 for (const f of map.map.slice(0, 10)) {
33 parts.push(` ${f.nodeName || f.nodeId}: ${f.summary || f.content?.slice(0, 100) || "found"} (relevance: ${f.relevance || "?"})`);
34 }
35 if (map.map.length > 10) parts.push(` ...and ${map.map.length - 10} more`);
36 }
37 if (map.gaps?.length > 0) parts.push(`\nGaps: ${map.gaps.join("; ")}`);
38 if (map.unexplored?.length > 0) {
39 parts.push(`\nUnexplored: ${map.unexplored.slice(0, 5).map(u => u.name || u.nodeId).join(", ")}`);
40 }
41
42 return {
43 content: [{ type: "text", text: parts.join("\n") }],
44 };
45 } catch (err) {
46 return { content: [{ type: "text", text: `Exploration failed: ${err.message}` }] };
47 }
48 },
49 },
50 {
51 name: "explore-map",
52 description: "Read the last exploration map at a position. Shows what was found without re-exploring.",
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 map = await getExploreMap(nodeId);
63 if (!map) return { content: [{ type: "text", text: "No exploration map at this position. Run explore-branch first." }] };
64 return { content: [{ type: "text", text: JSON.stringify(map, null, 2) }] };
65 } catch (err) {
66 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
67 }
68 },
69 },
70 {
71 name: "explore-drill",
72 description: "Drill into a specific unexplored node from a previous exploration. Continues where the last explore stopped.",
73 schema: {
74 nodeId: z.string().describe("The unexplored node to drill into."),
75 query: z.string().describe("The same query or a refined one."),
76 userId: z.string().describe("Injected by server. Ignore."),
77 username: z.string().optional().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: false, openWorldHint: true },
82 handler: async ({ nodeId, query, userId, username }) => {
83 try {
84 const map = await runExplore(nodeId, query, userId, username || "system", {});
85 if (map.error) {
86 return { content: [{ type: "text", text: map.error }] };
87 }
88
89 const parts = [`Drilled into ${map.rootNode}. Explored ${map.nodesExplored} nodes, read ${map.notesRead} notes (${map.coverage} coverage).`];
90 if (map.map?.length > 0) {
91 parts.push(`\nFindings:`);
92 for (const f of map.map.slice(0, 10)) {
93 parts.push(` ${f.nodeName || f.nodeId}: ${f.summary || f.content?.slice(0, 100) || "found"} (relevance: ${f.relevance || "?"})`);
94 }
95 }
96 if (map.gaps?.length > 0) parts.push(`\nGaps: ${map.gaps.join("; ")}`);
97
98 return {
99 content: [{ type: "text", text: parts.join("\n") }],
100 };
101 } catch (err) {
102 return { content: [{ type: "text", text: `Drill failed: ${err.message}` }] };
103 }
104 },
105 },
106];
107
Loading comments...