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
@@ -1,14 +1,17 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { getConfigPath } from "../core/paths.js";
4
5
  import { isVerbose, warn, warnVerbose } from "../core/warn.js";
5
6
  import { embedBatch } from "../llm/embedder.js";
6
7
  import { DETERMINISTIC_EMBED_MODEL_ID, isDeterministicEmbedEnabled } from "../llm/embedders/deterministic.js";
7
8
  import { DEFAULT_LOCAL_MODEL } from "../llm/embedders/local.js";
8
- import { buildTokenBoundedBatches, DEFAULT_REMOTE_BATCH_SIZE, DEFAULT_TOKEN_BUDGET, estimateTokenCount, hasRemoteEndpoint, } from "../llm/embedders/remote.js";
9
+ import { buildTokenBoundedBatches, capEmbeddingText, DEFAULT_MAX_INPUT_TOKENS, DEFAULT_REMOTE_BATCH_SIZE, DEFAULT_TOKEN_BUDGET, describeEmbeddingCredential, estimateTokenCount, hasRemoteEndpoint, normalizeEmbeddingEndpoint, } from "../llm/embedders/remote.js";
10
+ import { cosineSimilarity } from "../llm/embedders/types.js";
11
+ import { purgeEmbeddingSalvage, relabelEmbeddingSalvageFingerprint, reuseSalvagedEmbeddings, } from "../storage/repositories/embedding-salvage-repository.js";
9
12
  import { getEmbeddableEntryCount } from "../storage/repositories/index-entries-repository.js";
10
13
  import { deleteMeta, getMeta, setMeta } from "../storage/repositories/index-meta-repository.js";
11
- import { getAllEntriesForEmbedding, getEmbeddingCount, isVecFastPathComplete, isVecFastPathReady, purgeEmbeddings, setVecFastPathReady, upsertEmbedding, } from "../storage/repositories/index-vec-repository.js";
14
+ import { getAllEntriesForEmbedding, getEmbeddingCount, isVecFastPathComplete, isVecFastPathReady, purgeEmbeddings, sampleEmbeddedEntriesForCanary, setVecFastPathReady, upsertEmbedding, } from "../storage/repositories/index-vec-repository.js";
12
15
  /** Identifies the embedding provider+model+dimension a stored vector was generated with. */
