@sean.holung/minicode 0.3.5 → 0.3.7
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 +22 -45
- package/dist/scripts/run-benchmarks.js +1 -0
- package/dist/src/agent/config.js +53 -66
- package/dist/src/agent/editable-config.js +56 -58
- package/dist/src/agent/home-env.js +74 -0
- package/dist/src/cli/config-slash-command.js +15 -13
- package/dist/src/serve/agent-bridge.js +87 -28
- package/dist/src/serve/mcp-server.js +19 -13
- package/dist/src/serve/server.js +190 -4
- 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/symbol-search.js +156 -0
- package/dist/src/tools/search-code-map.js +27 -35
- package/dist/src/web/app.js +582 -64
- package/dist/src/web/index.html +84 -6
- package/dist/src/web/style.css +256 -1
- package/dist/tests/config-api.test.js +10 -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 +21 -4
- package/dist/tests/editable-config.test.js +15 -12
- package/dist/tests/graph-onboarding.test.js +22 -1
- package/dist/tests/graph-search.test.js +66 -0
- package/dist/tests/graph-selection.test.js +58 -0
- package/dist/tests/home-env.test.js +56 -0
- package/dist/tests/mcp-and-plugin.test.js +3 -0
- package/dist/tests/search-code-map.test.js +9 -0
- package/dist/tests/serve.integration.test.js +255 -6
- package/dist/tests/session-preview.test.js +56 -0
- package/dist/tests/session-ui.test.js +2 -0
- package/dist/tests/settings-ui.test.js +18 -0
- package/dist/tests/system-prompt.test.js +1 -0
- package/dist/tests/test-utils.js +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +8 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +143 -27
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js +87 -0
- package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js +1 -0
- package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
import { MINICODE_HOME } from "./config.js";
|
|
5
|
+
export function getHomeEnvPath(minicodeHome = MINICODE_HOME) {
|
|
6
|
+
return path.join(minicodeHome, ".env");
|
|
7
|
+
}
|
|
8
|
+
export async function loadHomeEnvValues(minicodeHome = MINICODE_HOME) {
|
|
9
|
+
const envPath = getHomeEnvPath(minicodeHome);
|
|
10
|
+
try {
|
|
11
|
+
const existing = await readFile(envPath, "utf8");
|
|
12
|
+
return dotenv.parse(existing);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function formatEnvValue(value) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export async function upsertHomeEnvValues(options) {
|
|
22
|
+
const minicodeHome = options.minicodeHome ?? MINICODE_HOME;
|
|
23
|
+
const envPath = getHomeEnvPath(minicodeHome);
|
|
24
|
+
await mkdir(path.dirname(envPath), { recursive: true });
|
|
25
|
+
let existing = "";
|
|
26
|
+
try {
|
|
27
|
+
existing = await readFile(envPath, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
existing = "";
|
|
31
|
+
}
|
|
32
|
+
const pending = new Map(Object.entries(options.values));
|
|
33
|
+
const managedKeys = new Set(pending.keys());
|
|
34
|
+
const assignmentPattern = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
35
|
+
const normalizedLines = existing === ""
|
|
36
|
+
? []
|
|
37
|
+
: existing.split(/\r?\n/);
|
|
38
|
+
const nextLines = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const line of normalizedLines) {
|
|
41
|
+
const match = line.match(assignmentPattern);
|
|
42
|
+
if (!match) {
|
|
43
|
+
nextLines.push(line);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const key = match[1];
|
|
47
|
+
if (!managedKeys.has(key)) {
|
|
48
|
+
nextLines.push(line);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (seen.has(key)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const nextValue = pending.get(key);
|
|
55
|
+
if (nextValue !== null) {
|
|
56
|
+
nextLines.push(`${key}=${formatEnvValue(nextValue)}`);
|
|
57
|
+
}
|
|
58
|
+
seen.add(key);
|
|
59
|
+
pending.delete(key);
|
|
60
|
+
}
|
|
61
|
+
const pendingEntries = [...pending.entries()].filter((entry) => entry[1] !== null);
|
|
62
|
+
if (pendingEntries.length > 0 && nextLines.length > 0 && nextLines[nextLines.length - 1] !== "") {
|
|
63
|
+
nextLines.push("");
|
|
64
|
+
}
|
|
65
|
+
for (const [key, value] of pendingEntries) {
|
|
66
|
+
nextLines.push(`${key}=${formatEnvValue(value)}`);
|
|
67
|
+
}
|
|
68
|
+
const fileContent = `${nextLines.join("\n").replace(/\n+$/, "")}\n`;
|
|
69
|
+
await writeFile(envPath, fileContent, "utf8");
|
|
70
|
+
return {
|
|
71
|
+
path: envPath,
|
|
72
|
+
updatedKeys: Object.keys(options.values),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatConfigForDisplay, MINICODE_HOME, resolveConfigEnv } from "../agent/config.js";
|
|
2
|
-
import { formatPersistedConfigValue, getEditableConfigDefinition, getEffectiveEditableConfigValue, isEditableConfigKey, listEditableConfigDefinitions,
|
|
2
|
+
import { buildStructuredConfigPayload, formatPersistedConfigValue, getEditableConfigDefinition, getEffectiveEditableConfigValue, isEditableConfigKey, listEditableConfigDefinitions, setPersistedConfigValue, unsetPersistedConfigValue, } from "../agent/editable-config.js";
|
|
3
3
|
function renderUsage() {
|
|
4
4
|
return [
|
|
5
5
|
'Usage:',
|
|
@@ -12,7 +12,7 @@ function renderUsage() {
|
|
|
12
12
|
}
|
|
13
13
|
function renderEditableKeys() {
|
|
14
14
|
const lines = [
|
|
15
|
-
"Editable config keys (persisted in ~/.minicode
|
|
15
|
+
"Editable config keys (persisted in ~/.minicode/.env; exported shell environment variables take precedence):",
|
|
16
16
|
];
|
|
17
17
|
for (const definition of listEditableConfigDefinitions()) {
|
|
18
18
|
const valueHint = definition.type === "enum"
|
|
@@ -21,7 +21,7 @@ function renderEditableKeys() {
|
|
|
21
21
|
lines.push(` ${definition.key} ${valueHint} — ${definition.description} (env: ${definition.envVar})`);
|
|
22
22
|
}
|
|
23
23
|
lines.push("");
|
|
24
|
-
lines.push('Use "/config set <key> <value>" to update
|
|
24
|
+
lines.push('Use "/config set <key> <value>" to update ~/.minicode/.env.');
|
|
25
25
|
lines.push("Secrets like API keys stay env-only for now.");
|
|
26
26
|
return lines.join("\n");
|
|
27
27
|
}
|
|
@@ -31,14 +31,16 @@ async function renderConfigValue(key, context) {
|
|
|
31
31
|
}
|
|
32
32
|
const minicodeHome = context.minicodeHome ?? MINICODE_HOME;
|
|
33
33
|
const definition = getEditableConfigDefinition(key);
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
34
|
+
const payload = await buildStructuredConfigPayload(context.config, minicodeHome);
|
|
35
|
+
const entry = payload.entries.find((item) => item.key === key);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
return `Unknown editable config key "${key}".\n\n${renderEditableKeys()}`;
|
|
38
|
+
}
|
|
37
39
|
return [
|
|
38
40
|
`${definition.key}`,
|
|
39
41
|
` effective: ${getEffectiveEditableConfigValue(context.config, key)}`,
|
|
40
|
-
`
|
|
41
|
-
` env override (${definition.envVar}): ${formatPersistedConfigValue(envValue)}`,
|
|
42
|
+
` saved in ~/.minicode/.env: ${formatPersistedConfigValue(entry.persistedValue)}`,
|
|
43
|
+
` exported env override (${definition.envVar}): ${formatPersistedConfigValue(entry.envValue)}`,
|
|
42
44
|
].join("\n");
|
|
43
45
|
}
|
|
44
46
|
async function persistConfigValue(key, rawValue, context) {
|
|
@@ -59,8 +61,8 @@ async function persistConfigValue(key, rawValue, context) {
|
|
|
59
61
|
`File: ${result.path}`,
|
|
60
62
|
"Restart minicode to pick up persisted config changes in a new session.",
|
|
61
63
|
];
|
|
62
|
-
if (env.
|
|
63
|
-
lines.push(`Note: ${definition.envVar} is currently
|
|
64
|
+
if (env.sources[definition.envVar] === "process") {
|
|
65
|
+
lines.push(`Note: ${definition.envVar} is currently exported in your shell and will override this saved value until it is unset.`);
|
|
64
66
|
}
|
|
65
67
|
return lines.join("\n");
|
|
66
68
|
}
|
|
@@ -82,11 +84,11 @@ async function removeConfigValue(key, context) {
|
|
|
82
84
|
});
|
|
83
85
|
const lines = [
|
|
84
86
|
`Removed persisted value for "${key}".`,
|
|
85
|
-
`File: ${minicodeHome}
|
|
87
|
+
`File: ${minicodeHome}/.env`,
|
|
86
88
|
"Restart minicode to ensure the updated config is applied in a new session.",
|
|
87
89
|
];
|
|
88
|
-
if (env.
|
|
89
|
-
lines.push(`Note: ${definition.envVar} is still
|
|
90
|
+
if (env.sources[definition.envVar] === "process") {
|
|
91
|
+
lines.push(`Note: ${definition.envVar} is still exported in your shell, so the effective value may remain unchanged.`);
|
|
90
92
|
}
|
|
91
93
|
return lines.join("\n");
|
|
92
94
|
}
|
|
@@ -13,9 +13,11 @@ import { getSymbolDisplayName } from "../indexer/symbol-names.js";
|
|
|
13
13
|
export class AgentBridge {
|
|
14
14
|
agent;
|
|
15
15
|
config;
|
|
16
|
+
baseConfig;
|
|
16
17
|
modelClient;
|
|
17
18
|
projectIndex;
|
|
18
|
-
|
|
19
|
+
toolRegistry;
|
|
20
|
+
sessionOpenRouterConnected = false;
|
|
19
21
|
busy = false;
|
|
20
22
|
abortController = null;
|
|
21
23
|
broadcast;
|
|
@@ -43,9 +45,8 @@ export class AgentBridge {
|
|
|
43
45
|
}
|
|
44
46
|
async init() {
|
|
45
47
|
const config = await loadAgentConfig();
|
|
46
|
-
let modelClient;
|
|
47
48
|
try {
|
|
48
|
-
modelClient = createModelClient(config);
|
|
49
|
+
this.modelClient = createModelClient(config);
|
|
49
50
|
}
|
|
50
51
|
catch {
|
|
51
52
|
// Model client may fail to initialize if API keys are missing.
|
|
@@ -75,28 +76,47 @@ export class AgentBridge {
|
|
|
75
76
|
return this.appendAnnotationsToResult(name, input, result);
|
|
76
77
|
};
|
|
77
78
|
this.config = config;
|
|
79
|
+
this.baseConfig = AgentBridge.cloneConfig(config);
|
|
78
80
|
this.projectIndex = projectIndex;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
82
|
-
return new CodingAgent({
|
|
83
|
-
config,
|
|
84
|
-
modelClient,
|
|
85
|
-
toolRegistry,
|
|
86
|
-
verbose: this.verbose,
|
|
87
|
-
...(session ? { session } : {}),
|
|
88
|
-
...(projectIndex !== undefined
|
|
89
|
-
? { getCodeMap: (focusSymbols) => projectIndex.getCodeMap(undefined, focusSymbols) }
|
|
90
|
-
: {}),
|
|
91
|
-
onUiUpdate: onUiUpdate ?? ((event) => {
|
|
92
|
-
this.emit(event);
|
|
93
|
-
}),
|
|
94
|
-
getSystemPromptSuffix: () => this.buildAnnotationSuffix(),
|
|
95
|
-
});
|
|
96
|
-
};
|
|
97
|
-
this.agent = this.buildAgent();
|
|
81
|
+
this.toolRegistry = toolRegistry;
|
|
82
|
+
if (this.modelClient) {
|
|
83
|
+
this.agent = this.createAgent();
|
|
98
84
|
}
|
|
99
85
|
}
|
|
86
|
+
static cloneConfig(config) {
|
|
87
|
+
return {
|
|
88
|
+
...config,
|
|
89
|
+
commandDenylist: [...config.commandDenylist],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
static applyConfig(target, source) {
|
|
93
|
+
const targetRecord = target;
|
|
94
|
+
for (const key of Object.keys(targetRecord)) {
|
|
95
|
+
if (!(key in source)) {
|
|
96
|
+
delete targetRecord[key];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
Object.assign(targetRecord, AgentBridge.cloneConfig(source));
|
|
100
|
+
}
|
|
101
|
+
createAgent(session, onUiUpdate) {
|
|
102
|
+
if (!this.modelClient || !this.toolRegistry) {
|
|
103
|
+
throw new Error("Agent runtime is not initialized.");
|
|
104
|
+
}
|
|
105
|
+
return new CodingAgent({
|
|
106
|
+
config: this.config,
|
|
107
|
+
modelClient: this.modelClient,
|
|
108
|
+
toolRegistry: this.toolRegistry,
|
|
109
|
+
verbose: this.verbose,
|
|
110
|
+
...(session ? { session } : {}),
|
|
111
|
+
...(this.projectIndex !== undefined
|
|
112
|
+
? { getCodeMap: (focusSymbols) => this.projectIndex.getCodeMap(undefined, focusSymbols) }
|
|
113
|
+
: {}),
|
|
114
|
+
onUiUpdate: onUiUpdate ?? ((event) => {
|
|
115
|
+
this.emit(event);
|
|
116
|
+
}),
|
|
117
|
+
getSystemPromptSuffix: () => this.buildAnnotationSuffix(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
100
120
|
// ── File watcher for automatic reindexing ──
|
|
101
121
|
static WATCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
102
122
|
static SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
|
|
@@ -171,6 +191,45 @@ export class AgentBridge {
|
|
|
171
191
|
getConfig() {
|
|
172
192
|
return this.config;
|
|
173
193
|
}
|
|
194
|
+
isOpenRouterSessionConnected() {
|
|
195
|
+
return this.sessionOpenRouterConnected;
|
|
196
|
+
}
|
|
197
|
+
connectOpenRouter(apiKey) {
|
|
198
|
+
const trimmedKey = apiKey.trim();
|
|
199
|
+
if (!trimmedKey) {
|
|
200
|
+
throw new Error("OpenRouter OAuth exchange did not return an API key.");
|
|
201
|
+
}
|
|
202
|
+
if (this.busy) {
|
|
203
|
+
throw new Error("busy");
|
|
204
|
+
}
|
|
205
|
+
const currentSession = this.agent?.getSession();
|
|
206
|
+
this.config.modelProvider = "openai-compatible";
|
|
207
|
+
this.config.openAiBaseUrl = "https://openrouter.ai/api/v1";
|
|
208
|
+
this.config.openAiApiKey = trimmedKey;
|
|
209
|
+
this.sessionOpenRouterConnected = true;
|
|
210
|
+
this.modelClient = createModelClient(this.config);
|
|
211
|
+
this.agent = this.createAgent(currentSession);
|
|
212
|
+
}
|
|
213
|
+
disconnectOpenRouter() {
|
|
214
|
+
if (this.busy) {
|
|
215
|
+
throw new Error("busy");
|
|
216
|
+
}
|
|
217
|
+
if (!this.sessionOpenRouterConnected) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
const currentSession = this.agent?.getSession();
|
|
221
|
+
AgentBridge.applyConfig(this.config, this.baseConfig);
|
|
222
|
+
this.sessionOpenRouterConnected = false;
|
|
223
|
+
try {
|
|
224
|
+
this.modelClient = createModelClient(this.config);
|
|
225
|
+
this.agent = this.createAgent(currentSession);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
this.modelClient = undefined;
|
|
229
|
+
this.agent = undefined;
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
174
233
|
requireAgent() {
|
|
175
234
|
if (!this.agent) {
|
|
176
235
|
throw new Error("Agent is not configured. Set MODEL and provider settings in ~/.minicode/.env");
|
|
@@ -230,13 +289,13 @@ export class AgentBridge {
|
|
|
230
289
|
return saveSession(agent.getSession(), label, annotationsObj);
|
|
231
290
|
}
|
|
232
291
|
async loadSess(label) {
|
|
233
|
-
if (!this.
|
|
292
|
+
if (!this.modelClient) {
|
|
234
293
|
throw new Error("Agent is not configured.");
|
|
235
294
|
}
|
|
236
295
|
const result = (await loadSessionByLabel(label)) ?? (await loadSession(label));
|
|
237
296
|
if (!result)
|
|
238
297
|
return null;
|
|
239
|
-
this.agent = this.
|
|
298
|
+
this.agent = this.createAgent(result.session);
|
|
240
299
|
// Restore annotations from saved session
|
|
241
300
|
this.annotations.clear();
|
|
242
301
|
if (result.annotations) {
|
|
@@ -473,12 +532,12 @@ export class AgentBridge {
|
|
|
473
532
|
async explainSymbol(name, onEvent, signal) {
|
|
474
533
|
if (!this.projectIndex)
|
|
475
534
|
throw new Error("No project index");
|
|
476
|
-
if (!this.
|
|
535
|
+
if (!this.modelClient)
|
|
477
536
|
throw new Error("Agent is not configured.");
|
|
478
537
|
const sym = this.projectIndex.getSymbol(name);
|
|
479
538
|
if (!sym)
|
|
480
539
|
throw new Error(`Symbol "${name}" not found`);
|
|
481
|
-
const explainAgent = this.
|
|
540
|
+
const explainAgent = this.createAgent(undefined, onEvent);
|
|
482
541
|
const prompt = `Explain "${sym.name}" (${sym.kind} in ${sym.filePath}).
|
|
483
542
|
Use read_symbol, get_dependencies, find_references to gather context.
|
|
484
543
|
Explain what it does, how it works, what depends on it, and key design decisions.
|
|
@@ -491,12 +550,12 @@ Be concise but thorough.`;
|
|
|
491
550
|
const report = this.getStructuralAnalysis();
|
|
492
551
|
if (!report)
|
|
493
552
|
throw new Error("No project index");
|
|
494
|
-
if (!this.
|
|
553
|
+
if (!this.modelClient)
|
|
495
554
|
throw new Error("Agent is not configured.");
|
|
496
555
|
const finding = report.findings.find((item) => item.id === findingId);
|
|
497
556
|
if (!finding)
|
|
498
557
|
throw new Error(`Structural finding "${findingId}" not found`);
|
|
499
|
-
const explainAgent = this.
|
|
558
|
+
const explainAgent = this.createAgent(undefined, onEvent);
|
|
500
559
|
const affectedSymbols = finding.symbols.length > 0
|
|
501
560
|
? finding.symbols.slice(0, 8).join(", ")
|
|
502
561
|
: "(none)";
|
|
@@ -11,6 +11,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { getSymbolDisplayName } from "../indexer/symbol-names.js";
|
|
13
13
|
import { formatAmbiguousSymbolMatches, resolveSymbolInput, } from "../shared/symbol-resolution.js";
|
|
14
|
+
import { searchSymbols } from "../shared/symbol-search.js";
|
|
14
15
|
/** Active transports keyed by session ID. */
|
|
15
16
|
const transports = new Map();
|
|
16
17
|
/**
|
|
@@ -157,22 +158,27 @@ function createMcpServer(bridge, emit) {
|
|
|
157
158
|
kind: z.string().optional().describe("Filter by kind: function, class, interface, type, variable, method"),
|
|
158
159
|
}, async ({ query, kind }) => {
|
|
159
160
|
return wrapToolCall("search_code_map", { query, kind }, async () => {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
161
|
+
const result = searchSymbols(bridge.getSymbols().map((symbol) => ({
|
|
162
|
+
symbol,
|
|
163
|
+
record: {
|
|
164
|
+
name: symbol.name,
|
|
165
|
+
qualifiedName: symbol.qualifiedName,
|
|
166
|
+
kind: symbol.kind,
|
|
167
|
+
filePath: symbol.filePath,
|
|
168
|
+
startLine: symbol.startLine,
|
|
169
|
+
exported: symbol.exported,
|
|
170
|
+
},
|
|
171
|
+
lookupNames: [symbol.name, symbol.qualifiedName],
|
|
172
|
+
})), query, { kind, limit: 30 });
|
|
173
|
+
if (result.total === 0) {
|
|
172
174
|
return { content: [{ type: "text", text: `No symbols matching "${query}"${kind ? ` (kind: ${kind})` : ""}.` }] };
|
|
173
175
|
}
|
|
176
|
+
const matches = result.matches;
|
|
177
|
+
const heading = result.mode === "similar"
|
|
178
|
+
? `No exact substring matches for "${query}"${kind ? ` (kind: ${kind})` : ""}. Showing ${matches.length} similar symbol(s):`
|
|
179
|
+
: `Found ${matches.length} symbol(s) matching "${query}":`;
|
|
174
180
|
const lines = [
|
|
175
|
-
|
|
181
|
+
heading,
|
|
176
182
|
...matches.map((s) => ` - ${s.name} (${s.kind}) — ${s.filePath}:${s.startLine} — qualified: ${s.qualifiedName}${s.signature ? `\n ${s.signature}` : ""}`),
|
|
177
183
|
];
|
|
178
184
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
package/dist/src/serve/server.js
CHANGED
|
@@ -8,9 +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";
|
|
12
13
|
import { serializeSymbolMatch } from "../shared/symbol-resolution.js";
|
|
13
14
|
import { handleMcpRequest } from "./mcp-server.js";
|
|
15
|
+
import { buildSessionPreview } from "../session/session-preview.js";
|
|
14
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
17
|
// Resolve web dir: always serve from dist/src/web (built by scripts/build-web.mjs)
|
|
16
18
|
// In dev (tsx): __dirname = src/serve → go up to project root, then dist/src/web
|
|
@@ -28,6 +30,19 @@ function sendJson(res, status, body) {
|
|
|
28
30
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
29
31
|
res.end(JSON.stringify(body));
|
|
30
32
|
}
|
|
33
|
+
function resolveWorkspaceFilePath(workspaceRoot, requestedPath) {
|
|
34
|
+
const trimmedPath = requestedPath.trim();
|
|
35
|
+
if (trimmedPath.length === 0) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const root = path.resolve(workspaceRoot);
|
|
39
|
+
const absolutePath = path.resolve(root, trimmedPath);
|
|
40
|
+
const relativePath = path.relative(root, absolutePath);
|
|
41
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { absolutePath, relativePath };
|
|
45
|
+
}
|
|
31
46
|
function readBody(req) {
|
|
32
47
|
return new Promise((resolve, reject) => {
|
|
33
48
|
const chunks = [];
|
|
@@ -49,7 +64,12 @@ async function serveStatic(res, urlPath) {
|
|
|
49
64
|
const content = await readFile(filePath);
|
|
50
65
|
const ext = path.extname(filePath);
|
|
51
66
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
52
|
-
res.writeHead(200, {
|
|
67
|
+
res.writeHead(200, {
|
|
68
|
+
"Content-Type": contentType,
|
|
69
|
+
// This local UI changes often during development. Avoid stale browser bundles
|
|
70
|
+
// causing the app to run an older client against a newer server.
|
|
71
|
+
"Cache-Control": "no-store",
|
|
72
|
+
});
|
|
53
73
|
res.end(content);
|
|
54
74
|
}
|
|
55
75
|
catch {
|
|
@@ -62,7 +82,6 @@ async function buildWebSettingsPayload(config, minicodeHome) {
|
|
|
62
82
|
}
|
|
63
83
|
/** Create the HTTP request handler. Exported for testing. */
|
|
64
84
|
export function createRequestHandler(bridge, emit, options = {}) {
|
|
65
|
-
const config = bridge.getConfig();
|
|
66
85
|
const emitFn = emit ?? (() => { });
|
|
67
86
|
const minicodeHome = options.minicodeHome;
|
|
68
87
|
return (req, res) => {
|
|
@@ -70,6 +89,7 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
70
89
|
const method = req.method ?? "GET";
|
|
71
90
|
const pathname = url.pathname;
|
|
72
91
|
const handle = async () => {
|
|
92
|
+
const config = bridge.getConfig();
|
|
73
93
|
// MCP (Model Context Protocol) endpoint
|
|
74
94
|
if (pathname === "/mcp") {
|
|
75
95
|
await handleMcpRequest(req, res, bridge, emitFn);
|
|
@@ -92,6 +112,8 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
92
112
|
workspace: config.workspaceRoot,
|
|
93
113
|
model: config.model,
|
|
94
114
|
provider: config.modelProvider,
|
|
115
|
+
baseUrl: config.openAiBaseUrl,
|
|
116
|
+
sessionOpenRouterConnected: bridge.isOpenRouterSessionConnected(),
|
|
95
117
|
needsSetup: missing.length > 0,
|
|
96
118
|
missing,
|
|
97
119
|
});
|
|
@@ -102,6 +124,132 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
102
124
|
sendJson(res, 200, { models, activeModel: config.model });
|
|
103
125
|
return;
|
|
104
126
|
}
|
|
127
|
+
if (pathname === "/api/openrouter/connect" && method === "POST") {
|
|
128
|
+
const body = JSON.parse(await readBody(req));
|
|
129
|
+
if (!body.code || typeof body.code !== "string") {
|
|
130
|
+
sendJson(res, 400, { error: "code is required" });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!body.codeVerifier || typeof body.codeVerifier !== "string") {
|
|
134
|
+
sendJson(res, 400, { error: "codeVerifier is required" });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let exchangeResponse;
|
|
138
|
+
try {
|
|
139
|
+
exchangeResponse = await fetch("https://openrouter.ai/api/v1/auth/keys", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
},
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
code: body.code,
|
|
146
|
+
code_verifier: body.codeVerifier,
|
|
147
|
+
code_challenge_method: "S256",
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
const message = error instanceof Error ? error.message : "OpenRouter OAuth exchange failed";
|
|
153
|
+
sendJson(res, 502, { error: message });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!exchangeResponse.ok) {
|
|
157
|
+
const message = await exchangeResponse.text();
|
|
158
|
+
sendJson(res, exchangeResponse.status, {
|
|
159
|
+
error: message.trim().length > 0
|
|
160
|
+
? `OpenRouter OAuth exchange failed: ${message}`
|
|
161
|
+
: `OpenRouter OAuth exchange failed (${exchangeResponse.status})`,
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const payload = await exchangeResponse.json();
|
|
166
|
+
if (!payload.key || typeof payload.key !== "string") {
|
|
167
|
+
sendJson(res, 502, { error: "OpenRouter OAuth exchange did not return an API key." });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
bridge.connectOpenRouter(payload.key);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
const message = error instanceof Error ? error.message : "Failed to configure OpenRouter";
|
|
175
|
+
sendJson(res, message === "busy" ? 409 : 400, { error: message });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
let persistedToEnv = false;
|
|
179
|
+
let persistedEnvPath = null;
|
|
180
|
+
let persistWarning = null;
|
|
181
|
+
if (body.persistToEnv === true) {
|
|
182
|
+
try {
|
|
183
|
+
const result = await upsertHomeEnvValues({
|
|
184
|
+
values: {
|
|
185
|
+
MODEL_PROVIDER: "openai-compatible",
|
|
186
|
+
OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
|
|
187
|
+
OPENROUTER_API_KEY: payload.key,
|
|
188
|
+
},
|
|
189
|
+
...(minicodeHome ? { minicodeHome } : {}),
|
|
190
|
+
});
|
|
191
|
+
persistedToEnv = true;
|
|
192
|
+
persistedEnvPath = result.path;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
const message = error instanceof Error ? error.message : "Failed to update ~/.minicode/.env";
|
|
196
|
+
persistedEnvPath = getHomeEnvPath(minicodeHome);
|
|
197
|
+
persistWarning = `OpenRouter connected for this serve session, but minicode could not update ${persistedEnvPath}: ${message}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const missing = getConfigMissing(config);
|
|
201
|
+
const onlyModelMissing = missing.length === 1 && missing[0] === "MODEL is not set";
|
|
202
|
+
const message = persistWarning
|
|
203
|
+
? `${persistWarning}${onlyModelMissing ? " Select a model to continue." : ""}`
|
|
204
|
+
: persistedToEnv
|
|
205
|
+
? (onlyModelMissing
|
|
206
|
+
? "OpenRouter connected for this serve session and saved to ~/.minicode/.env. Select a model to continue, and minicode will remember it for future runs."
|
|
207
|
+
: "OpenRouter connected for this serve session and saved to ~/.minicode/.env for future runs.")
|
|
208
|
+
: (onlyModelMissing
|
|
209
|
+
? "OpenRouter connected for this serve session. Select a model to continue."
|
|
210
|
+
: "OpenRouter connected for this serve session.");
|
|
211
|
+
sendJson(res, 200, {
|
|
212
|
+
ok: true,
|
|
213
|
+
sessionOnly: true,
|
|
214
|
+
persistedToEnv,
|
|
215
|
+
persistedEnvPath,
|
|
216
|
+
persistWarning,
|
|
217
|
+
provider: config.modelProvider,
|
|
218
|
+
model: config.model,
|
|
219
|
+
baseUrl: config.openAiBaseUrl,
|
|
220
|
+
needsSetup: missing.length > 0,
|
|
221
|
+
missing,
|
|
222
|
+
message,
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (pathname === "/api/openrouter/disconnect" && method === "POST") {
|
|
227
|
+
let disconnected = false;
|
|
228
|
+
try {
|
|
229
|
+
disconnected = bridge.disconnectOpenRouter();
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
const message = error instanceof Error ? error.message : "Failed to remove OpenRouter session";
|
|
233
|
+
sendJson(res, message === "busy" ? 409 : 400, { error: message });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const missing = getConfigMissing(config);
|
|
237
|
+
const body = {
|
|
238
|
+
ok: true,
|
|
239
|
+
disconnected,
|
|
240
|
+
sessionOnly: true,
|
|
241
|
+
provider: config.modelProvider,
|
|
242
|
+
model: config.model,
|
|
243
|
+
baseUrl: config.openAiBaseUrl,
|
|
244
|
+
needsSetup: missing.length > 0,
|
|
245
|
+
missing,
|
|
246
|
+
message: disconnected
|
|
247
|
+
? "Removed the session-only OpenRouter connection and restored your original provider settings."
|
|
248
|
+
: "No session-only OpenRouter connection was active.",
|
|
249
|
+
};
|
|
250
|
+
sendJson(res, 200, body);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
105
253
|
if (pathname === "/api/model" && method === "POST") {
|
|
106
254
|
const body = JSON.parse(await readBody(req));
|
|
107
255
|
if (!body.model || typeof body.model !== "string") {
|
|
@@ -109,7 +257,26 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
109
257
|
return;
|
|
110
258
|
}
|
|
111
259
|
bridge.switchModel(body.model);
|
|
112
|
-
|
|
260
|
+
let persistedToEnv = false;
|
|
261
|
+
let persistedEnvPath = null;
|
|
262
|
+
let message;
|
|
263
|
+
try {
|
|
264
|
+
const result = await upsertHomeEnvValues({
|
|
265
|
+
values: {
|
|
266
|
+
MODEL: body.model,
|
|
267
|
+
},
|
|
268
|
+
...(minicodeHome ? { minicodeHome } : {}),
|
|
269
|
+
});
|
|
270
|
+
persistedToEnv = true;
|
|
271
|
+
persistedEnvPath = result.path;
|
|
272
|
+
message = `Saved MODEL=${body.model} to ~/.minicode/.env.`;
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
const persistMessage = error instanceof Error ? error.message : "Failed to update ~/.minicode/.env";
|
|
276
|
+
sendJson(res, 500, { error: persistMessage });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
sendJson(res, 200, { model: body.model, persistedToEnv, persistedEnvPath, message });
|
|
113
280
|
return;
|
|
114
281
|
}
|
|
115
282
|
if (pathname === "/api/context" && method === "GET") {
|
|
@@ -180,7 +347,10 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
180
347
|
sendJson(res, 404, { error: "Session not found" });
|
|
181
348
|
return;
|
|
182
349
|
}
|
|
183
|
-
sendJson(res, 200, {
|
|
350
|
+
sendJson(res, 200, {
|
|
351
|
+
label: result.label,
|
|
352
|
+
messages: buildSessionPreview(result.session.getMessages()),
|
|
353
|
+
});
|
|
184
354
|
return;
|
|
185
355
|
}
|
|
186
356
|
// ── Graph / Index API ──
|
|
@@ -266,6 +436,22 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
266
436
|
}
|
|
267
437
|
return;
|
|
268
438
|
}
|
|
439
|
+
if (pathname === "/api/file-source" && method === "GET") {
|
|
440
|
+
const requestedPath = url.searchParams.get("path") ?? "";
|
|
441
|
+
const resolved = resolveWorkspaceFilePath(config.workspaceRoot, requestedPath);
|
|
442
|
+
if (!resolved) {
|
|
443
|
+
sendJson(res, 403, { error: "Invalid workspace file path" });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const source = await readFile(resolved.absolutePath, "utf8");
|
|
448
|
+
sendJson(res, 200, { filePath: resolved.relativePath, source });
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
sendJson(res, 404, { error: `Could not read file: ${resolved.relativePath}` });
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
269
455
|
if (pathname === "/api/code-map" && method === "GET") {
|
|
270
456
|
const budgetParam = url.searchParams.get("budget");
|
|
271
457
|
const budget = budgetParam ? Number(budgetParam) : undefined;
|
|
@@ -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
|
+
}
|