@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.
Files changed (47) hide show
  1. package/README.md +22 -45
  2. package/dist/scripts/run-benchmarks.js +1 -0
  3. package/dist/src/agent/config.js +53 -66
  4. package/dist/src/agent/editable-config.js +56 -58
  5. package/dist/src/agent/home-env.js +74 -0
  6. package/dist/src/cli/config-slash-command.js +15 -13
  7. package/dist/src/serve/agent-bridge.js +87 -28
  8. package/dist/src/serve/mcp-server.js +19 -13
  9. package/dist/src/serve/server.js +190 -4
  10. package/dist/src/session/session-preview.js +14 -0
  11. package/dist/src/shared/graph-search.js +80 -0
  12. package/dist/src/shared/graph-selection.js +40 -0
  13. package/dist/src/shared/symbol-search.js +156 -0
  14. package/dist/src/tools/search-code-map.js +27 -35
  15. package/dist/src/web/app.js +582 -64
  16. package/dist/src/web/index.html +84 -6
  17. package/dist/src/web/style.css +256 -1
  18. package/dist/tests/config-api.test.js +10 -5
  19. package/dist/tests/config-integration.test.js +130 -56
  20. package/dist/tests/config-slash-command.test.js +12 -11
  21. package/dist/tests/config.test.js +21 -4
  22. package/dist/tests/editable-config.test.js +15 -12
  23. package/dist/tests/graph-onboarding.test.js +22 -1
  24. package/dist/tests/graph-search.test.js +66 -0
  25. package/dist/tests/graph-selection.test.js +58 -0
  26. package/dist/tests/home-env.test.js +56 -0
  27. package/dist/tests/mcp-and-plugin.test.js +3 -0
  28. package/dist/tests/search-code-map.test.js +9 -0
  29. package/dist/tests/serve.integration.test.js +255 -6
  30. package/dist/tests/session-preview.test.js +56 -0
  31. package/dist/tests/session-ui.test.js +2 -0
  32. package/dist/tests/settings-ui.test.js +18 -0
  33. package/dist/tests/system-prompt.test.js +1 -0
  34. package/dist/tests/test-utils.js +1 -0
  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 +143 -27
  40. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  41. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js +87 -0
  42. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js.map +1 -1
  43. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.d.ts.map +1 -1
  44. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js +1 -0
  45. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js.map +1 -1
  46. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  47. 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, loadPersistedConfig, setPersistedConfigValue, unsetPersistedConfigValue, } from "../agent/editable-config.js";
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/agent.config.json; environment variables take precedence):",
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 your global config.');
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 persisted = await loadPersistedConfig(minicodeHome);
35
- const env = await resolveConfigEnv({ minicodeHome });
36
- const envValue = env.values[definition.envVar];
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
- ` config file: ${formatPersistedConfigValue(persisted[definition.fileKey])}`,
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.values[definition.envVar] !== undefined) {
63
- lines.push(`Note: ${definition.envVar} is currently set and will override this persisted value until it is unset.`);
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}/agent.config.json`,
87
+ `File: ${minicodeHome}/.env`,
86
88
  "Restart minicode to ensure the updated config is applied in a new session.",
87
89
  ];
88
- if (env.values[definition.envVar] !== undefined) {
89
- lines.push(`Note: ${definition.envVar} is still set in the environment, so the effective value may remain unchanged.`);
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
- buildAgent;
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
- if (modelClient) {
80
- this.modelClient = modelClient;
81
- this.buildAgent = (session, onUiUpdate) => {
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.buildAgent) {
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.buildAgent(result.session);
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.buildAgent)
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.buildAgent(undefined, onEvent);
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.buildAgent)
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.buildAgent(undefined, onEvent);
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 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") }] };
@@ -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, { "Content-Type": contentType });
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
- sendJson(res, 200, { model: body.model });
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, { label: result.label });
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
+ }