akm-cli 0.9.15 → 0.9.16-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  3. package/dist/cli/retired-commands.js +2 -0
  4. package/dist/cli/unknown-flags.js +36 -3
  5. package/dist/commands/improve/collapse-detector.js +2 -2
  6. package/dist/commands/improve/consolidate.js +6 -4
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/proposal/repository.js +12 -3
  9. package/dist/commands/read/curate.js +34 -44
  10. package/dist/commands/read/search.js +50 -2
  11. package/dist/commands/sources/index-status.js +99 -0
  12. package/dist/commands/sources/info.js +8 -8
  13. package/dist/commands/sources/installed-stashes.js +33 -12
  14. package/dist/commands/sources/source-add.js +21 -6
  15. package/dist/commands/sources/stash-cli.js +119 -111
  16. package/dist/core/adapter/adapters/akm-adapter.js +35 -3
  17. package/dist/core/adapter/adapters/akm-metadata.js +11 -1
  18. package/dist/core/asset/asset-placement.js +35 -0
  19. package/dist/core/config/schema/embedding.js +7 -30
  20. package/dist/core/config/schema/search.js +11 -9
  21. package/dist/core/errors.js +5 -2
  22. package/dist/core/hash.js +18 -0
  23. package/dist/core/maintenance-barrier.js +8 -6
  24. package/dist/core/paths.js +0 -11
  25. package/dist/core/run-lock.js +5 -2
  26. package/dist/core/state/migrations.js +26 -1
  27. package/dist/core/state-db.js +63 -27
  28. package/dist/indexer/drain.js +306 -0
  29. package/dist/indexer/embedding-identity.js +20 -0
  30. package/dist/indexer/enrich.js +260 -0
  31. package/dist/indexer/ensure-index.js +5 -0
  32. package/dist/indexer/index-written-assets.js +133 -171
  33. package/dist/indexer/indexer.js +458 -1621
  34. package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
  35. package/dist/indexer/passes/metadata.js +18 -1
  36. package/dist/indexer/reconcile.js +890 -0
  37. package/dist/indexer/scan/drain-dir.js +27 -70
  38. package/dist/indexer/scan/parse-file.js +66 -0
  39. package/dist/indexer/search/db-search.js +373 -89
  40. package/dist/indexer/search/ranking-contributors.js +21 -16
  41. package/dist/indexer/search/ranking.js +135 -57
  42. package/dist/indexer/units/unit.js +159 -0
  43. package/dist/llm/client.js +10 -1
  44. package/dist/llm/embedder.js +10 -3
  45. package/dist/llm/embedders/provider-limits.js +288 -0
  46. package/dist/llm/embedders/remote.js +133 -104
  47. package/dist/llm/feature-gate.js +4 -2
  48. package/dist/llm/rerank-client.js +3 -3
  49. package/dist/output/shapes/passthrough.js +1 -0
  50. package/dist/output/text/command-format.js +19 -13
  51. package/dist/output/text/helpers.js +1 -1
  52. package/dist/output/text/index.js +5 -2
  53. package/dist/scripts/akm-migrate-node.js +1141 -1237
  54. package/dist/scripts/akm-migrate.js +1141 -1237
  55. package/dist/setup/semantic-assets.js +2 -2
  56. package/dist/setup/steps/connection.js +3 -2
  57. package/dist/storage/repositories/files-repository.js +181 -0
  58. package/dist/storage/repositories/index-connection.js +1 -3
  59. package/dist/storage/repositories/index-entries-repository.js +77 -68
  60. package/dist/storage/repositories/index-entry-schema.js +16 -25
  61. package/dist/storage/repositories/index-fts-repository.js +29 -263
  62. package/dist/storage/repositories/index-meta-repository.js +0 -29
  63. package/dist/storage/repositories/index-schema.js +115 -122
  64. package/dist/storage/repositories/index-utility-repository.js +1 -1
  65. package/dist/storage/repositories/index-vec-repository.js +21 -334
  66. package/dist/storage/repositories/units-repository.js +510 -0
  67. package/docs/migration/release-notes/0.9.15.md +34 -36
  68. package/docs/migration/release-notes/0.9.16.md +110 -0
  69. package/docs/migration/release-notes/README.md +5 -0
  70. package/docs/reference/cli.md +93 -87
  71. package/docs/reference/configuration.md +128 -89
  72. package/docs/reference/data-and-telemetry.md +2 -1
  73. package/package.json +1 -1
  74. package/schemas/akm-config.json +2 -58
  75. package/dist/indexer/index-db-contention.js +0 -56
  76. package/dist/indexer/index-rebuild-lock.js +0 -73
  77. package/dist/indexer/materialize-embeddings.js +0 -771
  78. package/dist/indexer/passes/dir-staleness.js +0 -161
  79. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
@@ -0,0 +1,890 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The reconcile engine (docs/plans/index-redesign-contract.md, B1).
6
+ *
7
+ * Replaces the incremental dir-staleness pass (`passes/dir-staleness.ts`) and
8
+ * the directory-fingerprint walk in `indexer.ts` with a flat, file-level
9
+ * diff: stat every file under a root, hash and re-parse only the ones whose
10
+ * `(size, mtime)` moved or are new, and delete whichever previously-tracked
11
+ * paths are gone. `files` (`storage/repositories/files-repository.ts`) is
12
+ * the stat cache the diff runs against. Idempotent — a second run with
13
+ * nothing changed on disk touches no row.
14
+ *
15
+ * Per changed file: parse it with the SAME per-file parse the full-directory
16
+ * drain uses (`scan/parse-file.ts`'s `parseFileDocument`, shared so the two
17
+ * never drift), upsert `entries` (`content_hash` = the parsed blob hash),
18
+ * derive its embedding units (A1's `deriveUnits`, `units/unit.ts`) bounded by
19
+ * the provider's real window (`unitMaxChars(probeProviderLimits(...))`,
20
+ * probed once and cached for the whole run), write any new unit texts, and
21
+ * point `entry_units` at them (A2's `replaceEntryUnits`). Because units are
22
+ * content-addressed, `unit_texts`/`units_fts` never gain a duplicate row for
23
+ * a hash already stored — `INSERT OR IGNORE` skips it.
24
+ *
25
+ * `reconcileRoots` additionally recognizes a same-bundle rename: a gone path
26
+ * whose last known `blob_hash` matches a changed/new path's freshly-parsed
27
+ * hash is re-pointed with ONE `UPDATE` of the existing `entries` row
28
+ * (`repointEntry`) rather than a delete-then-insert, so the row keeps its
29
+ * `id` (and anything keyed off it, e.g. usage history) across the move. Note
30
+ * on "no re-derive": the akm adapter's canonical name — and therefore every
31
+ * unit's header line — is derived from the file's path (`akm-adapter.ts`,
32
+ * "NOT the frontmatter title"), so a rename that changes the name still
33
+ * changes every unit's hash; "no re-derive" here means the row is updated in
34
+ * place (not deleted and re-inserted under a fresh id) and its `content_hash`
35
+ * is the SAME blob hash already produced by the one parse this file went
36
+ * through, never independently recomputed. Content-addressing (INSERT OR
37
+ * IGNORE) still means a fragment whose text is unchanged after the header is
38
+ * rebuilt never becomes a distinct stored row versus an unrelated edit that
39
+ * happens to produce the same fragment text elsewhere.
40
+ *
41
+ * Every per-file write is its own short `BEGIN IMMEDIATE` transaction
42
+ * (`core/state-db.ts`'s `withImmediateTransaction`, which already retries on
43
+ * contention) — never one transaction for the whole run — so two reconciles
44
+ * racing the same root serialize file-by-file and converge on the same end
45
+ * state instead of one clobbering the other's snapshot.
46
+ */
47
+ import fs from "node:fs";
48
+ import path from "node:path";
49
+ import { hashContent } from "../core/adapter/adapters/shared.js";
50
+ import { adapterForId } from "../core/adapter/registry.js";
51
+ import { compareCodePoints } from "../core/common.js";
52
+ import { loadConfig } from "../core/config/config.js";
53
+ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
54
+ import { canonicalizeWorkflowName } from "../core/recognition-util.js";
55
+ import { withImmediateTransaction } from "../core/state-db.js";
56
+ import { isVerbose, warn, warnOnce } from "../core/warn.js";
57
+ import { probeProviderLimits, unitMaxChars } from "../llm/embedders/provider-limits.js";
58
+ import { deleteFileStates, getFileState, getFileStatesByBundle, insertNewUnitTexts, pruneOrphanUnitTexts, pruneOrphanUnitTextsForHashes, upsertFileState, } from "../storage/repositories/files-repository.js";
59
+ import { deleteEntriesByIds, upsertEntry } from "../storage/repositories/index-entries-repository.js";
60
+ import { replaceFragmentSource } from "../storage/repositories/index-fts-repository.js";
61
+ import { replaceEntryUnits } from "../storage/repositories/units-repository.js";
62
+ import { resolveWorkflowSourceDomains, workflowNameForSourcePath } from "../workflows/source-files.js";
63
+ import { enrichReconciledEntries } from "./enrich.js";
64
+ import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
65
+ import { getMarkdownFragmentContent, hasMarkdownFragmentContent, isWorkflowSkipWarning, setMarkdownFragmentContent, } from "./passes/metadata.js";
66
+ import { parseFileDocument } from "./scan/parse-file.js";
67
+ import { buildSearchText } from "./search/search-fields.js";
68
+ import { resolveSourceEntries } from "./search/search-source.js";
69
+ import { deriveUnits, toUnitSource } from "./units/unit.js";
70
+ import { buildFileContext } from "./walk/file-context.js";
71
+ import { walkStashFlatWithStatus } from "./walk/walker.js";
72
+ // ── Public API ──────────────────────────────────────────────────────────────
73
+ /**
74
+ * Prefix of the per-root "done with this root" progress line
75
+ * (`"${RECONCILE_ROOT_PROGRESS_PREFIX}<path>": N files scanned."`), one per
76
+ * root reconciled. Exported so a caller juggling several `onProgress`
77
+ * sources (`stash-cli.ts`'s `akm index`) can recognize — and, outside
78
+ * `--verbose`, suppress — this specific high-frequency line by prefix rather
79
+ * than re-deriving its own copy of the pattern (#954), while still always
80
+ * showing the aggregate reconcile-totals line `akmIndex` emits separately
81
+ * after this whole walk finishes.
82
+ */
83
+ export const RECONCILE_ROOT_PROGRESS_PREFIX = 'Reconciled "';
84
+ /**
85
+ * Stat-walk every root, hash files whose (size, mtime) moved or are new,
86
+ * derive new blob hashes, delete gone paths. Idempotent.
87
+ */
88
+ export async function reconcileRoots(db, roots, opts) {
89
+ const counts = emptyCounts();
90
+ const config = loadConfig();
91
+ const maxChars = unitMaxChars(await probeProviderLimits(config.embedding ?? {}, { signal: opts?.signal }));
92
+ // Collected across every root, then handed to `enrichReconciledEntries`
93
+ // ONCE after this whole walk (and `resolvePhysicalOverlaps`) settles — see
94
+ // `enrich.ts`'s module doc for why this runs only on the full-root path and
95
+ // only after every per-file transaction below has already committed.
96
+ const enrichmentCandidates = [];
97
+ for (const root of roots) {
98
+ throwIfAborted(opts?.signal);
99
+ const ctx = resolveRootContext(root.path, root.bundleId);
100
+ if (!ctx) {
101
+ opts?.onProgress?.(`Skipping "${root.path}": no adapter resolved for bundle "${root.bundleId}".`);
102
+ counts.complete = false;
103
+ continue;
104
+ }
105
+ const walked = walkStashFlatWithStatus(root.path, walkOptionsFor(ctx.component));
106
+ counts.scanned += walked.files.length;
107
+ // An incomplete walk (a listing or stat failure somewhere under this
108
+ // root) freezes the WHOLE root's snapshot for this run, not merely the
109
+ // gone-path sweep below: the walker cannot vouch for what it saw either,
110
+ // since a transient failure on one file says nothing about whether the
111
+ // OTHER files it did list are genuinely current — a sibling that
112
+ // legitimately changed on disk this run is not distinguishable, from
113
+ // here, from one whose apparent change is an artifact of the same
114
+ // underlying disruption (a mid-run unmount, a git-index race). Applying
115
+ // adds/changes for the files that happened to walk fine while treating
116
+ // the rest as gone would publish a half-true snapshot with no way for a
117
+ // caller to know it is half true. The next complete walk catches up
118
+ // fully; `counts.complete = false` (→ `IndexResponse.scanComplete`)
119
+ // reports the freeze truthfully in the meantime.
120
+ if (!walked.complete) {
121
+ opts?.onProgress?.(`"${root.path}" was not scanned completely; preserving its entire snapshot this run.`);
122
+ counts.complete = false;
123
+ continue;
124
+ }
125
+ warnIfAdapterSkipsAkmContent(ctx.component, walked.files, ctx.adapter);
126
+ // #339-adjacent: `files` (the stat cache) and `entries` can desync —
127
+ // most plainly when something clears `entries` without also clearing
128
+ // `files` (a crash mid-rebuild, a hand operation, a bug elsewhere) —
129
+ // and the two are two different tables with no FK between them, so
130
+ // SQLite enforces nothing here. A `storedHint` whose row has genuinely
131
+ // gone missing must never let `classifyFile`'s stat-only short-circuit
132
+ // conclude "unchanged": that would leave the file silently unindexed
133
+ // forever, since nothing else would ever re-examine it. Filtering
134
+ // `storedByPath` down to paths that still have a live `entries` row
135
+ // makes every other file look "new" to `classifyFile`, which reparses
136
+ // and reinserts it — self-healing the desync on the very next reconcile;
137
+ // the orphaned `files` rows the filter drops are deleted below so they
138
+ // do not linger and inflate the stat cache forever.
139
+ const entryPaths = new Set(db.prepare("SELECT file_path FROM entries WHERE bundle_id = ?").all(root.bundleId).map((row) => row.file_path));
140
+ const allFileStates = getFileStatesByBundle(db, root.bundleId);
141
+ const orphanedFileStates = allFileStates.filter((row) => !entryPaths.has(row.path));
142
+ if (orphanedFileStates.length > 0) {
143
+ withImmediateTransaction(db, () => {
144
+ deleteFileStates(db, orphanedFileStates.map((row) => row.path));
145
+ }, "index");
146
+ }
147
+ const storedByPath = new Map(allFileStates.filter((row) => entryPaths.has(row.path)).map((row) => [row.path, row]));
148
+ const currentPaths = new Set(walked.files.map((file) => file.absPath));
149
+ // A full-root walk can see both peer workflow formats (.md/.yml) for one
150
+ // canonical ref at once — ownership arbitration needs that whole-root view,
151
+ // so it happens here rather than in the single-file `classifyFile` (which
152
+ // only ever sees one already-known-changed file, mirroring drain-dir.ts's
153
+ // pre-redesign "peer-workflow-format ownership arbitration" doc comment,
154
+ // fallback included: the owner file is always tried FIRST, and if IT
155
+ // drops with its own workflow-compile error, the shadow lifts so the
156
+ // previously-shadowed sibling still gets indexed rather than the whole
157
+ // canonical ref silently vanishing). Only `reconcileRoots` (the full-walk
158
+ // path) does this: `reconcilePaths` (index-written-assets.ts's targeted
159
+ // write) has no visibility into a sibling file it did not just write, so
160
+ // a newly-added shadowed sibling there deliberately stales the cache
161
+ // instead — the next full reconcile (or the read-path physical-owner
162
+ // fallback in the meantime) resolves it.
163
+ const ownership = workflowOwnershipContext(ctx.component, walked.files);
164
+ // Phase 1: classify every walked file without writing anything yet.
165
+ // Unchanged files short-circuit on the stat hint alone; everything else
166
+ // is parsed once (yielding the hash a rename match needs) and queued.
167
+ // Iterates in ownership order (a workflow canonical ref's owner file
168
+ // before its shadowed peers) so the fallback above can observe the
169
+ // owner's outcome before deciding a peer's fate.
170
+ const pendingChanges = [];
171
+ for (const file of ownership.orderedFiles) {
172
+ throwIfAborted(opts?.signal);
173
+ if (ownership.isShadowed(file)) {
174
+ if (deleteFileAndEntryByPath(db, file.absPath))
175
+ counts.removed++;
176
+ continue;
177
+ }
178
+ const classified = classifyFile(ctx, file, opts?.forceReparse ? undefined : storedByPath.get(file.absPath), (message, isWorkflowDrop) => {
179
+ counts.warnings.push(message);
180
+ ownership.onDropped(file, isWorkflowDrop);
181
+ });
182
+ if (classified === "unchanged") {
183
+ counts.unchanged++;
184
+ }
185
+ else if (classified === "unindexable") {
186
+ if (deleteFileAndEntryByPath(db, file.absPath))
187
+ counts.removed++;
188
+ }
189
+ else {
190
+ pendingChanges.push(classified);
191
+ }
192
+ }
193
+ // Phase 2: gone paths, indexed by blob hash so phase 3 can recognize a
194
+ // same-bundle rename instead of a delete paired with an unrelated
195
+ // insert. The walk is known-complete here (an incomplete one already
196
+ // `continue`d the whole root above), so reading "not currently walked" as
197
+ // "gone" is safe — nothing this root's walk merely failed to see can end
198
+ // up here.
199
+ const goneByHash = new Map();
200
+ for (const stale of storedByPath.values()) {
201
+ if (currentPaths.has(stale.path))
202
+ continue;
203
+ const bucket = goneByHash.get(stale.blobHash);
204
+ if (bucket)
205
+ bucket.push(stale);
206
+ else
207
+ goneByHash.set(stale.blobHash, [stale]);
208
+ }
209
+ // Phase 3: apply every queued change, claiming a rename match when one exists.
210
+ for (const change of pendingChanges) {
211
+ throwIfAborted(opts?.signal);
212
+ const bucket = goneByHash.get(change.hash);
213
+ // A rename match is only trustworthy when exactly one gone row shares
214
+ // this hash. With two or more byte-identical candidates (e.g. three
215
+ // copies of the same memory, one deleted and another renamed this same
216
+ // run) there is no evidence which one this change actually became —
217
+ // `bucket[0]` would pick whichever row `getFileStatesByBundle`
218
+ // happened to list first, silently repointing the change onto a
219
+ // possibly-unrelated row's `entries.id` (and therefore its usage
220
+ // history). Leaving an ambiguous bucket untouched here falls through
221
+ // to the ordinary upsert below and lets Phase 4 delete every row still
222
+ // sitting in it as genuinely gone.
223
+ const candidate = bucket?.length === 1 ? bucket[0] : undefined;
224
+ // Only actually CLAIM (shift out of `goneByHash`) a same-hash "gone"
225
+ // row when it is a genuine rename — the identity this change's own
226
+ // content/path derives is not ALREADY a different, established row.
227
+ // Two independently-named files that happen to be byte-identical
228
+ // (e.g. an un-cleaned-up duplicate memory) hash-match a "gone" row
229
+ // that is not really them renamed; repointOrInsert declines that case
230
+ // too (see its own doc comment) and falls back to a plain upsert, so
231
+ // leaving the candidate UNSHIFTED here is what lets Phase 4 still see
232
+ // it as genuinely gone and delete it — shifting it out regardless of
233
+ // whether it gets used would strand it: not repointed, not deleted,
234
+ // permanently stale.
235
+ const itemRefAlreadyClaimed = candidate
236
+ ? db
237
+ .prepare("SELECT 1 FROM entries WHERE item_ref = ? AND file_path <> ?")
238
+ .get(deriveItemRefForChange(ctx, change), candidate.path) != null
239
+ : false;
240
+ const renameSource = candidate && !itemRefAlreadyClaimed ? bucket?.shift() : undefined;
241
+ const result = applyChange(db, ctx, change, maxChars, renameSource, false);
242
+ applyOutcome(counts, result);
243
+ enrichmentCandidates.push({
244
+ entryId: result.entryId,
245
+ blobHash: change.hash,
246
+ entry: result.entry,
247
+ filePath: change.file.absPath,
248
+ provenance: result.provenance,
249
+ });
250
+ }
251
+ // Phase 4: whatever gone rows no rename claimed are genuinely gone —
252
+ // except a path the walk merely could not read (a permission change, a
253
+ // symlink loop — #791): `walkStashManual`/`walkStashGit` silently drop an
254
+ // unresolvable path from `walked.files` the same way they drop an
255
+ // ordinary symlink, so it lands here looking exactly like "deleted".
256
+ // "Absent" (ENOENT) is deleted as before; "inaccessible" keeps its row
257
+ // and is reported, mirroring the pre-redesign `--clean` pass's own
258
+ // absent-vs-inaccessible contract, now applied on every run since
259
+ // reconcile is what "removes what's gone" unconditionally.
260
+ const unreadableStale = [];
261
+ for (const bucket of goneByHash.values()) {
262
+ for (const stale of bucket) {
263
+ throwIfAborted(opts?.signal);
264
+ if (classifyPathAccess(stale.path).access === "inaccessible") {
265
+ unreadableStale.push(stale);
266
+ continue;
267
+ }
268
+ if (deleteFileAndEntryByPath(db, stale.path))
269
+ counts.removed++;
270
+ }
271
+ }
272
+ if (unreadableStale.length > 0) {
273
+ const shown = unreadableStale
274
+ .slice(0, 5)
275
+ .map((row) => describeInaccessiblePath(row.path, classifyPathAccess(row.path).code));
276
+ warn(`Reconcile kept ${unreadableStale.length} entr${unreadableStale.length === 1 ? "y" : "ies"} whose file akm ` +
277
+ `cannot read (unreadable is not deleted): ${shown.join("; ")}${unreadableStale.length > shown.length ? "; …" : ""}`);
278
+ }
279
+ opts?.onProgress?.(`${RECONCILE_ROOT_PROGRESS_PREFIX}${root.path}": ${walked.files.length} files scanned.`);
280
+ }
281
+ // Runs BEFORE `resolvePhysicalOverlaps` below, not after: enrichment writes
282
+ // through `upsertEntry`, keyed by `item_ref` (INSERT ... ON CONFLICT), so
283
+ // applying it to a candidate whose row `resolvePhysicalOverlaps` is about to
284
+ // delete as a physical-overlap LOSER would silently re-INSERT that exact
285
+ // row — resurrecting the very row the overlap resolution just removed.
286
+ // Running first means a wasted enrichment call on a loser is simply deleted
287
+ // moments later (entry_units cascades with its `entries` row; any orphaned
288
+ // `unit_texts`/`units_fts` rows are swept by `pruneOrphanUnitTexts` below
289
+ // regardless of ordering) — never a resurrection.
290
+ if (!opts?.insideBorrowedTransaction) {
291
+ await enrichReconciledEntries(db, config, enrichmentCandidates, maxChars, {
292
+ signal: opts?.signal,
293
+ onProgress: opts?.onProgress,
294
+ });
295
+ }
296
+ // Two configured bundle roots can physically overlap (a bundle added inside
297
+ // another bundle's root, or one adapter's `includeAllDirectories` reaching a
298
+ // dotdir the other skips): each root's own per-file loop above writes its
299
+ // own row for the shared file under its own item_ref, oblivious to the
300
+ // other root's claim — and `files` (the stat cache) has exactly one row PER
301
+ // PATH (`path TEXT PRIMARY KEY`, files-repository.ts), so whichever root's
302
+ // write lands last simply steals that tracking row out from under the
303
+ // other, regardless of which bundle should actually own the file. Resolving
304
+ // this per-file, during either root's own walk, would make the outcome
305
+ // depend on processing order; instead this runs once, after every root has
306
+ // had its turn, and is therefore order-independent.
307
+ resolvePhysicalOverlaps(db, roots);
308
+ pruneOrphanUnitTexts(db);
309
+ // Workflow validation noise gate (issue #273): suppress per-spec stderr
310
+ // lines at default verbosity and emit a single summary instead. In verbose
311
+ // mode the per-spec lines are already printed by `buildMetadataSkipWarning`
312
+ // at generation time (inside `parseFileDocument`) — no second pass needed
313
+ // here. `counts.warnings` itself always carries every per-file detail,
314
+ // verbosity or not — only this immediate stderr summary is gated.
315
+ if (!isVerbose()) {
316
+ const skippedWorkflowCount = counts.warnings.filter(isWorkflowSkipWarning).length;
317
+ if (skippedWorkflowCount > 0) {
318
+ const noun = skippedWorkflowCount === 1 ? "workflow spec" : "workflow specs";
319
+ warn(`${skippedWorkflowCount} ${noun} skipped due to validation errors; ` +
320
+ "rerun with --verbose (or AKM_VERBOSE=1) to see details.");
321
+ }
322
+ }
323
+ return counts;
324
+ }
325
+ const NO_WORKFLOW_OWNERSHIP = {
326
+ orderedFiles: [],
327
+ isShadowed: () => false,
328
+ onDropped: () => undefined,
329
+ };
330
+ /**
331
+ * Peer-workflow-format ownership arbitration over one root's walked files —
332
+ * the deterministic ".md wins over .yml" precedence `resolveWorkflowSourceDomains`
333
+ * already applies for the read path (`resolveAdapterConceptOwner` →
334
+ * `resolveUniqueWorkflowSource` → `pickWorkflowSource`), now applied at
335
+ * indexing time (ported verbatim from the pre-redesign `drain-dir.ts`'s
336
+ * `drainDirDocuments`, whose caller this reconcile engine replaced) so the
337
+ * persisted row for a colliding canonical ref agrees with what a lookup/show
338
+ * would physically resolve to.
339
+ *
340
+ * Content validity plays no part in the INITIAL pick — a malformed `.md`
341
+ * still shadows a perfectly valid `.yml` sibling, since `pickWorkflowSource`'s
342
+ * domain resolution is path-level only — but the shadow is provisional:
343
+ * `orderedFiles` visits the owner before its peers, and if the owner's own
344
+ * `parseFileDocument` call reports a workflow-compile drop (`onDropped` with
345
+ * `isWorkflowDrop: true`), `isShadowed` starts returning `false` for that
346
+ * canonical name so the peer still gets indexed rather than the ref vanishing
347
+ * entirely — never a "multiple sources" collision, just the owner's own
348
+ * parse error surfacing alone. A domain with no resolvable owner at all
349
+ * (every candidate individually invalid — a broken symlink, an escaping
350
+ * path) shadows nothing: each candidate fails its own validation independently.
351
+ */
352
+ function workflowOwnershipContext(component, files) {
353
+ if (component.adapter !== "akm" && component.adapter !== "akm-workflow") {
354
+ return { ...NO_WORKFLOW_OWNERSHIP, orderedFiles: files };
355
+ }
356
+ const adapterId = component.adapter;
357
+ const domains = resolveWorkflowSourceDomains(component.root, adapterId, files.map((file) => file.absPath));
358
+ const ownerPathByCanonicalName = new Map();
359
+ for (const domain of domains) {
360
+ if (domain.source)
361
+ ownerPathByCanonicalName.set(domain.canonicalName, path.resolve(domain.source.path));
362
+ }
363
+ const invalidOwnerNames = new Set();
364
+ const canonicalNameFor = (file) => {
365
+ const name = workflowNameForSourcePath(component.root, adapterId, file.absPath);
366
+ return name === undefined ? undefined : canonicalizeWorkflowName(name);
367
+ };
368
+ const orderedFiles = [...files].sort((left, right) => {
369
+ const leftName = canonicalNameFor(left);
370
+ const rightName = canonicalNameFor(right);
371
+ const leftOwner = leftName !== undefined && ownerPathByCanonicalName.get(leftName) === path.resolve(left.absPath);
372
+ const rightOwner = rightName !== undefined && ownerPathByCanonicalName.get(rightName) === path.resolve(right.absPath);
373
+ if (leftOwner !== rightOwner)
374
+ return leftOwner ? -1 : 1;
375
+ return compareCodePoints(left.absPath, right.absPath);
376
+ });
377
+ return {
378
+ orderedFiles,
379
+ isShadowed(file) {
380
+ const canonicalName = canonicalNameFor(file);
381
+ if (canonicalName === undefined)
382
+ return false;
383
+ const ownerPath = ownerPathByCanonicalName.get(canonicalName);
384
+ return (ownerPath !== undefined && ownerPath !== path.resolve(file.absPath) && !invalidOwnerNames.has(canonicalName));
385
+ },
386
+ onDropped(file, isWorkflowDrop) {
387
+ if (!isWorkflowDrop)
388
+ return;
389
+ const canonicalName = canonicalNameFor(file);
390
+ if (canonicalName !== undefined)
391
+ invalidOwnerNames.add(canonicalName);
392
+ },
393
+ };
394
+ }
395
+ /**
396
+ * The same per-file step for a known list of paths (the write paths call this
397
+ * inline).
398
+ *
399
+ * `opts.root`, when given, is used directly to build the component/adapter
400
+ * context (the same direct derivation `reconcileRoots` uses for its own
401
+ * roots) instead of resolving `bundleId` back to a root through the
402
+ * configured-sources lookup (`resolveBundleRoot`, below). A write-path caller
403
+ * that already knows the exact stash directory it just wrote to (every
404
+ * caller of `indexWrittenAssets` does) should pass it: `bundleId` alone can
405
+ * resolve to the WRONG root for a bundle that is not (yet, or ever) a
406
+ * `bundles.<key>` config entry — for example a proposal's ad hoc named write
407
+ * target — silently deriving a corrupt, path-traversal-laced conceptId
408
+ * rather than a clean no-op. Omit `opts.root` only when no root is at hand
409
+ * (e.g. a caller working purely from a configured bundle id); the id must
410
+ * then match a real `bundles` entry or this is a documented no-op.
411
+ */
412
+ export async function reconcilePaths(db, paths, bundleId, opts) {
413
+ const counts = emptyCounts();
414
+ if (paths.length === 0)
415
+ return counts;
416
+ const config = loadConfig();
417
+ let rootPath;
418
+ let ctx;
419
+ if (opts?.root) {
420
+ const resolvedCtx = resolveRootContext(opts.root, bundleId);
421
+ if (!resolvedCtx)
422
+ return counts;
423
+ rootPath = opts.root;
424
+ ctx = resolvedCtx;
425
+ }
426
+ else {
427
+ const resolvedRoot = resolveBundleRoot(bundleId, config);
428
+ if (!resolvedRoot)
429
+ return counts;
430
+ rootPath = resolvedRoot.rootPath;
431
+ ctx = { bundleId, component: resolvedRoot.component, adapter: resolvedRoot.adapter };
432
+ }
433
+ const maxChars = unitMaxChars(await probeProviderLimits(config.embedding ?? {}));
434
+ for (const rawPath of paths) {
435
+ counts.scanned++;
436
+ const absPath = path.resolve(rawPath);
437
+ if (!fs.existsSync(absPath)) {
438
+ if (deleteFileAndEntryByPath(db, absPath))
439
+ counts.removed++;
440
+ continue;
441
+ }
442
+ const file = buildFileContext(rootPath, absPath);
443
+ const classified = classifyFile(ctx, file, getFileState(db, absPath), (message) => counts.warnings.push(message));
444
+ if (classified === "unchanged")
445
+ counts.unchanged++;
446
+ else if (classified === "unindexable") {
447
+ if (deleteFileAndEntryByPath(db, absPath))
448
+ counts.removed++;
449
+ }
450
+ else {
451
+ // No rename matching for the known-paths write path (index-redesign B1
452
+ // scoping decision): the caller already knows exactly which paths it
453
+ // just wrote, so there is no gone-path pool here to correlate against.
454
+ applyOutcome(counts, applyChange(db, ctx, classified, maxChars, undefined, true));
455
+ }
456
+ }
457
+ return counts;
458
+ }
459
+ // ── Root / bundle resolution ─────────────────────────────────────────────────
460
+ /**
461
+ * Resolve `(path, bundleId)` into the component + adapter that dispatches
462
+ * `recognize` for it, exactly the way `indexer.ts` resolves a configured
463
+ * source's provenance (`deriveInstallations`) — `bundleId` becomes the
464
+ * installation id verbatim (a slug-legal `registryId` IS the installation id).
465
+ */
466
+ function resolveRootContext(rootPath, bundleId) {
467
+ const component = deriveInstallations([
468
+ { path: rootPath, registryId: bundleId, writable: true, adapterId: configuredAdapterIdForBundle(bundleId) },
469
+ ])[0]?.components[0];
470
+ if (!component)
471
+ return undefined;
472
+ const adapter = adapterForId(component.adapter);
473
+ if (!adapter)
474
+ return undefined;
475
+ return { bundleId, component, adapter };
476
+ }
477
+ /**
478
+ * A bundle's explicitly configured adapter (`bundles.<id>.components.<name>.adapter`),
479
+ * or `undefined` when unconfigured. Without this, `resolveRootContext` built its
480
+ * synthetic single-source `SearchSource` with no `adapterId` at all, so
481
+ * `deriveInstallations` fell through to `detectAdapterId` — silently
482
+ * AUTO-DETECTING an adapter instead of respecting one a human explicitly
483
+ * configured, exactly the kind of drift `detectAndPersistBundleAdapters`
484
+ * (indexer.ts) exists to prevent everywhere else. Mirrors the same
485
+ * first-component convention `detectAndPersistBundleAdapters` and
486
+ * `resolveBundleRoot` (below) already use for a single-component bundle.
487
+ */
488
+ function configuredAdapterIdForBundle(bundleId) {
489
+ const bundle = loadConfig().bundles?.[bundleId];
490
+ if (!bundle)
491
+ return undefined;
492
+ return Object.values(bundle.components ?? {})[0]?.adapter;
493
+ }
494
+ /** Resolve a configured bundle id back to its root path + component + adapter, for `reconcilePaths`. */
495
+ function resolveBundleRoot(bundleId, config) {
496
+ const sources = resolveSourceEntries(undefined, config);
497
+ const installations = deriveInstallations(sources);
498
+ const index = installations.findIndex((installation) => installation.id === bundleId);
499
+ const source = index === -1 ? undefined : sources[index];
500
+ const component = index === -1 ? undefined : installations[index]?.components[0];
501
+ if (!source || !component)
502
+ return undefined;
503
+ const adapter = adapterForId(component.adapter);
504
+ if (!adapter)
505
+ return undefined;
506
+ return { rootPath: source.path, component, adapter };
507
+ }
508
+ /** `includeAllDirectories`/`workflowSymlinkAdapter` mirror the full-index walk's own adapter-specific options exactly. */
509
+ function walkOptionsFor(component) {
510
+ return {
511
+ includeAllDirectories: component.adapter === "okf",
512
+ ...(component.adapter === "akm" || component.adapter === "akm-workflow"
513
+ ? { workflowSymlinkAdapter: component.adapter }
514
+ : {}),
515
+ };
516
+ }
517
+ /**
518
+ * #908: a bundle whose explicitly configured adapter is not `akm` can shadow
519
+ * whole top-level directories of ordinary akm-recognizable content (a stash
520
+ * with `content/`, `knowledge/`, `scripts/`, `workflows/`, `workspace/`
521
+ * indexed under, say, `agent-skills` — an adapter that only claims one of
522
+ * those) with zero disclosure: the files are simply never recognized, never
523
+ * indexed, and nothing says why. This warns ONCE per root, naming the count
524
+ * and the directories, not one warning per directory — enough to point an
525
+ * operator at the fix (`components.<name>.adapter: "akm"`).
526
+ *
527
+ * A directory only counts as "skipped" when the CHOSEN adapter recognizes
528
+ * NOTHING in it (so a directory the chosen adapter partially owns is not
529
+ * flagged) AND the `akm` adapter would have recognized at least one file
530
+ * there (so a directory neither adapter cares about — e.g. `.git/`, `node_modules/`
531
+ * — is not a false positive).
532
+ */
533
+ function warnIfAdapterSkipsAkmContent(component, files, adapter) {
534
+ if (adapter.id === "akm")
535
+ return;
536
+ const akm = adapterForId("akm");
537
+ if (!akm)
538
+ return;
539
+ const byTopDir = new Map();
540
+ for (const file of files) {
541
+ const top = file.ancestorDirs[0];
542
+ if (!top)
543
+ continue; // a root-level file is not a "skipped directory" concern
544
+ const group = byTopDir.get(top);
545
+ if (group)
546
+ group.push(file);
547
+ else
548
+ byTopDir.set(top, [file]);
549
+ }
550
+ const akmComponent = { ...component, adapter: "akm" };
551
+ let skippedCount = 0;
552
+ const skippedDirs = [];
553
+ for (const [dir, dirFiles] of byTopDir) {
554
+ const chosenRecognizesAny = dirFiles.some((file) => {
555
+ try {
556
+ return adapter.recognize(component, file) !== null;
557
+ }
558
+ catch {
559
+ return false;
560
+ }
561
+ });
562
+ if (chosenRecognizesAny)
563
+ continue; // the chosen adapter owns this dir; nothing skipped
564
+ const akmCandidates = dirFiles.filter((file) => {
565
+ try {
566
+ return akm.recognize(akmComponent, file) !== null;
567
+ }
568
+ catch {
569
+ return false;
570
+ }
571
+ });
572
+ if (akmCandidates.length === 0)
573
+ continue; // akm would drop it too — not a shadowing case
574
+ skippedCount += akmCandidates.length;
575
+ skippedDirs.push(dir);
576
+ }
577
+ if (skippedCount === 0)
578
+ return;
579
+ skippedDirs.sort();
580
+ warnOnce("adapter-skip-akm-content", `${adapter.id} adapter skipped ${skippedCount} file${skippedCount === 1 ? "" : "s"} in ` +
581
+ `${skippedDirs.map((dir) => `${dir}/`).join(", ")} — set components.<name>.adapter to "akm" to index them`);
582
+ }
583
+ /**
584
+ * Classify one walked file against its stored stat hint WITHOUT writing
585
+ * anything: `"unchanged"` short-circuits before any parse; `"unindexable"`
586
+ * covers both "vanished before it could be stat'd" and "no matcher claims it
587
+ * (any more)"; otherwise the file is parsed (the one parse its `ParsedChange`
588
+ * carries forward) and queued for `applyChange`.
589
+ */
590
+ function classifyFile(ctx, file, storedHint, onWarning) {
591
+ let stat;
592
+ try {
593
+ stat = file.stat();
594
+ }
595
+ catch {
596
+ // #791, applied here too (not just the gone-path sweep below): a walker
597
+ // can list a path (a git-tracked listing, say) that a later, individual
598
+ // stat call cannot reach — a permission change or a symlink loop, not
599
+ // "gone". Treating that as "unindexable" would delete any existing row
600
+ // for a file that is still genuinely there. `classifyPathAccess`
601
+ // distinguishes the two; only a truly absent path (or any other
602
+ // unexpected classification) falls through to "unindexable" and its
603
+ // unconditional delete.
604
+ const access = classifyPathAccess(file.absPath);
605
+ if (access.access === "inaccessible") {
606
+ // A known file (a row already exists) is a no-op — "unchanged" leaves
607
+ // that existing row untouched, exactly like the sweep's own
608
+ // `unreadableStale` handling below. A file with NO prior row has
609
+ // nothing to preserve, so silently folding it into `counts.unchanged`
610
+ // would mean it is never indexed and nothing ever says why. Report it
611
+ // through the same channel a parse failure uses, mirroring the
612
+ // gone-path sweep's own unreadable disclosure.
613
+ if (!storedHint) {
614
+ onWarning?.(`New file akm cannot read, never indexed: ${describeInaccessiblePath(file.absPath, access.code)}`, false);
615
+ }
616
+ return "unchanged";
617
+ }
618
+ return "unindexable";
619
+ }
620
+ // A file's own (size, mtime) cannot move when only its BUNDLE's configured
621
+ // adapter changes — the adapter comparison catches that case and forces a
622
+ // re-parse under the new adapter even though the file on disk is untouched.
623
+ // ctime is compared alongside size/mtime (not size/mtime alone) because an
624
+ // edit that happens to restore the exact same size and mtime (`rsync -a`,
625
+ // `cp -p`, reproducible-build tooling) still moves ctime on every
626
+ // filesystem akm supports — the only signal left standing to catch it.
627
+ if (storedHint &&
628
+ storedHint.size === stat.size &&
629
+ storedHint.mtimeMs === stat.mtimeMs &&
630
+ storedHint.ctimeMs === stat.ctimeMs &&
631
+ storedHint.adapterId === ctx.adapter.id) {
632
+ return "unchanged";
633
+ }
634
+ const outcome = parseFileDocument(ctx.adapter, ctx.component, file);
635
+ if (outcome.parsed === null) {
636
+ if (outcome.warning !== null)
637
+ onWarning?.(outcome.warning, outcome.isWorkflowDrop);
638
+ return "unindexable";
639
+ }
640
+ const hash = outcome.parsed.hash ?? hashContent(file.content());
641
+ return { file, stat, entry: outcome.parsed.entry, conceptId: outcome.parsed.conceptId, hash };
642
+ }
643
+ /** The `item_ref` a change's own content/path would derive — same inputs `applyChange` itself feeds `deriveEntryProvenance`, exposed separately so Phase 3 can check it BEFORE deciding whether to claim a same-hash rename candidate. */
644
+ function deriveItemRefForChange(ctx, change) {
645
+ return deriveEntryProvenance({ bundleId: ctx.bundleId, componentId: ctx.component.id, adapterId: ctx.component.adapter }, change.entry.type, change.entry.name, change.conceptId).itemRef;
646
+ }
647
+ /**
648
+ * Write one already-parsed change: with a `renameSource`, re-point the
649
+ * existing row at `renameSource.path` in place (`repointEntry`, preserving
650
+ * `entries.id`); otherwise upsert normally (`upsertEntry`, keyed by
651
+ * `item_ref`). Either way, derive units and point `entry_units` at them, all
652
+ * inside one `BEGIN IMMEDIATE` transaction.
653
+ */
654
+ function applyChange(db, ctx, change, maxChars, renameSource, supersedeOtherBundles) {
655
+ const { file, stat, entry, conceptId, hash } = change;
656
+ const searchText = buildSearchText(entry);
657
+ const provenance = deriveEntryProvenance({ bundleId: ctx.bundleId, componentId: ctx.component.id, adapterId: ctx.component.adapter }, entry.type, entry.name, conceptId);
658
+ const entryWithSize = { ...entry, fileSize: stat.size };
659
+ if (hasMarkdownFragmentContent(entry))
660
+ setMarkdownFragmentContent(entryWithSize, getMarkdownFragmentContent(entry));
661
+ // Populated inside the transaction below (F4), then pruned AFTER it
662
+ // commits — see the call site past `withImmediateTransaction` for why.
663
+ let orphanedUnitHashes = [];
664
+ const result = withImmediateTransaction(db, () => {
665
+ // A materialized file has one current owner: if `entries` already holds a
666
+ // row at this exact path under a DIFFERENT item_ref — the same physical
667
+ // file reconciled earlier under another bundle identity (a config change,
668
+ // or a write path with no stable configured bundle to anchor to) — drop
669
+ // that stale row before publishing the canonical one below, mirroring
670
+ // the pre-redesign write path's own supersede check
671
+ // (index-written-assets.ts's prior `supersededIds` logic). Without this a
672
+ // second identity's reconcile leaves two rows at one file_path and any
673
+ // plain `WHERE file_path = ?` lookup can return either.
674
+ //
675
+ // `reconcileRoots`'s full walk passes `supersedeOtherBundles: false`: a
676
+ // NESTED bundle (`akm bundle add ./vendor` where vendor sits inside the
677
+ // primary stash) walks the same physical file from BOTH roots by design
678
+ // — the enclosing bundle's own path-derived conceptId and the nested
679
+ // bundle's own conceptId are two legitimate, simultaneously-valid
680
+ // identities for it, and an unqualified ref can only resolve through the
681
+ // enclosing bundle's copy (`lookupBundleRefWithResolutionUsing`,
682
+ // indexer.ts, stops at the first candidate whose physical owner has no
683
+ // matching row rather than trying a later, more specific source). Which
684
+ // of the two ends up the durable survivor when they physically collide on
685
+ // the SAME file is `resolvePhysicalOverlaps`'s job, run once after every
686
+ // root has had its own turn (see its own doc comment) — not this
687
+ // per-file, order-dependent supersede. Only `reconcilePaths` (the
688
+ // write-time path, which reconciles ONE bundle at a time with no
689
+ // whole-root visibility into a sibling bundle that might physically
690
+ // overlap it, so it has no later pass to rely on) still needs the
691
+ // stale-identity cleanup this guards.
692
+ if (supersedeOtherBundles)
693
+ supersedeOtherItemRefsAtPath(db, file.absPath, provenance.itemRef);
694
+ const written = renameSource
695
+ ? repointOrInsert(db, renameSource.path, file.absPath, entryWithSize, searchText, provenance, hash)
696
+ : upsertOrInsert(db, file.absPath, entryWithSize, searchText, provenance, hash);
697
+ if (renameSource)
698
+ deleteFileStates(db, [renameSource.path]);
699
+ upsertFileState(db, {
700
+ path: file.absPath,
701
+ bundleId: ctx.bundleId,
702
+ size: stat.size,
703
+ mtimeMs: stat.mtimeMs,
704
+ ctimeMs: stat.ctimeMs,
705
+ blobHash: hash,
706
+ adapterId: ctx.adapter.id,
707
+ });
708
+ const units = deriveUnits(toUnitSource(written.entryId, entry), maxChars);
709
+ const { inserted } = insertNewUnitTexts(db, units.map((unit) => ({
710
+ hash: unit.hash,
711
+ kind: unit.fragmentId === null ? "card" : "fragment",
712
+ text: unit.text,
713
+ })));
714
+ // Capture the entry's PREVIOUS unit_hash mapping before replaceEntryUnits
715
+ // overwrites it — this write's own replaced hashes are exactly the ones
716
+ // it may have just orphaned (F4). A brand-new entry simply has no prior
717
+ // mapping, so this is empty and nothing below does any work.
718
+ const previousHashes = db.prepare("SELECT unit_hash FROM entry_units WHERE entry_id = ?").all(written.entryId).map((row) => row.unit_hash);
719
+ replaceEntryUnits(db, written.entryId, units.map((unit) => ({ ordinal: unit.ordinal, fragmentId: unit.fragmentId, hash: unit.hash })));
720
+ const currentHashes = new Set(units.map((unit) => unit.hash));
721
+ orphanedUnitHashes = previousHashes.filter((oldHash) => !currentHashes.has(oldHash));
722
+ return {
723
+ outcome: written.outcome,
724
+ unitsAdded: inserted,
725
+ entryId: written.entryId,
726
+ entry: entryWithSize,
727
+ provenance,
728
+ };
729
+ }, "index");
730
+ // Outside the transaction (mirroring reconcileRoots's own end-of-run
731
+ // pruneOrphanUnitTexts, which also runs after every per-file write has
732
+ // committed): a hash this write replaced is only ACTUALLY orphaned once
733
+ // nothing else references it, and pruneOrphanUnitTextsForHashes checks
734
+ // that itself — narrow, hash-scoped cleanup instead of reconcileRoots's
735
+ // whole-table sweep, so `reconcilePaths` (which never ran that sweep) no
736
+ // longer leaks a replaced unit's text/FTS rows forever on every edit.
737
+ // Vectors are untouched: dropping unit_texts never drops units/units_vec.
738
+ if (orphanedUnitHashes.length > 0)
739
+ pruneOrphanUnitTextsForHashes(db, orphanedUnitHashes);
740
+ return result;
741
+ }
742
+ /** The ordinary path: `upsertEntry`, keyed by `item_ref` — a fresh row if none existed at this path before, else an update in place. */
743
+ function upsertOrInsert(db, filePath, entry, searchText, provenance, hash) {
744
+ const existedBefore = getFileState(db, filePath) !== undefined;
745
+ const entryId = upsertEntry(db, filePath, entry, searchText, provenance, hash);
746
+ return { entryId, outcome: existedBefore ? "changed" : "added" };
747
+ }
748
+ /**
749
+ * The rename path: find the entries row still sitting at `oldPath` and
750
+ * re-point it at `newPath` in place, preserving its id. Falls back to a
751
+ * plain insert on the (should-not-happen) case where the stale `files` row
752
+ * outlived its `entries` row.
753
+ */
754
+ function repointOrInsert(db, oldPath, newPath, entry, searchText, provenance, hash) {
755
+ const oldRow = db.prepare("SELECT id FROM entries WHERE file_path = ?").get(oldPath);
756
+ if (!oldRow)
757
+ return upsertOrInsert(db, newPath, entry, searchText, provenance, hash);
758
+ // A blob-hash match is only a genuine rename when the identity the NEW
759
+ // path's own content derives (`provenance.itemRef`) is not ALREADY a
760
+ // different, established row. Two independently-named files that happen
761
+ // to be byte-identical (e.g. an un-cleaned-up duplicate memory) hash-match
762
+ // a "gone" row that is not really them renamed — it just happens to share
763
+ // bytes with something that went away elsewhere. Repointing onto an
764
+ // item_ref another row already legitimately holds would collide on the
765
+ // UNIQUE constraint, and would be wrong even if it somehow did not: it
766
+ // would silently reassign that OTHER row's id/embeddings/utility scores
767
+ // onto this unrelated file. Fall back to a plain upsert at the new path
768
+ // instead — the existing row for THIS identity updates in place as usual,
769
+ // and the old "gone" row is left unclaimed for Phase 4's ordinary delete.
770
+ const claimedByOther = db
771
+ .prepare("SELECT 1 FROM entries WHERE item_ref = ? AND id <> ?")
772
+ .get(provenance.itemRef, oldRow.id);
773
+ if (claimedByOther)
774
+ return upsertOrInsert(db, newPath, entry, searchText, provenance, hash);
775
+ repointEntry(db, oldRow.id, newPath, entry, searchText, provenance, hash);
776
+ return { entryId: oldRow.id, outcome: "changed" };
777
+ }
778
+ /** UPDATE one `entries` row in place — same id, new path/identity/content — and refresh its safe-fragment source. */
779
+ function repointEntry(db, entryId, filePath, entry, searchText, provenance, contentHash) {
780
+ const derivedFrom = typeof entry.derivedFrom === "string" && entry.derivedFrom.trim() ? entry.derivedFrom.trim() : null;
781
+ db.prepare(`UPDATE entries SET item_ref = ?, bundle_id = ?, component_id = ?, concept_id = ?, adapter_id = ?, type = ?,
782
+ file_path = ?, content_hash = ?, document_json = ?, search_text = ?, derived_from = ?
783
+ WHERE id = ?`).run(provenance.itemRef, provenance.bundleId, provenance.componentId, provenance.conceptId, provenance.adapterId, entry.type, filePath, contentHash, JSON.stringify(entry), searchText, derivedFrom, entryId);
784
+ replaceFragmentSource(db, entryId, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
785
+ }
786
+ /** Delete any `entries` row at `filePath` whose `item_ref` is not `keepItemRef` (cascade removes its `entry_units`). Must run inside the caller's own transaction. */
787
+ function supersedeOtherItemRefsAtPath(db, filePath, keepItemRef) {
788
+ const staleIds = db.prepare("SELECT id FROM entries WHERE file_path = ? AND item_ref <> ?").all(filePath, keepItemRef).map((row) => row.id);
789
+ if (staleIds.length > 0)
790
+ deleteEntriesByIds(db, staleIds);
791
+ }
792
+ /**
793
+ * Resolve every file_path this run's `roots` disagree about: when it has
794
+ * `entries` rows under more than one of THESE bundles, keep exactly one and
795
+ * delete the rest, then repoint the shared `files` stat-cache row at the
796
+ * winner. Two bundles can only physically share one file when one bundle's
797
+ * root contains the other's (there is no other way for the same absolute
798
+ * path to fall under two distinct configured roots), so comparing resolved
799
+ * root-path length is sufficient to find the more specific (longer) root's
800
+ * sibling and the broader (shorter, outer) root that contains it — the outer
801
+ * root's row wins, mirroring the pre-redesign read path's own
802
+ * `findSourceForPath` "more specific source is attributed the asset for
803
+ * POLICY purposes, but the outer bundle is still what a plain unqualified ref
804
+ * resolves through" split: the physical-owner arbitration in
805
+ * `resolveAdapterConceptOwner`/`findSourceForPath` already picks the more
806
+ * specific source for a raw path lookup regardless of which bundle's index
807
+ * row exists, so the index only needs to keep ONE durable identity per file
808
+ * and the outer bundle's is the one every unqualified/enclosing ref depends
809
+ * on. A collision naming a bundleId outside `roots` (some other, unrelated
810
+ * bundle this call was not asked to reconcile) is left alone.
811
+ */
812
+ function resolvePhysicalOverlaps(db, roots) {
813
+ if (roots.length < 2)
814
+ return;
815
+ const rootPathByBundle = new Map(roots.map((root) => [root.bundleId, path.resolve(root.path)]));
816
+ const collisions = db
817
+ .prepare(`SELECT file_path FROM entries WHERE file_path IN (
818
+ SELECT file_path FROM entries GROUP BY file_path HAVING COUNT(DISTINCT bundle_id) > 1
819
+ ) GROUP BY file_path`)
820
+ .all();
821
+ for (const { file_path: filePath } of collisions) {
822
+ withImmediateTransaction(db, () => {
823
+ const rows = db.prepare("SELECT id, bundle_id AS bundleId FROM entries WHERE file_path = ?").all(filePath);
824
+ if (rows.length < 2 || !rows.every((row) => rootPathByBundle.has(row.bundleId)))
825
+ return;
826
+ let winner = rows[0];
827
+ for (const row of rows) {
828
+ if ((rootPathByBundle.get(row.bundleId) ?? "").length < (rootPathByBundle.get(winner.bundleId) ?? "").length) {
829
+ winner = row;
830
+ }
831
+ }
832
+ const losers = rows.filter((row) => row.id !== winner.id);
833
+ deleteEntriesByIds(db, losers.map((row) => row.id));
834
+ // `files` has one row per path (the global PK), so whichever losing
835
+ // bundle wrote it last this run may currently own its tracking even
836
+ // though it just lost the collision. Repoint it at the winner with a
837
+ // fresh stat: the winner's own next reconcile then sees this path as
838
+ // already tracked (an "unchanged" short-circuit), and a demoted loser
839
+ // sees it as untracked — able to compete again if it ever regains sole
840
+ // physical access to the file.
841
+ try {
842
+ const stat = fs.statSync(filePath);
843
+ const winnerRow = db
844
+ .prepare("SELECT content_hash AS hash, adapter_id AS adapterId FROM entries WHERE id = ?")
845
+ .get(winner.id);
846
+ if (winnerRow) {
847
+ upsertFileState(db, {
848
+ path: filePath,
849
+ bundleId: winner.bundleId,
850
+ size: stat.size,
851
+ mtimeMs: stat.mtimeMs,
852
+ ctimeMs: stat.ctimeMs,
853
+ blobHash: winnerRow.hash,
854
+ adapterId: winnerRow.adapterId,
855
+ });
856
+ }
857
+ }
858
+ catch {
859
+ // Vanished between the write and this pass — the next gone-path sweep handles it.
860
+ }
861
+ }, "index");
862
+ }
863
+ }
864
+ /** Delete a gone path's `entries` row (cascade removes `entry_units`) and its `files` row. Returns whether anything existed. */
865
+ function deleteFileAndEntryByPath(db, filePath) {
866
+ return withImmediateTransaction(db, () => {
867
+ const entryIds = db.prepare("SELECT id FROM entries WHERE file_path = ?").all(filePath).map((row) => row.id);
868
+ if (entryIds.length > 0)
869
+ deleteEntriesByIds(db, entryIds);
870
+ const hadFileRow = getFileState(db, filePath) !== undefined;
871
+ if (hadFileRow)
872
+ deleteFileStates(db, [filePath]);
873
+ return entryIds.length > 0 || hadFileRow;
874
+ }, "index");
875
+ }
876
+ // ── Small helpers ─────────────────────────────────────────────────────────────
877
+ function emptyCounts() {
878
+ return { scanned: 0, unchanged: 0, added: 0, changed: 0, removed: 0, unitsAdded: 0, complete: true, warnings: [] };
879
+ }
880
+ function applyOutcome(counts, result) {
881
+ if (result.outcome === "added")
882
+ counts.added++;
883
+ else
884
+ counts.changed++;
885
+ counts.unitsAdded += result.unitsAdded;
886
+ }
887
+ function throwIfAborted(signal) {
888
+ if (signal?.aborted)
889
+ throw signal.reason instanceof Error ? signal.reason : new Error("reconcile interrupted");
890
+ }