@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.
- package/README.md +25 -47
- package/dist/scripts/run-benchmarks.js +73 -28
- package/dist/src/agent/config.js +51 -66
- package/dist/src/agent/editable-config.js +50 -58
- package/dist/src/agent/home-env.js +74 -0
- package/dist/src/benchmark/runner.js +142 -59
- package/dist/src/cli/config-slash-command.js +15 -13
- package/dist/src/indexer/project-index.js +49 -13
- package/dist/src/serve/agent-bridge.js +99 -31
- package/dist/src/serve/mcp-server.js +70 -21
- package/dist/src/serve/server.js +198 -8
- package/dist/src/session/session-preview.js +14 -0
- package/dist/src/shared/graph-search.js +80 -0
- package/dist/src/shared/graph-selection.js +40 -0
- package/dist/src/shared/graph-symbols.js +82 -0
- package/dist/src/shared/symbol-resolution.js +33 -0
- package/dist/src/tools/find-path.js +15 -6
- package/dist/src/tools/find-references.js +7 -2
- package/dist/src/tools/get-dependencies.js +8 -3
- package/dist/src/tools/read-symbol.js +9 -3
- package/dist/src/tools/registry.js +4 -1
- package/dist/src/tools/search-code-map.js +18 -3
- package/dist/src/web/app.js +646 -87
- package/dist/src/web/index.html +68 -6
- package/dist/src/web/style.css +208 -1
- package/dist/tests/benchmark-harness.test.js +100 -0
- package/dist/tests/config-api.test.js +5 -5
- package/dist/tests/config-integration.test.js +130 -56
- package/dist/tests/config-slash-command.test.js +12 -11
- package/dist/tests/config.test.js +12 -4
- package/dist/tests/editable-config.test.js +15 -12
- package/dist/tests/file-tools.test.js +34 -1
- package/dist/tests/find-path.test.js +43 -2
- package/dist/tests/find-references.test.js +49 -0
- package/dist/tests/get-dependencies.test.js +23 -0
- package/dist/tests/graph-onboarding.test.js +10 -1
- package/dist/tests/graph-search.test.js +66 -0
- package/dist/tests/graph-selection.test.js +58 -0
- package/dist/tests/graph-symbols.test.js +45 -0
- package/dist/tests/home-env.test.js +56 -0
- package/dist/tests/indexer.test.js +6 -0
- package/dist/tests/read-symbol.test.js +35 -0
- package/dist/tests/request-tracker.test.js +15 -0
- package/dist/tests/run-benchmarks.test.js +117 -33
- package/dist/tests/search-code-map.test.js +2 -0
- package/dist/tests/serve.integration.test.js +338 -9
- package/dist/tests/session-preview.test.js +56 -0
- package/dist/tests/session-ui.test.js +4 -0
- package/dist/tests/settings-ui.test.js +18 -0
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +2 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +3 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts +3 -0
- package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js +4 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/registry.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts +11 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +4 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/search.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js +16 -8
- package/node_modules/@minicode/agent-sdk/dist/src/tools/search.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js +19 -2
- package/node_modules/@minicode/agent-sdk/dist/tests/file-tools.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
package/dist/src/serve/server.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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
|
|
219
|
-
if (
|
|
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
|
|
31
|
-
if (
|
|
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
|
|
37
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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
|
|
32
|
-
if (
|
|
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
|
|
36
|
-
if (
|
|
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
|
-
|
|
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)}`;
|