toolnexus 0.21.0 → 0.22.0

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/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from "./adapters.js"
14
14
  export * from "./translate.js"
15
15
  export * from "./toolkit.js"
16
16
  export * from "./client.js"
17
+ export * from "./store.js"
17
18
  export * from "./classifier.js"
18
19
  export * as agents from "./agents/index.js"
19
20
  // Simple judgments (§8B, add-judge-adapters). `judge.noul/choice/score` are the NAMED builders
@@ -42,3 +43,7 @@ export {
42
43
  type GateOutcome,
43
44
  type Policy,
44
45
  } from "./judge.js"
46
+ export * from "./promptcache.js"
47
+ export * from "./tooltiers.js"
48
+ export * from "./models.js"
49
+ export * from "./builtin_extras.js"
package/src/mcp.ts CHANGED
@@ -10,7 +10,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
10
10
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
11
11
  import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
12
12
  import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
13
- import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
13
+ import { type JSONSchema, type Tool, type ToolContext, type ToolHints, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
14
14
  import { type ContentPart, summarizePart } from "./content.js"
15
15
 
16
16
  // ---------------------------------------------------------------------------
@@ -88,12 +88,38 @@ function isEnabled(cfg: ServerConfig): boolean {
88
88
  export function parseMcpConfig(input: string | object): McpConfig {
89
89
  const raw: any = typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input
90
90
  const wrapped = raw.mcpServers ?? raw.servers ?? raw.mcp
91
- if (wrapped !== undefined) return wrapped as McpConfig
91
+ if (wrapped !== undefined) return normalizeServers(wrapped)
92
92
  // Bare map of servers — but strip sibling top-level config keys (builtins/
93
93
  // agents/a2a/mcpServer) so they are not mistaken for MCP servers when no
94
94
  // wrapper key is present.
95
95
  const { builtins: _builtins, agents: _agents, a2a: _a2a, mcpServer: _mcpServer, ...servers } = raw
96
- return servers as McpConfig
96
+ return normalizeServers(servers)
97
+ }
98
+
99
+ /**
100
+ * §2 `command` + `args`. A local server's `command` may be a non-empty string or a non-empty
101
+ * string array; an optional `args` string array is appended. The result is always the argv form
102
+ * (`command: string[]`, no `args`). Spawned with no shell. A remote server ignores `args`.
103
+ */
104
+ function normalizeServers(servers: Record<string, any>): McpConfig {
105
+ const out: McpConfig = {}
106
+ for (const [name, cfg] of Object.entries(servers ?? {})) {
107
+ if (cfg === null || typeof cfg !== "object" || isRemote(cfg as ServerConfig)) {
108
+ out[name] = cfg
109
+ continue
110
+ }
111
+ const { args, command, ...rest } = cfg as any
112
+ const head: unknown[] | undefined =
113
+ typeof command === "string" ? [command] : Array.isArray(command) ? command : undefined
114
+ if (!head || head.length === 0 || head[0] === "" || head.some((c) => typeof c !== "string")) {
115
+ throw new Error(`mcp server "${name}": invalid command`)
116
+ }
117
+ if (args !== undefined && (!Array.isArray(args) || args.some((a: unknown) => typeof a !== "string"))) {
118
+ throw new Error(`mcp server "${name}": invalid args`)
119
+ }
120
+ out[name] = { ...rest, command: [...(head as string[]), ...((args as string[] | undefined) ?? [])] }
121
+ }
122
+ return out
97
123
  }
98
124
 
99
125
  /**
@@ -240,12 +266,16 @@ function convertTool(server: string, def: McpToolDef, client: Client, timeout: n
240
266
  description: def.description ?? "",
241
267
  inputSchema,
242
268
  source: "mcp",
269
+ ...(mcpHints((def as any).annotations) ? { hints: mcpHints((def as any).annotations) } : {}),
243
270
  async execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
244
271
  try {
245
272
  const result: any = await client.callTool(
246
273
  { name: def.name, arguments: args ?? {} },
247
274
  undefined,
248
- { resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
275
+ // The SDK only attaches `_meta.progressToken` when `onprogress` is supplied; without it
276
+ // `resetTimeoutOnProgress` never fires (add-mcp-progress-timeout). Notifications for a
277
+ // token that matches no in-flight request are dropped by the SDK, so they never reset.
278
+ { onprogress: () => {}, resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
249
279
  )
250
280
  // Parts are collected BEFORE any branch returns: the isError and structuredContent
251
281
  // short-circuits both read `content[]` on the way past, so neither reintroduces the
@@ -271,6 +301,129 @@ export interface McpSource {
271
301
  tools: Tool[]
272
302
  status: Record<string, McpStatus>
273
303
  close(): Promise<void>
304
+ /**
305
+ * `list_mcp_resources` and `read_mcp_resource` bound to this source's connected servers
306
+ * (SPEC §4C). Both read-only. Opt-in: register them on a toolkit. They stop working once the
307
+ * source is closed.
308
+ */
309
+ resourceTools(): Tool[]
310
+ }
311
+
312
+ /** One server's outcome, reported by `loadMcp`'s `onServer` the moment that server finishes. */
313
+ export interface McpServerResult {
314
+ name: string
315
+ status: McpStatus
316
+ /** This server's converted tools (empty unless connected). Usable until the source closes. */
317
+ tools: Tool[]
318
+ /** Why the server failed (absent when connected or disabled). */
319
+ error?: Error
320
+ }
321
+
322
+ /**
323
+ * MCP tool annotations → ToolHints (SPEC §4C). No hint fields at all ⇒ undefined (unknown).
324
+ * Defaults follow the MCP spec: readOnlyHint false, destructiveHint and openWorldHint true.
325
+ */
326
+ export function mcpHints(a: { readOnlyHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean } | undefined): ToolHints | undefined {
327
+ if (!a || (a.readOnlyHint === undefined && a.destructiveHint === undefined && a.openWorldHint === undefined)) return undefined
328
+ const h: ToolHints = { readOnly: a.readOnlyHint ?? false, destructive: a.destructiveHint ?? true, openWorld: a.openWorldHint ?? true }
329
+ if (h.readOnly) h.destructive = false
330
+ return h
331
+ }
332
+
333
+ interface ServerConn {
334
+ client: Client
335
+ timeout: number
336
+ }
337
+
338
+ function resourceTools(servers: Map<string, ServerConn>): Tool[] {
339
+ const ro: ToolHints = { readOnly: true, destructive: false, openWorld: false }
340
+ const names = () => [...servers.keys()].sort()
341
+ const conn = (server: string): ServerConn => {
342
+ const c = servers.get(server)
343
+ if (!c) throw new Error(`no connected MCP server "${server}" (connected: ${names().join(", ")})`)
344
+ return c
345
+ }
346
+ const list: Tool = {
347
+ name: "list_mcp_resources",
348
+ description: "List the resources MCP servers offer (uri, name, description, mimeType). Optionally for one server.",
349
+ inputSchema: {
350
+ type: "object",
351
+ properties: { server: { type: "string", description: "Only this server (default: all)" } },
352
+ additionalProperties: false,
353
+ },
354
+ source: "mcp",
355
+ hints: ro,
356
+ async execute(args, ctx) {
357
+ let targets = names()
358
+ const s = typeof args?.server === "string" ? args.server : ""
359
+ if (s) {
360
+ try {
361
+ conn(s)
362
+ } catch (e) {
363
+ return { output: `list_mcp_resources: ${(e as Error).message}`, isError: true }
364
+ }
365
+ targets = [s]
366
+ }
367
+ const lines: string[] = []
368
+ const items: unknown[] = []
369
+ for (const name of targets) {
370
+ const c = conn(name)
371
+ try {
372
+ const res: any = await c.client.listResources({}, { timeout: c.timeout, signal: ctx?.signal })
373
+ for (const r of res.resources ?? []) {
374
+ let line = `${name} ${r.uri}`
375
+ if (r.name) line += ` — ${r.name}`
376
+ if (r.mimeType) line += ` (${r.mimeType})`
377
+ if (r.description) line += `: ${r.description}`
378
+ lines.push(line)
379
+ items.push({ server: name, uri: r.uri, name: r.name ?? "", mimeType: r.mimeType ?? "", description: r.description ?? "" })
380
+ }
381
+ } catch (e) {
382
+ lines.push(`${name}: error: ${e instanceof Error ? e.message : String(e)}`)
383
+ }
384
+ }
385
+ if (!lines.length) return { output: "No resources.", isError: false, metadata: { count: 0 } }
386
+ return { output: lines.join("\n"), isError: false, metadata: { count: items.length, resources: items } }
387
+ },
388
+ }
389
+ const read: Tool = {
390
+ name: "read_mcp_resource",
391
+ description: "Read one MCP resource by server and uri. Text comes back as text; binary content is described, not inlined.",
392
+ inputSchema: {
393
+ type: "object",
394
+ properties: {
395
+ server: { type: "string", description: "The MCP server name" },
396
+ uri: { type: "string", description: "The resource uri" },
397
+ },
398
+ required: ["server", "uri"],
399
+ additionalProperties: false,
400
+ },
401
+ source: "mcp",
402
+ hints: ro,
403
+ async execute(args, ctx) {
404
+ const server = typeof args?.server === "string" ? args.server : ""
405
+ const uri = typeof args?.uri === "string" ? args.uri : ""
406
+ let c: ServerConn
407
+ try {
408
+ c = conn(server)
409
+ } catch (e) {
410
+ return { output: `read_mcp_resource: ${(e as Error).message}`, isError: true }
411
+ }
412
+ if (!uri) return { output: "read_mcp_resource: uri is required", isError: true }
413
+ try {
414
+ const res: any = await c.client.readResource({ uri }, { timeout: c.timeout, signal: ctx?.signal })
415
+ const parts: string[] = []
416
+ for (const rc of res.contents ?? []) {
417
+ if (typeof rc.text === "string") parts.push(rc.text)
418
+ else if (typeof rc.blob === "string") parts.push(`[binary ${rc.mimeType ?? ""}, ${rc.blob.length} base64 chars]`)
419
+ }
420
+ return { output: parts.join("\n"), isError: false, metadata: { server, uri } }
421
+ } catch (e) {
422
+ return { output: `read_mcp_resource: ${e instanceof Error ? e.message : String(e)}`, isError: true }
423
+ }
424
+ },
425
+ }
426
+ return [list, read]
274
427
  }
275
428
 
276
429
  /** Create a fresh (not-yet-connected) client, with the elicitation bridge attached if a waitFor
@@ -318,19 +471,44 @@ async function connectTransport(client: Client, cfg: ServerConfig): Promise<void
318
471
  * whole load promptly on parent cancellation. */
319
472
  export async function loadMcp(
320
473
  input: string | object,
321
- opts?: { waitFor?: (request: Request) => Promise<Answer>; signal?: AbortSignal },
474
+ opts?: {
475
+ waitFor?: (request: Request) => Promise<Answer>
476
+ signal?: AbortSignal
477
+ /**
478
+ * Called once per server the moment it is connected, failed or found disabled (SPEC §4C),
479
+ * so a host can register a fast server's tools while a slow one is still connecting.
480
+ * Calls are serialized (an async callback is awaited before the next one runs); the load
481
+ * still resolves only after every server and every callback has finished.
482
+ */
483
+ onServer?: (result: McpServerResult) => void | Promise<void>
484
+ },
322
485
  ): Promise<McpSource> {
323
486
  const config = parseMcpConfig(input)
324
487
  const tools: Tool[] = []
325
488
  const status: Record<string, McpStatus> = {}
326
489
  const clients: Client[] = []
490
+ const servers = new Map<string, ServerConn>()
327
491
  const waitFor = opts?.waitFor
328
492
  const signal = opts?.signal
493
+ const onServer = opts?.onServer
494
+ let reports: Promise<void> = Promise.resolve()
495
+ let reportError: unknown
496
+ const report = (r: McpServerResult) => {
497
+ if (!onServer) return
498
+ reports = reports.then(async () => {
499
+ try {
500
+ await onServer(r)
501
+ } catch (e) {
502
+ reportError ??= e
503
+ }
504
+ })
505
+ }
329
506
 
330
- await Promise.all(
507
+ const loading = Promise.all(
331
508
  Object.entries(config).map(async ([name, cfg]) => {
332
509
  if (!isEnabled(cfg)) {
333
510
  status[name] = "disabled"
511
+ report({ name, status: "disabled", tools: [] })
334
512
  return
335
513
  }
336
514
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
@@ -341,19 +519,37 @@ export async function loadMcp(
341
519
  await raceTimeout(connectTransport(client, cfg), timeout, signal)
342
520
  const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal)
343
521
  const kept = applyToolsFilter(name, defs, (cfg as { tools?: Record<string, boolean> }).tools)
344
- for (const def of kept) tools.push(convertTool(name, def, client, timeout))
522
+ const converted = kept.map((def) => convertTool(name, def, client, timeout))
523
+ tools.push(...converted)
345
524
  clients.push(client)
525
+ servers.set(name, { client, timeout })
346
526
  status[name] = "connected"
527
+ report({ name, status: "connected", tools: converted })
347
528
  } catch (e) {
348
529
  status[name] = "failed"
349
530
  await client.close().catch(() => {})
350
531
  // Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
351
532
  // within budget isolates just this server.
352
533
  if (signal?.aborted) throw e
534
+ report({ name, status: "failed", tools: [], error: e instanceof Error ? e : new Error(String(e)) })
353
535
  console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
354
536
  }
355
537
  }),
356
538
  )
539
+ try {
540
+ await loading
541
+ } catch (e) {
542
+ // Parent cancellation: close every client, including ones whose tools onServer handed out.
543
+ await reports
544
+ await Promise.all(clients.map((c) => c.close().catch(() => {})))
545
+ throw e
546
+ }
547
+
548
+ await reports
549
+ if (reportError) {
550
+ await Promise.all(clients.map((c) => c.close().catch(() => {})))
551
+ throw reportError
552
+ }
357
553
 
358
554
  return {
359
555
  tools,
@@ -361,6 +557,7 @@ export async function loadMcp(
361
557
  async close() {
362
558
  await Promise.all(clients.map((c) => c.close().catch(() => {})))
363
559
  },
560
+ resourceTools: () => resourceTools(servers),
364
561
  }
365
562
  }
366
563
 
package/src/models.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * listModels (SPEC §4C): the models a provider offers, for model switching. One GET per page,
3
+ * no retries. Same key rules as the client (apiKey, apiKeyEnv, or the host's own env var).
4
+ */
5
+ import { resolveKey, type ClientOptions } from "./client.js"
6
+
7
+ /** One model a provider lists. */
8
+ export interface ModelInfo {
9
+ id: string
10
+ /** The provider's display name, when it gives one. */
11
+ name?: string
12
+ /** Context window in tokens when the provider reports it (OpenRouter does). */
13
+ contextLength?: number
14
+ }
15
+
16
+ async function getJson(f: typeof fetch, url: string, headers: Record<string, string>, signal?: AbortSignal): Promise<any> {
17
+ const res = await f(url, { method: "GET", headers, signal })
18
+ const text = await res.text()
19
+ if (!res.ok) throw new Error(`list models: HTTP ${res.status}: ${text.slice(0, 300)}`)
20
+ return JSON.parse(text)
21
+ }
22
+
23
+ /**
24
+ * List the models at `opts.baseUrl` in `opts.style`: openai `GET {base}/models`; anthropic
25
+ * `GET {base}/v1/models`, following `has_more`/`last_id`. Sorted by id.
26
+ */
27
+ export async function listModels(
28
+ opts: Pick<ClientOptions, "baseUrl" | "style" | "apiKey" | "apiKeyEnv" | "headers" | "fetch">,
29
+ ctx?: { signal?: AbortSignal },
30
+ ): Promise<ModelInfo[]> {
31
+ const key = resolveKey(opts)
32
+ const f = opts.fetch ?? fetch
33
+ const base = opts.baseUrl.replace(/\/+$/, "")
34
+ const out: ModelInfo[] = []
35
+ if (opts.style === "anthropic") {
36
+ const endpoint = base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`
37
+ let after = ""
38
+ for (let page = 0; page < 100; page++) {
39
+ const url = `${endpoint}?limit=1000${after ? `&after_id=${encodeURIComponent(after)}` : ""}`
40
+ const body = await getJson(f, url, { "x-api-key": key, "anthropic-version": "2023-06-01", ...opts.headers }, ctx?.signal)
41
+ for (const m of body?.data ?? []) out.push({ id: m.id, ...(m.display_name ? { name: m.display_name } : {}) })
42
+ if (!body?.has_more || !body?.last_id) break
43
+ after = body.last_id
44
+ }
45
+ } else {
46
+ const body = await getJson(f, `${base}/models`, { Authorization: `Bearer ${key}`, ...opts.headers }, ctx?.signal)
47
+ for (const m of body?.data ?? []) {
48
+ out.push({
49
+ id: m.id,
50
+ ...(m.name ? { name: m.name } : {}),
51
+ ...(typeof m.context_length === "number" && m.context_length > 0 ? { contextLength: m.context_length } : {}),
52
+ })
53
+ }
54
+ }
55
+ return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
56
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Prompt caching (SPEC §8 "Prompt caching", change add-prompt-cache). The engine keeps the
3
+ * request prefix stable and marks it for the provider's cache.
4
+ */
5
+
6
+ /** `ClientOptions.promptCache`. */
7
+ export interface PromptCacheOptions {
8
+ /** Groups requests that share a prefix (one per session). Sent as openai `prompt_cache_key`.
9
+ * Ignored by anthropic, which caches by prefix. */
10
+ key?: string
11
+ /** Ask for the provider's longer cache lifetime where one exists (anthropic `ttl: "1h"`). */
12
+ longTTL?: boolean
13
+ }
14
+
15
+ function withCacheControl(v: unknown, mark: Record<string, unknown>): unknown {
16
+ if (!v || typeof v !== "object" || Array.isArray(v)) return v
17
+ return { ...(v as Record<string, unknown>), cache_control: mark }
18
+ }
19
+
20
+ /**
21
+ * Mark the cacheable prefix in an assembled body, in place. The transcript's own objects are
22
+ * never mutated: every touched entry is copied.
23
+ * - anthropic: `cache_control` on the last tool, the system prompt (a string becomes one text
24
+ * block) and the last content block of the latest message (3 of the 4 allowed breakpoints).
25
+ * - openai: `prompt_cache_key = key` when set; the prefix is cached automatically.
26
+ * - gemini: nothing yet (explicit cachedContent is deferred).
27
+ */
28
+ export function applyPromptCache(body: Record<string, any>, style: string, o: PromptCacheOptions): void {
29
+ if (style === "anthropic") {
30
+ const mark: Record<string, unknown> = { type: "ephemeral" }
31
+ if (o.longTTL) mark.ttl = "1h"
32
+ if (Array.isArray(body.tools) && body.tools.length > 0) {
33
+ const out = [...body.tools]
34
+ out[out.length - 1] = withCacheControl(out[out.length - 1], mark)
35
+ body.tools = out
36
+ }
37
+ if (typeof body.system === "string") {
38
+ if (body.system !== "") body.system = [{ type: "text", text: body.system, cache_control: mark }]
39
+ } else if (Array.isArray(body.system) && body.system.length > 0) {
40
+ const out = [...body.system]
41
+ out[out.length - 1] = withCacheControl(out[out.length - 1], mark)
42
+ body.system = out
43
+ }
44
+ if (Array.isArray(body.messages) && body.messages.length > 0) {
45
+ const out = [...body.messages]
46
+ const last = out[out.length - 1]
47
+ if (last && typeof last === "object" && !Array.isArray(last)) {
48
+ const m: Record<string, unknown> = { ...last }
49
+ if (typeof m.content === "string") {
50
+ m.content = [{ type: "text", text: m.content, cache_control: mark }]
51
+ } else if (Array.isArray(m.content) && m.content.length > 0) {
52
+ const blocks = [...m.content]
53
+ blocks[blocks.length - 1] = withCacheControl(blocks[blocks.length - 1], mark)
54
+ m.content = blocks
55
+ }
56
+ out[out.length - 1] = m
57
+ }
58
+ body.messages = out
59
+ }
60
+ } else if (style === "openai") {
61
+ if (o.key) body.prompt_cache_key = o.key
62
+ }
63
+ }
package/src/store.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { promises as fsp } from "node:fs"
2
+ import path from "node:path"
3
+ import { randomBytes } from "node:crypto"
4
+ import { sortEntries, type ConversationEntry, type ConversationStore } from "./client.js"
5
+
6
+ const ID_RE = /^[A-Za-z0-9._-]{1,128}$/
7
+ const VERSION = 1
8
+
9
+ function checkId(id: string): void {
10
+ if (typeof id !== "string" || !ID_RE.test(id) || id === "." || id === "..") {
11
+ throw new Error(`invalid conversation id: ${JSON.stringify(id)}`)
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Durable {@link ConversationStore}: one file `<root>/<id>.json` per conversation holding
17
+ * `{"version":1,"messages":[...]}`. `save` writes a temp file (0600), fsyncs and renames it over
18
+ * the old one, so a reader sees the old or the new transcript, never a torn one (power-loss
19
+ * durability of the rename is best-effort). Same-id saves are serialized in-process; across
20
+ * processes the last rename wins.
21
+ */
22
+ /**
23
+ * Durability mode (SPEC §8 "Durability mode"): `full` (default) flushes the file then fsyncs the
24
+ * directory best-effort; `fsync` flushes the file only; `none` skips every flush. Temp-then-rename
25
+ * holds in every mode. (On macOS Node's `fh.sync()` is libuv's fsync, which already issues
26
+ * `F_FULLFSYNC`; Node exposes no plainer flush, so `fsync` differs from `full` only by the dir fsync.)
27
+ */
28
+ export type FileStoreSync = "full" | "fsync" | "none"
29
+
30
+ export interface FileStoreOptions {
31
+ sync?: FileStoreSync
32
+ }
33
+
34
+ export class FileConversationStore implements ConversationStore {
35
+ readonly root: string
36
+ readonly sync: FileStoreSync
37
+ private readonly chains = new Map<string, Promise<void>>()
38
+
39
+ constructor(root: string, opts: FileStoreOptions = {}) {
40
+ if (typeof root !== "string" || root === "") throw new Error("FileConversationStore: root is required")
41
+ const mode = opts.sync ?? "full"
42
+ if (mode !== "full" && mode !== "fsync" && mode !== "none") {
43
+ throw new Error(`FileConversationStore: unknown sync mode ${JSON.stringify(opts.sync)}`)
44
+ }
45
+ this.root = root
46
+ this.sync = mode
47
+ }
48
+
49
+ private file(id: string): string {
50
+ return path.join(this.root, id + ".json")
51
+ }
52
+
53
+ async get(id: string): Promise<any[] | undefined> {
54
+ checkId(id)
55
+ let text: string
56
+ try {
57
+ text = await fsp.readFile(this.file(id), "utf8")
58
+ } catch (e: any) {
59
+ if (e?.code === "ENOENT") return undefined
60
+ throw e
61
+ }
62
+ let body: any
63
+ try {
64
+ body = JSON.parse(text)
65
+ } catch (e) {
66
+ throw new Error(`conversation "${id}": corrupt file: ${e instanceof Error ? e.message : String(e)}`)
67
+ }
68
+ if (body === null || typeof body !== "object" || body.version !== VERSION) {
69
+ throw new Error(`conversation "${id}": unsupported version ${JSON.stringify(body?.version)}`)
70
+ }
71
+ if (!Array.isArray(body.messages)) throw new Error(`conversation "${id}": corrupt file: messages is not an array`)
72
+ return body.messages
73
+ }
74
+
75
+ async save(id: string, messages: any[]): Promise<void> {
76
+ checkId(id)
77
+ const prev = this.chains.get(id) ?? Promise.resolve()
78
+ const next = prev.catch(() => {}).then(() => this.write(id, messages))
79
+ this.chains.set(id, next)
80
+ try {
81
+ await next
82
+ } finally {
83
+ if (this.chains.get(id) === next) this.chains.delete(id)
84
+ }
85
+ }
86
+
87
+ private async write(id: string, messages: any[]): Promise<void> {
88
+ await fsp.mkdir(this.root, { recursive: true, mode: 0o700 })
89
+ const tmp = path.join(this.root, `.${id}.${randomBytes(6).toString("hex")}.tmp`)
90
+ const data = JSON.stringify({ version: VERSION, messages })
91
+ let fh: Awaited<ReturnType<typeof fsp.open>> | undefined
92
+ try {
93
+ fh = await fsp.open(tmp, "w", 0o600)
94
+ await fh.writeFile(data, "utf8")
95
+ if (this.sync !== "none") await fh.sync()
96
+ await fh.close()
97
+ fh = undefined
98
+ await fsp.rename(tmp, this.file(id))
99
+ } catch (e) {
100
+ await fh?.close().catch(() => {})
101
+ await fsp.rm(tmp, { force: true }).catch(() => {})
102
+ throw e
103
+ }
104
+ // Best-effort parent-dir fsync so the rename itself survives power loss where supported.
105
+ if (this.sync !== "full") return
106
+ try {
107
+ const dir = await fsp.open(this.root, "r")
108
+ await dir.sync().catch(() => {})
109
+ await dir.close()
110
+ } catch {}
111
+ }
112
+
113
+ /** Every stored conversation, newest first (mtime; ties by id ascending). Bodies are not parsed. */
114
+ async list(): Promise<ConversationEntry[]> {
115
+ let names: string[]
116
+ try {
117
+ names = await fsp.readdir(this.root)
118
+ } catch (e: any) {
119
+ if (e?.code === "ENOENT") return []
120
+ throw e
121
+ }
122
+ const out: ConversationEntry[] = []
123
+ for (const name of names) {
124
+ if (!name.endsWith(".json")) continue
125
+ const id = name.slice(0, -5)
126
+ if (!ID_RE.test(id) || id === "." || id === "..") continue
127
+ try {
128
+ const st = await fsp.stat(path.join(this.root, name))
129
+ if (st.isFile()) out.push({ id, updatedAt: st.mtime })
130
+ } catch (e: any) {
131
+ if (e?.code !== "ENOENT") throw e
132
+ }
133
+ }
134
+ return sortEntries(out)
135
+ }
136
+
137
+ /** Remove a conversation; absent ids are not an error. */
138
+ async delete(id: string): Promise<void> {
139
+ checkId(id)
140
+ await fsp.rm(this.file(id), { force: true })
141
+ }
142
+ }