akm-cli 0.9.14 → 0.9.15-beta.2

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 (120) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/akm +54 -1
  4. package/dist/akm-migrate +34 -1
  5. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  6. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  7. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  8. package/dist/assets/tasks/core/improve.yml +1 -1
  9. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  13. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  14. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  15. package/dist/cli/retired-commands.js +0 -1
  16. package/dist/cli/shared.js +9 -0
  17. package/dist/cli/unknown-flags.js +1 -0
  18. package/dist/cli.js +40 -3
  19. package/dist/commands/config-cli.js +85 -3
  20. package/dist/commands/env/env-cli.js +1 -42
  21. package/dist/commands/env/env.js +1 -1
  22. package/dist/commands/env/secret-cli.js +1 -2
  23. package/dist/commands/health/checks.js +357 -63
  24. package/dist/commands/health/engine-usage.js +45 -0
  25. package/dist/commands/health/improve-metrics.js +18 -0
  26. package/dist/commands/health/llm-usage.js +41 -1
  27. package/dist/commands/health/plugin-staleness.js +7 -3
  28. package/dist/commands/health/version-drift.js +93 -0
  29. package/dist/commands/health/windows.js +3 -1
  30. package/dist/commands/health.js +44 -9
  31. package/dist/commands/improve/consolidate/chunking.js +4 -2
  32. package/dist/commands/improve/improve-cli.js +99 -5
  33. package/dist/commands/improve/improve-report.js +154 -0
  34. package/dist/commands/improve/improve-result-file.js +45 -33
  35. package/dist/commands/improve/improve-strategies.js +133 -3
  36. package/dist/commands/improve/improve-usage-report.js +182 -0
  37. package/dist/commands/improve/improve.js +40 -3
  38. package/dist/commands/improve/locks.js +28 -78
  39. package/dist/commands/improve/planner.js +1 -0
  40. package/dist/commands/improve/preparation.js +9 -1
  41. package/dist/commands/improve/reflect.js +44 -4
  42. package/dist/commands/models-cli.js +50 -1
  43. package/dist/commands/proposal/repository.js +8 -3
  44. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  45. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  46. package/dist/commands/read/search-cli.js +38 -2
  47. package/dist/commands/read/show.js +103 -4
  48. package/dist/commands/sources/info.js +5 -1
  49. package/dist/commands/sources/installed-stashes.js +58 -16
  50. package/dist/commands/sources/self-update.js +2 -2
  51. package/dist/commands/sources/stash-cli.js +48 -0
  52. package/dist/commands/tasks/tasks-cli.js +49 -2
  53. package/dist/commands/workflow-cli.js +86 -12
  54. package/dist/core/asset/markdown-fragments.js +35 -0
  55. package/dist/core/config/config-schema.js +14 -0
  56. package/dist/core/config/config.js +302 -24
  57. package/dist/core/config/schema/embedding.js +41 -0
  58. package/dist/core/env-secret-ref.js +58 -5
  59. package/dist/core/errors.js +30 -0
  60. package/dist/core/file-lock.js +49 -15
  61. package/dist/core/improve-result.js +51 -0
  62. package/dist/core/loopback.js +17 -0
  63. package/dist/core/parent-watchdog.js +64 -0
  64. package/dist/core/paths.js +11 -0
  65. package/dist/core/run-lock.js +107 -0
  66. package/dist/core/sensitive-marker-path.js +19 -0
  67. package/dist/core/state-db.js +74 -14
  68. package/dist/indexer/index-rebuild-lock.js +73 -0
  69. package/dist/indexer/index-writer-lock.js +40 -1
  70. package/dist/indexer/index-written-assets.js +29 -1
  71. package/dist/indexer/indexer.js +93 -29
  72. package/dist/indexer/materialize-embeddings.js +564 -48
  73. package/dist/indexer/search/db-search.js +49 -2
  74. package/dist/indexer/search/search-source.js +23 -1
  75. package/dist/integrations/agent/engine-resolution.js +96 -6
  76. package/dist/integrations/agent/execution-definitions.js +6 -15
  77. package/dist/integrations/agent/execution-lowering.js +6 -1
  78. package/dist/integrations/agent/execution-preparation.js +1 -1
  79. package/dist/integrations/agent/model-map.js +123 -20
  80. package/dist/integrations/agent/prompts.js +40 -8
  81. package/dist/integrations/agent/runner-dispatch.js +9 -3
  82. package/dist/integrations/agent/runner.js +2 -0
  83. package/dist/llm/client.js +8 -3
  84. package/dist/llm/embedder.js +20 -8
  85. package/dist/llm/embedders/local.js +10 -2
  86. package/dist/llm/embedders/remote.js +497 -32
  87. package/dist/output/shapes/helpers.js +38 -2
  88. package/dist/output/shapes/models-list.js +16 -0
  89. package/dist/output/shapes/passthrough.js +2 -0
  90. package/dist/output/shapes.js +4 -0
  91. package/dist/output/text/command-format.js +29 -0
  92. package/dist/output/text/helpers.js +1 -1
  93. package/dist/output/text/improve-report.js +27 -0
  94. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  95. package/dist/output/text/show-format.js +4 -0
  96. package/dist/output/text.js +4 -0
  97. package/dist/scripts/akm-migrate-node.js +25146 -21759
  98. package/dist/scripts/akm-migrate.js +24271 -20885
  99. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  100. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  101. package/dist/storage/repositories/index-fts-repository.js +49 -6
  102. package/dist/storage/repositories/index-schema.js +16 -0
  103. package/dist/storage/repositories/index-vec-repository.js +30 -0
  104. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  105. package/dist/tasks/backends/cron.js +14 -7
  106. package/dist/tasks/run/run-native-task.js +23 -1
  107. package/dist/tasks/run/run-workflow-task.js +16 -0
  108. package/dist/workflows/exec/child-workflow.js +2 -2
  109. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  110. package/dist/workflows/exec/run-workflow.js +6 -5
  111. package/dist/workflows/runtime/runs.js +33 -5
  112. package/docs/migration/release-notes/0.9.15.md +133 -0
  113. package/docs/migration/release-notes/README.md +5 -0
  114. package/docs/reference/cli.md +271 -30
  115. package/docs/reference/configuration.md +234 -21
  116. package/docs/reference/data-and-telemetry.md +8 -0
  117. package/docs/reference/tasks.md +16 -1
  118. package/docs/reference/workflow-schema.md +5 -1
  119. package/package.json +1 -1
  120. package/schemas/akm-config.json +47 -0
