@agentex/agent 0.0.34 → 0.0.36

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 (81) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +2 -1
  3. package/dist/providers/claude/discovery.d.ts +64 -0
  4. package/dist/providers/claude/discovery.d.ts.map +1 -0
  5. package/dist/providers/claude/discovery.js +147 -0
  6. package/dist/providers/claude/discovery.js.map +1 -0
  7. package/dist/providers/claude/effort.d.ts +20 -0
  8. package/dist/providers/claude/effort.d.ts.map +1 -0
  9. package/dist/providers/claude/effort.js +32 -0
  10. package/dist/providers/claude/effort.js.map +1 -0
  11. package/dist/providers/claude/execute.d.ts.map +1 -1
  12. package/dist/providers/claude/execute.js +2 -1
  13. package/dist/providers/claude/execute.js.map +1 -1
  14. package/dist/providers/claude/index.d.ts.map +1 -1
  15. package/dist/providers/claude/index.js +2 -1
  16. package/dist/providers/claude/index.js.map +1 -1
  17. package/dist/providers/claude/parse.d.ts.map +1 -1
  18. package/dist/providers/claude/parse.js +26 -4
  19. package/dist/providers/claude/parse.js.map +1 -1
  20. package/dist/providers/claude/session.d.ts.map +1 -1
  21. package/dist/providers/claude/session.js +2 -1
  22. package/dist/providers/claude/session.js.map +1 -1
  23. package/dist/providers/codex/discovery.d.ts +20 -0
  24. package/dist/providers/codex/discovery.d.ts.map +1 -0
  25. package/dist/providers/codex/discovery.js +76 -0
  26. package/dist/providers/codex/discovery.js.map +1 -0
  27. package/dist/providers/codex/execute.d.ts.map +1 -1
  28. package/dist/providers/codex/execute.js +45 -3
  29. package/dist/providers/codex/execute.js.map +1 -1
  30. package/dist/providers/codex/index.d.ts.map +1 -1
  31. package/dist/providers/codex/index.js +2 -1
  32. package/dist/providers/codex/index.js.map +1 -1
  33. package/dist/providers/codex/parse.d.ts +14 -6
  34. package/dist/providers/codex/parse.d.ts.map +1 -1
  35. package/dist/providers/codex/parse.js +51 -16
  36. package/dist/providers/codex/parse.js.map +1 -1
  37. package/dist/providers/codex/session.d.ts +29 -1
  38. package/dist/providers/codex/session.d.ts.map +1 -1
  39. package/dist/providers/codex/session.js +255 -57
  40. package/dist/providers/codex/session.js.map +1 -1
  41. package/dist/providers/cursor/discovery.d.ts.map +1 -1
  42. package/dist/providers/cursor/discovery.js +4 -0
  43. package/dist/providers/cursor/discovery.js.map +1 -1
  44. package/dist/providers/opencode/discovery.d.ts.map +1 -1
  45. package/dist/providers/opencode/discovery.js +11 -8
  46. package/dist/providers/opencode/discovery.js.map +1 -1
  47. package/dist/providers/opencode/event-parse.d.ts +29 -0
  48. package/dist/providers/opencode/event-parse.d.ts.map +1 -1
  49. package/dist/providers/opencode/event-parse.js +55 -0
  50. package/dist/providers/opencode/event-parse.js.map +1 -1
  51. package/dist/providers/opencode/history.d.ts.map +1 -1
  52. package/dist/providers/opencode/history.js +25 -4
  53. package/dist/providers/opencode/history.js.map +1 -1
  54. package/dist/providers/opencode/http-session.d.ts.map +1 -1
  55. package/dist/providers/opencode/http-session.js +72 -8
  56. package/dist/providers/opencode/http-session.js.map +1 -1
  57. package/dist/types.d.ts +19 -5
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/utils/model-cache.d.ts +33 -0
  60. package/dist/utils/model-cache.d.ts.map +1 -0
  61. package/dist/utils/model-cache.js +57 -0
  62. package/dist/utils/model-cache.js.map +1 -0
  63. package/package.json +1 -1
  64. package/src/providers/claude/discovery.ts +215 -0
  65. package/src/providers/claude/effort.ts +34 -0
  66. package/src/providers/claude/execute.ts +2 -1
  67. package/src/providers/claude/index.ts +2 -1
  68. package/src/providers/claude/parse.ts +27 -5
  69. package/src/providers/claude/session.ts +2 -1
  70. package/src/providers/codex/discovery.ts +91 -0
  71. package/src/providers/codex/execute.ts +42 -3
  72. package/src/providers/codex/index.ts +2 -1
  73. package/src/providers/codex/parse.ts +60 -18
  74. package/src/providers/codex/session.ts +285 -59
  75. package/src/providers/cursor/discovery.ts +5 -0
  76. package/src/providers/opencode/discovery.ts +10 -7
  77. package/src/providers/opencode/event-parse.ts +77 -0
  78. package/src/providers/opencode/history.ts +32 -4
  79. package/src/providers/opencode/http-session.ts +72 -8
  80. package/src/types.ts +19 -5
  81. package/src/utils/model-cache.ts +67 -0
