akm-cli 0.9.14 → 0.9.15-beta.1

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 (110) hide show
  1. package/CHANGELOG.md +397 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  4. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  5. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  6. package/dist/assets/tasks/core/improve.yml +1 -1
  7. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  8. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  9. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  13. package/dist/cli/retired-commands.js +0 -1
  14. package/dist/cli/shared.js +9 -0
  15. package/dist/cli/unknown-flags.js +1 -0
  16. package/dist/cli.js +3 -2
  17. package/dist/commands/config-cli.js +85 -3
  18. package/dist/commands/env/env-cli.js +1 -42
  19. package/dist/commands/env/env.js +1 -1
  20. package/dist/commands/env/secret-cli.js +1 -2
  21. package/dist/commands/health/checks.js +357 -63
  22. package/dist/commands/health/engine-usage.js +45 -0
  23. package/dist/commands/health/improve-metrics.js +18 -0
  24. package/dist/commands/health/llm-usage.js +41 -1
  25. package/dist/commands/health/plugin-staleness.js +7 -3
  26. package/dist/commands/health/version-drift.js +93 -0
  27. package/dist/commands/health/windows.js +3 -1
  28. package/dist/commands/health.js +44 -9
  29. package/dist/commands/improve/consolidate/chunking.js +4 -2
  30. package/dist/commands/improve/improve-cli.js +99 -5
  31. package/dist/commands/improve/improve-report.js +154 -0
  32. package/dist/commands/improve/improve-result-file.js +45 -33
  33. package/dist/commands/improve/improve-strategies.js +133 -3
  34. package/dist/commands/improve/improve-usage-report.js +182 -0
  35. package/dist/commands/improve/improve.js +40 -3
  36. package/dist/commands/improve/locks.js +27 -78
  37. package/dist/commands/improve/planner.js +1 -0
  38. package/dist/commands/improve/preparation.js +9 -1
  39. package/dist/commands/improve/reflect.js +44 -4
  40. package/dist/commands/models-cli.js +50 -1
  41. package/dist/commands/proposal/repository.js +8 -3
  42. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  43. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  44. package/dist/commands/read/search-cli.js +38 -2
  45. package/dist/commands/read/show.js +103 -4
  46. package/dist/commands/sources/info.js +5 -1
  47. package/dist/commands/sources/self-update.js +2 -2
  48. package/dist/commands/sources/stash-cli.js +31 -0
  49. package/dist/commands/tasks/tasks-cli.js +49 -2
  50. package/dist/commands/workflow-cli.js +86 -12
  51. package/dist/core/asset/markdown-fragments.js +35 -0
  52. package/dist/core/config/config-schema.js +14 -0
  53. package/dist/core/config/config.js +302 -24
  54. package/dist/core/env-secret-ref.js +58 -5
  55. package/dist/core/errors.js +30 -0
  56. package/dist/core/improve-result.js +51 -0
  57. package/dist/core/loopback.js +17 -0
  58. package/dist/core/paths.js +11 -0
  59. package/dist/core/run-lock.js +96 -0
  60. package/dist/core/sensitive-marker-path.js +19 -0
  61. package/dist/core/state-db.js +74 -14
  62. package/dist/indexer/index-rebuild-lock.js +73 -0
  63. package/dist/indexer/index-writer-lock.js +40 -1
  64. package/dist/indexer/index-written-assets.js +21 -1
  65. package/dist/indexer/indexer.js +18 -17
  66. package/dist/indexer/materialize-embeddings.js +282 -32
  67. package/dist/indexer/search/db-search.js +49 -2
  68. package/dist/integrations/agent/engine-resolution.js +96 -6
  69. package/dist/integrations/agent/execution-definitions.js +6 -15
  70. package/dist/integrations/agent/execution-lowering.js +6 -1
  71. package/dist/integrations/agent/execution-preparation.js +1 -1
  72. package/dist/integrations/agent/model-map.js +123 -20
  73. package/dist/integrations/agent/prompts.js +40 -8
  74. package/dist/integrations/agent/runner-dispatch.js +9 -3
  75. package/dist/integrations/agent/runner.js +2 -0
  76. package/dist/llm/client.js +8 -3
  77. package/dist/llm/embedder.js +20 -8
  78. package/dist/llm/embedders/local.js +10 -2
  79. package/dist/llm/embedders/remote.js +188 -21
  80. package/dist/output/shapes/helpers.js +38 -2
  81. package/dist/output/shapes/models-list.js +16 -0
  82. package/dist/output/shapes/passthrough.js +2 -0
  83. package/dist/output/shapes.js +4 -0
  84. package/dist/output/text/command-format.js +29 -0
  85. package/dist/output/text/helpers.js +1 -1
  86. package/dist/output/text/improve-report.js +27 -0
  87. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  88. package/dist/output/text/show-format.js +4 -0
  89. package/dist/output/text.js +4 -0
  90. package/dist/scripts/akm-migrate-node.js +24798 -21732
  91. package/dist/scripts/akm-migrate.js +23408 -20343
  92. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  93. package/dist/storage/repositories/index-fts-repository.js +49 -6
  94. package/dist/storage/repositories/index-vec-repository.js +30 -0
  95. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  96. package/dist/tasks/backends/cron.js +14 -7
  97. package/dist/tasks/run/run-workflow-task.js +16 -0
  98. package/dist/workflows/exec/child-workflow.js +2 -2
  99. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  100. package/dist/workflows/exec/run-workflow.js +6 -5
  101. package/dist/workflows/runtime/runs.js +33 -5
  102. package/docs/migration/release-notes/0.9.15.md +52 -0
  103. package/docs/migration/release-notes/README.md +4 -0
  104. package/docs/reference/cli.md +245 -29
  105. package/docs/reference/configuration.md +180 -19
  106. package/docs/reference/data-and-telemetry.md +8 -0
  107. package/docs/reference/tasks.md +16 -1
  108. package/docs/reference/workflow-schema.md +5 -1
  109. package/package.json +1 -1
  110. package/schemas/akm-config.json +8 -0
