@sean.holung/minicode 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -45
- package/dist/src/agent/config.js +51 -66
- package/dist/src/agent/editable-config.js +50 -58
- package/dist/src/agent/home-env.js +74 -0
- package/dist/src/cli/config-slash-command.js +15 -13
- package/dist/src/serve/agent-bridge.js +87 -28
- package/dist/src/serve/server.js +161 -4
- package/dist/src/session/session-preview.js +14 -0
- package/dist/src/shared/graph-search.js +80 -0
- package/dist/src/shared/graph-selection.js +40 -0
- package/dist/src/web/app.js +494 -56
- package/dist/src/web/index.html +68 -6
- package/dist/src/web/style.css +208 -1
- package/dist/tests/config-api.test.js +5 -5
- package/dist/tests/config-integration.test.js +130 -56
- package/dist/tests/config-slash-command.test.js +12 -11
- package/dist/tests/config.test.js +12 -4
- package/dist/tests/editable-config.test.js +15 -12
- package/dist/tests/graph-onboarding.test.js +10 -1
- package/dist/tests/graph-search.test.js +66 -0
- package/dist/tests/graph-selection.test.js +58 -0
- package/dist/tests/home-env.test.js +56 -0
- package/dist/tests/serve.integration.test.js +229 -6
- package/dist/tests/session-preview.test.js +56 -0
- package/dist/tests/session-ui.test.js +2 -0
- package/dist/tests/settings-ui.test.js +18 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatConfigForDisplay, MINICODE_HOME, resolveConfigEnv } from "../agent/config.js";
|
|
2
|
-
import { formatPersistedConfigValue, getEditableConfigDefinition, getEffectiveEditableConfigValue, isEditableConfigKey, listEditableConfigDefinitions,
|
|
2
|
+
import { buildStructuredConfigPayload, formatPersistedConfigValue, getEditableConfigDefinition, getEffectiveEditableConfigValue, isEditableConfigKey, listEditableConfigDefinitions, setPersistedConfigValue, unsetPersistedConfigValue, } from "../agent/editable-config.js";
|
|
3
3
|
function renderUsage() {
|
|
4
4
|
return [
|
|
5
5
|
'Usage:',
|
|
@@ -12,7 +12,7 @@ function renderUsage() {
|
|
|
12
12
|
}
|
|
13
13
|
function renderEditableKeys() {
|
|
14
14
|
const lines = [
|
|
15
|
-
"Editable config keys (persisted in ~/.minicode
|
|
15
|
+
"Editable config keys (persisted in ~/.minicode/.env; exported shell environment variables take precedence):",
|
|
16
16
|
];
|
|
17
17
|
for (const definition of listEditableConfigDefinitions()) {
|
|
18
18
|
const valueHint = definition.type === "enum"
|
|
@@ -21,7 +21,7 @@ function renderEditableKeys() {
|
|
|
21
21
|
lines.push(` ${definition.key} ${valueHint} — ${definition.description} (env: ${definition.envVar})`);
|
|
22
22
|
}
|
|
23
23
|
lines.push("");
|
|
24
|
-
lines.push('Use "/config set <key> <value>" to update
|
|
24
|
+
lines.push('Use "/config set <key> <value>" to update ~/.minicode/.env.');
|
|
25
25
|
lines.push("Secrets like API keys stay env-only for now.");
|
|
26
26
|
return lines.join("\n");
|
|
27
27
|
}
|
|
@@ -31,14 +31,16 @@ async function renderConfigValue(key, context) {
|
|
|
31
31
|
}
|
|
32
32
|
const minicodeHome = context.minicodeHome ?? MINICODE_HOME;
|
|
33
33
|
const definition = getEditableConfigDefinition(key);
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
34
|
+
const payload = await buildStructuredConfigPayload(context.config, minicodeHome);
|
|
35
|
+
const entry = payload.entries.find((item) => item.key === key);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
return `Unknown editable config key "${key}".\n\n${renderEditableKeys()}`;
|
|
38
|
+
}
|
|
37
39
|
return [
|
|
38
40
|
`${definition.key}`,
|
|
39
41
|
` effective: ${getEffectiveEditableConfigValue(context.config, key)}`,
|
|
40
|
-
`
|
|
41
|
-
` env override (${definition.envVar}): ${formatPersistedConfigValue(envValue)}`,
|
|
42
|
+
` saved in ~/.minicode/.env: ${formatPersistedConfigValue(entry.persistedValue)}`,
|
|
43
|
+
` exported env override (${definition.envVar}): ${formatPersistedConfigValue(entry.envValue)}`,
|
|
42
44
|
].join("\n");
|
|
43
45
|
}
|
|
44
46
|
async function persistConfigValue(key, rawValue, context) {
|
|
@@ -59,8 +61,8 @@ async function persistConfigValue(key, rawValue, context) {
|
|
|
59
61
|
`File: ${result.path}`,
|
|
60
62
|
"Restart minicode to pick up persisted config changes in a new session.",
|
|
61
63
|
];
|
|
62
|
-
if (env.
|
|
63
|
-
lines.push(`Note: ${definition.envVar} is currently
|
|
64
|
+
if (env.sources[definition.envVar] === "process") {
|
|
65
|
+
lines.push(`Note: ${definition.envVar} is currently exported in your shell and will override this saved value until it is unset.`);
|
|
64
66
|
}
|
|
65
67
|
return lines.join("\n");
|
|
66
68
|
}
|
|
@@ -82,11 +84,11 @@ async function removeConfigValue(key, context) {
|
|
|
82
84
|
});
|
|
83
85
|
const lines = [
|
|
84
86
|
`Removed persisted value for "${key}".`,
|
|
85
|
-
`File: ${minicodeHome}
|
|
87
|
+
`File: ${minicodeHome}/.env`,
|
|
86
88
|
"Restart minicode to ensure the updated config is applied in a new session.",
|
|
87
89
|
];
|
|
88
|
-
if (env.
|
|
89
|
-
lines.push(`Note: ${definition.envVar} is still
|
|
90
|
+
if (env.sources[definition.envVar] === "process") {
|
|
91
|
+
lines.push(`Note: ${definition.envVar} is still exported in your shell, so the effective value may remain unchanged.`);
|
|
90
92
|
}
|
|
91
93
|
return lines.join("\n");
|
|
92
94
|
}
|
|
@@ -13,9 +13,11 @@ import { getSymbolDisplayName } from "../indexer/symbol-names.js";
|
|
|
13
13
|
export class AgentBridge {
|
|
14
14
|
agent;
|
|
15
15
|
config;
|
|
16
|
+
baseConfig;
|
|
16
17
|
modelClient;
|
|
17
18
|
projectIndex;
|
|
18
|
-
|
|
19
|
+
toolRegistry;
|
|
20
|
+
sessionOpenRouterConnected = false;
|
|
19
21
|
busy = false;
|
|
20
22
|
abortController = null;
|
|
21
23
|
broadcast;
|
|
@@ -43,9 +45,8 @@ export class AgentBridge {
|
|
|
43
45
|
}
|
|
44
46
|
async init() {
|
|
45
47
|
const config = await loadAgentConfig();
|
|
46
|
-
let modelClient;
|
|
47
48
|
try {
|
|
48
|
-
modelClient = createModelClient(config);
|
|
49
|
+
this.modelClient = createModelClient(config);
|
|
49
50
|
}
|
|
50
51
|
catch {
|
|
51
52
|
// Model client may fail to initialize if API keys are missing.
|
|
@@ -75,28 +76,47 @@ export class AgentBridge {
|
|
|
75
76
|
return this.appendAnnotationsToResult(name, input, result);
|
|
76
77
|
};
|
|
77
78
|
this.config = config;
|
|
79
|
+
this.baseConfig = AgentBridge.cloneConfig(config);
|
|
78
80
|
this.projectIndex = projectIndex;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.
|
|
82
|
-
return new CodingAgent({
|
|
83
|
-
config,
|
|
84
|
-
modelClient,
|
|
85
|
-
toolRegistry,
|
|
86
|
-
verbose: this.verbose,
|
|
87
|
-
...(session ? { session } : {}),
|
|
88
|
-
...(projectIndex !== undefined
|
|
89
|
-
? { getCodeMap: (focusSymbols) => projectIndex.getCodeMap(undefined, focusSymbols) }
|
|
90
|
-
: {}),
|
|
91
|
-
onUiUpdate: onUiUpdate ?? ((event) => {
|
|
92
|
-
this.emit(event);
|
|
93
|
-
}),
|
|
94
|
-
getSystemPromptSuffix: () => this.buildAnnotationSuffix(),
|
|
95
|
-
});
|
|
96
|
-
};
|
|
97
|
-
this.agent = this.buildAgent();
|
|
81
|
+
this.toolRegistry = toolRegistry;
|
|
82
|
+
if (this.modelClient) {
|
|
83
|
+
this.agent = this.createAgent();
|
|
98
84
|
}
|
|
99
85
|
}
|
|
86
|
+
static cloneConfig(config) {
|
|
87
|
+
return {
|
|
88
|
+
...config,
|
|
89
|
+
commandDenylist: [...config.commandDenylist],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
static applyConfig(target, source) {
|
|
93
|
+
const targetRecord = target;
|
|
94
|
+
for (const key of Object.keys(targetRecord)) {
|
|
95
|
+
if (!(key in source)) {
|
|
96
|
+
delete targetRecord[key];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
Object.assign(targetRecord, AgentBridge.cloneConfig(source));
|
|
100
|
+
}
|
|
101
|
+
createAgent(session, onUiUpdate) {
|
|
102
|
+
if (!this.modelClient || !this.toolRegistry) {
|
|
103
|
+
throw new Error("Agent runtime is not initialized.");
|
|
104
|
+
}
|
|
105
|
+
return new CodingAgent({
|
|
106
|
+
config: this.config,
|
|
107
|
+
modelClient: this.modelClient,
|
|
108
|
+
toolRegistry: this.toolRegistry,
|
|
109
|
+
verbose: this.verbose,
|
|
110
|
+
...(session ? { session } : {}),
|
|
111
|
+
...(this.projectIndex !== undefined
|
|
112
|
+
? { getCodeMap: (focusSymbols) => this.projectIndex.getCodeMap(undefined, focusSymbols) }
|
|
113
|
+
: {}),
|
|
114
|
+
onUiUpdate: onUiUpdate ?? ((event) => {
|
|
115
|
+
this.emit(event);
|
|
116
|
+
}),
|
|
117
|
+
getSystemPromptSuffix: () => this.buildAnnotationSuffix(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
100
120
|
// ── File watcher for automatic reindexing ──
|
|
101
121
|
static WATCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
102
122
|
static SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
|
|
@@ -171,6 +191,45 @@ export class AgentBridge {
|
|
|
171
191
|
getConfig() {
|
|
172
192
|
return this.config;
|
|
173
193
|
}
|
|
194
|
+
isOpenRouterSessionConnected() {
|
|
195
|
+
return this.sessionOpenRouterConnected;
|
|
196
|
+
}
|
|
197
|
+
connectOpenRouter(apiKey) {
|
|
198
|
+
const trimmedKey = apiKey.trim();
|
|
199
|
+
if (!trimmedKey) {
|
|
200
|
+
throw new Error("OpenRouter OAuth exchange did not return an API key.");
|
|
201
|
+
}
|
|
202
|
+
if (this.busy) {
|
|
203
|
+
throw new Error("busy");
|
|
204
|
+
}
|
|
205
|
+
const currentSession = this.agent?.getSession();
|
|
206
|
+
this.config.modelProvider = "openai-compatible";
|
|
207
|
+
this.config.openAiBaseUrl = "https://openrouter.ai/api/v1";
|
|
208
|
+
this.config.openAiApiKey = trimmedKey;
|
|
209
|
+
this.sessionOpenRouterConnected = true;
|
|
210
|
+
this.modelClient = createModelClient(this.config);
|
|
211
|
+
this.agent = this.createAgent(currentSession);
|
|
212
|
+
}
|
|
213
|
+
disconnectOpenRouter() {
|
|
214
|
+
if (this.busy) {
|
|
215
|
+
throw new Error("busy");
|
|
216
|
+
}
|
|
217
|
+
if (!this.sessionOpenRouterConnected) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
const currentSession = this.agent?.getSession();
|
|
221
|
+
AgentBridge.applyConfig(this.config, this.baseConfig);
|
|
222
|
+
this.sessionOpenRouterConnected = false;
|
|
223
|
+
try {
|
|
224
|
+
this.modelClient = createModelClient(this.config);
|
|
225
|
+
this.agent = this.createAgent(currentSession);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
this.modelClient = undefined;
|
|
229
|
+
this.agent = undefined;
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
174
233
|
requireAgent() {
|
|
175
234
|
if (!this.agent) {
|
|
176
235
|
throw new Error("Agent is not configured. Set MODEL and provider settings in ~/.minicode/.env");
|
|
@@ -230,13 +289,13 @@ export class AgentBridge {
|
|
|
230
289
|
return saveSession(agent.getSession(), label, annotationsObj);
|
|
231
290
|
}
|
|
232
291
|
async loadSess(label) {
|
|
233
|
-
if (!this.
|
|
292
|
+
if (!this.modelClient) {
|
|
234
293
|
throw new Error("Agent is not configured.");
|
|
235
294
|
}
|
|
236
295
|
const result = (await loadSessionByLabel(label)) ?? (await loadSession(label));
|
|
237
296
|
if (!result)
|
|
238
297
|
return null;
|
|
239
|
-
this.agent = this.
|
|
298
|
+
this.agent = this.createAgent(result.session);
|
|
240
299
|
// Restore annotations from saved session
|
|
241
300
|
this.annotations.clear();
|
|
242
301
|
if (result.annotations) {
|
|
@@ -473,12 +532,12 @@ export class AgentBridge {
|
|
|
473
532
|
async explainSymbol(name, onEvent, signal) {
|
|
474
533
|
if (!this.projectIndex)
|
|
475
534
|
throw new Error("No project index");
|
|
476
|
-
if (!this.
|
|
535
|
+
if (!this.modelClient)
|
|
477
536
|
throw new Error("Agent is not configured.");
|
|
478
537
|
const sym = this.projectIndex.getSymbol(name);
|
|
479
538
|
if (!sym)
|
|
480
539
|
throw new Error(`Symbol "${name}" not found`);
|
|
481
|
-
const explainAgent = this.
|
|
540
|
+
const explainAgent = this.createAgent(undefined, onEvent);
|
|
482
541
|
const prompt = `Explain "${sym.name}" (${sym.kind} in ${sym.filePath}).
|
|
483
542
|
Use read_symbol, get_dependencies, find_references to gather context.
|
|
484
543
|
Explain what it does, how it works, what depends on it, and key design decisions.
|
|
@@ -491,12 +550,12 @@ Be concise but thorough.`;
|
|
|
491
550
|
const report = this.getStructuralAnalysis();
|
|
492
551
|
if (!report)
|
|
493
552
|
throw new Error("No project index");
|
|
494
|
-
if (!this.
|
|
553
|
+
if (!this.modelClient)
|
|
495
554
|
throw new Error("Agent is not configured.");
|
|
496
555
|
const finding = report.findings.find((item) => item.id === findingId);
|
|
497
556
|
if (!finding)
|
|
498
557
|
throw new Error(`Structural finding "${findingId}" not found`);
|
|
499
|
-
const explainAgent = this.
|
|
558
|
+
const explainAgent = this.createAgent(undefined, onEvent);
|
|
500
559
|
const affectedSymbols = finding.symbols.length > 0
|
|
501
560
|
? finding.symbols.slice(0, 8).join(", ")
|
|
502
561
|
: "(none)";
|
package/dist/src/serve/server.js
CHANGED
|
@@ -8,9 +8,11 @@ import { createWebSocketServer } from "./websocket.js";
|
|
|
8
8
|
import { handleChatCompletions, handleModels } from "./openai-compat.js";
|
|
9
9
|
import { formatConfigForDisplay, getConfigMissing } from "../agent/config.js";
|
|
10
10
|
import { applyPersistedConfigUpdates, buildStructuredConfigPayload } from "../agent/editable-config.js";
|
|
11
|
+
import { getHomeEnvPath, upsertHomeEnvValues } from "../agent/home-env.js";
|
|
11
12
|
import { sortModelsAlphabetically } from "../model-utils.js";
|
|
12
13
|
import { serializeSymbolMatch } from "../shared/symbol-resolution.js";
|
|
13
14
|
import { handleMcpRequest } from "./mcp-server.js";
|
|
15
|
+
import { buildSessionPreview } from "../session/session-preview.js";
|
|
14
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
17
|
// Resolve web dir: always serve from dist/src/web (built by scripts/build-web.mjs)
|
|
16
18
|
// In dev (tsx): __dirname = src/serve → go up to project root, then dist/src/web
|
|
@@ -49,7 +51,12 @@ async function serveStatic(res, urlPath) {
|
|
|
49
51
|
const content = await readFile(filePath);
|
|
50
52
|
const ext = path.extname(filePath);
|
|
51
53
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
52
|
-
res.writeHead(200, {
|
|
54
|
+
res.writeHead(200, {
|
|
55
|
+
"Content-Type": contentType,
|
|
56
|
+
// This local UI changes often during development. Avoid stale browser bundles
|
|
57
|
+
// causing the app to run an older client against a newer server.
|
|
58
|
+
"Cache-Control": "no-store",
|
|
59
|
+
});
|
|
53
60
|
res.end(content);
|
|
54
61
|
}
|
|
55
62
|
catch {
|
|
@@ -62,7 +69,6 @@ async function buildWebSettingsPayload(config, minicodeHome) {
|
|
|
62
69
|
}
|
|
63
70
|
/** Create the HTTP request handler. Exported for testing. */
|
|
64
71
|
export function createRequestHandler(bridge, emit, options = {}) {
|
|
65
|
-
const config = bridge.getConfig();
|
|
66
72
|
const emitFn = emit ?? (() => { });
|
|
67
73
|
const minicodeHome = options.minicodeHome;
|
|
68
74
|
return (req, res) => {
|
|
@@ -70,6 +76,7 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
70
76
|
const method = req.method ?? "GET";
|
|
71
77
|
const pathname = url.pathname;
|
|
72
78
|
const handle = async () => {
|
|
79
|
+
const config = bridge.getConfig();
|
|
73
80
|
// MCP (Model Context Protocol) endpoint
|
|
74
81
|
if (pathname === "/mcp") {
|
|
75
82
|
await handleMcpRequest(req, res, bridge, emitFn);
|
|
@@ -92,6 +99,8 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
92
99
|
workspace: config.workspaceRoot,
|
|
93
100
|
model: config.model,
|
|
94
101
|
provider: config.modelProvider,
|
|
102
|
+
baseUrl: config.openAiBaseUrl,
|
|
103
|
+
sessionOpenRouterConnected: bridge.isOpenRouterSessionConnected(),
|
|
95
104
|
needsSetup: missing.length > 0,
|
|
96
105
|
missing,
|
|
97
106
|
});
|
|
@@ -102,6 +111,132 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
102
111
|
sendJson(res, 200, { models, activeModel: config.model });
|
|
103
112
|
return;
|
|
104
113
|
}
|
|
114
|
+
if (pathname === "/api/openrouter/connect" && method === "POST") {
|
|
115
|
+
const body = JSON.parse(await readBody(req));
|
|
116
|
+
if (!body.code || typeof body.code !== "string") {
|
|
117
|
+
sendJson(res, 400, { error: "code is required" });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!body.codeVerifier || typeof body.codeVerifier !== "string") {
|
|
121
|
+
sendJson(res, 400, { error: "codeVerifier is required" });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let exchangeResponse;
|
|
125
|
+
try {
|
|
126
|
+
exchangeResponse = await fetch("https://openrouter.ai/api/v1/auth/keys", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
code: body.code,
|
|
133
|
+
code_verifier: body.codeVerifier,
|
|
134
|
+
code_challenge_method: "S256",
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const message = error instanceof Error ? error.message : "OpenRouter OAuth exchange failed";
|
|
140
|
+
sendJson(res, 502, { error: message });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!exchangeResponse.ok) {
|
|
144
|
+
const message = await exchangeResponse.text();
|
|
145
|
+
sendJson(res, exchangeResponse.status, {
|
|
146
|
+
error: message.trim().length > 0
|
|
147
|
+
? `OpenRouter OAuth exchange failed: ${message}`
|
|
148
|
+
: `OpenRouter OAuth exchange failed (${exchangeResponse.status})`,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const payload = await exchangeResponse.json();
|
|
153
|
+
if (!payload.key || typeof payload.key !== "string") {
|
|
154
|
+
sendJson(res, 502, { error: "OpenRouter OAuth exchange did not return an API key." });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
bridge.connectOpenRouter(payload.key);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
const message = error instanceof Error ? error.message : "Failed to configure OpenRouter";
|
|
162
|
+
sendJson(res, message === "busy" ? 409 : 400, { error: message });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
let persistedToEnv = false;
|
|
166
|
+
let persistedEnvPath = null;
|
|
167
|
+
let persistWarning = null;
|
|
168
|
+
if (body.persistToEnv === true) {
|
|
169
|
+
try {
|
|
170
|
+
const result = await upsertHomeEnvValues({
|
|
171
|
+
values: {
|
|
172
|
+
MODEL_PROVIDER: "openai-compatible",
|
|
173
|
+
OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
|
|
174
|
+
OPENROUTER_API_KEY: payload.key,
|
|
175
|
+
},
|
|
176
|
+
...(minicodeHome ? { minicodeHome } : {}),
|
|
177
|
+
});
|
|
178
|
+
persistedToEnv = true;
|
|
179
|
+
persistedEnvPath = result.path;
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
const message = error instanceof Error ? error.message : "Failed to update ~/.minicode/.env";
|
|
183
|
+
persistedEnvPath = getHomeEnvPath(minicodeHome);
|
|
184
|
+
persistWarning = `OpenRouter connected for this serve session, but minicode could not update ${persistedEnvPath}: ${message}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const missing = getConfigMissing(config);
|
|
188
|
+
const onlyModelMissing = missing.length === 1 && missing[0] === "MODEL is not set";
|
|
189
|
+
const message = persistWarning
|
|
190
|
+
? `${persistWarning}${onlyModelMissing ? " Select a model to continue." : ""}`
|
|
191
|
+
: persistedToEnv
|
|
192
|
+
? (onlyModelMissing
|
|
193
|
+
? "OpenRouter connected for this serve session and saved to ~/.minicode/.env. Select a model to continue, and minicode will remember it for future runs."
|
|
194
|
+
: "OpenRouter connected for this serve session and saved to ~/.minicode/.env for future runs.")
|
|
195
|
+
: (onlyModelMissing
|
|
196
|
+
? "OpenRouter connected for this serve session. Select a model to continue."
|
|
197
|
+
: "OpenRouter connected for this serve session.");
|
|
198
|
+
sendJson(res, 200, {
|
|
199
|
+
ok: true,
|
|
200
|
+
sessionOnly: true,
|
|
201
|
+
persistedToEnv,
|
|
202
|
+
persistedEnvPath,
|
|
203
|
+
persistWarning,
|
|
204
|
+
provider: config.modelProvider,
|
|
205
|
+
model: config.model,
|
|
206
|
+
baseUrl: config.openAiBaseUrl,
|
|
207
|
+
needsSetup: missing.length > 0,
|
|
208
|
+
missing,
|
|
209
|
+
message,
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (pathname === "/api/openrouter/disconnect" && method === "POST") {
|
|
214
|
+
let disconnected = false;
|
|
215
|
+
try {
|
|
216
|
+
disconnected = bridge.disconnectOpenRouter();
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
const message = error instanceof Error ? error.message : "Failed to remove OpenRouter session";
|
|
220
|
+
sendJson(res, message === "busy" ? 409 : 400, { error: message });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const missing = getConfigMissing(config);
|
|
224
|
+
const body = {
|
|
225
|
+
ok: true,
|
|
226
|
+
disconnected,
|
|
227
|
+
sessionOnly: true,
|
|
228
|
+
provider: config.modelProvider,
|
|
229
|
+
model: config.model,
|
|
230
|
+
baseUrl: config.openAiBaseUrl,
|
|
231
|
+
needsSetup: missing.length > 0,
|
|
232
|
+
missing,
|
|
233
|
+
message: disconnected
|
|
234
|
+
? "Removed the session-only OpenRouter connection and restored your original provider settings."
|
|
235
|
+
: "No session-only OpenRouter connection was active.",
|
|
236
|
+
};
|
|
237
|
+
sendJson(res, 200, body);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
105
240
|
if (pathname === "/api/model" && method === "POST") {
|
|
106
241
|
const body = JSON.parse(await readBody(req));
|
|
107
242
|
if (!body.model || typeof body.model !== "string") {
|
|
@@ -109,7 +244,26 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
109
244
|
return;
|
|
110
245
|
}
|
|
111
246
|
bridge.switchModel(body.model);
|
|
112
|
-
|
|
247
|
+
let persistedToEnv = false;
|
|
248
|
+
let persistedEnvPath = null;
|
|
249
|
+
let message;
|
|
250
|
+
try {
|
|
251
|
+
const result = await upsertHomeEnvValues({
|
|
252
|
+
values: {
|
|
253
|
+
MODEL: body.model,
|
|
254
|
+
},
|
|
255
|
+
...(minicodeHome ? { minicodeHome } : {}),
|
|
256
|
+
});
|
|
257
|
+
persistedToEnv = true;
|
|
258
|
+
persistedEnvPath = result.path;
|
|
259
|
+
message = `Saved MODEL=${body.model} to ~/.minicode/.env.`;
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const persistMessage = error instanceof Error ? error.message : "Failed to update ~/.minicode/.env";
|
|
263
|
+
sendJson(res, 500, { error: persistMessage });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
sendJson(res, 200, { model: body.model, persistedToEnv, persistedEnvPath, message });
|
|
113
267
|
return;
|
|
114
268
|
}
|
|
115
269
|
if (pathname === "/api/context" && method === "GET") {
|
|
@@ -180,7 +334,10 @@ export function createRequestHandler(bridge, emit, options = {}) {
|
|
|
180
334
|
sendJson(res, 404, { error: "Session not found" });
|
|
181
335
|
return;
|
|
182
336
|
}
|
|
183
|
-
sendJson(res, 200, {
|
|
337
|
+
sendJson(res, 200, {
|
|
338
|
+
label: result.label,
|
|
339
|
+
messages: buildSessionPreview(result.session.getMessages()),
|
|
340
|
+
});
|
|
184
341
|
return;
|
|
185
342
|
}
|
|
186
343
|
// ── Graph / Index API ──
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const DEFAULT_SESSION_PREVIEW_LIMIT = 10;
|
|
2
|
+
const COMPACTION_SUMMARY_PREFIX = "[Conversation Summary";
|
|
3
|
+
export function isCompactionSummaryMessage(message) {
|
|
4
|
+
return (message.role === "user" &&
|
|
5
|
+
message.content.startsWith(COMPACTION_SUMMARY_PREFIX));
|
|
6
|
+
}
|
|
7
|
+
export function buildSessionPreview(messages, limit = DEFAULT_SESSION_PREVIEW_LIMIT) {
|
|
8
|
+
if (limit <= 0) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
return messages
|
|
12
|
+
.filter((message) => !isCompactionSummaryMessage(message))
|
|
13
|
+
.slice(-limit);
|
|
14
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { compareGraphNodeIds, getGraphNodeLabel, matchesGraphNodeQuery, } from "./graph-symbols.js";
|
|
2
|
+
export function getGraphNodeFilePath(node) {
|
|
3
|
+
return node.filePath || node.file || "";
|
|
4
|
+
}
|
|
5
|
+
export function buildGraphFileIndex(nodes) {
|
|
6
|
+
const files = new Map();
|
|
7
|
+
for (const [id, node] of nodes) {
|
|
8
|
+
const filePath = getGraphNodeFilePath(node);
|
|
9
|
+
if (!filePath)
|
|
10
|
+
continue;
|
|
11
|
+
const existing = files.get(filePath);
|
|
12
|
+
if (existing) {
|
|
13
|
+
existing.push(id);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
files.set(filePath, [id]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for (const symbolIds of files.values()) {
|
|
20
|
+
symbolIds.sort((a, b) => compareGraphNodeIds(a, b, nodes));
|
|
21
|
+
}
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
export function compareGraphFilePaths(a, b, fileIndex) {
|
|
25
|
+
const countDifference = (fileIndex.get(b)?.length ?? 0) - (fileIndex.get(a)?.length ?? 0);
|
|
26
|
+
if (countDifference !== 0) {
|
|
27
|
+
return countDifference;
|
|
28
|
+
}
|
|
29
|
+
return a.localeCompare(b, undefined, { sensitivity: "base" });
|
|
30
|
+
}
|
|
31
|
+
export function matchesGraphFileQuery(query, filePath) {
|
|
32
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
33
|
+
if (normalizedQuery.length === 0) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return filePath.toLowerCase().includes(normalizedQuery);
|
|
37
|
+
}
|
|
38
|
+
export function buildGraphSearchResults({ query, symbolIds, nodes, fileIndex, symbolLimit = 12, fileLimit = 8, }) {
|
|
39
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
40
|
+
const showDefaultResults = normalizedQuery.length < 2;
|
|
41
|
+
const rankedFiles = [...fileIndex.keys()].sort((a, b) => compareGraphFilePaths(a, b, fileIndex));
|
|
42
|
+
const symbolResults = symbolIds
|
|
43
|
+
.filter((id) => {
|
|
44
|
+
if (showDefaultResults) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return matchesGraphNodeQuery(normalizedQuery, nodes.get(id) || {}, id);
|
|
48
|
+
})
|
|
49
|
+
.slice(0, symbolLimit)
|
|
50
|
+
.map((id) => {
|
|
51
|
+
const node = nodes.get(id) || {};
|
|
52
|
+
return {
|
|
53
|
+
type: "symbol",
|
|
54
|
+
id,
|
|
55
|
+
label: getGraphNodeLabel(node, id),
|
|
56
|
+
subtitle: getGraphNodeFilePath(node),
|
|
57
|
+
kind: (node.kind || "symbol").toLowerCase(),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const fileResults = rankedFiles
|
|
61
|
+
.filter((filePath) => {
|
|
62
|
+
if (showDefaultResults) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return matchesGraphFileQuery(normalizedQuery, filePath);
|
|
66
|
+
})
|
|
67
|
+
.slice(0, fileLimit)
|
|
68
|
+
.map((filePath) => {
|
|
69
|
+
const symbolCount = fileIndex.get(filePath)?.length ?? 0;
|
|
70
|
+
return {
|
|
71
|
+
type: "file",
|
|
72
|
+
id: filePath,
|
|
73
|
+
label: filePath,
|
|
74
|
+
subtitle: `${symbolCount} symbol${symbolCount === 1 ? "" : "s"}`,
|
|
75
|
+
kind: "file",
|
|
76
|
+
symbolCount,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
return [...symbolResults, ...fileResults];
|
|
80
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function buildGraphEdgeId(edge) {
|
|
2
|
+
return `${edge.source}->${edge.target}:${edge.kind}`;
|
|
3
|
+
}
|
|
4
|
+
export function buildGraphEdgeIndex(edges) {
|
|
5
|
+
const edgeIndex = new Map();
|
|
6
|
+
for (const edge of edges) {
|
|
7
|
+
const sourceEdges = edgeIndex.get(edge.source);
|
|
8
|
+
if (sourceEdges) {
|
|
9
|
+
sourceEdges.push(edge);
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
edgeIndex.set(edge.source, [edge]);
|
|
13
|
+
}
|
|
14
|
+
const targetEdges = edgeIndex.get(edge.target);
|
|
15
|
+
if (targetEdges) {
|
|
16
|
+
targetEdges.push(edge);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
edgeIndex.set(edge.target, [edge]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return edgeIndex;
|
|
23
|
+
}
|
|
24
|
+
export function buildFileFocusedSelection({ filePath, fileIndex, edgeIndex, }) {
|
|
25
|
+
const fileSymbolIds = fileIndex.get(filePath) || [];
|
|
26
|
+
const nodeIds = new Set();
|
|
27
|
+
const edges = new Map();
|
|
28
|
+
for (const symbolId of fileSymbolIds) {
|
|
29
|
+
nodeIds.add(symbolId);
|
|
30
|
+
for (const edge of edgeIndex.get(symbolId) || []) {
|
|
31
|
+
nodeIds.add(edge.source);
|
|
32
|
+
nodeIds.add(edge.target);
|
|
33
|
+
edges.set(buildGraphEdgeId(edge), edge);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
nodeIds: [...nodeIds],
|
|
38
|
+
edges: [...edges.values()],
|
|
39
|
+
};
|
|
40
|
+
}
|