@@ -7,8 +7,11 @@
7
7
  * Calls the configured `/embeddings` endpoint and L2-normalizes the returned
8
8
  * vectors so the scoring pipeline's L2-to-cosine conversion is correct.
9
9
  */
10
- import { fetchWithTimeout, isHttpUrl, readBodyWithByteCap } from "../../core/common.js";
10
+ import { abortableDelay, backoffDelay, fetchWithTimeout, isHttpUrl, readBodyWithByteCap } from "../../core/common.js";
11
+ import { concurrentMap } from "../../core/concurrent.js";
11
12
  import { resolveSecret } from "../../core/config/config.js";
13
+ import { ENV_REFERENCE_PATTERN, SECRET_STORE_REFERENCE_PATTERN } from "../../core/config/schema/primitives.js";
14
+ import { defaultConcurrencyForEndpoint } from "../../core/loopback.js";
12
15
  import { redactErrorBody, redactSensitiveText } from "../../core/redaction.js";
13
16
  import { warnVerbose } from "../../core/warn.js";
14
17
  import { resolveSecretFromStore } from "../../sources/snapshot-fetchers/secret-seam.js";
@@ -22,7 +25,8 @@ import { resolveSecretFromStore } from "../../sources/snapshot-fetchers/secret-s
22
25
  export const DEFAULT_REMOTE_BATCH_SIZE = 100;