@@ -1,5 +1,6 @@
1
1
  import type { ListModelsOptions, ProviderModel } from "../../types.js";
2
2
  import { acquireOpenCodeRuntime } from "./runtime.js";
3
+ import { withModelCache } from "../../utils/model-cache.js";
3
4
 
4
5
  function record(value: unknown): Record<string, unknown> {
5
6
  return value && typeof value === "object" && !Array.isArray(value)
@@ -59,11 +60,13 @@ export function openCodeModelsFromPayload(payload: Record<string, unknown>): Pro
59
60
  }
60
61
 
61
62
  export async function listOpenCodeModels(options: ListModelsOptions = {}): Promise<ProviderModel[]> {
62
- const runtime = await acquireOpenCodeRuntime(options);
63
- try {
64
- const payload = await runtime.server.client.json<Record<string, unknown>>("/provider");
65
- return openCodeModelsFromPayload(payload);
66
- } finally {
67
- runtime.server.release();
68
- }
63
+ return withModelCache("opencode", options, options.cacheTtlMs, async () => {
64
+ const runtime = await acquireOpenCodeRuntime(options);
65
+ try {
66
+ const payload = await runtime.server.client.json<Record<string, unknown>>("/provider");
67
+ return openCodeModelsFromPayload(payload);
68
+ } finally {
69
+ runtime.server.release();
70
+ }
71
+ });
69
72
  }
@@ -54,6 +54,83 @@ export function turnStatusFromMessage(info: unknown): "completed" | "failed" {
54
54
  return m && m["error"] ? "failed" : "completed";
55
55
  }
56
56
 
57
+ /**
58
+ * Finish reasons that represent the model deliberately ending its turn. Anything
59
+ * outside this set that also produced no text is treated as an incomplete turn
60
+ * (see `terminalOutcome`). `tool-calls` is included because it's an intermediate
61
+ * step in the agent loop, not an empty terminal turn.
62
+ */
63
+ const CLEAN_FINISH_REASONS = new Set(["stop", "length", "end_turn", "stop_sequence", "tool-calls"]);
64
+
65
+ /** User-facing note for a turn that ended with no reply (provider dropped the stream). */
66
+ export function incompleteTurnMessage(finish: string | null): string {
67
+ const reason = finish ? ` (finish reason "${finish}")` : "";
68
+ return (
69
+ `The model ended the turn without sending a reply${reason}. ` +
70
+ "The upstream provider returned no content, which usually means the stream was dropped. " +
71
+ "Send your message again to retry."
72
+ );
73
+ }
74
+
75
+ export interface TerminalOutcome {
76
+ status: "completed" | "failed" | "aborted";
77
+ errorCode: string | null;
78
+ errorMessage: string | null;
79
+ /**
80
+ * True when the turn ended with no reply, no tool output, and no error — the
81
+ * "silent empty turn" opencode records as a normal completion (typically after
82
+ * an upstream stream error, surfaced as `finish: "unknown"`). Distinct from a
83
+ * user interrupt, which opencode marks with a `MessageAbortedError` in
84
+ * `info.error` and so reports as `aborted` below.
85
+ */
86
+ incomplete: boolean;
87
+ }
88
+
89
+ /**
90
+ * Classify a terminal assistant message. Extends `turnStatusFromMessage` by
91
+ * catching the empty-turn case so a dropped stream surfaces as `failed` (with a
92
+ * visible note) instead of rendering as a stall. Keyed on the message having no
93
+ * visible text and a non-clean finish reason; never fires when the model
94
+ * produced an answer or stopped cleanly, and never fires on an interrupt.
95
+ *
96
+ * Both the live and reconcile paths call this, so the "finished" guard lives
97
+ * here: a message with neither a finish reason nor an error is still running (or
98
+ * a malformed response) and stays `completed`. Without it, an empty `/message`
99
+ * body would flip to `failed` with no note — the exact silent stall this exists
100
+ * to remove, reached from the other direction.
101
+ */
102
+ export function terminalOutcome(info: unknown, hasVisibleText: boolean): TerminalOutcome {
103
+ const m = rec(info);
104
+ const rawError = m?.["error"] ?? null;
105
+ const finish = m ? str(m["finish"] ?? null) : null;
106
+
107
+ // Not a finished turn (no finish reason, no error): don't reclassify.
108
+ if (rawError == null && finish === null) {
109
+ return { status: "completed", errorCode: null, errorMessage: null, incomplete: false };
110
+ }
111
+
112
+ if (rawError != null) {
113
+ // A user interrupt (through opencode's own UI, on a session this process is
114
+ // attached to) is recorded as a MessageAbortedError. Report it as `aborted`
115
+ // to match self-aborts and Codex, not a generic error with a JSON blob.
116
+ if (rec(rawError)?.["name"] === "MessageAbortedError") {
117
+ return { status: "aborted", errorCode: "aborted", errorMessage: "The turn was interrupted.", incomplete: false };
118
+ }
119
+ return { status: "failed", errorCode: "agent_error", errorMessage: JSON.stringify(rawError), incomplete: false };
120
+ }
121
+
122
+ const cleanStop = CLEAN_FINISH_REASONS.has(finish!);
123
+ if (!hasVisibleText && !cleanStop) {
124
+ return {
125
+ status: "failed",
126
+ errorCode: "incomplete_turn",
127
+ errorMessage: incompleteTurnMessage(finish),
128
+ incomplete: true,
129
+ };
130
+ }
131
+ return { status: "completed", errorCode: null, errorMessage: null, incomplete: false };
132
+ }
133
+
57
134
  /** Map opencode's `tokens` (+ model identity) to agentex usage. */
