1/**
2 * Go Core
3 *
4 * Cross-tree navigation by intent. Searches all user's trees
5 * via the routing index and node names.
6 */
7
8import Node from "../../seed/models/node.js";
9import log from "../../seed/log.js";
10
11let _getExtension = null;
12let _getUserRoots = null;
13
14export function configure({ getExtension }) {
15 _getExtension = getExtension;
16}
17
18function getRoutingExports() {
19 if (!_getExtension) return null;
20 return _getExtension("tree-orchestrator")?.exports || null;
21}
22
23function getNavExports() {
24 if (!_getExtension) return null;
25 return _getExtension("navigation")?.exports || null;
26}
27
28// ─────────────────────────────────────────────────────────────────────────
29// LIST ALL POSITIONS
30// ─────────────────────────────────────────────────────────────────────────
31
32export async function listPositions(userId) {
33 // Tree roots = direct children of land root, excluding system nodes
34 const landRoot = await Node.findOne({ systemRole: "land-root" }).select("_id").lean();
35 if (!landRoot) return { trees: [], extensions: [] };
36
37 const allRoots = await Node.find({
38 parent: landRoot._id,
39 systemRole: null,
40 }).select("_id name dateCreated rootOwner contributors").lean();
41
42 // Filter to roots this user owns or contributes to
43 const userRoots = allRoots.filter(r =>
44 String(r.rootOwner) === String(userId) ||
45 (r.contributors || []).some(c => String(c) === String(userId))
46 );
47
48 // Deduplicate by name (keep newest)
49 const seen = new Map();
50 for (const root of userRoots) {
51 const existing = seen.get(root.name);
52 if (!existing || root.dateCreated > existing.dateCreated) {
53 seen.set(root.name, root);
54 }
55 }
56 const trees = [...seen.values()].map(r => ({
57 nodeId: String(r._id),
58 name: r.name,
59 }));
60
61 // Extension positions from routing index
62 const extensions = [];
63 const routing = getRoutingExports();
64 if (routing?.getIndexForRoot) {
65 for (const root of trees) {
66 const index = routing.getIndexForRoot(root.nodeId);
67 if (!index) continue;
68 for (const [extName, entry] of index) {
69 extensions.push({
70 nodeId: entry.nodeId,
71 name: extName,
72 path: entry.path,
73 });
74 }
75 }
76 }
77
78 return { trees, extensions };
79}
80
81// ─────────────────────────────────────────────────────────────────────────
82// FIND DESTINATION
83// ─────────────────────────────────────────────────────────────────────────
84
85export async function findDestination(query, userId) {
86 const target = query.toLowerCase().trim();
87 if (!target) return listPositions(userId);
88
89 const matches = [];
90
91 // Get user's roots
92 const nav = getNavExports();
93 const roots = nav?.getUserRootsWithNames
94 ? await nav.getUserRootsWithNames(userId)
95 : await Node.find({ rootOwner: userId }).select("_id name").lean();
96
97 const routing = getRoutingExports();
98
99 // Search routing index across all trees
100 for (const root of roots) {
101 const rootId = String(root._id);
102
103 if (routing?.getIndexForRoot) {
104 const index = routing.getIndexForRoot(rootId);
105 if (index) {
106 for (const [extName, entry] of index) {
107 const score = matchScore(target, extName, entry.name, entry.path);
108 if (score > 0) {
109 matches.push({ ...entry, extension: extName, score });
110 }
111 }
112 }
113 }
114
115 // Check root name
116 const rootScore = matchScore(target, null, root.name, "/" + root.name);
117 if (rootScore > 0) {
118 matches.push({
119 nodeId: rootId,
120 name: root.name,
121 path: "/" + root.name,
122 mode: null,
123 score: rootScore,
124 });
125 }
126 }
127
128 // If no matches from index, search node names directly
129 if (matches.length === 0) {
130 const nodeMatch = await searchNodesByName(target, roots.map(r => r._id));
131 if (nodeMatch) {
132 matches.push(nodeMatch);
133 }
134 }
135
136 // Sort by score descending
137 matches.sort((a, b) => b.score - a.score);
138
139 if (matches.length === 0) {
140 return { found: false, query: target };
141 }
142
143 if (matches.length === 1 || matches[0].score > matches[1].score * 1.5) {
144 // Clear winner
145 return { found: true, destination: matches[0] };
146 }
147
148 // Ambiguous
149 return { found: true, ambiguous: true, options: matches.slice(0, 5) };
150}
151
152function matchScore(target, extName, nodeName, path) {
153 const lowerName = (nodeName || "").toLowerCase();
154 const lowerPath = (path || "").toLowerCase();
155 const lowerExt = (extName || "").toLowerCase();
156
157 // Exact extension name match
158 if (lowerExt === target) return 10;
159
160 // Exact node name match
161 if (lowerName === target) return 9;
162
163 // Extension name contains target
164 if (lowerExt && lowerExt.includes(target)) return 7;
165
166 // Node name contains target
167 if (lowerName.includes(target)) return 6;
168
169 // Path contains target
170 if (lowerPath.includes(target)) return 4;
171
172 // Word match in name
173 const targetWords = target.split(/\s+/);
174 const nameWords = lowerName.split(/[\s\-_\/]+/);
175 const pathWords = lowerPath.split(/[\s\-_\/]+/);
176 const allWords = [...nameWords, ...pathWords, lowerExt].filter(Boolean);
177
178 let wordHits = 0;
179 for (const tw of targetWords) {
180 if (allWords.some(w => w.includes(tw) || tw.includes(w))) wordHits++;
181 }
182 if (wordHits > 0) return 2 + wordHits;
183
184 return 0;
185}
186
187async function searchNodesByName(target, rootIds) {
188 try {
189 // Search for nodes whose name matches the target across user's trees
190 const regex = new RegExp(target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
191 const node = await Node.findOne({
192 name: regex,
193 $or: rootIds.map(id => ({ $or: [{ _id: id }, { parent: { $exists: true } }] })),
194 }).select("_id name parent").lean();
195
196 if (!node) return null;
197
198 // Build path
199 const parts = [node.name];
200 let current = node;
201 let depth = 0;
202 while (current.parent && depth < 10) {
203 current = await Node.findById(current.parent).select("name parent rootOwner").lean();
204 if (!current) break;
205 parts.unshift(current.name);
206 if (current.rootOwner) break;
207 depth++;
208 }
209
210 return {
211 nodeId: String(node._id),
212 name: node.name,
213 path: "/" + parts.join("/"),
214 mode: null,
215 score: 3,
216 };
217 } catch {
218 return null;
219 }
220}
221
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { configure, findDestination, listPositions } from "./core.js";
4
5export async function init(core) {
6 // Wire dependencies
7 try {
8 const { getExtension } = await import("../loader.js");
9 configure({ getExtension });
10 } catch {}
11
12 const { default: router } = await import("./routes.js");
13
14 // Tool handler
15 const toolHandlers = {
16 "go-to": async ({ destination }, { userId }) => {
17 if (!destination) {
18 const { trees, extensions } = await listPositions(userId);
19 let out = "";
20 if (trees.length > 0) out += "Trees:\n" + trees.map(t => ` ${t.name}`).join("\n");
21 if (extensions.length > 0) out += (out ? "\n\n" : "") + "Extensions:\n" + extensions.map(e => ` ${e.name} ${e.path}`).join("\n");
22 return out || "No trees found.";
23 }
24
25 const result = await findDestination(destination, userId);
26
27 if (!result.found) {
28 return `No match for "${destination}". Try 'go' with no arguments to see all positions.`;
29 }
30
31 if (result.ambiguous) {
32 return "Multiple matches:\n" + result.options.map(o => ` ${o.path}${o.extension ? ` (${o.extension})` : ""}`).join("\n") + "\nBe more specific.";
33 }
34
35 const dest = result.destination;
36 return JSON.stringify({
37 _navigate: dest.nodeId,
38 answer: `Navigating to ${dest.path}`,
39 });
40 },
41 };
42
43 log.info("Go", "Loaded. Navigate by intent.");
44
45 return {
46 router,
47 tools,
48 toolHandlers,
49 exports: { findDestination, listPositions },
50 };
51}
52
1export default {
2 name: "go",
3 version: "1.0.3",
4 builtFor: "TreeOS",
5 description:
6 "Navigate by intent. Say where, not how. " +
7 "'go workout' finds fitness across all your trees. 'go food' finds the food node. " +
8 "'go' with no arguments shows all navigable positions ranked by recency. " +
9 "Reads the routing index from tree-orchestrator. Matches extension names, tree names, " +
10 "node names. Navigates to the best match. No LLM call. Microseconds.",
11
12 needs: {
13 services: ["hooks"],
14 models: ["Node"],
15 },
16
17 optional: {
18 extensions: ["tree-orchestrator", "navigation"],
19 },
20
21 provides: {
22 models: {},
23 routes: "./routes.js",
24 tools: true,
25 jobs: false,
26 orchestrator: false,
27 energyActions: {},
28 sessionTypes: {},
29 env: [],
30
31 cli: [
32 {
33 command: "go [destination...]",
34 scope: ["tree", "home"],
35 description: "Navigate to a position by name or intent. No args = show all positions.",
36 method: "GET",
37 endpoint: "/go?q=:destination",
38 },
39 ],
40
41 hooks: {
42 fires: [],
43 listens: [],
44 },
45 },
46};
47
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { findDestination, listPositions } from "./core.js";
5
6const router = express.Router();
7
8router.get("/go", authenticate, async (req, res) => {
9 try {
10 const query = req.query.q || req.query.destination || "";
11 const result = query.trim()
12 ? await findDestination(query, req.userId)
13 : await listPositions(req.userId);
14
15 sendOk(res, result);
16 } catch (err) {
17 sendError(res, 500, ERR.INTERNAL, err.message);
18 }
19});
20
21export default router;
22
1export default [
2 {
3 name: "go-to",
4 description:
5 "Navigate to a node by name or intent. Searches across all trees. " +
6 "Use when the user says 'go to workout', 'take me to food', 'navigate to study'. " +
7 "Returns the destination node ID and path for navigation.",
8 inputSchema: {
9 type: "object",
10 properties: {
11 destination: {
12 type: "string",
13 description: "What to navigate to. Extension name, tree name, or node name.",
14 },
15 },
16 required: ["destination"],
17 },
18 },
19];
20
Loading comments...