akm-cli 0.9.2-alpha.1 → 0.9.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +15 -28
  2. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  3. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  4. package/dist/commands/improve/collapse-detector.js +3 -4
  5. package/dist/commands/improve/extract-prompt.js +64 -22
  6. package/dist/commands/improve/extract.js +122 -53
  7. package/dist/commands/read/curate.js +43 -22
  8. package/dist/commands/sources/bundle-cli.js +1 -1
  9. package/dist/commands/sources/installed-stashes.js +67 -26
  10. package/dist/core/adapter/adapters/akm-adapter.js +2 -1
  11. package/dist/core/config/config.js +2 -6
  12. package/dist/core/config/schema/index-config.js +0 -27
  13. package/dist/indexer/index-written-assets.js +17 -9
  14. package/dist/indexer/indexer.js +32 -214
  15. package/dist/indexer/materialize-embeddings.js +155 -0
  16. package/dist/indexer/passes/metadata.js +263 -118
  17. package/dist/indexer/scan/doc-to-entry.js +0 -1
  18. package/dist/indexer/search/db-search.js +58 -28
  19. package/dist/indexer/search/fts-query.js +40 -40
  20. package/dist/indexer/search/ranking.js +36 -1
  21. package/dist/indexer/search/search-attribution.js +3 -1
  22. package/dist/indexer/search/search-fields.js +23 -14
  23. package/dist/output/text/command-format.js +3 -1
  24. package/dist/scripts/akm-migrate-node.js +12892 -12731
  25. package/dist/scripts/akm-migrate.js +12892 -12731
  26. package/dist/storage/repositories/index-entries-repository.js +40 -26
  27. package/dist/storage/repositories/index-entry-schema.js +1 -1
  28. package/dist/storage/repositories/index-fts-repository.js +56 -63
  29. package/dist/storage/repositories/index-schema.js +4 -9
  30. package/dist/storage/repositories/index-vec-repository.js +55 -6
  31. package/docs/reference/cli.md +4 -4
  32. package/docs/reference/configuration.md +6 -9
  33. package/package.json +1 -1
  34. package/schemas/akm-config.json +0 -8
@@ -355,9 +355,19 @@ function buildUpdateResponse(stashDir, target, all, processed, opts) {
355
355
  totalEntries: index.totalEntries,
356
356
  directoriesScanned: index.directoriesScanned,
357
357
  directoriesSkipped: index.directoriesSkipped,
358
+ ...(index.scanComplete !== undefined ? { scanComplete: index.scanComplete } : {}),
358
359
  },
359
360
  };
360
361
  }
362
+ function incompleteFilesystemOutcome(id) {
363
+ return {
364
+ id,
365
+ kind: "filesystem",
366
+ status: "skipped",
367
+ code: "SOURCE_SCAN_INCOMPLETE",
368
+ reason: `Filesystem source "${id}" was not scanned completely; preserving its last-known-good index rows.`,
369
+ };
370
+ }
361
371
  let updateTransactionHookForTests;
362
372
  /** TEST-ONLY. Inject faults at source-update transaction boundaries. */
