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
@@ -78,12 +78,12 @@ export async function prepareSemanticSearchAssets(config) {
78
78
  p.log.info("sqlite-vec is available for fast vector search.");
79
79
  }
80
80
  else {
81
- p.log.info("sqlite-vec is not available. Semantic search will use the JS fallback until the optional extension is installed.");
81
+ p.log.info("sqlite-vec is not available. Semantic search will stay unavailable (keyword search still works) until the optional extension is installed.");
82
82
  }
83
83
  }
84
84
  catch (error) {
85
85
  const message = error instanceof Error ? error.message : String(error);
86
- p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will use the JS fallback. (${message})\n` +
86
+ p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will stay unavailable. (${message})\n` +
87
87
  "Check file permissions and available disk space in the cache directory, or run `akm index --full --verbose` to diagnose.");
88
88
  }
89
89
  finally {
@@ -89,8 +89,9 @@ export async function stepOllama(current) {
89
89
  " • qwen3-embedding-0.6b — fast and lightweight (ollama pull qwen3-embedding-0.6b)",
90
90
  " • qwen3-embedding-4b — higher quality (ollama pull qwen3-embedding-4b)",
91
91
  "",
92
- "For long documents (wiki pages, large files), set context length to avoid 400 errors:",
93
- " akm config set embedding.contextLength 8192",
92
+ "akm index probes Ollama for this model's own context window and sends it as",
93
+ "num_ctx automatically — no config needed. To override, set",
94
+ "embedding.ollamaOptions.num_ctx explicitly.",
94
95
  ].join("\n"), "Embedding tips");
95
96
  }
96
97
  // else: undefined → use built-in local
