akm-cli 0.9.13 → 0.9.14-beta.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.
@@ -126,23 +126,38 @@ export function openExistingDatabase(dbPath) {
126
126
  if (classifyPathAccess(resolvedPath).access === "absent") {
127
127
  throw new Error(`Index database not found at ${resolvedPath}. Run 'akm index' to build it.`);
128
128
  }
129
- return openManagedDatabase({
129
+ const db = openManagedDatabase({
130
130
  path: resolvedPath,
131
131
  init: (db) => {
132
132
  loadVecExtension(db);
133
- warnIfNonCanonicalIndexGeneration(db, resolvedPath);
134
133
  },
135
134
  create: false,
136
135
  });
136
+ try {
137
+ assertCanonicalIndexGeneration(db, resolvedPath);
138
+ return db;
139
+ }
140
+ catch (error) {
141
+ db.close();
142
+ throw error;
143
+ }
137
144
  }
138
- function warnIfNonCanonicalIndexGeneration(db, resolvedPath) {
145
+ /**
146
+ * Read callers must never receive a known-incompatible derived index. The
147
+ * writable opener owns rebuilding an older generation; a reader can only
148
+ * report the one action that is safe for the direction of the mismatch.
149
+ */
150
+ function assertCanonicalIndexGeneration(db, resolvedPath) {
139
151
  if (isCanonicalIndexGeneration(db))
140
152
  return;
141
153
  const classification = classifyIndexGeneration(db);
142
- warnOnce(`index-read-noncanonical:${resolvedPath}`, `Index database at ${resolvedPath} does not match this akm's derived schema (stored generation ` +
143
- `${classification.storedVersion ?? "unknown"}; this binary understands ${CANONICAL_INDEX_DB_VERSION}). ` +
144
- "Reading it as-is; a query that needs a table or column this generation lacks will fail on its own. " +
145
- "Run 'akm index' to rebuild it for this binary.");
154
+ const stored = classification.storedVersion ?? "unknown";
155
+ if (classification.status === "newer") {
156
+ throw new ConfigError(`Index database at ${resolvedPath} was built by a newer akm (stored generation ${stored}; ` +
157
+ `this binary understands ${CANONICAL_INDEX_DB_VERSION}). Upgrade akm to use this index.`, "INDEX_SCHEMA_INCOMPATIBLE", "Upgrade akm to a version that understands this index generation.");
158
+ }
159
+ throw new ConfigError(`Index database at ${resolvedPath} is not usable with this akm's derived schema (stored generation ${stored}; ` +
160
+ `this binary understands ${CANONICAL_INDEX_DB_VERSION}). Run 'akm index' to rebuild it.`, "INDEX_SCHEMA_INCOMPATIBLE", "Run `akm index` to rebuild the derived index from the currently materialized sources.");
146
161
  }
147
162
  /**
148
163
  * Refuse to treat an UNREADABLE index as a missing one (#791).
@@ -204,7 +219,7 @@ export function openReadonlyExistingDatabase(dbPath, options) {
204
219
  // connection, so apply just that one.
205
220
  try {
206
221
  db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
207
- warnIfNonCanonicalIndexGeneration(db, resolvedPath);
222
+ assertCanonicalIndexGeneration(db, resolvedPath);
208
223
  return db;
209
224
  }
210
225
  catch (error) {
@@ -16,6 +16,7 @@ import { bestEffort } from "../../core/best-effort.js";
16
16
  import { isPathAbsent } from "../../core/path-access.js";
17
17
  import { getStateDbPath, withStateDb } from "../../core/state-db.js";
18
18
  import { warn } from "../../core/warn.js";
19
+ import { getMarkdownFragmentContent, hasMarkdownFragmentContent, } from "../../indexer/passes/metadata.js";
19
20
  import { buildSearchText } from "../../indexer/search/search-fields.js";
20
21
  import { ENTRY_COLUMNS, rowToIndexedEntry } from "./index-entry-mapper.js";
21
22
  import { deleteFtsEntries, replaceFtsEntry } from "./index-fts-repository.js";
@@ -47,7 +48,7 @@ export function upsertEntry(db, filePath, entry, searchText, provenance, content
47
48
  throw new Error("upsertEntry: item_ref not found after upsert");
48
49
  if (previous?.id === result.id && previous.search_text !== searchText)
49
50
  deleteEntryVectors(db, result.id);
50
- replaceFtsEntry(db, result.id, entry);
51
+ replaceFtsEntry(db, result.id, entry, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
51
52
  return result.id;
52
53
  };
53
54
  // Always enter the driver's transaction wrapper. Both supported SQLite
@@ -249,7 +250,7 @@ export function rekeyEntryInPlace(db, opts) {
249
250
  if (row.search_text !== searchText)
250
251
  deleteEntryVectors(db, row.id);
251
252
  if (document)
252
- replaceFtsEntry(db, row.id, document);
253
+ replaceFtsEntry(db, row.id, document, hasMarkdownFragmentContent(document) ? (getMarkdownFragmentContent(document) ?? null) : undefined);
253
254
  else
254
255
  deleteFtsEntries(db, [row.id]);
255
256
  })();
@@ -10,7 +10,9 @@
10
10
  * serving preflights, and read-only evaluator tooling cannot drift into
11
11
  * separate definitions of "current".
12
12
  */
13
- export const CANONICAL_INDEX_DB_VERSION = 22;
13
+ // v23 adds an isolated fragment FTS population. v22 is the last shipped
14
+ // generation and is intentionally rebuilt rather than migrated in place.
15
+ export const CANONICAL_INDEX_DB_VERSION = 23;
14
16
  export const CANONICAL_ENTRY_SCHEMA_SQL = `
15
17
  CREATE TABLE IF NOT EXISTS entries (
16
18
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -31,6 +33,31 @@ export const CANONICAL_ENTRY_SCHEMA_SQL = `
31
33
  CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type);
32
34
  CREATE INDEX IF NOT EXISTS idx_entries_file_path ON entries(file_path);
33
35
  CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from);
36
+
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
+
49
+ CREATE TABLE IF NOT EXISTS entry_fragments (
50
+ entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
51
+ safe_markdown TEXT NOT NULL
52
+ );
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
+ );
34
61
  `;
35
62
  const CANONICAL_ENTRY_SCHEMA_FINGERPRINT = {
36
63
  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 )",
@@ -182,6 +209,11 @@ const CANONICAL_ENTRY_SCHEMA_FINGERPRINT = {
182
209
  ],
183
210
  },
184
211
  ],
212
+ searchSurfaces: {
213
+ entriesFtsSql: "CREATE VIRTUAL TABLE entries_fts USING fts5( entry_id UNINDEXED, name, description, tags, hints, content, tokenize='porter unicode61' )",
214
+ 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
+ },
185
217
  };
186
218
  function sqlString(value) {
187
219
  return `'${value.replaceAll("'", "''")}'`;
