EXTENSION for treeos-intelligence
explore
The AI navigates a tree branch the way Claude Code navigates a codebase. It does not read everything. It reads the structure first. Names, types, child counts, depths. No note content. Just the skeleton. Then it probes metadata: evolution fitness, long-memory connections, codebook entries, embed vectors, contradiction state. Each signal produces a score. Candidates re-rank. Then it reads notes only from the top candidates. Recent notes first. Capped per node. If confidence is below threshold, it drills deeper. If a branch is a dead end, it backtracks. The loop runs until confidence exceeds threshold or max iterations reached. The final output is a navigation map: what is where, what was found, what was not explored, what gaps remain. Explored 1.15% of the branch. Found the answer. Did not read the other 98.85%. Each explore writes its map to metadata so the next explore at the same position starts where the last one stopped. The tree is too big for the AI to see all at once. Explore gives the AI eyes that move through the tree the way yours move through code.
v1.0.1 by TreeOS Site 0 downloads 6 files 840 lines 29.5 KB published 38d ago
treeos ext install explore
View changelog

Manifest

Provides

  • routes
  • tools
  • 1 CLI commands

Requires

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

Optional

  • extensions: embed, long-memory, codebook, evolution, contradiction, inverse-tree, scout, intent
SHA256: 7785d38c757673bbf604e58ef89ad0713f6631fca39097e0bd31fd3f9d52a89b

Dependents

1 package depend on this

PackageTypeRelationship
treeos-intelligence v1.0.2bundleincludes

CLI Commands

CommandMethodDescription
explorePOSTExplore branch below current position. Actions: map, gaps. No action starts exploration.
explore deepPOSTMore iterations, lower threshold
explore mapGETShow last explore map
explore gapsGETUnexplored areas from last map

Hooks

Listens To

  • enrichContext

Source Code

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

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 explore

Comments

Loading comments...

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