@sean.holung/minicode 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +25 -47
  2. package/dist/scripts/run-benchmarks.js +73 -28
  3. package/dist/src/agent/config.js +51 -66
  4. package/dist/src/agent/editable-config.js +50 -58
  5. package/dist/src/agent/home-env.js +74 -0
  6. package/dist/src/benchmark/runner.js +142 -59
  7. package/dist/src/cli/config-slash-command.js +15 -13
  8. package/dist/src/indexer/project-index.js +49 -13
  9. package/dist/src/serve/agent-bridge.js +99 -31
  10. package/dist/src/serve/mcp-server.js +70 -21
  11. package/dist/src/serve/server.js +198 -8
  12. package/dist/src/session/session-preview.js +14 -0
  13. package/dist/src/shared/graph-search.js +80 -0
  14. package/dist/src/shared/graph-selection.js +40 -0
  15. package/dist/src/shared/graph-symbols.js +82 -0
  16. package/dist/src/shared/symbol-resolution.js +33 -0
  17. package/dist/src/tools/find-path.js +15 -6
  18. package/dist/src/tools/find-references.js +7 -2
  19. package/dist/src/tools/get-dependencies.js +8 -3
  20. package/dist/src/tools/read-symbol.js +9 -3
  21. package/dist/src/tools/registry.js +4 -1
  22. package/dist/src/tools/search-code-map.js +18 -3
  23. package/dist/src/web/app.js +646 -87
  24. package/dist/src/web/index.html +68 -6
  25. package/dist/src/web/style.css +208 -1
  26. package/dist/tests/benchmark-harness.test.js +100 -0
  27. package/dist/tests/config-api.test.js +5 -5
  28. package/dist/tests/config-integration.test.js +130 -56
  29. package/dist/tests/config-slash-command.test.js +12 -11
  30. package/dist/tests/config.test.js +12 -4
  31. package/dist/tests/editable-config.test.js +15 -12
  32. package/dist/tests/file-tools.test.js +34 -1
  33. package/dist/tests/find-path.test.js +43 -2
  34. package/dist/tests/find-references.test.js +49 -0
  35. package/dist/tests/get-dependencies.test.js +23 -0
  36. package/dist/tests/graph-onboarding.test.js +10 -1
  37. package/dist/tests/graph-search.test.js +66 -0
  38. package/dist/tests/graph-selection.test.js +58 -0
  39. package/dist/tests/graph-symbols.test.js +45 -0
  40. package/dist/tests/home-env.test.js +56 -0
  41. package/dist/tests/indexer.test.js +6 -0
  42. package/dist/tests/read-symbol.test.js +35 -0
  43. package/dist/tests/request-tracker.test.js +15 -0
  44. package/dist/tests/run-benchmarks.test.js +117 -33
  45. package/dist/tests/search-code-map.test.js +2 -0
  46. package/dist/tests/serve.integration.test.js +338 -9
  47. package/dist/tests/session-preview.test.js +56 -0
  48. package/dist/tests/session-ui.test.js +4 -0
  49. package/dist/tests/settings-ui.test.js +18 -0
  50. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  51. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +2 -1
  52. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  53. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +1 -1
  54. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  55. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  56. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +3 -0
  57. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
  58. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts +3 -0
  59. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts.map +1 -1
  60. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js +4 -1
  61. package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js.map +1 -1
  62. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts +11 -1
  63. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  64. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +4 -1
  65. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  66. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.d.ts.map +1 -1
  67. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js +16 -8
  68. package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js.map +1 -1
  69. package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js +19 -2
  70. package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js.map +1 -1
  71. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  72. package/package.json +1 -1
@@ -8,8 +8,11 @@ import { createWebSocketServer } from "./websocket.js";
8
8
  import { handleChatCompletions, handleModels } from "./openai-compat.js";
9
9
  import { formatConfigForDisplay, getConfigMissing } from "../agent/config.js";
10
10
  import { applyPersistedConfigUpdates, buildStructuredConfigPayload } from "../agent/editable-config.js";
11
+ import { getHomeEnvPath, upsertHomeEnvValues } from "../agent/home-env.js";
11
12
  import { sortModelsAlphabetically } from "../model-utils.js";
13
+ import { serializeSymbolMatch } from "../shared/symbol-resolution.js";
12
14
  import { handleMcpRequest } from "./mcp-server.js";
