akm-cli 0.9.15 → 0.9.16-alpha.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 (79) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  3. package/dist/cli/retired-commands.js +2 -0
  4. package/dist/cli/unknown-flags.js +36 -3
  5. package/dist/commands/improve/collapse-detector.js +2 -2
  6. package/dist/commands/improve/consolidate.js +6 -4
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/proposal/repository.js +12 -3
  9. package/dist/commands/read/curate.js +34 -44
  10. package/dist/commands/read/search.js +50 -2
  11. package/dist/commands/sources/index-status.js +99 -0
  12. package/dist/commands/sources/info.js +8 -8
  13. package/dist/commands/sources/installed-stashes.js +33 -12
  14. package/dist/commands/sources/source-add.js +21 -6
  15. package/dist/commands/sources/stash-cli.js +119 -111
  16. package/dist/core/adapter/adapters/akm-adapter.js +35 -3
  17. package/dist/core/adapter/adapters/akm-metadata.js +11 -1
  18. package/dist/core/asset/asset-placement.js +35 -0
  19. package/dist/core/config/schema/embedding.js +7 -30
  20. package/dist/core/config/schema/search.js +11 -9
  21. package/dist/core/errors.js +5 -2
  22. package/dist/core/hash.js +18 -0
  23. package/dist/core/maintenance-barrier.js +8 -6
  24. package/dist/core/paths.js +0 -11
  25. package/dist/core/run-lock.js +5 -2
  26. package/dist/core/state/migrations.js +26 -1
  27. package/dist/core/state-db.js +63 -27
  28. package/dist/indexer/drain.js +306 -0
  29. package/dist/indexer/embedding-identity.js +20 -0
  30. package/dist/indexer/enrich.js +260 -0
  31. package/dist/indexer/ensure-index.js +5 -0
  32. package/dist/indexer/index-written-assets.js +133 -171
  33. package/dist/indexer/indexer.js +458 -1621
  34. package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
  35. package/dist/indexer/passes/metadata.js +18 -1
  36. package/dist/indexer/reconcile.js +890 -0
  37. package/dist/indexer/scan/drain-dir.js +27 -70
  38. package/dist/indexer/scan/parse-file.js +66 -0
  39. package/dist/indexer/search/db-search.js +373 -89
  40. package/dist/indexer/search/ranking-contributors.js +21 -16
  41. package/dist/indexer/search/ranking.js +135 -57
  42. package/dist/indexer/units/unit.js +159 -0
  43. package/dist/llm/client.js +10 -1
  44. package/dist/llm/embedder.js +10 -3
  45. package/dist/llm/embedders/provider-limits.js +288 -0
  46. package/dist/llm/embedders/remote.js +133 -104
  47. package/dist/llm/feature-gate.js +4 -2
  48. package/dist/llm/rerank-client.js +3 -3
  49. package/dist/output/shapes/passthrough.js +1 -0
  50. package/dist/output/text/command-format.js +19 -13
  51. package/dist/output/text/helpers.js +1 -1
  52. package/dist/output/text/index.js +5 -2
  53. package/dist/scripts/akm-migrate-node.js +1141 -1237
  54. package/dist/scripts/akm-migrate.js +1141 -1237
  55. package/dist/setup/semantic-assets.js +2 -2
  56. package/dist/setup/steps/connection.js +3 -2
  57. package/dist/storage/repositories/files-repository.js +181 -0
  58. package/dist/storage/repositories/index-connection.js +1 -3
  59. package/dist/storage/repositories/index-entries-repository.js +77 -68
  60. package/dist/storage/repositories/index-entry-schema.js +16 -25
  61. package/dist/storage/repositories/index-fts-repository.js +29 -263
  62. package/dist/storage/repositories/index-meta-repository.js +0 -29
  63. package/dist/storage/repositories/index-schema.js +115 -122
  64. package/dist/storage/repositories/index-utility-repository.js +1 -1
  65. package/dist/storage/repositories/index-vec-repository.js +21 -334
  66. package/dist/storage/repositories/units-repository.js +510 -0
  67. package/docs/migration/release-notes/0.9.15.md +34 -36
  68. package/docs/migration/release-notes/0.9.16.md +110 -0
  69. package/docs/migration/release-notes/README.md +5 -0
  70. package/docs/reference/cli.md +93 -87
  71. package/docs/reference/configuration.md +128 -89
  72. package/docs/reference/data-and-telemetry.md +2 -1
  73. package/package.json +1 -1
  74. package/schemas/akm-config.json +2 -58
  75. package/dist/indexer/index-db-contention.js +0 -56
  76. package/dist/indexer/index-rebuild-lock.js +0 -73
  77. package/dist/indexer/materialize-embeddings.js +0 -771
  78. package/dist/indexer/passes/dir-staleness.js +0 -161
  79. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
@@ -4,75 +4,80 @@
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { detectAdapterId } from "../core/adapter/detect-adapter.js";
7
- import { adapterForId } from "../core/adapter/registry.js";
8
- import { isHttpUrl, toErrorMessage } from "../core/common.js";
9
- import { concurrentMap } from "../core/concurrent.js";
10
- import { ConfigError } from "../core/errors.js";
7
+ import { makeBundleRef } from "../core/asset/asset-ref.js";
8
+ import { conceptIdFromTypeName } from "../core/asset/resolve-ref.js";
9
+ import { isHttpUrl } from "../core/common.js";
10
+ import { AkmError, TransientError } from "../core/errors.js";
11
11
  import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
12
- import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
13
12
  import { getDbPath } from "../core/paths.js";
14
- import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
15
- import { withStateDb } from "../core/state-db.js";
16
- import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
17
- import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
18
- import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
19
- import { resolveIndexPassExecution } from "../llm/index-passes.js";
20
- import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
13
+ import { isSqliteContentionError, withStateDb } from "../core/state-db.js";
14
+ import { warn } from "../core/warn.js";
15
+ import { DEFAULT_REMOTE_BATCH_SIZE } from "../llm/embedders/remote.js";
21
16
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
22
- import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
23
17
  import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