@@ -191,8 +223,11 @@ function normalizeSchemaSql(value) {
191
223
  return null;
192
224
  return value.replace(/\s+/g, " ").trim();
193
225
  }
226
+ function readNamedTableSql(db, name) {
227
+ const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ${sqlString(name)}`).get();
228
+ return normalizeSchemaSql(row?.sql);
229
+ }
194
230
  export function readEntrySchemaFingerprint(db) {
195
- const tableRow = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'entries'").get();
196
231
  const sqliteSequenceTable = db.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'").get() !=
197
232
  null;
198
233
  const maxId = Number(db.prepare("SELECT COALESCE(MAX(id), 0) AS maxId FROM entries").get().maxId);
@@ -229,11 +264,16 @@ export function readEntrySchemaFingerprint(db) {
229
264
  }))
230
265
  .sort((left, right) => left.name.localeCompare(right.name));
231
266
  return {
232
- tableSql: normalizeSchemaSql(tableRow?.sql),
267
+ tableSql: readNamedTableSql(db, "entries"),
233
268
  sqliteSequenceTable,
234
269
  sqliteSequenceValid,
235
270
  columns,
236
271
  indexes,
272
+ searchSurfaces: {
273
+ entriesFtsSql: readNamedTableSql(db, "entries_fts"),
274
+ fragmentSourceSql: readNamedTableSql(db, "entry_fragments"),
275
+ fragmentsFtsSql: readNamedTableSql(db, "entry_fragments_fts"),
276
+ },
237
277
  };
238
278
  }
239
279
  export function hasCanonicalEntrySchema(db) {
@@ -7,11 +7,14 @@
7
7
  * Owns the `entries_fts` full-text query path, per-entry projections, and the
8
8
  * explicit full recovery rebuild.
9
9
  */
10
+ import { fragmentForSelector, splitMarkdownFragments } from "../../core/asset/markdown-fragments.js";
11
+ import { stableFtsScore } from "../../core/lexical-score.js";
10
12
  import { warn } from "../../core/warn.js";
11
13
  import { buildLexicalQueryPlan } from "../../indexer/search/fts-query.js";
12
14
  import { buildSearchFields } from "../../indexer/search/search-fields.js";
13
15
  import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
14
16
  const INSERT_FTS_SQL = "INSERT INTO entries_fts (entry_id, name, description, tags, hints, content) VALUES (?, ?, ?, ?, ?, ?)";
17
+ const INSERT_FRAGMENT_SQL = "INSERT INTO entry_fragments_fts (entry_id, fragment_id, fragment_ordinal, content) VALUES (?, ?, ?, ?)";
15
18
  const ftsMutationStatementsByDb = new WeakMap();
16
19
  function getFtsMutationStatements(db) {
17
20
  const existing = ftsMutationStatementsByDb.get(db);
@@ -20,16 +23,34 @@ function getFtsMutationStatements(db) {
20
23
  const statements = {
21
24
  deleteOne: db.prepare("DELETE FROM entries_fts WHERE entry_id = ?"),
22
25
  insert: db.prepare(INSERT_FTS_SQL),
26
+ deleteFragments: db.prepare("DELETE FROM entry_fragments_fts WHERE entry_id = ?"),
27
+ upsertFragmentSource: db.prepare("INSERT INTO entry_fragments (entry_id, safe_markdown) VALUES (?, ?) ON CONFLICT(entry_id) DO UPDATE SET safe_markdown = excluded.safe_markdown"),
28
+ deleteFragmentSource: db.prepare("DELETE FROM entry_fragments WHERE entry_id = ?"),
29
+ insertFragment: db.prepare(INSERT_FRAGMENT_SQL),
23
30
  };
24
31
  ftsMutationStatementsByDb.set(db, statements);
25
32
  return statements;
26
33
  }
27
34
  /** Replace one entry's derived FTS projection inside the caller's transaction. */
28
- export function replaceFtsEntry(db, entryId, entry) {
35
+ export function replaceFtsEntry(db, entryId, entry, fragmentContent) {
29
36
  const fields = buildSearchFields(entry);
30
37
  const statements = getFtsMutationStatements(db);
31
38
  statements.deleteOne.run(entryId);
32
39
  statements.insert.run(entryId, fields.name, fields.description, fields.tags, fields.hints, fields.content);
40
+ if (fragmentContent === undefined) {
41
+ // Metadata-only re-upserts and re-keys deserialize the public document
42
+ // without the internal substrate. Leave the persisted source untouched.
43
+ // A scan that did read Markdown always supplies a value below.
44
+ return;
45
+ }
46
+ statements.deleteFragments.run(entryId);
47
+ statements.deleteFragmentSource.run(entryId);
48
+ if (!fragmentContent)
49
+ return;
50
+ statements.upsertFragmentSource.run(entryId, fragmentContent);
51
+ for (const fragment of splitMarkdownFragments(fragmentContent)) {
52
+ statements.insertFragment.run(entryId, fragment.fragmentId, fragment.ordinal, fragment.text.toLowerCase());
53
+ }
33
54
  }
34
55
  /** Delete derived FTS projections for canonical entries that are being removed. */
35
56
  export function deleteFtsEntries(db, entryIds) {
@@ -37,6 +58,8 @@ export function deleteFtsEntries(db, entryIds) {
37
58
  const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
38
59
  const placeholders = chunk.map(() => "?").join(",");
39
60
  db.prepare(`DELETE FROM entries_fts WHERE entry_id IN (${placeholders})`).run(...chunk);
61
+ db.prepare(`DELETE FROM entry_fragments_fts WHERE entry_id IN (${placeholders})`).run(...chunk);
62
+ db.prepare(`DELETE FROM entry_fragments WHERE entry_id IN (${placeholders})`).run(...chunk);
40
63
  }
41
64
  }
42
65
  export function searchFts(db, query, limit, entryType, excludeTypes) {
@@ -58,41 +81,92 @@ export function searchFts(db, query, limit, entryType, excludeTypes) {
58
81
  // input whose filler terms prevented a strict hit.
59
82
  return plan.relaxed ? runFtsQuery(db, plan.relaxed, "relaxed", limit, entryType, excludeTypes) : [];
60
83
  }
84
+ /**
85
+ * Resolve an opaque fragment selector from the indexed safe projection, not
86
+ * from current disk. A search result remains self-consistent across a later
87
+ * file edit; the next index refresh atomically publishes the new revision.
88
+ */
89
+ export function getIndexedMarkdownFragment(db, itemRef, fragmentId) {
90
+ const row = db
91
+ .prepare("SELECT s.safe_markdown FROM entry_fragments_fts f JOIN entry_fragments s ON s.entry_id = f.entry_id JOIN entries e ON e.id = f.entry_id WHERE e.item_ref = ? AND f.fragment_id = ?")
92
+ .get(itemRef, fragmentId);
93
+ const fragment = row ? fragmentForSelector(row.safe_markdown, fragmentId) : undefined;
94
+ return fragment ? { content: fragment.text } : undefined;
95
+ }
61
96
  function runFtsQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludeTypes) {
97
+ // Preserve the repository's ordinary limit contract for direct callers.
98
+ // The boundary-expansion rule applies only to a positive candidate pool.
99
+ if (limit <= 0)
100
+ return [];
62
101
  // #627 — exclude-type clause. Only applies on the untyped ('any') path; an
63
102
  // explicit include filter (entryType) already narrows to a single type, so
64
103
  // exclusion is redundant there. An empty list skips the clause entirely
65
104
  // (never emit `NOT IN ()`, which is a SQL error / always-false).
66
105
  const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
106
+ const candidateBoundaryOffset = Math.max(0, limit - 1);
67
107
  // The typed and untyped paths differ only by one `type` WHERE clause
68
- // equality vs. an optional NOT IN exclusion) and their param order — the
69
- // SELECT/JOIN/ORDER/LIMIT is shared, so build it once. Join on integer
70
- // entry_id directly (no CAST; we store integer). bm25() per-column weights:
108
+ // equality vs. an optional NOT IN exclusion) and their parameter order.
109
+ // Join on integer entry_id directly (no CAST; we store integer). bm25()
110
+ // per-column weights:
71
111
  // entry_id(0), name(10), description(5), tags(3), hints(2), content(1).
72
112
  let filterClause;
73
113
  let params;
74
114
  if (entryType && entryType !== "any") {
75
115
  filterClause = "AND e.type = ?";
76
- params = [ftsQuery, entryType, limit];
116
+ params = [ftsQuery, entryType, candidateBoundaryOffset];
77
117
  }
78
118
  else {
79
119
  filterClause = excludes.length > 0 ? `AND e.type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
80
- // Param order: MATCH, then the NOT IN values, then LIMIT.
81
- params = [ftsQuery, ...excludes, limit];
120
+ // Param order: MATCH, then the NOT IN values, then the zero-based
121
+ // candidate-boundary offset.
122
+ params = [ftsQuery, ...excludes, candidateBoundaryOffset];
82
123
  }
