akm-cli 0.9.15 → 0.9.16-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  3. package/dist/cli/retired-commands.js +2 -0
  4. package/dist/cli/unknown-flags.js +36 -3
  5. package/dist/commands/improve/collapse-detector.js +2 -2
  6. package/dist/commands/improve/consolidate.js +6 -4
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/proposal/repository.js +12 -3
  9. package/dist/commands/read/curate.js +34 -44
  10. package/dist/commands/read/search.js +50 -2
  11. package/dist/commands/sources/index-status.js +99 -0
  12. package/dist/commands/sources/info.js +8 -8
  13. package/dist/commands/sources/installed-stashes.js +33 -12
  14. package/dist/commands/sources/source-add.js +21 -6
  15. package/dist/commands/sources/stash-cli.js +119 -111
  16. package/dist/core/adapter/adapters/akm-adapter.js +35 -3
  17. package/dist/core/adapter/adapters/akm-metadata.js +11 -1
  18. package/dist/core/asset/asset-placement.js +35 -0
  19. package/dist/core/config/schema/embedding.js +7 -30
  20. package/dist/core/config/schema/search.js +11 -9
  21. package/dist/core/errors.js +5 -2
  22. package/dist/core/hash.js +18 -0
  23. package/dist/core/maintenance-barrier.js +8 -6
  24. package/dist/core/paths.js +0 -11
  25. package/dist/core/run-lock.js +5 -2
  26. package/dist/core/state/migrations.js +26 -1
  27. package/dist/core/state-db.js +63 -27
  28. package/dist/indexer/drain.js +306 -0
  29. package/dist/indexer/embedding-identity.js +20 -0
  30. package/dist/indexer/enrich.js +260 -0
  31. package/dist/indexer/ensure-index.js +5 -0
  32. package/dist/indexer/index-written-assets.js +133 -171
  33. package/dist/indexer/indexer.js +458 -1621
  34. package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
  35. package/dist/indexer/passes/metadata.js +18 -1
  36. package/dist/indexer/reconcile.js +890 -0
  37. package/dist/indexer/scan/drain-dir.js +27 -70
  38. package/dist/indexer/scan/parse-file.js +66 -0
  39. package/dist/indexer/search/db-search.js +373 -89
  40. package/dist/indexer/search/ranking-contributors.js +21 -16
  41. package/dist/indexer/search/ranking.js +135 -57
  42. package/dist/indexer/units/unit.js +159 -0
  43. package/dist/llm/client.js +10 -1
  44. package/dist/llm/embedder.js +10 -3
  45. package/dist/llm/embedders/provider-limits.js +288 -0
  46. package/dist/llm/embedders/remote.js +133 -104
  47. package/dist/llm/feature-gate.js +4 -2
  48. package/dist/llm/rerank-client.js +3 -3
  49. package/dist/output/shapes/passthrough.js +1 -0
  50. package/dist/output/text/command-format.js +19 -13
  51. package/dist/output/text/helpers.js +1 -1
  52. package/dist/output/text/index.js +5 -2
  53. package/dist/scripts/akm-migrate-node.js +1141 -1237
  54. package/dist/scripts/akm-migrate.js +1141 -1237
  55. package/dist/setup/semantic-assets.js +2 -2
  56. package/dist/setup/steps/connection.js +3 -2
  57. package/dist/storage/repositories/files-repository.js +181 -0
  58. package/dist/storage/repositories/index-connection.js +1 -3
  59. package/dist/storage/repositories/index-entries-repository.js +77 -68
  60. package/dist/storage/repositories/index-entry-schema.js +16 -25
  61. package/dist/storage/repositories/index-fts-repository.js +29 -263
  62. package/dist/storage/repositories/index-meta-repository.js +0 -29
  63. package/dist/storage/repositories/index-schema.js +115 -122
  64. package/dist/storage/repositories/index-utility-repository.js +1 -1
  65. package/dist/storage/repositories/index-vec-repository.js +21 -334
  66. package/dist/storage/repositories/units-repository.js +510 -0
  67. package/docs/migration/release-notes/0.9.15.md +34 -36
  68. package/docs/migration/release-notes/0.9.16.md +110 -0
  69. package/docs/migration/release-notes/README.md +5 -0
  70. package/docs/reference/cli.md +93 -87
  71. package/docs/reference/configuration.md +128 -89
  72. package/docs/reference/data-and-telemetry.md +2 -1
  73. package/package.json +1 -1
  74. package/schemas/akm-config.json +2 -58
  75. package/dist/indexer/index-db-contention.js +0 -56
  76. package/dist/indexer/index-rebuild-lock.js +0 -73
  77. package/dist/indexer/materialize-embeddings.js +0 -771
  78. package/dist/indexer/passes/dir-staleness.js +0 -161
  79. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
