akm-cli 0.9.16-alpha.1 → 0.9.16

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 (147) hide show
  1. package/CHANGELOG.md +56 -132
  2. package/dist/assets/hints/cli-hints-full.md +13 -6
  3. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  4. package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
  5. package/dist/cli/retired-commands.js +0 -4
  6. package/dist/cli/unknown-flags.js +3 -36
  7. package/dist/commands/env/env-binding.js +4 -4
  8. package/dist/commands/env/env-cli.js +3 -3
  9. package/dist/commands/improve/collapse-detector.js +2 -2
  10. package/dist/commands/improve/consolidate.js +4 -6
  11. package/dist/commands/improve/improve-cli.js +20 -15
  12. package/dist/commands/improve/reflect.js +23 -2
  13. package/dist/commands/lint/base-linter.js +9 -0
  14. package/dist/commands/lint/env-key-rules.js +2 -2
  15. package/dist/commands/proposal/propose.js +15 -1
  16. package/dist/commands/proposal/repository.js +3 -12
  17. package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
  18. package/dist/commands/proposal/validators/proposal-validators.js +5 -4
  19. package/dist/commands/read/curate.js +44 -34
  20. package/dist/commands/read/search.js +35 -54
  21. package/dist/commands/read/show.js +21 -2
  22. package/dist/commands/registry-cli.js +5 -5
  23. package/dist/commands/sources/add-cli.js +59 -16
  24. package/dist/commands/sources/bundle-cli.js +35 -11
  25. package/dist/commands/sources/bundle-config-ops.js +30 -0
  26. package/dist/commands/sources/dangerous-env-audit.js +4 -4
  27. package/dist/commands/sources/info.js +8 -8
  28. package/dist/commands/sources/installed-stashes.js +55 -61
  29. package/dist/commands/sources/source-add.js +39 -38
  30. package/dist/commands/sources/source-manage.js +34 -12
  31. package/dist/commands/sources/stash-cli.js +111 -119
  32. package/dist/commands/sources/stash-skeleton.js +6 -3
  33. package/dist/commands/tasks/explain.js +4 -1
  34. package/dist/commands/tasks/tasks-cli.js +31 -9
  35. package/dist/commands/tasks/tasks.js +239 -194
  36. package/dist/commands/tasks/validate.js +20 -32
  37. package/dist/core/activation-policy.js +4 -4
  38. package/dist/core/adapter/adapters/akm-adapter.js +8 -35
  39. package/dist/core/adapter/adapters/akm-metadata.js +1 -11
  40. package/dist/core/adapter/execution-source.js +10 -29
  41. package/dist/core/asset/asset-placement.js +0 -35
  42. package/dist/core/config/config-schema.js +64 -8
  43. package/dist/core/config/config-sources.js +96 -2
  44. package/dist/core/config/config.js +190 -24
  45. package/dist/core/config/legacy-source-shape-shim.js +9 -0
  46. package/dist/core/config/schema/embedding.js +30 -7
  47. package/dist/core/config/schema/execution.js +23 -0
  48. package/dist/core/config/schema/experimental.js +1 -1
  49. package/dist/core/config/schema/scheduler.js +20 -0
  50. package/dist/core/config/schema/search.js +10 -12
  51. package/dist/core/config/schema/sources-bundles.js +32 -1
  52. package/dist/core/content-safety.js +52 -0
  53. package/dist/core/errors.js +2 -5
  54. package/dist/core/maintenance-barrier.js +11 -13
  55. package/dist/core/paths.js +11 -0
  56. package/dist/core/run-lock.js +2 -5
  57. package/dist/core/state/migrations.js +1 -26
  58. package/dist/core/state-db.js +27 -63
  59. package/dist/core/type-presentation.js +1 -1
  60. package/dist/core/write-source.js +13 -8
  61. package/dist/indexer/bundle-identity-guard.js +45 -8
  62. package/dist/indexer/ensure-index.js +0 -5
  63. package/dist/indexer/index-db-contention.js +56 -0
  64. package/dist/indexer/index-rebuild-lock.js +73 -0
  65. package/dist/indexer/index-written-assets.js +171 -133
  66. package/dist/indexer/indexer.js +1621 -458
  67. package/dist/indexer/lookup/adapter-concept-owner.js +5 -19
  68. package/dist/indexer/materialize-embeddings.js +785 -0
  69. package/dist/indexer/passes/dir-staleness.js +161 -0
  70. package/dist/indexer/passes/metadata.js +1 -18
  71. package/dist/indexer/scan/drain-dir.js +70 -27
  72. package/dist/indexer/search/db-search.js +89 -373
  73. package/dist/indexer/search/ranking-contributors.js +16 -21
  74. package/dist/indexer/search/ranking.js +57 -135
  75. package/dist/indexer/search/search-source.js +29 -11
  76. package/dist/integrations/agent/execution-lowering.js +3 -2
  77. package/dist/integrations/agent/execution-preparation.js +32 -1
  78. package/dist/integrations/agent/prompts.js +1 -1
  79. package/dist/integrations/agent/request-lowering.js +3 -2
  80. package/dist/llm/client.js +3 -11
  81. package/dist/llm/embedder.js +3 -10
  82. package/dist/llm/embedders/remote.js +104 -133
  83. package/dist/llm/feature-gate.js +2 -4
  84. package/dist/llm/rerank-client.js +3 -3
  85. package/dist/output/html-render.js +2 -1
  86. package/dist/output/shapes/passthrough.js +2 -1
  87. package/dist/output/stdout.js +24 -0
  88. package/dist/output/text/command-format.js +13 -19
  89. package/dist/output/text/helpers.js +1 -1
  90. package/dist/output/text/index.js +2 -5
  91. package/dist/output/text.js +4 -3
  92. package/dist/registry/resolve.js +37 -10
  93. package/dist/scripts/akm-migrate-node.js +15197 -11351
  94. package/dist/scripts/akm-migrate.js +15514 -11668
  95. package/dist/setup/semantic-assets.js +2 -2
  96. package/dist/setup/setup.js +3 -3
  97. package/dist/setup/steps/connection.js +2 -3
  98. package/dist/setup/steps/tasks.js +29 -36
  99. package/dist/sources/providers/git-install.js +17 -11
  100. package/dist/sources/providers/git-provider.js +12 -5
  101. package/dist/sources/providers/git-stash.js +38 -16
  102. package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
  103. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  104. package/dist/storage/repositories/index-connection.js +3 -1
  105. package/dist/storage/repositories/index-entries-repository.js +68 -77
  106. package/dist/storage/repositories/index-entry-schema.js +25 -16
  107. package/dist/storage/repositories/index-fts-repository.js +263 -29
  108. package/dist/storage/repositories/index-meta-repository.js +29 -0
  109. package/dist/storage/repositories/index-schema.js +122 -115
  110. package/dist/storage/repositories/index-utility-repository.js +1 -1
  111. package/dist/storage/repositories/index-vec-repository.js +435 -22
  112. package/dist/tasks/activation-config.js +90 -0
  113. package/dist/tasks/backends/cron.js +9 -0
  114. package/dist/tasks/backends/launchd.js +1 -0
  115. package/dist/tasks/backends/schtasks.js +2 -0
  116. package/dist/tasks/embedded.js +4 -5
  117. package/dist/tasks/scheduler-binding.js +2 -2
  118. package/dist/tasks/scheduler-sync-preview.js +8 -1
  119. package/dist/tasks/scheduler-sync.js +19 -10
  120. package/dist/tasks/source/parse-task-source.js +10 -113
  121. package/dist/tasks/source/project-v4.js +2 -2
  122. package/dist/tasks/source/task-source-v4.js +4 -12
  123. package/dist/tasks/source/task-to-v3.js +4 -12
  124. package/dist/tasks/source/task-to-v4.js +40 -7
  125. package/docs/migration/README.md +1 -0
  126. package/docs/migration/release-notes/0.9.15.md +36 -34
  127. package/docs/migration/release-notes/0.9.16.md +60 -98
  128. package/docs/migration/release-notes/README.md +0 -5
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
  130. package/docs/reference/cli.md +124 -122
  131. package/docs/reference/configuration.md +137 -133
  132. package/docs/reference/data-and-telemetry.md +1 -2
  133. package/docs/reference/tasks.md +34 -29
  134. package/package.json +1 -1
  135. package/schemas/akm-config.json +170 -6
  136. package/schemas/akm-task.json +1 -2
  137. package/dist/commands/sources/index-status.js +0 -99
  138. package/dist/core/hash.js +0 -18
  139. package/dist/indexer/drain.js +0 -306
  140. package/dist/indexer/embedding-identity.js +0 -20
  141. package/dist/indexer/enrich.js +0 -260
  142. package/dist/indexer/reconcile.js +0 -890
  143. package/dist/indexer/scan/parse-file.js +0 -66
  144. package/dist/indexer/units/unit.js +0 -159
  145. package/dist/llm/embedders/provider-limits.js +0 -288
  146. package/dist/storage/repositories/files-repository.js +0 -181
  147. package/dist/storage/repositories/units-repository.js +0 -510
