@sean.holung/minicode 0.3.7 → 0.3.9

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 (32) hide show
  1. package/dist/src/agent/config.js +25 -0
  2. package/dist/src/model-utils.js +18 -1
  3. package/dist/src/serve/agent-bridge.js +89 -15
  4. package/dist/src/serve/server.js +151 -3
  5. package/dist/src/session/session-store.js +29 -1
  6. package/dist/src/web/app.js +691 -105
  7. package/dist/src/web/index.html +117 -9
  8. package/dist/src/web/style.css +198 -10
  9. package/dist/tests/agent.test.js +16 -0
  10. package/dist/tests/config-integration.test.js +91 -1
  11. package/dist/tests/context-indicator.test.js +9 -0
  12. package/dist/tests/file-tools.test.js +12 -0
  13. package/dist/tests/graph-onboarding.test.js +8 -0
  14. package/dist/tests/model-client-openai.test.js +41 -0
  15. package/dist/tests/model-dropdown-ui.test.js +23 -0
  16. package/dist/tests/model-utils.test.js +26 -1
  17. package/dist/tests/serve.integration.test.js +194 -0
  18. package/dist/tests/session-store.test.js +32 -1
  19. package/dist/tests/session-ui.test.js +6 -0
  20. package/dist/tests/settings-ui.test.js +11 -0
  21. package/dist/tests/setup-overlay-state.test.js +49 -0
  22. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  23. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +10 -1
  24. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  25. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  26. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +21 -0
  27. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  28. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  29. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +60 -6
  30. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  31. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +1 -1
@@ -60,6 +60,31 @@ export function getConfigMissing(config) {
60
60
  }
61
61
  return missing;