@@ -0,0 +1,510 @@
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-unit repository (docs/plans/index-fragment-vectors.md).
6
+ *
7
+ * Owns the durable, content-addressed vector store: `units_vec` (the vec0
8
+ * table, the ONE copy of every embedding), `units` (a plain lookup table
9
+ * mirroring `units_vec`'s `(unit_hash, identity)` key — vec0 auxiliary
10
+ * columns are not indexable, so a point lookup or an `IN (...)` filter on
11
+ * `units_vec` directly would be a full scan), and `entry_units` (the cheap,
12
+ * derived entry → ordinal → unit_hash mapping that a reindex rebuilds).
13
+ *
14
+ * Lifecycle invariant (the whole design, docs/plans/index-fragment-vectors.md
15
+ * "Vectors are content-addressed and never rebuilt"): `units` and `units_vec`
16
+ * are created if missing and NEVER dropped by a generation rebuild, by
17
+ * `akm index --full`, or by any embeddings purge — only {@link dropOtherIdentities}
18
+ * removes rows, and only for an identity other than the one being kept. A row
19
+ * exists in `units`/`units_vec` if and only if a vector was actually written
20
+ * for it: {@link upsertUnitVectors} and {@link dropOtherIdentities} are both
21
+ * no-ops when sqlite-vec is unavailable, rather than recording a hash with no
22
+ * vector behind it (which would make {@link listMissingHashes} lie).
23
+ */
24
+ import { ConfigError } from "../../core/errors.js";
25
+ import { withImmediateTransaction } from "../../core/state-db.js";
26
+ import { getMeta } from "./index-meta-repository.js";
27
+ import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
28
+ import { isVecAvailable } from "./index-vec-repository.js";
29
+ // ── Schema ──────────────────────────────────────────────────────────────────
30
+ function createUnitsVecTable(db, dim) {
31
+ db.exec(`
32
+ CREATE VIRTUAL TABLE units_vec USING vec0(
33
+ unit_id INTEGER PRIMARY KEY,
34
+ embedding FLOAT[${dim}],
35
+ +unit_hash TEXT,
36
+ +identity TEXT
37
+ );
38
+ `);
39
+ }
40
+ function tableExists(db, name) {
41
+ return db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) != null;
42
+ }
43
+ /**
44
+ * The vector width `units_vec` was created at, read back from its own DDL
45
+ * (`sqlite_master.sql` carries the `CREATE VIRTUAL TABLE` text verbatim) so
46
+ * there is one source of truth for "what width is this table at right now" —
47
+ * no separate meta key to keep in sync. Returns `undefined` when the table
48
+ * does not exist.
49
+ */
50
+ function unitsVecDimension(db) {
51
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'units_vec'").get();
52
+ const match = row?.sql.match(/FLOAT\[(\d+)\]/);
53
+ return match?.[1] === undefined ? undefined : Number(match[1]);
54
+ }
55
+ /**
56
+ * Create `units`, `entry_units` and (when sqlite-vec is loaded) `units_vec`
57
+ * if they do not already exist. Idempotent and safe to call on every schema
58
+ * ensure — it never touches an existing table's rows or drops anything.
59
+ *
60
+ * `dim` only matters the first time `units_vec` is created; an existing table
61
+ * keeps its width until {@link dropOtherIdentities} recreates it for a real
62
+ * dimension change.
63
+ */
64
+ export function ensureUnitTables(db, dim) {
65
+ db.exec(`
66
+ CREATE TABLE IF NOT EXISTS units (
67
+ unit_id INTEGER PRIMARY KEY,
68
+ unit_hash TEXT NOT NULL,
69
+ identity TEXT NOT NULL,
70
+ UNIQUE (unit_hash, identity)
71
+ );
72
+ CREATE TABLE IF NOT EXISTS entry_units (
73
+ entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
74
+ ordinal INTEGER NOT NULL,
75
+ fragment_id TEXT,
76
+ unit_hash TEXT NOT NULL,
77
+ PRIMARY KEY (entry_id, ordinal)
78
+ );
79
+ CREATE INDEX IF NOT EXISTS entry_units_hash ON entry_units(unit_hash);
80
+ `);
81
+ // Mirrors entries_vec: a vec0 virtual table needs the extension loaded to
82
+ // even be created. `units`/`entry_units` above are plain tables and exist
83
+ // regardless, so lookups and the entry→hash mapping keep working; only the
84
+ // KNN path is unavailable (see `searchUnits`).
85
+ if (!isVecAvailable(db))
86
+ return;
87
+ if (tableExists(db, "units_vec"))
88
+ return;
89
+ createUnitsVecTable(db, dim);
90
+ }
91
+ // ── Vector storage ──────────────────────────────────────────────────────────
92
+ function float32Buffer(vector) {
93
+ return Buffer.from(new Float32Array(vector).buffer);
94
+ }
95
+ /**
96
+ * Write new (hash, identity) → vector rows. Rows whose (hash, identity)
97
+ * already exists in `units` are left as-is in `units` (content-addressed: the
98
+ * same text under the same identity is the same vector) but their `units_vec`
99
+ * row is still rewritten, so a prior run that wrote the `units` row but failed
100
+ * before its vec0 mirror (or ran while sqlite-vec was unavailable) self-heals
101
+ * on the next call instead of leaving a permanent gap `searchUnits` can never
102
+ * see.
103
+ *
104
+ * A no-op returning `{ inserted: 0, failed: 0 }` when sqlite-vec is
105
+ * unavailable: there is no BLOB fallback table for units
106
+ * (docs/plans/index-fragment-vectors.md — one copy of every vector, in
107
+ * vec0), so writing a `units` row with no vector behind it would make it
108
+ * look present to {@link listMissingHashes} forever.
109
+ *
110
+ * Each row commits in its OWN transaction (not one transaction for the whole
111
+ * batch): a vec0 insert can throw — most concretely a vector-width mismatch,
112
+ * "Dimension mismatch for inserted vector" — and a single malformed or
113
+ * wrong-width row from an otherwise-good provider response must not roll
114
+ * back every other row this call already wrote. A row that fails is left out
115
+ * of `units` entirely (any `units` placeholder this call itself just created
116
+ * for it is deleted again), not left dangling with no vector behind it, so
117
+ * {@link listMissingHashes} still sees it as missing and retries it on the
118
+ * next drain — the same "no row without a vector" invariant the module
119
+ * doc above states, just enforced per-row instead of per-batch.
120
+ */
121
+ export function upsertUnitVectors(db, rows) {
122
+ if (rows.length === 0 || !isVecAvailable(db))
123
+ return { inserted: 0, failed: 0 };
124
+ const insertUnit = db.prepare("INSERT INTO units (unit_hash, identity) VALUES (?, ?) ON CONFLICT(unit_hash, identity) DO NOTHING");
125
+ const selectUnitId = db.prepare("SELECT unit_id FROM units WHERE unit_hash = ? AND identity = ?");
126
+ const deleteVec = db.prepare("DELETE FROM units_vec WHERE unit_id = ?");
127
+ const insertVec = db.prepare("INSERT INTO units_vec (unit_id, embedding, unit_hash, identity) VALUES (?, ?, ?, ?)");
128
+ const deleteUnit = db.prepare("DELETE FROM units WHERE unit_id = ?");
129
+ // Each row gets its OWN `BEGIN IMMEDIATE` transaction (not a shared bare
130
+ // `db.transaction()`), for two reasons: a bare deferred transaction whose
131
+ // body reads (selectUnitId) before it writes fails instantly with
132
+ // SQLITE_BUSY under a competing writer instead of honouring
133
+ // `busy_timeout` (docs/plans/index-redesign.md rule 5), and this call
134
+ // must still commit PER ROW even once immediate — a malformed vector must
135
+ // not roll back the rest of a provider batch (see the docstring above).
136
+ const writeOne = (row) => withImmediateTransaction(db, () => {
137
+ const result = insertUnit.run(row.hash, row.identity);
138
+ const freshlyInserted = Number(result.changes) > 0;
139
+ const unitRow = selectUnitId.get(row.hash, row.identity);
140
+ if (!unitRow)
141
+ return false;
142
+ // DELETE-then-INSERT (not INSERT OR REPLACE) — the established pattern
143
+ // for writing a fixed-rowid row into a vec0 table on this driver.
144
+ deleteVec.run(unitRow.unit_id);
145
+ try {
146
+ insertVec.run(unitRow.unit_id, float32Buffer(row.vector), row.hash, row.identity);
147
+ }
148
+ catch (err) {
149
+ // Undo the vector-less `units` row this call would otherwise leave
150
+ // behind — whether it was just created above or already existed (its
151
+ // prior vector, if any, is already gone via deleteVec either way).
152
+ deleteUnit.run(unitRow.unit_id);
153
+ throw err;
154
+ }
155
+ return freshlyInserted;
156
+ }, "index");
157
+ let inserted = 0;
158
+ let failed = 0;
159
+ for (const row of rows) {
160
+ try {
161
+ if (writeOne(row))
162
+ inserted++;
163
+ }
164
+ catch {
165
+ failed++;
166
+ }
167
+ }
168
+ return { inserted, failed };
169
+ }
170
+ /**
171
+ * Which of `hashes` have no vector yet for `identity`. The set-difference
172
+ * that drives the whole indexing loop (docs/plans/index-fragment-vectors.md
173
+ * "Indexing is a set difference"): callers hash every unit's text, ask what is
174
+ * missing, and only send that difference to the provider.
175
+ *
176
+ * Deduplicated and returned in first-occurrence order — the same unit text
177
+ * commonly recurs across entries (content-addressed), so de-duping here is
178
+ * what keeps a shared fragment from being requested from the provider twice
179
+ * in one pass.
180
+ */
181
+ export function listMissingHashes(db, hashes, identity) {
182
+ const unique = [...new Set(hashes)];
183
+ if (unique.length === 0)
184
+ return [];
185
+ const present = new Set();
186
+ for (let offset = 0; offset < unique.length; offset += SQLITE_CHUNK_SIZE) {
187
+ const chunk = unique.slice(offset, offset + SQLITE_CHUNK_SIZE);
188
+ const placeholders = chunk.map(() => "?").join(",");
189
+ const rows = db
190
+ .prepare(`SELECT unit_hash FROM units WHERE identity = ? AND unit_hash IN (${placeholders})`)
191
+ .all(identity, ...chunk);
192
+ for (const row of rows)
193
+ present.add(row.unit_hash);
194
+ }
195
+ return unique.filter((hash) => !present.has(hash));
196
+ }
197
+ /**
198
+ * Remove rows for every identity except `keep`, recreating `units_vec` at
199
+ * `dim` first when its current width differs from `dim`.
200
+ *
201
+ * vec0's embedding column width is fixed for the whole table, so a genuine
202
+ * dimension change (a real model swap, not a rename) cannot be expressed as a
203
+ * row-level DELETE — the table is dropped and recreated instead. Any row that
204
+ * survives a width mismatch could only belong to `keep`, and it could only
205
+ * exist if it had already been written at `dim` — which the OLD, differently
206
+ * sized table would have rejected — so nothing behind `keep` is lost by the
207
+ * drop.
208
+ *
209
+ * The width check runs UNCONDITIONALLY, independent of `staleCount` (E6): a
210
+ * fresh index (or one whose only prior identity was already dropped) has
211
+ * zero rows under any identity, so gating the width check behind
212
+ * `staleCount > 0` — the round-2 shape of this bug — meant a `units_vec`
213
+ * created at a stale/misconfigured width (e.g. `embedding.dimension` set to
214
+ * a value the provider does not actually return) could never be corrected:
215
+ * every write fails "Dimension mismatch for inserted vector", the row this
216
+ * call itself just tried to write is discarded, `units` stays empty, and
217
+ * `staleCount` stays 0 forever — not even `akm index --reembed` (which
218
+ * reaches this same function) can recover, only deleting index.db could.
219
+ * Checking the width regardless of `staleCount` costs nothing extra when
220
+ * nothing is stale (`unitsVecDimension` is one cheap `sqlite_master` read)
221
+ * and fixes exactly this: the very first call for a new width recreates the
222
+ * table before anything is ever written under it.
223
+ *
224
+ * The drop/create/delete (or delete-stale-rows/delete) sequence runs in ONE
225
+ * transaction (E5a): as two separate autocommit statements, a concurrent
226
+ * `upsertUnitVectors` landing between the `DROP TABLE` and the `CREATE
227
+ * TABLE` would silently lose an already-paid-for embedding (counted
228
+ * `failed` and discarded) rather than simply retrying against the
229
+ * recreated table.
230
+ *
231
+ * A no-op when sqlite-vec is unavailable: `units`/`units_vec` only ever gain
232
+ * rows when a vector was actually written (see {@link upsertUnitVectors}), so
233
+ * there is nothing to remove and no vec0 table this could safely touch.
234
+ */
235
+ export function dropOtherIdentities(db, keep, dim) {
236
+ if (!isVecAvailable(db))
237
+ return { removed: 0 };
238
+ ensureUnitTables(db, dim);
239
+ const currentDim = unitsVecDimension(db);
240
+ const widthChanged = currentDim !== undefined && currentDim !== dim;
241
+ const staleCount = db.prepare("SELECT COUNT(*) AS n FROM units WHERE identity != ?").get(keep).n;
242
+ if (staleCount === 0 && !widthChanged)
243
+ return { removed: 0 };
244
+ withImmediateTransaction(db, () => {
245
+ if (widthChanged) {
246
+ db.exec("DROP TABLE IF EXISTS units_vec");
247
+ createUnitsVecTable(db, dim);
248
+ }
249
+ else {
250
+ const staleIds = db.prepare("SELECT unit_id FROM units WHERE identity != ?").all(keep).map((row) => row.unit_id);
251
+ for (let offset = 0; offset < staleIds.length; offset += SQLITE_CHUNK_SIZE) {
252
+ const chunk = staleIds.slice(offset, offset + SQLITE_CHUNK_SIZE);
253
+ const placeholders = chunk.map(() => "?").join(",");
254
+ db.prepare(`DELETE FROM units_vec WHERE unit_id IN (${placeholders})`).run(...chunk);
255
+ }
256
+ }
257
+ db.prepare("DELETE FROM units WHERE identity != ?").run(keep);
258
+ }, "index");
259
+ return { removed: staleCount };
260
+ }
261
+ // ── Entry → unit mapping ────────────────────────────────────────────────────
262
+ /** Replace every `entry_units` row for `entryId` with `units` (delete-then-insert). */
263
+ export function replaceEntryUnits(db, entryId, units) {
264
+ // `applyChange` (indexer/reconcile.ts) normally already holds an outer
265
+ // `withImmediateTransaction` when it calls this — the join-if-open guard
266
+ // means this runs inside that transaction rather than opening its own.
267
+ withImmediateTransaction(db, () => {
268
+ db.prepare("DELETE FROM entry_units WHERE entry_id = ?").run(entryId);
269
+ const insert = db.prepare("INSERT INTO entry_units (entry_id, ordinal, fragment_id, unit_hash) VALUES (?, ?, ?, ?)");
270
+ for (const unit of units) {
271
+ insert.run(entryId, unit.ordinal, unit.fragmentId, unit.hash);
272
+ }
273
+ }, "index");
274
+ }
275
+ /** Remove every `entry_units` row for the given entries (e.g. entries deleted outside a cascade). */
276
+ export function deleteEntryUnits(db, entryIds) {
277
+ const unique = [...new Set(entryIds)];
278
+ if (unique.length === 0)
279
+ return;
280
+ for (let offset = 0; offset < unique.length; offset += SQLITE_CHUNK_SIZE) {
281
+ const chunk = unique.slice(offset, offset + SQLITE_CHUNK_SIZE);
282
+ const placeholders = chunk.map(() => "?").join(",");
283
+ db.prepare(`DELETE FROM entry_units WHERE entry_id IN (${placeholders})`).run(...chunk);
284
+ }
285
+ }
286
+ // ── Search ──────────────────────────────────────────────────────────────────
287
+ /**
288
+ * vec0 0.1.9 rejects a KNN query that also filters on an auxiliary column —
289
+ * "An illegal WHERE constraint was provided on a vec0 auxiliary column in a
290
+ * KNN query" — so `identity` cannot be pushed into the `MATCH` query itself
291
+ * and has to be filtered afterward. Over-fetching by this factor keeps that
292
+ * post-filter from starving recall during the rare, transient window where
293
+ * more than one identity's rows coexist in `units_vec` (the steady state,
294
+ * enforced by {@link dropOtherIdentities}, is exactly one identity).
295
+ *
296
+ * index-redesign integration note (kept, not dropped): the notes proposed
297
+ * dropping this filter/overfetch entirely for B5 and asserting the
298
+ * one-identity invariant where the store is written instead, keeping the
299
+ * overfetch only if a test proves two identities can coexist. One does:
300
+ * `tests/storage/units-repository.test.ts`'s "searchUnits returns nearest
301
+ * units first, scoped by identity" writes two identities into `units_vec`
302
+ * via the real {@link upsertUnitVectors} (not a raw-SQL seed) and asserts
303
+ * `searchUnits` still returns only the requested identity's nearest rows —
304
+ * without this post-filter that test's own tied-vector fixture would drop
305
+ * a real hit for the requested identity, not merely tolerate a stray one.
306
+ * `identity` is also a required part of this function's stage-1 contract
307
+ * signature, used by every real caller (B3's semantic search branch), so an
308
+ * unenforced parameter here would be a silent footgun. Left in place; no
309
+ * hard invariant assertion added elsewhere, since the transient
310
+ * two-identity window this docblock already describes is a real,
311
+ * non-error state a hard assertion would wrongly reject.
312
+ */
313
+ const UNIT_SEARCH_OVERFETCH = 4;
314
+ /**
315
+ * Which of a KNN candidate batch's unit hashes have an owning entry (via
316
+ * `entry_units` → `entries`) satisfying both a `typeFilter` and an
317
+ * `excludeTypes` predicate at once (index-redesign search-fix item 3).
318
+ *
319
+ * vec0 rejects a KNN query that also filters on an auxiliary column ("An
320
+ * illegal WHERE constraint was provided on a vec0 auxiliary column in a KNN
321
+ * query"), so the type predicate cannot be pushed INTO the `embedding MATCH`
322
+ * query itself. Solved instead by joining AROUND the KNN: run the KNN
323
+ * untyped (already over-fetching via {@link UNIT_SEARCH_OVERFETCH}), then
324
+ * filter the raw candidate hashes through `entry_units`/`entries` BEFORE the
325
+ * `k` cap below — the same "filter before the cap" fix as the lexical side
326
+ * (`runUnitsFtsQuery`, db-search.ts), so an eligible neighbor is never pushed
327
+ * out of the window by ineligible ones that rank closer.
328
+ */
329
+ function eligibleUnitHashesByType(db, hashes, typeOpts) {
330
+ const eligible = new Set();
331
+ const clauses = [];
332
+ const baseParams = [];
333
+ if (typeOpts.typeFilter?.length) {
334
+ clauses.push(`e.type IN (${typeOpts.typeFilter.map(() => "?").join(",")})`);
335
+ baseParams.push(...typeOpts.typeFilter);
336
+ }
337
+ if (typeOpts.excludeTypes?.length) {
338
+ clauses.push(`e.type NOT IN (${typeOpts.excludeTypes.map(() => "?").join(",")})`);
339
+ baseParams.push(...typeOpts.excludeTypes);
340
+ }
341
+ for (let offset = 0; offset < hashes.length; offset += SQLITE_CHUNK_SIZE) {
342
+ const chunk = hashes.slice(offset, offset + SQLITE_CHUNK_SIZE);
343
+ const placeholders = chunk.map(() => "?").join(",");
344
+ const rows = db
345
+ .prepare(`SELECT DISTINCT eu.unit_hash AS hash FROM entry_units eu JOIN entries e ON e.id = eu.entry_id
346
+ WHERE eu.unit_hash IN (${placeholders}) AND ${clauses.join(" AND ")}`)
347
+ .all(...chunk, ...baseParams);
348
+ for (const row of rows)
349
+ eligible.add(row.hash);
350
+ }
351
+ return eligible;
352
+ }
353
+ /**
354
+ * KNN search over `units_vec` for the given `identity`, nearest first.
355
+ * Throws a {@link ConfigError} when sqlite-vec is not loaded — there is no JS
356
+ * fallback for units (docs/plans/index-fragment-vectors.md), so the caller is
357
+ * expected to catch this and fall back to lexical search.
358
+ *
359
+ * `typeOpts`, when given, is applied to the raw KNN candidates BEFORE the `k`
360
+ * cap (see {@link eligibleUnitHashesByType} for how — vec0 cannot filter this
361
+ * inside the KNN query itself), the same fix `runUnitsFtsQuery` applies on
362
+ * the lexical side.
363
+ */
364
+ export function searchUnits(db, query, k, identity, typeOpts) {
365
+ if (!isVecAvailable(db)) {
366
+ throw new ConfigError("sqlite-vec is not loaded, so unit search cannot run its vec0 KNN query. Fall back to lexical search.", "EMBEDDING_VEC_UNAVAILABLE");
367
+ }
368
+ if (k <= 0)
369
+ return [];
370
+ const rows = db
371
+ .prepare("SELECT unit_id AS unitId, unit_hash AS hash, identity AS identity, distance AS distance " +
372
+ "FROM units_vec WHERE embedding MATCH ? AND k = ?")
373
+ .all(float32Buffer(query), k * UNIT_SEARCH_OVERFETCH);
374
+ const identityFiltered = rows.filter((row) => row.identity === identity);
375
+ const hasTypeFilter = Boolean(typeOpts?.typeFilter?.length || typeOpts?.excludeTypes?.length);
376
+ const typed = hasTypeFilter
377
+ ? (() => {
378
+ const eligible = eligibleUnitHashesByType(db, [...new Set(identityFiltered.map((row) => row.hash))], typeOpts);
379
+ return identityFiltered.filter((row) => eligible.has(row.hash));
380
+ })()
381
+ : identityFiltered;
382
+ return typed.slice(0, k).map(({ unitId, hash, distance }) => ({ unitId, hash, distance }));
383
+ }
384
+ /**
385
+ * Group unit hits to their owning entries, keeping the best (lowest distance)
386
+ * unit per entry. A unit hash can back more than one `entry_units` row (the
387
+ * same fragment text shared across entries, or multiple hits sharing a hash
388
+ * under different identities in the rare transient window `searchUnits`
389
+ * already tolerates), so both the hit list and the hash→entries fan-out are
390
+ * reduced to a minimum by distance.
391
+ */
392
+ export function groupUnitHitsByEntry(db, hits) {
393
+ const result = new Map();
394
+ if (hits.length === 0)
395
+ return result;
396
+ const distanceByHash = new Map();
397
+ for (const hit of hits) {
398
+ const existing = distanceByHash.get(hit.hash);
399
+ if (existing === undefined || hit.distance < existing)
400
+ distanceByHash.set(hit.hash, hit.distance);
401
+ }
402
+ const hashes = [...distanceByHash.keys()];
403
+ for (let offset = 0; offset < hashes.length; offset += SQLITE_CHUNK_SIZE) {
404
+ const chunk = hashes.slice(offset, offset + SQLITE_CHUNK_SIZE);
405
+ const placeholders = chunk.map(() => "?").join(",");
406
+ const rows = db
407
+ .prepare(`SELECT entry_id AS entryId, fragment_id AS fragmentId, unit_hash AS hash FROM entry_units WHERE unit_hash IN (${placeholders})`)
408
+ .all(...chunk);
409
+ for (const row of rows) {
410
+ const distance = distanceByHash.get(row.hash);
411
+ if (distance === undefined)
412
+ continue;
413
+ const existing = result.get(row.entryId);
414
+ if (!existing || distance < existing.distance) {
415
+ result.set(row.entryId, { distance, fragmentId: row.fragmentId, hash: row.hash });
416
+ }
417
+ }
418
+ }
419
+ return result;
420
+ }
421
+ /**
422
+ * The number of raw unit hits `getNeighborsByEntryId` over-fetches relative
423
+ * to the requested `k` other entries, one-sentence reason: an entry can own
424
+ * several units (its card plus every fragment), so the nearest raw unit hits
425
+ * collapse into fewer distinct OTHER entries once grouped by entry and the
426
+ * querying entry's own units are excluded — over-fetching keeps that
427
+ * collapse from starving the requested `k`.
428
+ */
429
+ const NEIGHBOR_CANDIDATE_OVERFETCH = 4;
430
+ /**
431
+ * Decode a `units_vec` embedding BLOB (the same `Buffer.from(new
432
+ * Float32Array(vector).buffer)` layout {@link upsertUnitVectors} writes) back
433
+ * into a plain vector, dimension inferred from byte length.
434
+ */
435
+ function float32BufferToVector(buf) {
436
+ return Array.from(new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.byteLength / 4)));
437
+ }
438
+ /**
439
+ * The `k` nearest OTHER entries to `id`, by distance between their card
440
+ * units (`entry_units` ordinal 0) under the active embedding identity —
441
+ * index-redesign-contract.md B5f item 4's replacement for the legacy
442
+ * `embeddings`/`entries_vec`-backed `getNeighborsByEntryId`
443
+ * (`index-vec-repository.ts`), which nothing wrote to since the units path
444
+ * became the only index. Those tables themselves — and everything that only
445
+ * ever read or wrote them — were deleted in B5h.
446
+ *
447
+ * No re-embedding, no network: reads the entry's own already-indexed card
448
+ * vector via a `units` rowid lookup (not a `units_vec` aux-column scan) and
449
+ * reuses {@link searchUnits}'s KNN, then groups the raw unit hits back to
450
+ * entries the same way search does ({@link groupUnitHitsByEntry}).
451
+ * The querying entry is excluded from its own result — its card is its own
452
+ * nearest neighbour at distance 0, and a caller asking for `k` neighbours
453
+ * wants `k` genuinely OTHER entries, not one slot spent confirming an entry
454
+ * is close to itself.
455
+ *
456
+ * Returns `[]` when there is no active identity yet, `id` has no card unit,
457
+ * or that unit has no vector for the active identity (drain has not reached
458
+ * it) — the caller (`consolidate.ts`'s `narrowToIncrementalCandidates`) fails
459
+ * open to the full pool on an empty/unusable result, same as it did for the
460
+ * legacy table being absent.
461
+ */
462
+ export function getNeighborsByEntryId(db, id, k) {
463
+ if (k <= 0)
464
+ return [];
465
+ const identity = getMeta(db, "embeddingIdentity");
466
+ if (!identity)
467
+ return [];
468
+ const cardRow = db
469
+ .prepare("SELECT unit_hash AS unitHash FROM entry_units WHERE entry_id = ? AND ordinal = 0")
470
+ .get(id);
471
+ if (!cardRow)
472
+ return [];
473
+ const unitRow = db
474
+ .prepare("SELECT unit_id AS unitId FROM units WHERE unit_hash = ? AND identity = ?")
475
+ .get(cardRow.unitHash, identity);
476
+ if (!unitRow)
477
+ return [];
478
+ const vecRow = db.prepare("SELECT embedding FROM units_vec WHERE unit_id = ?").get(unitRow.unitId);
479
+ if (!vecRow)
480
+ return [];
481
+ const queryVector = float32BufferToVector(vecRow.embedding);
482
+ if (queryVector.length === 0)
483
+ return [];
484
+ const hits = searchUnits(db, queryVector, k * NEIGHBOR_CANDIDATE_OVERFETCH, identity);
485
+ const byEntry = groupUnitHitsByEntry(db, hits);
486
+ byEntry.delete(id);
487
+ return [...byEntry.entries()]
488
+ .sort((a, b) => a[1].distance - b[1].distance)
489
+ .slice(0, k)
490
+ .map(([entryId, match]) => ({ id: entryId, distance: match.distance }));
491
+ }
492
+ // ── Coverage ────────────────────────────────────────────────────────────────
493
+ /** How much of the current `entry_units` mapping has a vector for `identity`, for progress reporting. */
494
+ export function unitCoverage(db, identity) {
495
+ const entries = db.prepare("SELECT COUNT(DISTINCT entry_id) AS n FROM entry_units").get().n;
496
+ const unitsTotal = db.prepare("SELECT COUNT(*) AS n FROM entry_units").get().n;
497
+ const unitsPresent = db
498
+ .prepare("SELECT COUNT(*) AS n FROM entry_units eu JOIN units u ON u.unit_hash = eu.unit_hash AND u.identity = ?")
499
+ .get(identity).n;
500
+ const entriesFullyCovered = db
501
+ .prepare(`SELECT COUNT(*) AS n FROM (
502
+ SELECT eu.entry_id
503
+ FROM entry_units eu
504
+ LEFT JOIN units u ON u.unit_hash = eu.unit_hash AND u.identity = ?
505
+ GROUP BY eu.entry_id
506
+ HAVING COUNT(*) = COUNT(u.unit_id)
507
+ )`)
508
+ .get(identity).n;
509
+ return { entries, entriesFullyCovered, unitsTotal, unitsPresent };
510
+ }
@@ -85,27 +85,33 @@ scheduled or opportunistic run step aside instead of contending with a rebuild
85
85
  already in progress; the shipped `index-refresh` scheduled task already passes