@@ -4,80 +4,75 @@
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 { 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";
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";
11
11
  import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
12
+ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
12
13
  import { getDbPath } from "../core/paths.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";
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";
16
21
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
22
+ import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
17
23
  import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.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";
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";
21
27
  import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
22
- import { isVecAvailable } from "../storage/repositories/index-vec-repository.js";
23
- import { dropOtherIdentities, unitCoverage } from "../storage/repositories/units-repository.js";
28
+ import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
24
29
  import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
25
30
  import { deleteStoredGraph } from "./db/graph-db.js";
26
- import { drainEmbeddingQueue } from "./drain.js";
27
- import { deriveInstallations } from "./installations.js";
31
+ import { reclassifyIndexDbContention } from "./index-db-contention.js";
32
+ import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
28
33
  import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
29
- import { reconcileRoots } from "./reconcile.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";
30
39
  import { purgeOldUsageEvents } from "./usage/usage-events.js";
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;
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
+ }
40
51
  function throwIfAborted(signal) {
41
52
  if (signal?.aborted) {
42
53
  throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
43
54
  }
44
55
  }
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
- */
62
56
  export function getDefaultLlmConcurrency(llmConfig) {
63
57
  if (typeof llmConfig?.concurrency === "number")
64
58
  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).
65
69
  return defaultConcurrencyForEndpoint(llmConfig?.endpoint);
