@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.
Files changed (50) hide show
  1. package/README.md +2 -1
  2. package/dist/scripts/run-benchmarks.js +1 -0
  3. package/dist/src/agent/config.js +27 -0
  4. package/dist/src/agent/editable-config.js +6 -0
  5. package/dist/src/model-utils.js +18 -1
  6. package/dist/src/serve/agent-bridge.js +85 -14
  7. package/dist/src/serve/mcp-server.js +19 -13
  8. package/dist/src/serve/server.js +166 -3
  9. package/dist/src/session/session-store.js +18 -0
  10. package/dist/src/shared/symbol-search.js +156 -0
  11. package/dist/src/tools/search-code-map.js +27 -35
  12. package/dist/src/web/app.js +662 -113
  13. package/dist/src/web/index.html +128 -8
  14. package/dist/src/web/style.css +189 -7
  15. package/dist/tests/agent.test.js +16 -0
  16. package/dist/tests/config-api.test.js +5 -0
  17. package/dist/tests/config-integration.test.js +91 -1
  18. package/dist/tests/config.test.js +9 -0
  19. package/dist/tests/file-tools.test.js +12 -0
  20. package/dist/tests/graph-onboarding.test.js +20 -0
  21. package/dist/tests/mcp-and-plugin.test.js +3 -0
  22. package/dist/tests/model-client-openai.test.js +41 -0
  23. package/dist/tests/model-dropdown-ui.test.js +23 -0
  24. package/dist/tests/model-utils.test.js +26 -1
  25. package/dist/tests/search-code-map.test.js +9 -0
  26. package/dist/tests/serve.integration.test.js +189 -0
  27. package/dist/tests/session-store.test.js +15 -1
  28. package/dist/tests/settings-ui.test.js +11 -0
  29. package/dist/tests/setup-overlay-state.test.js +49 -0
  30. package/dist/tests/system-prompt.test.js +1 -0
  31. package/dist/tests/test-utils.js +1 -0
  32. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  33. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +10 -1
  34. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  35. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +1 -0
  36. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  37. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +8 -1
  38. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  39. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +164 -27
  40. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  41. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  42. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +60 -6
  43. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  44. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js +87 -0
  45. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js.map +1 -1
  46. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.d.ts.map +1 -1
  47. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js +1 -0
  48. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js.map +1 -1
  49. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  50. 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),
@@ -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",
@@ -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
- sessionOpenRouterConnected = false;
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.toolRegistry = toolRegistry;
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 ignore
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.sessionOpenRouterConnected;
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.sessionOpenRouterConnected = true;
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 (!this.sessionOpenRouterConnected) {
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.sessionOpenRouterConnected = false;
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 symbols = bridge.getSymbols();
161
- const queryLower = query.toLowerCase();
162
- let matches = symbols.filter((s) => {
163
- const nameMatch = s.name.toLowerCase().includes(queryLower) ||
164
- s.qualifiedName.toLowerCase().includes(queryLower);
165
- return nameMatch;
166
- });
167
- if (kind) {
168
- matches = matches.filter((s) => s.kind === kind);
169
- }
170
- matches = matches.slice(0, 30);
171
- if (matches.length === 0) {
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
- `Found ${matches.length} symbol(s) matching "${query}":`,
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") }] };
@@ -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
- const meta = await bridge.saveSess(body.label);
327
- sendJson(res, 200, meta);
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,