23
26
  /**
24
27
  * Conservative default token budget per HTTP request when the config gives
25
- * no better number (`maxTokens` or `contextLength`). #874's measurements:
28
+ * no better number (`maxTokens` see #956 for why `contextLength`
29
+ * no longer feeds this). #874's measurements:
26
30
  * a batch of 100 small docs (~400 KB, ~100K tokens) took 14.8s against a
27
31
  * healthy local endpoint — half the 30s request timeout — and a single
28
32
  * 128 KB (~24K token) document alone was rejected by the endpoint as
@@ -34,6 +38,167 @@ export const DEFAULT_TOKEN_BUDGET = 8000;
34
38
  export function estimateTokenCount(text) {
35
39
  return Math.round(text.length / 4);
36
40
  }
41
+ /**
42
+ * Default per-document embedding cap (`embedding.maxInputTokens`, #956)
43
+ * — the materializer truncates a document's embedded text to
44
+ * this cap (head only) instead of skipping it outright, so one oversized
45
+ * entry can no longer fail a whole batch. Fragments are not embedded at all
46
+ * (only the entry's own search text is), so this is the only lever on how
47
+ * much of a large document contributes to its vector.
48
+ */
49
+ export const DEFAULT_MAX_INPUT_TOKENS = 512;
50
+ /**
51
+ * Truncate `text` to at most `maxTokens` (estimated via
52
+ * {@link estimateTokenCount}, the same 4-chars≈1-token rule the batching
53
+ * budget uses), keeping only its head. The cut never splits a UTF-16
54
+ * surrogate pair. Text already at or under the cap is returned unchanged
55
+ * (`truncated: false`) — including empty text, which is never itself
56
+ * "truncated".
57
+ */
58
+ export function capEmbeddingText(text, maxTokens) {
59
+ if (estimateTokenCount(text) <= maxTokens)
60
+ return { text, truncated: false };
61
+ const charBudget = Math.max(0, maxTokens * 4);
62
+ let cut = Math.min(charBudget, text.length);
63
+ if (cut > 0 && cut < text.length) {
64
+ const code = text.charCodeAt(cut);
65
+ // A low surrogate (0xDC00-0xDFFF) at the cut point means its high
66
+ // surrogate is the character just before it — back off one position so
67
+ // the pair stays together rather than yielding a lone surrogate.
68
+ if (code >= 0xdc00 && code <= 0xdfff)
69
+ cut -= 1;
70
+ }
71
+ return { text: text.slice(0, cut), truncated: true };
72
+ }
73
+ /**
74
+ * Default per-request timeout when `embedding.timeoutMs` is unset (#954).
75
+ * The prior fixed 30s cut off exactly the field-report case: a
76
+ * local model server on an 8000-token (`DEFAULT_TOKEN_BUDGET`) batch
77
+ * legitimately takes longer than that, and the timeout fired mid-response
78
+ * with no retry — every batch it hit was silently dropped for the rest of
79
+ * an hours-long run. 120s comfortably covers a slow local batch while still
80
+ * bounding a genuinely dead endpoint to a few minutes, not forever.
81
+ */
82
+ export const DEFAULT_EMBEDDING_TIMEOUT_MS = 120_000;
83
+ /** Resolve the effective per-request timeout: `embedding.timeoutMs` when set, else the default above. */
84
+ export function resolveEmbeddingTimeoutMs(config) {
85
+ return config.timeoutMs ?? DEFAULT_EMBEDDING_TIMEOUT_MS;
86
+ }
87
+ /**
88
+ * Scale the per-request timeout down for a smaller-than-budget request
89
+ * (#954, field-report follow-up): `embedding.timeoutMs` /
90
+ * {@link resolveEmbeddingTimeoutMs} is the budget for a request at the FULL
91
+ * token budget; a batch using only a fraction of it gets a proportionally
92
+ * smaller timeout, floored at 30s and never above the configured
93
+ * `timeoutMs` itself, so a dead server is detected in seconds on the common
94
+ * case of small documents instead of always waiting out the full configured
95
+ * budget.
96
+ */
97
+ export function scaleEmbeddingTimeoutMs(timeoutMs, requestTokens, tokenBudget) {
98
+ const scaled = tokenBudget > 0 ? timeoutMs * (requestTokens / tokenBudget) : timeoutMs;
99
+ return Math.min(Math.max(scaled, 30_000), timeoutMs);
100
+ }
101
+ /**
102
+ * True when `err` is a request- or body-read timeout (#954) —
103
+ * `fetchWithTimeout`'s connection/header timeout ("Request timed out
104
+ * after...") or `readBodyWithByteCap`'s body-phase `BodyReadTimeoutError`.
105
+ * Only this failure mode gets the back-off-and-retry treatment:
106
+ * the field evidence was specifically that a timed-out request keeps
107
+ * computing server-side, so abandoning it immediately (the prior
108
+ * behavior) just grows the provider's queue further. A genuine network/HTTP
109
+ * failure (connection refused, malformed response, a real error response)
110
+ * has no such still-in-flight hazard and keeps the original
111
+ * skip-immediately behavior.
112
+ */
113
+ export function isEmbeddingTimeoutError(err) {
114
+ if (!(err instanceof Error))
115
+ return false;
116
+ if (err.name === "BodyReadTimeoutError")
117
+ return true;
118
+ return err.message.startsWith("Request timed out after ");
119
+ }
120
+ /** TEST-ONLY seam: override the backoff base/max so retry-backoff tests run fast without waiting real seconds. */
121
+ let embeddingTimeoutBackoffOverrideForTests;
122
+ /** TEST-ONLY. Pass undefined to restore the real 5s/60s backoff. */
123
+ export function _setEmbeddingTimeoutBackoffForTests(config) {
124
+ embeddingTimeoutBackoffOverrideForTests = config;
125
+ }
126
+ /**
127
+ * Backoff before the single same-size retry on a request timeout
128
+ * (#954) — reuses the same jittered
129
+ * exponential formula {@link backoffDelay} uses for the rest of the
130
+ * codebase's retry paths, at a base of "5s, doubling, capped at 60s".
131
+ *
132
+ * `timeoutAttempt` (#954, field-report follow-up) is how many times the
133
+ * SAME-SIZE-retry-then-split chain has already split before reaching this
134
+ * size — 0 at the top level. The first-timeout backoff for each successive,
135
+ * smaller size after a split grows with it (5s, then doubling, capped at
136
+ * 60s) instead of every split resetting to a flat ~5s: the field evidence
137
+ * was that an abandoned request keeps computing server-side, so a server
138
+ * already draining a whole chain of abandoned requests needs progressively
139
+ * more room, not the same fixed pause at every size.
140
+ */
141
+ export function embeddingTimeoutRetryBackoffMs(timeoutAttempt = 0) {
142
+ const { baseMs, maxMs } = embeddingTimeoutBackoffOverrideForTests ?? { baseMs: 5_000, maxMs: 60_000 };
143
+ return backoffDelay(timeoutAttempt, baseMs, maxMs);
144
+ }
145
+ /**
146
+ * Distinguishes a batch rejected because it exceeded the endpoint's context
147
+ * window from every other failure mode (network error, 5xx, malformed
148
+ * response). Only this error class triggers the split-in-half retry in
149
+ * {@link RemoteEmbedder.embedBatch} (#954) — anything else keeps the
150
+ * original skip-the-whole-batch behavior pinned by
151
+ * tests/integration/embedding-batch-partial-failure.test.ts.
152
+ */
153
+ export class ContextExceededError extends Error {
154
+ constructor(message) {
155
+ super(message);
156
+ this.name = "ContextExceededError";
157
+ }
158
+ }
159
+ /**
160
+ * Patterns providers use to report a request too large for the model's
161
+ * context window. `input is too large to process`/`physical batch size`/
162
+ * `ubatch` (#954) cover llama.cpp's own physical-batch
163
+ * rejection (HTTP 500, e.g. "input is too large to process. increase the
164
+ * physical batch size"), which was previously an unrecognized generic
165
+ * failure — the whole batch was dropped instead of split and retried.
166
+ */
167
+ const CONTEXT_EXCEEDED_PATTERN = /exceed_context_size_error|context size|context length|too many tokens|input is too large to process|physical batch size|ubatch/i;
168
+ /**
169
+ * True when an HTTP failure means "this request's input is too large for the
170
+ * endpoint's context window" rather than some other failure. HTTP 413
171
+ * (Payload Too Large) is always treated this way regardless of body; other
172
+ * status codes are checked against known provider error-body phrasing.
173
+ */
174
+ export function isContextExceededResponse(status, body) {
175
+ if (status === 413)
176
+ return true;
177
+ return CONTEXT_EXCEEDED_PATTERN.test(body);
178
+ }
179
+ /**
180
+ * Resolve the effective in-flight request window for `RemoteEmbedder.embedBatch`.
181
+ * Default (unset `embedding.concurrency`): 1 for a loopback endpoint, 2 for a
182
+ * remote one, via the shared `defaultConcurrencyForEndpoint`
183
+ * (`src/core/loopback.ts`), the same lowest-common-denominator rule
184
+ * `getDefaultLlmConcurrency` (`src/indexer/indexer.ts`) uses.
185
+ *
186
+ * `embedding.concurrency` (#954) overrides this default in
187
+ * either direction, bounded 1-16 at the config schema — added after field
188
+ * evidence that a multi-slot local server (llama.cpp `--parallel N`, vLLM)
189
+ * genuinely serves parallel requests and was left idle by the fixed default.
190
+ * Request SIZE remains the first throughput lever regardless:
191
+ * `embedding.batchSize` (document cap) and `embedding.maxTokens` (request
192
+ * token budget — see #956; `contextLength` no longer feeds it)
193
+ * reach a larger batch per request, which is where most of the win is for a
194
+ * single-slot server — a 32-input batch takes about the same wall time as
195
+ * one input against a healthy endpoint.
196
+ */
197
+ export function resolveEmbeddingConcurrency(config) {
198
+ if (typeof config.concurrency === "number")
199
+ return config.concurrency;
200
+ return defaultConcurrencyForEndpoint(config.endpoint);
201
+ }
37
202
  /**
38
203
  * Group `texts` into request-sized batches bounded by BOTH an estimated
39
204
  * token budget and a document-count cap, so one large document does not
@@ -95,6 +260,7 @@ export class RemoteEmbedder {
95
260
  if (ollamaOpts) {
96
261
  body.options = ollamaOpts;
97
262
  }
263
+ const timeoutMs = resolveEmbeddingTimeoutMs(this.config);
98
264
  // `signal` MUST go through fetchWithTimeout's dedicated 4th parameter, not
99
265
  // the RequestInit: fetchWithTimeout replaces `opts.signal` with its own
100
266
  // controller (`{ ...opts, signal: controller.signal }`), so a signal passed
@@ -103,16 +269,16 @@ export class RemoteEmbedder {
103
269
  method: "POST",
104
270
  headers,
105
271
  body: JSON.stringify(body),
106
- }, 30_000, signal);
272
+ }, timeoutMs, signal);
107
273
  if (!response.ok) {
108
- const errBody = await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }).catch((err) => {
274
+ const errBody = await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: timeoutMs, signal }).catch((err) => {
109
275
  if (signal?.aborted)
110
276
  throw err;
111
277
  return "";
112
278
  });
113
279
  throw new Error(`Embedding request failed (${response.status}): ${this.safeErrorBody(errBody)}`);
114
280
  }
115
- const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }));
281
+ const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: timeoutMs, signal }));
116
282
  if (!json.data?.[0]?.embedding) {
117
283
  throw new Error(`Unexpected embedding response format: missing data[0].embedding.${embeddingEndpointPathHint(this.endpoint)}`);
118
284
  }
@@ -130,50 +296,326 @@ export class RemoteEmbedder {
130
296
  * `onSkip` (index into `texts` + a named reason) rather than silently
131
297
  * dropped; the returned array holds `undefined` at every skipped index.
132
298
  * A caller abort (`signal.aborted`) still propagates as a rejection.
299
+ *
300
+ * Provider batches are dispatched through a bounded pool sized by
301
+ * `resolveEmbeddingConcurrency` (#954) instead of strictly sequentially, so
302
+ * request latency overlaps. `onBatch`, when given, fires once per provider
303
+ * batch (success or skip) as it completes, only after that batch's own
304
+ * outcome has already been classified — pass it to commit each batch's
305
+ * rows durably as they land rather than buffering the whole call.
306
+ * Completion order does not matter to `results` placement, which is always
307
+ * written by index regardless of dispatch order. A throw from `onBatch`
308
+ * itself (e.g. the caller's own commit failing) is never mistaken for a
309
+ * provider/network failure; it is captured and rethrown once every batch
310
+ * has been dispatched, alongside the `signal.aborted` check. The FIRST
311
+ * `onBatch` throw also stops the pool from dispatching any further
312
+ * provider request (#954 gap fix) — a persistence failure means every
313
+ * later batch's result can never be committed either, so there is no
314
+ * point paying for more HTTP requests; a batch already in flight when this
315
+ * happens is left to finish (never network-aborted) but its result is
316
+ * discarded rather than committed.
317
+ *
318
+ * A batch rejected specifically for exceeding the endpoint's context window
319
+ * (HTTP 413, or a recognised context-size error body — see
320
+ * {@link isContextExceededResponse}) is split in half and retried
321
+ * recursively rather than skipped outright, down to individual documents; a
322
+ * single document that still fails this way becomes a genuine
323
+ * `context-window-exceeded` skip.
324
+ *
325
+ * A request TIMEOUT (see {@link isEmbeddingTimeoutError}) never drops the
326
+ * batch outright (#954): the field evidence
327
+ * was that akm abandoning a timed-out request does not stop the server
328
+ * from still computing it, so immediately skipping (or immediately
329
+ * splitting, the prior behavior) just let the provider's queue grow
330
+ * while every following batch died the same way. Instead, on a timeout,
331
+ * this backs off ({@link embeddingTimeoutRetryBackoffMs}) and retries the
332
+ * SAME request once; a second timeout splits it in half (like a
333
+ * context-size rejection) and retries each half the same way, down to
334
+ * single documents — a single document that times out twice is finally
335
+ * skipped. Every other failure (network error, malformed response, a
336
+ * non-timeout HTTP failure) keeps the original skip-the-whole-batch-
337
+ * immediately behavior, at any size. The per-request timeout itself also
338
+ * scales down with the request's estimated size via
339
+ * {@link scaleEmbeddingTimeoutMs}, so a dead server is detected in seconds
340
+ * on a small batch rather than always waiting out the full configured
341
+ * `embedding.timeoutMs`.
133
342
  */