@@ -0,0 +1,181 @@
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
+ import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
5
+ // ── Schema ──────────────────────────────────────────────────────────────────
6
+ /**
7
+ * Create `files`, `unit_texts` and `units_fts` if they do not already exist.
8
+ * Idempotent; never drops or rewrites an existing row. Safe to call on every
9
+ * schema ensure, same as `units-repository.ts`'s `ensureUnitTables`.
10
+ */
11
+ export function ensureFileAndUnitTextTables(db) {
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS files (
14
+ path TEXT PRIMARY KEY,
15
+ bundle_id TEXT NOT NULL,
16
+ size INTEGER NOT NULL,
17
+ mtime_ms REAL NOT NULL,
18
+ ctime_ms REAL NOT NULL DEFAULT -1,
19
+ blob_hash TEXT NOT NULL,
20
+ adapter_id TEXT NOT NULL DEFAULT ''
21
+ );
22
+ CREATE INDEX IF NOT EXISTS files_bundle ON files(bundle_id);
23
+
24
+ CREATE TABLE IF NOT EXISTS unit_texts (
25
+ unit_hash TEXT PRIMARY KEY,
26
+ kind TEXT NOT NULL CHECK (kind IN ('card','fragment')),
27
+ text TEXT NOT NULL
28
+ );
29
+ -- index-redesign-contract.md B5f item 2: search runs the lexical query as
30
+ -- two kind-scoped lists (card, fragment) so a name/description match
31
+ -- ranks in its own small pool instead of competing with body text in one
32
+ -- BM25 pool. That kind filter runs on every search, so it needs an index.
33
+ CREATE INDEX IF NOT EXISTS unit_texts_kind ON unit_texts(kind);
34
+
35
+ CREATE VIRTUAL TABLE IF NOT EXISTS units_fts USING fts5(
36
+ unit_hash UNINDEXED, text, tokenize='porter unicode61'
37
+ );
38
+ `);
39
+ ensureFilesAdapterIdColumn(db);
40
+ ensureFilesCtimeColumn(db);
41
+ }
42
+ /**
43
+ * `adapter_id` was added after `files`' first release, so a database created
44
+ * before it needs an `ALTER TABLE` (`CREATE TABLE IF NOT EXISTS` only shapes
45
+ * a fresh table). Idempotent. A pre-existing row's default `''` matches no
46
+ * real adapter id, so the very next reconcile sees it as a mismatch and
47
+ * re-parses that one file under its current adapter — a one-time,
48
+ * self-healing cost, not a correctness gap.
49
+ */
50
+ function ensureFilesAdapterIdColumn(db) {
51
+ const columns = db.prepare("PRAGMA table_info(files)").all();
52
+ if (!columns.some((column) => column.name === "adapter_id")) {
53
+ db.exec("ALTER TABLE files ADD COLUMN adapter_id TEXT NOT NULL DEFAULT ''");
54
+ }
55
+ }
56
+ /**
57
+ * `ctime_ms` was added after `files`' first release, the same additive,
58
+ * self-healing way `adapter_id` was (see {@link ensureFilesAdapterIdColumn}):
59
+ * a database created before it needs an `ALTER TABLE`. A pre-existing row's
60
+ * default `-1` matches no real `ctimeMs` (always >= 0), so the very next
61
+ * reconcile sees it as a mismatch and re-parses that one file — a one-time,
62
+ * self-healing cost, not a correctness gap.
63
+ */
64
+ function ensureFilesCtimeColumn(db) {
65
+ const columns = db.prepare("PRAGMA table_info(files)").all();
66
+ if (!columns.some((column) => column.name === "ctime_ms")) {
67
+ db.exec("ALTER TABLE files ADD COLUMN ctime_ms REAL NOT NULL DEFAULT -1");
68
+ }
69
+ }
70
+ // ── files ───────────────────────────────────────────────────────────────────
71
+ function rowToFileState(row) {
72
+ return {
73
+ path: row.path,
74
+ bundleId: row.bundle_id,
75
+ size: row.size,
76
+ mtimeMs: row.mtime_ms,
77
+ ctimeMs: row.ctime_ms,
78
+ blobHash: row.blob_hash,
79
+ adapterId: row.adapter_id,
80
+ };
81
+ }
82
+ /** The stored stat/hash row for one path, or `undefined` if it has never been reconciled. */
83
+ export function getFileState(db, path) {
84
+ const row = db
85
+ .prepare("SELECT path, bundle_id, size, mtime_ms, ctime_ms, blob_hash, adapter_id FROM files WHERE path = ?")
86
+ .get(path);
87
+ return row ? rowToFileState(row) : undefined;
88
+ }
89
+ /** Every stored `files` row for one bundle — the stat cache `reconcileRoots` diffs one root's walk against. */
90
+ export function getFileStatesByBundle(db, bundleId) {
91
+ const rows = db
92
+ .prepare("SELECT path, bundle_id, size, mtime_ms, ctime_ms, blob_hash, adapter_id FROM files WHERE bundle_id = ?")
93
+ .all(bundleId);
94
+ return rows.map(rowToFileState);
95
+ }
96
+ /** Insert or replace one file's stat/hash row. */
97
+ export function upsertFileState(db, row) {
98
+ db.prepare(`INSERT INTO files (path, bundle_id, size, mtime_ms, ctime_ms, blob_hash, adapter_id) VALUES (?, ?, ?, ?, ?, ?, ?)
99
+ ON CONFLICT(path) DO UPDATE SET bundle_id = excluded.bundle_id, size = excluded.size,
100
+ mtime_ms = excluded.mtime_ms, ctime_ms = excluded.ctime_ms, blob_hash = excluded.blob_hash,
101
+ adapter_id = excluded.adapter_id`).run(row.path, row.bundleId, row.size, row.mtimeMs, row.ctimeMs, row.blobHash, row.adapterId);
102
+ }
103
+ /** Remove `files` rows for paths that no longer have an entry (gone, or the adapter no longer recognizes them). */
104
+ export function deleteFileStates(db, paths) {
105
+ for (let offset = 0; offset < paths.length; offset += SQLITE_CHUNK_SIZE) {
106
+ const chunk = paths.slice(offset, offset + SQLITE_CHUNK_SIZE);
107
+ const placeholders = chunk.map(() => "?").join(",");
108
+ db.prepare(`DELETE FROM files WHERE path IN (${placeholders})`).run(...chunk);
109
+ }
110
+ }
111
+ // ── unit_texts / units_fts ───────────────────────────────────────────────────
112
+ /**
113
+ * Write the text for every hash in `units` reconcile has not stored before.
114
+ * `unit_texts` dedupes itself (`INSERT OR IGNORE` on its `unit_hash` PRIMARY
115
+ * KEY); a `units_fts` mirror row is written ONLY when the `unit_texts` insert
116
+ * actually happened (FTS5 has no PRIMARY KEY to `OR IGNORE` against, so
117
+ * inserting unconditionally would duplicate a hash already indexed).
118
+ */
119
+ export function insertNewUnitTexts(db, units) {
120
+ if (units.length === 0)
121
+ return { inserted: 0 };
122
+ const insertText = db.prepare("INSERT OR IGNORE INTO unit_texts (unit_hash, kind, text) VALUES (?, ?, ?)");
123
+ const insertFts = db.prepare("INSERT INTO units_fts (unit_hash, text) VALUES (?, ?)");
124
+ let inserted = 0;
125
+ for (const unit of units) {
126
+ const result = insertText.run(unit.hash, unit.kind, unit.text);
127
+ if (Number(result.changes) > 0) {
128
+ insertFts.run(unit.hash, unit.text);
129
+ inserted++;
130
+ }
131
+ }
132
+ return { inserted };
133
+ }
134
+ /**
135
+ * Delete `unit_texts`/`units_fts` rows no `entry_units` row references any
136
+ * more — a single `NOT EXISTS` sweep, run once at the end of `reconcileRoots`
137
+ * (never `reconcilePaths`, whose small known-paths scope makes a full-table
138
+ * sweep wasteful for the write-path caller it serves). Vectors
139
+ * (`units`/`units_vec`) are never touched here — dropping the text does not
140
+ * drop the embedding, so a hash that comes back later resumes serving search
141
+ * without re-embedding.
142
+ */
143
+ export function pruneOrphanUnitTexts(db) {
144
+ const before = db.prepare("SELECT COUNT(*) AS n FROM unit_texts").get().n;
145
+ db.exec("DELETE FROM units_fts WHERE unit_hash IN (SELECT unit_hash FROM unit_texts WHERE NOT EXISTS " +
146
+ "(SELECT 1 FROM entry_units WHERE entry_units.unit_hash = unit_texts.unit_hash))");
147
+ db.exec("DELETE FROM unit_texts WHERE NOT EXISTS (SELECT 1 FROM entry_units WHERE entry_units.unit_hash = unit_texts.unit_hash)");
148
+ const after = db.prepare("SELECT COUNT(*) AS n FROM unit_texts").get().n;
149
+ return { removed: before - after };
150
+ }
151
+ /**
152
+ * Delete `unit_texts`/`units_fts` rows for exactly the hashes in `hashes`
153
+ * that no `entry_units` row references any more — the narrow counterpart to
154
+ * {@link pruneOrphanUnitTexts}'s whole-table sweep, for a caller that already
155
+ * knows precisely which hashes its own write just orphaned (`reconcile.ts`'s
156
+ * `applyChange`, run after every write — including `reconcilePaths`, which
157
+ * previously never pruned anything and so grew `unit_texts`/`units_fts`
158
+ * without bound across repeated edits) rather than sweeping every row in the
159
+ * table on every single write. Vectors (`units`/`units_vec`) are never
160
+ * touched here either, for the same reason `pruneOrphanUnitTexts` leaves
161
+ * them alone: dropping a unit's text must not drop its vector, so a hash
162
+ * that returns resumes serving search without re-embedding.
163
+ */
164
+ export function pruneOrphanUnitTextsForHashes(db, hashes) {
165
+ const unique = [...new Set(hashes)];
166
+ if (unique.length === 0)
167
+ return { removed: 0 };
168
+ let removed = 0;
169
+ for (let offset = 0; offset < unique.length; offset += SQLITE_CHUNK_SIZE) {
170
+ const chunk = unique.slice(offset, offset + SQLITE_CHUNK_SIZE);
171
+ const placeholders = chunk.map(() => "?").join(",");
172
+ db.prepare(`DELETE FROM units_fts WHERE unit_hash IN (${placeholders}) AND NOT EXISTS ` +
173
+ `(SELECT 1 FROM entry_units WHERE entry_units.unit_hash = units_fts.unit_hash)`).run(...chunk);
174
+ const result = db
175
+ .prepare(`DELETE FROM unit_texts WHERE unit_hash IN (${placeholders}) AND NOT EXISTS ` +
176
+ `(SELECT 1 FROM entry_units WHERE entry_units.unit_hash = unit_texts.unit_hash)`)
177
+ .run(...chunk);
178
+ removed += Number(result.changes);
179
+ }
180
+ return { removed };
181
+ }
@@ -22,7 +22,7 @@ import { SQLITE_BUSY_TIMEOUT_MS } from "../sqlite-pragmas.js";
22
22
  import { openSqliteReadSnapshot, SqliteReadSnapshotUnavailableError } from "../sqlite-read-snapshot.js";
23
23
  import { CANONICAL_INDEX_DB_VERSION, classifyIndexGeneration, isCanonicalIndexGeneration } from "./index-entry-schema.js";
24
24
  import { ensureSchema } from "./index-schema.js";
25
- import { loadVecExtension, warnIfVecMissing } from "./index-vec-repository.js";
25
+ import { loadVecExtension } from "./index-vec-repository.js";
26
26
  /**
27
27
  * Whether `error` is SQLite reporting on-disk corruption (`SQLITE_CORRUPT`,
28
28
  * "database disk image is malformed") rather than a permission, lock, or
@@ -54,8 +54,6 @@ export function openIndexDatabase(dbPath, options) {
54
54
  // ensureSchema from touching `index_meta.embeddingDim` at all.
55
55
  const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
56
56
  ensureSchema(db, resolvedDim);
57
- // Warn once at init if using JS fallback with many entries
58
- warnIfVecMissing(db, { once: true });
59
57
  },
60
58
  };
61
59
  try {
@@ -14,22 +14,24 @@ import { parseBundleRef } from "../../core/asset/asset-ref.js";
14
14
  import { conceptIdFromTypeName } from "../../core/asset/resolve-ref.js";
15
15
  import { bestEffort } from "../../core/best-effort.js";
16
16
  import { isPathAbsent } from "../../core/path-access.js";
17
- import { getStateDbPath, withStateDb } from "../../core/state-db.js";
17
+ import { getStateDbPath, withImmediateTransaction, withStateDb } from "../../core/state-db.js";
18
18
  import { warn } from "../../core/warn.js";
19
19
  import { getMarkdownFragmentContent, hasMarkdownFragmentContent, } from "../../indexer/passes/metadata.js";
20
20
  import { buildSearchText } from "../../indexer/search/search-fields.js";
21
21
  import { ENTRY_COLUMNS, rowToIndexedEntry } from "./index-entry-mapper.js";
22
- import { deleteFtsEntries, replaceFtsEntry } from "./index-fts-repository.js";
22
+ import { deleteFragmentSource, replaceFragmentSource } from "./index-fts-repository.js";
23
23
  import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
24
- import { deleteEntryVectors, isVecAvailable } from "./index-vec-repository.js";
25
24
  // ── Entry operations ────────────────────────────────────────────────────────
26
25
  /**
27
26
  * Insert or update one canonical entry and all synchronously derived search
28
27
  * state. Returns the stable row id.
29
28
  *
30
- * The entries row, FTS projection, and stale-vector invalidation commit as one
31
- * SQLite transaction. Callers therefore cannot publish an entry and forget a
32
- * second FTS maintenance step.
29
+ * The entries row and its safe-Markdown fragment source commit as one SQLite
30
+ * transaction. Callers therefore cannot publish an entry and forget the
31
+ * fragment-source write. (Unit derivation — `unit_texts`/`units_fts`/
32
+ * `entry_units` — is the caller's job, driven by reconcile: a changed
33
+ * `search_text` needs no explicit vector invalidation here, since a new
34
+ * document simply derives new content-addressed unit hashes.)
33
35
  */
34
36
  export function upsertEntry(db, filePath, entry, searchText, provenance, contentHash) {
35
37
  // Hot path during indexing — cache prepared statements per database
@@ -42,20 +44,37 @@ export function upsertEntry(db, filePath, entry, searchText, provenance, content
42
44
  // `content_hash` is optional on the LLM-enrichment re-upsert; a missing hash
43
45
  // preserves the scan writer's current value.
44
46
  const apply = () => {
45
- const previous = stmts.findByItemRef.get(provenance.itemRef);
46
47
  const result = stmts.upsert.get(provenance.itemRef, provenance.bundleId, provenance.componentId, provenance.conceptId, provenance.adapterId, entry.type, filePath, contentHash ?? null, JSON.stringify(entry), searchText, derivedFrom);
47
48
  if (!result)
48
49
  throw new Error("upsertEntry: item_ref not found after upsert");
49
- if (previous?.id === result.id && previous.search_text !== searchText)
50
- deleteEntryVectors(db, result.id);
51
- replaceFtsEntry(db, result.id, entry, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
50
+ replaceFragmentSource(db, result.id, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
52
51
  return result.id;
53
52
  };
54
- // Always enter the driver's transaction wrapper. Both supported SQLite
55
- // drivers lower a transaction opened inside another transaction to a
56
- // savepoint, so a caller that catches this mutation's error cannot commit a
57
- // partial entries row through its outer transaction.
58
- return db.transaction(apply)();
53
+ // Both production callers (reconcile.ts's `applyChange`, enrich.ts's
54
+ // `applyEnrichmentToEntry`) always invoke this from inside their OWN outer
55
+ // `withImmediateTransaction`, so `db.inTransaction` is already true and a
56
+ // standalone top-level call never happens today — but a caller that DOES
57
+ // call this standalone (no outer transaction) must still get a proper
58
+ // `BEGIN IMMEDIATE`, not a bare deferred `db.transaction()` that fails
59
+ // instantly with SQLITE_BUSY under a competing writer instead of honouring
60
+ // `busy_timeout` (docs/plans/index-redesign.md rule 5). Route ONLY that
61
+ // standalone case through `withImmediateTransaction`.
62
+ //
63
+ // The nested case deliberately keeps the driver's own `db.transaction()`
64
+ // instead of `withImmediateTransaction`: the driver lowers a transaction
65
+ // opened inside an already-open one to a SAVEPOINT, so a caller that
66
+ // catches this mutation's error and continues its OWN outer (bare)
67
+ // transaction still cannot commit a partial entries row — the exact
68
+ // contract tests/integration/indexer/index-mutation-boundary.test.ts pins
69
+ // (#820). `withImmediateTransaction`'s join-if-open guard runs `fn`
70
+ // directly with NO isolation boundary of its own (by design, #686 — see
71
+ // its doc comment), which would let such a caught, partial write escape
72
+ // through the outer transaction's COMMIT instead of rolling back to a
73
+ // savepoint. Both real production callers already hold their OWN
74
+ // immediate transaction before calling this, so this nested branch is a
75
+ // savepoint under an already-immediate lock, not a fresh deferred BEGIN —
76
+ // no busy_timeout race to lose here either way.
77
+ return db.inTransaction ? db.transaction(apply)() : withImmediateTransaction(db, apply, "index");
59
78
  }
60
79
  const upsertStmtsByDb = new WeakMap();
61
80
  // item_ref is the sole durable conflict target. `content_hash` COALESCEs so a
@@ -88,7 +107,6 @@ function getUpsertStmts(db) {
88
107
  ON CONFLICT(item_ref) DO UPDATE ${UPSERT_SET_CLAUSE}
89
108
  RETURNING id
90
109
  `),