24
- import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
25
- import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
26
- import { deleteIndexDirState, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
18
+ import { deleteEntriesByBundle, findEntryIdByRef, getEntryCount, relinkUsageEvents, } from "../storage/repositories/index-entries-repository.js";
19
+ import { getMeta, setMeta } from "../storage/repositories/index-meta-repository.js";
20
+ import { EMBEDDING_DIM } from "../storage/repositories/index-schema.js";
27
21
  import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
28
- import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
22
+ import { isVecAvailable } from "../storage/repositories/index-vec-repository.js";
23
+ import { dropOtherIdentities, unitCoverage } from "../storage/repositories/units-repository.js";
29
24
  import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
30
25
  import { deleteStoredGraph } from "./db/graph-db.js";
31
- import { reclassifyIndexDbContention } from "./index-db-contention.js";
32
- import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
26
+ import { drainEmbeddingQueue } from "./drain.js";
27
+ import { deriveInstallations } from "./installations.js";
33
28
  import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
34
- import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
35
- import { canUseIncrementalSkip, computeDirFingerprint, getCachedDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
36
- import { getMarkdownFragmentContent, hasMarkdownFragmentContent, isEnrichmentComplete, isWorkflowSkipWarning, setMarkdownFragmentContent, } from "./passes/metadata.js";
37
- import { drainDirDocuments } from "./scan/drain-dir.js";
38
- import { buildSearchText } from "./search/search-fields.js";
29
+ import { reconcileRoots } from "./reconcile.js";
39
30
  import { purgeOldUsageEvents } from "./usage/usage-events.js";
40
- import { walkStashFlatWithStatus } from "./walk/walker.js";
41
- function collectLoweringNotices(target, notices) {
42
- const keys = new Set(target.map((notice) => JSON.stringify(notice)));
43
- for (const notice of notices) {
44
- const key = JSON.stringify(notice);
45
- if (keys.has(key))
46
- continue;
47
- keys.add(key);
48
- target.push(notice);
49
- }
50
- }
31
+ /**
32
+ * How many units an IMPLICIT (read-path bootstrap) run embeds before leaving
33
+ * the rest of the queue to a later drain — one provider request's worth,
34
+ * reusing `DEFAULT_REMOTE_BATCH_SIZE` (the per-request document cap in
35
+ * `src/llm/embedders/remote.ts`) rather than a number picked here, so the
36
+ * bound means something concrete: a first read waits on roughly one round
37
+ * trip to the provider, not on the corpus.
38
+ */
39
+ const IMPLICIT_DRAIN_UNIT_LIMIT = DEFAULT_REMOTE_BATCH_SIZE;
51
40
  function throwIfAborted(signal) {
52
41
  if (signal?.aborted) {
53
42
  throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
54
43
  }
55
44
  }
45
+ /**
46
+ * Bounded-pool width for the metadata-enrichment pass (`./enrich.ts`,
47
+ * index-redesign B5e). An explicit `llmConfig.concurrency` wins (schema
48
+ * field, though `resolveLlmEngineUse` never populates it on this path — see
49
+ * AGENTS.md's LLM Defaults section — so this branch is effectively dead in
50
+ * production but kept for direct callers/tests); otherwise it is
51
+ * auto-derived from the endpoint via the ONE shared local-vs-remote
52
+ * classifier (`defaultConcurrencyForEndpoint`, `core/loopback.ts`), also used
53
+ * by the embedding pool (`resolveEmbeddingConcurrency`,
54
+ * `src/llm/embedders/remote.ts`): 1 for a loopback endpoint (a local model
55
+ * server serves one inference at a time; parallel requests cause "Model
56
+ * reloaded" / HTTP 500 errors), 2 for a remote one. `./enrich.ts` calls
57
+ * `defaultConcurrencyForEndpoint` directly rather than this wrapper to avoid
58
+ * an indexer.ts → enrich.ts → indexer.ts import cycle (the same reason
59
+ * `src/llm/embedders/remote.ts` cannot import this wrapper either); the two
60
+ * stay behaviorally identical since the override branch never fires here.
61
+ */
56
62
  export function getDefaultLlmConcurrency(llmConfig) {
57
63
  if (typeof llmConfig?.concurrency === "number")
58
64
  return llmConfig.concurrency;
59
- // ONE classifier decides the local-vs-remote default (`core/loopback.ts`'s
60
- // `defaultConcurrencyForEndpoint`), shared with the embedding pool
61
- // (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`) and the
62
- // workflow engine's frozen concurrency default.
63
- //
64
- // The explicit-override branch above only fires for callers that put
65
- // `concurrency` on the connection themselves — `engines.<name>.concurrency`
66
- // is a valid schema field but `resolveLlmEngineUse` does NOT copy it into
67
- // the resolved connection, so on the enrichment path the auto-derived 1/2
68
- // is what runs (see docs/architecture/internals/indexing.md).
69
65
  return defaultConcurrencyForEndpoint(llmConfig?.endpoint);
70
66
  }
67
+ /** Every currently-configured source's bundle id + resolved root, in installation-priority order. */
71
68
  function sourceOwners(sources) {
72
69
  const installations = deriveInstallations([...sources]);
73
70
  return sources.flatMap((source, index) => {
74
71
  const installation = installations[index];
75
- return installation ? [{ bundleId: installation.id, sourceRoot: path.resolve(source.path) }] : [];
72
+ return installation
73
+ ? [
74
+ {
75
+ bundleId: installation.id,
76
+ sourceRoot: path.resolve(source.path),
77
+ ...(source.unresolved ? { unresolved: true } : {}),
78
+ },
79
+ ]
80
+ : [];
76
81
  });
77
82
  }
78
83
  function parseStoredSourceOwners(raw) {
@@ -99,336 +104,28 @@ function parseStoredSourceOwners(raw) {
99
104
  }
100
105
  }
101
106
  /**
102
- * Source cache phase: ensure git stash caches are up to date and purge orphaned
103
- * entries from removed sources (incremental only).
104
- */
105
- async function runSourceCachePhase(ctx) {
106
- const { db, isIncremental, full, sources } = ctx;
107
- if (isIncremental && !full) {
108
- const currentByBundle = new Map(sourceOwners(sources).map((owner) => [owner.bundleId, owner]));
109
- for (const previous of parseStoredSourceOwners(getMeta(db, "sourceOwners"))) {
110
- const current = currentByBundle.get(previous.bundleId);
111
- if (!current || current.sourceRoot !== previous.sourceRoot) {
112
- ctx.hadRemovedSources = true;
113
- ctx.removedSources.push({
114
- ...previous,
115
- removeBundleEntries: current === undefined,
116
- });
117
- }
118
- }
119
- }
120
- // Source caches are hydrated before akmIndex() calls this phase; nothing
121
- // further to do here. The flag is exposed on ctx for runWalkPhase().
122
- }
123
- function applyRemovedSources(ctx) {
124
- if (!ctx.scanComplete)
125
- return;
126
- const currentRoots = new Set(sourceOwners(ctx.sources).map((owner) => owner.sourceRoot));
127
- for (const removed of ctx.removedSources) {
128
- if (removed.removeBundleEntries)
129
- deleteEntriesByBundle(ctx.db, removed.bundleId);
130
- if (!currentRoots.has(removed.sourceRoot))
131
- deleteStoredGraph(ctx.db, removed.sourceRoot);
132
- }
133
- }
134
- /**
135
- * Walk phase: scan the filesystem, generate metadata, and persist entries to
136
- * the database. Also kicks off LLM enrichment for directories that need it.
137
- *
138
- * Writes `ctx.scannedDirs`, `ctx.skippedDirs`, `ctx.generatedCount`,
139
- * `ctx.walkWarnings`, and `ctx.dirsNeedingLlm` for downstream phases.
107
+ * Delete entries (and their derived rows, via cascade) for any bundle that
108
+ * was configured on the previous run and is not any more, or whose root
109
+ * moved — `reconcileRoots` only walks CURRENT roots, so a removed bundle's
110
+ * rows would otherwise never be revisited. Runs before `reconcileRoots` so
111
+ * its own `pruneOrphanUnitTexts` sweep also collects any unit_texts this
112
+ * orphans. A root no longer claimed by ANY current bundle also drops its
113
+ * stored graph extraction (graph rows are keyed by root, not by entry, so
114
+ * entry deletion above does not reach them).
140
115
  */
141
- async function runWalkPhase(ctx) {
142
- const { db, sources, isIncremental, builtAtMs, hadRemovedSources, full, clean, signal, onProgress, config } = ctx;
143
- throwIfAborted(signal);
144
- ctx.timing.tWalkStart = Date.now();
145
- const doFullDelete = full || !isIncremental;
146
- const { scannedDirs, skippedDirs, generatedCount, dirsNeedingLlm, warnings, complete } = await indexEntries(db, sources, isIncremental, builtAtMs, hadRemovedSources, doFullDelete, onProgress, !clean, async (dirRecords, ownersByRoot) => {
147
- const runner = ctx.enrichmentExecution.runner;
148
- if (runner &&
149
- isLlmFeatureEnabled(config, "metadata_enhance") &&
150
- dirRecordsNeedMetadataDispatch(db, dirRecords, ownersByRoot)) {
151
- ctx.enrichmentLease = await preflightStructuredLlmRunner(runner);
152
- }
153
- });
154
- ctx.scannedDirs = scannedDirs;
155
- ctx.skippedDirs = skippedDirs;
156
- ctx.generatedCount = generatedCount;
157
- ctx.walkWarnings = warnings;
158
- ctx.dirsNeedingLlm = dirsNeedingLlm;
159
- ctx.scanComplete = complete;
160
- onProgress({
161
- phase: "scan",
162
- message: `Scanned ${scannedDirs} ${scannedDirs === 1 ? "directory" : "directories"} and skipped ${skippedDirs}.`,
163
- });
164
- // Workflow validation noise gate (issue #273): suppress per-spec stderr lines
165
- // at default verbosity and emit a single summary instead.
166
- // In verbose mode the per-spec lines are already printed by
167
- // buildMetadataSkipWarning at generation time — no second pass needed here.
168
- if (!isVerbose()) {
169
- const workflowSkipWarnings = warnings.filter(isWorkflowSkipWarning);
170
- const skippedWorkflowCount = workflowSkipWarnings.length;
171
- if (skippedWorkflowCount > 0) {
172
- const noun = skippedWorkflowCount === 1 ? "workflow spec" : "workflow specs";
173
- warn(`${skippedWorkflowCount} ${noun} skipped due to validation errors; ` +
174
- "rerun with --verbose (or AKM_VERBOSE=1) to see details.");
175
- }
176
- }
177
- ctx.timing.tWalkEnd = Date.now();
178
- throwIfAborted(signal);
179
- // LLM enrichment for directories that need it
180
- await enhanceDirsWithLlm(db, config, ctx.enrichmentExecution, dirsNeedingLlm, onProgress, signal, (notices) => collectLoweringNotices(ctx.loweringNotices, notices), ctx.enrichmentLease);
181
- onProgress({
182
- phase: "llm",
183
- message: ctx.enrichmentExecution.runner
184
- ? `LLM enhancement reviewed ${dirsNeedingLlm.length} ${dirsNeedingLlm.length === 1 ? "directory" : "directories"}.`
185
- : "LLM enhancement disabled.",
186
- });
187
- ctx.timing.tLlmEnd = Date.now();
188
- }
189
- /**
190
- * The ONE embedding-phase implementation (#954): generate and
191
- * store vectors for every entry missing one, then compute the `hasEmbeddings`
192
- * fact and the semantic-search verification off the result. `akmIndex`'s own
193
- * (non-deferred) run calls this from {@link runEmbeddingPhase} below; `akm
194
- * bundle update`'s coordinator calls it directly on its own connection AFTER
195
- * its unified update transaction commits, since the ambient-transaction drift
196
- * guard (and the whole point of per-batch commit, #954) requires `db` to have
197
- * no ambient transaction open.
198
- */
199
- export async function runEmbeddingPass(params) {
200
- const { db, config, onProgress, signal, reembed } = params;
201
- const embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal, undefined, {
202
- forceReembed: reembed,
203
- });
204
- setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
205
- const semanticEntryCount = getEmbeddableEntryCount(db);
206
- onProgress({ phase: "finalize", message: "Verifying semantic search state." });
207
- const verification = verifyIndexState(db, config, semanticEntryCount, embeddingResult);
208
- onProgress({ phase: "verify", message: verification.message });
209
- return { embeddingResult, verification };
210
- }
211
- /**
212
- * Embedding phase: generate and store vector embeddings for all unembedded
213
- * entries. Writes `ctx.embeddingResult` and `ctx.verification` for the
214
- * finalize phase / caller.
215
- */
216
- async function runEmbeddingPhase(ctx) {
217
- const { db, config, signal, onProgress, reembed, deferredUpdateTransaction } = ctx;
218
- throwIfAborted(signal);
219
- if (deferredUpdateTransaction) {
220
- // `akm bundle update`'s deferred pass (#954): the embedding
221
- // phase runs AFTER the coordinator's own commit, on its own connection,
222
- // via the coordinator's direct `runEmbeddingPass` call — never here,
223
- // inside the borrowed transaction (the ambient-transaction drift guard
224
- // would reject it anyway). `runFinalizePhase` records semantic state as
225
- // "pending".
226
- ctx.timing.tEmbedEnd = Date.now();
227
- return;
228
- }
229
- // Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
230
- // inert — its throwIfAborted checks and the signal it threads into embedBatch
231
- // (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
232
- // chunks) never saw a controller. Ctrl-C and the improve budget abort could not
233
- // stop the embedding phase, the longest phase of an index run.
234
- const { embeddingResult, verification } = await runEmbeddingPass({ db, config, onProgress, signal, reembed });
235
- ctx.embeddingResult = embeddingResult;
236
- ctx.verification = verification;
237
- ctx.timing.tEmbedEnd = Date.now();
238
- }
239
- /**
240
- * Finalize phase: confirm transactionally materialized FTS state, re-link
241
- * usage events, recompute utility scores, update index metadata, and emit the
242
- * verify event.
243
- */
244
- async function runFinalizePhase(ctx) {
245
- const { db, config, sources, sourceDirs, stashDir, signal, onProgress, deferredUpdateTransaction } = ctx;
246
- ctx.timing.tFinalizeStart = Date.now();
247
- // `upsertEntry` and every canonical delete own their FTS projection. This is
248
- // an observation point, not a second materialization pass.
249
- onProgress({
250
- phase: "fts",
251
- message: "Full-text search index is current.",
252
- });
253
- ctx.timing.tFtsEnd = Date.now();
254
- // Re-link state.db usage events to the regenerated index and recompute the
255
- // derived utility cache. Stored refs already use the current item-ref grammar,
256
- // so this idempotent pass only restores derived entry ids.
257
- const mutateState = (stateDb, stateSchema) => {
258
- onProgress({ phase: "finalize", message: "Relinking usage events." });
259
- relinkUsageEvents(db, stateDb, { sources, defaultStashDir: stashDir, stateSchema });
260
- onProgress({ phase: "finalize", message: "Recomputing utility scores." });
261
- recomputeUtilityScores(db, stateDb, { stateSchema });
262
- };
263
- if (deferredUpdateTransaction) {
264
- if (deferredUpdateTransaction.db !== db || !db.inTransaction) {
265
- throw new Error("Source update index finalization requires its borrowed unified transaction.");
266
- }
267
- // state.db is ATTACHed to this same index connection before the outer
268
- // BEGIN IMMEDIATE. Index and state mutations therefore share one SQLite
269
- // commit/rollback decision rather than an unsafe two-connection ordering.
270
- mutateState(db, deferredUpdateTransaction.stateSchema);
271
- }
272
- else {
273
- withStateDb(mutateState);
274
- }
275
- // Purge LLM cache entries for assets that no longer exist in the index.
276
- try {
277
- onProgress({ phase: "finalize", message: "Clearing stale LLM cache entries." });
278
- clearStaleCacheEntries(db);
279
- }
280
- catch {
281
- /* ignore */
282
- }
283
- throwIfAborted(signal);
284
- // An incomplete run preserves the prior freshness watermark. Advancing it
285
- // could make a recovered source look unchanged even though this run never
286
- // persisted its files.
287
- if (ctx.scanComplete) {
288
- setMeta(db, "builtAt", new Date().toISOString());
289
- setMeta(db, "stashDir", stashDir);
290
- setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
291
- setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
292
- }
293
- warnIfVecMissing(db);
294
- const totalEntries = getEntryCount(db);
295
- if (deferredUpdateTransaction) {
296
- // #954: the embedding phase was skipped for this borrowed
297
- // transaction — record semantic state as pending, never ready, until the
298
- // coordinator's own post-commit `runEmbeddingPass` call reports the
299
- // truth on a fresh connection.
300
- setMeta(db, "hasEmbeddings", "0");
301
- const semanticEntryCount = getEmbeddableEntryCount(db);
302
- const message = "Semantic index update deferred until after the source-update commit.";
303
- onProgress({ phase: "verify", message });
304
- ctx.verification = {
305
- ok: true,
306
- message,
307
- semanticSearchEnabled: config.semanticSearchMode === "auto",
308
- semanticSearchMode: config.semanticSearchMode,
309
- semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
310
- embeddingProvider: getEmbeddingProvider(config.embedding),
311
- entryCount: semanticEntryCount,
312
- embeddingCount: getEmbeddingCount(db),
313
- vecAvailable: isVecAvailable(db),
314
- };
315
- }
316
- // Non-deferred: ctx.verification was already populated by runEmbeddingPhase
317
- // (via the shared runEmbeddingPass).
318
- ctx.totalEntries = totalEntries;
319
- ctx.timing.tFinalizeEnd = Date.now();
320
- // suppress unused warning — sources was previously used inline
321
- void sources;
322
- }
323
- // ── Clean pass ───────────────────────────────────────────────────────────────
324
- /**
325
- * Missing-file reconciliation: scan the `entries` table for rows whose source
326
- * file no longer exists on disk and remove them (unless `dryRun` is true).
327
- *
328
- * Only rows with a non-empty `file_path` are checked — remote/virtual entries
329
- * that have no local path are always skipped.
330
- *
331
- * "No longer exists" means ABSENT, never merely unreadable (#791). This pass
332
- * DELETES rows, and `fs.existsSync` reported `false` for a file akm lacked
333
- * permission to look at exactly as for one that had been removed — so a
334
- * bundle temporarily mounted read-restricted (a uid mismatch, a tightened
335
- * parent directory) had its whole index wiped, and the run reported the
336
- * deletions as a clean success. Unreadable files keep their rows and are
337
- * reported instead.
338
- */
339
- function runCleanPass(db, dryRun) {
340
- const allEntries = db.prepare("SELECT id, item_ref AS ref, file_path AS path FROM entries").all();
341
- // Only check entries that have a non-empty local path (skip remote/virtual).
342
- const localEntries = allEntries.filter((e) => typeof e.path === "string" && e.path.trim() !== "");
343
- const missing = [];
344
- const unreadable = [];
345
- for (const entry of localEntries) {
346
- const { access, code } = classifyPathAccess(entry.path);
347
- if (access === "absent")
348
- missing.push(entry);
349
- else if (access === "inaccessible")
350
- unreadable.push({ path: entry.path, ...(code ? { code } : {}) });
351
- }
352
- if (unreadable.length > 0) {
353
- const shown = unreadable.slice(0, 5).map((u) => describeInaccessiblePath(u.path, u.code));
354
- warn(`Index clean pass kept ${unreadable.length} entr${unreadable.length === 1 ? "y" : "ies"} whose file akm cannot ` +
355
- `read (unreadable is not deleted): ${shown.join("; ")}${unreadable.length > shown.length ? "; …" : ""}`);
356
- }
357
- if (!dryRun && missing.length > 0) {
358
- deleteEntriesByIds(db, missing.map((e) => e.id));
359
- }
360
- return {
361
- checked: localEntries.length,
362
- removed: dryRun ? 0 : missing.length,
363
- removedRefs: missing.map((e) => e.ref),
364
- dryRun,
365
- };
366
- }
367
- // ── Indexer ──────────────────────────────────────────────────────────────────
368
- // ── Test seam ────────────────────────────────────────────────────────────────
369
- // Swap-and-restore override. Inert in production; only tests call the setter.
370
- let akmIndexOverride;
371
- /** TEST-ONLY. Swap the implementation of `akmIndex`; pass undefined to restore. */
372
- export function _setAkmIndexForTests(fake) {
373
- akmIndexOverride = fake;
374
- }
375
- // Moved to its own module (field follow-up to #956) so
376
- // `generateEmbeddingsForDb` (materialize-embeddings.ts) can reuse the same
377
- // classifier without an indexer.ts <-> materialize-embeddings.ts import
378
- // cycle. Re-exported here for back-compat with existing call sites/tests
379
- // that import it from `./indexer`. See index-db-contention.ts for the full
380
- // rationale.
381
- export { reclassifyIndexDbContention };
382
- export async function akmIndex(options) {
383
- try {
384
- const override = akmIndexOverride;
385
- return override ? await override(options) : await akmIndexReal(options);
386
- }
387
- catch (error) {
388
- const updateDb = options.deferredUpdateTransaction?.db;
389
- if (updateDb?.inTransaction) {
390
- try {
391
- updateDb.exec("ROLLBACK");
392
- }
393
- catch {
394
- // Preserve the indexing error. The update coordinator will retry
395
- // rollback before closing its borrowed unified handle.
396
- }
397
- }
398
- throw reclassifyIndexDbContention(error);
116
+ function removeStaleSourceOwners(db, currentOwners) {
117
+ const currentByBundle = new Map(currentOwners.map((owner) => [owner.bundleId, owner]));
118
+ const currentRoots = new Set(currentOwners.map((owner) => owner.sourceRoot));
119
+ for (const previous of parseStoredSourceOwners(getMeta(db, "sourceOwners"))) {
120
+ const current = currentByBundle.get(previous.bundleId);
121
+ if (current && current.sourceRoot === previous.sourceRoot)
122
+ continue;
123
+ if (!current)
124
+ deleteEntriesByBundle(db, previous.bundleId);
125
+ if (!currentRoots.has(previous.sourceRoot))
126
+ deleteStoredGraph(db, previous.sourceRoot);
399
127
  }
400
128
  }
401
- let indexTransactionHookForTests;
402
- /**
403
- * TEST-ONLY. Observe the in-flight reindex transaction; `undefined` restores.
404
- *
405
- * Exists because the delete-then-reinsert atomicity guarantee is, by
406
- * construction, invisible from outside the transaction: by the time
407
- * `akmIndex()` resolves, the commit has already collapsed both generations
408
- * into one observable state. Concurrency tests install a hook that opens a
409
- * SECOND connection at these points and asserts it still sees the previous
410
- * complete generation. Inert in production (one `undefined?.()` per reindex).
411
- */
412
- export function _setIndexTransactionHookForTests(hook) {
413
- indexTransactionHookForTests = hook;
414
- }
415
- /** Fire a named in-transaction observation point (no-op outside tests). */
416
- function indexTransactionHook(point) {
417
- indexTransactionHookForTests?.(point);
418
- }
419
- let drainObserverForTests;
420
- /**
421
- * TEST-ONLY. Observe every directory that actually reaches
422
- * `drainDirDocuments` — the per-file read/sha256-hash/frontmatter-parse step
423
- * (#900) — with the directory path and its walked file count. `undefined`
424
- * restores. A directory the pre-drain gate (`getCachedDirState`) skips never
425
- * fires this observer, so it is the
426
- * seam #900's own tests use to assert an unchanged directory's files are
427
- * never read on a no-op incremental run.
428
- */
429
- export function _setDrainObserverForTests(observer) {
430
- drainObserverForTests = observer;
431
- }
432
129
  /**
433
130
  * Detect an adapter for every resolvable source that does not declare one, and
434
131
  * persist each detection into `config.json`.
@@ -440,10 +137,6 @@ export function _setDrainObserverForTests(observer) {
440
137
  * emitted here. The map is cleared at the top of every callback invocation
441
138
  * because `mutateConfig` may retry optimistically, and a retry must not report
442
139
  * a superseded attempt.
443
- *
444
- * Extracted from `akmIndexReal` as one self-contained named pass, both to keep
445
- * that function under the src-wide function-size bar and because the detection
446
- * and its disclosure belong together.
447
140
  */
448
141
  function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, opts) {
449
142
  const detectedByBundle = new Map();
@@ -490,695 +183,10 @@ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig,
490
183
  }
491
184
  return { config: nextConfig, persistedAdapters };
492
185
  }
493
- function createIndexRunContext(options) {
494
- const prevStashDir = getMeta(options.db, "stashDir");
495
- const prevBuiltAt = getMeta(options.db, "builtAt");
496
- const isIncremental = !options.full && prevStashDir === options.stashDir && !!prevBuiltAt;
497
- const builtAtMs = isIncremental && prevBuiltAt ? new Date(prevBuiltAt).getTime() : 0;
498
- const { t0, ...context } = options;
499
- return {
500
- ...context,
501
- loweringNotices: [...options.enrichmentExecution.notices],
502
- timing: {
503
- t0,
504
- tWalkStart: t0,
505
- tWalkEnd: t0,
506
- tLlmEnd: t0,
507
- tFtsEnd: t0,
508
- tEmbedEnd: t0,
509
- tFinalizeStart: t0,
510
- tFinalizeEnd: t0,
511
- },
512
- isIncremental,
513
- builtAtMs,
514
- hadRemovedSources: false,
515
- removedSources: [],
516
- scanComplete: true,
517
- scannedDirs: 0,
518
- skippedDirs: 0,
519
- generatedCount: 0,
520
- walkWarnings: [],
521
- dirsNeedingLlm: [],
522
- embeddingResult: null,
523
- };
524
- }
525
- async function akmIndexReal(options) {
526
- // R-022: `dryRun` only ever gated the `--clean` stale-entry removal pass
527
- // (see `runCleanPass` below) — every other phase (walk, LLM enrichment,
528
- // embeddings, FTS, the adapter-detection config write) ran for real
529
- // regardless, so `akm index --dry-run` alone silently performed a full,
530
- // real index. The flag's own docs (`IndexOptions.dryRun` above, and the
531
- // CLI help in stash-cli.ts) already scope it to `--clean`; reject the
532
- // combination that was never implemented instead of quietly doing
533
- // something other than what "dry run" promised. Checked before the writer
534
- // lease is even requested so a bad invocation fails instantly.
535
- if (options?.dryRun === true && options?.clean !== true) {
536
- const { UsageError } = await import("../core/errors.js");
537
- throw new UsageError("`--dry-run` only applies together with `--clean` (it previews which stale entries `--clean` would remove). " +
538
- "Pass `akm index --clean --dry-run`, or drop `--dry-run` to run a real index.", "INVALID_FLAG_VALUE", "Run `akm index --clean --dry-run` to preview, or `akm index --clean` to apply.");
539
- }
540
- const requestedAt = Date.now();
541
- return (async () => {
542
- const stashDir = options.stashDir;
543
- const onProgress = options?.onProgress ?? (() => { });
544
- const signal = options?.signal;
545
- const full = options?.full === true;
546
- const clean = options?.clean === true;
547
- const dryRun = options?.dryRun === true;
548
- const reembed = options?.reembed === true;
549
- // Load config and resolve all stash sources
550
- const { loadConfig, mutateConfig } = await import("../core/config/config.js");
551
- let config = loadConfig();
552
- // Durable state must be runtime-compatible before source hydration,
553
- // adapter persistence, or index.db creation can mutate the installation.
554
- onProgress({ phase: "preflight", message: "Validating durable state." });
555
- if (!options.deferredUpdateTransaction)
556
- withStateDb(() => undefined);
557
- // Ensure git stash caches are extracted before resolving stash dirs,
558
- // so their content directories exist on disk for the walker to discover.
559
- const sourceCacheStart = Date.now();
560
- onProgress({ phase: "preflight", message: "Hydrating source caches." });
561
- const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
562
- // Inject the store-backed secret resolver from here — a composition root
563
- // ABOVE the provider/fetcher import cycle (this module reaches
564
- // search-source only via dynamic import). This is what lets a website
565
- // source's X fetcher resolve `secrets/x-bearer-token` during
566
- // bundle-update / hydrate, not just from the command-layer URL-ingest
567
- // path. `secret-seam` is imported here, never from inside the cycle.
568
- const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
569
- await ensureSourceCaches(config, {
570
- force: full,
571
- materialize: options.hydrateSources !== false,
572
- secrets: storeSecretResolver,
573
- // Same progress channel as every other phase (#954) — a
574
- // stalled clone/fetch here runs BEFORE index.db is even opened, so
575
- // without this it looked identical to "no database open, nothing
576
- // written".
577
- onProgress: (message) => onProgress({ phase: "preflight", message }),
578
- });
579
- const sourceCacheEnd = Date.now();
580
- const allSourceEntries = resolveSourceEntries(stashDir, config);
581
- const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
582
- announce: options.implicit !== true,
583
- persist: options.persistDetectedAdapters !== false,
584
- });
585
- config = detected.config;
586
- const persistedAdapters = detected.persistedAdapters;
587
- const allSourceDirs = allSourceEntries.map((s) => s.path);
588
- onProgress({
589
- phase: "preflight",
590
- message: `Resolved ${allSourceDirs.length} stash source${allSourceDirs.length === 1 ? "" : "s"}.`,
591
- });
592
- const t0 = Date.now();
593
- const enrichmentExecution = resolveIndexPassExecution("enrichment", config);
594
- // Open database — pass embedding dimension from config if available
595
- const dbPath = getDbPath();
596
- const embeddingDim = config.embedding?.dimension;
597
- const borrowedUpdateDb = options.deferredUpdateTransaction?.db;
598
- const db = borrowedUpdateDb ?? openIndexDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
599
- if (borrowedUpdateDb && !borrowedUpdateDb.inTransaction) {
600
- throw new Error("Source update index requires an active borrowed index transaction.");
601
- }
602
- let indexRunContext;
603
- try {
604
- // Assemble the run context
605
- const ctx = createIndexRunContext({
606
- db,
607
- config,
608
- enrichmentExecution,
609
- sources: allSourceEntries,
610
- sourceDirs: allSourceDirs,
611
- full,
612
- clean,
613
- reembed,
614
- stashDir,
615
- onProgress,
616
- signal,
617
- t0,
618
- deferredUpdateTransaction: options.deferredUpdateTransaction,
619
- });
620
- indexRunContext = ctx;
621
- onProgress({
622
- phase: "summary",
623
- message: buildIndexSummaryMessage({
624
- mode: ctx.isIncremental ? "incremental" : "full",
625
- sourcesCount: allSourceDirs.length,
626
- semanticSearchMode: config.semanticSearchMode,
627
- embeddingProvider: getEmbeddingProvider(config.embedding),
628
- llmEnabled: !!enrichmentExecution.runner,
629
- vecAvailable: isVecAvailable(db),
630
- }),
631
- });
632
- let cleanResult;
633
- let cleanStart = Date.now();
634
- let cleanEnd = cleanStart;
635
- // ── Phase sequence ───────────────────────────────────────────────────────
636
- await runSourceCachePhase(ctx);
637
- await runWalkPhase(ctx);
638
- applyRemovedSources(ctx);
639
- // Reconcile explicit missing-file cleanup before embeddings, totals, or
640
- // verification describe this generation. Dry-run intentionally leaves
641
- // the generation unchanged while still returning the previewed refs.
642
- cleanStart = Date.now();
643
- if (clean) {
644
- onProgress({
645
- phase: "finalize",
646
- message: dryRun ? "Scanning for stale index entries (dry run)." : "Removing stale index entries.",
647
- });
648
- if (ctx.scanComplete) {
649
- cleanResult = runCleanPass(db, dryRun);
650
- }
651
- else {
652
- warn("[index] --clean skipped because one or more configured sources were not scanned completely.");
653
- cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
654
- }
655
- }
656
- cleanEnd = Date.now();
657
- await runEmbeddingPhase(ctx);
658
- await runFinalizePhase(ctx);
659
- // ────────────────────────────────────────────────────────────────────────
660
- // runFinalizePhase always populates these before returning.
661
- const verification = ctx.verification;
662
- const totalEntries = ctx.totalEntries;
663
- const { timing } = ctx;
664
- return {
665
- stashDir,
666
- totalEntries,
667
- generatedMetadata: ctx.generatedCount,
668
- indexPath: dbPath,
669
- mode: ctx.isIncremental ? "incremental" : "full",
670
- directoriesScanned: ctx.scannedDirs,
671
- directoriesSkipped: ctx.skippedDirs,
672
- scanComplete: ctx.scanComplete,
673
- ...(ctx.walkWarnings.length > 0 ? { warnings: ctx.walkWarnings } : {}),
674
- ...(ctx.loweringNotices.length > 0 ? { notices: Object.freeze([...ctx.loweringNotices]) } : {}),
675
- ...(Object.keys(persistedAdapters).length > 0
676
- ? { configUpdated: { detectedAdapters: persistedAdapters } }
677
- : {}),
678
- verification,
679
- timing: {
680
- totalMs: Date.now() - timing.t0,
681
- walkMs: timing.tWalkEnd - timing.tWalkStart,
682
- llmMs: timing.tLlmEnd - timing.tWalkEnd,
683
- embedMs: timing.tEmbedEnd - timing.tLlmEnd,
684
- ftsMs: timing.tFtsEnd - timing.tEmbedEnd,
685
- finalizeMs: timing.tFinalizeEnd - timing.tFinalizeStart,
686
- cleanMs: clean ? cleanEnd - cleanStart : 0,
687
- preflightMs: timing.t0 - requestedAt,
688
- sourceCacheMs: sourceCacheEnd - sourceCacheStart,
689
- endToEndMs: Date.now() - requestedAt,
690
- },
691
- ...(cleanResult !== undefined ? { clean: cleanResult } : {}),
692
- };
693
- }
694
- finally {
695
- if (indexRunContext?.enrichmentLease) {
696
- disposeLoweredExecutionDispatchLease(indexRunContext.enrichmentLease);
697
- }
698
- if (!borrowedUpdateDb)
699
- closeDatabase(db);
700
- }
701
- })();
702
- }
703
- function buildIndexedSourceOwners(sources) {
704
- const installations = deriveInstallations([...sources]);
705
- const owners = new Map();
706
- sources.forEach((source, index) => {
707
- const installation = installations[index];
708
- if (!installation)
709
- return;
710
- const component = installation.components[0];
711
- owners.set(path.resolve(source.path), {
712
- bundleId: installation.id,
713
- componentId: component?.id ?? installation.id,
714
- adapterId: component?.adapter ?? "akm",
715
- });
716
- });
717
- return owners;
718
- }
719
- /** Read-only mirror of the enrichment cache gate used before entry persistence. */
720
- function dirRecordsNeedMetadataDispatch(db, records, ownersByRoot) {
721
- for (const record of records) {
722
- if (record.skip || record.remove || !record.stash)
723
- continue;
724
- const owner = ownersByRoot.get(path.resolve(record.currentStashDir));
725
- if (!owner)
726
- throw new Error(`Missing bundle provenance for indexed source ${record.currentStashDir}`);
727
- for (const entry of record.stash.entries) {
728
- if (entry.quality !== "generated" || isEnrichmentComplete(entry))
729
- continue;
730
- const entryFile = entry.filename ? path.join(record.dirPath, entry.filename) : undefined;
731
- if (!entryFile)
732
- continue;
733
- const adapterConceptId = record.conceptIdByFile?.get(entryFile);
734
- if (!adapterConceptId)
735
- continue;
736
- let fileContent;
737
- try {
738
- fileContent = fs.readFileSync(entryFile, "utf8");
739
- }
740
- catch {
741
- // The dispatch path uses the same deterministic metadata fallback.
742
- }
743
- const bodyHash = computeBodyHash(fileContent ?? `${entry.name}\n${entry.description ?? ""}`);
744
- const cacheKey = deriveEntryProvenance(owner, entry.type, entry.name, adapterConceptId).itemRef;
745
- const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
746
- if (!cached)
747
- return true;
748
- try {
749
- JSON.parse(cached.resultJson);
750
- }
751
- catch {
752
- return true;
753
- }
754
- }
755
- }
756
- return false;
757
- }
758
- function removalsFirst(records) {
759
- return [...records.filter((record) => record.remove), ...records.filter((record) => !record.remove)];
760
- }
761
- function addEntryIds(target, ids) {
762
- for (const id of ids)
763
- target.add(id);
764
- }
765
- /**
766
- * Map each source root → its durable `BundleComponent` (`deriveInstallations`,
767
- * batch-unique bundle ids, source order preserved). The per-dir document drain
768
- * dispatches `adapterForId(component.adapter).recognize` for this component. The
769
- * component id only surfaces on `IndexDocument.ref`, which the persist layer
770
- * re-derives independently — so a source missing from the map (never happens: the
771
- * map is built from the same sources) is harmless.
772
- */
773
- function buildComponentBySource(sources) {
774
- const map = new Map();
775
- const installations = deriveInstallations(sources);
776
- sources.forEach((source, i) => {
777
- const component = installations[i]?.components[0];
778
- if (component)
779
- map.set(source.path, component);
780
- });
781
- return map;
782
- }
783
- function componentForSource(components, sourcePath) {
784
- return (components.get(sourcePath) ?? {
785
- id: sourcePath,
786
- adapter: "akm",
787
- root: sourcePath,
788
- writable: false,
789
- });
790
- }
791
- function groupFileContextsByDir(fileContexts) {
792
- const groups = new Map();
793
- for (const ctx of fileContexts) {
794
- const group = groups.get(ctx.parentDirAbs);
795
- if (group)
796
- group.push(ctx);
797
- else
798
- groups.set(ctx.parentDirAbs, [ctx]);
799
- }
800
- return groups;
801
- }
802
- function sourceSnapshotRemovals(db, currentStashDir, bundleId, currentDirs, allIndexedDirsByBundle) {
803
- const indexedDirs = allIndexedDirsByBundle?.get(bundleId) ?? getIndexedDirPathsByBundleId(db, bundleId);
804
- return [...indexedDirs]
805
- .map((dirPath) => path.resolve(dirPath))
806
- .filter((dirPath) => !currentDirs.has(dirPath))
807
- .map((dirPath) => ({
808
- dirPath,
809
- currentStashDir,
810
- files: [],
811
- stash: null,
812
- skip: false,
813
- remove: true,
814
- reason: { kind: "not-in-source-snapshot" },
815
- }));
816
- }
817
- /**
818
- * Warn ONCE per process (#908) when the chosen adapter for a component
819
- * entirely skips a top-level directory that holds files the `akm` adapter —
820
- * the format-neutral superset — would have indexed. `detectAdapterId` now
821
- * corrects this for AUTO-DETECTION (a mixed layout detects as `akm`); this
822
- * covers the case detection cannot see, an EXPLICITLY configured narrow
823
- * adapter (`components.<name>.adapter: "agent-skills"`, say) sitting next to
824
- * ordinary akm content. One line for the whole process — not one per bundle,
825
- * not one per directory — naming the count and the directories is enough to
826
- * point an operator at the fix.
827
- */
828
- function warnIfAdapterSkipsAkmContent(component, files, adapter) {
829
- if (adapter.id === "akm")
830
- return;
831
- const akm = adapterForId("akm");
832
- if (!akm)
833
- return;
834
- const byTopDir = new Map();
835
- for (const file of files) {
836
- const top = file.ancestorDirs[0];
837
- if (!top)
838
- continue; // a root-level file is not a "skipped directory" concern
839
- const group = byTopDir.get(top);
840
- if (group)
841
- group.push(file);
842
- else
843
- byTopDir.set(top, [file]);
844
- }
845
- const akmComponent = { ...component, adapter: "akm" };
846
- let skippedCount = 0;
847
- const skippedDirs = [];
848
- for (const [dir, dirFiles] of byTopDir) {
849
- const chosenRecognizesAny = dirFiles.some((file) => {
850
- try {
851
- return adapter.recognize(component, file) !== null;
852
- }
853
- catch {
854
- return false;
855
- }
856
- });
857
- if (chosenRecognizesAny)
858
- continue; // the chosen adapter owns this dir; nothing skipped
859
- const akmCandidates = dirFiles.filter((file) => {
860
- try {
861
- return akm.recognize(akmComponent, file) !== null;
862
- }
863
- catch {
864
- return false;
865
- }
866
- });
867
- if (akmCandidates.length === 0)
868
- continue; // akm would drop it too — not a shadowing case
869
- skippedCount += akmCandidates.length;
870
- skippedDirs.push(dir);
871
- }
872
- if (skippedCount === 0)
873
- return;
874
- skippedDirs.sort();
875
- warnOnce("adapter-skip-akm-content", `${adapter.id} adapter skipped ${skippedCount} file${skippedCount === 1 ? "" : "s"} in ` +
876
- `${skippedDirs.map((dir) => `${dir}/`).join(", ")} — set components.<name>.adapter to "akm" to index them`);
877
- }
878
- function buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs) {
879
- const componentBySource = buildComponentBySource(allSourceEntries);
880
- const handoffDirs = new Set();
881
- const plans = allSourceEntries.map((sourceAdded) => {
882
- const currentStashDir = sourceAdded.path;
883
- const component = componentForSource(componentBySource, currentStashDir);
884
- if (sourceAdded.unresolved) {
885
- return {
886
- currentStashDir,
887
- component,
888
- adapter: undefined,
889
- indexVariant: undefined,
890
- dirGroups: new Map(),
891
- removals: [],
892
- walkComplete: false,
893
- };
894
- }
895
- const walked = walkStashFlatWithStatus(currentStashDir, {
896
- includeAllDirectories: component.adapter === "okf",
897
- ...(component.adapter === "akm" || component.adapter === "akm-workflow"
898
- ? { workflowSymlinkAdapter: component.adapter }
899
- : {}),
900
- });
901
- const dirGroups = groupFileContextsByDir(walked.files);
902
- const adapter = adapterForId(component.adapter);
903
- if (adapter)
904
- warnIfAdapterSkipsAkmContent(component, walked.files, adapter);
905
- return {
906
- currentStashDir,
907
- component,
908
- adapter,
909
- indexVariant: adapter ? `${adapter.id}@${adapter.version}` : undefined,
910
- dirGroups,
911
- removals: [],
912
- walkComplete: walked.complete,
913
- };
914
- });
915
- const removalKeys = new Set();
916
- const addRemoval = (plan, dirPath, stashDir) => {
917
- const resolvedDir = path.resolve(dirPath);
918
- const key = `${resolvedDir}\0${path.resolve(stashDir)}`;
919
- if (removalKeys.has(key))
920
- return;
921
- removalKeys.add(key);
922
- plan.removals.push({
923
- dirPath,
924
- currentStashDir: stashDir,
925
- files: [],
926
- stash: null,
927
- skip: false,
928
- remove: true,
929
- reason: { kind: "not-in-source-snapshot" },
930
- });
931
- handoffDirs.add(resolvedDir);
932
- };
933
- const allComplete = plans.every((plan) => plan.walkComplete && plan.adapter !== undefined);
934
- // A full, globally-complete run uses the atomic table wipe below. Every
935
- // other run reconciles only sources that produced trustworthy snapshots.
936
- if (reconcileMissingDirs && (isIncremental || !allComplete)) {
937
- const allIndexedDirsByBundle = !isIncremental ? new Map() : undefined;
938
- if (allIndexedDirsByBundle) {
939
- for (const entry of getAllEntries(db)) {
940
- const dirs = allIndexedDirsByBundle.get(entry.bundleId) ?? new Set();
941
- dirs.add(path.dirname(path.resolve(entry.filePath)));
942
- allIndexedDirsByBundle.set(entry.bundleId, dirs);
943
- }
944
- }
945
- for (const plan of plans) {
946
- if (!plan.walkComplete || !plan.adapter)
947
- continue;
948
- const currentDirs = new Set([...plan.dirGroups.keys()].map((dirPath) => path.resolve(dirPath)));
949
- for (const removal of sourceSnapshotRemovals(db, plan.currentStashDir, plan.component.id, currentDirs, allIndexedDirsByBundle)) {
950
- addRemoval(plan, removal.dirPath, removal.currentStashDir);
951
- }
952
- }
953
- }
954
- // Cross-source ownership handoffs can delete another source's rows, so they
955
- // still require every possible owner to have completed its scan.
956
- if (!allComplete)
957
- return { plans, handoffDirs };
958
- // The first configured source that exposes a physical directory owns it.
959
- // Remove rows left by a prior owner even when both adapters are identical.
960
- const claimedDirs = new Set();
961
- const sourcePathByBundle = new Map(plans.map((plan) => [plan.component.id, plan.currentStashDir]));
962
- for (const plan of plans) {
963
- for (const dirPath of plan.dirGroups.keys()) {
964
- const resolvedDir = path.resolve(dirPath);
965
- if (claimedDirs.has(resolvedDir))
966
- continue;
967
- claimedDirs.add(resolvedDir);
968
- for (const priorOwnerBundle of getIndexedBundleIdsByDir(db, dirPath)) {
969
- if (priorOwnerBundle !== plan.component.id) {
970
- const priorOwnerPath = sourcePathByBundle.get(priorOwnerBundle);
971
- if (priorOwnerPath)
972
- addRemoval(plan, dirPath, priorOwnerPath);
973
- }
974
- }
975
- }
976
- }
977
- return { plans, handoffDirs };
978
- }
979
186
  /**
980
- * Phase 1 (async): walk every source directory and pre-generate all metadata
981
- * outside any transaction, producing the per-directory scan records that
982
- * {@link persistDirRecords} later writes.
983
- *
984
- * The per-dir document drain (`drainDirDocuments` × the component's dispatched
985
- * `adapter.recognize`, F4a M-core-2) is synchronous, but the walk still runs
986
- * outside `db.transaction()` so the persist pass can be a single synchronous
987
- * transaction.
988
- */
989
- function reportSourceScanProgress(onProgress, processed, total, message) {
990
- onProgress?.({ phase: "scan", message, processed, total });
991
- }
992
- async function scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs = true) {
993
- let scannedDirs = 0;
994
- let skippedDirs = 0;
995
- let generatedCount = 0;
996
- const warnings = [];
997
- const seenPaths = new Set();
998
- const { plans, handoffDirs } = buildSourceScanPlans(db, allSourceEntries, isIncremental, reconcileMissingDirs);
999
- const dirRecords = [];
1000
- let processedDirs = 0;
1001
- let priorDirsChanged = hadRemovedSources;
1002
- const reportScanProgress = (message) => reportSourceScanProgress(onProgress, processedDirs, allSourceEntries.length, message);
1003
- const reportDirDecision = (kind, dirPath, currentStashDir, reason, persistedRowCount) => {
1004
- if (!isVerbose())
1005
- return;
1006
- const detail = reason.detail ? ` (${reason.detail})` : "";
1007
- const rowInfo = persistedRowCount !== undefined ? `; previous rows=${persistedRowCount}` : "";
1008
- reportScanProgress(`${kind === "scan" ? "Rescanning" : "Skipping"} ${path.relative(currentStashDir, dirPath) || "."} ` +
1009
- `from ${currentStashDir}: ${reason.kind}${detail}${rowInfo}`);
1010
- };
1011
- // Only the first source that exposes a physical directory may index it.
1012
- const markSeenOrSkipDuplicate = (dirPath, currentStashDir, files) => {
1013
- const resolved = path.resolve(dirPath);
1014
- if (seenPaths.has(resolved)) {
1015
- const reason = { kind: "duplicate-dir" };
1016
- dirRecords.push({ dirPath, currentStashDir, files, stash: null, skip: true, reason });
1017
- reportDirDecision("skip", dirPath, currentStashDir, reason);
1018
- return true;
1019
- }
1020
- seenPaths.add(resolved);
1021
- return false;
1022
- };
1023
- // Incremental freshness gate shared by both branches: consult the persisted
1024
- // dir state and record either a skip (unchanged + eligible for incremental
1025
- // skip) or a scan record carrying the candidate stash.
1026
- const recordFreshnessDecision = (dirPath, currentStashDir, stateFiles, fingerprint, stash, hashByFile, conceptIdByFile, indexVariant, forceScan, pruneMissing) => {
1027
- const previousState = getDirIndexState(db, dirPath, stateFiles, builtAtMs, indexVariant, fingerprint);
1028
- if (isIncremental && !forceScan && !previousState.stale && canUseIncrementalSkip(previousState, priorDirsChanged)) {
1029
- skippedDirs++;
1030
- dirRecords.push({
1031
- dirPath,
1032
- currentStashDir,
1033
- files: stateFiles,
1034
- fingerprint,
1035
- stash: null,
1036
- skip: true,
1037
- reason: previousState.reason,
1038
- persistedRowCount: previousState.persistedRowCount,
1039
- indexVariant,
1040
- });
1041
- reportDirDecision("skip", dirPath, currentStashDir, previousState.reason, previousState.persistedRowCount);
1042
- return;
1043
- }
1044
- scannedDirs++;
1045
- priorDirsChanged = true;
1046
- const reason = isIncremental ? previousState.reason : { kind: "full-rebuild" };
1047
- dirRecords.push({
1048
- dirPath,
1049
- currentStashDir,
1050
- files: stateFiles,
1051
- fingerprint,
1052
- stash,
1053
- skip: false,
1054
- reason,
1055
- persistedRowCount: previousState.persistedRowCount,
1056
- hashByFile,
1057
- conceptIdByFile,
1058
- indexVariant,
1059
- pruneMissing,
1060
- });
1061
- reportDirDecision("scan", dirPath, currentStashDir, reason, previousState.persistedRowCount);
1062
- };
1063
- for (const plan of plans) {
1064
- const { currentStashDir, component, adapter, dirGroups, removals, walkComplete } = plan;
1065
- processedDirs++;
1066
- reportScanProgress(`Processed ${processedDirs}/${allSourceEntries.length} source${allSourceEntries.length === 1 ? "" : "s"}.`);
1067
- if (!walkComplete) {
1068
- for (const dirPath of dirGroups.keys())
1069
- seenPaths.add(path.resolve(dirPath));
1070
- warn(`[index] source "${component.id}" was not scanned completely; preserving its last-known-good rows.`);
1071
- continue;
1072
- }
1073
- // Owner ruling 2026-07-21: dispatch each component's DETECTED adapter (§4).
1074
- // An unknown adapter id has no `adapterForId` match → skip the whole
1075
- // component with a warning (one bundle = one component = one adapter).
1076
- if (!adapter) {
1077
- for (const dirPath of dirGroups.keys())
1078
- seenPaths.add(path.resolve(dirPath));
1079
- warn(`Skipping component "${component.id}": unknown adapter id "${component.adapter}".`);
1080
- continue;
1081
- }
1082
- const indexVariant = plan.indexVariant ?? `${adapter.id}@${adapter.version}`;
1083
- for (const removal of removals) {
1084
- dirRecords.push(removal);
1085
- scannedDirs++;
1086
- priorDirsChanged = true;
1087
- reportDirDecision("scan", removal.dirPath, currentStashDir, removal.reason);
1088
- }
1089
- for (const [dirPath, ctxs] of dirGroups) {
1090
- // Adapter-owned filtering (owner ruling 2026-07-21): the drain no longer
1091
- // pre-filters with AKM-stash policy — each adapter's `recognize` claims or
1092
- // abstains on its own bundle's walked files. The core walk keeps only the
1093
- // universal hygiene `walkStashFlat` already applies (.git/dot-dirs/etc.).
1094
- const indexableFiles = ctxs.map((ctx) => ctx.absPath);
1095
- const forceScan = handoffDirs.has(path.resolve(dirPath)) || requiresWorkflowSourcePreflight(ctxs);
1096
- if (markSeenOrSkipDuplicate(dirPath, currentStashDir, indexableFiles))
1097
- continue;
1098
- if (indexableFiles.length === 0) {
1099
- skippedDirs++;
1100
- const reason = { kind: "no-indexable-files" };
1101
- dirRecords.push({ dirPath, currentStashDir, files: indexableFiles, stash: null, skip: true, reason });
1102
- reportDirDecision("skip", dirPath, currentStashDir, reason);
1103
- continue;
1104
- }
1105
- // #900: decide from stat data alone whether the directory can be skipped,
1106
- // before drainDirDocuments reads, hashes, and parses every file.
1107
- const fingerprint = computeDirFingerprint(dirPath, indexableFiles, indexVariant);
1108
- const cachedState = isIncremental &&
1109
- !forceScan &&
1110
- getCachedDirState(db, dirPath, indexableFiles, builtAtMs, priorDirsChanged, indexVariant, fingerprint);
1111
- if (cachedState) {
1112
- skippedDirs++;
1113
- dirRecords.push({
1114
- dirPath,
1115
- currentStashDir,
1116
- files: indexableFiles,
1117
- stash: null,
1118
- skip: true,
1119
- reason: cachedState.reason,
1120
- indexVariant,
1121
- });
1122
- reportDirDecision("skip", dirPath, currentStashDir, cachedState.reason, cachedState.persistedRowCount);
1123
- continue;
1124
- }
1125
- // F4a M-core-2 (the flip): drain the dir's `IndexDocument` stream via the
1126
- // component's dispatched `adapter.recognize` (broken workflows dropped-with-
1127
- // warning at the drain layer) and reconstruct the durable `IndexDocument`s.
1128
- drainObserverForTests?.(dirPath, ctxs.length);
1129
- const drained = drainDirDocuments(adapter, component, ctxs);
1130
- if (drained.warnings.length)
1131
- warnings.push(...drained.warnings);
1132
- const generated = drained.warnings.length
1133
- ? { entries: drained.entries, warnings: drained.warnings }
1134
- : { entries: drained.entries };
1135
- // `.stash.json` sidecar overrides retired (#39): the cutover's content
1136
- // migration folded sidecar metadata into frontmatter and deleted the
1137
- // files; the runtime no longer reads them.
1138
- const { stash, staleFiles } = buildIndexedDirCandidate(dirPath, indexableFiles, generated);
1139
- if (generated.entries.length > 0) {
1140
- generatedCount += generated.entries.length;
1141
- }
1142
- recordFreshnessDecision(dirPath, currentStashDir, staleFiles, fingerprint, stash, drained.hashByFile, drained.conceptIdByFile, indexVariant, forceScan, walkComplete);
1143
- }
1144
- }
1145
- return {
1146
- dirRecords: removalsFirst(dirRecords),
1147
- scannedDirs,
1148
- skippedDirs,
1149
- generatedCount,
1150
- warnings,
1151
- complete: plans.every((plan) => plan.walkComplete && plan.adapter !== undefined),
1152
- };
1153
- }
1154
- function requiresWorkflowSourcePreflight(ctxs) {
1155
- return ctxs.some((ctx) => {
1156
- try {
1157
- return fs.lstatSync(ctx.absPath).isSymbolicLink();
1158
- }
1159
- catch {
1160
- return true;
1161
- }
1162
- });
1163
- }
1164
- function preserveExistingIndex(doFullDelete, dirRecords, sourceRoots) {
1165
- if (!doFullDelete)
1166
- return false;
1167
- const incomingDocCount = dirRecords.reduce((n, record) => n + (record.skip ? 0 : (record.stash?.entries.length ?? 0)), 0);
1168
- if (incomingDocCount > 0 || allSourceRootsReadable(sourceRoots))
1169
- return false;
1170
- warn("[index] --full produced zero documents while one or more source roots are missing or unreadable — " +
1171
- "preserving the existing index (last-known-good) rather than wiping it. Re-run once the sources are available.");
1172
- return true;
1173
- }
1174
- /**
1175
- * #624-P1 zero-document preflight probe. A source root counts as "readable"
1176
- * when it exists on disk as a directory whose listing can be read. A root that
1177
- * is missing or unreadable (a transient mount failure, a permission race, or a
1178
- * source that vanished mid-run) makes a zero-document scan untrustworthy: the
1179
- * walk saw nothing not because the stash is empty but because it could not be
1180
- * read. Returns true only when EVERY root is readable, so a single unreadable
1181
- * root blocks the full-rebuild wipe.
187
+ * `detectAndPersistBundleAdapters` only auto-detects an adapter for a source
188
+ * root that exists and can be listed an unreadable/missing root gets no
189
+ * silent guess.
1182
190
  */
1183
191
  function allSourceRootsReadable(roots) {
1184
192
  for (const root of roots) {
@@ -1186,7 +194,7 @@ function allSourceRootsReadable(roots) {
1186
194
  const st = fs.statSync(root);
1187
195
  if (!st.isDirectory())
1188
196
  return false;
1189
- fs.readdirSync(root); // probe readability, not just existence
197
+ fs.readdirSync(root);
1190
198
  }
1191
199
  catch {
1192
200
  return false;
@@ -1194,424 +202,133 @@ function allSourceRootsReadable(roots) {
1194
202
  }
1195
203
  return true;
1196
204
  }
205
+ // ── Indexer ──────────────────────────────────────────────────────────────────
206
+ // ── Test seam ────────────────────────────────────────────────────────────────
207
+ // Swap-and-restore override. Inert in production; only tests call the setter.
208
+ let akmIndexOverride;
209
+ /** TEST-ONLY. Swap the implementation of `akmIndex`; pass undefined to restore. */
210
+ export function _setAkmIndexForTests(fake) {
211
+ akmIndexOverride = fake;
212
+ }
1197
213
  /**
1198
- * Phase 2 (sync): write all pre-generated scan records inside a single
1199
- * transaction, returning the directories that still need LLM enrichment.
214
+ * Reclassify a contention-shaped error escaping reconcile or drain into a
215
+ * retryable-shortly `TransientError` (field follow-up to #956): a concurrent
216
+ * writer can make index.db busy, and the raw SQLite driver error ("database is
217
+ * locked") used to escape as exit 70 (internal/unclassified) instead of the
218
+ * "retry shortly" contract exit 75 gives a scheduler to branch on — mirroring
219
+ * `STATE_DB_CONTENDED`'s precedent for state.db (`core/state-db.ts`). Reuses
220
+ * the ONE shared classifier, `isSqliteContentionError`, rather than a second
221
+ * one. An error that is already a classified akm error is never re-wrapped —
222
+ * only a raw, unclassified error matching the shared contention shape is
223
+ * reclassified. Every other error is rethrown unchanged.
224
+ *
225
+ * index-redesign note: under the new design (docs/plans/index-redesign.md,
226
+ * rule 5) every index write is a short immediate transaction under WAL with
227
+ * SQLite's own busy timeout — no rebuild lock, no writer lock on the index
228
+ * path — so genuine contention is rarer, but still possible when two
229
+ * processes race the same file, and the reclassification still matters when
230
+ * it happens.
1200
231
  */
1201
- function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, scanComplete, bundleByRoot) {
1202
- const dirsNeedingLlm = [];
1203
- const fullDelete = doFullDelete && scanComplete;
1204
- // #624-P1 zero-document preflight (spec §4). A full-rebuild wipe is a
1205
- // legitimate mass-delete ONLY when the scan legitimately found nothing. If
1206
- // the walk produced zero documents AND any configured source root is missing
1207
- // or unreadable, the empty result is almost certainly a transient scan
1208
- // failure, not an emptied stash — wiping here would cascade-destroy the
1209
- // last-known-good index (entries + embeddings + utility/usage). Preserve it
1210
- // and warn instead; the next successful run reconciles. A genuinely empty
1211
- // stash whose roots ARE readable still wipes, as before.
1212
- if (preserveExistingIndex(fullDelete, dirRecords, sourceRoots))
1213
- return { dirsNeedingLlm };
1214
- // Per-source dedup: the same logical asset can appear more than once within
1215
- // one owning source, where source order still makes the first occurrence win.
1216
- // The owner is part of the key so identical concepts in different bundles
1217
- // remain distinct indexed rows.
1218
- const indexedAssetIdentities = new Set();
1219
- const deletedUsageEntryIds = new Set();
1220
- const insertTransaction = db.transaction(() => {
1221
- // Perform the full-rebuild wipe as the FIRST step of the insert
1222
- // transaction so delete and re-insert are atomic — a concurrent reader
1223
- // never observes an empty database between the two operations.
1224
- if (fullDelete) {
1225
- // #955: copy every (search_text hash, embedding) pair about to be
1226
- // discarded wholesale into `embedding_salvage`, tagged with the
1227
- // fingerprint the discarded vectors were generated under, BEFORE the
1228
- // wipe below — inside the SAME transaction so the copy and the
1229
- // discard commit or roll back together. The embedding phase later in
1230
- // this run hands salvaged vectors back to unchanged content instead
1231
- // of re-embedding the whole corpus.
1232
- salvageEmbeddingsBeforeDiscard(db);
1233
- // Entries and every child materialization share one deletion authority.
1234
- // Usage events live in state.db and survive so finalize can relink them
1235
- // to the replacement generation's row ids.
1236
- deleteAllEntries(db, { cleanupUsageEvents: false });
1237
- db.exec("DELETE FROM index_dir_state");
1238
- // Chunk-8 WI-8.3: usage_events lives in state.db now (not index.db), so the
1239
- // wipe no longer detaches it here. The finalize pass's relinkUsageEvents
1240
- // (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
1241
- // re-resolves the rest by entry_ref — subsuming the old detach.
1242
- // Atomicity observation point: inside the transaction the tables are now
1243
- // empty, but no other connection may observe that. See
1244
- // tests/integration/indexer/reindex-generation-atomicity.test.ts.
1245
- indexTransactionHook("full-delete-applied");
1246
- }
1247
- for (const { dirPath, currentStashDir, files, fingerprint, stash, skip, reason, persistedRowCount, hashByFile, conceptIdByFile, indexVariant, remove, pruneMissing, } of dirRecords) {
1248
- const bundle = bundleByRoot.get(path.resolve(currentStashDir));
1249
- if (!bundle)
1250
- throw new Error(`Missing bundle provenance for indexed source ${currentStashDir}`);
1251
- if (remove) {
1252
- const removedIds = deleteEntriesByDirAndBundle(db, dirPath, bundle.bundleId, {
1253
- cleanupUsageEvents: false,
1254
- });
1255
- addEntryIds(deletedUsageEntryIds, removedIds);
1256
- deleteIndexDirState(db, dirPath);
1257
- continue;
1258
- }
1259
- if (skip) {
1260
- // "unchanged" is the post-drain verdict: re-persist so the row carries
1261
- // row_count and the gate skips this directory before draining next
1262
- // time. "unchanged-precheck" already matched the stored row.
1263
- if (reason?.kind === "unchanged" && fingerprint) {
1264
- upsertIndexDirState(db, { dirPath, ...fingerprint, reason: reason.kind, rowCount: persistedRowCount });
1265
- }
1266
- continue;
1267
- }
1268
- // Diff-persist (F4a M-core-2, spec §14.2): upsert the current file set
1269
- // FIRST (ON CONFLICT preserving `entries.id` so embeddings / utility /
1270
- // usage stay attached to unchanged rows), tracking every upserted
1271
- // durable `item_ref`, then prune only the departed rows below. Replaces the old
1272
- // `deleteEntriesByDir` truncate-and-reinsert (which discarded ids).
1273
- const keptItemRefs = new Set();
1274
- let persistedRows = 0;
1275
- let dedupedRows = 0;
1276
- if (stash) {
1277
- const ownerIdentity = bundle.bundleId;
1278
- for (const entry of stash.entries) {
1279
- const entryPath = entry.filename ? path.join(dirPath, entry.filename) : null;
1280
- if (!entryPath) {
1281
- warn(`Skipping entry with no resolvable path in ${dirPath}`);
1282
- continue;
1283
- }
1284
- const adapterConceptId = conceptIdByFile?.get(entryPath);
1285
- if (!adapterConceptId) {
1286
- warn(`Skipping entry without adapter-owned concept identity: ${entryPath}`);
1287
- continue;
1288
- }
1289
- // Adapter-owned concept identity is path-based and cannot be replaced
1290
- // by presentation fields such as type/title.
1291
- const identityKey = `${ownerIdentity}\0${adapterConceptId}`;
1292
- if (indexedAssetIdentities.has(identityKey)) {
1293
- dedupedRows++;
1294
- continue;
1295
- }
1296
- indexedAssetIdentities.add(identityKey);
1297
- const searchText = buildSearchText(entry);
1298
- const entryWithSize = attachFileSize(entry, entryPath);
1299
- // content_hash = doc.hash from the drain, keyed by the recognized
1300
- // file's path. A missing hash preserves the existing value on upsert.
1301
- const contentHash = hashByFile?.get(entryPath);
1302
- const provenance = deriveEntryProvenance(bundle, entry.type, entry.name, adapterConceptId);
1303
- keptItemRefs.add(provenance.itemRef);
1304
- upsertEntry(db, entryPath, entryWithSize, searchText, provenance, contentHash);
1305
- persistedRows++;
1306
- }
1307
- // Collect dirs needing LLM enhancement during the first walk.
1308
- // Only dirs with "generated" entries need enrichment.
1309
- if (stash.entries.some((e) => e.quality === "generated")) {
1310
- dirsNeedingLlm.push({ dirPath, files, currentStashDir, stash });
1311
- }
1312
- }
1313
- // Prune the departed rows: everything under this dir NOT re-upserted above
1314
- // (files deleted, deduped away, or abstained on by the adapter). With
1315
- // an empty kept-set this deletes every row for the dir — the exact net
1316
- // effect of the old unconditional `deleteEntriesByDir`, minus the id churn.
1317
- if (pruneMissing !== false) {
1318
- addEntryIds(deletedUsageEntryIds, deleteEntriesByDirExceptRefs(db, dirPath, bundle.bundleId, keptItemRefs, { cleanupUsageEvents: false }));
1319
- }
1320
- const persistedFingerprint = fingerprint ?? computeDirFingerprint(dirPath, files, indexVariant);
1321
- const persistedReason = persistedRows === 0
1322
- ? inferZeroRowReason(stash, reason, warnings, dirPath, dedupedRows)
1323
- : reason?.kind === "full-rebuild"
1324
- ? "full-rebuild"
1325
- : (reason?.kind ?? "updated");
1326
- upsertIndexDirState(db, {
1327
- dirPath,
1328
- ...persistedFingerprint,
1329
- reason: persistedReason,
1330
- // A directory that lost rows to per-source dedup depends on the
1331
- // directories persisted before it, not only on its own files, so it
1332
- // must keep draining every run (as it did before the gate) until a
1333
- // drain persists it without dedup. NULL keeps the gate closed.
1334
- rowCount: dedupedRows === 0 ? persistedRows : undefined,
1335
- });
1336
- if (persistedRows === 0) {
1337
- // Warn only when the dir had files that *could* produce entries (.md or
1338
- // known script extensions). Dirs with only non-indexable types (.json,
1339
- // .yaml, .conf, .env, .gitkeep) or deduped-only rows are expected and
1340
- // not actionable at normal log level.
1341
- const hasIndexableExtension = files.some((f) => {
1342
- const ext = path.extname(f).toLowerCase();
1343
- return ext === ".md" || SCRIPT_EXTENSIONS.has(ext);
1344
- });
1345
- if (persistedReason !== "deduped-zero-row" && hasIndexableExtension) {
1346
- warn(`[index] zero-row ${dirPath}: ${persistedReason}`);
1347
- }
1348
- else {
1349
- warnVerbose(`[index] zero-row ${dirPath}: ${persistedReason}`);
1350
- }
1351
- }
1352
- }
1353
- // Atomicity observation point: the new generation is fully written but
1354
- // uncommitted, so it must still be invisible to other connections.
1355
- indexTransactionHook("records-persisted");
1356
- });
1357
- insertTransaction();
1358
- deleteUsageEventsByEntryIds([...deletedUsageEntryIds]);
1359
- return { dirsNeedingLlm };
232
+ export function reclassifyIndexDbContention(error) {
233
+ if (error instanceof AkmError || !isSqliteContentionError(error))
234
+ return error;
235
+ const contended = new TransientError("akm's index database is busy (another akm process is writing it); retry shortly.", "INDEX_DB_CONTENDED");
236
+ contended.cause = error;
237
+ return contended;
1360
238
  }
1361
- async function indexEntries(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, doFullDelete = false, onProgress, reconcileMissingDirs = true, beforePersist) {
1362
- // Phase 1 (async): walk directories and pre-generate all metadata outside the
1363
- // transaction.
1364
- const { dirRecords, scannedDirs, skippedDirs, generatedCount, warnings, complete } = await scanSourceDirs(db, allSourceEntries, isIncremental, builtAtMs, hadRemovedSources, onProgress, reconcileMissingDirs);
1365
- const bundleByRoot = buildIndexedSourceOwners(allSourceEntries);
1366
- await beforePersist?.(dirRecords, bundleByRoot);
1367
- // Phase 2 (sync): write all pre-generated metadata inside a single transaction.
1368
- // Source roots feed the #624-P1 zero-document preflight (a full-rebuild wipe
1369
- // is suppressed when the scan is empty because roots are unreadable).
1370
- const sourceRoots = allSourceEntries.map((s) => s.path);
1371
- // Map each source root → its durable bundle id so the writer can persist
1372
- // `item_ref = <bundle>//<conceptId>` and canonical component/adapter
1373
- // provenance. `deriveInstallations`
1374
- // preserves source order, so a positional zip yields the SAME bundle id the
1375
- // dispatched `adapter.recognize` emits as `IndexDocument.ref` for that root.
1376
- const { dirsNeedingLlm } = persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots, complete, bundleByRoot);
1377
- return { scannedDirs, skippedDirs, generatedCount, warnings, dirsNeedingLlm, complete };
1378
- }
1379
- function indexedProvenanceForFile(db, filePath) {
1380
- const row = db
1381
- .prepare("SELECT item_ref AS itemRef, bundle_id AS bundleId, component_id AS componentId, " +
1382
- "concept_id AS conceptId, adapter_id AS adapterId FROM entries WHERE file_path = ? LIMIT 1")
1383
- .get(filePath);
1384
- if (!row?.itemRef || !row.bundleId || !row.componentId || !row.conceptId || !row.adapterId) {
1385
- throw new Error(`Missing indexed provenance for ${filePath}`);
1386
- }
1387
- return {
1388
- itemRef: row.itemRef,
1389
- bundleId: row.bundleId,
1390
- componentId: row.componentId,
1391
- conceptId: row.conceptId,
1392
- adapterId: row.adapterId,
1393
- };
1394
- }
1395
- async function enhanceDirsWithLlm(db, config, execution, dirsNeedingLlm, onProgress, signal, onNotices, lease) {
1396
- // The invocation owns one frozen symbolic selection. Summary reporting and
1397
- // every enrichment dispatch consume this same snapshot.
1398
- const llmRunner = execution.runner;
1399
- if (!llmRunner || dirsNeedingLlm.length === 0)
1400
- return;
1401
- // Aggregate per-entry failures so a misconfigured LLM endpoint surfaces
1402
- // as a single visible warning instead of silently degrading every entry
1403
- // and leaving the user wondering why nothing got enhanced.
1404
- const summary = { attempted: 0, succeeded: 0, skipped: 0, failureSamples: [] };
1405
- let completedDirs = 0;
1406
- let completedEntries = 0;
1407
- const totalDirs = dirsNeedingLlm.length;
1408
- const totalEntries = dirsNeedingLlm.reduce((sum, { stash }) => {
1409
- const entriesToEnhance = stash.entries.filter((e) => {
1410
- if (e.quality !== "generated")
1411
- return false;
1412
- if (isEnrichmentComplete(e))
1413
- return false;
1414
- return true;
1415
- });
1416
- return sum + entriesToEnhance.length;
1417
- }, 0);
1418
- // P3 — wall-clock budget for the enrichment pass. Defaults to the resolved
1419
- // engine's timeoutMs (or 10 minutes if not set). Users can extend it via
1420
- // `index.enrichment.timeoutMs` (or `index.defaults.timeoutMs`, or the
1421
- // engine's own `engines.<name>.timeoutMs`) — no separate knob needed.
1422
- const enrichDeadline = createEnrichmentDeadline(llmRunner.timeoutMs, totalEntries);
1423
- let deadlineHit = false;
1424
- const enrichSignal = (() => {
1425
- if (!enrichDeadline)
1426
- return signal ?? new AbortController().signal;
1427
- if (!signal)
1428
- return enrichDeadline;
1429
- // Combine: abort when either fires.
1430
- const controller = new AbortController();
1431
- const onAbort = () => controller.abort();
1432
- signal.addEventListener("abort", onAbort, { once: true });
1433
- enrichDeadline.addEventListener("abort", () => {
1434
- deadlineHit = true;
1435
- controller.abort();
1436
- }, { once: true });
1437
- return controller.signal;
1438
- })();
1439
- if (totalEntries > 0) {
1440
- onProgress?.({
1441
- phase: "llm",
1442
- message: `LLM enhancement starting for ${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} ` +
1443
- `across ${totalDirs} director${totalDirs === 1 ? "y" : "ies"} (concurrency ${getDefaultLlmConcurrency(llmRunner.connection)}).`,
1444
- processed: 0,
1445
- total: totalEntries,
1446
- });
1447
- }
1448
- let currentDirLabel;
1449
- let configFailure;
1450
- let lastProgressAt = Date.now();
1451
- let heartbeatTimer;
1452
- if (totalEntries > 0 && onProgress) {
1453
- heartbeatTimer = setInterval(() => {
1454
- if (Date.now() - lastProgressAt < 15000)
1455
- return;
1456
- onProgress({
1457
- phase: "llm",
1458
- message: `Still enriching ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}` +
1459
- (currentDirLabel ? `; waiting on ${currentDirLabel}` : "") +
1460
- ".",
1461
- processed: completedEntries,
1462
- total: totalEntries,
1463
- });
1464
- lastProgressAt = Date.now();
1465
- }, 15000);
1466
- }
239
+ export async function akmIndex(options) {
1467
240
  try {
1468
- await concurrentMap(dirsNeedingLlm, async ({ dirPath, files, currentStashDir, stash: originalStash }) => {
1469
- if (enrichSignal.aborted)
1470
- return undefined;
1471
- // Only enhance generated entries; user-provided overrides should not
1472
- // be overwritten. Skip entries that are already fully enriched
1473
- // (description + tags + searchHints).
1474
- const entriesToEnhance = originalStash.entries.filter((e) => {
1475
- if (e.quality !== "generated")
1476
- return false;
1477
- if (isEnrichmentComplete(e)) {
1478
- warnVerbose(`[akm] skipping LLM enrichment for "${e.name}" — entry already complete`);
1479
- return false;
1480
- }
1481
- return true;
1482
- });
1483
- if (entriesToEnhance.length === 0)
1484
- return undefined;
1485
- currentDirLabel = path.relative(currentStashDir, dirPath) || ".";
1486
- onProgress?.({
1487
- phase: "llm",
1488
- message: `Enhancing ${currentDirLabel} ` +
1489
- `(${entriesToEnhance.length} entr${entriesToEnhance.length === 1 ? "y" : "ies"}).`,
1490
- processed: completedEntries,
1491
- total: totalEntries,
1492
- });
1493
- lastProgressAt = Date.now();
1494
- const targetStash = { entries: entriesToEnhance };
1495
- const itemRefs = entriesToEnhance.map((entry) => {
1496
- const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
1497
- return indexedProvenanceForFile(db, entryPath).itemRef;
1498
- });
1499
- let enhanced;
241
+ const override = akmIndexOverride;
242
+ return override ? await override(options) : await akmIndexReal(options);
243
+ }
244
+ catch (error) {
245
+ const updateDb = options.deferredUpdateTransaction?.db;
246
+ if (updateDb?.inTransaction) {
1500
247
  try {
1501
- enhanced = await enhanceStashWithLlm(llmRunner, targetStash, files, summary, enrichSignal, db, itemRefs, config, (event) => {
1502
- completedEntries++;
1503
- lastProgressAt = Date.now();
1504
- onProgress?.({
1505
- phase: "llm",
1506
- message: `Enhanced ${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"}; ` +
1507
- `${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"} complete` +
1508
- (event.entryName ? `; current ${event.entryName}` : "") +
1509
- (currentDirLabel ? ` in ${currentDirLabel}` : "") +
1510
- (event.outcome === "cache-hit" ? " (cache hit)" : ""),
1511
- processed: completedEntries,
1512
- total: totalEntries,
1513
- });
1514
- }, onNotices, lease);
248
+ updateDb.exec("ROLLBACK");
1515
249
  }
1516
- catch (err) {
1517
- if (err instanceof ConfigError) {
1518
- configFailure ??= err;
1519
- return undefined;
1520
- }
1521
- throw err;
250
+ catch {
251
+ // Preserve the indexing error. The update coordinator will retry
252
+ // rollback before closing its borrowed unified handle.
1522
253
  }
1523
- // Re-upsert the enhanced entries in a single transaction so a crash
1524
- // cannot leave half the entries updated and the rest stale.
1525
- db.transaction(() => {
1526
- for (const entry of enhanced.entries) {
1527
- const entryPath = entry.filename ? path.join(dirPath, entry.filename) : files[0] || dirPath;
1528
- const searchText = buildSearchText(entry);
1529
- const provenance = indexedProvenanceForFile(db, entryPath);
1530
- upsertEntry(db, entryPath, attachFileSize(entry, entryPath), searchText, provenance);
1531
- }
1532
- })();
1533
- completedDirs++;
1534
- lastProgressAt = Date.now();
1535
- onProgress?.({
1536
- phase: "llm",
1537
- message: `Completed ${completedDirs}/${totalDirs} director${totalDirs === 1 ? "y" : "ies"}; ` +
1538
- `${completedEntries}/${totalEntries} entr${totalEntries === 1 ? "y" : "ies"} processed.`,
1539
- processed: completedEntries,
1540
- total: totalEntries,
1541
- });
1542
- return undefined;
1543
- },
1544
- // Defaults: 2 for remote LLM APIs, 1 for local model servers (LM
1545
- // Studio, Ollama run one inference at a time — parallel requests cause
1546
- // "Model reloaded" / 500 errors). No config override reaches this path:
1547
- // `resolveLlmEngineUse` does not forward `engines.<name>.concurrency`.
1548
- getDefaultLlmConcurrency(llmRunner.connection));
1549
- if (configFailure)
1550
- throw configFailure;
1551
- }
1552
- finally {
1553
- if (heartbeatTimer)
1554
- clearInterval(heartbeatTimer);
1555
- }
1556
- if (deadlineHit) {
1557
- warn("[akm] LLM enrichment budget exceeded. Re-run `akm index` to continue. Increase index.enrichment.timeoutMs for a larger budget.");
1558
- }
1559
- // Gate-closed (`skipped`) entries are not failures — exclude them so a
1560
- // deliberately disabled feature never surfaces as an enrichment error.
1561
- const failed = summary.attempted - summary.succeeded - summary.skipped;
1562
- if (failed > 0 && summary.succeeded === 0) {
1563
- const sample = summary.failureSamples.length ? ` Example: ${summary.failureSamples[0]}` : "";
1564
- warn(`LLM enhancement failed for all ${failed} attempted entries — index built without LLM enrichment.` +
1565
- ` Check llm.endpoint and llm.model in your config.${sample}`);
1566
- }
1567
- else if (failed > 0) {
1568
- const sample = summary.failureSamples.length ? ` Examples: ${summary.failureSamples.join("; ")}` : "";
1569
- warn(`LLM enhancement failed for ${failed}/${summary.attempted} entries — they were left un-enhanced.${sample}`);
254
+ }
255
+ throw reclassifyIndexDbContention(error);
1570
256
  }
1571
257
  }
1572
- export function createEnrichmentDeadline(timeoutMs, totalEntries) {
1573
- const perEntryTimeoutMs = timeoutMs === undefined ? 10 * 60 * 1000 : timeoutMs;
1574
- return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
1575
- }
1576
- // ── Helpers ─────────────────────────────────────────────────────────────────
1577
- function attachFileSize(entry, entryPath) {
1578
- try {
1579
- const sized = { ...entry, fileSize: fs.statSync(entryPath).size };
1580
- if (hasMarkdownFragmentContent(entry))
1581
- setMarkdownFragmentContent(sized, getMarkdownFragmentContent(entry));
1582
- return sized;
1583
- }
1584
- catch {
1585
- return entry;
1586
- }
258
+ /**
259
+ * The effective embedding vector width for this db: the config's explicit
260
+ * `embedding.dimension` when set, else the width `index-schema.ts`'s
261
+ * `ensureSchema` already stamped into `index_meta.embeddingDim` (every
262
+ * `openIndexDatabase` call runs `ensureSchema` before this code ever runs),
263
+ * else the static default. Mirrors `ensureSchema`'s own derivation so
264
+ * `dropOtherIdentities` below never recreates `units_vec` at the wrong width.
265
+ */
266
+ function effectiveEmbeddingDim(db, config) {
267
+ const configured = config.embedding?.dimension;
268
+ if (typeof configured === "number" && Number.isInteger(configured) && configured > 0)
269
+ return configured;
270
+ const stored = Number(getMeta(db, "embeddingDim"));
271
+ return Number.isInteger(stored) && stored > 0 ? stored : EMBEDDING_DIM;
1587
272
  }
1588
- function buildIndexSummaryMessage(options) {
1589
- const stashSourceLabel = options.sourcesCount === 1 ? "stash source" : "stash sources";
1590
- const semanticDetail = getSemanticSearchLabel(options.semanticSearchMode, options.embeddingProvider, options.vecAvailable);
1591
- return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}, LLM: ${options.llmEnabled ? "enabled" : "disabled"}).`;
273
+ /**
274
+ * `akm index --reembed`: drop the active embedding identity's vectors so
275
+ * drain re-embeds every unit from scratch under that same identity. Reuses
276
+ * A2's `dropOtherIdentities(db, keep, dim)` by asking it to keep an identity
277
+ * string no real vector can ever carry — `""`, never produced by
278
+ * `deriveObservedEmbeddingIdentity` — so every row under the real identity is
279
+ * removed and none are spared. `index_meta.embeddingIdentity` is left as-is:
280
+ * re-embedding the same unchanged provider/model reproduces the same identity
281
+ * string, so there is nothing stale to clear.
282
+ */
283
+ function dropActiveIdentityVectors(db, config) {
284
+ const identity = getMeta(db, "embeddingIdentity");
285
+ if (!identity)
286
+ return;
287
+ dropOtherIdentities(db, "", effectiveEmbeddingDim(db, config));
1592
288
  }
1593
289
  function getEmbeddingProvider(embedding) {
1594
290
  return isHttpUrl(embedding?.endpoint) ? "remote" : "local";
1595
291
  }
1596
- function getSemanticSearchLabel(semanticSearchMode, embeddingProvider, vecAvailable) {
1597
- if (semanticSearchMode === "off")
1598
- return "disabled";
1599
- return `${embeddingProvider} embeddings, ${vecAvailable ? "sqlite-vec" : "JS fallback"}`;
292
+ function buildIndexSummaryMessage(options) {
293
+ const stashSourceLabel = options.sourcesCount === 1 ? "stash source" : "stash sources";
294
+ const semanticDetail = options.semanticSearchMode === "off"
295
+ ? "disabled"
296
+ : `${options.embeddingProvider} embeddings, ${options.vecAvailable ? "sqlite-vec" : "unavailable"}`;
297
+ return `Starting ${options.mode} index (${options.sourcesCount} ${stashSourceLabel}, semantic search: ${semanticDetail}).`;
1600
298
  }
1601
- function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
1602
- const embeddingCount = getEmbeddingCount(db);
1603
- const vecAvailable = isVecAvailable(db);
299
+ /**
300
+ * Compute the `IndexResponse.verification` envelope from the current unit
301
+ * coverage for the active embedding identity. `drain` is the just-completed
302
+ * `DrainCounts`, when a drain ran this call (`null` when semantic search is
303
+ * off, the embedding phase was deferred to `akm bundle update`'s post-commit
304
+ * pass, or the drain call itself threw — see `drainFailed`).
305
+ *
306
+ * `drainFailed` is set when `drainEmbeddingQueue` threw outright (a genuine
307
+ * interruption — an abort, a provider crash before its own per-batch
308
+ * retry/circuit-breaker ever engaged) rather than returning normally with
309
+ * some batches skipped. `drain` is `null` in that case too (the counts a
310
+ * completed call would have returned were never produced), so this call
311
+ * cannot tell "threw after embedding half of them" apart from "threw before
312
+ * embedding any" by inspecting `drain` alone — `drainFailed` carries that
313
+ * distinction forward explicitly: this run did not finish its embedding
314
+ * phase, whatever partial progress the DB already durably committed
315
+ * (`coverage.unitsPresent`, read fresh below) notwithstanding.
316
+ */
317
+ function buildIndexVerification(db, config, drain, drainFailed = false) {
1604
318
  const embeddingProvider = getEmbeddingProvider(config.embedding);
1605
- if (embeddableEntries === 0) {
319
+ const vecAvailable = isVecAvailable(db);
320
+ const totalEntries = getEntryCount(db);
321
+ const semanticSearchEnabled = config.semanticSearchMode === "auto";
322
+ if (totalEntries === 0) {
1606
323
  return {
1607
324
  ok: true,
1608
325
  message: "Index ready. No assets were found yet.",
1609
- semanticSearchEnabled: config.semanticSearchMode === "auto",
326
+ semanticSearchEnabled,
1610
327
  semanticSearchMode: config.semanticSearchMode,
1611
328
  semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
1612
329
  embeddingProvider,
1613
- entryCount: embeddableEntries,
1614
- embeddingCount,
330
+ entryCount: 0,
331
+ embeddingCount: 0,
1615
332
  vecAvailable,
1616
333
  };
1617
334
  }
@@ -1623,190 +340,310 @@ function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
1623
340
  semanticSearchMode: config.semanticSearchMode,
1624
341
  semanticStatus: "disabled",
1625
342
  embeddingProvider,
1626
- entryCount: embeddableEntries,
1627
- embeddingCount,
343
+ entryCount: totalEntries,
344
+ embeddingCount: 0,
1628
345
  vecAvailable,
1629
346
  };
1630
347
  }
1631
- if (embeddingCount >= embeddableEntries) {
1632
- // "ready-vec" must reflect the path search will ACTUALLY take: the vec
1633
- // extension being loaded is not enough when the embedding phase recorded
1634
- // fast-path insert failures (searchVec then routes to the JS-cosine
1635
- // fallback via isVecFastPathReady). Reporting vec health from
1636
- // isVecAvailable alone overstated `akm info` after partial vec failures
1637
- // (§24.2 "Semantic" gate — truthful ready-vec).
1638
- const vecActive = vecAvailable && isVecFastPathReady(db);
348
+ const identity = getMeta(db, "embeddingIdentity");
349
+ const coverage = identity
350
+ ? unitCoverage(db, identity)
351
+ : { entries: 0, entriesFullyCovered: 0, unitsTotal: 0, unitsPresent: 0 };
352
+ if (coverage.entries > 0 && coverage.entriesFullyCovered >= coverage.entries && vecAvailable) {
1639
353
  return {
1640
354
  ok: true,
1641
- message: `Semantic search ready (${embeddingCount}/${embeddableEntries} embeddings, ${vecActive
1642
- ? "sqlite-vec active"
1643
- : vecAvailable
1644
- ? "JS fallback active — vec fast path degraded, run 'akm index --full' to restore"
1645
- : "JS fallback active"}).`,
355
+ message: `Semantic search ready (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings, sqlite-vec active).`,
1646
356
  semanticSearchEnabled: true,
1647
357
  semanticSearchMode: config.semanticSearchMode,
1648
- semanticStatus: vecActive ? "ready-vec" : "ready-js",
358
+ semanticStatus: "ready-vec",
359
+ embeddingProvider,
360
+ entryCount: totalEntries,
361
+ embeddingCount: coverage.unitsPresent,
362
+ vecAvailable,
363
+ };
364
+ }
365
+ // sqlite-vec is the only vector store (no BLOB fallback since the index
366
+ // redesign), so without the extension no drain can ever make progress and
367
+ // "in progress" would be a status that never resolves. Report it as
368
+ // blocked, with the fix.
369
+ if (!vecAvailable) {
370
+ return {
371
+ ok: false,
372
+ message: `Semantic search unavailable: the sqlite-vec extension is not loaded (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings).`,
373
+ guidance: "Install the optional sqlite-vec extension (see docs/reference/configuration.md, sqlite-vec extension), then run `akm index`; keyword search keeps working meanwhile.",
374
+ semanticSearchEnabled,
375
+ semanticSearchMode: config.semanticSearchMode,
376
+ semanticStatus: "blocked",
1649
377
  embeddingProvider,
1650
- entryCount: embeddableEntries,
1651
- embeddingCount,
378
+ entryCount: totalEntries,
379
+ embeddingCount: coverage.unitsPresent,
1652
380
  vecAvailable,
1653
381
  };
1654
382
  }
383
+ // Not fully covered: a fresh index whose queue is still draining ("pending",
384
+ // not a failure) vs. a drain that ran and made no progress at all, OR
385
+ // threw outright mid-run ("blocked" either way — matches the guidance the
386
+ // old materialize-embeddings path gave for the same shape of failure).
387
+ const madeNoProgress = drainFailed || (drain !== null && drain.pending > 0 && drain.embedded === 0 && drain.failed > 0);
1655
388
  return {
1656
- ok: false,
1657
- message: embeddingResult.message ??
1658
- `Semantic search verification failed (${embeddingCount}/${embeddableEntries} embeddings available).`,
1659
- guidance: embeddingProvider === "remote"
1660
- ? "Check your embedding endpoint and credentials, then retry `akm index --full --verbose`."
1661
- : "Retry `akm index --full --verbose`. If it still fails, confirm local model downloads are permitted and see docs/reference/configuration.md for local embedding dependency setup.",
389
+ ok: !madeNoProgress,
390
+ message: madeNoProgress
391
+ ? `Semantic search verification failed (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings available).`
392
+ : `Semantic search embedding in progress (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings).`,
393
+ ...(madeNoProgress
394
+ ? {
395
+ guidance: embeddingProvider === "remote"
396
+ ? "Check your embedding endpoint and credentials, then retry `akm index --full --verbose`."
397
+ : "Retry `akm index --full --verbose`. If it still fails, confirm local model downloads are permitted and see docs/reference/configuration.md for local embedding dependency setup.",
398
+ }
399
+ : {}),
1662
400
  semanticSearchEnabled: true,
1663
401
  semanticSearchMode: config.semanticSearchMode,
1664
- semanticStatus: "blocked",
402
+ semanticStatus: madeNoProgress ? "blocked" : "pending",
1665
403
  embeddingProvider,
1666
- entryCount: embeddableEntries,
1667
- embeddingCount,
404
+ entryCount: totalEntries,
405
+ embeddingCount: coverage.unitsPresent,
1668
406
  vecAvailable,
1669
407
  };
1670
408
  }
1671
- function buildIndexedDirCandidate(dirPath, indexableFiles, generated) {
1672
- const stash = generated.entries.length > 0 ? { entries: generated.entries } : null;
1673
- const staleFiles = stash ? resolveIndexedFiles(dirPath, indexableFiles, stash) : indexableFiles;
1674
- return { stash, staleFiles };
409
+ /**
410
+ * The ONE embedding-phase implementation: drain the content-addressed
411
+ * embedding queue (B4's `drainEmbeddingQueue`) and compute the post-drain
412
+ * `IndexVerification` from unit coverage. `akmIndex`'s own (non-deferred) run
413
+ * calls this inline; `akm bundle update`'s coordinator calls it directly on
414
+ * its own connection AFTER its unified update transaction commits, since
415
+ * draining writes per-provider-batch transactions that must not nest inside
416
+ * the coordinator's long-lived one.
417
+ */
418
+ export async function runEmbeddingPass(params) {
419
+ const { db, config, onProgress, signal } = params;
420
+ const drainCounts = await drainEmbeddingQueue(db, config, {
421
+ signal,
422
+ onProgress: (line) => onProgress({ phase: "embeddings", message: line }),
423
+ });
424
+ const verification = buildIndexVerification(db, config, drainCounts);
425
+ setMeta(db, "hasEmbeddings", verification.semanticStatus === "ready-vec" ? "1" : "0");
426
+ onProgress({ phase: "verify", message: verification.message });
427
+ return { drainCounts, verification };
1675
428
  }
1676
- function resolveIndexedFiles(dirPath, files, stash) {
1677
- const resolved = new Set();
1678
- for (const entry of stash.entries) {
1679
- if (entry.filename)
1680
- resolved.add(path.join(dirPath, entry.filename));
429
+ async function akmIndexReal(options) {
430
+ const requestedAt = Date.now();
431
+ const stashDir = options.stashDir;
432
+ const onProgress = options?.onProgress ?? (() => { });
433
+ const signal = options?.signal;
434
+ const full = options?.full === true;
435
+ const reembed = options?.reembed === true;
436
+ const { loadConfig, mutateConfig } = await import("../core/config/config.js");
437
+ let config = loadConfig();
438
+ // Durable state must be runtime-compatible before source hydration,
439
+ // adapter persistence, or index.db creation can mutate the installation.
440
+ onProgress({ phase: "preflight", message: "Validating durable state." });
441
+ if (!options.deferredUpdateTransaction)
442
+ withStateDb(() => undefined);
443
+ // Source hydration: ensure git/website/npm caches are extracted before
444
+ // resolving stash dirs, so their content directories exist on disk for
445
+ // reconcile's walk to discover. This is NOT index derivation — it is what
446
+ // makes derivation possible — so it stays even though the walk/derive
447
+ // pipeline below it does not.
448
+ const sourceCacheStart = Date.now();
449
+ onProgress({ phase: "preflight", message: "Hydrating source caches." });
450
+ const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
451
+ // Inject the store-backed secret resolver from here — a composition root
452
+ // ABOVE the provider/fetcher import cycle (this module reaches
453
+ // search-source only via dynamic import). This is what lets a website
454
+ // source's fetcher resolve `secrets/x-bearer-token` during bundle-update /
455
+ // hydrate, not just from the command-layer URL-ingest path.
456
+ const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
457
+ await ensureSourceCaches(config, {
458
+ force: full,
459
+ materialize: options.hydrateSources !== false,
460
+ secrets: storeSecretResolver,
461
+ onProgress: (message) => onProgress({ phase: "preflight", message }),
462
+ });
463
+ const sourceCacheEnd = Date.now();
464
+ const allSourceEntries = resolveSourceEntries(stashDir, config);
465
+ const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
466
+ announce: options.implicit !== true,
467
+ persist: options.persistDetectedAdapters !== false,
468
+ });
469
+ config = detected.config;
470
+ const persistedAdapters = detected.persistedAdapters;
471
+ const allSourceDirs = allSourceEntries.map((s) => s.path);
472
+ onProgress({
473
+ phase: "preflight",
474
+ message: `Resolved ${allSourceDirs.length} stash source${allSourceDirs.length === 1 ? "" : "s"}.`,
475
+ });
476
+ const t0 = Date.now();
477
+ const dbPath = getDbPath();
478
+ const embeddingDim = config.embedding?.dimension;
479
+ const borrowedUpdateDb = options.deferredUpdateTransaction?.db;
480
+ const db = borrowedUpdateDb ?? openIndexDatabase(dbPath, embeddingDim ? { embeddingDim } : undefined);
481
+ if (borrowedUpdateDb && !borrowedUpdateDb.inTransaction) {
482
+ throw new Error("Source update index requires an active borrowed index transaction.");
1681
483
  }
1682
- return resolved.size > 0 ? [...resolved] : files;
1683
- }
1684
- async function enhanceStashWithLlm(llmRunner, stash, files, summary, signal, db, itemRefs, akmConfig, onEntryDone, onNotices, lease) {
1685
- const { enhanceMetadata } = await import("../llm/metadata-enhance.js");
1686
- const { computeBodyHash, getLlmCacheEntry, upsertLlmCacheEntry } = await import("../storage/repositories/index-llm-cache-repository.js");
1687
- let configFailure;
1688
- const results = await concurrentMap(stash.entries, async (entry, idx) => {
1689
- if (signal?.aborted)
1690
- return entry;
1691
- summary.attempted++;
1692
- try {
1693
- const entryFile = entry.filename
1694
- ? (files.find((f) => path.basename(f) === entry.filename) ?? files[0])
1695
- : files[0];
1696
- let fileContent;
1697
- if (entryFile) {
1698
- try {
1699
- fileContent = fs.readFileSync(entryFile, "utf8");
1700
- }
1701
- catch {
1702
- warn(`Could not read file for LLM enrichment: ${entry.filename ?? entry.name}`);
1703
- }
1704
- }
1705
- // Incremental cache: skip LLM call when file body is unchanged. The
1706
- // Cache metadata enrichment by the canonical durable item ref.
1707
- const cacheBody = fileContent ?? `${entry.name}\n${entry.description ?? ""}`;
1708
- const bodyHash = computeBodyHash(cacheBody);
1709
- const cacheKey = itemRefs?.[idx];
1710
- if (!cacheKey)
1711
- throw new Error(`Missing canonical item ref for enrichment entry ${entry.name}.`);
1712
- if (db) {
1713
- const cached = getLlmCacheEntry(db, cacheKey, bodyHash);
1714
- if (cached) {
1715
- try {
1716
- const parsed = JSON.parse(cached.resultJson);
1717
- const updated = { ...entry };
1718
- if (parsed.description)
1719
- updated.description = parsed.description;
1720
- if (parsed.searchHints?.length)
1721
- updated.searchHints = parsed.searchHints;
1722
- if (parsed.tags?.length)
1723
- updated.tags = parsed.tags;
1724
- updated.quality = "enriched";
1725
- summary.succeeded++;
1726
- onEntryDone?.({ entryName: entry.name, outcome: "cache-hit" });
1727
- return updated;
1728
- }
1729
- catch {
1730
- warn(`LLM enrichment cache entry corrupt for ${entry.name}; re-running enrichment`);
1731
- }
1732
- }
1733
- }
1734
- const outcome = await enhanceMetadata(llmRunner, entry, fileContent, signal, akmConfig, onNotices, lease);
1735
- if (outcome.status !== "enriched") {
1736
- // Not a genuine LLM success: the gate was closed (`skipped`) or the
1737
- // call errored/timed out (`failed`). Do NOT mark the entry enriched
1738
- // and do NOT write the LLM cache — caching here would poison the
1739
- // entry into a permanent enrichment skip even though nothing was
1740
- // enhanced. Surface failures honestly; stay silent on gated-off skips.
1741
- if (outcome.status === "failed") {
1742
- const msg = outcome.error ?? "metadata enrichment failed";
1743
- if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(msg)) {
1744
- summary.failureSamples.push(msg);
1745
- }
1746
- onEntryDone?.({ entryName: entry.name, outcome: "failed" });
1747
- }
1748
- else {
1749
- summary.skipped++;
1750
- onEntryDone?.({ entryName: entry.name, outcome: "skipped" });
1751
- }
1752
- return entry;
484
+ try {
485
+ const owners = sourceOwners(allSourceEntries);
486
+ if (full) {
487
+ onProgress({ phase: "preflight", message: "Forcing full re-derivation for a full reindex." });
488
+ }
489
+ if (reembed) {
490
+ onProgress({ phase: "preflight", message: "Dropping vectors for the active embedding identity." });
491
+ dropActiveIdentityVectors(db, config);
492
+ }
493
+ onProgress({
494
+ phase: "summary",
495
+ message: buildIndexSummaryMessage({
496
+ mode: full ? "full" : "incremental",
497
+ sourcesCount: allSourceDirs.length,
498
+ semanticSearchMode: config.semanticSearchMode,
499
+ embeddingProvider: getEmbeddingProvider(config.embedding),
500
+ vecAvailable: isVecAvailable(db),
501
+ }),
502
+ });
503
+ removeStaleSourceOwners(db, owners);
504
+ throwIfAborted(signal);
505
+ const reconcileStart = Date.now();
506
+ const reconcileCounts = await reconcileRoots(db, owners
507
+ .filter((owner) => !owner.unresolved)
508
+ .map((owner) => ({ path: owner.sourceRoot, bundleId: owner.bundleId })), {
509
+ signal,
510
+ onProgress: (line) => onProgress({ phase: "scan", message: line }),
511
+ forceReparse: full,
512
+ // #954-precedent (same as the embedding drain skip below): a
513
+ // borrowed transaction must not hold open across enrichment's LLM
514
+ // round trip.
515
+ insideBorrowedTransaction: Boolean(options.deferredUpdateTransaction),
516
+ });
517
+ onProgress({
518
+ phase: "scan",
519
+ message: `Reconciled ${reconcileCounts.scanned} file${reconcileCounts.scanned === 1 ? "" : "s"} ` +
520
+ `(${reconcileCounts.added} added, ${reconcileCounts.changed} changed, ${reconcileCounts.removed} removed).`,
521
+ });
522
+ const reconcileEnd = Date.now();
523
+ throwIfAborted(signal);
524
+ const deferred = options.deferredUpdateTransaction;
525
+ let drainCounts = null;
526
+ let drainFailed = false;
527
+ if (deferred) {
528
+ // #954-precedent: the embedding phase is SKIPPED entirely for a
529
+ // borrowed transaction — draining commits per provider batch, which
530
+ // must not nest inside the coordinator's long-lived transaction.
531
+ // Finalize below records semantic state as "pending", never "ready",
532
+ // until the coordinator's own post-commit `runEmbeddingPass` call
533
+ // reports the truth on a fresh connection.
534
+ setMeta(db, "hasEmbeddings", "0");
535
+ }
536
+ else if (config.semanticSearchMode !== "off") {
537
+ try {
538
+ drainCounts = await drainEmbeddingQueue(db, config, {
539
+ signal,
540
+ onProgress: (line) => onProgress({ phase: "embeddings", message: line }),
541
+ // An implicit (read-path) run takes a bounded slice see
542
+ // `IndexOptions.implicit`. An explicit `akm index` drains the
543
+ // whole queue, as it always has.
544
+ ...(options.implicit === true ? { limit: IMPLICIT_DRAIN_UNIT_LIMIT } : {}),
545
+ });
1753
546
  }
1754
- const improvements = outcome.metadata;
1755
- const updated = { ...entry };
1756
- if (improvements.description)
1757
- updated.description = improvements.description;
1758
- if (improvements.searchHints?.length)
1759
- updated.searchHints = improvements.searchHints;
1760
- if (improvements.tags?.length)
1761
- updated.tags = improvements.tags;
1762
- // Mark as enriched so subsequent index runs skip re-enrichment (P2).
1763
- // An empty-but-successful response is still cached: the LLM was paid
1764
- // for this body_hash and produced no improvements, so re-running would
1765
- // only re-pay for the same no-op. (The cache protects against re-paying
1766
- // for the LLM call when the file body is unchanged.)
1767
- updated.quality = "enriched";
1768
- // Persist to cache so the next run can skip the LLM call when the
1769
- // file body has not changed.
1770
- if (db) {
1771
- upsertLlmCacheEntry(db, cacheKey, bodyHash, JSON.stringify({
1772
- description: improvements.description,
1773
- searchHints: improvements.searchHints,
1774
- tags: improvements.tags,
1775
- }));
547
+ catch (drainError) {
548
+ // Best-effort, same contract as the write-path drain
549
+ // (index-written-assets.ts): the provider can fail outright (a model
550
+ // download blocked, DNS down, a bad endpoint) before it ever reaches
551
+ // drainEmbeddingQueue's own per-batch retry/circuit-breaker — an
552
+ // index run must still finish lexically searchable rather than
553
+ // crash. The embedding queue is durable: the next run (or the
554
+ // scheduler) computes the same "no vector yet" query and resumes.
555
+ // `drainFailed` records that this run's embedding phase did not
556
+ // finish (verification below reports `ok: false`), independent of
557
+ // however many batches had already committed durably before the
558
+ // throw those are real, already reflected in the next read of
559
+ // unit coverage, not undone by this catch.
560
+ throwIfAborted(signal);
561
+ drainFailed = true;
562
+ // This catch is non-fatal by design, so `akmIndex`'s outer catch never
563
+ // sees the error and never classifies it — which is exactly how the
564
+ // field saw a raw "database is locked" driver string in this warning
565
+ // under the phase pipeline this replaced. Run it through the same
566
+ // classifier that path uses, so contention reads as contention.
567
+ const reportedDrainError = reclassifyIndexDbContention(drainError);
568
+ warn("[index] Embedding drain failed; the index is lexically searchable and vectors will be attempted on the next run:", reportedDrainError instanceof Error ? reportedDrainError.message : String(reportedDrainError));
1776
569
  }
1777
- summary.succeeded++;
1778
- onEntryDone?.({ entryName: entry.name, outcome: "llm" });
1779
- return updated;
1780
570
  }
1781
- catch (err) {
1782
- if (err instanceof ConfigError) {
1783
- configFailure ??= err;
1784
- return entry;
1785
- }
1786
- const msg = toErrorMessage(err);
1787
- // failureSamples is bounded to 3 items, so a linear scan is cheaper
1788
- // than maintaining a parallel Set for membership checks (#177 review).
1789
- if (summary.failureSamples.length < 3 && !summary.failureSamples.includes(msg)) {
1790
- summary.failureSamples.push(msg);
571
+ const embedEnd = Date.now();
572
+ // ── Finalize / meta bookkeeping ───────────────────────────────────────
573
+ const finalizeStart = Date.now();
574
+ const mutateState = (stateDb, stateSchema) => {
575
+ onProgress({ phase: "finalize", message: "Relinking usage events." });
576
+ relinkUsageEvents(db, stateDb, { sources: allSourceEntries, defaultStashDir: stashDir, stateSchema });
577
+ onProgress({ phase: "finalize", message: "Recomputing utility scores." });
578
+ recomputeUtilityScores(db, stateDb, { stateSchema });
579
+ };
580
+ if (deferred) {
581
+ if (deferred.db !== db || !db.inTransaction) {
582
+ throw new Error("Source update index finalization requires its borrowed unified transaction.");
1791
583
  }
1792
- onEntryDone?.({ entryName: entry.name, outcome: "failed" });
1793
- return entry;
584
+ mutateState(db, deferred.stateSchema);
585
+ }
586
+ else {
587
+ withStateDb(mutateState);
588
+ }
589
+ // An incomplete reconcile (a configured source that could not be walked
590
+ // this run) preserves the prior freshness watermark. Advancing it could
591
+ // make a source that came back look unchanged even though this run never
592
+ // saw its files — the same #624-P1 preflight the old walk phase applied
593
+ // to `builtAt`, now keyed off `reconcileCounts.complete`.
594
+ if (reconcileCounts.complete) {
595
+ const builtAt = new Date().toISOString();
596
+ setMeta(db, "builtAt", builtAt);
597
+ setMeta(db, "stashDir", stashDir);
598
+ setMeta(db, "stashDirs", JSON.stringify(owners.map((owner) => owner.sourceRoot)));
599
+ setMeta(db, "sourceOwners", JSON.stringify(owners));
600
+ setMeta(db, "lastReconcileAt", builtAt);
1794
601
  }
1795
- },
1796
- // Defaults: 2 for remote LLM APIs, 1 for local model servers. No config
1797
- // override reaches this path (see getDefaultLlmConcurrency).
1798
- getDefaultLlmConcurrency(llmRunner.connection));
1799
- if (configFailure)
1800
- throw configFailure;
1801
- // concurrentMap returns Array<T | undefined>; filter out undefined slots
1802
- // (which can only occur if the callback itself returned undefined, which
1803
- // it never does above — but TypeScript needs the filter for type safety).
1804
- const enhanced = results.map((r, i) => r ?? stash.entries[i]);
1805
- return { entries: enhanced };
602
+ const verification = deferred
603
+ ? {
604
+ ok: true,
605
+ message: "Semantic index update deferred until after the source-update commit.",
606
+ semanticSearchEnabled: config.semanticSearchMode === "auto",
607
+ semanticSearchMode: config.semanticSearchMode,
608
+ semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
609
+ embeddingProvider: getEmbeddingProvider(config.embedding),
610
+ entryCount: getEntryCount(db),
611
+ embeddingCount: 0,
612
+ vecAvailable: isVecAvailable(db),
613
+ }
614
+ : buildIndexVerification(db, config, drainCounts, drainFailed);
615
+ if (!deferred)
616
+ setMeta(db, "hasEmbeddings", verification.semanticStatus === "ready-vec" ? "1" : "0");
617
+ onProgress({ phase: "verify", message: verification.message });
618
+ const totalEntries = getEntryCount(db);
619
+ const finalizeEnd = Date.now();
620
+ return {
621
+ stashDir,
622
+ totalEntries,
623
+ entriesUpserted: reconcileCounts.added + reconcileCounts.changed,
624
+ indexPath: dbPath,
625
+ mode: full ? "full" : "incremental",
626
+ sourcesScanned: owners.length,
627
+ scanComplete: reconcileCounts.complete,
628
+ ...(reconcileCounts.warnings.length > 0 ? { warnings: reconcileCounts.warnings } : {}),
629
+ ...(Object.keys(persistedAdapters).length > 0 ? { configUpdated: { detectedAdapters: persistedAdapters } } : {}),
630
+ verification,
631
+ timing: {
632
+ totalMs: Date.now() - t0,
633
+ preflightMs: t0 - requestedAt,
634
+ sourceCacheMs: sourceCacheEnd - sourceCacheStart,
635
+ reconcileMs: reconcileEnd - reconcileStart,
636
+ embedMs: embedEnd - reconcileEnd,
637
+ finalizeMs: finalizeEnd - finalizeStart,
638
+ endToEndMs: Date.now() - requestedAt,
639
+ },
640
+ };
641
+ }
642
+ finally {
643
+ if (!borrowedUpdateDb)
644
+ closeDatabase(db);
645
+ }
1806
646
  }
1807
- // ── lookup ─────────────────────────────────────────────────────────────────
1808
- import { makeBundleRef } from "../core/asset/asset-ref.js";
1809
- import { conceptIdFromTypeName } from "../core/asset/resolve-ref.js";
1810
647
  async function resolveLookupSources() {
1811
648
  const { loadConfig } = await import("../core/config/config.js");
1812
649
  const { resolveSourceEntries } = await import("./search/search-source.js");
@@ -1957,7 +794,7 @@ const USAGE_EVENT_RETENTION_DAYS = 90;
1957
794
  * Also purges usage_events older than 90 days and ensures the M-1
1958
795
  * usage_events table exists before querying.
1959
796
  *
1960
- * Called during `akm index` after FTS rebuild.
797
+ * Called during `akm index` after reconcile.
1961
798
  */
1962
799
  export function recomputeUtilityScores(db, stateDb, options) {
1963
800
  const EMA_DECAY = 0.7;