13
16
  export function deriveSemanticProviderFingerprint(embedding) {
14
17
  if (isDeterministicEmbedEnabled()) {
@@ -22,17 +25,223 @@ export function deriveSemanticProviderFingerprint(embedding) {
22
25
  }
23
26
  return `local:${embedding?.localModel ?? DEFAULT_LOCAL_MODEL}`;
24
27
  }
28
+ /**
29
+ * The heartbeat text emitted every 15s while a provider request is in
30
+ * flight, and by default (not `--verbose`-only) since silence indistinguishable
31
+ * from a hang was the field report's own symptom (#954).
32
+ */
33
+ export function formatEmbeddingHeartbeat(storedCount, total, failedCount) {
34
+ return `Still generating embeddings: ${storedCount}/${total} stored, ${failedCount} failed; waiting on embedding provider.`;
35
+ }
36
+ /**
37
+ * Number of already-embedded entries sampled for the fingerprint-rename
38
+ * canary (#955) — small and cheap even against a slow local server; a
39
+ * handful of chunks is a strong compatibility signal (a different model
40
+ * cannot plausibly land near-identical vectors by chance).
41
+ */
42
+ const CANARY_SAMPLE_SIZE = 8;
43
+ /**
44
+ * Minimum median cosine similarity between stored and freshly re-embedded
45
+ * canary vectors for a fingerprint-string change to be treated as a
46
+ * same-model rename rather than a real model change (#955).
47
+ */
48
+ const CANARY_SIMILARITY_THRESHOLD = 0.999;
49
+ /**
50
+ * Consecutive transport failures after which the embedding pass stops
51
+ * dispatching further requests and ends the run as a failure rather than
52
+ * grinding through every remaining batch against a dead endpoint (#954).
53
+ * Two independent trip conditions
54
+ * share this threshold — see `onSkip` below: 3 consecutive failures at
55
+ * single-document size (timeout OR network error — a multi-document
56
+ * timeout is not by itself evidence the endpoint is dead, since
57
+ * `RemoteEmbedder.embedBatch` already retries and splits it smaller before
58
+ * ever reporting it as failed at single-document size), or 3 consecutive
59
+ * network errors at ANY size (a network error is never retried, so it is
60
+ * trusted immediately regardless of how large the request was).
61
+ * `context-window-exceeded` never counts — that reason proves the provider
62
+ * IS reachable, and split-and-retry already handles it; it resets both
63
+ * streaks instead.
64
+ */
65
+ const CIRCUIT_BREAKER_THRESHOLD = 3;
66
+ /**
67
+ * Pure decision: do stored vectors remain valid against freshly re-embedded
68
+ * canary samples? The ONE place that computes the canary's similarity
69
+ * numbers — callers must use this result rather than recomputing it (#955).
70
+ *
71
+ * An empty sample means nothing is stored to lose or verify against, so
72
+ * there is nothing to decide — keep.
73
+ *
74
+ * A sample whose re-embed FAILED (`fresh === undefined`, e.g. a provider
75
+ * sub-batch that was skipped) is EXCLUDED from the similarity computation
76
+ * entirely, not scored as zero: a partial provider failure is not evidence
77
+ * of a different model (#955). A dimension mismatch on a successful
78
+ * re-embed still counts as zero similarity via {@link cosineSimilarity}'s
79
+ * own dimension-mismatch guard — that IS evidence. When half or fewer of
80
+ * the sampled entries re-embedded successfully, the sample is too thin to
81
+ * trust either verdict — the outcome is `unverifiable`, the same outcome a
82
+ * total canary failure already produces.
83
+ *
84
+ * Otherwise the MEDIAN pairwise cosine similarity of the verified samples
85
+ * must clear {@link CANARY_SIMILARITY_THRESHOLD}; the median (not the
86
+ * minimum or mean) tolerates one stale or lightly-edited sample without
87
+ * either discarding a real match or being fooled by it.
88
+ */
89
+ export function decideEmbeddingCompatibility(pairs) {
90
+ if (pairs.length === 0)
91
+ return { outcome: "keep", medianSimilarity: undefined, verifiedSamples: 0 };
92
+ const verified = pairs.filter((pair) => pair.fresh !== undefined);
93
+ if (verified.length * 2 <= pairs.length) {
94
+ return { outcome: "unverifiable", medianSimilarity: undefined, verifiedSamples: verified.length };
95
+ }
96
+ const similarities = verified.map((pair) => cosineSimilarity(pair.stored, pair.fresh));
97
+ const medianSimilarity = medianOf(similarities);
98
+ return {
99
+ outcome: medianSimilarity >= CANARY_SIMILARITY_THRESHOLD ? "keep" : "rebuild",
100
+ medianSimilarity,
101
+ verifiedSamples: verified.length,
102
+ };
103
+ }
104
+ function medianOf(values) {
105
+ const sorted = [...values].sort((a, b) => a - b);
106
+ const mid = Math.floor(sorted.length / 2);
107
+ return sorted.length % 2 === 0
108
+ ? (sorted[mid - 1] + sorted[mid]) / 2
109
+ : sorted[mid];
110
+ }
111
+ /**
112
+ * Identity of the embedding vectors actually observed on a run — as opposed
113
+ * to {@link deriveSemanticProviderFingerprint}'s CONFIG-derived string. Keys
114
+ * on what the server (or local model) actually reported plus the observed
115
+ * vector width, so a gateway/transport change that keeps returning the same
116
+ * underlying model can be told apart from a genuine model change without
117
+ * relying on the operator's config string (#955).
118
+ * Returns undefined when nothing was actually observed this call (no vector
119
+ * to measure yet).
120
+ */
121
+ function deriveObservedEmbeddingIdentity(embedding, observedModel, observedVectorLen) {
122
+ if (isDeterministicEmbedEnabled()) {
123
+ return `deterministic:${DETERMINISTIC_EMBED_MODEL_ID}`;
124
+ }
125
+ if (observedVectorLen === undefined)
126
+ return undefined;
127
+ if (embedding?.endpoint) {
128
+ return `remote:${observedModel ?? embedding.model ?? "unknown"}|${observedVectorLen}`;
129
+ }
130
+ return `local:${embedding?.localModel ?? DEFAULT_LOCAL_MODEL}|${observedVectorLen}`;
131
+ }
132
+ /**
133
+ * Run the fingerprint-rename canary: re-embed a small sample of already-
134
+ * stored entries with the CURRENT config and decide whether the stored
135
+ * index survives. Goes through the standard {@link embedBatch} facade (not a
136
+ * direct `RemoteEmbedder`) so every embedder branch — remote, local,
137
+ * deterministic, and test overrides via `_setEmbedderForTests` — is
138
+ * exercised identically to the main embedding pass.
139
+ */
140
+ async function runEmbeddingCanary(db, config, signal) {
141
+ const samples = sampleEmbeddedEntriesForCanary(db, CANARY_SAMPLE_SIZE);
142
+ if (samples.length === 0) {
143
+ return { outcome: "keep", verified: false, viaIdentityMatch: false };
144
+ }
145
+ let observedModel;
146
+ const skips = [];
147
+ let canaryVectors;
148
+ try {
149
+ canaryVectors = await embedBatch(samples.map((sample) => sample.searchText), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
150
+ if (model)
151
+ observedModel = model;
152
+ });
153
+ }
154
+ catch (error) {
155
+ const message = error instanceof Error ? error.message : String(error);
156
+ return {
157
+ outcome: "unverifiable",
158
+ message: `could not verify embedding compatibility (${message}); keeping existing vectors — rerun akm index when the endpoint is reachable`,
159
+ };
160
+ }
161
+ const observedVectorLen = canaryVectors.find((vector) => vector !== undefined)?.length;
162
+ const observedIdentity = deriveObservedEmbeddingIdentity(config.embedding, observedModel, observedVectorLen);
163
+ const storedIdentity = getMeta(db, "embeddingIdentity");
164
+ if (storedIdentity && observedIdentity && storedIdentity === observedIdentity) {
165
+ // The server reports the same model identity as last time — no need to
166
+ // even look at the cosines; the config string alone was misleading.
167
+ return { outcome: "keep", verified: true, identity: observedIdentity, viaIdentityMatch: true };
168
+ }
169
+ const pairs = samples.map((sample, i) => ({ stored: sample.vector, fresh: canaryVectors[i] }));
170
+ const decision = decideEmbeddingCompatibility(pairs);
171
+ if (decision.outcome === "unverifiable") {
172
+ // Covers both a total provider failure (RemoteEmbedder skips a failing
173
+ // request rather than throwing, #874, so an unreachable endpoint
174
+ // surfaces here as an all-`undefined` canary result, not a caught
175
+ // exception) and a partial one thin enough that neither verdict can be
176
+ // trusted (#955) — same message path either way.
177
+ const message = skips[0]?.message ?? "embedding provider returned no vectors for the canary sample";
178
+ return {
179
+ outcome: "unverifiable",
180
+ message: `could not verify embedding compatibility (${message}); keeping existing vectors — rerun akm index when the endpoint is reachable`,
181
+ };
182
+ }
183
+ if (decision.outcome === "keep") {
184
+ return {
185
+ outcome: "keep",
186
+ verified: true,
187
+ identity: observedIdentity,
188
+ viaIdentityMatch: false,
189
+ medianSimilarity: decision.medianSimilarity,
190
+ };
191
+ }
192
+ return {
193
+ outcome: "rebuild",
194
+ identity: observedIdentity,
195
+ reason: `vectors differ (median similarity ${decision.medianSimilarity?.toFixed(3)})`,
196
+ };
197
+ }
25
198
  function throwIfAborted(signal) {
26
199
  if (signal?.aborted) {
27
200
  throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
28
201
  }
29
202
  }