91
- findByItemRef: db.prepare("SELECT id, search_text FROM entries WHERE item_ref = ?"),
92
110
  };
93
111
  upsertStmtsByDb.set(db, stmts);
94
112
  return stmts;
@@ -164,16 +182,18 @@ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
164
182
  /**
165
183
  * Re-key an entries row in place for the opt-in source-maintenance script.
166
184
  *
167
- * The row id is preserved on purpose — `utility_scores`,
168
- * `utility_scores_scoped`, and `embeddings` are keyed by `entry_id`, so an
169
- * UPDATE (rather than a delete + insert under the new `item_ref`) is what
170
- * keeps the asset's accumulated usage-ranking history attached across a
171
- * rename. (`asset_salience` / `asset_outcome` live in state.db keyed by
172
- * `asset_ref` TEXT and are re-keyed separately by `akm mv` — see
173
- * the state rekey helper.) `document_json.name` (and `filename`, when
174
- * present) is patched and `search_text` rebuilt so search reflects the new
175
- * name. Its FTS projection and stale vector are updated in the same
176
- * transaction as the canonical identity.
185
+ * The row id is preserved on purpose — `utility_scores` and
186
+ * `utility_scores_scoped` are keyed by `entry_id`, so an UPDATE (rather than
187
+ * a delete + insert under the new `item_ref`) is what keeps the asset's
188
+ * accumulated usage-ranking history attached across a rename.
189
+ * (`asset_salience` / `asset_outcome` live in state.db keyed by `asset_ref`
190
+ * TEXT and are re-keyed separately by `akm mv` — see the state rekey
191
+ * helper.) `document_json.name` (and `filename`, when present) is patched
192
+ * and `search_text` rebuilt so search reflects the new name. Its
193
+ * safe-Markdown fragment source is updated in the same transaction as the
194
+ * canonical identity; the new `search_text` needs no explicit vector
195
+ * invalidation — content-addressed units simply derive new hashes on the
196
+ * next reconcile.
177
197
  *
178
198
  * Bundle-qualified `usage_events.entry_ref` rows for the old conceptId are
179
199
  * rewritten to the new item ref. Without this, events keep the old
@@ -187,10 +207,8 @@ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
187
207
  * A stale row already occupying the new item ref (the caller has verified no
188
208
  * FILE exists at the target, so such a row can only be a leftover for a
189
209
  * deleted file) is evicted first — through {@link deleteRelatedRows}, so its
190
- * child rows (embeddings, entries_vec, utility scores, usage events) go with
191
- * it. A bare `DELETE FROM entries` would trip the non-CASCADE `embeddings`
192
- * FK under `PRAGMA foreign_keys = ON` and roll back the whole re-key.
193
- * The moved row keeps its id.
210
+ * child rows (utility scores, usage events) go with it. The moved row keeps
211
+ * its id.
194
212
  *
195
213
  * Returns the surviving row id, or `null` when no row matches the old item ref
196
214
  * (nothing indexed under the old name — the caller falls open and the next
@@ -232,14 +250,12 @@ export function rekeyEntryInPlace(db, opts) {
232
250
  throw new Error(`Refusing to re-key entry ${oldItemRef}: target ref does not match the entry type and name.`);
233
251
  }
234
252
  const newItemRef = `${opts.sourceName}//${opts.newRef}`;
235
- db.transaction(() => {
253
+ withImmediateTransaction(db, () => {
236
254
  const stale = db.prepare("SELECT id FROM entries WHERE item_ref = ?").get(newItemRef);
237
255
  if (stale && stale.id !== row.id) {
238
- // Full child-row cleanup (embeddings, entries_vec, utility scores,
239
- // usage events, FTS + dirty marks) BEFORE the entries delete: the
240
- // `embeddings` FK is non-CASCADE and `foreign_keys = ON`, so a bare
241
- // entries delete would throw and roll back the entire re-key; and
242
- // without it the FK-less child rows would orphan permanently.
256
+ // Full child-row cleanup (utility scores, usage events, fragment
257
+ // source) BEFORE the entries delete — the FK-less child rows would
258
+ // otherwise orphan permanently.
243
259
  deleteRelatedRows(db, [{ id: stale.id }]);
244
260
  db.prepare("DELETE FROM entries WHERE id = ?").run(stale.id);
245
261
  }
@@ -247,13 +263,11 @@ export function rekeyEntryInPlace(db, opts) {
247
263
  if (opts.newDerivedFrom !== undefined) {
248
264
  db.prepare("UPDATE entries SET derived_from = ? WHERE id = ?").run(opts.newDerivedFrom, row.id);
249
265
  }
250
- if (row.search_text !== searchText)
251
- deleteEntryVectors(db, row.id);
252
266
  if (document)
253
- replaceFtsEntry(db, row.id, document, hasMarkdownFragmentContent(document) ? (getMarkdownFragmentContent(document) ?? null) : undefined);
267
+ replaceFragmentSource(db, row.id, hasMarkdownFragmentContent(document) ? (getMarkdownFragmentContent(document) ?? null) : undefined);
254
268
  else
255
- deleteFtsEntries(db, [row.id]);
256
- })();
269
+ deleteFragmentSource(db, [row.id]);
270
+ }, "index");
257
271
  // Re-point usage history at the new ref. Chunk-8 WI-8.3: usage_events lives in
258
272
  // state.db now, so this is a SEPARATE cross-DB transaction (best-effort — the
259
273
  // rename itself already committed above; on failure the next full index's
@@ -283,9 +297,9 @@ function rewriteUsageEventRefForMove(opts) {
283
297
  };
284
298
  try {
285
299
  withStateDb((stateDb) => {
286
- stateDb.transaction(() => {
300
+ withImmediateTransaction(stateDb, () => {
287
301
  rename(stateDb, `${opts.sourceName}//${opts.oldRef}`, `${opts.sourceName}//${opts.newRef}`);
288
- })();
302
+ });
289
303
  });
290
304
  }
291
305
  catch (error) {
@@ -372,14 +386,14 @@ function deleteEntryRows(db, rows, options = {}) {
372
386
  return rows.map((row) => row.id);
373
387
  }
374
388
  export function deleteEntriesByDirAndBundle(db, dirPath, bundleId, options = {}) {
375
- return db.transaction(() => deleteEntryRows(db, rowsInDirectory(db, dirPath, bundleId), options))();
389
+ return withImmediateTransaction(db, () => deleteEntryRows(db, rowsInDirectory(db, dirPath, bundleId), options), "index");
376
390
  }
377
391
  /** Delete every entry and child row belonging to one canonical bundle. */
