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.
- package/CHANGELOG.md +144 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/cli/retired-commands.js +2 -0
- package/dist/cli/unknown-flags.js +36 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +6 -4
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/proposal/repository.js +12 -3
- package/dist/commands/read/curate.js +34 -44
- package/dist/commands/read/search.js +50 -2
- package/dist/commands/sources/index-status.js +99 -0
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +33 -12
- package/dist/commands/sources/source-add.js +21 -6
- package/dist/commands/sources/stash-cli.js +119 -111
- package/dist/core/adapter/adapters/akm-adapter.js +35 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -1
- package/dist/core/asset/asset-placement.js +35 -0
- package/dist/core/config/schema/embedding.js +7 -30
- package/dist/core/config/schema/search.js +11 -9
- package/dist/core/errors.js +5 -2
- package/dist/core/hash.js +18 -0
- package/dist/core/maintenance-barrier.js +8 -6
- package/dist/core/paths.js +0 -11
- package/dist/core/run-lock.js +5 -2
- package/dist/core/state/migrations.js +26 -1
- package/dist/core/state-db.js +63 -27
- package/dist/indexer/drain.js +306 -0
- package/dist/indexer/embedding-identity.js +20 -0
- package/dist/indexer/enrich.js +260 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/index-written-assets.js +133 -171
- package/dist/indexer/indexer.js +458 -1621
- package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
- package/dist/indexer/passes/metadata.js +18 -1
- package/dist/indexer/reconcile.js +890 -0
- package/dist/indexer/scan/drain-dir.js +27 -70
- package/dist/indexer/scan/parse-file.js +66 -0
- package/dist/indexer/search/db-search.js +373 -89
- package/dist/indexer/search/ranking-contributors.js +21 -16
- package/dist/indexer/search/ranking.js +135 -57
- package/dist/indexer/units/unit.js +159 -0
- package/dist/llm/client.js +10 -1
- package/dist/llm/embedder.js +10 -3
- package/dist/llm/embedders/provider-limits.js +288 -0
- package/dist/llm/embedders/remote.js +133 -104
- package/dist/llm/feature-gate.js +4 -2
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +19 -13
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +5 -2
- package/dist/scripts/akm-migrate-node.js +1141 -1237
- package/dist/scripts/akm-migrate.js +1141 -1237
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/steps/connection.js +3 -2
- package/dist/storage/repositories/files-repository.js +181 -0
- package/dist/storage/repositories/index-connection.js +1 -3
- package/dist/storage/repositories/index-entries-repository.js +77 -68
- package/dist/storage/repositories/index-entry-schema.js +16 -25
- package/dist/storage/repositories/index-fts-repository.js +29 -263
- package/dist/storage/repositories/index-meta-repository.js +0 -29
- package/dist/storage/repositories/index-schema.js +115 -122
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +21 -334
- package/dist/storage/repositories/units-repository.js +510 -0
- package/docs/migration/release-notes/0.9.15.md +34 -36
- package/docs/migration/release-notes/0.9.16.md +110 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +93 -87
- package/docs/reference/configuration.md +128 -89
- package/docs/reference/data-and-telemetry.md +2 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +2 -58
- package/dist/indexer/index-db-contention.js +0 -56
- package/dist/indexer/index-rebuild-lock.js +0 -73
- package/dist/indexer/materialize-embeddings.js +0 -771
- package/dist/indexer/passes/dir-staleness.js +0 -161
- 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
|
+
}
|