134
- async embedBatch(texts, signal, onSkip) {
343
+ async embedBatch(texts, signal, onSkip, onBatch) {
135
344
  if (texts.length === 0)
136
345
  return [];
137
346
  const results = new Array(texts.length).fill(undefined);
138
347
  const headers = this.buildHeaders();
139
348
  const ollamaOpts = resolveOllamaOptions(this.config);
140
- const tokenBudget = this.config.maxTokens ?? this.config.contextLength ?? DEFAULT_TOKEN_BUDGET;
349
+ // #956: `contextLength` is Ollama's `num_ctx` ONLY (see
350
+ // resolveOllamaOptions below) — it used to double as this client-side
351
+ // request budget too, so a config author setting it for one purpose
352
+ // silently changed the other. `maxTokens` is the sole knob for the
353
+ // request budget now; unset falls back to DEFAULT_TOKEN_BUDGET.
354
+ const tokenBudget = this.config.maxTokens ?? DEFAULT_TOKEN_BUDGET;
141
355
  const maxCount = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
142
- const batches = buildTokenBoundedBatches(texts, tokenBudget, maxCount);
143
- for (const textBatch of batches) {
144
- if (textBatch.oversized) {
145
- const idx = textBatch.indices[0];
146
- const estTokens = estimateTokenCount(texts[idx]);
147
- onSkip?.({
148
- index: idx,
149
- reason: "context-window-exceeded",
150
- message: `Document estimated at ${estTokens} tokens exceeds the ${tokenBudget}-token embedding budget; skipped.`,
151
- });
152
- continue;
356
+ const textBatches = buildTokenBoundedBatches(texts, tokenBudget, maxCount);
357
+ const configuredTimeoutMs = resolveEmbeddingTimeoutMs(this.config);
358
+ // Stops the pool from claiming any FURTHER provider batch once the
359
+ // caller's onBatch has failed once (the materializer's transaction
360
+ // failed, so a subsequent commit would just fail again) — dispatching
361
+ // real HTTP requests whose results can never be persisted is pure waste.
362
+ // Deliberately a SEPARATE controller from the caller's own `signal`,
363
+ // chained one-way to it: aborting this one must never cancel an
364
+ // in-flight request's own network call (it is left to finish and its
365
+ // result is discarded, not persisted), and a genuine caller abort must
366
+ // still surface below as the caller's own abort reason, not this
367
+ // internal one.
368
+ const dispatchAbort = new AbortController();
369
+ const stopDispatch = (reason) => {
370
+ if (!dispatchAbort.signal.aborted)
371
+ dispatchAbort.abort(reason);
372
+ };
373
+ let callerAbortListener;
374
+ if (signal) {
375
+ if (signal.aborted)
376
+ stopDispatch(signal.reason);
377
+ else {
378
+ callerAbortListener = () => stopDispatch(signal.reason);
379
+ signal.addEventListener("abort", callerAbortListener, { once: true });
153
380
  }
154
- const batch = textBatch.indices.map((i) => texts[i]);
381
+ }
382
+ // First error thrown BY the caller's onBatch callback (e.g. a real
383
+ // competing-process SQLITE_BUSY from the materializer's db.transaction())
384
+ // rather than by requestBatch itself. Captured here instead of being left
385
+ // to reach requestAndCommit's try/catch below, which exists solely to
386
+ // classify requestBatch's own provider/network failures — a persistence
387
+ // failure must never be caught by that block and misreported as a
388
+ // fabricated "batch-request-failed" skip (#954). Checked (and rethrown)
389
+ // once the pool drains, the same way `signal?.aborted` is today.
390
+ let firstOnBatchError;
391
+ const commitBatch = (indices, embeddings, model, outcome) => {
392
+ if (!onBatch)
393
+ return;
394
+ // Once persistence has failed once, an already in-flight batch that
395
+ // finishes afterward has nowhere safe to land — its result is
396
+ // discarded rather than retried into a transaction that will fail
397
+ // again (see the dispatch-stop comment above).
398
+ if (firstOnBatchError !== undefined)
399
+ return;
155
400
  try {
156
- const embeddings = await this.requestBatch(batch, headers, ollamaOpts, signal);
157
- for (let k = 0; k < textBatch.indices.length; k++) {
158
- results[textBatch.indices[k]] = embeddings[k];
401
+ onBatch(indices, embeddings, model, outcome);
402
+ }
403
+ catch (err) {
404
+ firstOnBatchError = err;
405
+ stopDispatch(err);
406
+ }
407
+ };
408
+ // Requests a single provider batch (by index list), recursing on a
409
+ // context-size rejection OR a repeated timeout (#954). Never
410
+ // throws except to propagate a genuine caller abort — every other
411
+ // outcome (success or a non-abort failure) resolves normally after
412
+ // reporting via onSkip/onBatch. `onBatch` fires only once this
413
+ // try/catch has already settled success vs. failure, so a throw from it
414
+ // is never caught and reclassified by this block.
415
+ //
416
+ // `isTimeoutRetry` marks the SECOND attempt at this exact `indices`
417
+ // (after the one same-size backoff-and-retry) — a second
418
+ // timeout at that point splits or terminally skips rather than backing
419
+ // off again.
420
+ //
421
+ // `timeoutAttempt` (#954, field-report follow-up) counts how many splits
422
+ // down the timeout-retry chain this call is: 0 at the top level, then
423
+ // +1 each time a second timeout at one size splits into two smaller
424
+ // requests. It is the `attempt` fed to {@link embeddingTimeoutRetryBackoffMs}
425
+ // so each successive size's first-timeout backoff is longer than the
426
+ // last (5s, doubling, capped at 60s) instead of every split restarting
427
+ // at the same ~5s delay — a server still draining a whole run of
428
+ // abandoned requests needs more room the deeper the chain goes, not the
429
+ // same fixed pause every time.
430
+ const requestAndCommit = async (indices, batchIndex, isTimeoutRetry = false, timeoutAttempt = 0) => {
431
+ // A concurrent batch may have tripped the circuit breaker (or failed
432
+ // onBatch) while this call was queued behind a split or a backoff —
433
+ // never let a deeper recursive call make a request that can no longer
434
+ // be reported, mirroring how the pool below never claims a
435
+ // not-yet-started textBatch once dispatch has stopped.
436
+ if (dispatchAbort.signal.aborted)
437
+ return;
438
+ const batch = indices.map((i) => texts[i]);
439
+ const requestTokens = batch.reduce((sum, text) => sum + estimateTokenCount(text), 0);
440
+ const requestTimeoutMs = scaleEmbeddingTimeoutMs(configuredTimeoutMs, requestTokens, tokenBudget);
441
+ const requestStart = Date.now();
442
+ let batchEmbeddings;
443
+ let responseModel;
444
+ let outcome;
445
+ let failureReason;
446
+ try {
447
+ const { vectors, model } = await this.requestBatch(batch, headers, ollamaOpts, requestTimeoutMs, signal);
448
+ for (let k = 0; k < indices.length; k++) {
449
+ results[indices[k]] = vectors[k];
159
450
  }
451
+ batchEmbeddings = indices.map((i) => results[i]);
452
+ responseModel = model;
453
+ outcome = "stored";
160
454
  }
161
455
  catch (err) {
162
456
  // A caller abort must still propagate — it is not a "this batch
163
457
  // failed" condition, it means stop entirely.
164
458
  if (signal?.aborted)
165
459
  throw err;
460
+ if (err instanceof ContextExceededError && indices.length > 1) {
461
+ const mid = Math.ceil(indices.length / 2);
462
+ await requestAndCommit(indices.slice(0, mid), batchIndex, false, timeoutAttempt);
463
+ await requestAndCommit(indices.slice(mid), batchIndex, false, timeoutAttempt);
464
+ return;
465
+ }
466
+ const timedOut = isEmbeddingTimeoutError(err);
467
+ if (timedOut && !isTimeoutRetry) {
468
+ // First timeout at this size: back off so the provider can drain
469
+ // the abandoned request, then retry the SAME request once before
470
+ // ever splitting or skipping (#954). The backoff grows with
471
+ // `timeoutAttempt`, not a flat ~5s every time, so a chain of
472
+ // splits down to smaller and smaller requests gives the provider
473
+ // proportionally more room to drain each time.
474
+ const backoffMs = embeddingTimeoutRetryBackoffMs(timeoutAttempt);
475
+ warnVerbose(`[embed] batch of ${batch.length} document(s) timed out after ${requestTimeoutMs}ms; retrying once after a ${Math.round(backoffMs)}ms backoff`);
476
+ // Default-level notice (#954 field-report follow-up), not just the
477
+ // verbose line above — a run silently waiting out a multi-minute
478
+ // back-off looked identical to a hang otherwise. Nothing has
479
+ // failed or succeeded yet, so there is nothing to persist:
480
+ // `embeddings` are all `undefined` and the materializer's onBatch
481
+ // must not touch storage for this event.
482
+ commitBatch(indices, indices.map(() => undefined), undefined, {
483
+ batchIndex,
484
+ batchCount: textBatches.length,
485
+ docCount: indices.length,
486
+ requestTokens,
487
+ elapsedMs: backoffMs,
488
+ outcome: "retrying",
489
+ reason: "timed out",
490
+ });
491
+ await abortableDelay(backoffMs, signal, "embedding interrupted during retry backoff");
492
+ if (!dispatchAbort.signal.aborted) {
493
+ return requestAndCommit(indices, batchIndex, true, timeoutAttempt);
494
+ }
495
+ // Dispatch was stopped (by another batch's circuit-breaker trip)
496
+ // while this one was backing off — fall through and skip below
497
+ // instead of issuing a request that can no longer be reported.
498
+ }
499
+ else if (timedOut && indices.length > 1) {
500
+ // Timed out again on the retry: split rather than skip outright —
501
+ // the provider may still fit it once it is smaller (#954),
502
+ // the same treatment a context-size rejection gets.
503
+ // Each half's OWN first-timeout backoff (#954 follow-up) starts
504
+ // one attempt further down the chain than this size's did.
505
+ const mid = Math.ceil(indices.length / 2);
506
+ await requestAndCommit(indices.slice(0, mid), batchIndex, false, timeoutAttempt + 1);
507
+ await requestAndCommit(indices.slice(mid), batchIndex, false, timeoutAttempt + 1);
508
+ return;
509
+ }
166
510
  const message = err instanceof Error ? err.message : String(err);
167
- warnVerbose(`[embed] batch of ${batch.length} document(s) failed and was skipped: ${message}`);
168
- for (const idx of textBatch.indices) {
169
- onSkip?.({ index: idx, reason: "batch-request-failed", message });
511
+ const reason = err instanceof ContextExceededError ? "context-window-exceeded" : "batch-request-failed";
512
+ const failureKind = reason === "batch-request-failed" ? (timedOut ? "timeout" : "network-error") : undefined;
513
+ // Default-level visibility for a failed batch (not verbose-only) is
514
+ // still guaranteed here — just not via warn(). The `commitBatch` call
515
+ // below carries `outcome: "failed"` and this `message` as `reason`
516
+ // through `onBatch`, and materialize-embeddings.ts's per-batch line
517
+ // (also default-level) prints it from there. A warn() call here used
518
+ // to print the identical event a second time on stderr — the same
519
+ // class of double-print bug fixed for the truncation/re-embed-reason
520
+ // lines in materialize-embeddings.ts (#954, field-report follow-up).
521
+ // Per-entry batch-mapping detail stays verbose-only
522
+ // (materialize-embeddings.ts).
523
+ let stopRequested = false;
524
+ for (const [k, idx] of indices.entries()) {
525
+ if (onSkip?.({
526
+ index: idx,
527
+ reason,
528
+ message,
529
+ batchStart: k === 0,
530
+ batchSize: indices.length,
531
+ failureKind,
532
+ }) === false)
533
+ stopRequested = true;
170
534
  }
535
+ // #954: the caller's circuit breaker asked to stop —
536
+ // gate further dispatch through the SAME dispatchAbort controller
537
+ // the onBatch-throw path above uses, but resolve this call normally
538
+ // (never reject) with whatever results already landed, since this is
539
+ // a policy decision, not a persistence failure.
540
+ if (stopRequested)
541
+ stopDispatch();
542
+ batchEmbeddings = indices.map(() => undefined);
543
+ outcome = "failed";
544
+ failureReason = message;
545
+ }
546
+ commitBatch(indices, batchEmbeddings, responseModel, {
547
+ batchIndex,
548
+ batchCount: textBatches.length,
549
+ docCount: indices.length,
550
+ requestTokens,
551
+ elapsedMs: Date.now() - requestStart,
552
+ outcome,
553
+ reason: failureReason,
554
+ });
555
+ };
556
+ const runProviderBatch = async (textBatch, batchIndex) => {
557
+ if (textBatch.oversized) {
558
+ const idx = textBatch.indices[0];
559
+ const estTokens = estimateTokenCount(texts[idx]);
560
+ onSkip?.({
561
+ index: idx,
562
+ reason: "context-window-exceeded",
563
+ message: `Document estimated at ${estTokens} tokens exceeds the ${tokenBudget}-token embedding budget; skipped.`,
564
+ batchStart: true,
565
+ batchSize: 1,
566
+ });
567
+ // Never made a request — excluded from the default-level per-batch
568
+ // line (there is no request outcome to report), but still counted
569
+ // in the run's oversized-skip total via `onSkip` above.
570
+ commitBatch([idx], [undefined], undefined, {
571
+ batchIndex,
572
+ batchCount: textBatches.length,
573
+ docCount: 1,
574
+ requestTokens: estTokens,
575
+ elapsedMs: 0,
576
+ outcome: "failed",
577
+ reason: "oversized",
578
+ });
579
+ return;
171
580
  }
581
+ await requestAndCommit(textBatch.indices, batchIndex);
582
+ };
583
+ const concurrency = resolveEmbeddingConcurrency(this.config);
584
+ // concurrentMap swallows a thrown fn (per-item, results discarded here —
585
+ // requestAndCommit only throws to signal a caller abort), so abort must
586
+ // be re-checked once the pool has drained rather than relying on the
587
+ // throw itself to escape. Dispatch is gated on `dispatchAbort`, not the
588
+ // caller's `signal` directly — see the comment above. `batchIndex` is
589
+ // 1-based (matches the human-readable "batch N/Total" line) and comes
590
+ // from `concurrentMap`'s own 0-based item index, not a separately
591
+ // tracked counter, so it stays correct under concurrency.
592
+ await concurrentMap(textBatches, (textBatch, i) => runProviderBatch(textBatch, i + 1), concurrency, {
593
+ signal: dispatchAbort.signal,
594
+ });
595
+ if (callerAbortListener)
596
+ signal?.removeEventListener("abort", callerAbortListener);
597
+ if (signal?.aborted) {
598
+ throw signal.reason instanceof Error ? signal.reason : new Error("embedding interrupted");
599
+ }
600
+ if (firstOnBatchError !== undefined) {
601
+ throw firstOnBatchError instanceof Error ? firstOnBatchError : new Error(String(firstOnBatchError));
172
602
  }
173
603
  return results;
174
604
  }
175
- /** Send one batch request and return its embeddings in input order. Throws on any failure. */
176
- async requestBatch(batch, headers, ollamaOpts, signal) {
605
+ /**
606
+ * Send one batch request and return its embeddings in input order, plus the
607
+ * server-reported `model` id when the response body carried one (#955) —
608
+ * used by the embedding-fingerprint canary to verify a config-string
609
+ * rename against what the endpoint actually served, not just re-assert the
610
+ * configured string. Throws on any failure.
611
+ *
612
+ * `timeoutMs` is the caller's ALREADY-SCALED per-request timeout (#954
613
+ * — see {@link scaleEmbeddingTimeoutMs}), not re-resolved here:
614
+ * `embedBatch` computes it per request from that request's own size so a
615
+ * split-down retry gets a smaller, size-appropriate timeout rather than
616
+ * always the full configured `embedding.timeoutMs`.
617
+ */
618
+ async requestBatch(batch, headers, ollamaOpts, timeoutMs, signal) {
177
619
  const body = {
178
620
  input: batch,
179
621
  model: this.model,
@@ -190,16 +632,20 @@ export class RemoteEmbedder {
190
632
  method: "POST",
191
633
  headers,
192
634
  body: JSON.stringify(body),
193
- }, 30_000, signal);
635
+ }, timeoutMs, signal);
194
636
  if (!response.ok) {
195
- const respBody = await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }).catch((err) => {
637
+ const respBody = await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: timeoutMs, signal }).catch((err) => {
196
638
  if (signal?.aborted)
197
639
  throw err;
198
640
  return "";
199
641
  });
200
- throw new Error(`Embedding batch request failed (${response.status}): ${this.safeErrorBody(respBody)}`);
642
+ const message = `Embedding batch request failed (${response.status}): ${this.safeErrorBody(respBody)}`;
643
+ if (isContextExceededResponse(response.status, respBody)) {
644
+ throw new ContextExceededError(message);
645
+ }
646
+ throw new Error(message);
201
647
  }
202
- const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }));
648
+ const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: timeoutMs, signal }));
203
649
  if (!json.data || json.data.length !== batch.length) {
204
650
  throw new Error(`Unexpected embedding batch response: expected ${batch.length} embeddings, got ${json.data?.length ?? 0}.${embeddingEndpointPathHint(this.endpoint)}`);
205
651
  }