378
392
  export function deleteEntriesByBundle(db, bundleId) {
379
- db.transaction(() => {
393
+ withImmediateTransaction(db, () => {
380
394
  const rows = db.prepare("SELECT id FROM entries WHERE bundle_id = ?").all(bundleId);
381
395
  deleteEntryRows(db, rows);
382
- })();
396
+ }, "index");
383
397
  }
384
398
  /**
385
399
  * Delete the complete regenerable entry generation through the same child-row
@@ -387,10 +401,10 @@ export function deleteEntriesByBundle(db, bundleId) {
387
401
  * usage events so the finalize pass can relink them to the new row ids.
388
402
  */
389
403
  export function deleteAllEntries(db, options = {}) {
390
- return db.transaction(() => {
404
+ return withImmediateTransaction(db, () => {
391
405
  const rows = db.prepare("SELECT id FROM entries").all();
392
406
  return deleteEntryRows(db, rows, options);
393
- })();
407
+ }, "index");
394
408
  }
395
409
  /**
396
410
  * Diff-persist orphan delete: remove every entry under `dirPath` whose durable
@@ -398,7 +412,7 @@ export function deleteAllEntries(db, options = {}) {
398
412
  *
399
413
  * Replaces the old per-dir `deleteEntriesByDir` + full re-insert: the caller
400
414
  * upserts the current file set first (ON CONFLICT preserving `entries.id`, so
401
- * embeddings / utility / usage stay attached to unchanged rows), then calls this
415
+ * utility / usage stay attached to unchanged rows), then calls this
402
416
  * to prune only the departed rows. The net row-state for the directory is identical
403
417
  * to delete-then-reinsert; the win is that unchanged rows keep their id.
404
418
  *
@@ -406,27 +420,25 @@ export function deleteAllEntries(db, options = {}) {
406
420
  * prune one another's rows.
407
421
  */