30
- export async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds) {
203
+ export async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds, opts) {
204
+ // Drift guard (#954): refuse an ambient transaction. Every
205
+ // per-batch `db.transaction()` below is meant to be its own durable commit
206
+ // (#954) — inside an already-open outer transaction it would nest as an
207
+ // unobservable SAVEPOINT instead, so an interruption (competing-process
208
+ // collision, SIGKILL) could lose the whole pass rather than only the batch
209
+ // in flight. This is an internal contract error (a caller bug), not a
210
+ // user-facing failure class: callers with their own transaction (e.g. `akm
211
+ // bundle update`'s unified update transaction) must run the embedding
212
+ // phase on a separate connection AFTER their own transaction commits — see
213
+ // `runEmbeddingPass` in `src/indexer/indexer.ts`.
214
+ if (db.inTransaction) {
215
+ throw new Error("generateEmbeddingsForDb was called with an ambient transaction already open on `db`: per-batch commits " +
216
+ "would become SAVEPOINTs inside it, losing the crash-durability contract per-batch commit exists for. " +
217
+ "Run the embedding phase on a connection with no open transaction.");
218
+ }
31
219
  throwIfAborted(signal);
32
220
  if (config.semanticSearchMode === "off") {
221
+ // #955: salvage is self-emptying only if every path that skips reuse
222
+ // also drains it — otherwise a full rebuild performed with semantic
223
+ // search disabled leaves permanent orphaned rows behind (nothing will
224
+ // ever consume them, since this path never reaches the reuse step).
225
+ purgeEmbeddingSalvage(db);
33
226
  onProgress({ phase: "embeddings", message: "Semantic search disabled; skipping embeddings." });
34
227
  return { success: false, message: "Semantic search is disabled." };
35
228
  }
229
+ // #953 field gap: the actionable outcome is a self-diagnosing run, not a
230
+ // fix (every RemoteEmbedder path already resolves secret:// through one
231
+ // boundary — a keyless request can only mean embedding.apiKey was absent
232
+ // from the config THIS run loaded). One default-level line, before the
233
+ // first provider request of the phase (the canary probe or the main
234
+ // pass, whichever runs first below), naming the endpoint/model/credential
235
+ // SOURCE — never the credential value.
236
+ if (hasRemoteEndpoint(config.embedding ?? {})) {
237
+ const endpoint = normalizeEmbeddingEndpoint(config.embedding?.endpoint ?? "");
238
+ const credential = describeEmbeddingCredential(config.embedding?.apiKey);
239
+ const configFileSuffix = isVerbose() ? `; config: ${getConfigPath()}` : "";
240
+ onProgress({
241
+ phase: "embeddings",
242
+ message: `[embed] endpoint ${endpoint}, model ${config.embedding?.model ?? "unknown"}; credential: ${credential}${configFileSuffix}`,
243
+ });
244
+ }
36
245
  // A targeted call starts from an already-published generation. Preserve its
37
246
  // trust decision in O(1): successful writes for the changed IDs keep a
38
247
  // healthy fast path healthy, but can never promote a generation already
@@ -41,26 +250,169 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
41
250
  const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
42
251
  const storedFingerprint = getMeta(db, "embeddingFingerprint");
43
252
  let targetEntryIds = entryIds;
44
- if (storedFingerprint && storedFingerprint !== currentFingerprint) {
45
- purgeEmbeddings(db, { dropVecTable: true });
46
- deleteMeta(db, "embeddingDim");
47
- // A provider/model change invalidates the entire vector generation, even
48
- // when a targeted write happened to discover it first.
253
+ /** Set only on an actual rebuild, so the up-front "Re-embedding N entries" line names why. */
254
+ let rebuildReason;
255
+ if (opts?.forceReembed) {
256
+ // `akm index --reembed`: an explicit operator override, skips the canary
257
+ // entirely. The new fingerprint (and identity, now stale/unknown until
258
+ // the next successful pass observes it) is written in the SAME
259
+ // transaction as the purge, before any embedding request — a restart
260
+ // then sees a matching fingerprint and only heals what is still missing
261
+ // instead of purging again from zero (#955/#956).
262
+ db.transaction(() => {
263
+ purgeEmbeddings(db, { dropVecTable: true });
264
+ // #955: an explicit forced rebuild must re-embed everything, not
265
+ // quietly satisfy some of it from stale salvage.
266
+ purgeEmbeddingSalvage(db);
267
+ deleteMeta(db, "embeddingDim");
268
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
269
+ deleteMeta(db, "embeddingIdentity");
270
+ })();
49
271
  targetEntryIds = undefined;
272
+ rebuildReason = "forced by --reembed";
273
+ }
274
+ else if (storedFingerprint && storedFingerprint !== currentFingerprint) {
275
+ const decision = await runEmbeddingCanary(db, config, signal);
276
+ if (decision.outcome === "unverifiable") {
277
+ // Destroying a good index because the server happens to be down right
278
+ // now is worse than leaving a rename unverified until the next run —
279
+ // keep the vectors AND the old fingerprint so the next `akm index`
280
+ // retries the canary instead of silently treating this as resolved.
281
+ warn(`[embed] ${decision.message}`);
282
+ onProgress({ phase: "embeddings", message: decision.message });
283
+ return { success: false, message: decision.message };
284
+ }
285
+ if (decision.outcome === "rebuild") {
286
+ db.transaction(() => {
287
+ purgeEmbeddings(db, { dropVecTable: true });
288
+ // #955: the stored vectors AND any leftover salvage both belong to
289
+ // a different model now — neither is reusable, so both go.
290
+ purgeEmbeddingSalvage(db);
291
+ deleteMeta(db, "embeddingDim");
292
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
293
+ if (decision.identity)
294
+ setMeta(db, "embeddingIdentity", decision.identity);
295
+ else
296
+ deleteMeta(db, "embeddingIdentity");
297
+ })();
298
+ targetEntryIds = undefined;
299
+ rebuildReason = decision.reason;
300
+ }
301
+ else {
302
+ // Keep: adopt the new fingerprint (and identity, when observed)
303
+ // immediately rather than deferring to end-of-run — nothing was
304
+ // purged, so there is nothing an interruption could lose, and an
305
+ // immediate write means a crash right after this decision does not
306
+ // re-run the canary needlessly on the next attempt.
307
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
308
+ // #955: the model did not actually change, only the fingerprint
309
+ // STRING did (e.g. a gateway rename) — any leftover salvage rows
310
+ // tagged with the OLD string are still valid vectors. Relabel them so
311
+ // the reuse step below (and any later pass) can still find them.
312
+ relabelEmbeddingSalvageFingerprint(db, storedFingerprint, currentFingerprint);
313
+ if (decision.identity)
314
+ setMeta(db, "embeddingIdentity", decision.identity);
315
+ if (decision.verified) {
316
+ const keptCount = getEmbeddingCount(db);
317
+ const detail = decision.viaIdentityMatch
318
+ ? "server-reported model unchanged"
319
+ : `stored vectors are compatible (median similarity ${decision.medianSimilarity?.toFixed(3)})`;
320
+ const message = `[embed] embedding model renamed (${storedFingerprint} → ${currentFingerprint}); ${detail}, keeping ${keptCount} embedding${keptCount === 1 ? "" : "s"}.`;
321
+ warn(message);
322
+ onProgress({ phase: "embeddings", message });
323
+ }
324
+ // Empty-sample case (decision.verified === false): nothing stored to
325
+ // lose or verify against — adopt the label silently, no purge line.
326
+ }
327
+ }
328
+ else {
329
+ // No rename to verify (either this is the very first
330
+ // pass ever for this db, or the fingerprint already matches the last
331
+ // successful one) — still record it NOW rather than deferring to a
332
+ // fully successful pass, mirroring the rebuild/keep branches above
333
+ // (#955/#956). Without this, an interrupted FIRST-EVER pass left
334
+ // `embeddingFingerprint` unset despite a per-batch commit below (#954)
335
+ // already having durably written real vectors — a later `akm index
336
+ // --full`'s salvage-before-discard step tags rows by this meta
337
+ // (`salvageEmbeddingsBeforeDiscard`) and treats an unset fingerprint as
338
+ // "nothing was ever verified", silently turning genuinely-embedded
339
+ // vectors into a full re-embed instead of a salvage-and-reuse.
340
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
50
341
  }
51
342
  try {
52
343
  throwIfAborted(signal);
53
344
  const allEntries = getAllEntriesForEmbedding(db, targetEntryIds);
54
- if (allEntries.length === 0) {
345
+ let vecFailedCount = 0;
346
+ let vecUnavailableCount = 0;
347
+ // #955: before any provider call, hand back vectors salvaged from a
348
+ // full rebuild or a generation bump for entries whose search_text is
349
+ // byte-identical to what was salvaged under the SAME fingerprint — a
350
+ // fingerprint mismatch or a single-byte content change both correctly
351
+ // fall through to the provider below instead.
352
+ const { reusedCount, remaining: candidateEntries } = reuseSalvagedEmbeddings(db, allEntries, currentFingerprint, (entry, embedding) => {
353
+ const result = upsertEmbedding(db, entry.id, embedding);
354
+ if (result.vec === "failed")
355
+ vecFailedCount++;
356
+ if (result.vec === "unavailable")
357
+ vecUnavailableCount++;
358
+ return result.stored;
359
+ });
360
+ if (reusedCount > 0) {
361
+ onProgress({
362
+ phase: "embeddings",
363
+ message: `Reused ${reusedCount} embedding${reusedCount === 1 ? "" : "s"} from the previous generation; embedding ${candidateEntries.length} new.`,
364
+ });
365
+ }
366
+ if (candidateEntries.length === 0) {
55
367
  onProgress({ phase: "embeddings", message: "Embeddings already up to date." });
56
368
  setMeta(db, "embeddingFingerprint", currentFingerprint);
57
- return { success: true };
369
+ if (reusedCount > 0) {
370
+ const vecGenerationComplete = targetEntryIds === undefined ? isVecFastPathComplete(db) : vecFastPathWasReady;
371
+ setVecFastPathReady(db, vecFailedCount === 0 && vecUnavailableCount === 0 && vecGenerationComplete);
372
+ }
373
+ // A pass that completes (even one that did nothing but reuse) purges
374
+ // whatever is left — salvage is consumed by the NEXT pass, never kept
375
+ // around as a second cache.
376
+ purgeEmbeddingSalvage(db);
377
+ return reusedCount > 0 ? { success: true, vecInsertFailures: vecFailedCount } : { success: true };
378
+ }
379
+ // Cap each document's embedded text at
380
+ // embedding.maxInputTokens (default DEFAULT_MAX_INPUT_TOKENS) instead of
381
+ // ever failing a whole batch over one oversized entry — truncation keeps
382
+ // the head of the text, unicode-safe. A document is skipped only when its
383
+ // head is empty (the impossible case: nothing left to embed), never
384
+ // merely for being long.
385
+ const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
386
+ let truncatedCount = 0;
387
+ const texts = [];
388
+ const pendingEntries = [];
389
+ for (const entry of candidateEntries) {
390
+ const capped = capEmbeddingText(entry.searchText, maxInputTokens);
391
+ if (capped.text.length === 0)
392
+ continue;
393
+ if (capped.truncated)
394
+ truncatedCount++;
395
+ pendingEntries.push(entry);
396
+ texts.push(capped.text);
397
+ }
398
+ if (truncatedCount > 0) {
399
+ // Through onProgress ONLY, not warn() too — onProgress already reaches
400
+ // stderr at the default level in every output mode (#954), and the
401
+ // index CLI's progress handler writes it through info() (log-file
402
+ // aware), so calling warn() as well printed the identical sentence
403
+ // twice in text mode.
404
+ const message = `[embed] ${truncatedCount} entr${truncatedCount === 1 ? "y" : "ies"} truncated to the ${maxInputTokens}-token embedding cap (embedding.maxInputTokens); rerun with a higher cap to embed the full text.`;
405
+ onProgress({ phase: "embeddings", message });
406
+ }
407
+ if (rebuildReason) {
408
+ // See the truncation notice above: onProgress ONLY.
409
+ const message = `[embed] Re-embedding ${pendingEntries.length} entr${pendingEntries.length === 1 ? "y" : "ies"} because ${rebuildReason}`;
410
+ onProgress({ phase: "embeddings", message });
58
411
  }
59
412
  onProgress({
60
413
  phase: "embeddings",
61
- message: `Generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}.`,
414
+ message: `Generating embeddings for ${pendingEntries.length} entr${pendingEntries.length === 1 ? "y" : "ies"}.`,
62
415
  });
63
- const texts = allEntries.map((entry) => entry.searchText);
64
416
  if (isVerbose()) {
65
417
  // Mirror RemoteEmbedder's actual token-bounded batching (#874) so this
66
418
  // log reflects the real request grouping rather than a fixed count of
@@ -69,7 +421,9 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
69
421
  // for inference throughput only, never fails/skips), so there's
70
422
  // nothing meaningful to report per-batch for them.
71
423
  if (hasRemoteEndpoint(config.embedding ?? {})) {
72
- const tokenBudget = config.embedding?.maxTokens ?? config.embedding?.contextLength ?? DEFAULT_TOKEN_BUDGET;
424
+ // Mirrors RemoteEmbedder.embedBatch's own tokenBudget resolution
425
+ // (#956: contextLength no longer feeds this).
426
+ const tokenBudget = config.embedding?.maxTokens ?? DEFAULT_TOKEN_BUDGET;
73
427
  const maxCount = config.embedding?.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
74
428
  const batches = buildTokenBoundedBatches(texts, tokenBudget, maxCount);
75
429
  const batchNumberByIndex = new Map();
@@ -77,7 +431,7 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
77
431
  for (const i of batch.indices)
78
432
  batchNumberByIndex.set(i, batchIdx + 1);
79
433
  });
80
- for (const [i, entry] of allEntries.entries()) {
434
+ for (const [i, entry] of pendingEntries.entries()) {
81
435
  const chars = entry.searchText.length;
82
436
  const tokens = estimateTokenCount(entry.searchText);
83
437
  const batch = batches[batchNumberByIndex.get(i) - 1];
@@ -88,59 +442,160 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
88
442
  }
89
443
  }
90
444
  else {
91
- for (const entry of allEntries) {
445
+ for (const entry of pendingEntries) {
92
446
  warnVerbose(`[embed] ${entry.itemRef} (${entry.searchText.length} chars, est. ${estimateTokenCount(entry.searchText)} tokens)`);
93
447
  }
94
448
  }
95
449
  }
96
450
  let heartbeatTimer;
451
+ let storedCount = 0;
452
+ let skippedCount = 0;
453
+ let embedFailedCount = 0;
454
+ let storedTokens = 0;
97
455
  try {
98
456
  heartbeatTimer = setInterval(() => {
99
457
  onProgress({
100
458
  phase: "embeddings",
101
- message: `Still generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}; waiting on embedding provider.`,
459
+ message: formatEmbeddingHeartbeat(storedCount, pendingEntries.length, embedFailedCount),
102
460
  });
103
461
  }, 15000);
104
462
  // A failing sub-batch or an oversized document is SKIPPED by embedBatch,
105
463
  // not thrown (#874) — collect what couldn't be embedded and why, so a
106
464
  // few bad documents don't discard every other entry's embedding.
107
465
  const skips = [];
108
- const embeddings = await embedBatch(texts, config.embedding, signal, (skip) => skips.push(skip));
109
- throwIfAborted(signal);
110
- let storedCount = 0;
111
- let skippedCount = 0;
112
- let embedFailedCount = 0;
113
- let vecFailedCount = 0;
114
- let vecUnavailableCount = 0;
115
- db.transaction(() => {
116
- for (const [i, entry] of allEntries.entries()) {
117
- const embedding = embeddings[i];
118
- if (!embedding) {
119
- embedFailedCount++;
120
- continue;
466
+ const embedStart = Date.now();
467
+ // Circuit breaker (#954): stop
468
+ // dispatching further batches once either consecutive-failure streak
469
+ // below reaches CIRCUIT_BREAKER_THRESHOLD — a dead/hung provider used
470
+ // to grind through every remaining batch for hours, one 30s (now
471
+ // configurable, and now backed off/retried/split first — see
472
+ // RemoteEmbedder.embedBatch) timeout at a time, ending in one
473
+ // aggregate warning and `ok: true`. Counted per BATCH
474
+ // (`skip.batchStart`), not per document: a single failed 100-document
475
+ // batch must not look like 100 consecutive failures.
476
+ let consecutiveSingleDocFailures = 0;
477
+ let consecutiveNetworkErrorFailures = 0;
478
+ let circuitBreakerReason;
479
+ const onSkip = (skip) => {
480
+ skips.push(skip);
481
+ if (!skip.batchStart)
482
+ return undefined;
483
+ if (skip.reason === "context-window-exceeded") {
484
+ consecutiveSingleDocFailures = 0;
485
+ consecutiveNetworkErrorFailures = 0;
486
+ return undefined;
487
+ }
488
+ // "batch-request-failed": a timeout only counts once retries have
489
+ // already narrowed it down to a single document (embedBatch backs
490
+ // off, retries, and splits a multi-document timeout before ever
491
+ // reporting it here); a network error counts immediately at any
492
+ // size — it was never retried, so it is trusted right away.
493
+ consecutiveSingleDocFailures = skip.batchSize === 1 ? consecutiveSingleDocFailures + 1 : 0;
494
+ consecutiveNetworkErrorFailures =
495
+ skip.failureKind === "network-error" ? consecutiveNetworkErrorFailures + 1 : 0;
496
+ if (consecutiveSingleDocFailures >= CIRCUIT_BREAKER_THRESHOLD ||
497
+ consecutiveNetworkErrorFailures >= CIRCUIT_BREAKER_THRESHOLD) {
498
+ circuitBreakerReason = skip.message;
499
+ return false;
500
+ }
501
+ return undefined;
502
+ };
503
+ // Commit each provider batch in its own short transaction as it lands,
504
+ // rather than buffering the whole run in memory for one transaction at
505
+ // the very end (#954) — a competing-process lock error or any other
506
+ // interruption partway through now keeps whatever already committed
507
+ // instead of losing the entire pass.
508
+ // Tracks what this run actually observed, so a successful pass can
509
+ // record `embeddingIdentity` from real data rather than the config
510
+ // string alone (#955) — only the first non-empty batch's vector width
511
+ // is kept; every batch from one run shares the same provider/model.
512
+ let observedModel;
513
+ let observedVectorLen;
514
+ // Whether the remote provider's endpoint/model/token language is
515
+ // meaningful for this run — the per-batch diagnostic line below is
516
+ // remote-only, same gate the credential diagnostic (#953) above uses.
517
+ const reportPerBatchLine = hasRemoteEndpoint(config.embedding ?? {});
518
+ const onBatch = (indices, batchEmbeddings, model, outcome) => {
519
+ // #954 field-report follow-up: a "retrying" event carries nothing to
520
+ // commit — the request hasn't settled yet — only the notice that a
521
+ // back-off is about to be waited out, default-level so a run is
522
+ // never silently stalled indistinguishably from a hang.
523
+ if (outcome?.outcome === "retrying") {
524
+ if (reportPerBatchLine) {
525
+ onProgress({
526
+ phase: "embeddings",
527
+ message: `[embed] batch ${outcome.batchIndex}/${outcome.batchCount}: ${outcome.docCount} docs, ${outcome.requestTokens.toLocaleString()} tokens → retrying after ${(outcome.elapsedMs / 1000).toFixed(1)} s`,
528
+ });
121
529
  }
122
- const result = upsertEmbedding(db, entry.id, embedding);
123
- if (result.stored)
124
- storedCount++;
125
- else
126
- skippedCount++;
127
- if (result.vec === "failed")
128
- vecFailedCount++;
129
- if (result.vec === "unavailable")
130
- vecUnavailableCount++;
530
+ return;
131
531
  }
132
- })();
532
+ if (model)
533
+ observedModel = model;
534
+ // A batch that delivered at least one real embedding proves the
535
+ // provider is currently answering — reset both circuit-breaker
536
+ // streaks. (A wholly failed batch's `batchEmbeddings` are all
537
+ // `undefined`, per commitBatch's skip path, so this never
538
+ // re-triggers what onSkip just counted moments earlier.)
539
+ if (batchEmbeddings.some((embedding) => embedding !== undefined)) {
540
+ consecutiveSingleDocFailures = 0;
541
+ consecutiveNetworkErrorFailures = 0;
542
+ }
543
+ db.transaction(() => {
544
+ for (let k = 0; k < indices.length; k++) {
545
+ const entry = pendingEntries[indices[k]];
546
+ if (!entry)
547
+ continue;
548
+ const embedding = batchEmbeddings[k];
549
+ if (!embedding) {
550
+ embedFailedCount++;
551
+ continue;
552
+ }
553
+ if (observedVectorLen === undefined)
554
+ observedVectorLen = embedding.length;
555
+ const result = upsertEmbedding(db, entry.id, embedding);
556
+ if (result.stored) {
557
+ storedCount++;
558
+ storedTokens += estimateTokenCount(entry.searchText);
559
+ }
560
+ else {
561
+ skippedCount++;
562
+ }
563
+ if (result.vec === "failed")
564
+ vecFailedCount++;
565
+ if (result.vec === "unavailable")
566
+ vecUnavailableCount++;
567
+ }
568
+ })();
569
+ // Default level, one line per provider batch (#954, field-report
570
+ // follow-up): oversized documents never made a request
571
+ // (`reason === "oversized"`), so there is no batch outcome to
572
+ // report — they are covered by the run's final oversized-skip
573
+ // count and list instead.
574
+ if (outcome && outcome.reason !== "oversized" && reportPerBatchLine) {
575
+ const elapsedSeconds = (outcome.elapsedMs / 1000).toFixed(1);
576
+ const outcomeLabel = outcome.outcome === "stored"
577
+ ? `${outcome.docCount} stored (${elapsedSeconds} s)`
578
+ : `failed: ${outcome.reason}`;
579
+ onProgress({
580
+ phase: "embeddings",
581
+ message: `[embed] batch ${outcome.batchIndex}/${outcome.batchCount}: ${outcome.docCount} docs, ${outcome.requestTokens.toLocaleString()} tokens → ${outcomeLabel}`,
582
+ });
583
+ }
584
+ // Every committed batch, not just every 500 stored entries (#954)
585
+ // — the prior bucketing left a non-verbose run silent
586
+ // for the entire embedding phase on anything smaller than 500
587
+ // entries, indistinguishable from a hang.
588
+ onProgress({
589
+ phase: "embeddings",
590
+ message: `Embedded ${storedCount}/${pendingEntries.length} entries.`,
591
+ });
592
+ };
593
+ await embedBatch(texts, config.embedding, signal, onSkip, onBatch);
594
+ throwIfAborted(signal);
595
+ const elapsedSeconds = Math.max((Date.now() - embedStart) / 1000, 0.001);
133
596
  if (skippedCount > 0) {
134
597
  warn(`[embed] ${skippedCount} embedding${skippedCount === 1 ? "" : "s"} skipped (entry deleted between queue and write)`);
135
598
  }
136
- if (embedFailedCount > 0) {
137
- const detail = skips
138
- .slice(0, 20)
139
- .map((skip) => ` - ${allEntries[skip.index]?.itemRef ?? skip.index} (${skip.reason}): ${skip.message}`)
140
- .join("\n");
141
- const more = skips.length > 20 ? `\n ...and ${skips.length - 20} more` : "";
142
- warn(`[embed] ${embedFailedCount} embedding${embedFailedCount === 1 ? "" : "s"} could not be generated and ${embedFailedCount === 1 ? "was" : "were"} skipped:\n${detail}${more}`);
143
- }
144
599
  const vecGenerationComplete = targetEntryIds === undefined ? isVecFastPathComplete(db) : vecFastPathWasReady;
145
600
  setVecFastPathReady(db, vecFailedCount === 0 && vecUnavailableCount === 0 && vecGenerationComplete);
146
601
  if (vecFailedCount > 0) {
@@ -148,11 +603,68 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
148
603
  "semantic search will use the slower JS-cosine fallback over stored embeddings. " +
149
604
  "Rebuild with 'akm index --full' after resolving the vec table (often a vector-dimension mismatch).");
150
605
  }
606
+ const entriesPerSec = storedCount / elapsedSeconds;
607
+ const tokensPerSec = storedTokens / elapsedSeconds;
608
+ const totalStored = storedCount + reusedCount;
609
+ // #954, field-report follow-up: the final line
610
+ // reports every outcome, not just what was stored — counts come from
611
+ // the same collected `skips` the circuit breaker already uses,
612
+ // categorized by `reason`/`failureKind`. "oversized skipped" =
613
+ // context-window-exceeded (never fit any request, at any size);
614
+ // "timed out" = a batch-request-failed skip whose last attempt timed
615
+ // out (retries/splits already exhausted before this counted); "failed"
616
+ // = every other batch-request-failed skip (a genuine, never-retried
617
+ // network/HTTP failure).
618
+ const oversizedSkips = skips.filter((skip) => skip.reason === "context-window-exceeded");
619
+ const timedOutSkips = skips.filter((skip) => skip.reason === "batch-request-failed" && skip.failureKind === "timeout");
620
+ const failedSkips = skips.filter((skip) => skip.reason === "batch-request-failed" && skip.failureKind !== "timeout");
621
+ const throughputLine = reusedCount > 0
622
+ ? // #955: report reused and newly-embedded counts separately — the
623
+ // rate figures below are provider throughput only (reuse is a
624
+ // plain DB write, not provider work) and would be misleadingly
625
+ // inflated if reused entries were folded into them.
626
+ `Stored ${totalStored} embedding${totalStored === 1 ? "" : "s"} (${reusedCount} reused, ${storedCount} newly embedded) in ${elapsedSeconds.toFixed(1)}s (${entriesPerSec.toFixed(1)} entries/s, ~${Math.round(tokensPerSec)} tokens/s)`
627
+ : `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"} in ${elapsedSeconds.toFixed(1)}s (${entriesPerSec.toFixed(1)} entries/s, ~${Math.round(tokensPerSec)} tokens/s)`;
151
628
  onProgress({
152
629
  phase: "embeddings",
153
- message: `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"}.`,
630
+ message: `${throughputLine}; ${oversizedSkips.length} oversized skipped, ${timedOutSkips.length} timed out, ${failedSkips.length} failed.`,
154
631
  });
632
+ // Bounded itemRef-level detail for every skip category, not just
633
+ // oversized — the aggregate counts above say HOW MANY documents timed
634
+ // out or failed, but give the operator no way to find out WHICH ones
635
+ // short of rerunning with --verbose and re-reading the whole log.
636
+ // Default level caps each list (there is nothing actionable about the
637
+ // 21st identical failure); --verbose prints every one, matching the
638
+ // per-document mapping lines' own verbosity gate above.
639
+ const printSkipList = (label, skipList) => {
640
+ if (skipList.length === 0)
641
+ return;
642
+ const limit = isVerbose() ? skipList.length : 20;
643
+ const listed = skipList
644
+ .slice(0, limit)
645
+ .map((skip) => ` - ${pendingEntries[skip.index]?.itemRef ?? skip.index}: ${skip.message}`)
646
+ .join("\n");
647
+ const more = skipList.length > limit ? `\n ...and ${skipList.length - limit} more` : "";
648
+ onProgress({ phase: "embeddings", message: `[embed] ${label} skipped:\n${listed}${more}` });
649
+ };
650
+ printSkipList("oversized documents", oversizedSkips);
651
+ printSkipList("timed-out documents", timedOutSkips);
652
+ printSkipList("failed documents", failedSkips);
155
653
  setMeta(db, "embeddingFingerprint", currentFingerprint);
654
+ const observedIdentity = deriveObservedEmbeddingIdentity(config.embedding, observedModel, observedVectorLen);
655
+ if (observedIdentity)
656
+ setMeta(db, "embeddingIdentity", observedIdentity);
657
+ // Circuit breaker tripped (#954): committed batches are
658
+ // kept (nothing above discards them), but the pass is not a success —
659
+ // the provider looks dead, not just occasionally flaky.
660
+ if (circuitBreakerReason !== undefined) {
661
+ const message = `embedding provider failed ${CIRCUIT_BREAKER_THRESHOLD} consecutive batches ` +
662
+ `(last: ${circuitBreakerReason}); stopped after ${storedCount} embedding${storedCount === 1 ? "" : "s"} ` +
663
+ "were stored — rerun akm index when the endpoint is healthy";
664
+ warn(`[embed] ${message}`);
665
+ onProgress({ phase: "embeddings", message });
666
+ return { success: false, message, vecInsertFailures: vecFailedCount };
667
+ }
156
668
  // Only a total failure (nothing at all embedded, despite having entries
157
669
  // to embed) turns into a phase failure. Any partial success — the vast
158
670
  // majority of a large bundle embedding fine around a handful of skips —
@@ -166,6 +678,10 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
166
678
  message: `All ${embedFailedCount} embedding batch(es) failed: ${firstMessage}`,
167
679
  };
168
680
  }
681
+ // A pass that completes without abort or circuit-break purges
682
+ // whatever salvage is left — consumed by this pass's reuse step
683
+ // above, or superseded by what it just embedded.
684
+ purgeEmbeddingSalvage(db);
169
685
  return { success: true, vecInsertFailures: vecFailedCount };
170
686
  }
171
687
  finally {