86
86
  it.
87
87
 
88
- `embedding.maxTokens`'s default (the per-request token budget) is now 6000,
89
- down from 8000: a field report on an 8192-token llama.cpp embedder showed the
90
- 4-chars-per-token estimator undercounts dense technical text by 7-55%, so the
91
- old default regularly overshot the endpoint's real context window. If you
92
- already set `embedding.maxTokens` explicitly, this default change does not
93
- affect you — your configured value is unchanged. `akm index` also now
94
- recovers automatically within a run: on the first request rejected for
95
- exceeding the endpoint's context window, it lowers its effective budget for
96
- the rest of that run (reported with one line) rather than continuing to hit
97
- the same wall on every following batch.
88
+ `akm index` now packs embedding requests against the provider's own probed
89
+ context window and slot count (llama.cpp's `GET /props`, Ollama's
90
+ `POST /api/show`) instead of a flat configured token budget: an 8192-token
91
+ llama.cpp embedder, for example, is packed against its real 8192 rather
92
+ than a generic guess. An endpoint that answers neither probe (an
93
+ OpenAI-compatible server, a gateway) gets a conservative built-in default,
94
+ with the same run-scoped recovery as before — on the first request rejected
95
+ for exceeding the endpoint's context window, `akm index` lowers its
96
+ effective budget for the rest of that run (reported with one line) rather
97
+ than continuing to hit the same wall on every following batch; a window akm
98
+ actually probed from the endpoint is treated as authoritative and is never
99
+ second-guessed this way. `embedding.maxTokens` and `embedding.batchSize`
100
+ (the previous per-request token-budget and document-count config keys) are
101
+ retired — see [Retired Configuration](../../reference/configuration.md#retired-configuration)
102
+ — since the provider's own limits are now the source of truth.
98
103
 
99
104
  `embedding.concurrency` (positive integer, 1-16) overrides the number of
100
105
  embedding requests kept in flight at once, which otherwise defaults to 1 for
101
- a loopback endpoint and 2 for a remote one. Set it only for an endpoint that
102
- genuinely serves parallel requests — a local model server started with a
103
- multi-slot flag (llama.cpp's `--parallel N`, vLLM) — since the default
104
- already protects an ordinary single-slot server from reload-thrash.
105
- Embedding throughput is still tuned first by `embedding.batchSize`
106
- (documents per request) and `embedding.maxTokens` (token
107
- budget per request); the concurrency override is a second lever for a
108
- server that can actually use it.
106
+ a loopback endpoint and 2 for a remote one, or the provider's own probed
107
+ slot count (llama.cpp's `total_slots`) when the probe reports one. Set an
108
+ explicit override only for an endpoint that genuinely serves parallel
109
+ requests — a local model server started with a multi-slot flag (llama.cpp's
110
+ `--parallel N`, vLLM) — since the default already protects an ordinary
111
+ single-slot server from reload-thrash. Embedding throughput is still tuned
112
+ first by request SIZE — the probed token budget and document-count cap
113
+ above; the concurrency override is a second lever for a server that can
114
+ actually use it.
109
115
 
110
116
  `embedding.timeoutMs` bounds each embedding request (default 120s, up from a
111
117
  prior fixed 30s that cut off a slow local model server mid-response). It is
@@ -142,24 +148,16 @@ this is a drop-in fix for anyone running `akm` under a scheduler,
142
148
  supervisor, or hook that can time out or kill the launcher process
143
149
  directly.
144
150
 
145
- `embedding.maxInputTokens` (default 512) now caps how much of a single
146
- document's text is sent to the embedding provider, truncating to the head
147
- instead of ever failing a whole batch over one oversized document.
148
-
149
- - An existing install's already-stored vectors are untouched and stay
150
- valid — this only changes what happens for entries embedded *after*
151
- upgrading.
152
- - New embeddings (any entry indexed for the first time, or re-indexed after
153
- a content change) go through the new 512-token cap by default. If you
154
- were relying on documents longer than ~2000 characters being embedded in
155
- full, set `embedding.maxInputTokens` higher in `config.json`.
156
- - `akm index --reembed` re-embeds every entry under the new cap — run it if
157
- you want your entire existing index rebuilt against the new default (or a
158
- custom `embedding.maxInputTokens` you've set).
159
- - `embedding.contextLength` is Ollama's `num_ctx` only now; it no longer
160
- also sets the per-request token budget (`embedding.maxTokens`). If you had
161
- set `contextLength` specifically to control request batching (not your
162
- Ollama server's context window), set `embedding.maxTokens` instead.
151
+ A large document is no longer truncated for embedding — `akm index` splits
152
+ it into multiple content-addressed units instead (one vector per section,
153
+ sized against the embedding provider's own probed context window), so a
154
+ long document's later sections are searchable too, not silently dropped
155
+ past a head cap. `embedding.maxInputTokens`, the earlier per-document
156
+ truncation cap this superseded within the same 0.9.15 cycle, is retired —
157
+ see [Retired Configuration](../../reference/configuration.md#retired-configuration).
158
+ `embedding.contextLength` is retired too: Ollama's `num_ctx` is now sent
159
+ automatically from the same probe, or set explicitly via
160
+ `embedding.ollamaOptions.num_ctx`.
163
161
 
164
162
  **Which token knob fixed the original 8k-context overflow.** A 0.9.15-beta
165
163
  field report described documents estimated under the request budget that