408
422
  export function deleteEntriesByDirExceptRefs(db, dirPath, bundleId, keepRefs, options = {}) {
409
- return db.transaction(() => {
423
+ return withImmediateTransaction(db, () => {
410
424
  const doomed = rowsInDirectory(db, dirPath, bundleId).filter((row) => !keepRefs.has(row.item_ref));
411
425
  return deleteEntryRows(db, doomed, options);
412
- })();
426
+ }, "index");
413
427
  }
414
428
  function deleteRelatedRows(db, ids, options = {}) {
415
429
  if (ids.length === 0)
416
430
  return;
417
431
  const numericIds = ids.map((r) => r.id);
418
- const vecAvail = isVecAvailable(db);
419
- // FTS is part of the canonical mutation boundary, not a caller-maintained
420
- // dirty queue. Delete it before the parent row inside this transaction.
421
- deleteFtsEntries(db, numericIds);
432
+ // The safe-Markdown fragment source is part of the canonical mutation
433
+ // boundary, not a caller-maintained dirty queue. Delete it before the
434
+ // parent row inside this transaction (redundant with entry_fragments' own
435
+ // ON DELETE CASCADE, but explicit here alongside the other child-row
436
+ // cleanup this function owns).
437
+ deleteFragmentSource(db, numericIds);
422
438
  // Process in chunks to stay within SQLITE_MAX_VARIABLE_NUMBER
423
439
  for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
424
440
  const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
425
441
  const placeholders = chunk.map(() => "?").join(",");
426
- bestEffort(() => db.prepare(`DELETE FROM embeddings WHERE id IN (${placeholders})`).run(...chunk), "delete embeddings for entries");
427
- if (vecAvail) {
428
- bestEffort(() => db.prepare(`DELETE FROM entries_vec WHERE id IN (${placeholders})`).run(...chunk), "delete entries_vec for entries");
429
- }
430
442
  // Clean up utility scores before deleting entries
431
443
  bestEffort(() => db.prepare(`DELETE FROM utility_scores WHERE entry_id IN (${placeholders})`).run(...chunk), "delete utility_scores for entries");
432
444
  bestEffort(() => db.prepare(`DELETE FROM utility_scores_scoped WHERE entry_id IN (${placeholders})`).run(...chunk), "delete utility_scores_scoped for entries");
@@ -481,15 +493,14 @@ export function deleteUsageEventsByEntryIds(entryIds) {
481
493
  }
482
494
  /**
483
495
  * Delete entries by their primary key IDs, along with all related rows
484
- * (embeddings, entries_vec, entries_fts, utility scores, usage_events).
496
+ * (entry_fragments, entry_units, utility scores, usage_events).
485
497
  *
486
- * Used by explicit `--clean` reconciliation before embeddings and final
487
- * verification to remove stale entries whose source files no longer exist.
498
+ * Used by `reconcile.ts` to remove entries whose source files are gone.
488
499
  */
489
500
  export function deleteEntriesByIds(db, ids) {
490
501
  if (ids.length === 0)
491
502
  return;
492
- db.transaction(() => {
503
+ withImmediateTransaction(db, () => {
493
504
  const idObjs = ids.map((id) => ({ id }));
494
505
  deleteRelatedRows(db, idObjs);
495
506
  for (let i = 0; i < ids.length; i += SQLITE_CHUNK_SIZE) {
@@ -497,7 +508,7 @@ export function deleteEntriesByIds(db, ids) {
497
508
  const placeholders = chunk.map(() => "?").join(",");
498
509
  db.prepare(`DELETE FROM entries WHERE id IN (${placeholders})`).run(...chunk);
499
510
  }
500
- })();
511
+ }, "index");
501
512
  }
502
513
  // ── All entries ─────────────────────────────────────────────────────────────
503
514
  function parseEntryRows(rows, context) {
@@ -792,11 +803,10 @@ export function relinkUsageEvents(indexDb, stateDb, options = {}) {
792
803
  });
793
804
  if (staleLinks.length > 0) {
794
805
  const nullOut = stateDb.prepare(`UPDATE ${usageEvents} SET entry_id = NULL WHERE entry_id = ? AND entry_ref IS ?`);
795
- const nullTx = stateDb.transaction(() => {
806
+ withImmediateTransaction(stateDb, () => {
796
807
  for (const { id, ref } of staleLinks)
797
808
  nullOut.run(id, ref);
798
809
  });
799
- nullTx();
800
810
  }
801
811
  // Step 2: re-resolve each fully-qualified ref. Bare rows are not current
802
812
  // durable identities and remain detached.
@@ -804,7 +814,7 @@ export function relinkUsageEvents(indexDb, stateDb, options = {}) {
804
814
  .prepare(`SELECT DISTINCT entry_ref AS ref FROM ${usageEvents} WHERE entry_id IS NULL AND entry_ref IS NOT NULL`)
805
815
  .all();
806
816
  const update = stateDb.prepare(`UPDATE ${usageEvents} SET entry_id = ? WHERE entry_ref = ? AND entry_id IS NULL`);
807
- const relinkTx = stateDb.transaction(() => {
817
+ withImmediateTransaction(stateDb, () => {
808
818
  for (const { ref } of refs) {
809
819
  let id;
810
820
  try {
@@ -819,6 +829,5 @@ export function relinkUsageEvents(indexDb, stateDb, options = {}) {
819
829
  update.run(id, ref);
820
830
  }
821
831
  });
822
- relinkTx();
823
832
  }, "usage_events table may not exist yet during entry_id re-resolution");
824
833
  }
@@ -12,7 +12,18 @@
12
12
  */
13
13
  // v23 adds an isolated fragment FTS population. v22 is the last shipped
14
14
  // generation and is intentionally rebuilt rather than migrated in place.
15
- export const CANONICAL_INDEX_DB_VERSION = 23;
15
+ //
16
+ // v23→v24 (index-redesign B5c): `entries_fts` and `entry_fragments_fts` are
17
+ // dropped from the canonical shape. Lexical search runs entirely over
18
+ // `units_fts` now (index-redesign-contract.md B1/B3) — the card unit already
19
+ // carries name/description/tags/hints, so an entry-level lexical query is a
20
+ // units query grouped by entry, and a fragment-level lexical query is the
21
+ // same `units_fts` table filtered to fragment-kind units. `entry_fragments`
22
+ // (the safe-rendered Markdown source, NOT an FTS index) stays: it is what a
23
+ // matched fragment hit's display metadata is projected from, and what `akm
24
+ // show <ref>#<fragmentId>` resolves an opaque fragment selector through —
25
+ // both are consumers independent of which table search itself queries.
26
+ export const CANONICAL_INDEX_DB_VERSION = 24;
16
27
  export const CANONICAL_ENTRY_SCHEMA_SQL = `
17
28
  CREATE TABLE IF NOT EXISTS entries (
18
29
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -34,30 +45,14 @@ export const CANONICAL_ENTRY_SCHEMA_SQL = `
34
45
  CREATE INDEX IF NOT EXISTS idx_entries_file_path ON entries(file_path);
35
46
  CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from);
36
47
 
37
- -- Keep parent metadata and body fragments in separate FTS populations.
38
- -- Combining them changes parent-document IDF and conjunction semantics.
39
- CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
40
- entry_id UNINDEXED,
41
- name,
42
- description,
43
- tags,
44
- hints,
45
- content,
46
- tokenize='porter unicode61'
47
- );
48
-
48
+ -- The safe-rendered Markdown source a matched fragment hit's display
49
+ -- metadata is projected from (index-fts-repository.ts's
50
+ -- getIndexedMarkdownFragment(s)) and that akm show's opaque fragment
51
+ -- selectors resolve through. Not a search index — units_fts is.
49
52
  CREATE TABLE IF NOT EXISTS entry_fragments (
50
53
  entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
51
54
  safe_markdown TEXT NOT NULL
52
55
  );
53
-
54
- CREATE VIRTUAL TABLE IF NOT EXISTS entry_fragments_fts USING fts5(
55
- entry_id UNINDEXED,
56
- fragment_id UNINDEXED,
57
- fragment_ordinal UNINDEXED,
58
- content,
59
- tokenize='porter unicode61'
60
- );
61
56
  `;
62
57
  const CANONICAL_ENTRY_SCHEMA_FINGERPRINT = {
63
58
  tableSql: "CREATE TABLE entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_ref TEXT NOT NULL UNIQUE, bundle_id TEXT NOT NULL, component_id TEXT NOT NULL, concept_id TEXT NOT NULL, adapter_id TEXT NOT NULL, type TEXT NOT NULL, file_path TEXT NOT NULL, content_hash TEXT, document_json TEXT NOT NULL, search_text TEXT NOT NULL, derived_from TEXT )",
@@ -210,9 +205,7 @@ const CANONICAL_ENTRY_SCHEMA_FINGERPRINT = {
210
205
  },
211
206
  ],
212
207
  searchSurfaces: {
213
- entriesFtsSql: "CREATE VIRTUAL TABLE entries_fts USING fts5( entry_id UNINDEXED, name, description, tags, hints, content, tokenize='porter unicode61' )",
214
208
  fragmentSourceSql: "CREATE TABLE entry_fragments ( entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE, safe_markdown TEXT NOT NULL )",
215
- fragmentsFtsSql: "CREATE VIRTUAL TABLE entry_fragments_fts USING fts5( entry_id UNINDEXED, fragment_id UNINDEXED, fragment_ordinal UNINDEXED, content, tokenize='porter unicode61' )",
216
209
  },
217
210
  };
218
211
  function sqlString(value) {
@@ -270,9 +263,7 @@ export function readEntrySchemaFingerprint(db) {
270
263
  columns,
271
264
  indexes,
272
265
  searchSurfaces: {
273
- entriesFtsSql: readNamedTableSql(db, "entries_fts"),
274
266
  fragmentSourceSql: readNamedTableSql(db, "entry_fragments"),
275
- fragmentsFtsSql: readNamedTableSql(db, "entry_fragments_fts"),
276
267
  },
277
268
  };
278
269
  }