363
373
  export function _setUpdateTransactionHookForTests(hook) {
@@ -893,6 +903,22 @@ async function updateWebsiteSource(stashDir, target, all, websiteSource, allowIn
893
903
  const synced = await syncWebsitePlainSource(websiteSource, stashDir, allowInsecure);
894
904
  return buildUpdateResponse(stashDir, target, all, [], { plainSynced: [synced.item], index: synced.index });
895
905
  }
906
+ /** Reconcile a filesystem source's current bytes without provider hydration. */
907
+ async function updateFilesystemSource(stashDir, target, all, filesystemSource) {
908
+ const id = filesystemSource.name ?? filesystemSource.path ?? target;
909
+ const ref = filesystemSource.path ?? target;
910
+ const index = await akmIndex({ stashDir, hydrateSources: false });
911
+ if (!index.scanComplete) {
912
+ return buildUpdateResponse(stashDir, target, all, [], {
913
+ skipped: [incompleteFilesystemOutcome(id)],
914
+ index,
915
+ });
916
+ }
917
+ return buildUpdateResponse(stashDir, target, all, [], {
918
+ plainSynced: [{ id, kind: "filesystem", ref }],
919
+ index,
920
+ });
921
+ }
896
922
  /**
897
923
  * A plain (lockless) npm bundle has no deterministic content path — unlike
898
924
  * git/website, resolving an npm package requires a registry round-trip to
@@ -1226,6 +1252,15 @@ export async function akmUpdate(input) {
1226
1252
  const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(npmMatch), force, yes, stashDir, allowInsecure);
1227
1253
  return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
1228
1254
  }
1255
+ const filesystemMatch = stashes.find((source) => {
1256
+ if (source.type !== "filesystem")
1257
+ return false;
1258
+ if (source.name === target)
1259
+ return true;
1260
+ return resolvedPath !== undefined && source.path !== undefined && path.resolve(source.path) === resolvedPath;
1261
+ });
1262
+ if (filesystemMatch)
1263
+ return updateFilesystemSource(stashDir, target, all, filesystemMatch);
1229
1264
  }
1230
1265
  const enabledManagedInstalls = all
1231
1266
  ? managedInstalls.filter((managed) => config.bundles?.[managed.bundleKey]?.enabled !== false)
@@ -1257,6 +1292,7 @@ export async function akmUpdate(input) {
1257
1292
  if (all) {
1258
1293
  const managedKeys = new Set(managedInstalls.map((m) => m.bundleKey));
1259
1294
  const plainSources = getSources(config).filter((source) => source.enabled !== false && !managedKeys.has(source.name ?? ""));
1295
+ const filesystemSources = [];
1260
1296
  for (const plain of plainSources) {
1261
1297
  const id = plain.name ?? plain.path ?? plain.url ?? "";
1262
1298
  try {
@@ -1276,18 +1312,43 @@ export async function akmUpdate(input) {
1276
1312
  latestIndex = updated.index;
1277
1313
  }
1278
1314
  else {
1279
- skipped.push({
1280
- id,
1281
- kind: plain.type,
1282
- status: "skipped",
1283
- reason: "reflects your files in place and has no remote to sync; run `akm index` to refresh the search index.",
1284
- });
1315
+ filesystemSources.push(plain);
1285
1316
  }
1286
1317
  }
1287
1318
  catch (error) {
1288
1319
  skipped.push(updateFailureOutcome(id, plain.type, error));
1289
1320
  }
1290
1321
  }
1322
+ if (filesystemSources.length > 0) {
1323
+ try {
1324
+ // A remote source may have reconciled before a later source finished
1325
+ // hydrating, so an incomplete intermediate result is not authoritative
1326
+ // for the final `--all` outcome. Retry once after every source update;
1327
+ // a genuinely missing filesystem root remains incomplete and is
1328
+ // reported below without claiming reconciliation.
1329
+ if (!latestIndex?.scanComplete)
1330
+ latestIndex = await akmIndex({ stashDir, hydrateSources: false });
1331
+ if (latestIndex.scanComplete) {
1332
+ for (const source of filesystemSources) {
1333
+ plainSynced.push({
1334
+ id: source.name ?? source.path ?? "",
1335
+ kind: "filesystem",
1336
+ ref: source.path ?? source.name ?? "",
1337
+ });
1338
+ }
1339
+ }
1340
+ else {
1341
+ for (const source of filesystemSources) {
1342
+ skipped.push(incompleteFilesystemOutcome(source.name ?? source.path ?? ""));
1343
+ }
1344
+ }
1345
+ }
1346
+ catch (error) {
1347
+ for (const source of filesystemSources) {
1348
+ skipped.push(updateFailureOutcome(source.name ?? source.path ?? "", "filesystem", error));
1349
+ }
1350
+ }
1351
+ }
1291
1352
  }
1292
1353
  return buildUpdateResponse(stashDir, target, all, processed, {
1293
1354
  plainSynced,
@@ -1326,26 +1387,6 @@ function selectManagedTargets(config, installs, target, all) {
1326
1387
  const found = resolveManagedTarget(config, target);
1327
1388
  if (found)
1328
1389
  return [found];
1329
- // Give a helpful message when the target names a plain (non-managed) source.
1330
- const stashes = getSources(config);
1331
- const isUrl = target.startsWith("http://") || target.startsWith("https://");
1332
- const resolvedPath = !isUrl ? path.resolve(target) : undefined;
1333
- const stashMatch = stashes.find((s) => {
1334
- if (isUrl && s.url === target)
1335
- return true;
1336
- if (resolvedPath && s.path && path.resolve(s.path) === resolvedPath)
1337
- return true;
1338
- if (s.name === target)
1339
- return true;
1340
- return false;
1341
- });
1342
- if (stashMatch) {
1343
- if (stashMatch.type === "website") {
1344
- throw new UsageError(`"${target}" is a website source — website caching not yet implemented for --all. ` +
1345
- `Run \`akm bundle update ${target}\` to re-mirror this source individually.`, "TARGET_NOT_UPDATABLE");
1346
- }
1347
- throw new UsageError(`"${target}" is a local directory — it reflects your files in place. To refresh the search index, run: akm index`, "TARGET_NOT_UPDATABLE");
1348
- }
1349
1390
  throw new NotFoundError(`No matching source for target: ${target}`, "SOURCE_NOT_FOUND");
1350
1391
  }
1351
1392
  /**
@@ -179,7 +179,6 @@ const DOCUMENT_JSON_CARRIED_FIELDS = [
179
179
  "whenToUse",
180
180
  "toc",
181
181
  "parameters",
182
- "bodyOpening",
183
182
  "source",
184
183
  "category",
185
184
  "supersededBy",
@@ -222,6 +221,8 @@ function indexDocumentFromEntry(entry, base, rendererName) {
222
221
  doc.description = entry.description;
223
222
  if (entry.tags !== undefined)
224
223
  doc.tags = entry.tags;
224
+ if (entry.content !== undefined)
225
+ doc.content = entry.content;
225
226
  if (entry.aliases !== undefined)
226
227
  doc.aliases = entry.aliases;
227
228
  if (entry.searchHints !== undefined)
@@ -347,12 +347,8 @@ export function resolveSecret(value) {
347
347
  * filtering out the reserved feature-section keys so callers don't mistake
348
348
  * `metadataEnhance` for a pass.
349
349
  */
350
- /**
351
- * Reserved well-known keys on IndexConfig that are NOT per-pass entries.
352
- * `indexBodyOpening` (stash-conventions SPEC-8) is a boolean feature flag, not
353
- * a pass section.
354
- */
355
- const INDEX_RESERVED_KEYS = new Set(["metadataEnhance", "indexBodyOpening"]);
350
+ /** Reserved well-known keys on IndexConfig that are NOT per-pass entries. */
351
+ const INDEX_RESERVED_KEYS = new Set(["metadataEnhance"]);
356
352
  export function getIndexPassConfig(config, passName) {
357
353
  if (!config)
358
354
  return undefined;
@@ -99,14 +99,6 @@ const IndexDefaultsSchema = z
99
99
  * Index config is a union of reserved feature sections and per-pass entries.
100
100
  * Passthrough so per-pass entries (keyed by arbitrary pass names like `graph`,
101
101
  * `enrichment`) can live next to the reserved keys.
102
- *
103
- * Reserved scalar key `indexBodyOpening` (stash-conventions SPEC-8, default
104
- * false): when true, the metadata pass captures the first prose paragraph of
105
- * each markdown asset body into `entry.bodyOpening`, which folds into the
106
- * lowest-weight `content` FTS column and the embedding text. It is a boolean,
107
- * not a per-pass object — the preprocess below exempts it from the
108
- * object-shape check so it never routes into the per-pass catchall.
109
- *
110
102
  * The outer preprocess emits the legacy parser's actionable error messages
111
103
  * for the two most common type-shape mistakes:
112
104
  * - An array at the `index` block.
@@ -136,17 +128,6 @@ const IndexConfigRuntimeSchema = z.preprocess((raw, ctx) => {
136
128
  });
137
129
  return raw;
138
130
  }
139
- if (passName === "indexBodyOpening") {
140
- if (typeof value !== "boolean") {
141
- ctx.addIssue({
142
- code: z.ZodIssueCode.custom,
143
- message: "Invalid `index.indexBodyOpening`: expected a boolean (true to index the first body paragraph " +
144
- `of markdown assets into search). Got ${Array.isArray(value) ? "array" : typeof value}.`,
145
- });
146
- return raw;
147
- }
148
- continue;
149
- }
150
131
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
151
132
  ctx.addIssue({
152
133
  code: z.ZodIssueCode.custom,
@@ -160,14 +141,6 @@ const IndexConfigRuntimeSchema = z.preprocess((raw, ctx) => {
160
141
  .object({
161
142
  defaults: IndexDefaultsSchema.optional(),
162
143
  metadataEnhance: MetadataEnhanceSchema.optional(),
163
- indexBodyOpening: z
164
- .boolean()
165
- .optional()
166
- .describe("Index the first prose paragraph of each markdown asset body (capped at 280 chars) into the " +
167
- "lowest-weight `content` search column and the embedding text (default false). Secret/env files " +
168
- "and session-kind memories are never captured. Toggling the flag changes indexed text: run " +
169
- "`akm index --full` afterwards to re-extract every entry and regenerate embeddings, and re-mint " +
170
- "collapse-detector canary baselines via `bun scripts/refresh-canary-set.ts --refresh`."),
171
144
  })
172
145
  .catchall(IndexPassConfigSchema));
173
146
  // The runtime catchall correctly validates arbitrary pass objects, but its
@@ -13,24 +13,25 @@
13
13
  * the 2026-07 read-path reindex-contention findings §7).
14
14
  *
15
15
  * This is NOT a general reindex. It upserts exactly the files the caller just
16
- * wrote: frontmatter/metadata via the shared matcher pipeline, the `entries`
17
- * row, and an incremental FTS refresh. Embeddings, index-time LLM passes,
18
- * graph extraction, `builtAt`, and the per-dir walk cache are all deliberately
19
- * untouched the next full run heals them (the opportunistic-recovery
20
- * strategy of the index-consistency ADR).
16
+ * wrote: frontmatter/metadata via the shared matcher pipeline, the canonical
17
+ * row with its transactionally owned FTS projection, and vectors for changed
18
+ * entry IDs when semantic search is enabled. Index-time LLM passes, graph
19
+ * extraction, `builtAt`, and the per-dir walk cache remain full-index
20
+ * responsibilities.
21
21
  */
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { akmAdapter } from "../core/adapter/adapters/akm-adapter.js";
25
+ import { loadConfig } from "../core/config/config.js";
25
26
  import { isDataDirUnreadableError } from "../core/errors.js";
26
27
  import { isPathAbsent } from "../core/path-access.js";
27
28
  import { getDbPath } from "../core/paths.js";
28
29
  import { warn, warnVerbose } from "../core/warn.js";
29
30
  import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
30
31
  import { deleteEntriesByIds, getEntryCount, upsertEntry } from "../storage/repositories/index-entries-repository.js";
31
- import { rebuildFts } from "../storage/repositories/index-fts-repository.js";
32
32
  import { withIndexWriterLease } from "./index-writer-lock.js";
33
33
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
34
+ import { generateEmbeddingsForDb, publishTargetedSemanticStatus } from "./materialize-embeddings.js";
34
35
  import { drainDirDocuments } from "./scan/drain-dir.js";
35
36
  import { buildSearchText } from "./search/search-fields.js";
36
37
  import { buildFileContext } from "./walk/file-context.js";
@@ -127,6 +128,8 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
127
128
  db.exec(`PRAGMA busy_timeout = ${WRITE_PATH_INDEX_BUSY_TIMEOUT_MS}`);
128
129
  if (getEntryCount(db) === 0)
129
130
  return true;
131
+ const targetEntryIds = new Set();
132
+ let mutated = false;
130
133
  db.transaction(() => {
131
134
  const unindexableEntryIds = new Set();
132
135
  for (const file of unindexable) {
@@ -151,6 +154,7 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
151
154
  unindexableEntryIds.add(row.id);
152
155
  }
153
156
  deleteEntriesByIds(db, [...unindexableEntryIds]);
157
+ mutated ||= unindexableEntryIds.size > 0;
154
158
  for (const { file, entry, conceptId, contentHash } of pairs) {
155
159
  let entryWithSize = entry;
156
160
  try {
@@ -171,11 +175,15 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
171
175
  .prepare("SELECT id FROM entries WHERE file_path = ? AND item_ref <> ?")
172
176
  .all(file, provenance.itemRef);
173
177
  deleteEntriesByIds(db, supersededIds.map((row) => row.id));
174
- upsertEntry(db, file, entryWithSize, buildSearchText(entry), provenance, contentHash);
178
+ targetEntryIds.add(upsertEntry(db, file, entryWithSize, buildSearchText(entry), provenance, contentHash));
179
+ mutated = true;
175
180
  }
176
- if (pairs.length > 0 || unindexable.size > 0)
177
- rebuildFts(db, { incremental: true });
178
181
  })();
182
+ if (mutated) {
183
+ const config = loadConfig();
184
+ const embeddingResult = await generateEmbeddingsForDb(db, config, () => { }, undefined, [...targetEntryIds]);
185
+ publishTargetedSemanticStatus(db, config, embeddingResult);
186
+ }
179
187
  }
180
188
  finally {
181
189
  closeDatabase(db);
@@ -20,22 +20,22 @@ import { resolveIndexPassExecution } from "../llm/index-passes.js";
20
20
  import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
21
21
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
22
22
  import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
23
- import { deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
24
- import { rebuildFts } from "../storage/repositories/index-fts-repository.js";
23
+ import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
25
24
  import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
26
- import { deleteIndexDirState, deleteMeta, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
25
+ import { deleteIndexDirState, getMeta, setMeta, upsertIndexDirState, } from "../storage/repositories/index-meta-repository.js";
27
26
  import { upsertUtilityScore } from "../storage/repositories/index-utility-repository.js";
28
- import { getAllEntriesForEmbedding, getEmbeddingCount, isVecAvailable, isVecFastPathReady, purgeEmbeddings, setVecFastPathReady, upsertEmbedding, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
27
+ import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
29
28
  import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
30
29
  import { deleteStoredGraph } from "./db/graph-db.js";
31
30
  import { withIndexWriterLease } from "./index-writer-lock.js";
32
31
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
33
32
  import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
33
+ import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
34
34
  import { canUseIncrementalSkip, computeDirFingerprint, getCachedZeroRowDirState, getDirIndexState, inferZeroRowReason, } from "./passes/dir-staleness.js";
35
35
  import { isEnrichmentComplete, isWorkflowSkipWarning } from "./passes/metadata.js";
36
36
  import { drainDirDocuments } from "./scan/drain-dir.js";
37
37
  import { buildSearchText } from "./search/search-fields.js";
38
- import { classifySemanticFailure, clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "./search/semantic-status.js";
38
+ import { clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus } from "./search/semantic-status.js";
39
39
  import { purgeOldUsageEvents } from "./usage/usage-events.js";
40
40
  import { walkStashFlatWithStatus } from "./walk/walker.js";
41
41
  function collectLoweringNotices(target, notices) {
@@ -205,19 +205,18 @@ async function runEmbeddingPhase(ctx) {
205
205
  ctx.timing.tEmbedEnd = Date.now();
206
206
  }
207
207
  /**
208
- * Finalize phase: rebuild FTS, re-link usage events, recompute utility scores,
209
- * regenerate wiki indexes, update index metadata, and emit the verify event.
208
+ * Finalize phase: confirm transactionally materialized FTS state, re-link
209
+ * usage events, recompute utility scores, update index metadata, and emit the
210
+ * verify event.
210
211
  */
211
212
  async function runFinalizePhase(ctx, deferredUpdateTransaction) {
212
213
  const { db, config, sources, sourceDirs, isIncremental, stashDir, signal, onProgress } = ctx;
213
214
  ctx.timing.tFinalizeStart = Date.now();
214
- // Rebuild FTS after all inserts. Use incremental mode when this whole
215
- // index run is incremental only entries touched by `upsertEntry`
216
- // since the last rebuild are re-indexed.
217
- rebuildFts(db, { incremental: isIncremental });
215
+ // `upsertEntry` and every canonical delete own their FTS projection. This is
216
+ // an observation point, not a second materialization pass.
218
217
  onProgress({
219
218
  phase: "fts",
220
- message: isIncremental ? "Rebuilt full-text search index (dirty rows only)." : "Rebuilt full-text search index.",
219
+ message: "Full-text search index is current.",
221
220
  });
222
221
  ctx.timing.tFtsEnd = Date.now();
223
222
  // Re-link state.db usage events to the regenerated index and recompute the
@@ -261,11 +260,6 @@ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
261
260
  setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
262
261
  }
263
262
  setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
264
- // Stash-organization conventions (SPEC-8): track which `index.indexBodyOpening`
265
- // state the index was built with, and warn while the flag diverges from it.
266
- const bodyOpeningWarning = reconcileBodyOpeningIndexState(db, config.index?.indexBodyOpening === true, (ctx.full || !isIncremental) && ctx.scanComplete);
267
- if (bodyOpeningWarning)
268
- warn(bodyOpeningWarning);
269
263
  warnIfVecMissing(db);
270
264
  const totalEntries = getEntryCount(db);
271
265
  const semanticEntryCount = getEmbeddableEntryCount(db);
@@ -300,45 +294,10 @@ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
300
294
  // suppress unused warning — sources was previously used inline
301
295
  void sources;
302
296
  }
303
- /**
304
- * Stash-organization conventions (SPEC-8): reconcile the `index.indexBodyOpening`
305
- * flag with the state the index was last FULLY built with (index_meta key
306
- * `indexBodyOpening`), returning a warning message while they diverge.
307
- *
308
- * Incremental runs re-extract only changed files (and embeddings are only
309
- * generated for rows lacking one), so a flag toggle leaves the index MIXED
310
- * until a full rebuild — `akm index --full` re-extracts every entry and wipes
311
- * embeddings so they regenerate from the new text. The warning therefore
312
- * repeats on every incremental run until a full walk records the flag state
313
- * as applied.
314
- *
315
- * A missing meta key on an incremental run means the index predates this
316
- * feature, i.e. it was necessarily built with the flag OFF — so the absent
317
- * key reads (and is seeded) as "0", never as the current flag value. This
318
- * keeps the most likely real toggle scenario — upgrade, enable the flag, run
319
- * a plain `akm index` — warning until `--full` runs (review finding).
320
- *
321
- * Exported for tests; production's only caller is the finalize phase above.
322
- */
323
- export function reconcileBodyOpeningIndexState(db, flagEnabled, isFullWalk) {
324
- const bodyOpeningFlag = flagEnabled ? "1" : "0";
325
- const prevBodyOpeningFlag = getMeta(db, "indexBodyOpening") ?? "0";
326
- // Only a full walk (which includes the first build ever) may record the
327
- // current flag as the applied state; incremental runs preserve — or, for a
328
- // pre-feature index, seed — the state of the last full build.
329
- setMeta(db, "indexBodyOpening", isFullWalk ? bodyOpeningFlag : prevBodyOpeningFlag);
330
- if (isFullWalk || prevBodyOpeningFlag === bodyOpeningFlag)
331
- return undefined;
332
- return (`index.indexBodyOpening is ${flagEnabled ? "enabled" : "disabled"} but the index was built with it ` +
333
- `${flagEnabled ? "disabled" : "enabled"}. Incremental runs only re-extract changed files, so ` +
334
- "indexed text and embeddings are stale for unchanged entries. Run `akm index --full` to apply the new " +
335
- "setting everywhere (embeddings regenerate), and re-mint collapse-detector canary baselines via " +
336
- "`bun scripts/refresh-canary-set.ts --refresh` if you use them.");
337
- }
338
297
  // ── Clean pass ───────────────────────────────────────────────────────────────
339
298
  /**
340
- * Post-index clean pass: scan the `entries` table for rows whose source file
341
- * no longer exists on disk and remove them (unless `dryRun` is true).
299
+ * Missing-file reconciliation: scan the `entries` table for rows whose source
300
+ * file no longer exists on disk and remove them (unless `dryRun` is true).
342
301
  *
343
302
  * Only rows with a non-empty `file_path` are checked — remote/virtual entries
344
303
  * that have no local path are always skipped.
@@ -629,22 +588,17 @@ async function akmIndexReal(options) {
629
588
  vecAvailable: isVecAvailable(db),
630
589
  }),
631
590
  });
591
+ let cleanResult;
592
+ let cleanStart = Date.now();
593
+ let cleanEnd = cleanStart;
632
594
  // ── Phase sequence ───────────────────────────────────────────────────────
633
595
  await runSourceCachePhase(ctx);
634
596
  await runWalkPhase(ctx);
635
597
  applyRemovedSources(ctx);
636
- await runEmbeddingPhase(ctx);
637
- await runFinalizePhase(ctx, options.deferredUpdateTransaction);
638
- // ────────────────────────────────────────────────────────────────────────
639
- // runFinalizePhase always populates these before returning.
640
- const verification = ctx.verification;
641
- const totalEntries = ctx.totalEntries;
642
- const { timing } = ctx;
643
- // ── Clean pass ───────────────────────────────────────────────────────────
644
- // After the normal index completes, remove entries whose source files no
645
- // longer exist on disk. Remote entries (empty file_path) are skipped.
646
- let cleanResult;
647
- const cleanStart = Date.now();
598
+ // Reconcile explicit missing-file cleanup before embeddings, totals, or
599
+ // verification describe this generation. Dry-run intentionally leaves
600
+ // the generation unchanged while still returning the previewed refs.
601
+ cleanStart = Date.now();
648
602
  if (clean) {
649
603
  onProgress({
650
604
  phase: "finalize",
@@ -658,8 +612,14 @@ async function akmIndexReal(options) {
658
612
  cleanResult = { checked: 0, removed: 0, removedRefs: [], dryRun };
659
613
  }
660
614
  }
661
- const cleanEnd = Date.now();
615
+ cleanEnd = Date.now();
616
+ await runEmbeddingPhase(ctx);
617
+ await runFinalizePhase(ctx, options.deferredUpdateTransaction);
662
618
  // ────────────────────────────────────────────────────────────────────────
619
+ // runFinalizePhase always populates these before returning.
620
+ const verification = ctx.verification;
621
+ const totalEntries = ctx.totalEntries;
622
+ const { timing } = ctx;
663
623
  return {
664
624
  stashDir,
665
625
  totalEntries,
@@ -668,6 +628,7 @@ async function akmIndexReal(options) {
668
628
  mode: ctx.isIncremental ? "incremental" : "full",
669
629
  directoriesScanned: ctx.scannedDirs,
670
630
  directoriesSkipped: ctx.skippedDirs,
631
+ scanComplete: ctx.scanComplete,
671
632
  ...(ctx.walkWarnings.length > 0 ? { warnings: ctx.walkWarnings } : {}),
672
633
  ...(ctx.loweringNotices.length > 0 ? { notices: Object.freeze([...ctx.loweringNotices]) } : {}),
673
634
  ...(Object.keys(persistedAdapters).length > 0
@@ -1151,28 +1112,15 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1151
1112
  // transaction so delete and re-insert are atomic — a concurrent reader
1152
1113
  // never observes an empty database between the two operations.
1153
1114
  if (fullDelete) {
1154
- try {
1155
- db.exec("DELETE FROM embeddings");
1156
- }
1157
- catch {
1158
- /* ignore */
1159
- }
1160
- if (isVecAvailable(db)) {
1161
- try {
1162
- db.exec("DELETE FROM entries_vec");
1163
- }
1164
- catch {
1165
- /* ignore */
1166
- }
1167
- }
1168
- db.exec("DELETE FROM entries_fts");
1169
- db.exec("DELETE FROM utility_scores");
1115
+ // Entries and every child materialization share one deletion authority.
1116
+ // Usage events live in state.db and survive so finalize can relink them
1117
+ // to the replacement generation's row ids.
1118
+ deleteAllEntries(db, { cleanupUsageEvents: false });
1170
1119
  db.exec("DELETE FROM index_dir_state");
1171
1120
  // Chunk-8 WI-8.3: usage_events lives in state.db now (not index.db), so the
1172
1121
  // wipe no longer detaches it here. The finalize pass's relinkUsageEvents
1173
1122
  // (cross-DB) nulls entry_ids that no longer resolve to a rebuilt entry and
1174
1123
  // re-resolves the rest by entry_ref — subsuming the old detach.
1175
- db.exec("DELETE FROM entries");
1176
1124
  // Atomicity observation point: inside the transaction the tables are now
1177
1125
  // empty, but no other connection may observe that. See
1178
1126
  // tests/integration/indexer/reindex-generation-atomicity.test.ts.
@@ -1506,136 +1454,6 @@ export function createEnrichmentDeadline(timeoutMs, totalEntries) {
1506
1454
  const perEntryTimeoutMs = timeoutMs === undefined ? 10 * 60 * 1000 : timeoutMs;
1507
1455
  return perEntryTimeoutMs === null ? undefined : AbortSignal.timeout(perEntryTimeoutMs * Math.max(totalEntries, 1));
1508
1456
  }
1509
- async function generateEmbeddingsForDb(db, config, onProgress, signal) {
1510
- throwIfAborted(signal);
1511
- if (config.semanticSearchMode === "off") {
1512
- onProgress({ phase: "embeddings", message: "Semantic search disabled; skipping embeddings." });
1513
- return { success: false, reason: "index-missing", message: "Semantic search is disabled." };
1514
- }
1515
- // Detect embedding model/provider changes and purge stale embeddings
1516
- // so that incremental reindex regenerates all vectors with the new model.
1517
- const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
1518
- const storedFingerprint = getMeta(db, "embeddingFingerprint");
1519
- if (storedFingerprint && storedFingerprint !== currentFingerprint) {
1520
- // Model/provider changed → stored vectors are incompatible. Clear them;
1521
- // re-embedded by this index run.
1522
- //
1523
- // The vec table goes too. "Same dimension, so keep the vec table" only held
1524
- // for a same-width model swap: entries_vec is a vec0 virtual table declared
1525
- // at a FIXED width, so after a dimension-changing model change every insert
1526
- // failed against the old width, and ensureSchema's dim-change rebuild never
1527
- // fired because it only runs for callers that pass an explicit
1528
- // embeddingDim. The stale table survived `--full` — the exact remedy the
1529
- // warning recommended. Clearing the stored dim lets the next ensureSchema
1530
- // materialize it at the new width; until then the fast-path flag reads
1531
- // false (no table) and search uses the complete BLOB table.
1532
- purgeEmbeddings(db, { dropVecTable: true });
1533
- deleteMeta(db, "embeddingDim");
1534
- }
1535
- try {
1536
- const { embedBatch } = await import("../llm/embedder.js");
1537
- const { estimateTokenCount } = await import("../llm/embedders/remote.js");
1538
- throwIfAborted(signal);
1539
- const allEntries = getAllEntriesForEmbedding(db);
1540
- if (allEntries.length === 0) {
1541
- onProgress({ phase: "embeddings", message: "Embeddings already up to date." });
1542
- setMeta(db, "embeddingFingerprint", currentFingerprint);
1543
- return { success: true };
1544
- }
1545
- onProgress({
1546
- phase: "embeddings",
1547
- message: `Generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}.`,
1548
- });
1549
- const texts = allEntries.map((e) => e.searchText);
1550
- // Verbose: log each document before it is sent to the embedding API so
1551
- // operators can see exactly where embedding fails without waiting for an error.
1552
- if (isVerbose()) {
1553
- const EMBED_BATCH_SIZE = 100; // mirrors REMOTE_BATCH_SIZE in remote.ts
1554
- const totalBatches = Math.ceil(texts.length / EMBED_BATCH_SIZE);
1555
- for (let i = 0; i < texts.length; i++) {
1556
- const batchNum = Math.floor(i / EMBED_BATCH_SIZE) + 1;
1557
- const chars = texts[i].length;
1558
- const tokens = estimateTokenCount(texts[i]);
1559
- const ref = allEntries[i].itemRef;
1560
- warnVerbose(`[embed] ${ref} (${chars} chars, est. ${tokens} tokens) → batch ${batchNum}/${totalBatches}`);
1561
- }
1562
- }
1563
- let heartbeatTimer;
1564
- try {
1565
- heartbeatTimer = setInterval(() => {
1566
- onProgress({
1567
- phase: "embeddings",
1568
- message: `Still generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}; waiting on embedding provider.`,
1569
- });
1570
- }, 15000);
1571
- const embeddings = await embedBatch(texts, config.embedding, signal);
1572
- throwIfAborted(signal);
1573
- // Wrap all embedding upserts in a single transaction so partial
1574
- // state is rolled back on failure rather than leaving the table half-filled.
1575
- let storedCount = 0;
1576
- let skippedCount = 0;
1577
- let vecFailedCount = 0;
1578
- let vecUnavailableCount = 0;
1579
- db.transaction(() => {
1580
- for (let i = 0; i < allEntries.length; i++) {
1581
- const res = upsertEmbedding(db, allEntries[i].id, embeddings[i]);
1582
- if (res.stored) {
1583
- storedCount++;
1584
- }
1585
- else {
1586
- skippedCount++;
1587
- }
1588
- if (res.vec === "failed")
1589
- vecFailedCount++;
1590
- if (res.vec === "unavailable")
1591
- vecUnavailableCount++;
1592
- }
1593
- })();
1594
- if (skippedCount > 0) {
1595
- warn(`[embed] ${skippedCount} embedding${skippedCount === 1 ? "" : "s"} skipped (entry deleted between queue and write)`);
1596
- }
1597
- // Record the ACTUAL vec-insert outcome so semantic search reflects it
1598
- // instead of inferring readiness from stored-BLOB counts. Any failure
1599
- // marks the fast path degraded, routing search to the JS-cosine fallback
1600
- // over the (complete) BLOB table — honest degradation, not a hard failure.
1601
- //
1602
- // 'unavailable' has to degrade the flag too. It means no vec row was
1603
- // written at all, so marking the fast path ready left a later open (a
1604
- // different runtime, or sqlite-vec installed afterwards) trusting an
1605
- // empty entries_vec and returning zero semantic hits against a fully
1606
- // populated BLOB table.
1607
- setVecFastPathReady(db, vecFailedCount === 0 && vecUnavailableCount === 0);
1608
- if (vecFailedCount > 0) {
1609
- warn(`[embed] ${vecFailedCount} sqlite-vec fast-path insert${vecFailedCount === 1 ? "" : "s"} failed — ` +
1610
- "semantic search will use the slower JS-cosine fallback over stored embeddings. " +
1611
- "Rebuild with 'akm index --full' after resolving the vec table (often a vector-dimension mismatch).");
1612
- }
1613
- onProgress({
1614
- phase: "embeddings",
1615
- message: `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"}.`,
1616
- });
1617
- setMeta(db, "embeddingFingerprint", currentFingerprint);
1618
- return { success: true, vecInsertFailures: vecFailedCount };
1619
- }
1620
- finally {
1621
- if (heartbeatTimer)
1622
- clearInterval(heartbeatTimer);
1623
- }
1624
- }
1625
- catch (error) {
1626
- const message = error instanceof Error ? error.message : String(error);
1627
- warn("Embedding generation failed, continuing without:", message);
1628
- onProgress({
1629
- phase: "embeddings",
1630
- message: `Embedding generation failed: ${message}`,
1631
- });
1632
- return {
1633
- success: false,
1634
- reason: classifySemanticFailure(message),
1635
- message: `Semantic search verification failed: ${message}`,
1636
- };
1637
- }
1638
- }
1639
1457
  // ── Helpers ─────────────────────────────────────────────────────────────────
1640
1458
  function attachFileSize(entry, entryPath) {
1641
1459
  try {