@@ -8,7 +8,7 @@ import { adapterForId } from "../core/adapter/registry.js";
8
8
  import { isHttpUrl, toErrorMessage } from "../core/common.js";
9
9
  import { concurrentMap } from "../core/concurrent.js";
10
10
  import { ConfigError } from "../core/errors.js";
11
- import { isLoopbackEndpoint } from "../core/loopback.js";
11
+ import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
12
12
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
13
13
  import { getDbPath } from "../core/paths.js";
14
14
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
@@ -54,20 +54,17 @@ function throwIfAborted(signal) {
54
54
  export function getDefaultLlmConcurrency(llmConfig) {
55
55
  if (typeof llmConfig?.concurrency === "number")
56
56
  return llmConfig.concurrency;
57
- // Local model servers stay at 1 (single loaded model; parallel requests
58
- // trigger reload thrash); an absent or unparseable endpoint fails safe as
59
- // local. ONE classifier decides what "local" means (`core/loopback.ts`,
60
- // shared with the workflow engine's frozen concurrency default).
61
- if (isLoopbackEndpoint(llmConfig?.endpoint))
62
- return 1;
63
- // Remote endpoints default to a modest 2-wide pool (owner ruling 2026-07-21):
64
- // enough to overlap request latency without hammering rate-limited APIs.
65
- // The explicit-override branch above only fires for
66
- // callers that put `concurrency` on the connection themselves —
67
- // `engines.<name>.concurrency` is a valid schema field but `resolveLlmEngineUse`
68
- // does NOT copy it into the resolved connection, so on the enrichment path the
69
- // auto-derived 1/2 is what runs (see docs/architecture/internals/indexing.md).
70
- return 2;
57
+ // ONE classifier decides the local-vs-remote default (`core/loopback.ts`'s
58
+ // `defaultConcurrencyForEndpoint`), shared with the embedding pool
59
+ // (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`) and the
60
+ // workflow engine's frozen concurrency default.
61
+ //
62
+ // The explicit-override branch above only fires for callers that put
63
+ // `concurrency` on the connection themselves `engines.<name>.concurrency`
64
+ // is a valid schema field but `resolveLlmEngineUse` does NOT copy it into
65
+ // the resolved connection, so on the enrichment path the auto-derived 1/2
66
+ // is what runs (see docs/architecture/internals/indexing.md).
67
+ return defaultConcurrencyForEndpoint(llmConfig?.endpoint);
71
68
  }
72
69
  function sourceOwners(sources) {
73
70
  const installations = deriveInstallations([...sources]);
@@ -192,14 +189,16 @@ async function runWalkPhase(ctx) {
192
189
  * entries. Writes `ctx.embeddingResult` for the finalize phase.
193
190
  */
194
191
  async function runEmbeddingPhase(ctx) {
195
- const { db, config, signal, onProgress } = ctx;
192
+ const { db, config, signal, onProgress, reembed } = ctx;
196
193
  throwIfAborted(signal);
197
194
  // Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
198
195
  // inert — its throwIfAborted checks and the signal it threads into embedBatch
199
196
  // (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
200
197
  // chunks) never saw a controller. Ctrl-C and the improve budget abort could not
201
198
  // stop the embedding phase, the longest phase of an index run.
202
- ctx.embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal);
199
+ ctx.embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal, undefined, {
200
+ forceReembed: reembed,
201
+ });
203
202
  ctx.timing.tEmbedEnd = Date.now();
204
203
  }
205
204
  /**
@@ -489,6 +488,7 @@ async function akmIndexReal(options) {
489
488
  const full = options?.full === true;
490
489
  const clean = options?.clean === true;
491
490
  const dryRun = options?.dryRun === true;
491
+ const reembed = options?.reembed === true;
492
492
  // Load config and resolve all stash sources
493
493
  const { loadConfig, mutateConfig } = await import("../core/config/config.js");
494
494
  let config = loadConfig();
@@ -548,6 +548,7 @@ async function akmIndexReal(options) {
548
548
  sourceDirs: allSourceDirs,
549
549
  full,
550
550
  clean,
551
+ reembed,
551
552
  stashDir,
552
553
  onProgress,
553
554
  signal,
@@ -6,9 +6,10 @@ import { embedBatch } from "../llm/embedder.js";
6
6
  import { DETERMINISTIC_EMBED_MODEL_ID, isDeterministicEmbedEnabled } from "../llm/embedders/deterministic.js";
7
7
  import { DEFAULT_LOCAL_MODEL } from "../llm/embedders/local.js";
8
8
  import { buildTokenBoundedBatches, DEFAULT_REMOTE_BATCH_SIZE, DEFAULT_TOKEN_BUDGET, estimateTokenCount, hasRemoteEndpoint, } from "../llm/embedders/remote.js";
9
+ import { cosineSimilarity } from "../llm/embedders/types.js";
9
10
  import { getEmbeddableEntryCount } from "../storage/repositories/index-entries-repository.js";
10
11
  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";
12
+ import { getAllEntriesForEmbedding, getEmbeddingCount, isVecFastPathComplete, isVecFastPathReady, purgeEmbeddings, sampleEmbeddedEntriesForCanary, setVecFastPathReady, upsertEmbedding, } from "../storage/repositories/index-vec-repository.js";
12
13
  /** Identifies the embedding provider+model+dimension a stored vector was generated with. */
13
14
  export function deriveSemanticProviderFingerprint(embedding) {
14
15
  if (isDeterministicEmbedEnabled()) {
@@ -22,12 +23,159 @@ export function deriveSemanticProviderFingerprint(embedding) {
22
23
  }
23
24
  return `local:${embedding?.localModel ?? DEFAULT_LOCAL_MODEL}`;
24
25
  }
26
+ /** How often (in stored entries) to emit a progress line during a large embedding run (#954). */
27
+ const PROGRESS_INTERVAL = 500;
28
+ /**
29
+ * Number of already-embedded entries sampled for the fingerprint-rename
30
+ * canary (#955) — small and cheap even against a slow local server; a
31
+ * handful of chunks is a strong compatibility signal (a different model
32
+ * cannot plausibly land near-identical vectors by chance).
33
+ */
34
+ const CANARY_SAMPLE_SIZE = 8;
35
+ /**
36
+ * Minimum median cosine similarity between stored and freshly re-embedded
37
+ * canary vectors for a fingerprint-string change to be treated as a
38
+ * same-model rename rather than a real model change (#955).
39
+ */
40
+ const CANARY_SIMILARITY_THRESHOLD = 0.999;
41
+ /**
42
+ * Pure decision: do stored vectors remain valid against freshly re-embedded
43
+ * canary samples? The ONE place that computes the canary's similarity
44
+ * numbers — callers must use this result rather than recomputing it (#955).
45
+ *
46
+ * An empty sample means nothing is stored to lose or verify against, so
47
+ * there is nothing to decide — keep.
48
+ *
49
+ * A sample whose re-embed FAILED (`fresh === undefined`, e.g. a provider
50
+ * sub-batch that was skipped) is EXCLUDED from the similarity computation
51
+ * entirely, not scored as zero: a partial provider failure is not evidence
52
+ * of a different model (#955). A dimension mismatch on a successful
53
+ * re-embed still counts as zero similarity via {@link cosineSimilarity}'s
54
+ * own dimension-mismatch guard — that IS evidence. When half or fewer of
55
+ * the sampled entries re-embedded successfully, the sample is too thin to
56
+ * trust either verdict — the outcome is `unverifiable`, the same outcome a
57
+ * total canary failure already produces.
58
+ *
59
+ * Otherwise the MEDIAN pairwise cosine similarity of the verified samples
60
+ * must clear {@link CANARY_SIMILARITY_THRESHOLD}; the median (not the
61
+ * minimum or mean) tolerates one stale or lightly-edited sample without
62
+ * either discarding a real match or being fooled by it.
63
+ */
64
+ export function decideEmbeddingCompatibility(pairs) {
65
+ if (pairs.length === 0)
66
+ return { outcome: "keep", medianSimilarity: undefined, verifiedSamples: 0 };
67
+ const verified = pairs.filter((pair) => pair.fresh !== undefined);
68
+ if (verified.length * 2 <= pairs.length) {
69
+ return { outcome: "unverifiable", medianSimilarity: undefined, verifiedSamples: verified.length };
70
+ }
71
+ const similarities = verified.map((pair) => cosineSimilarity(pair.stored, pair.fresh));
72
+ const medianSimilarity = medianOf(similarities);
73
+ return {
74
+ outcome: medianSimilarity >= CANARY_SIMILARITY_THRESHOLD ? "keep" : "rebuild",
75
+ medianSimilarity,
76
+ verifiedSamples: verified.length,
77
+ };
78
+ }
79
+ function medianOf(values) {
80
+ const sorted = [...values].sort((a, b) => a - b);
81
+ const mid = Math.floor(sorted.length / 2);
82
+ return sorted.length % 2 === 0
83
+ ? (sorted[mid - 1] + sorted[mid]) / 2
84
+ : sorted[mid];
85
+ }
86
+ /**
87
+ * Identity of the embedding vectors actually observed on a run — as opposed
88
+ * to {@link deriveSemanticProviderFingerprint}'s CONFIG-derived string. Keys
89
+ * on what the server (or local model) actually reported plus the observed
90
+ * vector width, so a gateway/transport change that keeps returning the same
91
+ * underlying model can be told apart from a genuine model change without
92
+ * relying on the operator's config string (#955 field-review addendum).
93
+ * Returns undefined when nothing was actually observed this call (no vector
94
+ * to measure yet).
95
+ */
96
+ function deriveObservedEmbeddingIdentity(embedding, observedModel, observedVectorLen) {
97
+ if (isDeterministicEmbedEnabled()) {
98
+ return `deterministic:${DETERMINISTIC_EMBED_MODEL_ID}`;
99
+ }
100
+ if (observedVectorLen === undefined)
101
+ return undefined;
102
+ if (embedding?.endpoint) {
103
+ return `remote:${observedModel ?? embedding.model ?? "unknown"}|${observedVectorLen}`;
104
+ }
105
+ return `local:${embedding?.localModel ?? DEFAULT_LOCAL_MODEL}|${observedVectorLen}`;
106
+ }
107
+ /**
108
+ * Run the fingerprint-rename canary: re-embed a small sample of already-
109
+ * stored entries with the CURRENT config and decide whether the stored
110
+ * index survives. Goes through the standard {@link embedBatch} facade (not a
111
+ * direct `RemoteEmbedder`) so every embedder branch — remote, local,
112
+ * deterministic, and test overrides via `_setEmbedderForTests` — is
113
+ * exercised identically to the main embedding pass.
114
+ */
115
+ async function runEmbeddingCanary(db, config, signal) {
116
+ const samples = sampleEmbeddedEntriesForCanary(db, CANARY_SAMPLE_SIZE);
117
+ if (samples.length === 0) {
118
+ return { outcome: "keep", verified: false, viaIdentityMatch: false };
119
+ }
120
+ let observedModel;
121
+ const skips = [];
122
+ let canaryVectors;
123
+ try {
124
+ canaryVectors = await embedBatch(samples.map((sample) => sample.searchText), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
125
+ if (model)
126
+ observedModel = model;
127
+ });
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ return {
132
+ outcome: "unverifiable",
133
+ message: `could not verify embedding compatibility (${message}); keeping existing vectors — rerun akm index when the endpoint is reachable`,
134
+ };
135
+ }
136
+ const observedVectorLen = canaryVectors.find((vector) => vector !== undefined)?.length;
137
+ const observedIdentity = deriveObservedEmbeddingIdentity(config.embedding, observedModel, observedVectorLen);
138
+ const storedIdentity = getMeta(db, "embeddingIdentity");
139
+ if (storedIdentity && observedIdentity && storedIdentity === observedIdentity) {
140
+ // The server reports the same model identity as last time — no need to
141
+ // even look at the cosines; the config string alone was misleading.
142
+ return { outcome: "keep", verified: true, identity: observedIdentity, viaIdentityMatch: true };
143
+ }
144
+ const pairs = samples.map((sample, i) => ({ stored: sample.vector, fresh: canaryVectors[i] }));
145
+ const decision = decideEmbeddingCompatibility(pairs);
146
+ if (decision.outcome === "unverifiable") {
147
+ // Covers both a total provider failure (RemoteEmbedder skips a failing
148
+ // request rather than throwing, #874, so an unreachable endpoint
149
+ // surfaces here as an all-`undefined` canary result, not a caught
150
+ // exception) and a partial one thin enough that neither verdict can be
151
+ // trusted (#955) — same message path either way.
152
+ const message = skips[0]?.message ?? "embedding provider returned no vectors for the canary sample";
153
+ return {
154
+ outcome: "unverifiable",
155
+ message: `could not verify embedding compatibility (${message}); keeping existing vectors — rerun akm index when the endpoint is reachable`,
156
+ };
157
+ }
158
+ if (decision.outcome === "keep") {
159
+ return {
160
+ outcome: "keep",
161
+ verified: true,
162
+ identity: observedIdentity,
163
+ viaIdentityMatch: false,
164
+ medianSimilarity: decision.medianSimilarity,
165
+ };
166
+ }
167
+ return {
168
+ outcome: "rebuild",
169
+ identity: observedIdentity,
170
+ reason: `vectors differ (median similarity ${decision.medianSimilarity?.toFixed(3)})`,
171
+ };
172
+ }
25
173
  function throwIfAborted(signal) {
26
174
  if (signal?.aborted) {
27
175
  throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
28
176
  }
29
177
  }
30
- export async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds) {
178
+ export async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds, opts) {
31
179
  throwIfAborted(signal);
32
180
  if (config.semanticSearchMode === "off") {
33
181
  onProgress({ phase: "embeddings", message: "Semantic search disabled; skipping embeddings." });
@@ -41,12 +189,69 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
41
189
  const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
42
190
  const storedFingerprint = getMeta(db, "embeddingFingerprint");
43
191
  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.
192
+ /** Set only on an actual rebuild, so the up-front "Re-embedding N entries" line names why. */
193
+ let rebuildReason;
194
+ if (opts?.forceReembed) {
195
+ // `akm index --reembed`: an explicit operator override, skips the canary
196
+ // entirely. The new fingerprint (and identity, now stale/unknown until
197
+ // the next successful pass observes it) is written in the SAME
198
+ // transaction as the purge, before any embedding request — a restart
199
+ // then sees a matching fingerprint and only heals what is still missing
200
+ // instead of purging again from zero (#955/#956).
201
+ db.transaction(() => {
202
+ purgeEmbeddings(db, { dropVecTable: true });
203
+ deleteMeta(db, "embeddingDim");
204
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
205
+ deleteMeta(db, "embeddingIdentity");
206
+ })();
49
207
  targetEntryIds = undefined;
208
+ rebuildReason = "forced by --reembed";
209
+ }
210
+ else if (storedFingerprint && storedFingerprint !== currentFingerprint) {
211
+ const decision = await runEmbeddingCanary(db, config, signal);
212
+ if (decision.outcome === "unverifiable") {
213
+ // Destroying a good index because the server happens to be down right
214
+ // now is worse than leaving a rename unverified until the next run —
215
+ // keep the vectors AND the old fingerprint so the next `akm index`
216
+ // retries the canary instead of silently treating this as resolved.
217
+ warn(`[embed] ${decision.message}`);
218
+ onProgress({ phase: "embeddings", message: decision.message });
219
+ return { success: false, message: decision.message };
220
+ }
221
+ if (decision.outcome === "rebuild") {
222
+ db.transaction(() => {
223
+ purgeEmbeddings(db, { dropVecTable: true });
224
+ deleteMeta(db, "embeddingDim");
225
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
226
+ if (decision.identity)
227
+ setMeta(db, "embeddingIdentity", decision.identity);
228
+ else
229
+ deleteMeta(db, "embeddingIdentity");
230
+ })();
231
+ targetEntryIds = undefined;
232
+ rebuildReason = decision.reason;
233
+ }
234
+ else {
235
+ // Keep: adopt the new fingerprint (and identity, when observed)
236
+ // immediately rather than deferring to end-of-run — nothing was
237
+ // purged, so there is nothing an interruption could lose, and an
238
+ // immediate write means a crash right after this decision does not
239
+ // re-run the canary needlessly on the next attempt.
240
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
241
+ if (decision.identity)
242
+ setMeta(db, "embeddingIdentity", decision.identity);
243
+ if (decision.verified) {
244
+ const keptCount = getEmbeddingCount(db);
245
+ const detail = decision.viaIdentityMatch
246
+ ? "server-reported model unchanged"
247
+ : `stored vectors are compatible (median similarity ${decision.medianSimilarity?.toFixed(3)})`;
248
+ const message = `[embed] embedding model renamed (${storedFingerprint} → ${currentFingerprint}); ${detail}, keeping ${keptCount} embedding${keptCount === 1 ? "" : "s"}.`;
249
+ warn(message);
250
+ onProgress({ phase: "embeddings", message });
251
+ }
252
+ // Empty-sample case (decision.verified === false): nothing stored to
253
+ // lose or verify against — adopt the label silently, no purge line.
254
+ }
50
255
  }
51
256
  try {
52
257
  throwIfAborted(signal);
@@ -56,6 +261,11 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
56
261
  setMeta(db, "embeddingFingerprint", currentFingerprint);
57
262
  return { success: true };
58
263
  }
264
+ if (rebuildReason) {
265
+ const message = `[embed] Re-embedding ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"} because ${rebuildReason}`;
266
+ warn(message);
267
+ onProgress({ phase: "embeddings", message });
268
+ }
59
269
  onProgress({
60
270
  phase: "embeddings",
61
271
  message: `Generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}.`,
@@ -94,42 +304,77 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
94
304
  }
95
305
  }
96
306
  let heartbeatTimer;
307
+ let storedCount = 0;
308
+ let skippedCount = 0;
309
+ let embedFailedCount = 0;
310
+ let vecFailedCount = 0;
311
+ let vecUnavailableCount = 0;
312
+ let storedTokens = 0;
313
+ let lastProgressBucket = 0;
97
314
  try {
98
315
  heartbeatTimer = setInterval(() => {
99
316
  onProgress({
100
317
  phase: "embeddings",
101
- message: `Still generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}; waiting on embedding provider.`,
318
+ message: `Still generating embeddings: ${storedCount}/${allEntries.length} stored; waiting on embedding provider.`,
102
319
  });
103
320
  }, 15000);
104
321
  // A failing sub-batch or an oversized document is SKIPPED by embedBatch,
105
322
  // not thrown (#874) — collect what couldn't be embedded and why, so a
106
323
  // few bad documents don't discard every other entry's embedding.
107
324
  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;
325
+ const embedStart = Date.now();
326
+ // Commit each provider batch in its own short transaction as it lands,
327
+ // rather than buffering the whole run in memory for one transaction at
328
+ // the very end (#954) — a competing-process lock error or any other
329
+ // interruption partway through now keeps whatever already committed
330
+ // instead of losing the entire pass.
331
+ // Tracks what this run actually observed, so a successful pass can
332
+ // record `embeddingIdentity` from real data rather than the config
333
+ // string alone (#955) only the first non-empty batch's vector width
334
+ // is kept; every batch from one run shares the same provider/model.
335
+ let observedModel;
336
+ let observedVectorLen;
337
+ const onBatch = (indices, batchEmbeddings, model) => {
338
+ if (model)
339
+ observedModel = model;
340
+ db.transaction(() => {
341
+ for (let k = 0; k < indices.length; k++) {
342
+ const entry = allEntries[indices[k]];
343
+ if (!entry)
344
+ continue;
345
+ const embedding = batchEmbeddings[k];
346
+ if (!embedding) {
347
+ embedFailedCount++;
348
+ continue;
349
+ }
350
+ if (observedVectorLen === undefined)
351
+ observedVectorLen = embedding.length;
352
+ const result = upsertEmbedding(db, entry.id, embedding);
353
+ if (result.stored) {
354
+ storedCount++;
355
+ storedTokens += estimateTokenCount(entry.searchText);
356
+ }
357
+ else {
358
+ skippedCount++;
359
+ }
360
+ if (result.vec === "failed")
361
+ vecFailedCount++;
362
+ if (result.vec === "unavailable")
363
+ vecUnavailableCount++;
121
364
  }
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++;
365
+ })();
366
+ const bucket = Math.floor(storedCount / PROGRESS_INTERVAL);
367
+ if (bucket > lastProgressBucket) {
368
+ lastProgressBucket = bucket;
369
+ onProgress({
370
+ phase: "embeddings",
371
+ message: `Embedded ${storedCount}/${allEntries.length} entries.`,
372
+ });
131
373
  }
132
- })();
374
+ };
375
+ await embedBatch(texts, config.embedding, signal, (skip) => skips.push(skip), onBatch);
376
+ throwIfAborted(signal);
377
+ const elapsedSeconds = Math.max((Date.now() - embedStart) / 1000, 0.001);
133
378
  if (skippedCount > 0) {
134
379
  warn(`[embed] ${skippedCount} embedding${skippedCount === 1 ? "" : "s"} skipped (entry deleted between queue and write)`);
135
380
  }
@@ -148,11 +393,16 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
148
393
  "semantic search will use the slower JS-cosine fallback over stored embeddings. " +
149
394
  "Rebuild with 'akm index --full' after resolving the vec table (often a vector-dimension mismatch).");
