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
|
@@ -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
|
-
}
|