@sean.holung/minicode 0.3.6 → 0.3.8
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 +2 -1
- package/dist/scripts/run-benchmarks.js +1 -0
- package/dist/src/agent/config.js +27 -0
- package/dist/src/agent/editable-config.js +6 -0
- package/dist/src/model-utils.js +18 -1
- package/dist/src/serve/agent-bridge.js +85 -14
- package/dist/src/serve/mcp-server.js +19 -13
- package/dist/src/serve/server.js +166 -3
- package/dist/src/session/session-store.js +18 -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 +662 -113
- package/dist/src/web/index.html +128 -8
- package/dist/src/web/style.css +189 -7
- package/dist/tests/agent.test.js +16 -0
- package/dist/tests/config-api.test.js +5 -0
- package/dist/tests/config-integration.test.js +91 -1
- package/dist/tests/config.test.js +9 -0
- package/dist/tests/file-tools.test.js +12 -0
- package/dist/tests/graph-onboarding.test.js +20 -0
- package/dist/tests/mcp-and-plugin.test.js +3 -0
- package/dist/tests/model-client-openai.test.js +41 -0
- package/dist/tests/model-dropdown-ui.test.js +23 -0
- package/dist/tests/model-utils.test.js +26 -1
- package/dist/tests/search-code-map.test.js +9 -0
- package/dist/tests/serve.integration.test.js +189 -0
- package/dist/tests/session-store.test.js +15 -1
- package/dist/tests/settings-ui.test.js +11 -0
- package/dist/tests/setup-overlay-state.test.js +49 -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/agent.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +10 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
- 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 +164 -27
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -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 +60 -6
- package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# minicode
|
|
2
2
|
|
|
3
|
-
> Now supports connecting to [OpenRouter](https://openrouter.ai/) account via minicode UI. Sign in with OpenRouter account. Use for free with compatible free tier OpenRouter hosted models from MiniMax, Nvidia, Qwen, Google, etc.
|
|
3
|
+
> Now supports connecting to [OpenRouter](https://openrouter.ai/) account via minicode UI. Sign in with OpenRouter account. Use for free with compatible [free tier OpenRouter hosted models](https://openrouter.ai/models?q=free) from MiniMax, Nvidia, Qwen, Google, etc.
|
|
4
4
|
|
|
5
5
|
A graph-native coding agent and code exploration environment built around structural context optimization that leverages symbol-aware retrieval, dependency graphs, and targeted context. It started as a way to make local models viable under tighter context budgets, and it now also works well with hosted frontier models through the same runtime, web UI, and OpenAI-compatible serve mode.
|
|
6
6
|
|
|
@@ -199,6 +199,7 @@ Nothing is written inside your workspace; config and cache live under `~/.minico
|
|
|
199
199
|
| `COMMAND_DENYLIST` | No | none | Optional JSON array or comma-separated regex patterns appended to the built-in destructive-command denylist |
|
|
200
200
|
| `MAX_STEPS` | No | `50` | Max agent loop iterations per user turn |
|
|
201
201
|
| `MAX_TOKENS` | No | `4096` | Max model output tokens per model call |
|
|
202
|
+
| `MODEL_TIMEOUT_SECONDS` | No | `60` | Timeout waiting for a model API call to start responding before aborting and surfacing an error |
|
|
202
203
|
| `MAX_CONTEXT_TOKENS` | No | `32000` | Approximate session history trimming target. For small models (e.g. 8k context), set lower (e.g. `6000`) to leave room for responses. |
|
|
203
204
|
| `MAX_TOOL_OUTPUT_CHARS` | No | `8000` | Max chars per tool result before truncation. Set to `0` to disable. |
|
|
204
205
|
| `WORKSPACE_ROOT` | No | current working directory | Root directory tools are allowed to access (set at runtime, not typically configured) |
|
|
@@ -99,6 +99,7 @@ export function buildConfig(options = {}) {
|
|
|
99
99
|
model,
|
|
100
100
|
maxSteps: getNumberSetting(getShellOverride("MAX_STEPS"), fileConfig.maxSteps, 50),
|
|
101
101
|
maxTokens: getNumberSetting(getShellOverride("MAX_TOKENS"), fileConfig.maxTokens, 4096),
|
|
102
|
+
modelTimeoutSeconds: getNumberSetting(getShellOverride("MODEL_TIMEOUT_SECONDS"), fileConfig.modelTimeoutSeconds, 60),
|
|
102
103
|
maxContextTokens: getNumberSetting(getShellOverride("MAX_CONTEXT_TOKENS"), fileConfig.maxContextTokens, 32000),
|
|
103
104
|
workspaceRoot: repoRoot,
|
|
104
105
|
commandTimeoutMs: getNumberSetting(getShellOverride("COMMAND_TIMEOUT_MS"), fileConfig.commandTimeoutMs, 30000),
|
package/dist/src/agent/config.js
CHANGED
|
@@ -16,6 +16,7 @@ export function formatConfigForDisplay(config) {
|
|
|
16
16
|
"model: " + config.model,
|
|
17
17
|
"maxSteps: " + config.maxSteps,
|
|
18
18
|
"maxTokens: " + config.maxTokens,
|
|
19
|
+
"modelTimeoutSeconds: " + config.modelTimeoutSeconds,
|
|
19
20
|
"maxContextTokens: " + config.maxContextTokens,
|
|
20
21
|
"commandTimeoutMs: " + config.commandTimeoutMs,
|
|
21
22
|
"maxFileSizeBytes: " + config.maxFileSizeBytes,
|
|
@@ -59,6 +60,31 @@ export function getConfigMissing(config) {
|
|
|
59
60
|
}
|
|
60
61
|
return missing;
|
|
61
62
|
}
|
|
63
|
+
function hasExplicitConfigValue(value) {
|
|
64
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
65
|
+
}
|
|
66
|
+
function parseExplicitModelProvider(value) {
|
|
67
|
+
if (!hasExplicitConfigValue(value)) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return parseModelProvider(value);
|
|
71
|
+
}
|
|
72
|
+
export function getConfiguredProvider(config, env = process.env) {
|
|
73
|
+
const explicitModelProvider = parseExplicitModelProvider(env.MODEL_PROVIDER);
|
|
74
|
+
if (config.modelProvider === "anthropic") {
|
|
75
|
+
return explicitModelProvider === "anthropic" || hasExplicitConfigValue(env.ANTHROPIC_API_KEY)
|
|
76
|
+
? "anthropic"
|
|
77
|
+
: null;
|
|
78
|
+
}
|
|
79
|
+
const hasExplicitOpenAiCompatibleConfig = explicitModelProvider === "openai-compatible" ||
|
|
80
|
+
hasExplicitConfigValue(env.OPENAI_BASE_URL) ||
|
|
81
|
+
hasExplicitConfigValue(env.OPENROUTER_API_KEY);
|
|
82
|
+
if (!hasExplicitOpenAiCompatibleConfig) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const baseUrl = (env.OPENAI_BASE_URL ?? config.openAiBaseUrl).trim().toLowerCase();
|
|
86
|
+
return baseUrl.includes("openrouter") ? "openrouter" : "openai-compatible";
|
|
87
|
+
}
|
|
62
88
|
export function getConfigSetupMessage(config) {
|
|
63
89
|
const missing = getConfigMissing(config);
|
|
64
90
|
if (missing.length === 0) {
|
|
@@ -230,6 +256,7 @@ export async function loadAgentConfig(cwd = process.cwd(), options = {}) {
|
|
|
230
256
|
model: env.MODEL ?? "",
|
|
231
257
|
maxSteps: parseNumber(env.MAX_STEPS, 50),
|
|
232
258
|
maxTokens: parseNumber(env.MAX_TOKENS, 4096),
|
|
259
|
+
modelTimeoutSeconds: parseNumber(env.MODEL_TIMEOUT_SECONDS, 60),
|
|
233
260
|
maxContextTokens: parseNumber(env.MAX_CONTEXT_TOKENS, 32_000),
|
|
234
261
|
workspaceRoot,
|
|
235
262
|
commandTimeoutMs: parseNumber(env.COMMAND_TIMEOUT_MS, 30_000),
|
|
@@ -34,6 +34,12 @@ export const EDITABLE_CONFIG_DEFINITIONS = [
|
|
|
34
34
|
type: "number",
|
|
35
35
|
description: "Maximum completion tokens per model response",
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
key: "modelTimeoutSeconds",
|
|
39
|
+
envVar: "MODEL_TIMEOUT_SECONDS",
|
|
40
|
+
type: "number",
|
|
41
|
+
description: "Maximum time to wait for a model API call to start responding",
|
|
42
|
+
},
|
|
37
43
|
{
|
|
38
44
|
key: "maxContextTokens",
|
|
39
45
|
envVar: "MAX_CONTEXT_TOKENS",
|
package/dist/src/model-utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
function getModelDisplayName(model) {
|
|
1
|
+
export function getModelDisplayName(model) {
|
|
2
2
|
return (model.name ?? model.id).trim();
|
|
3
3
|
}
|
|
4
4
|
export function sortModelsAlphabetically(models) {
|
|
@@ -16,3 +16,20 @@ export function sortModelsAlphabetically(models) {
|
|
|
16
16
|
});
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
|
+
function normalizeModelSearchValue(value) {
|
|
20
|
+
return value
|
|
21
|
+
.trim()
|
|
22
|
+
.toLocaleLowerCase()
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
export function filterModelsByQuery(models, query) {
|
|
27
|
+
const tokens = normalizeModelSearchValue(query);
|
|
28
|
+
if (tokens.length === 0) {
|
|
29
|
+
return [...models];
|
|
30
|
+
}
|
|
31
|
+
return models.filter((model) => {
|
|
32
|
+
const haystack = `${getModelDisplayName(model)} ${model.id}`.toLocaleLowerCase();
|
|
33
|
+
return tokens.every((token) => haystack.includes(token));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -17,7 +17,7 @@ export class AgentBridge {
|
|
|
17
17
|
modelClient;
|
|
18
18
|
projectIndex;
|
|
19
19
|
toolRegistry;
|
|
20
|
-
|
|
20
|
+
sessionProviderConnection = null;
|
|
21
21
|
busy = false;
|
|
22
22
|
abortController = null;
|
|
23
23
|
broadcast;
|
|
@@ -68,17 +68,10 @@ export class AgentBridge {
|
|
|
68
68
|
catch {
|
|
69
69
|
projectIndex = undefined;
|
|
70
70
|
}
|
|
71
|
-
const toolRegistry = createToolRegistry(config, projectIndex);
|
|
72
|
-
// Wrap tool registry execute to inject annotations into tool results
|
|
73
|
-
const originalExecute = toolRegistry.execute.bind(toolRegistry);
|
|
74
|
-
toolRegistry.execute = async (name, input) => {
|
|
75
|
-
const result = await originalExecute(name, input);
|
|
76
|
-
return this.appendAnnotationsToResult(name, input, result);
|
|
77
|
-
};
|
|
78
71
|
this.config = config;
|
|
79
72
|
this.baseConfig = AgentBridge.cloneConfig(config);
|
|
80
73
|
this.projectIndex = projectIndex;
|
|
81
|
-
this.
|
|
74
|
+
this.installToolRegistry(projectIndex);
|
|
82
75
|
if (this.modelClient) {
|
|
83
76
|
this.agent = this.createAgent();
|
|
84
77
|
}
|
|
@@ -98,6 +91,16 @@ export class AgentBridge {
|
|
|
98
91
|
}
|
|
99
92
|
Object.assign(targetRecord, AgentBridge.cloneConfig(source));
|
|
100
93
|
}
|
|
94
|
+
installToolRegistry(projectIndex) {
|
|
95
|
+
const toolRegistry = createToolRegistry(this.config, projectIndex);
|
|
96
|
+
// Wrap tool registry execute to inject annotations into tool results.
|
|
97
|
+
const originalExecute = toolRegistry.execute.bind(toolRegistry);
|
|
98
|
+
toolRegistry.execute = async (name, input) => {
|
|
99
|
+
const result = await originalExecute(name, input);
|
|
100
|
+
return this.appendAnnotationsToResult(name, input, result);
|
|
101
|
+
};
|
|
102
|
+
this.toolRegistry = toolRegistry;
|
|
103
|
+
}
|
|
101
104
|
createAgent(session, onUiUpdate) {
|
|
102
105
|
if (!this.modelClient || !this.toolRegistry) {
|
|
103
106
|
throw new Error("Agent runtime is not initialized.");
|
|
@@ -179,7 +182,8 @@ export class AgentBridge {
|
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
catch {
|
|
182
|
-
// File may have been deleted
|
|
185
|
+
// File may have been deleted or renamed. A workspace refresh prunes stale symbols.
|
|
186
|
+
await this.refreshIndex();
|
|
183
187
|
}
|
|
184
188
|
}
|
|
185
189
|
isReady() {
|
|
@@ -192,7 +196,10 @@ export class AgentBridge {
|
|
|
192
196
|
return this.config;
|
|
193
197
|
}
|
|
194
198
|
isOpenRouterSessionConnected() {
|
|
195
|
-
return this.
|
|
199
|
+
return this.sessionProviderConnection === "openrouter";
|
|
200
|
+
}
|
|
201
|
+
isOpenAiCompatibleSessionConnected() {
|
|
202
|
+
return this.sessionProviderConnection === "openai-compatible";
|
|
196
203
|
}
|
|
197
204
|
connectOpenRouter(apiKey) {
|
|
198
205
|
const trimmedKey = apiKey.trim();
|
|
@@ -206,7 +213,29 @@ export class AgentBridge {
|
|
|
206
213
|
this.config.modelProvider = "openai-compatible";
|
|
207
214
|
this.config.openAiBaseUrl = "https://openrouter.ai/api/v1";
|
|
208
215
|
this.config.openAiApiKey = trimmedKey;
|
|
209
|
-
this.
|
|
216
|
+
this.sessionProviderConnection = "openrouter";
|
|
217
|
+
this.modelClient = createModelClient(this.config);
|
|
218
|
+
this.agent = this.createAgent(currentSession);
|
|
219
|
+
}
|
|
220
|
+
connectOpenAiCompatible(baseUrl, apiKey) {
|
|
221
|
+
const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
|
222
|
+
if (!trimmedBaseUrl) {
|
|
223
|
+
throw new Error("OpenAI-compatible endpoint is required.");
|
|
224
|
+
}
|
|
225
|
+
if (this.busy) {
|
|
226
|
+
throw new Error("busy");
|
|
227
|
+
}
|
|
228
|
+
const currentSession = this.agent?.getSession();
|
|
229
|
+
this.config.modelProvider = "openai-compatible";
|
|
230
|
+
this.config.openAiBaseUrl = trimmedBaseUrl;
|
|
231
|
+
const trimmedApiKey = apiKey?.trim() ?? "";
|
|
232
|
+
if (trimmedApiKey) {
|
|
233
|
+
this.config.openAiApiKey = trimmedApiKey;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
delete this.config.openAiApiKey;
|
|
237
|
+
}
|
|
238
|
+
this.sessionProviderConnection = "openai-compatible";
|
|
210
239
|
this.modelClient = createModelClient(this.config);
|
|
211
240
|
this.agent = this.createAgent(currentSession);
|
|
212
241
|
}
|
|
@@ -214,12 +243,24 @@ export class AgentBridge {
|
|
|
214
243
|
if (this.busy) {
|
|
215
244
|
throw new Error("busy");
|
|
216
245
|
}
|
|
217
|
-
if (
|
|
246
|
+
if (this.sessionProviderConnection !== "openrouter") {
|
|
218
247
|
return false;
|
|
219
248
|
}
|
|
249
|
+
return this.disconnectSessionProvider();
|
|
250
|
+
}
|
|
251
|
+
disconnectOpenAiCompatible() {
|
|
252
|
+
if (this.busy) {
|
|
253
|
+
throw new Error("busy");
|
|
254
|
+
}
|
|
255
|
+
if (this.sessionProviderConnection !== "openai-compatible") {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return this.disconnectSessionProvider();
|
|
259
|
+
}
|
|
260
|
+
disconnectSessionProvider() {
|
|
220
261
|
const currentSession = this.agent?.getSession();
|
|
221
262
|
AgentBridge.applyConfig(this.config, this.baseConfig);
|
|
222
|
-
this.
|
|
263
|
+
this.sessionProviderConnection = null;
|
|
223
264
|
try {
|
|
224
265
|
this.modelClient = createModelClient(this.config);
|
|
225
266
|
this.agent = this.createAgent(currentSession);
|
|
@@ -312,6 +353,36 @@ export class AgentBridge {
|
|
|
312
353
|
hasIndex() {
|
|
313
354
|
return this.projectIndex !== undefined;
|
|
314
355
|
}
|
|
356
|
+
async refreshIndex() {
|
|
357
|
+
try {
|
|
358
|
+
if (this.projectIndex) {
|
|
359
|
+
await this.projectIndex.refreshFromWorkspace();
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
this.projectIndex = await buildProjectIndex(this.config.workspaceRoot);
|
|
363
|
+
this.installToolRegistry(this.projectIndex);
|
|
364
|
+
if (this.modelClient) {
|
|
365
|
+
this.agent = this.createAgent(this.agent?.getSession());
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const index = this.projectIndex;
|
|
369
|
+
if (!index) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
const cacheDir = getWorkspaceCacheDir(this.config.workspaceRoot);
|
|
373
|
+
const fileHashes = await computeFileHashes(this.config.workspaceRoot);
|
|
374
|
+
await saveIndex(index, cacheDir, fileHashes);
|
|
375
|
+
this.evictStaleAnnotations();
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (this.verbose) {
|
|
380
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
381
|
+
console.error(`[index] Refresh failed: ${message}`);
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
315
386
|
getSymbols() {
|
|
316
387
|
if (!this.projectIndex)
|
|
317
388
|
return [];
|
|
@@ -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
|
@@ -6,13 +6,14 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { AgentBridge } from "./agent-bridge.js";
|
|
7
7
|
import { createWebSocketServer } from "./websocket.js";
|
|
8
8
|
import { handleChatCompletions, handleModels } from "./openai-compat.js";
|
|
9
|
-
import { formatConfigForDisplay, getConfigMissing } from "../agent/config.js";
|
|
9
|
+
import { formatConfigForDisplay, getConfiguredProvider, getConfigMissing, resolveConfigEnv } from "../agent/config.js";
|
|
10
10
|
import { applyPersistedConfigUpdates, buildStructuredConfigPayload } from "../agent/editable-config.js";
|
|
11
11
|
import { getHomeEnvPath, upsertHomeEnvValues } from "../agent/home-env.js";
|
|
12
12
|
import { sortModelsAlphabetically } from "../model-utils.js";
|
|
13
13
|
import { serializeSymbolMatch } from "../shared/symbol-resolution.js";
|
|
14
14
|
import { handleMcpRequest } from "./mcp-server.js";
|
|
15
15
|
import { buildSessionPreview } from "../session/session-preview.js";
|
|
16
|
+
import { DuplicateSessionLabelError } from "../session/session-store.js";
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
// Resolve web dir: always serve from dist/src/web (built by scripts/build-web.mjs)
|
|
18
19
|
// In dev (tsx): __dirname = src/serve → go up to project root, then dist/src/web
|
|
@@ -30,6 +31,19 @@ function sendJson(res, status, body) {
|
|
|
30
31
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
31
32
|
res.end(JSON.stringify(body));
|
|
32
33
|
}
|
|
34
|
+
function resolveWorkspaceFilePath(workspaceRoot, requestedPath) {
|
|
35
|
+
const trimmedPath = requestedPath.trim();
|
|
36
|
+
if (trimmedPath.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const root = path.resolve(workspaceRoot);
|
|
40
|
+
const absolutePath = path.resolve(root, trimmedPath);
|
|
41
|
+
const relativePath = path.relative(root, absolutePath);
|
|
42
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return { absolutePath, relativePath };
|
|
46
|
+
}
|
|
33
47
|
function readBody(req) {
|
|
34
48
|
return new Promise((resolve, reject) => {
|
|
35
49
|
const chunks = [];
|
|
@@ -38,6 +52,9 @@ function readBody(req) {
|
|
|
38
52
|
req.on("error", reject);
|
|
39
53
|
});
|
|
40
54
|
}
|
|
55
|
+
function normalizeBaseUrl(value) {
|
|
56
|
+
return value.trim().replace(/\/+$/, "");
|
|
57
|
+
}
|
|
41
58
|
async function serveStatic(res, urlPath) {
|
|
42
59
|
const fileName = urlPath === "/" ? "index.html" : urlPath.slice(1);
|
|
43
60
|
const filePath = path.join(webDir, fileName);
|
|
@@ -94,13 +111,16 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
94
111
|
// Minicode REST API
|
|
95
112
|
if (pathname === "/api/status" && method === "GET") {
|
|
96
113
|
const missing = getConfigMissing(config);
|
|
114
|
+
const resolvedEnv = await resolveConfigEnv({ ...(minicodeHome ? { minicodeHome } : {}) });
|
|
97
115
|
sendJson(res, 200, {
|
|
98
116
|
status: bridge.isBusy() ? "busy" : "ready",
|
|
99
117
|
workspace: config.workspaceRoot,
|
|
100
118
|
model: config.model,
|
|
101
119
|
provider: config.modelProvider,
|
|
102
120
|
baseUrl: config.openAiBaseUrl,
|
|
121
|
+
configuredProvider: getConfiguredProvider(config, resolvedEnv.values),
|
|
103
122
|
sessionOpenRouterConnected: bridge.isOpenRouterSessionConnected(),
|
|
123
|
+
sessionOpenAiCompatibleConnected: bridge.isOpenAiCompatibleSessionConnected(),
|
|
104
124
|
needsSetup: missing.length > 0,
|
|
105
125
|
missing,
|
|
106
126
|
});
|
|
@@ -210,6 +230,84 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
210
230
|
});
|
|
211
231
|
return;
|
|
212
232
|
}
|
|
233
|
+
if (pathname === "/api/openai-compatible/connect" && method === "POST") {
|
|
234
|
+
const body = JSON.parse(await readBody(req));
|
|
235
|
+
if (!body.baseUrl || typeof body.baseUrl !== "string") {
|
|
236
|
+
sendJson(res, 400, { error: "baseUrl is required" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const normalizedBaseUrl = normalizeBaseUrl(body.baseUrl);
|
|
240
|
+
if (!normalizedBaseUrl) {
|
|
241
|
+
sendJson(res, 400, { error: "baseUrl is required" });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const parsedUrl = new URL(normalizedBaseUrl);
|
|
246
|
+
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
247
|
+
throw new Error("invalid protocol");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
sendJson(res, 400, { error: "baseUrl must be a valid absolute http(s) URL" });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
bridge.connectOpenAiCompatible(normalizedBaseUrl, body.apiKey);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
const message = error instanceof Error ? error.message : "Failed to configure the OpenAI-compatible provider";
|
|
259
|
+
sendJson(res, message === "busy" ? 409 : 400, { error: message });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
let persistedToEnv = false;
|
|
263
|
+
let persistedEnvPath = null;
|
|
264
|
+
let persistWarning = null;
|
|
265
|
+
const trimmedApiKey = body.apiKey?.trim() ?? "";
|
|
266
|
+
if (body.persistToEnv === true) {
|
|
267
|
+
try {
|
|
268
|
+
const result = await upsertHomeEnvValues({
|
|
269
|
+
values: {
|
|
270
|
+
MODEL_PROVIDER: "openai-compatible",
|
|
271
|
+
OPENAI_BASE_URL: normalizedBaseUrl,
|
|
272
|
+
OPENAI_API_KEY: trimmedApiKey.length > 0 ? trimmedApiKey : null,
|
|
273
|
+
},
|
|
274
|
+
...(minicodeHome ? { minicodeHome } : {}),
|
|
275
|
+
});
|
|
276
|
+
persistedToEnv = true;
|
|
277
|
+
persistedEnvPath = result.path;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
const message = error instanceof Error ? error.message : "Failed to update ~/.minicode/.env";
|
|
281
|
+
persistedEnvPath = getHomeEnvPath(minicodeHome);
|
|
282
|
+
persistWarning = `The OpenAI-compatible provider connected for this serve session, but minicode could not update ${persistedEnvPath}: ${message}`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const missing = getConfigMissing(config);
|
|
286
|
+
const onlyModelMissing = missing.length === 1 && missing[0] === "MODEL is not set";
|
|
287
|
+
const message = persistWarning
|
|
288
|
+
? `${persistWarning}${onlyModelMissing ? " Select a model to continue." : ""}`
|
|
289
|
+
: persistedToEnv
|
|
290
|
+
? (onlyModelMissing
|
|
291
|
+
? "The OpenAI-compatible provider connected for this serve session and was saved to ~/.minicode/.env. Select a model to continue, and minicode will remember this endpoint for future runs."
|
|
292
|
+
: "The OpenAI-compatible provider connected for this serve session and was saved to ~/.minicode/.env for future runs.")
|
|
293
|
+
: (onlyModelMissing
|
|
294
|
+
? "The OpenAI-compatible provider connected for this serve session. Select a model to continue."
|
|
295
|
+
: "The OpenAI-compatible provider connected for this serve session.");
|
|
296
|
+
sendJson(res, 200, {
|
|
297
|
+
ok: true,
|
|
298
|
+
sessionOnly: true,
|
|
299
|
+
persistedToEnv,
|
|
300
|
+
persistedEnvPath,
|
|
301
|
+
persistWarning,
|
|
302
|
+
provider: config.modelProvider,
|
|
303
|
+
model: config.model,
|
|
304
|
+
baseUrl: config.openAiBaseUrl,
|
|
305
|
+
needsSetup: missing.length > 0,
|
|
306
|
+
missing,
|
|
307
|
+
message,
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
213
311
|
if (pathname === "/api/openrouter/disconnect" && method === "POST") {
|
|
214
312
|
let disconnected = false;
|
|
215
313
|
try {
|
|
@@ -237,6 +335,33 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
237
335
|
sendJson(res, 200, body);
|
|
238
336
|
return;
|
|
239
337
|
}
|
|
338
|
+
if (pathname === "/api/openai-compatible/disconnect" && method === "POST") {
|
|
339
|
+
let disconnected = false;
|
|
340
|
+
try {
|
|
341
|
+
disconnected = bridge.disconnectOpenAiCompatible();
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
const message = error instanceof Error ? error.message : "Failed to remove the OpenAI-compatible session";
|
|
345
|
+
sendJson(res, message === "busy" ? 409 : 400, { error: message });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const missing = getConfigMissing(config);
|
|
349
|
+
const body = {
|
|
350
|
+
ok: true,
|
|
351
|
+
disconnected,
|
|
352
|
+
sessionOnly: true,
|
|
353
|
+
provider: config.modelProvider,
|
|
354
|
+
model: config.model,
|
|
355
|
+
baseUrl: config.openAiBaseUrl,
|
|
356
|
+
needsSetup: missing.length > 0,
|
|
357
|
+
missing,
|
|
358
|
+
message: disconnected
|
|
359
|
+
? "Removed the session-only OpenAI-compatible connection and restored your original provider settings."
|
|
360
|
+
: "No session-only OpenAI-compatible connection was active.",
|
|
361
|
+
};
|
|
362
|
+
sendJson(res, 200, body);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
240
365
|
if (pathname === "/api/model" && method === "POST") {
|
|
241
366
|
const body = JSON.parse(await readBody(req));
|
|
242
367
|
if (!body.model || typeof body.model !== "string") {
|
|
@@ -323,8 +448,16 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
323
448
|
}
|
|
324
449
|
if (pathname === "/api/sessions/save" && method === "POST") {
|
|
325
450
|
const body = JSON.parse(await readBody(req));
|
|
326
|
-
|
|
327
|
-
|
|
451
|
+
try {
|
|
452
|
+
const meta = await bridge.saveSess(body.label);
|
|
453
|
+
sendJson(res, 200, meta);
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
const message = error instanceof Error ? error.message : "Failed to save session";
|
|
457
|
+
sendJson(res, error instanceof DuplicateSessionLabelError ? 409 : 500, {
|
|
458
|
+
error: message,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
328
461
|
return;
|
|
329
462
|
}
|
|
330
463
|
if (pathname === "/api/sessions/load" && method === "POST") {
|
|
@@ -423,6 +556,22 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
423
556
|
}
|
|
424
557
|
return;
|
|
425
558
|
}
|
|
559
|
+
if (pathname === "/api/file-source" && method === "GET") {
|
|
560
|
+
const requestedPath = url.searchParams.get("path") ?? "";
|
|
561
|
+
const resolved = resolveWorkspaceFilePath(config.workspaceRoot, requestedPath);
|
|
562
|
+
if (!resolved) {
|
|
563
|
+
sendJson(res, 403, { error: "Invalid workspace file path" });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
const source = await readFile(resolved.absolutePath, "utf8");
|
|
568
|
+
sendJson(res, 200, { filePath: resolved.relativePath, source });
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
sendJson(res, 404, { error: `Could not read file: ${resolved.relativePath}` });
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
426
575
|
if (pathname === "/api/code-map" && method === "GET") {
|
|
427
576
|
const budgetParam = url.searchParams.get("budget");
|
|
428
577
|
const budget = budgetParam ? Number(budgetParam) : undefined;
|
|
@@ -443,6 +592,20 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
443
592
|
sendJson(res, 200, result);
|
|
444
593
|
return;
|
|
445
594
|
}
|
|
595
|
+
if (pathname === "/api/index/refresh" && method === "POST") {
|
|
596
|
+
const refreshed = await bridge.refreshIndex();
|
|
597
|
+
if (!refreshed) {
|
|
598
|
+
sendJson(res, 404, { error: "No project index available" });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const graph = bridge.getGraph();
|
|
602
|
+
sendJson(res, 200, {
|
|
603
|
+
ok: true,
|
|
604
|
+
symbolCount: graph?.nodes.length ?? 0,
|
|
605
|
+
edgeCount: graph?.edges.length ?? 0,
|
|
606
|
+
});
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
446
609
|
if (pathname === "/api/analysis" && method === "GET") {
|
|
447
610
|
const result = bridge.getStructuralAnalysis();
|
|
448
611
|
if (!result) {
|
|
@@ -3,10 +3,23 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Session } from "@minicode/agent-sdk";
|
|
5
5
|
let sessionsDir = path.join(os.homedir(), ".minicode", "sessions");
|
|
6
|
+
export class DuplicateSessionLabelError extends Error {
|
|
7
|
+
label;
|
|
8
|
+
existingSessionId;
|
|
9
|
+
constructor(label, existingSessionId) {
|
|
10
|
+
super(`A saved session named "${label}" already exists. Choose a different name or load that session to update it.`);
|
|
11
|
+
this.label = label;
|
|
12
|
+
this.existingSessionId = existingSessionId;
|
|
13
|
+
this.name = "DuplicateSessionLabelError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
6
16
|
/** Override sessions directory (for testing). */
|
|
7
17
|
export function setSessionsDir(dir) {
|
|
8
18
|
sessionsDir = dir;
|
|
9
19
|
}
|
|
20
|
+
function normalizeSessionLabel(label) {
|
|
21
|
+
return label.trim().toLowerCase();
|
|
22
|
+
}
|
|
10
23
|
export async function saveSession(session, label, annotations) {
|
|
11
24
|
await mkdir(sessionsDir, { recursive: true });
|
|
12
25
|
const savedAt = new Date().toISOString();
|
|
@@ -14,6 +27,11 @@ export async function saveSession(session, label, annotations) {
|
|
|
14
27
|
const resolvedLabel = label && label.trim().length > 0
|
|
15
28
|
? label.trim()
|
|
16
29
|
: new Date().toLocaleString();
|
|
30
|
+
const duplicate = (await listSessions()).find((savedSession) => savedSession.id !== snapshot.id &&
|
|
31
|
+
normalizeSessionLabel(savedSession.label) === normalizeSessionLabel(resolvedLabel));
|
|
32
|
+
if (duplicate) {
|
|
33
|
+
throw new DuplicateSessionLabelError(resolvedLabel, duplicate.id);
|
|
34
|
+
}
|
|
17
35
|
const data = {
|
|
18
36
|
label: resolvedLabel,
|
|
19
37
|
savedAt,
|