150
395
  }
396
+ const entriesPerSec = storedCount / elapsedSeconds;
397
+ const tokensPerSec = storedTokens / elapsedSeconds;
151
398
  onProgress({
152
399
  phase: "embeddings",
153
- message: `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"}.`,
400
+ message: `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"} in ${elapsedSeconds.toFixed(1)}s (${entriesPerSec.toFixed(1)} entries/s, ~${Math.round(tokensPerSec)} tokens/s).`,
154
401
  });
155
402
  setMeta(db, "embeddingFingerprint", currentFingerprint);
403
+ const observedIdentity = deriveObservedEmbeddingIdentity(config.embedding, observedModel, observedVectorLen);
404
+ if (observedIdentity)
405
+ setMeta(db, "embeddingIdentity", observedIdentity);
156
406
  // Only a total failure (nothing at all embedded, despite having entries
157
407
  // to embed) turns into a phase failure. Any partial success — the vast
158
408
  // majority of a large bundle embedding fine around a handful of skips —
@@ -25,7 +25,7 @@ import { allowsFragmentRef, defaultRendererRegistry } from "../../core/type-pres
25
25
  import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
26
26
  import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
27
27
  import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
28
- import { searchFts } from "../../storage/repositories/index-fts-repository.js";
28
+ import { getIndexedMarkdownFragment, getIndexedMarkdownFragments, searchFts, } from "../../storage/repositories/index-fts-repository.js";
29
29
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
30
30
  import { getEmbeddingCount, searchVec } from "../../storage/repositories/index-vec-repository.js";