62
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
+ }
63
88
  export function getConfigSetupMessage(config) {
64
89
  const missing = getConfigMissing(config);
65
90
  if (missing.length === 0) {
@@ -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
+ }
@@ -8,7 +8,7 @@ import { computeFileHashes, getWorkspaceCacheDir, loadIndex, saveIndex, } from "
8
8
  import { buildProjectIndex } from "../indexer/project-index.js";
9
9
  import { sortModelsAlphabetically } from "../model-utils.js";
10
10
  import { createToolRegistry } from "../tools/registry.js";
11
- import { listSessions, loadSession, loadSessionByLabel, saveSession, } from "../session/session-store.js";
11
+ import { deleteSession, listSessions, loadSession, loadSessionByLabel, saveSession, } from "../session/session-store.js";
12
12
  import { getSymbolDisplayName } from "../indexer/symbol-names.js";
13
13
  export class AgentBridge {
14
14
  agent;
@@ -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") {
247
+ return false;
248
+ }
249
+ return this.disconnectSessionProvider();
250
+ }
251
+ disconnectOpenAiCompatible() {
252
+ if (this.busy) {
253
+ throw new Error("busy");
254
+ }
255
+ if (this.sessionProviderConnection !== "openai-compatible") {
218
256
  return false;
219
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);
@@ -308,10 +349,43 @@ export class AgentBridge {
308
349
  async listSess() {
309
350
  return listSessions();
310
351
  }
352
+ async deleteSess(sessionId) {
353
+ return deleteSession(sessionId);
354
+ }
311
355
  // ── Project index queries ──
312
356
  hasIndex() {
313
357
  return this.projectIndex !== undefined;
314
358
  }
359
+ async refreshIndex() {
360
+ try {
361
+ if (this.projectIndex) {
362
+ await this.projectIndex.refreshFromWorkspace();
363
+ }
364
+ else {
365
+ this.projectIndex = await buildProjectIndex(this.config.workspaceRoot);
366
+ this.installToolRegistry(this.projectIndex);
367
+ if (this.modelClient) {
368
+ this.agent = this.createAgent(this.agent?.getSession());
369
+ }
370
+ }
371
+ const index = this.projectIndex;
372
+ if (!index) {
373
+ return false;
374
+ }
375
+ const cacheDir = getWorkspaceCacheDir(this.config.workspaceRoot);
376
+ const fileHashes = await computeFileHashes(this.config.workspaceRoot);
377
+ await saveIndex(index, cacheDir, fileHashes);
378
+ this.evictStaleAnnotations();
379
+ return true;
380
+ }
381
+ catch (error) {
382
+ if (this.verbose) {
383
+ const message = error instanceof Error ? error.message : String(error);
384
+ console.error(`[index] Refresh failed: ${message}`);
385
+ }
386
+ return false;
387
+ }
388
+ }
315
389
  getSymbols() {
316
390
  if (!this.projectIndex)
317
391
  return [];
@@ -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
@@ -51,6 +52,9 @@ function readBody(req) {
51
52
  req.on("error", reject);
52
53
  });
53
54
  }
55
+ function normalizeBaseUrl(value) {
56
+ return value.trim().replace(/\/+$/, "");
57
+ }
54
58
  async function serveStatic(res, urlPath) {
55
59
  const fileName = urlPath === "/" ? "index.html" : urlPath.slice(1);
56
60
  const filePath = path.join(webDir, fileName);
@@ -107,13 +111,16 @@ export function createRequestHandler(bridge, emit, options = {}) {
107
111
  // Minicode REST API
108
112
  if (pathname === "/api/status" && method === "GET") {
109
113
  const missing = getConfigMissing(config);
114
+ const resolvedEnv = await resolveConfigEnv({ ...(minicodeHome ? { minicodeHome } : {}) });
110
115
  sendJson(res, 200, {
111
116
  status: bridge.isBusy() ? "busy" : "ready",
112
117
  workspace: config.workspaceRoot,
113
118
  model: config.model,
114
119
  provider: config.modelProvider,
115
120
  baseUrl: config.openAiBaseUrl,
121
+ configuredProvider: getConfiguredProvider(config, resolvedEnv.values),
116
122
  sessionOpenRouterConnected: bridge.isOpenRouterSessionConnected(),
123
+ sessionOpenAiCompatibleConnected: bridge.isOpenAiCompatibleSessionConnected(),
117
124
  needsSetup: missing.length > 0,
118
125
  missing,
119
126
  });
@@ -223,6 +230,84 @@ export function createRequestHandler(bridge, emit, options = {}) {
223
230
  });
224
231
  return;
225
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
+ }
226
311
  if (pathname === "/api/openrouter/disconnect" && method === "POST") {
227
312
  let disconnected = false;
228
313
  try {
@@ -250,6 +335,33 @@ export function createRequestHandler(bridge, emit, options = {}) {
250
335
  sendJson(res, 200, body);
251
336
  return;
252
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
+ }
253
365
  if (pathname === "/api/model" && method === "POST") {
254
366
  const body = JSON.parse(await readBody(req));
255
367
  if (!body.model || typeof body.model !== "string") {
@@ -336,8 +448,16 @@ export function createRequestHandler(bridge, emit, options = {}) {
336
448
  }
337
449
  if (pathname === "/api/sessions/save" && method === "POST") {
338
450
  const body = JSON.parse(await readBody(req));
339
- const meta = await bridge.saveSess(body.label);
340
- 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
+ }
341
461
  return;
342
462
  }
343
463
  if (pathname === "/api/sessions/load" && method === "POST") {
@@ -353,6 +473,20 @@ export function createRequestHandler(bridge, emit, options = {}) {
353
473
  });
354
474
  return;
355
475
  }
476
+ if (pathname.startsWith("/api/sessions/") && method === "DELETE") {
477
+ const sessionId = decodeURIComponent(pathname.slice("/api/sessions/".length));
478
+ if (!sessionId) {
479
+ sendJson(res, 400, { error: "Session id is required" });
480
+ return;
481
+ }
482
+ const deleted = await bridge.deleteSess(sessionId);
483
+ if (!deleted) {
484
+ sendJson(res, 404, { error: "Session not found" });
485
+ return;
486
+ }
487
+ sendJson(res, 200, { ok: true, deleted: true, id: sessionId });
488
+ return;
489
+ }
356
490
  // ── Graph / Index API ──
357
491
  if (pathname === "/api/symbols" && method === "GET") {
358
492
  if (!bridge.hasIndex()) {
@@ -472,6 +606,20 @@ export function createRequestHandler(bridge, emit, options = {}) {
472
606
  sendJson(res, 200, result);
473
607
  return;
474
608
  }
609
+ if (pathname === "/api/index/refresh" && method === "POST") {
610
+ const refreshed = await bridge.refreshIndex();
611
+ if (!refreshed) {
612
+ sendJson(res, 404, { error: "No project index available" });
613
+ return;
614
+ }
615
+ const graph = bridge.getGraph();
616
+ sendJson(res, 200, {
617
+ ok: true,
618
+ symbolCount: graph?.nodes.length ?? 0,
619
+ edgeCount: graph?.edges.length ?? 0,
620
+ });
621
+ return;
622
+ }
475
623
  if (pathname === "/api/analysis" && method === "GET") {
476
624
  const result = bridge.getStructuralAnalysis();
477
625
  if (!result) {
@@ -1,12 +1,25 @@
1
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
2
2
  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,
@@ -82,3 +100,13 @@ export async function loadSessionByLabel(label) {
82
100
  return undefined;
83
101
  return loadSession(match.id);
84
102
  }
103
+ export async function deleteSession(sessionId) {
104
+ const filePath = path.join(sessionsDir, `${sessionId}.json`);
105
+ try {
106
+ await unlink(filePath);
107
+ return true;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }