@ultimat3/ai 20.2.1 → 22.0.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/README.md CHANGED
@@ -270,7 +270,7 @@ summarize.contract(); // the contract tests
270
270
  | `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated |
271
271
  | `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation. `scope` receives `{ input, ctx }` and **defaults to the calling actor** — the narrowest key, `@ultimat3/query`'s `readAuthority` rule; a shared store is `scope: () => 'global'`, written down |
272
272
  | `policy` | the same object every surface evaluates — an MCP call and an HTTP call are denied identically |
273
- | `vars` | the one declared place a model call loads data, so a reader can see what was sent — and the one place a redactor sees it, and where a `Secret` is refused |
273
+ | `vars` | the declared place an `llm()` call loads data, so a reader can see what was sent — the redactor sees it (and every `agent()` tool result), and a `Secret` is refused here |
274
274
 
275
275
  ### Streaming is the same action
276
276
 
@@ -466,8 +466,9 @@ an action with no name reaches no route, no tool catalogue and no queue.
466
466
 
467
467
  ## Redaction: one declared seam
468
468
 
469
- `vars()` is the one place a model call loads data, so it is the one place anything can sit between
470
- the row and a third-party endpoint.
469
+ A model call loads data in two places — `vars()`, and in an `agent()` the RESULT of every tool it
470
+ runs — and the redactor sits on both, between the row and a third-party endpoint. It sat on
471
+ `vars()` alone until 2026-09-23, so an agent's tool results left the process unredacted.
471
472
 
472
473
  ```ts
473
474
  configureAi({ gateway, redact: (text) => scrubPatientIdentifiers(text) });
@@ -654,3 +655,36 @@ fail-closed `terminal` default.
654
655
  | `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
655
656
  | `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
656
657
  | `X_NOT_IMPLEMENTED` | a remote driver with no key or transport; the fix names the env var |
658
+
659
+ ### Error classes
660
+
661
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
662
+ a job boundary the class is gone and the `code` is what survives — match on that.
663
+
664
+ | Class | Code | Declared in |
665
+ |---|---|---|
666
+ | `AgentMaxTurnsError` | `X_AGENT_MAX_TURNS` | `src/errors.ts` |
667
+ | `AgentToolUnexposedError` | `X_AGENT_TOOL_UNEXPOSED` | `src/errors.ts` |
668
+ | `AiBudgetExceededError` | `X_AI_BUDGET_EXCEEDED` | `src/errors.ts` |
669
+ | `AiGatewayMissingError` | `X_AI_GATEWAY_MISSING` | `src/errors.ts` |
670
+ | `AiKeyMissingError` | `X_AI_KEY_MISSING` | `src/errors.ts` |
671
+ | `AiModelUnknownError` | `X_AI_MODEL_UNKNOWN` | `src/errors.ts` |
672
+ | `AiPromptRenderError` | `X_AI_PROMPT_VERSION` | `src/errors.ts` |
673
+ | `AiPromptSecretError` | `X_AI_PROMPT_SECRET` | `src/errors.ts` |
674
+ | `AiPromptVersionError` | `X_AI_PROMPT_VERSION` | `src/errors.ts` |
675
+ | `AiProviderUnavailableError` | `X_AI_PROVIDER_UNAVAILABLE` | `src/errors.ts` |
676
+ | `AiRequestInvalidError` | `X_AI_REQUEST_INVALID` | `src/errors.ts` |
677
+ | `AiTransportError` | `X_AI_PROVIDER_UNAVAILABLE` | `src/errors.ts` |
678
+ | `EmbedderDimMismatchError` | `X_VECTOR_DIM_MISMATCH` | `src/errors.ts` |
679
+ | `EvalBaselineInvalidError` | `X_EVAL_BASELINE_INVALID` | `src/eval-errors.ts` |
680
+ | `EvalBaselineMissingError` | `X_EVAL_BASELINE_MISSING` | `src/eval-errors.ts` |
681
+ | `EvalMissingError` | `X_EVAL_MISSING` | `src/eval-errors.ts` |
682
+ | `EvalRecordingError` | `X_EVAL_RECORDING` | `src/eval-errors.ts` |
683
+ | `EvalThresholdError` | `X_EVAL_THRESHOLD` | `src/eval-errors.ts` |
684
+ | `HiveEmptyError` | `X_HIVE_EMPTY` | `src/hive-errors.ts` |
685
+ | `LlmOutputInvalidError` | `X_LLM_OUTPUT_INVALID` | `src/errors.ts` |
686
+ | `LlmRefusedError` | `X_LLM_REFUSED` | `src/errors.ts` |
687
+ | `LlmStreamInvalidError` | `X_LLM_STREAM_INVALID` | `src/errors.ts` |
688
+ | `LlmTruncatedError` | `X_LLM_TRUNCATED` | `src/errors.ts` |
689
+ | `VectorDimMismatchError` | `X_VECTOR_DIM_MISMATCH` | `src/errors.ts` |
690
+ | `VectorScopeWidenedError` | `X_VECTOR_SCOPE_WIDENED` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "20.2.1",
3
+ "version": "22.0.0",
4
4
  "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,14 +32,14 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "20.2.1",
36
- "@ultimat3/cache": "20.2.1",
37
- "@ultimat3/core": "20.2.1",
38
- "@ultimat3/db": "20.2.1",
39
- "@ultimat3/jobs": "20.2.1",
40
- "@ultimat3/money": "20.2.1",
41
- "@ultimat3/policy": "20.2.1",
42
- "@ultimat3/schema": "20.2.1",
43
- "@ultimat3/time": "20.2.1"
35
+ "@ultimat3/action": "22.0.0",
36
+ "@ultimat3/cache": "22.0.0",
37
+ "@ultimat3/core": "22.0.0",
38
+ "@ultimat3/db": "22.0.0",
39
+ "@ultimat3/jobs": "22.0.0",
40
+ "@ultimat3/money": "22.0.0",
41
+ "@ultimat3/policy": "22.0.0",
42
+ "@ultimat3/schema": "22.0.0",
43
+ "@ultimat3/time": "22.0.0"
44
44
  }
45
45
  }
@@ -47,6 +47,12 @@ export function toolResultTurn(
47
47
  results: readonly LlmToolResult[],
48
48
  chars: number,
49
49
  unaccepted: readonly LlmToolCall[],
50
+ /**
51
+ * The app's `configureAi({ redact })`, run over each result BEFORE the truncation — a tool
52
+ * result is where an agent loads a row, and it left the process unredacted while the prompt
53
+ * that asked for it did not. Redacted first, so a cut never splits a match the redactor needs.
54
+ */
55
+ redact: (text: string) => string = (text) => text,
50
56
  ): AiMessage {
51
57
  return {
52
58
  role: 'user',
@@ -54,7 +60,7 @@ export function toolResultTurn(
54
60
  ...results.map((result) => ({
55
61
  type: 'tool_result' as const,
56
62
  tool_use_id: result.toolUseId,
57
- content: truncate(result.content, chars),
63
+ content: truncate(redact(result.content), chars),
58
64
  // A denial or a failure is an outcome the model should read and react to, flagged so it
59
65
  // does not read as data.
60
66
  ...(result.isError === true ? { is_error: true } : {}),
package/src/agent.ts CHANGED
@@ -51,6 +51,7 @@ import type {
51
51
  StopReason,
52
52
  TokenUsage,
53
53
  } from './provider';
54
+ import { isTruncated } from './provider';
54
55
  import { assertNoSecrets } from './redaction';
55
56
  import { aiGateway, aiRedactor } from './runtime';
56
57
  import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
@@ -266,8 +267,15 @@ async function run<
266
267
  'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
267
268
  });
268
269
 
269
- const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
270
+ // Screened first, as before: a bad declaration is refused whether or not a gateway exists.
271
+ const limits = limitsOf(def);
272
+ // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
270
273
  const gateway = aiGateway(name);
274
+ const ledger = (
275
+ currentBudget() ??
276
+ gateway.callLedger?.() ??
277
+ new BudgetLedger({ limits: {} })
278
+ ).derive(limits);
271
279
  const base: GenerateRequest = {
272
280
  model,
273
281
  ...(system === undefined ? {} : { system }),
@@ -340,7 +348,7 @@ async function run<
340
348
  // the loop continues, and the answer is REJECTED on the wire rather than dropped,
341
349
  // because a replayed `respond` block with no `tool_result` is a 400 and the whole run
342
350
  // becomes an `X_AI_PROVIDER_UNAVAILABLE`.
343
- toolResultTurn(results, chars, answers),
351
+ toolResultTurn(results, chars, answers, redact),
344
352
  ];
345
353
  continue;
346
354
  }
@@ -350,7 +358,7 @@ async function run<
350
358
  // A wrong shape gets another turn like any other, because unlike `llm()` this loop has
351
359
  // turns left by construction — and unlike a tool result, the correction is the message.
352
360
  issues = formatIssues(parsed.issues).join('; ');
353
- if (result.stopReason === 'max_tokens') {
361
+ if (isTruncated(result.stopReason)) {
354
362
  throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
355
363
  }
356
364
  messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
package/src/budget.ts CHANGED
@@ -19,7 +19,11 @@ import { estimateCost, estimateInputTokens, estimateTokens, totalTokens } from '
19
19
 
20
20
  /** Ceilings. An omitted scope is unlimited — declare the ones that matter. */
21
21
  export interface BudgetLimits {
22
- /** Token ceiling for one `generate`/`stream` call, including its pre-flight estimate. */
22
+ /**
23
+ * Token ceiling for one call CHAIN — every `generate`/`stream` sharing a ledger, pre-flight
24
+ * estimates included. An `llm()` repair turn and an `agent()` turn each debit the same ledger,
25
+ * so this is not "one provider call" once a scope is open.
26
+ */
23
27
  readonly request?: number;
24
28
  /**
25
29
  * Prompt-token ceiling for ONE call. Distinct from `request`, which counts the completion
package/src/gateway.ts CHANGED
@@ -76,6 +76,12 @@ export interface Gateway {
76
76
  scope<T>(input: { actorKey?: string; orgKey?: string }, fn: () => Promise<T>): Promise<T>;
77
77
  /** Accumulated cost of the ambient scope, or zero outside one. */
78
78
  spent(): Promise<Money>;
79
+ /**
80
+ * A ledger over this gateway's own `budget`, for a call made with no `scope()` open — which is
81
+ * every call `llm()` and `agent()` make. Optional so a hand-written gateway still satisfies the
82
+ * interface; absent, such a call runs under the ceilings its own declaration sets and no others.
83
+ */
84
+ callLedger?(): BudgetLedger;
79
85
  }
80
86
 
81
87
  export function createGateway(input: CreateGatewayInput): Gateway {
@@ -105,6 +111,18 @@ class GatewayImpl implements Gateway {
105
111
  this.random = config.random;
106
112
  }
107
113
 
114
+ /**
115
+ * `budget` held with no scope open. It was enforced ONLY inside `scope()`, which nothing in the
116
+ * framework calls — so `createGateway({ budget: { request: 100 } })` capped no call any app made.
117
+ * No actor or org key here: those scopes need `scope()` to name whose window they are.
118
+ */
119
+ callLedger(): BudgetLedger {
120
+ return new BudgetLedger({
121
+ limits: this.config.budget ?? {},
122
+ ...(this.config.budgetStore !== undefined ? { store: this.config.budgetStore } : {}),
123
+ });
124
+ }
125
+
108
126
  scope<T>(input: { actorKey?: string; orgKey?: string }, fn: () => Promise<T>): Promise<T> {
109
127
  const ledger = new BudgetLedger({
110
128
  limits: this.config.budget ?? {},
@@ -135,7 +153,7 @@ class GatewayImpl implements Gateway {
135
153
  // Reserve against the ESTIMATE before spending anything — tokens AND money, since a
136
154
  // cheap-in-tokens call on an expensive model is still a cost cap the app declared.
137
155
  // `record` below replaces the estimate with the provider's real counts.
138
- const ledger = currentBudget();
156
+ const ledger = currentBudget() ?? this.callLedger();
139
157
  // The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
140
158
  // all read the same `spent()` otherwise, all pass, and all three record against a ceiling
141
159
  // only one of them fitted.
@@ -178,7 +196,7 @@ class GatewayImpl implements Gateway {
178
196
  // `generate`, or reconnects itself and knows what it has already shown.
179
197
  const provider = this.providerFor(model);
180
198
 
181
- const ledger = currentBudget();
199
+ const ledger = currentBudget() ?? this.callLedger();
182
200
  const reservation = await ledger?.reserve(estimateSpend(resolved));
183
201
  let settled = false;
184
202
  try {
@@ -319,6 +337,20 @@ export function backoffMs(policy: RetryPolicy, attempt: number, random?: Random)
319
337
  * package was how it stayed invisible. The `code` branch has no equivalent in core and stays here:
320
338
  * that table is HTTP status only, and a socket that timed out never produced one.
321
339
  */
340
+ /**
341
+ * A transport that never produced a response. Node spells these `ETIMEDOUT`/`ECONNRESET`; Bun's
342
+ * `fetch` spells a refused, dropped or unopenable connection `ConnectionRefused`,
343
+ * `ConnectionClosed` and `FailedToOpenSocket` — which this list did not know, so on the one runtime
344
+ * the framework runs on a provider mid-restart was never retried.
345
+ */
346
+ const RETRYABLE_TRANSPORT_CODES: ReadonlySet<string> = new Set([
347
+ 'ETIMEDOUT',
348
+ 'ECONNRESET',
349
+ 'ConnectionRefused',
350
+ 'ConnectionClosed',
351
+ 'FailedToOpenSocket',
352
+ ]);
353
+
322
354
  export function isRetryable(error: unknown): boolean {
323
355
  if (typeof error !== 'object' || error === null) return false;
324
356
  // A `Provider` is the APP's object, so the value it rejected with is one the framework did not
@@ -328,7 +360,7 @@ export function isRetryable(error: unknown): boolean {
328
360
  try {
329
361
  const e = error as { status?: unknown; code?: unknown };
330
362
  if (typeof e.status === 'number') return isRetryableStatus(e.status);
331
- return e.code === 'ETIMEDOUT' || e.code === 'ECONNRESET';
363
+ return typeof e.code === 'string' && RETRYABLE_TRANSPORT_CODES.has(e.code);
332
364
  } catch {
333
365
  return false;
334
366
  }
package/src/index.ts CHANGED
@@ -63,7 +63,6 @@ export type { EvalBaseline, Regression } from './eval-baseline';
63
63
  export {
64
64
  baselinePath,
65
65
  describeRegression,
66
- OVERALL,
67
66
  RECORD_ENV,
68
67
  readBaseline,
69
68
  recordingBaselines,
@@ -113,7 +112,6 @@ export {
113
112
  modelIds,
114
113
  modelSpec,
115
114
  moreCapableThan,
116
- reasoningBody,
117
115
  registeredModels,
118
116
  registerModel,
119
117
  resetModels,
@@ -133,11 +131,7 @@ export {
133
131
  conditionsSql,
134
132
  ddlSql,
135
133
  deleteSql,
136
- hybridSql,
137
134
  searchSql,
138
- textSql,
139
- upsertSql,
140
- vectorLiteral,
141
135
  } from './pg-vector-sql';
142
136
  export type { DefinePromptInput, Prompt, PromptVars } from './prompt';
143
137
  export {
@@ -169,6 +163,7 @@ export {
169
163
  estimateInputTokens,
170
164
  estimateTextTokens,
171
165
  estimateTokens,
166
+ isTruncated,
172
167
  messageText,
173
168
  parseMessage,
174
169
  requiresStreaming,
@@ -193,7 +188,6 @@ export {
193
188
  aiRedactor,
194
189
  configureAi,
195
190
  MAX_SEMANTIC_CACHE_SCOPES,
196
- resetAiRuntime,
197
191
  semanticCacheFor,
198
192
  } from './runtime';
199
193
  export type { Scorer } from './scorers';
@@ -225,6 +219,5 @@ export type {
225
219
  } from './vector';
226
220
  export { fuse, MemoryVectorStore } from './vector';
227
221
  export type { VectorScope } from './vector-scope';
228
- export { NO_TENANT, narrowScope, scopeAdmits, tenantOf, UNSCOPED } from './vector-scope';
222
+ export { NO_TENANT, tenantOf, UNSCOPED } from './vector-scope';
229
223
  export type { StreamState } from './wire';
230
- export { parseStopDetails, ZERO_USAGE } from './wire';
package/src/llm.ts CHANGED
@@ -45,6 +45,7 @@ import type { ModelId } from './models';
45
45
  import { DEFAULT_MODEL, moreCapableThan } from './models';
46
46
  import type { Prompt, PromptVars } from './prompt';
47
47
  import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
48
+ import { isTruncated } from './provider';
48
49
  import { assertNoSecrets } from './redaction';
49
50
  import { aiGateway, aiRedactor } from './runtime';
50
51
  import type { LlmTool } from './tools';
@@ -254,10 +255,15 @@ async function generate<
254
255
  // A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
255
256
  // and org ceilings this call runs inside, never widen them. The gateway reserves against
256
257
  // it before the provider is touched — that is where `X_AI_BUDGET_EXCEEDED` comes from.
257
- const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(
258
- limitsOf(def.budget),
259
- );
258
+ // Screened first, as before: a bad declaration is refused whether or not a gateway exists.
259
+ const limits = limitsOf(def.budget);
260
+ // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
260
261
  const gateway = aiGateway(name);
262
+ const ledger = (
263
+ currentBudget() ??
264
+ gateway.callLedger?.() ??
265
+ new BudgetLedger({ limits: {} })
266
+ ).derive(limits);
261
267
  const sink = currentLlmSink();
262
268
 
263
269
  return withBudget(ledger, async () => {
@@ -293,7 +299,7 @@ async function generate<
293
299
  }
294
300
  // A cut-off answer that also fails its schema is not a disagreement about the shape: the
295
301
  // ceiling is the same on the next attempt, so the repair turn is a second truncation.
296
- if (result.stopReason === 'max_tokens') {
302
+ if (isTruncated(result.stopReason)) {
297
303
  throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
298
304
  }
299
305
  issues = formatIssues(parsed.issues).join('; ');
@@ -352,7 +358,7 @@ async function streamedAnswer<TOutput extends StandardSchemaV1>(
352
358
  explanation: result.stopDetails?.explanation,
353
359
  });
354
360
  }
355
- if (result.stopReason === 'max_tokens') {
361
+ if (isTruncated(result.stopReason)) {
356
362
  throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
357
363
  }
358
364
  // Prose, and its JSON parse when it has one. Both, because a stream carries no `respond` tool
@@ -192,6 +192,19 @@ order by f.score desc, d."id" asc
192
192
  limit ${args.k}`;
193
193
  }
194
194
 
195
+ /** Every row the scope and `filter` admit, except the ids in `keep` — a re-index's cleanup. */
196
+ export function pruneSql(
197
+ target: PgVectorTable,
198
+ scope: VectorScope,
199
+ filter: MetadataFilter,
200
+ keep: readonly string[],
201
+ ): SqlFragment {
202
+ const kept =
203
+ keep.length === 0 ? ALWAYS : sql`"id" not in (${join(keep.map((id) => sql`${id}`))})`;
204
+ return sql`delete from ${identifier(target.table)}
205
+ where ${kept} and ${conditionsSql(scope, filter)}`;
206
+ }
207
+
195
208
  export function deleteSql(
196
209
  target: PgVectorTable,
197
210
  scope: VectorScope,
package/src/pg-vector.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  hybridSql,
13
13
  type PgHybridArgs,
14
14
  type PgVectorTable,
15
+ pruneSql,
15
16
  searchSql,
16
17
  textSql,
17
18
  upsertSql,
@@ -87,11 +88,15 @@ export class PgVectorStore implements VectorStore {
87
88
  async upsert(records: readonly VectorRecord[]): Promise<void> {
88
89
  if (records.length === 0) return;
89
90
  for (const record of records) this.assertDimension(record.vector.length);
91
+ // One row per id, the LAST one winning — what `MemoryVectorStore` answers. Postgres refuses a
92
+ // statement whose `on conflict do update` touches one row twice, so a batch carrying an id
93
+ // twice failed here and succeeded there.
94
+ const unique = [...new Map(records.map((record) => [record.id, record])).values()];
90
95
  await this.client().execute(
91
96
  upsertSql(
92
97
  this.target,
93
98
  tenantOf(this.scope),
94
- records.map((record) => ({
99
+ unique.map((record) => ({
95
100
  id: record.id,
96
101
  vector: record.vector,
97
102
  text: record.text,
@@ -142,6 +147,10 @@ export class PgVectorStore implements VectorStore {
142
147
  return this.run(hybridSql(this.target, input.query, input.vector, args));
143
148
  }
144
149
 
150
+ async prune(filter: MetadataFilter, keep: readonly string[]): Promise<void> {
151
+ await this.client().execute(pruneSql(this.target, this.scope, filter, keep));
152
+ }
153
+
145
154
  async delete(ids: readonly string[]): Promise<void> {
146
155
  if (ids.length === 0) return;
147
156
  await this.client().execute(deleteSql(this.target, this.scope, ids));
package/src/provider.ts CHANGED
@@ -102,7 +102,13 @@ export type StopReason =
102
102
  | 'stop_sequence'
103
103
  | 'tool_use'
104
104
  | 'pause_turn'
105
- | 'refusal';
105
+ | 'refusal'
106
+ /** The prompt plus the answer outgrew the model's context window: cut off, like `max_tokens`. */
107
+ | 'model_context_window_exceeded';
108
+
109
+ /** Whether an answer was CUT OFF — never parse one of these as a complete answer. */
110
+ export const isTruncated = (reason: StopReason): boolean =>
111
+ reason === 'max_tokens' || reason === 'model_context_window_exceeded';
106
112
 
107
113
  /**
108
114
  * Why a refusal happened. Only ever present when `stopReason` is `refusal`, and `category` is
package/src/rag.ts CHANGED
@@ -154,7 +154,12 @@ function cutToBudget(run: string, size: number): readonly string[] {
154
154
  return pieces;
155
155
  }
156
156
 
157
- /** Index a document: chunk, embed, upsert. One call so no step is skipped by accident. */
157
+ /**
158
+ * Index a document: chunk, embed, upsert, prune. One call so no step is skipped by accident. The
159
+ * prune is what re-indexing a SHORTER document needs: chunk ids are `<doc>#<n>`, so the upsert
160
+ * overwrote the first chunks and left the old tail retrievable. Upsert first, prune after, so the
161
+ * document is never absent from a search in between. A store without `prune` cannot clean up.
162
+ */
158
163
  export async function indexDocument(input: {
159
164
  readonly store: VectorStore;
160
165
  readonly embedder: Embedder;
@@ -176,6 +181,11 @@ export async function indexDocument(input: {
176
181
  metadata: c.metadata,
177
182
  })),
178
183
  );
184
+ // `source` is the chunker's own stamp (`chunk()`), so only THIS document's rows are touched.
185
+ await input.store.prune?.(
186
+ { source: input.document.id },
187
+ chunks.map((c) => c.id),
188
+ );
179
189
  return chunks;
180
190
  }
181
191
 
@@ -132,7 +132,7 @@ export class RemoteEmbedder implements Embedder {
132
132
  throw new AiTransportError({
133
133
  provider: this.name,
134
134
  status: response.status,
135
- detail: (await response.text().catch(() => '')).slice(0, DETAIL_LIMIT),
135
+ detail: await detailOf(response.body),
136
136
  envVar: API_KEY_ENV,
137
137
  });
138
138
  }
@@ -218,3 +218,36 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
218
218
  function countOf(data: unknown): string {
219
219
  return Array.isArray(data) ? String(data.length) : 'no data array';
220
220
  }
221
+
222
+ /**
223
+ * The start of an error body, and never more. `response.text()` read it WHOLE — outside
224
+ * `maxResponseBytes`, which bounds a success only — so a provider streaming an endless 5xx body
225
+ * held the call and its memory forever. Capped through the same counting reader, and decoded from
226
+ * the bytes it kept: `over` still has a detail worth showing.
227
+ */
228
+ async function detailOf(body: ReadableStream<Uint8Array> | null): Promise<string> {
229
+ if (body === null) return '';
230
+ const reader = body.getReader();
231
+ const kept: Uint8Array[] = [];
232
+ let total = 0;
233
+ try {
234
+ while (total < DETAIL_LIMIT) {
235
+ const { done, value } = await reader.read();
236
+ if (done) break;
237
+ kept.push(value);
238
+ total += value.byteLength;
239
+ }
240
+ } catch {
241
+ // A body that fails mid-read still has whatever arrived; the status is the error's substance.
242
+ } finally {
243
+ await reader.cancel().catch(() => undefined);
244
+ reader.releaseLock();
245
+ }
246
+ const bytes = new Uint8Array(total);
247
+ let offset = 0;
248
+ for (const chunk of kept) {
249
+ bytes.set(chunk, offset);
250
+ offset += chunk.byteLength;
251
+ }
252
+ return new TextDecoder().decode(bytes).slice(0, DETAIL_LIMIT);
253
+ }
package/src/vector.ts CHANGED
@@ -53,6 +53,13 @@ export interface VectorStore {
53
53
  searchText(query: string, k: number, filter?: MetadataFilter): Promise<readonly SearchHit[]>;
54
54
  hybrid(input: HybridSearchInput): Promise<readonly SearchHit[]>;
55
55
  delete(ids: readonly string[]): Promise<void>;
56
+ /**
57
+ * Delete every row `filter` matches EXCEPT the ids in `keep`, inside this store's scope. What a
58
+ * re-index needs: `indexDocument` upserts a document's new chunks, then prunes the ones the
59
+ * shorter text no longer produced. Optional so a hand-written store still satisfies the type;
60
+ * without it a re-index cannot clean up, and says so in `indexDocument`'s doc.
61
+ */
62
+ prune?(filter: MetadataFilter, keep: readonly string[]): Promise<void>;
56
63
  }
57
64
 
58
65
  export interface MemoryVectorStoreInput {
@@ -188,6 +195,15 @@ export class MemoryVectorStore implements VectorStore {
188
195
  return fuse([dense, lexical], rrfK).slice(0, k);
189
196
  }
190
197
 
198
+ /** Scoped and filtered, exactly like `pruneSql`: everything `filter` matches but `keep`. */
199
+ async prune(filter: MetadataFilter, keep: readonly string[]): Promise<void> {
200
+ const kept = new Set(keep);
201
+ for (const record of this.candidates(filter)) {
202
+ if (kept.has(record.id)) continue;
203
+ for (const [key, stored] of this.records) if (stored === record) this.records.delete(key);
204
+ }
205
+ }
206
+
191
207
  /** Scoped, exactly like the SQL `delete ... where id in (...) and <scope>`. */
192
208
  async delete(ids: readonly string[]): Promise<void> {
193
209
  const wanted = new Set(ids);
package/src/wire.ts CHANGED
@@ -59,10 +59,16 @@ const STOP_REASONS: readonly StopReason[] = [
59
59
  'tool_use',
60
60
  'pause_turn',
61
61
  'refusal',
62
+ 'model_context_window_exceeded',
62
63
  ];
63
64
 
65
+ /**
66
+ * FAILS CLOSED: a reason this list does not know is read as `max_tokens`. It fell back to
67
+ * `end_turn`, so `model_context_window_exceeded` — a cut-off answer — was parsed as a complete
68
+ * one. A provider's new reason is far likelier to be a way of stopping early than of finishing.
69
+ */
64
70
  export function parseStopReason(raw: unknown): StopReason {
65
- return STOP_REASONS.find((reason) => reason === raw) ?? 'end_turn';
71
+ return STOP_REASONS.find((reason) => reason === raw) ?? 'max_tokens';
66
72
  }
67
73
 
68
74
  /**