31
31
  import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
@@ -451,6 +451,14 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
451
451
  });
452
452
  const rankMs = Date.now() - tRank0;
453
453
  const selected = beliefFiltered.slice(0, limit);
454
+ const fragmentSelections = selected.flatMap((ranked) => ranked.fragmentId && allowsFragmentRef(ranked.entry.type) && hasIndexedProvenance(ranked)
455
+ ? [{ entryId: ranked.id, itemRef: ranked.itemRef, fragmentId: ranked.fragmentId }]
456
+ : []);
457
+ const selectedFragments = getIndexedMarkdownFragments(db, fragmentSelections);
458
+ const selectedFragmentByEntryId = new Map();
459
+ fragmentSelections.forEach((selection, index) => {
460
+ selectedFragmentByEntryId.set(selection.entryId, selectedFragments[index]);
461
+ });
454
462
  const hits = await Promise.all(selected.map((ranked) => {
455
463
  const { entry, filePath, score, rankingMode, utilityBoosted } = ranked;
456
464
  // CLAUDE.md locks SearchHit.score in [0,1]. The boost loop deliberately
@@ -467,6 +475,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
467
475
  rankingMode,
468
476
  lexicalMatch: ranked.lexicalMatch,
469
477
  fragmentId: ranked.fragmentId,
478
+ indexedFragment: ranked.fragmentId ? (selectedFragmentByEntryId.get(ranked.id) ?? null) : undefined,
470
479
  defaultStashDir: stashDir,
471
480
  allSourceDirs,
472
481
  sources,
@@ -788,7 +797,19 @@ export async function buildDbHit(input) {
788
797
  // The central type-presentation contract opts those types out explicitly.
789
798
  const ref = input.fragmentId && allowsFragmentRef(input.entry.type) ? `${parentRef}#${input.fragmentId}` : parentRef;
790
799
  const editable = isEditable(absolutePath, input.config, input.sources);
791
- const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
800
+ const indexedFragment = input.indexedFragment === undefined
801
+ ? input.fragmentId && input.db
802
+ ? getIndexedMarkdownFragment(input.db, input.itemRef, input.fragmentId)
803
+ : undefined
804
+ : (input.indexedFragment ?? undefined);
805
+ const selectedRef = input.fragmentId && ref !== parentRef ? `${parentRef}#${input.fragmentId}` : undefined;
806
+ const parentEstimatedTokens = typeof input.entry.fileSize === "number"
807
+ ? Math.round(input.entry.fileSize / 4)
808
+ : indexedFragment
809
+ ? Math.round(indexedFragment.parentChars / 4)
810
+ : undefined;
811
+ const fragmentEstimatedTokens = indexedFragment ? Math.round(indexedFragment.fragmentChars / 4) : undefined;
812
+ const estimatedTokens = selectedRef === ref && fragmentEstimatedTokens !== undefined ? fragmentEstimatedTokens : parentEstimatedTokens;
792
813
  const hit = {
793
814
  type: input.entry.type,
794
815
  name: input.entry.name,
@@ -804,6 +825,32 @@ export async function buildDbHit(input) {
804
825
  score,
805
826
  whyMatched,
806
827
  ...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
828
+ ...(selectedRef
829
+ ? {
830
+ selectedRef,
831
+ parentRef,
832
+ ...(indexedFragment
833
+ ? {
834
+ fragmentOrdinal: indexedFragment.ordinal + 1,
835
+ fragmentCount: indexedFragment.count,
836
+ startLine: indexedFragment.startLine,
837
+ endLine: indexedFragment.endLine,
838
+ ...(indexedFragment.previousFragmentId
839
+ ? { previousRef: `${parentRef}#${indexedFragment.previousFragmentId}` }
840
+ : {}),
841
+ ...(indexedFragment.nextFragmentId
842
+ ? { nextRef: `${parentRef}#${indexedFragment.nextFragmentId}` }
843
+ : {}),
844
+ fragmentChars: indexedFragment.fragmentChars,
845
+ fragmentEstimatedTokens,
846
+ parentChars: indexedFragment.parentChars,
847
+ ...(parentEstimatedTokens !== undefined ? { parentEstimatedTokens } : {}),
848
+ }
849
+ : parentEstimatedTokens !== undefined
850
+ ? { parentEstimatedTokens }
851
+ : {}),
852
+ }
853
+ : {}),
807
854
  // Surface optional quality (v1 spec §4.2). Omitted when entry has
808
855
  // no `quality` field so payloads stay compact for the common case.
809
856
  ...(input.entry.quality ? { quality: input.entry.quality } : {}),