15
+ import { buildSessionPreview } from "../session/session-preview.js";
13
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
17
  // Resolve web dir: always serve from dist/src/web (built by scripts/build-web.mjs)
15
18
  // In dev (tsx): __dirname = src/serve → go up to project root, then dist/src/web
@@ -48,7 +51,12 @@ async function serveStatic(res, urlPath) {
48
51
  const content = await readFile(filePath);
49
52
  const ext = path.extname(filePath);
50
53
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
51
- res.writeHead(200, { "Content-Type": contentType });
54
+ res.writeHead(200, {
55
+ "Content-Type": contentType,
56
+ // This local UI changes often during development. Avoid stale browser bundles
57
+ // causing the app to run an older client against a newer server.
58
+ "Cache-Control": "no-store",
59
+ });
52
60
  res.end(content);
53
61
  }
54
62
  catch {
@@ -61,7 +69,6 @@ async function buildWebSettingsPayload(config, minicodeHome) {
61
69
  }
62
70
  /** Create the HTTP request handler. Exported for testing. */
63
71
  export function createRequestHandler(bridge, emit, options = {}) {
64
- const config = bridge.getConfig();
65
72
  const emitFn = emit ?? (() => { });
66
73
  const minicodeHome = options.minicodeHome;
67
74
  return (req, res) => {
@@ -69,6 +76,7 @@ export function createRequestHandler(bridge, emit, options = {}) {
69
76
  const method = req.method ?? "GET";
70
77
  const pathname = url.pathname;
71
78
  const handle = async () => {
79
+ const config = bridge.getConfig();
72
80
  // MCP (Model Context Protocol) endpoint
73
81
  if (pathname === "/mcp") {
74
82
  await handleMcpRequest(req, res, bridge, emitFn);
@@ -91,6 +99,8 @@ export function createRequestHandler(bridge, emit, options = {}) {
91
99
  workspace: config.workspaceRoot,
92
100
  model: config.model,
93
101
  provider: config.modelProvider,
102
+ baseUrl: config.openAiBaseUrl,
103
+ sessionOpenRouterConnected: bridge.isOpenRouterSessionConnected(),
94
104
  needsSetup: missing.length > 0,
95
105
  missing,
96
106
  });
@@ -101,6 +111,132 @@ export function createRequestHandler(bridge, emit, options = {}) {
101
111
  sendJson(res, 200, { models, activeModel: config.model });
102
112
  return;
103
113
  }
114
+ if (pathname === "/api/openrouter/connect" && method === "POST") {
115
+ const body = JSON.parse(await readBody(req));
116
+ if (!body.code || typeof body.code !== "string") {
117
+ sendJson(res, 400, { error: "code is required" });
118
+ return;
119
+ }
120
+ if (!body.codeVerifier || typeof body.codeVerifier !== "string") {
121
+ sendJson(res, 400, { error: "codeVerifier is required" });
122
+ return;
123
+ }
124
+ let exchangeResponse;
125
+ try {
126
+ exchangeResponse = await fetch("https://openrouter.ai/api/v1/auth/keys", {
127
+ method: "POST",
128
+ headers: {
129
+ "Content-Type": "application/json",
130
+ },
131
+ body: JSON.stringify({
132
+ code: body.code,
133
+ code_verifier: body.codeVerifier,
134
+ code_challenge_method: "S256",
135
+ }),
136
+ });
137
+ }
138
+ catch (error) {
139
+ const message = error instanceof Error ? error.message : "OpenRouter OAuth exchange failed";
140
+ sendJson(res, 502, { error: message });
141
+ return;
142
+ }
143
+ if (!exchangeResponse.ok) {
144
+ const message = await exchangeResponse.text();
145
+ sendJson(res, exchangeResponse.status, {
146
+ error: message.trim().length > 0
147
+ ? `OpenRouter OAuth exchange failed: ${message}`
148
+ : `OpenRouter OAuth exchange failed (${exchangeResponse.status})`,
149
+ });
150
+ return;
151
+ }
152
+ const payload = await exchangeResponse.json();
153
+ if (!payload.key || typeof payload.key !== "string") {
154
+ sendJson(res, 502, { error: "OpenRouter OAuth exchange did not return an API key." });
155
+ return;
156
+ }
157
+ try {
158
+ bridge.connectOpenRouter(payload.key);
159
+ }
160
+ catch (error) {
161
+ const message = error instanceof Error ? error.message : "Failed to configure OpenRouter";
162
+ sendJson(res, message === "busy" ? 409 : 400, { error: message });
163
+ return;
164
+ }
165
+ let persistedToEnv = false;
166
+ let persistedEnvPath = null;
167
+ let persistWarning = null;
168
+ if (body.persistToEnv === true) {
169
+ try {
170
+ const result = await upsertHomeEnvValues({
171
+ values: {
172
+ MODEL_PROVIDER: "openai-compatible",
173
+ OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
174
+ OPENROUTER_API_KEY: payload.key,
175
+ },
176
+ ...(minicodeHome ? { minicodeHome } : {}),
177
+ });
178
+ persistedToEnv = true;
179
+ persistedEnvPath = result.path;
180
+ }
181
+ catch (error) {
182
+ const message = error instanceof Error ? error.message : "Failed to update ~/.minicode/.env";
183
+ persistedEnvPath = getHomeEnvPath(minicodeHome);
184
+ persistWarning = `OpenRouter connected for this serve session, but minicode could not update ${persistedEnvPath}: ${message}`;
185
+ }
186
+ }
187
+ const missing = getConfigMissing(config);
188
+ const onlyModelMissing = missing.length === 1 && missing[0] === "MODEL is not set";
189
+ const message = persistWarning
190
+ ? `${persistWarning}${onlyModelMissing ? " Select a model to continue." : ""}`
191
+ : persistedToEnv
192
+ ? (onlyModelMissing
193
+ ? "OpenRouter connected for this serve session and saved to ~/.minicode/.env. Select a model to continue, and minicode will remember it for future runs."
194
+ : "OpenRouter connected for this serve session and saved to ~/.minicode/.env for future runs.")
195
+ : (onlyModelMissing
196
+ ? "OpenRouter connected for this serve session. Select a model to continue."
197
+ : "OpenRouter connected for this serve session.");
198
+ sendJson(res, 200, {
199
+ ok: true,
200
+ sessionOnly: true,
201
+ persistedToEnv,
202
+ persistedEnvPath,
203
+ persistWarning,
204
+ provider: config.modelProvider,
205
+ model: config.model,
206
+ baseUrl: config.openAiBaseUrl,
207
+ needsSetup: missing.length > 0,
208
+ missing,
209
+ message,
210
+ });
211
+ return;
212
+ }
213
+ if (pathname === "/api/openrouter/disconnect" && method === "POST") {
214
+ let disconnected = false;
215
+ try {
216
+ disconnected = bridge.disconnectOpenRouter();
217
+ }
218
+ catch (error) {
219
+ const message = error instanceof Error ? error.message : "Failed to remove OpenRouter session";
220
+ sendJson(res, message === "busy" ? 409 : 400, { error: message });
221
+ return;
222
+ }
223
+ const missing = getConfigMissing(config);
224
+ const body = {
225
+ ok: true,
226
+ disconnected,
227
+ sessionOnly: true,
228
+ provider: config.modelProvider,
229
+ model: config.model,
230
+ baseUrl: config.openAiBaseUrl,
231
+ needsSetup: missing.length > 0,
232
+ missing,
233
+ message: disconnected
234
+ ? "Removed the session-only OpenRouter connection and restored your original provider settings."
235
+ : "No session-only OpenRouter connection was active.",
236
+ };
237
+ sendJson(res, 200, body);
238
+ return;
239
+ }
104
240
  if (pathname === "/api/model" && method === "POST") {
105
241
  const body = JSON.parse(await readBody(req));
106
242
  if (!body.model || typeof body.model !== "string") {
@@ -108,7 +244,26 @@ export function createRequestHandler(bridge, emit, options = {}) {
108
244
  return;
109
245
  }
110
246
  bridge.switchModel(body.model);
111
- sendJson(res, 200, { model: body.model });
247
+ let persistedToEnv = false;
248
+ let persistedEnvPath = null;
249
+ let message;
250
+ try {
251
+ const result = await upsertHomeEnvValues({
252
+ values: {
253
+ MODEL: body.model,
254
+ },
255
+ ...(minicodeHome ? { minicodeHome } : {}),
256
+ });
257
+ persistedToEnv = true;
258
+ persistedEnvPath = result.path;
259
+ message = `Saved MODEL=${body.model} to ~/.minicode/.env.`;
260
+ }
261
+ catch (error) {
262
+ const persistMessage = error instanceof Error ? error.message : "Failed to update ~/.minicode/.env";
263
+ sendJson(res, 500, { error: persistMessage });
264
+ return;
265
+ }
266
+ sendJson(res, 200, { model: body.model, persistedToEnv, persistedEnvPath, message });
112
267
  return;
113
268
  }
114
269
  if (pathname === "/api/context" && method === "GET") {
@@ -179,7 +334,10 @@ export function createRequestHandler(bridge, emit, options = {}) {
179
334
  sendJson(res, 404, { error: "Session not found" });
180
335
  return;
181
336
  }
182
- sendJson(res, 200, { label: result.label });
337
+ sendJson(res, 200, {
338
+ label: result.label,
339
+ messages: buildSessionPreview(result.session.getMessages()),
340
+ });
183
341
  return;
184
342
  }
185
343
  // ── Graph / Index API ──
@@ -195,7 +353,19 @@ export function createRequestHandler(bridge, emit, options = {}) {
195
353
  const name = decodeURIComponent(pathname.slice("/api/symbols/".length, -"/dependencies".length));
196
354
  const depthParam = url.searchParams.get("depth");
197
355
  const depth = depthParam ? Number(depthParam) : undefined;
198
- const result = bridge.getDependencies(name, depth);
356
+ const matches = bridge.getSymbolMatches(name);
357
+ if (matches.length === 0) {
358
+ sendJson(res, 404, { error: `Symbol "${name}" not found` });
359
+ return;
360
+ }
361
+ if (matches.length > 1) {
362
+ sendJson(res, 409, {
363
+ error: `Symbol "${name}" is ambiguous`,
364
+ candidates: matches.map(serializeSymbolMatch),
365
+ });
366
+ return;
367
+ }
368
+ const result = bridge.getDependencies(matches[0].qualifiedName, depth);
199
369
  if (!result) {
200
370
  sendJson(res, 404, { error: `Symbol "${name}" not found` });
201
371
  return;
@@ -205,7 +375,19 @@ export function createRequestHandler(bridge, emit, options = {}) {
205
375
  }
206
376
  if (pathname.startsWith("/api/symbols/") && pathname.endsWith("/references") && method === "GET") {
207
377
  const name = decodeURIComponent(pathname.slice("/api/symbols/".length, -"/references".length));
208
- const result = bridge.getReferences(name);
378
+ const matches = bridge.getSymbolMatches(name);
379
+ if (matches.length === 0) {
380
+ sendJson(res, 404, { error: `Symbol "${name}" not found` });
381
+ return;
382
+ }
383
+ if (matches.length > 1) {
384
+ sendJson(res, 409, {
385
+ error: `Symbol "${name}" is ambiguous`,
386
+ candidates: matches.map(serializeSymbolMatch),
387
+ });
388
+ return;
389
+ }
390
+ const result = bridge.getReferences(matches[0].qualifiedName);
209
391
  if (!result) {
210
392
  sendJson(res, 404, { error: `Symbol "${name}" not found` });
211
393
  return;
@@ -215,11 +397,19 @@ export function createRequestHandler(bridge, emit, options = {}) {
215
397
  }
216
398
  if (pathname.startsWith("/api/symbols/") && pathname.endsWith("/source") && method === "GET") {
217
399
  const name = decodeURIComponent(pathname.slice("/api/symbols/".length, -"/source".length));
218
- const sym = bridge.getSymbol(name);
219
- if (!sym) {
400
+ const matches = bridge.getSymbolMatches(name);
401
+ if (matches.length === 0) {
220
402
  sendJson(res, 404, { error: `Symbol "${name}" not found` });
221
403
  return;
222
404
  }
405
+ if (matches.length > 1) {
406
+ sendJson(res, 409, {
407
+ error: `Symbol "${name}" is ambiguous`,
408
+ candidates: matches.map(serializeSymbolMatch),
409
+ });
410
+ return;
411
+ }
412
+ const sym = matches[0];
223
413
  try {
224
414
  const fileContent = await readFile(path.resolve(config.workspaceRoot, sym.filePath), "utf8");
225
415
  const lines = fileContent.split(/\r?\n/);
@@ -0,0 +1,14 @@
1
+ export const DEFAULT_SESSION_PREVIEW_LIMIT = 10;
2
+ const COMPACTION_SUMMARY_PREFIX = "[Conversation Summary";
3
+ export function isCompactionSummaryMessage(message) {
4
+ return (message.role === "user" &&
5
+ message.content.startsWith(COMPACTION_SUMMARY_PREFIX));
6
+ }
7
+ export function buildSessionPreview(messages, limit = DEFAULT_SESSION_PREVIEW_LIMIT) {
8
+ if (limit <= 0) {
9
+ return [];
10
+ }
11
+ return messages
12
+ .filter((message) => !isCompactionSummaryMessage(message))
13
+ .slice(-limit);
14
+ }
@@ -0,0 +1,80 @@
1
+ import { compareGraphNodeIds, getGraphNodeLabel, matchesGraphNodeQuery, } from "./graph-symbols.js";
2
+ export function getGraphNodeFilePath(node) {
3
+ return node.filePath || node.file || "";
4
+ }
5
+ export function buildGraphFileIndex(nodes) {
6
+ const files = new Map();
7
+ for (const [id, node] of nodes) {
8
+ const filePath = getGraphNodeFilePath(node);
9
+ if (!filePath)
10
+ continue;
11
+ const existing = files.get(filePath);
12
+ if (existing) {
13
+ existing.push(id);
14
+ }
15
+ else {
16
+ files.set(filePath, [id]);
17
+ }
18
+ }
19
+ for (const symbolIds of files.values()) {
20
+ symbolIds.sort((a, b) => compareGraphNodeIds(a, b, nodes));
21
+ }
22
+ return files;
23
+ }
24
+ export function compareGraphFilePaths(a, b, fileIndex) {
25
+ const countDifference = (fileIndex.get(b)?.length ?? 0) - (fileIndex.get(a)?.length ?? 0);
26
+ if (countDifference !== 0) {
27
+ return countDifference;
28
+ }
29
+ return a.localeCompare(b, undefined, { sensitivity: "base" });
30
+ }
31
+ export function matchesGraphFileQuery(query, filePath) {
32
+ const normalizedQuery = query.trim().toLowerCase();
33
+ if (normalizedQuery.length === 0) {
34
+ return false;
35
+ }
36
+ return filePath.toLowerCase().includes(normalizedQuery);
37
+ }
38
+ export function buildGraphSearchResults({ query, symbolIds, nodes, fileIndex, symbolLimit = 12, fileLimit = 8, }) {
39
+ const normalizedQuery = query.trim().toLowerCase();
40
+ const showDefaultResults = normalizedQuery.length < 2;
41
+ const rankedFiles = [...fileIndex.keys()].sort((a, b) => compareGraphFilePaths(a, b, fileIndex));
42
+ const symbolResults = symbolIds
43
+ .filter((id) => {
44
+ if (showDefaultResults) {
45
+ return true;
46
+ }
47
+ return matchesGraphNodeQuery(normalizedQuery, nodes.get(id) || {}, id);
48
+ })
49
+ .slice(0, symbolLimit)
50
+ .map((id) => {
51
+ const node = nodes.get(id) || {};
52
+ return {
53
+ type: "symbol",
54
+ id,
55
+ label: getGraphNodeLabel(node, id),
56
+ subtitle: getGraphNodeFilePath(node),
57
+ kind: (node.kind || "symbol").toLowerCase(),
58
+ };
59
+ });
60
+ const fileResults = rankedFiles
61
+ .filter((filePath) => {
62
+ if (showDefaultResults) {
63
+ return true;
64
+ }
65
+ return matchesGraphFileQuery(normalizedQuery, filePath);
66
+ })
67
+ .slice(0, fileLimit)
68
+ .map((filePath) => {
69
+ const symbolCount = fileIndex.get(filePath)?.length ?? 0;
70
+ return {
71
+ type: "file",
72
+ id: filePath,
73
+ label: filePath,
74
+ subtitle: `${symbolCount} symbol${symbolCount === 1 ? "" : "s"}`,
75
+ kind: "file",
76
+ symbolCount,
77
+ };
78
+ });
79
+ return [...symbolResults, ...fileResults];
80
+ }
@@ -0,0 +1,40 @@
1
+ export function buildGraphEdgeId(edge) {
2
+ return `${edge.source}->${edge.target}:${edge.kind}`;
3
+ }
4
+ export function buildGraphEdgeIndex(edges) {
5
+ const edgeIndex = new Map();
6
+ for (const edge of edges) {
7
+ const sourceEdges = edgeIndex.get(edge.source);
8
+ if (sourceEdges) {
9
+ sourceEdges.push(edge);
10
+ }
11
+ else {
12
+ edgeIndex.set(edge.source, [edge]);
13
+ }
14
+ const targetEdges = edgeIndex.get(edge.target);
15
+ if (targetEdges) {
16
+ targetEdges.push(edge);
17
+ }
18
+ else {
19
+ edgeIndex.set(edge.target, [edge]);
20
+ }
21
+ }
22
+ return edgeIndex;
23
+ }
24
+ export function buildFileFocusedSelection({ filePath, fileIndex, edgeIndex, }) {
25
+ const fileSymbolIds = fileIndex.get(filePath) || [];
26
+ const nodeIds = new Set();
27
+ const edges = new Map();
28
+ for (const symbolId of fileSymbolIds) {
29
+ nodeIds.add(symbolId);
30
+ for (const edge of edgeIndex.get(symbolId) || []) {
31
+ nodeIds.add(edge.source);
32
+ nodeIds.add(edge.target);
33
+ edges.set(buildGraphEdgeId(edge), edge);
34
+ }
35
+ }
36
+ return {
37
+ nodeIds: [...nodeIds],
38
+ edges: [...edges.values()],
39
+ };
40
+ }
@@ -0,0 +1,82 @@
1
+ function dedupe(values) {
2
+ return [...new Set(values.filter((value) => value.length > 0))];
3
+ }
4
+ function stripCollisionSuffix(value) {
5
+ const hashIndex = value.indexOf("#");
6
+ return hashIndex >= 0 ? value.slice(0, hashIndex) : value;
7
+ }
8
+ function stripDisplayKindSuffix(value) {
9
+ return value.replace(/\s+\([^()]+\)$/, "");
10
+ }
11
+ export function getGraphNodeId(node, fallbackId = "") {
12
+ return node.qualifiedName || node.id || fallbackId || node.name || "";
13
+ }
14
+ export function getGraphNodeLabel(node, fallbackId = "") {
15
+ const label = node.name?.trim();
16
+ if (label && label.length > 0) {
17
+ return label;
18
+ }
19
+ const id = getGraphNodeId(node, fallbackId);
20
+ return id.split(".").pop() || id;
21
+ }
22
+ export function getGraphNodeAliases(node, fallbackId = "") {
23
+ const id = getGraphNodeId(node, fallbackId);
24
+ const label = getGraphNodeLabel(node, fallbackId);
25
+ const shortId = id.split(".").pop() || id;
26
+ return dedupe([
27
+ id,
28
+ node.id ?? "",
29
+ node.qualifiedName ?? "",
30
+ label,
31
+ stripDisplayKindSuffix(label),
32
+ shortId,
33
+ stripCollisionSuffix(shortId),
34
+ stripCollisionSuffix(id),
35
+ ]);
36
+ }
37
+ function compareLabels(a, b) {
38
+ return a.localeCompare(b, undefined, { sensitivity: "base" });
39
+ }
40
+ export function compareGraphNodeIds(a, b, nodes) {
41
+ const nodeA = nodes.get(a);
42
+ const nodeB = nodes.get(b);
43
+ const exportedA = nodeA ? Number(!!nodeA.exported) : 0;
44
+ const exportedB = nodeB ? Number(!!nodeB.exported) : 0;
45
+ if (exportedA !== exportedB) {
46
+ return exportedB - exportedA;
47
+ }
48
+ const labelA = getGraphNodeLabel(nodeA ?? {}, a);
49
+ const labelB = getGraphNodeLabel(nodeB ?? {}, b);
50
+ const labelComparison = compareLabels(labelA, labelB);
51
+ if (labelComparison !== 0) {
52
+ return labelComparison;
53
+ }
54
+ return compareLabels(a, b);
55
+ }
56
+ export function matchesGraphNodeQuery(query, node, fallbackId = "") {
57
+ const normalizedQuery = query.trim().toLowerCase();
58
+ if (normalizedQuery.length === 0) {
59
+ return false;
60
+ }
61
+ return getGraphNodeAliases(node, fallbackId).some((alias) => alias.toLowerCase().includes(normalizedQuery));
62
+ }
63
+ export function resolveGraphNodeIds(nodes, symbolName) {
64
+ const query = symbolName.trim();
65
+ if (query.length === 0) {
66
+ return [];
67
+ }
68
+ if (nodes.has(query)) {
69
+ return [query];
70
+ }
71
+ const exactMatches = [...nodes.entries()]
72
+ .filter(([id, node]) => getGraphNodeAliases(node, id).includes(query))
73
+ .map(([id]) => id)
74
+ .sort((a, b) => compareGraphNodeIds(a, b, nodes));
75
+ if (exactMatches.length > 0) {
76
+ return exactMatches;
77
+ }
78
+ return [...nodes.entries()]
79
+ .filter(([id, node]) => matchesGraphNodeQuery(query, node, id))
80
+ .map(([id]) => id)
81
+ .sort((a, b) => compareGraphNodeIds(a, b, nodes));
82
+ }
@@ -0,0 +1,33 @@
1
+ import { getSymbolDisplayName } from "../indexer/symbol-names.js";
2
+ export function resolveSymbolInput(projectIndex, name) {
3
+ const matches = projectIndex.getSymbolMatches(name);
4
+ if (matches.length === 0) {
5
+ return { status: "missing" };
6
+ }
7
+ if (matches.length > 1) {
8
+ return { status: "ambiguous", matches };
9
+ }
10
+ return { status: "resolved", symbol: matches[0] };
11
+ }
12
+ export function formatSymbolMatch(match) {
13
+ return `${getSymbolDisplayName(match)} (${match.kind}) — ${match.filePath}:${match.startLine} — qualified: ${match.qualifiedName}`;
14
+ }
15
+ export function formatAmbiguousSymbolMatches(toolName, name, matches) {
16
+ return [
17
+ `Symbol "${name}" is ambiguous; ${matches.length} matches were found.`,
18
+ `Re-run ${toolName} with one of these qualified or disambiguated names:`,
19
+ "",
20
+ ...matches.map((match) => `- ${formatSymbolMatch(match)}`),
21
+ ].join("\n");
22
+ }
23
+ export function serializeSymbolMatch(match) {
24
+ return {
25
+ name: getSymbolDisplayName(match),
26
+ qualifiedName: match.qualifiedName,
27
+ kind: match.kind,
28
+ filePath: match.filePath,
29
+ startLine: match.startLine,
30
+ endLine: match.endLine,
31
+ signature: match.signature,
32
+ };
33
+ }
@@ -1,5 +1,6 @@
1
1
  import { expectNonEmptyString, expectOptionalNumber } from "@minicode/agent-sdk";
2
2
  import { getSymbolDisplayName } from "../indexer/symbol-names.js";
3
+ import { formatAmbiguousSymbolMatches, resolveSymbolInput, } from "../shared/symbol-resolution.js";
3
4
  export function createFindPathTool(projectIndex) {
4
5
  return {
5
6
  name: "find_path",
@@ -27,17 +28,25 @@ export function createFindPathTool(projectIndex) {
27
28
  const from = expectNonEmptyString(input, "from");
28
29
  const to = typeof input.to === "string" && input.to.length > 0 ? input.to : undefined;
29
30
  const maxDepth = expectOptionalNumber(input, "max_depth");
30
- const fromSymbol = projectIndex.getSymbol(from);
31
- if (!fromSymbol) {
31
+ const fromResolution = resolveSymbolInput(projectIndex, from);
32
+ if (fromResolution.status === "missing") {
32
33
  return `Symbol "${from}" not found in the project index.`;
33
34
  }
35
+ if (fromResolution.status === "ambiguous") {
36
+ return formatAmbiguousSymbolMatches("find_path", from, fromResolution.matches);
37
+ }
38
+ const fromSymbol = fromResolution.symbol;
34
39
  if (to) {
35
40
  // Path between two symbols
36
- const toSymbol = projectIndex.getSymbol(to);
37
- if (!toSymbol) {
41
+ const toResolution = resolveSymbolInput(projectIndex, to);
42
+ if (toResolution.status === "missing") {
38
43
  return `Symbol "${to}" not found in the project index.`;
39
44
  }
40
- const path = projectIndex.findPath(from, to, maxDepth ?? 10);
45
+ if (toResolution.status === "ambiguous") {
46
+ return formatAmbiguousSymbolMatches("find_path", to, toResolution.matches);
47
+ }
48
+ const toSymbol = toResolution.symbol;
49
+ const path = projectIndex.findPath(fromSymbol.qualifiedName, toSymbol.qualifiedName, maxDepth ?? 10);
41
50
  if (path.length === 0) {
42
51
  return `No path found between "${from}" and "${to}".`;
43
52
  }
@@ -53,7 +62,7 @@ export function createFindPathTool(projectIndex) {
53
62
  }
54
63
  else {
55
64
  // Trace to entry point(s)
56
- const paths = projectIndex.findPathToEntryPoint(from, maxDepth ?? 20);
65
+ const paths = projectIndex.findPathToEntryPoint(fromSymbol.qualifiedName, maxDepth ?? 20);
57
66
  if (paths.length === 0) {
58
67
  return `No entry point paths found for "${from}". It may itself be an entry point.`;
59
68
  }
@@ -1,5 +1,6 @@
1
1
  import { expectNonEmptyString, expectOptionalNumber } from "@minicode/agent-sdk";
2
2
  import { getSymbolDisplayName } from "../indexer/symbol-names.js";
3
+ import { formatAmbiguousSymbolMatches, resolveSymbolInput, } from "../shared/symbol-resolution.js";
3
4
  const DEFAULT_LIMIT = 50;
4
5
  export function createFindReferencesTool(projectIndex) {
5
6
  return {
@@ -28,10 +29,14 @@ export function createFindReferencesTool(projectIndex) {
28
29
  const name = expectNonEmptyString(input, "name");
29
30
  const skip = Math.max(0, expectOptionalNumber(input, "skip") ?? 0);
30
31
  const limit = Math.max(1, Math.min(100, expectOptionalNumber(input, "limit") ?? DEFAULT_LIMIT));
31
- const symbol = projectIndex.getSymbol(name);
32
- if (!symbol) {
32
+ const resolution = resolveSymbolInput(projectIndex, name);
33
+ if (resolution.status === "missing") {
33
34
  return `Symbol "${name}" not found in the project index.`;
34
35
  }
36
+ if (resolution.status === "ambiguous") {
37
+ return formatAmbiguousSymbolMatches("find_references", name, resolution.matches);
38
+ }
39
+ const symbol = resolution.symbol;
35
40
  const refs = projectIndex.dependencyEdges.filter((e) => e.to === symbol.qualifiedName || e.to === symbol.name);
36
41
  if (refs.length === 0) {
37
42
  return `No references found for "${name}".`;
@@ -1,5 +1,6 @@
1
1
  import { expectNonEmptyString, expectOptionalNumber } from "@minicode/agent-sdk";
2
2
  import { getSymbolDisplayName } from "../indexer/symbol-names.js";
3
+ import { formatAmbiguousSymbolMatches, resolveSymbolInput, } from "../shared/symbol-resolution.js";
3
4
  export function createGetDependenciesTool(projectIndex) {
4
5
  return {
5
6
  name: "get_dependencies",
@@ -32,11 +33,15 @@ export function createGetDependenciesTool(projectIndex) {
32
33
  const depth = expectOptionalNumber(input, "depth") ?? 1;
33
34
  const skip = Math.max(0, expectOptionalNumber(input, "skip") ?? 0);
34
35
  const limit = Math.max(1, Math.min(100, expectOptionalNumber(input, "limit") ?? 50));
35
- const symbol = projectIndex.getSymbol(name);
36
- if (!symbol) {
36
+ const resolution = resolveSymbolInput(projectIndex, name);
37
+ if (resolution.status === "missing") {
37
38
  return `Symbol "${name}" not found in the project index.`;
38
39
  }
39
- const cone = projectIndex.getDependencyCone(name, depth);
40
+ if (resolution.status === "ambiguous") {
41
+ return formatAmbiguousSymbolMatches("get_dependencies", name, resolution.matches);
42
+ }
43
+ const symbol = resolution.symbol;
44
+ const cone = projectIndex.getDependencyCone(symbol.qualifiedName, depth);
40
45
  const shown = cone.slice(skip, skip + limit);
41
46
  const lines = shown.map((s) => {
42
47
  const header = `${s.kind} ${getSymbolDisplayName(s)}`;