66
70
  }
67
- /** Every currently-configured source's bundle id + resolved root, in installation-priority order. */
68
71
  function sourceOwners(sources) {
69
72
  const installations = deriveInstallations([...sources]);
70
73
  return sources.flatMap((source, index) => {
71
74
  const installation = installations[index];
72
- return installation
73
- ? [
74
- {
75
- bundleId: installation.id,
76
- sourceRoot: path.resolve(source.path),
77
- ...(source.unresolved ? { unresolved: true } : {}),
78
- },
79
- ]
80
- : [];
75
+ return installation ? [{ bundleId: installation.id, sourceRoot: path.resolve(source.path) }] : [];
81
76
  });
82
77
  }
83
78
  function parseStoredSourceOwners(raw) {
@@ -104,28 +99,336 @@ function parseStoredSourceOwners(raw) {
104
99
  }
105
100
  }
106
101
  /**
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).
102
+ * Source cache phase: ensure git stash caches are up to date and purge orphaned
103
+ * entries from removed sources (incremental only).
115
104
  */
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);
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.
140
+ */
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);
127
399
  }
128
400
  }
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
+ }
129
432
  /**
130
433
  * Detect an adapter for every resolvable source that does not declare one, and
131
434
  * persist each detection into `config.json`.
@@ -137,6 +440,10 @@ function removeStaleSourceOwners(db, currentOwners) {
137
440
  * emitted here. The map is cleared at the top of every callback invocation
138
441
  * because `mutateConfig` may retry optimistically, and a retry must not report
139
442
  * 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.
140
447
  */