83
124
  const sql = `
84
- SELECT e.id, e.file_path AS filePath, e.document_json AS documentJson, e.search_text AS searchText,
85
- e.item_ref AS itemRef, e.bundle_id AS bundleId, e.concept_id AS conceptId, e.adapter_id AS adapterId,
86
- bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
125
+ -- Do not make a SQL-only relevance decision inside a tied BM25 boundary:
126
+ -- the TypeScript ranker adds exact-name, type, and other contributors
127
+ -- afterwards. Materialize BM25 once, locate the Nth score, and admit
128
+ -- every row tied with it. This deliberately makes the result set
129
+ -- data-bound for a pathological all-tied query; that is the only way to
130
+ -- avoid silently dropping a legitimate later ranking winner.
131
+ WITH scored AS MATERIALIZED (
132
+ -- Keep this materialized set deliberately narrow. document_json can be
133
+ -- large, and only rows admitted through the BM25 boundary need it.
134
+ SELECT e.id, bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
87
135
  FROM entries_fts f
88
136
  JOIN entries e ON e.id = f.entry_id
89
137
  WHERE entries_fts MATCH ?
90
138
  ${filterClause}
91
- ORDER BY bm25Score, e.id ASC
92
- LIMIT ?
139
+ ), boundary AS (
140
+ SELECT bm25Score
141
+ FROM scored
142
+ ORDER BY bm25Score
143
+ LIMIT 1 OFFSET ?
144
+ )
145
+ SELECT e.id, e.file_path AS filePath, e.document_json AS documentJson, e.search_text AS searchText,
146
+ e.item_ref AS itemRef, e.bundle_id AS bundleId, e.concept_id AS conceptId, e.adapter_id AS adapterId,
147
+ scored.bm25Score
148
+ FROM scored
149
+ JOIN entries e ON e.id = scored.id
150
+ WHERE NOT EXISTS (SELECT 1 FROM boundary)
151
+ OR scored.bm25Score <= (SELECT bm25Score FROM boundary)
152
+ ORDER BY scored.bm25Score, e.id ASC
93
153
  `;