58
135
  export function usageFromMessage(info: unknown): Record<string, TokenUsage> | undefined {
59
136
  const m = rec(info);
@@ -15,7 +15,14 @@ import { assertSessionRecord, createSessionRecord, MalformedSessionRecordError }
15
15
  import { acquireOpenCodeRuntime } from "./runtime.js";
16
16
  import { opencodeSessionCodec } from "./codec.js";
17
17
  import { createOpenCodeSession } from "./http-session.js";
18
- import { mapOpenCodePart, mapOpenCodeToolCall, type OcBaseInfo } from "./event-parse.js";
18
+ import {
19
+ assistantTextFromParts,
20
+ incompleteTurnMessage,
21
+ mapOpenCodePart,
22
+ mapOpenCodeToolCall,
23
+ terminalOutcome,
24
+ type OcBaseInfo,
25
+ } from "./event-parse.js";
19
26
 
20
27
  const PAGE_SIZE = 100;
21
28
  const MAX_PAGES = 100;
@@ -209,16 +216,37 @@ export function historicalEvents(
209
216
  const lastPartId = events.at(-1)?.partId;
210
217
  const finished = string(info["finish"]) !== null || info["error"] != null;
211
218
  if (messageId && lastPartId && finished) {
212
- const error = info["error"] != null;
219
+ const hasVisibleText = assistantTextFromParts(message.parts).trim().length > 0;
220
+ const term = terminalOutcome(info, hasVisibleText);
221
+ // A turn that ended with no reply (upstream dropped the stream) gets a
222
+ // visible note, deduped against the live session's note by the shared
223
+ // `:incomplete` event id.
224
+ if (term.incomplete) {
225
+ events.push({
226
+ partId: lastPartId,
227
+ event: {
228
+ type: "assistant",
229
+ text: incompleteTurnMessage(string(info["finish"])),
230
+ timestamp,
231
+ providerType: "opencode",
232
+ sessionId,
233
+ messageId,
234
+ eventId: `${messageId}:incomplete`,
235
+ turnId: null,
236
+ parentToolCallId: null,
237
+ raw: { synthetic: "incomplete_turn", info },
238
+ },
239
+ });
240
+ }
213
241
  events.push({
214
242
  partId: lastPartId,
215
243
  event: {
216
244
  type: "result",
217
245
  text: "",
218
246
  costUsd: typeof info["cost"] === "number" ? info["cost"] : null,
219
- isError: error,
247
+ isError: term.status !== "completed",
220
248
  stopReason: null,
221
- terminalReason: error ? "failed" : "completed",
249
+ terminalReason: term.status,
222
250
  numTurns: 1,
223
251
  durationMs: null,
224
252
  timestamp,
@@ -25,9 +25,10 @@ import { opencodeSessionCodec } from "./codec.js";
25
25
  import { prepareOpenCodeSkillConfig } from "./skill-config.js";
26
26
  import {
27
27
  assistantTextFromParts,
28
+ incompleteTurnMessage,
28
29
  mapOpenCodePart,
29
30
  mapOpenCodeToolCall,
30
- turnStatusFromMessage,
31
+ terminalOutcome,
31
32
  usageFromMessage,
32
33
  type OcBaseInfo,
33
34
  } from "./event-parse.js";
@@ -83,6 +84,12 @@ class OpenCodeSession implements AgentSession {
83
84
  private readonly _seenTextLen = new Map<string, number>();
84
85
  private readonly _emittedToolCall = new Set<string>();
85
86
  private readonly _emittedToolResult = new Set<string>();
87
+ /**
88
+ * messageID → role, learned from `message.updated` SSE frames. Lets `emitPart`
89
+ * drop a user message's own text part, which opencode streams once a turn is
90
+ * active and would otherwise surface as an assistant bubble echoing the prompt.
91
+ */
92
+ private readonly _messageRoles = new Map<string, string>();
86
93
  private _sse: AbortController | null = null;
87
94
  private readonly _handledInput = new Set<string>();
88
95
  private readonly _inputInFlight = new Set<string>();
@@ -245,6 +252,17 @@ class OpenCodeSession implements AgentSession {
245
252
  if (request) await this.handleQuestion(request);
246
253
  return;
247
254
  }
255
+ if (payload["type"] === "message.updated") {
256
+ // Track message roles so emitPart can distinguish a user message's own
257
+ // text part (echoed onto the SSE feed) from real assistant output.
258
+ const info = rec(rec(payload["properties"])?.["info"]) ?? rec(payload["properties"]);
259
+ const mid = info ? str(info["id"]) : null;
260
+ const role = info ? str(info["role"]) : null;
261
+ if (mid && role && str(info!["sessionID"]) === this._sessionId) {
262
+ this._messageRoles.set(mid, role);
263
+ }
264
+ return;
265
+ }
248
266
  if (payload["type"] !== "message.part.updated") return;
249
267
  const props = rec(payload["properties"]);
250
268
  const part = rec(props?.["part"]);
@@ -403,6 +421,12 @@ class OpenCodeSession implements AgentSession {
403
421
  // The SSE feed is global and async: drop stragglers that arrive while no turn
404
422
  // is active so a finished turn's late parts don't surface against the next one.
405
423
  if (!this._turnActive) return;
424
+ // Drop parts belonging to a user message — opencode re-streams the prompt's
425
+ // own text part once the turn is active, which would echo as an assistant
426
+ // bubble. Unknown role (message.updated not seen yet) falls through and
427
+ // emits, so a race can never suppress genuine assistant output.
428
+ const messageId = str(part["messageID"]);
429
+ if (messageId && this._messageRoles.get(messageId) === "user") return;
406
430
  const info: OcBaseInfo = {
407
431
  provider: "opencode",
408
432
  sessionId: this._sessionId,
@@ -467,6 +491,12 @@ class OpenCodeSession implements AgentSession {
467
491
  private finishTurn(): void {
468
492
  this._turnActive = false;
469
493
  this._inFlight = null;
494
+ // Cleared at turn end, not with the sibling dedup maps at turn start: the
495
+ // user message's role has to survive from its `message.updated` frame into
496
+ // the part stream that follows within the same turn, so clearing at the
497
+ // start would reintroduce the prompt echo. Bounding it here keeps the map
498
+ // from growing for the whole life of a long-running session.
499
+ this._messageRoles.clear();
470
500
  if (this._state !== "closed") this._state = "idle";
471
501
  }
472
502
 
@@ -537,18 +567,20 @@ class OpenCodeSession implements AgentSession {
537
567
  const data = (await res.json()) as Record<string, unknown>;
538
568
  terminalRaw = data;
539
569
  const info = rec(data["info"]);
540
- const status = turnStatusFromMessage(info);
541
570
  const usage = usageFromMessage(info);
542
571
  const summary = assistantTextFromParts(data["parts"]) || null;
543
572
  const costUsd = info ? num(info["cost"]) : null;
544
- const errorMessage = status === "failed" ? JSON.stringify(info?.["error"] ?? null) : null;
573
+ const term = terminalOutcome(info, summary != null && summary.trim().length > 0);
574
+ // A silent empty turn (upstream dropped the stream) would otherwise
575
+ // render as a stall. Surface a visible note before the terminal result.
576
+ if (term.incomplete && info) await this.emitIncompleteNote(info);
545
577
  outcome = {
546
578
  summary,
547
579
  ...(usage ? { usage } : {}),
548
580
  costUsd,
549
- status,
550
- errorCode: status === "failed" ? "agent_error" : null,
551
- errorMessage,
581
+ status: term.status,
582
+ errorCode: term.errorCode,
583
+ errorMessage: term.errorMessage,
552
584
  };
553
585
  }
554
586
  }
@@ -584,12 +616,42 @@ class OpenCodeSession implements AgentSession {
584
616
  return outcome;
585
617
  }
586
618
 
619
+ /**
620
+ * Surface a turn that ended with no reply as a visible assistant note. The
621
+ * stable `:incomplete` event id is shared with the reconcile path
622
+ * (`historicalEvents`) so a later catch-up dedups against this live note
623
+ * instead of duplicating it.
624
+ *
625
+ * This is library-authored prose, not model output. It is emitted as
626
+ * `type: "assistant"` because that is the only surface a host renders, and is
627
+ * tagged `raw.synthetic: "incomplete_turn"`. A host that replays transcript
628
+ * history back into a model (context rebuilding, summarization) should filter
629
+ * events carrying `raw.synthetic` so this note is never fed back as assistant
630
+ * turn content.
631
+ */
632
+ private async emitIncompleteNote(info: Record<string, unknown>): Promise<void> {
633
+ const messageId = str(info["id"]);
634
+ await this.emit({
635
+ type: "assistant",
636
+ text: incompleteTurnMessage(str(info["finish"])),
637
+ timestamp: new Date().toISOString(),
638
+ providerType: "opencode",
639
+ sessionId: this._sessionId,
640
+ messageId,
641
+ eventId: messageId ? `${messageId}:incomplete` : null,
642
+ turnId: null,
643
+ parentToolCallId: null,
644
+ raw: { synthetic: "incomplete_turn", info },
645
+ });
646
+ }
647
+
587
648
  private async emitTerminal(
588
649
  outcome: TurnResult,
589
650
  raw: Record<string, unknown>,
590
651
  startedAt: number,
591
652
  ): Promise<void> {
592
653
  const info = rec(raw["info"]);
654
+ const messageId = info ? str(info["id"]) : null;
593
655
  await this.emit({
594
656
  type: "result",
595
657
  text: outcome.summary ?? outcome.errorMessage ?? "",
@@ -602,8 +664,10 @@ class OpenCodeSession implements AgentSession {
602
664
  timestamp: new Date().toISOString(),
603
665
  providerType: "opencode",
604
666
  sessionId: this._sessionId,
605
- messageId: info ? str(info["id"]) : null,
606
- eventId: info ? str(info["id"]) : null,
667
+ messageId,
668
+ // Match the reconcile path's `${messageId}:result` id so the live and
669
+ // catch-up terminal markers dedup instead of both landing as rows.
670
+ eventId: messageId ? `${messageId}:result` : null,
607
671
  turnId: null,
608
672
  parentToolCallId: null,
609
673
  raw,
package/src/types.ts CHANGED
@@ -1203,17 +1203,29 @@ export type StreamEvent =
1203
1203
  * `phase: "completed"` settles only this task. It is never a root turn
1204
1204
  * result and must not be used to resolve `SendHandle.result`.
1205
1205
  *
1206
- * Events are reducer-friendly snapshots. `status` is always populated,
1207
- * while description/summary may be null when the provider did not report
1208
- * them. `parentTaskId` identifies a nested background task when that lineage
1209
- * is available.
1206
+ * Events are reducer-friendly snapshots, and every optional field follows one
1207
+ * rule: null means the provider did not report it, so keep what you already
1208
+ * have. That includes `status` — a sparse patch can rename a task without
1209
+ * saying anything about whether it is still running. `parentTaskId`
1210
+ * identifies a nested background task when that lineage is available.
1210
1211
  */
1211
1212
  | ({
1212
1213
  type: "background_task";
1213
1214
  taskId: string;
1214
1215
  taskType: BackgroundTaskType;
1215
1216
  phase: BackgroundTaskPhase;
1216
- status: BackgroundTaskStatus;
1217
+ /**
1218
+ * The task's state as the provider reported it, or `null` for "no change
1219
+ * reported" — read it as "keep whatever you have".
1220
+ *
1221
+ * Null rather than a default because the alternative is asserting a state
1222
+ * the provider never sent. A sparse patch that only renames a task would
1223
+ * otherwise claim `running` and silently resurrect a `paused` one. This
1224
+ * matches `description` and `summary`, where null already means "the
1225
+ * provider did not report this"; status was the one field that invented a
1226
+ * value instead of admitting absence.
1227
+ */
1228
+ status: BackgroundTaskStatus | null;
1217
1229
  description: string | null;
1218
1230
  summary: string | null;
1219
1231
  parentTaskId: string | null;
@@ -1373,6 +1385,8 @@ export interface AuthResolveContext {
1373
1385
  export interface ProviderModel {
1374
1386
  id: string;
1375
1387
  name: string;
1388
+ /** One-line blurb from the provider's own catalog, when it ships one. */
1389
+ description?: string;
1376
1390
  provider?: string;
1377
1391
  providerName?: string;
1378
1392
  variants?: Array<{
@@ -0,0 +1,67 @@
1
+ /**
2
+ * TTL cache behind `listModels()`.
3
+ *
4
+ * `ListModelsOptions.cacheTtlMs` was part of the public shape long before
5
+ * anything honored it, so callers could pass a TTL and silently get an
6
+ * uncached spawn every time. Model discovery costs a CLI round trip (or
7
+ * several, when a provider has to probe), which is why the option existed.
8
+ *
9
+ * Entries are keyed on the runtime identity that can change the answer —
10
+ * binary override, cwd, and any env the caller passed. Env is folded in
11
+ * because a different credential can yield a different catalog; only the key
12
+ * names and a hash-free ordering of values are used, and the key never leaves
13
+ * this process.
14
+ */
15
+
16
+ const store = new Map<string, { expiresAt: number; value: unknown }>();
17
+
18
+ export interface ModelCacheIdentity {
19
+ cwd?: string | undefined;
20
+ env?: Record<string, string | undefined> | undefined;
21
+ config?: { command?: string | undefined } | undefined;
22
+ }
23
+
24
+ function identityKey(provider: string, identity: ModelCacheIdentity): string {
25
+ const env = Object.entries(identity.env ?? {})
26
+ .filter(([, value]) => value !== undefined)
27
+ .sort(([left], [right]) => left.localeCompare(right));
28
+ return JSON.stringify([provider, identity.cwd ?? "", identity.config?.command ?? "", env]);
29
+ }
30
+
31
+ /**
32
+ * Run `load` at most once per TTL window for the given identity.
33
+ *
34
+ * `cacheTtlMs` of 0 (or undefined) forces a fresh load and refreshes the entry,
35
+ * which is how callers implement an explicit "refresh catalog" action.
36
+ * Rejections are never cached: a discovery failure is usually a transient
37
+ * "CLI is mid-upgrade" and should not pin an error for the whole window.
38
+ */
39
+ export async function withModelCache<T>(
40
+ provider: string,
41
+ identity: ModelCacheIdentity,
42
+ cacheTtlMs: number | undefined,
43
+ load: () => Promise<T>,
44
+ ): Promise<T> {
45
+ const key = identityKey(provider, identity);
46
+ const ttl = cacheTtlMs ?? 0;
47
+ if (ttl > 0) {
48
+ const hit = store.get(key);
49
+ if (hit && hit.expiresAt > Date.now()) return hit.value as T;
50
+ }
51
+ const value = await load();
52
+ if (ttl > 0) store.set(key, { expiresAt: Date.now() + ttl, value });
53
+ else store.delete(key);
54
+ return value;
55
+ }
56
+
57
+ /** Drop cached catalogs. Scoped to one provider when named, otherwise all. */
58
+ export function clearModelCache(provider?: string): void {
59
+ if (!provider) {
60
+ store.clear();
61
+ return;
62
+ }
63
+ const prefix = `["${provider}"`;
64
+ for (const key of [...store.keys()]) {
65
+ if (key.startsWith(prefix)) store.delete(key);
66
+ }
67
+ }