141
448
  function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, opts) {
142
449
  const detectedByBundle = new Map();
@@ -183,10 +490,695 @@ function detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig,
183
490
  }
184
491
  return { config: nextConfig, persistedAdapters };
185
492
  }
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
+ }
186
979
  /**
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.
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.
190
1182
  */
191
1183
  function allSourceRootsReadable(roots) {
192
1184
  for (const root of roots) {
@@ -194,7 +1186,7 @@ function allSourceRootsReadable(roots) {
194
1186
  const st = fs.statSync(root);
195
1187
  if (!st.isDirectory())
196
1188
  return false;
197
- fs.readdirSync(root);
1189
+ fs.readdirSync(root); // probe readability, not just existence
198
1190
  }
199
1191
  catch {
200
1192
  return false;
@@ -202,133 +1194,424 @@ function allSourceRootsReadable(roots) {
202
1194
  }
203
1195
  return true;
204
1196
  }
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
- }
213
1197
  /**
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.
1198
+ * Phase 2 (sync): write all pre-generated scan records inside a single
1199
+ * transaction, returning the directories that still need LLM enrichment.
231
1200
  */
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;
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 };
238
1360
  }
239
- export async function akmIndex(options) {
240
- try {
241
- const override = akmIndexOverride;
242
- return override ? await override(options) : await akmIndexReal(options);
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}`);
243
1386
  }
244
- catch (error) {
245
- const updateDb = options.deferredUpdateTransaction?.db;
246
- if (updateDb?.inTransaction) {
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
+ }
1467
+ 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;
247
1500
  try {
248
- updateDb.exec("ROLLBACK");
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);
249
1515
  }
250
- catch {
251
- // Preserve the indexing error. The update coordinator will retry
252
- // rollback before closing its borrowed unified handle.
1516
+ catch (err) {
1517
+ if (err instanceof ConfigError) {
1518
+ configFailure ??= err;
1519
+ return undefined;
1520
+ }
1521
+ throw err;
253
1522
  }
254
- }
255
- throw reclassifyIndexDbContention(error);
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}`);
256
1570
  }
257
1571
  }
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;
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));
272
1575
  }
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));
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
+ }
1587
+ }
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"}).`;
288
1592
  }
289
1593
  function getEmbeddingProvider(embedding) {
290
1594
  return isHttpUrl(embedding?.endpoint) ? "remote" : "local";
291
1595
  }
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}).`;
1596
+ function getSemanticSearchLabel(semanticSearchMode, embeddingProvider, vecAvailable) {
1597
+ if (semanticSearchMode === "off")
1598
+ return "disabled";
1599
+ return `${embeddingProvider} embeddings, ${vecAvailable ? "sqlite-vec" : "JS fallback"}`;
298
1600
  }
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) {
318
- const embeddingProvider = getEmbeddingProvider(config.embedding);
1601
+ function verifyIndexState(db, config, embeddableEntries, embeddingResult) {
1602
+ const embeddingCount = getEmbeddingCount(db);
319
1603
  const vecAvailable = isVecAvailable(db);
320
- const totalEntries = getEntryCount(db);
321
- const semanticSearchEnabled = config.semanticSearchMode === "auto";
322
- if (totalEntries === 0) {
1604
+ const embeddingProvider = getEmbeddingProvider(config.embedding);
1605
+ if (embeddableEntries === 0) {
323
1606
  return {
324
1607
  ok: true,
325
1608
  message: "Index ready. No assets were found yet.",
326
- semanticSearchEnabled,
1609
+ semanticSearchEnabled: config.semanticSearchMode === "auto",
327
1610
  semanticSearchMode: config.semanticSearchMode,
328
1611
  semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
329
1612
  embeddingProvider,
330
- entryCount: 0,
331
- embeddingCount: 0,
1613
+ entryCount: embeddableEntries,
1614
+ embeddingCount,
332
1615
  vecAvailable,
333
1616
  };
334
1617
  }
@@ -340,310 +1623,190 @@ function buildIndexVerification(db, config, drain, drainFailed = false) {
340
1623
  semanticSearchMode: config.semanticSearchMode,
341
1624
  semanticStatus: "disabled",
342
1625
  embeddingProvider,
343
- entryCount: totalEntries,
344
- embeddingCount: 0,
1626
+ entryCount: embeddableEntries,
1627
+ embeddingCount,
345
1628
  vecAvailable,
346
1629
  };
347
1630
  }
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) {
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);
353
1639
  return {
354
1640
  ok: true,
355
- message: `Semantic search ready (${coverage.unitsPresent}/${coverage.unitsTotal} unit embeddings, sqlite-vec active).`,
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' to repair valid stored vectors or 'akm index --reembed' to regenerate incompatible ones"
1645
+ : "JS fallback active"}).`,
356
1646
  semanticSearchEnabled: true,