94
154
  const rows = db.prepare(sql).all(...params);
95
- // Guard against corrupt JSON — skip the row rather than crashing
155
+ const results = materializeRows(rows, lexicalMatch);
156
+ // Fragments are a separate, intentionally calibrated evidence population:
157
+ // parent FTS remains the sole implementation of metadata/body conjunction.
158
+ // A selector is emitted only for one fragment that independently satisfies
159
+ // this query. Raw BM25 values are never claimed comparable across tables;
160
+ // each is passed through #933's stable mapping before merge.
161
+ const fragmentResults = hasFragmentFts(db)
162
+ ? runFragmentQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludes)
163
+ : [];
164
+ return mergeParentAndFragmentResults(results, fragmentResults);
165
+ }
166
+ function hasFragmentFts(db) {
167
+ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'entry_fragments_fts'").get());
168
+ }
169
+ function materializeRows(rows, lexicalMatch) {
96
170
  const results = [];
97
171
  for (const row of rows) {
98
172
  let entry;
@@ -118,6 +192,69 @@ function runFtsQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludeTypes)
118
192
  }
119
193
  return results;
120
194
  }
195
+ function runFragmentQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludes) {
196
+ const filter = entryType && entryType !== "any"
197
+ ? "AND e.type = ?"
198
+ : excludes.length
199
+ ? `AND e.type NOT IN (${excludes.map(() => "?").join(",")})`
200
+ : "";
201
+ const filterParams = entryType && entryType !== "any" ? [entryType] : excludes;
202
+ const candidateBoundaryOffset = Math.max(0, limit - 1);
203
+ // Select the winning child per parent inside SQLite before finding the
204
+ // candidate boundary. A document with many matching fragments therefore
205
+ // occupies one parent slot, while a boundary tie retains every parent for
206
+ // the TypeScript ranker to decide with its non-BM25 contributors. This has
207
+ // one FTS query and no OFFSET walk; the returned boundary is intentionally
208
+ // data-bound for a pathological all-tied query, just like parent FTS.
209
+ const sql = `
210
+ WITH matches AS MATERIALIZED (
211
+ -- Keep repeated child rows as narrow as parent FTS's scored CTE. The
212
+ -- document projection can be large; hydrate it only after the one-child
213
+ -- per-parent collapse and BM25 boundary filtering below.
214
+ SELECT e.id, f.fragment_id AS fragmentId, f.fragment_ordinal AS fragmentOrdinal,
215
+ bm25(entry_fragments_fts) AS bm25Score
216
+ FROM entry_fragments_fts f JOIN entries e ON e.id = f.entry_id
217
+ WHERE entry_fragments_fts MATCH ? ${filter}
218
+ ), ranked AS MATERIALIZED (
219
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY id ORDER BY bm25Score ASC, fragmentOrdinal ASC, fragmentId ASC) AS parentRank
220
+ FROM matches
221
+ ), parents AS MATERIALIZED (
222
+ SELECT * FROM ranked WHERE parentRank = 1
223
+ ), boundary AS (
224
+ SELECT bm25Score FROM parents ORDER BY bm25Score ASC LIMIT 1 OFFSET ?
225
+ )
226
+ SELECT e.id, e.file_path AS filePath, e.document_json AS documentJson, e.search_text AS searchText,
227
+ e.item_ref AS itemRef, e.bundle_id AS bundleId, e.concept_id AS conceptId, e.adapter_id AS adapterId,
228
+ parents.fragmentId, parents.bm25Score
229
+ FROM parents JOIN entries e ON e.id = parents.id
230
+ WHERE NOT EXISTS (SELECT 1 FROM boundary)
231
+ OR parents.bm25Score <= (SELECT bm25Score FROM boundary)
232
+ ORDER BY parents.bm25Score ASC, parents.id ASC`;
233
+ const rows = db.prepare(sql).all(ftsQuery, ...filterParams, candidateBoundaryOffset);
234
+ const results = [];
235
+ for (const row of rows) {
236
+ const [result] = materializeRows([row], lexicalMatch);
237
+ if (result) {
238
+ results.push({
239
+ ...result,
240
+ fragmentId: row.fragmentId,
241
+ lexicalScore: stableFtsScore(result.bm25Score, "fragment"),
242
+ });
243
+ }
244
+ }
245
+ return results;
246
+ }
247
+ function mergeParentAndFragmentResults(parents, fragments) {
248
+ const winners = new Map();
249
+ for (const parent of parents)
250
+ winners.set(parent.id, { ...parent, lexicalScore: stableFtsScore(parent.bm25Score) });
251
+ for (const fragment of fragments) {
252
+ const existing = winners.get(fragment.id);
253
+ if (!existing || (fragment.lexicalScore ?? 0) > (existing.lexicalScore ?? 0))
254
+ winners.set(fragment.id, fragment);
255
+ }
256
+ return [...winners.values()].sort((left, right) => (right.lexicalScore ?? 0) - (left.lexicalScore ?? 0) || left.id - right.id);
257
+ }
121
258
  /**
122
259
  * Explicitly rebuild the complete FTS5 projection from canonical entries.
123
260
  * Ordinary entry mutations do not call this: `upsertEntry` and the delete
@@ -131,8 +268,12 @@ function runFtsQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludeTypes)
131
268
  export function rebuildFts(db) {
132
269
  db.transaction(() => {
133
270
  db.exec("DELETE FROM entries_fts");
134
- const rows = db.prepare("SELECT id, document_json FROM entries").all();
271
+ db.exec("DELETE FROM entry_fragments_fts");
272
+ const rows = db
273
+ .prepare("SELECT e.id, e.document_json, f.safe_markdown FROM entries e LEFT JOIN entry_fragments f ON f.entry_id = e.id")
274
+ .all();
135
275
  const insertStmt = db.prepare(INSERT_FTS_SQL);
276
+ const fragmentStmt = db.prepare(INSERT_FRAGMENT_SQL);
136
277
  let skipped = 0;
137
278
  for (const row of rows) {
138
279
  let entry;
@@ -146,6 +287,11 @@ export function rebuildFts(db) {
146
287
  continue;
147
288
  }
148
289
  insertStmt.run(row.id, fields.name, fields.description, fields.tags, fields.hints, fields.content);
290
+ if (row.safe_markdown) {
291
+ for (const fragment of splitMarkdownFragments(row.safe_markdown)) {
292
+ fragmentStmt.run(row.id, fragment.fragmentId, fragment.ordinal, fragment.text.toLowerCase());
293
+ }
294
+ }
149
295
  }
150
296
  if (skipped > 0) {
151
297
  warn(`[db] rebuildFts: skipped ${skipped} entr${skipped === 1 ? "y" : "ies"} with invalid document_json`);
@@ -179,8 +179,8 @@ function rebuildIncompatibleIndexGeneration(db) {
179
179
  const classification = classifyIndexGeneration(db);
180
180
  if (classification.status === "newer") {
181
181
  throw new ConfigError(`Index database was built by a newer akm (stored generation ${classification.storedVersion ?? "unknown"}; ` +
182
- `this binary understands generation ${CANONICAL_INDEX_DB_VERSION}). Refusing to modify it — upgrade akm to ` +
183
- "write to this index, or delete index.db to rebuild it from scratch with this binary.", "INDEX_SCHEMA_INCOMPATIBLE");
182
+ `this binary understands generation ${CANONICAL_INDEX_DB_VERSION}). Refusing to modify it — upgrade akm ` +
183
+ "to use this index.", "INDEX_SCHEMA_INCOMPATIBLE", "Upgrade akm to a version that understands this index generation.");
184
184
  }
185
185
  warn(`Index database generation ${classification.storedVersion ?? "unknown"} is older than this akm's generation ` +
186
186
  `${CANONICAL_INDEX_DB_VERSION} — rebuilding the derived index (entries, FTS, embeddings, graph tables, ` +
@@ -202,6 +202,8 @@ function rebuildIncompatibleIndexGeneration(db) {
202
202
  db.exec("DROP TABLE IF EXISTS graph_extraction_queue");
203
203
  db.exec("DROP TABLE IF EXISTS graph_meta");
204
204
  db.exec("DROP TABLE IF EXISTS entries_fts_dirty");
205
+ db.exec("DROP TABLE IF EXISTS entry_fragments_fts");
206
+ db.exec("DROP TABLE IF EXISTS entry_fragments");
205
207
  db.exec("DROP TABLE IF EXISTS entries_fts");
206
208
  db.exec("DROP TABLE IF EXISTS embeddings");
207
209
  db.exec("DROP TABLE IF EXISTS utility_scores_scoped");
@@ -229,7 +231,6 @@ export function ensureSchema(db, embeddingDim) {
229
231
  // second persisted representation and was never used by current execution.
230
232
  // index.db is derived state, so remove the obsolete table on every open.
231
233
  db.exec("DROP TABLE IF EXISTS workflow_documents");
232
- setMeta(db, "version", String(DB_VERSION));
233
234
  // BLOB-based embedding storage (always available, no sqlite-vec needed)
234
235
  db.exec(`
235
236
  CREATE TABLE IF NOT EXISTS embeddings (
@@ -238,21 +239,6 @@ export function ensureSchema(db, embeddingDim) {
238
239
  FOREIGN KEY (id) REFERENCES entries(id)
239
240
  );
240
241
  `);
241
- // FTS5 table — multi-column with per-field weighting via bm25()
242
- const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_fts'").get();
243
- if (!ftsExists) {
244
- db.exec(`
245
- CREATE VIRTUAL TABLE entries_fts USING fts5(
246
- entry_id UNINDEXED,
247
- name,
248
- description,
249
- tags,
250
- hints,
251
- content,
252
- tokenize='porter unicode61'
253
- );
254
- `);
255
- }
256
242
  // usage_events lives in state.db. utility_scores remains a regenerable
257
243
  // index.db cache.
258
244
  // Utility scores table (aggregated per-entry utility metrics)
@@ -394,6 +380,10 @@ export function ensureSchema(db, embeddingDim) {
394
380
  // Registry index cache table — caches remote registry index documents so
395
381
  // `akm search` does not hit the network on every invocation.
396
382
  db.exec(REGISTRY_INDEX_CACHE_DDL);
383
+ // Write the generation stamp only after every required DDL surface exists.
384
+ // A crash before this point leaves an unversioned generation that the next
385
+ // writable open safely rebuilds instead of admitting a partial v23 index.
386
+ setMeta(db, "version", String(DB_VERSION));
397
387
  }
398
388
  /**
399
389
  * Returns true when a table exists in the current database.
@@ -0,0 +1,26 @@
1
+ Migration notes for akm v0.9.14
2
+
3
+ The derived `index.db` generation changes from v22 to v23 to support lexical
4
+ Markdown fragments. On the first normal read after upgrade, akm detects that
5
+ the v22 cache cannot serve queries and performs one inline v23 rebuild from
6
+ currently materialized sources before serving the request. An explicit
7
+ `akm index` performs the same writable rebuild. No hand-written database
8
+ migration or manual deletion is needed, but the first rebuild may take longer
9
+ than a normal read. Do not run an older akm against an index this release has
10
+ already rebuilt: upgrade that binary instead. If v23 schema creation is
11
+ interrupted, the partial cache is not admitted as current; the next writable
12
+ open rebuilds it, while an existing/read-only opener reports that `akm index`
13
+ is required.
14
+
15
+ Search may now return an addressable `#akm-fragment-…` suffix for the matching
16
+ part of a long Markdown asset. Pass that returned ref to `akm show` to display
17
+ the exact indexed fragment.
18
+
19
+ If you use collapse-detector canaries, their baseline predates the new index
20
+ generation. After the `akm index` rebuild/reindex completes, explicitly mint a
21
+ new set from the repository checkout:
22
+
23
+ bun scripts/refresh-canary-set.ts --refresh
24
+
25
+ The refresh is deliberate and is never automatic, so historical canary cycles
26
+ remain interpretable against their original baseline.
@@ -7,6 +7,8 @@ live one level up in `docs/migration/`.
7
7
 
8
8
  ## Available notes
9
9
 
10
+ - [0.9.14](0.9.14.md) — index v22-to-v23 derived-cache rebuild, lexical
11
+ fragments, and collapse-detector canary re-minting
10
12
  - [0.9.2](0.9.2.md) — task source v4 migration, workflow source IR v1 and
11
13
  durable-v4-family `irVersion: 5`, command diagnostics, and strategy judgment
12
14
  migration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.13",
3
+ "version": "0.9.14-beta.1",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [