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
@@ -1,161 +0,0 @@
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
- * Incremental dir-staleness engine.
6
- *
7
- * Decides, per stash directory, whether the directory's indexed rows are still
8
- * fresh relative to what is on disk — so an incremental `akm index` run can
9
- * skip unchanged directories instead of regenerating their metadata.
10
- *
11
- * Two persisted signals back the decision:
12
- * 1. The `entries` rows already indexed for the directory (`getEntriesByDir`).
13
- * 2. The `index_dir_state` row (`getIndexDirState`): the fingerprint of the
14
- * directory's walked file set (basename set + max mtime, `computeDirFingerprint`)
15
- * as of its last drain, plus the row count that drain persisted.
16
- *
17
- * `getCachedDirState` is the pre-drain gate (#900): a directory whose walked
18
- * fingerprint still matches its row is skipped before any file is read.
19
- */
20
- import { createHash } from "node:crypto";
21
- import fs from "node:fs";
22
- import path from "node:path";
23
- import { compareCodePoints } from "../../core/common.js";
24
- import { getEntriesByDir } from "../../storage/repositories/index-entries-repository.js";
25
- import { getIndexDirState } from "../../storage/repositories/index-meta-repository.js";
26
- /**
27
- * Post-drain freshness verdict. `files` is the recognized file set the drain
28
- * produced (compared against the persisted entries); `fingerprint` is the
29
- * walked-set fingerprint compared against the persisted row and defaults to
30
- * one computed over `files`.
31
- */
32
- export function getDirIndexState(db, dirPath, files, builtAtMs, indexVariant = "", fingerprint = computeDirFingerprint(dirPath, files, indexVariant)) {
33
- const prevEntries = getEntriesByDir(db, dirPath);
34
- if (prevEntries.length > 0) {
35
- const staleReason = getDirStaleReason(dirPath, files, prevEntries, builtAtMs);
36
- if (staleReason)
37
- return { stale: true, reason: staleReason, persistedRowCount: prevEntries.length };
38
- const cachedState = getIndexDirState(db, dirPath);
39
- if (!cachedState || cachedState.fileSetHash !== fingerprint.fileSetHash) {
40
- return {
41
- stale: true,
42
- reason: { kind: "index-context-changed", detail: indexVariant },
43
- persistedRowCount: prevEntries.length,
44
- };
45
- }
46
- return { stale: false, reason: { kind: "unchanged" }, persistedRowCount: prevEntries.length };
47
- }
48
- const cachedState = getIndexDirState(db, dirPath);
49
- if (cachedState && cachedState.fileSetHash === fingerprint.fileSetHash) {
50
- return {
51
- stale: false,
52
- reason: { kind: "cached-zero-row-state", detail: cachedState.reason },
53
- persistedRowCount: 0,
54
- };
55
- }
56
- return {
57
- stale: true,
58
- reason: { kind: "no-previous-rows", detail: cachedState ? `cached=${cachedState.reason}` : undefined },
59
- persistedRowCount: 0,
60
- };
61
- }
62
- /**
63
- * Pre-drain gate (#900). A directory whose walked-set fingerprint matches its
64
- * persisted row cannot recognize differently than last time, so it is skipped
65
- * before `drainDirDocuments` reads a single file. A row that recorded a real
66
- * generation (`rowCount > 0`) is skipped outright; a zero-row or pre-#900 row
67
- * goes through the entries-aware check so the dedup-order guard still applies.
68
- */
69
- export function getCachedDirState(db, dirPath, files, builtAtMs, priorDirsChanged, indexVariant, fingerprint) {
70
- const cached = getIndexDirState(db, dirPath);
71
- if (!cached || cached.fileSetHash !== fingerprint.fileSetHash)
72
- return undefined;
73
- if (cached.rowCount !== undefined && cached.rowCount > 0) {
74
- return { stale: false, reason: { kind: "unchanged-precheck" }, persistedRowCount: cached.rowCount };
75
- }
76
- const state = getDirIndexState(db, dirPath, files, builtAtMs, indexVariant, fingerprint);
77
- if (state.stale || state.reason.kind !== "cached-zero-row-state")
78
- return undefined;
79
- if (!canUseIncrementalSkip(state, priorDirsChanged))
80
- return undefined;
81
- return state;
82
- }
83
- export function canUseIncrementalSkip(state, priorDirsChanged) {
84
- return !(priorDirsChanged &&
85
- state.reason.kind === "cached-zero-row-state" &&
86
- state.reason.detail === "deduped-zero-row");
87
- }
88
- export function computeDirFingerprint(_dirPath, files, indexVariant = "") {
89
- // One `statSync` per file — the same call this function has always made — but
90
- // every field it returns that can witness a change is kept, per file, instead
91
- // of being collapsed into a single max.
92
- //
93
- // `Math.max` over mtimes discarded everything except the newest file, so an
94
- // edit to any other file landed below the max and was invisible; and mtime
95
- // alone is writable by ordinary tooling (`touch -r`, `rsync --times`,
96
- // `cp -p`, archive extraction), so a restored timestamp hid an edit outright.
97
- // Size catches any length-changing edit; ctime catches the rest, because
98
- // utimes(2) cannot hold the inode's change time back.
99
- //
100
- // This is still a heuristic: ctime also moves on metadata-only changes
101
- // (chmod/chown) and after copying a tree, which costs an unnecessary rescan.
102
- // That direction is safe — extra work, never stale content.
103
- const entries = [];
104
- let fileMtimeMaxMs = 0;
105
- for (const file of [...new Set(files)].sort(compareCodePoints)) {
106
- const name = path.basename(file);
107
- try {
108
- // `bigint: true` is the same syscall but reports nanoseconds. Millisecond
109
- // floats would let an edit made inside the same millisecond as the last
110
- // run's stat land on an identical digest.
111
- const stat = fs.statSync(file, { bigint: true });
112
- fileMtimeMaxMs = Math.max(fileMtimeMaxMs, Number(stat.mtimeMs));
113
- entries.push(`${name}\0${stat.size}\0${stat.mtimeNs}\0${stat.ctimeNs}`);
114
- }
115
- catch {
116
- // Unreadable or vanished: record it as such so the digest differs from
117
- // any run where the file could be read, forcing a rescan.
118
- entries.push(`${name}\0unreadable`);
119
- }
120
- }
121
- const digest = createHash("sha256")
122
- .update([indexVariant, ...entries].join("\n"), "utf8")
123
- .digest("hex");
124
- return { fileSetHash: digest, fileMtimeMaxMs };
125
- }
126
- function getDirStaleReason(_dirPath, currentFiles, previousEntries, builtAtMs) {
127
- const prevFileNames = new Set(previousEntries
128
- .map((ie) => {
129
- const fromPath = path.basename(ie.filePath);
130
- return fromPath || ie.entry.filename;
131
- })
132
- .filter((e) => !!e));
133
- const currFileNames = new Set(currentFiles.map((f) => path.basename(f)));
134
- if (prevFileNames.size !== currFileNames.size) {
135
- return { kind: "file-set-changed", detail: `${prevFileNames.size} -> ${currFileNames.size} files` };
136
- }
137
- for (const name of currFileNames) {
138
- if (!prevFileNames.has(name))
139
- return { kind: "file-set-changed", detail: name };
140
- }
141
- for (const file of currentFiles) {
142
- try {
143
- if (fs.statSync(file).mtimeMs > builtAtMs)
144
- return { kind: "mtime-changed", detail: path.basename(file) };
145
- }
146
- catch {
147
- return { kind: "missing-file", detail: path.basename(file) };
148
- }
149
- }
150
- return undefined;
151
- }
152
- export function inferZeroRowReason(stash, priorReason, warnings, dirPath, dedupedRows) {
153
- if (dedupedRows > 0)
154
- return "deduped-zero-row";
155
- const workflowNoise = warnings.some((warning) => warning.startsWith("Skipped workflow ") && warning.includes(dirPath));
156
- if (workflowNoise)
157
- return "workflow-noise";
158
- if (!stash || stash.entries.length === 0)
159
- return "empty-generated-set";
160
- return `zero-row:${priorReason?.kind ?? "unknown"}`;
161
- }
@@ -1,184 +0,0 @@
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
- * `index.db` embedding salvage (#955) — a transient, self-emptying table
6
- * that lets a full rebuild or an index-generation bump reuse vectors instead
7
- * of re-embedding a corpus whose content did not change.
8
- *
9
- * Zero steady-state cost by design: this is NOT a second embedding cache.
10
- * Rows are copied aside only at the moment they would otherwise be discarded
11
- * wholesale — a full-index wipe (`persistDirRecords`) or a generation bump
12
- * (`rebuildIncompatibleIndexGeneration`) — and are consumed by the very next
13
- * embedding pass (`generateEmbeddingsForDb`). A pass that completes without
14
- * abort or circuit-break purges whatever is left; an interrupted pass leaves
15
- * the table for the next attempt to pick up.
16
- *
17
- * Reuse is keyed on `sha256(search_text)` plus the fingerprint the vector was
18
- * generated under — a fingerprint mismatch or a single-byte content change
19
- * both correctly fall through to a real provider call. `content_hash` is the
20
- * PRIMARY KEY (not `(content_hash, fingerprint)`) so relabeling a whole
21
- * generation's fingerprint after a canary "keep" verdict is one UPDATE, and a
22
- * hash colliding across two discards simply keeps the most recent copy —
23
- * salvage is a best-effort optimization, not a durable multi-generation
24
- * archive.
25
- */
26
- import { createHash } from "node:crypto";
27
- import { blobToEmbedding } from "./embeddings-repository.js";
28
- import { getMeta } from "./index-meta-repository.js";
29
- import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
30
- /**
31
- * Create the salvage table. Additive-only DDL: it carries no bearing on the
32
- * `entries` generation fingerprint (`hasCanonicalEntrySchema`), so adding it
33
- * does not require an index-generation bump.
34
- */
35
- export function ensureEmbeddingSalvageTable(db) {
36
- db.exec(`
37
- CREATE TABLE IF NOT EXISTS embedding_salvage (
38
- content_hash TEXT PRIMARY KEY,
39
- fingerprint TEXT NOT NULL,
40
- embedding BLOB NOT NULL,
41
- salvaged_at TEXT NOT NULL
42
- );
43
- `);
44
- }
45
- /** The one hash function salvage writes and reuse lookups must agree on. */
46
- export function hashEmbeddableText(searchText) {
47
- return createHash("sha256").update(searchText, "utf8").digest("hex");
48
- }
49
- function tableExists(db, name) {
50
- return db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name) != null;
51
- }
52
- function tableHasColumn(db, table, column) {
53
- const columns = db.prepare(`PRAGMA table_info(${table})`).all();
54
- return columns.some((c) => c.name === column);
55
- }
56
- /**
57
- * Copy every (hash of search_text, embedding) pair about to be discarded
58
- * wholesale into `embedding_salvage`, tagged with the `embeddingFingerprint`
59
- * the discarded vectors were generated under. The caller MUST run this
60
- * inside the same transaction as the discard that follows it, so the copy
61
- * and the delete commit or roll back together.
62
- *
63
- * Streams `entries JOIN embeddings` in id-ordered pages of
64
- * {@link SQLITE_CHUNK_SIZE} instead of loading every row into memory before
65
- * hashing anything — a full rebuild of a large stash otherwise held the
66
- * entire corpus's search text and vectors in memory at once just to copy
67
- * them aside (#955, field-report follow-up).
68
- *
69
- * A no-op (returns 0) when there is no stored `embeddingFingerprint` to tag
70
- * rows with (nothing was ever verified against a provider, so there is
71
- * nothing worth reusing later) or the generation being discarded predates
72
- * the `entries.search_text` column or has no `embeddings` table at all — an
73
- * older generation than that has nothing this can safely read.
74
- */
75
- export function salvageEmbeddingsBeforeDiscard(db) {
76
- const fingerprint = getMeta(db, "embeddingFingerprint");
77
- if (!fingerprint)
78
- return 0;
79
- if (!tableExists(db, "entries") || !tableExists(db, "embeddings"))
80
- return 0;
81
- if (!tableHasColumn(db, "entries", "search_text"))
82
- return 0;
83
- const page = db.prepare("SELECT e.id AS id, e.search_text AS searchText, em.embedding AS embedding " +
84
- "FROM entries e JOIN embeddings em ON em.id = e.id WHERE e.id > ? ORDER BY e.id LIMIT ?");
85
- const insert = db.prepare("INSERT OR REPLACE INTO embedding_salvage (content_hash, fingerprint, embedding, salvaged_at) VALUES (?, ?, ?, ?)");
86
- const salvagedAt = new Date().toISOString();
87
- let lastId = 0;
88
- let total = 0;
89
- for (;;) {
90
- const rows = page.all(lastId, SQLITE_CHUNK_SIZE);
91
- if (rows.length === 0)
92
- break;
93
- for (const row of rows) {
94
- insert.run(hashEmbeddableText(row.searchText), fingerprint, row.embedding, salvagedAt);
95
- }
96
- total += rows.length;
97
- lastId = rows[rows.length - 1]?.id ?? lastId;
98
- if (rows.length < SQLITE_CHUNK_SIZE)
99
- break;
100
- }
101
- return total;
102
- }
103
- /**
104
- * Remove every salvage row. Called after an embedding pass completes without
105
- * abort or circuit-break (the salvaged generation has now either been reused
106
- * or superseded), and by `--reembed` / a canary "rebuild" verdict (the
107
- * salvaged vectors belong to a different model and are never reusable).
108
- */
109
- export function purgeEmbeddingSalvage(db) {
110
- db.exec("DELETE FROM embedding_salvage");
111
- }
112
- /**
113
- * A canary "keep" verdict means the model did not actually change — only its
114
- * fingerprint STRING did (e.g. a gateway rename). Salvage rows tagged with
115
- * the old string are still valid vectors; rewrite them to the new string so
116
- * they remain reusable instead of silently going stale.
117
- */
118
- export function relabelEmbeddingSalvageFingerprint(db, fromFingerprint, toFingerprint) {
119
- db.prepare("UPDATE embedding_salvage SET fingerprint = ? WHERE fingerprint = ?").run(toFingerprint, fromFingerprint);
120
- }
121
- /**
122
- * Reuse salvaged vectors for `entries` whose `searchText` hash matches a
123
- * salvage row tagged with the CURRENT `fingerprint` — never across
124
- * fingerprints, and never when `search_text` differs by even one byte (the
125
- * hash is exact-match only, by design). Matches are written via
126
- * `writeReused` in chunks of {@link SQLITE_CHUNK_SIZE}, each its own
127
- * transaction, mirroring the main pass's per-batch commit (#955) so an
128
- * interruption partway through the reuse step keeps whatever already wrote.
129
- *
130
- * The steady state of every ordinary run is an EMPTY salvage table (nothing
131
- * was just discarded), so this checks that first with one indexed lookup —
132
- * `SELECT 1 ... LIMIT 1` — before hashing a single pending entry. Hashing
133
- * every entry up front to look up a table that is empty 100% of the time
134
- * outside a rebuild was pure wasted work on the common path (#955,
135
- * field-report follow-up).
136
- */
137
- export function reuseSalvagedEmbeddings(db, entries, fingerprint, writeReused) {
138
- if (entries.length === 0)
139
- return { reusedCount: 0, remaining: [] };
140
- const anySalvageForFingerprint = db
141
- .prepare("SELECT 1 FROM embedding_salvage WHERE fingerprint = ? LIMIT 1")
142
- .get(fingerprint);
143
- if (!anySalvageForFingerprint)
144
- return { reusedCount: 0, remaining: [...entries] };
145
- const hashes = entries.map((entry) => hashEmbeddableText(entry.searchText));
146
- const salvageByHash = new Map();
147
- const uniqueHashes = [...new Set(hashes)];
148
- for (let offset = 0; offset < uniqueHashes.length; offset += SQLITE_CHUNK_SIZE) {
149
- const chunk = uniqueHashes.slice(offset, offset + SQLITE_CHUNK_SIZE);
150
- const placeholders = chunk.map(() => "?").join(",");
151
- const rows = db
152
- .prepare(`SELECT content_hash AS contentHash, embedding FROM embedding_salvage WHERE fingerprint = ? AND content_hash IN (${placeholders})`)
153
- .all(fingerprint, ...chunk);
154
- for (const row of rows)
155
- salvageByHash.set(row.contentHash, row.embedding);
156
- }
157
- if (salvageByHash.size === 0)
158
- return { reusedCount: 0, remaining: [...entries] };
159
- let reusedCount = 0;
160
- const remaining = [];
161
- for (let offset = 0; offset < entries.length; offset += SQLITE_CHUNK_SIZE) {
162
- const end = Math.min(offset + SQLITE_CHUNK_SIZE, entries.length);
163
- const chunkMatches = [];
164
- for (let i = offset; i < end; i++) {
165
- const entry = entries[i];
166
- const blob = salvageByHash.get(hashes[i]);
167
- if (blob)
168
- chunkMatches.push({ entry, blob });
169
- else
170
- remaining.push(entry);
171
- }
172
- if (chunkMatches.length === 0)
173
- continue;
174
- db.transaction(() => {
175
- for (const { entry, blob } of chunkMatches) {
176
- if (writeReused(entry, blobToEmbedding(blob)))
177
- reusedCount++;
178
- else
179
- remaining.push(entry);
180
- }
181
- })();
182
- }
183
- return { reusedCount, remaining };
184
- }