357
1647
  semanticSearchMode: config.semanticSearchMode,
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",
1648
+ semanticStatus: vecActive ? "ready-vec" : "ready-js",
377
1649
  embeddingProvider,
378
- entryCount: totalEntries,
379
- embeddingCount: coverage.unitsPresent,
1650
+ entryCount: embeddableEntries,
1651
+ embeddingCount,
380
1652
  vecAvailable,
381
1653
  };
382
1654
  }
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);
388
1655
  return {
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
- : {}),
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.",
400
1662
  semanticSearchEnabled: true,
401
1663
  semanticSearchMode: config.semanticSearchMode,
402
- semanticStatus: madeNoProgress ? "blocked" : "pending",
1664
+ semanticStatus: "blocked",
403
1665
  embeddingProvider,
404
- entryCount: totalEntries,
405
- embeddingCount: coverage.unitsPresent,
1666
+ entryCount: embeddableEntries,
1667
+ embeddingCount,
406
1668
  vecAvailable,
407
1669
  };
408
1670
  }
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 };
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 };
428
1675
  }
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.");
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));
483
1681
  }
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
- });
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
+ }
546
1704
  }
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));
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
+ }
569
1733
  }
570
- }
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.");
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;
583
1753
  }
584
- mutateState(db, deferred.stateSchema);
585
- }
586
- else {
587
- withStateDb(mutateState);
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
+ }));
1776
+ }
1777
+ summary.succeeded++;
1778
+ onEntryDone?.({ entryName: entry.name, outcome: "llm" });
1779
+ return updated;
588
1780
  }
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);
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);
1791
+ }
1792
+ onEntryDone?.({ entryName: entry.name, outcome: "failed" });
1793
+ return entry;
601
1794
  }
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
- }
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 };
646
1806
  }
1807
+ // ── lookup ─────────────────────────────────────────────────────────────────
1808
+ import { makeBundleRef } from "../core/asset/asset-ref.js";
1809
+ import { conceptIdFromTypeName } from "../core/asset/resolve-ref.js";
647
1810
  async function resolveLookupSources() {
648
1811
  const { loadConfig } = await import("../core/config/config.js");
649
1812
  const { resolveSourceEntries } = await import("./search/search-source.js");
@@ -794,7 +1957,7 @@ const USAGE_EVENT_RETENTION_DAYS = 90;
794
1957
  * Also purges usage_events older than 90 days and ensures the M-1
795
1958
  * usage_events table exists before querying.
796
1959
  *
797
- * Called during `akm index` after reconcile.
1960
+ * Called during `akm index` after FTS rebuild.
798
1961
  */
799
1962
  export function recomputeUtilityScores(db, stateDb, options) {
800
1963
  const EMA_DECAY = 0.7;