@@ -212,7 +658,7 @@ export class RemoteEmbedder {
212
658
  }
213
659
  results.push(l2Normalize(d.embedding));
214
660
  }
215
- return results;
661
+ return { vectors: results, model: typeof json.model === "string" && json.model ? json.model : undefined };
216
662
  }
217
663
  buildHeaders() {
218
664
  const headers = { "Content-Type": "application/json" };
@@ -304,3 +750,22 @@ function resolveOllamaOptions(config) {
304
750
  export function hasRemoteEndpoint(config) {
305
751
  return isHttpUrl(config.endpoint);
306
752
  }
753
+ /**
754
+ * Describe WHERE an `embedding.apiKey` came from, never its value — the
755
+ * actionable outcome of the #953 field gap: `resolveSecret` throws on an
756
+ * unresolvable `secret://` reference, so a keyless request can only mean
757
+ * `embedding.apiKey` was absent from the config the run actually loaded (a
758
+ * different config root, scope, or a config edited after the run started).
759
+ * A default-level progress line naming the credential's SOURCE (this
760
+ * helper), printed once before the first provider request, lets a field run
761
+ * self-diagnose that without ever surfacing the secret itself.
762
+ */
763
+ export function describeEmbeddingCredential(apiKey) {
764
+ if (!apiKey)
765
+ return "none configured";
766
+ if (SECRET_STORE_REFERENCE_PATTERN.test(apiKey))
767
+ return `${apiKey} (store)`;
768
+ if (ENV_REFERENCE_PATTERN.test(apiKey))
769
+ return `${apiKey} (env)`;
770
+ return "literal apiKey";
771
+ }