akm-cli 0.9.12 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/assets/workflows/workflow-template.md +4 -0
  3. package/dist/commands/improve/eligibility.js +27 -15
  4. package/dist/commands/improve/improve.js +1 -0
  5. package/dist/commands/lint/base-linter.js +10 -0
  6. package/dist/commands/proposal/drain.js +48 -6
  7. package/dist/commands/proposal/proposal-cli.js +1 -0
  8. package/dist/commands/read/curate.js +3 -2
  9. package/dist/commands/read/show.js +26 -9
  10. package/dist/core/adapter/adapters/akm-adapter.js +5 -1
  11. package/dist/core/asset/markdown-fragments.js +146 -0
  12. package/dist/core/config/config-walker.js +7 -3
  13. package/dist/core/config/config.js +21 -12
  14. package/dist/core/config/schema/primitives.js +8 -2
  15. package/dist/core/errors.js +2 -0
  16. package/dist/core/lexical-score.js +25 -0
  17. package/dist/core/type-presentation.js +36 -4
  18. package/dist/indexer/index-written-assets.js +4 -0
  19. package/dist/indexer/indexer.js +5 -2
  20. package/dist/indexer/passes/metadata.js +64 -1
  21. package/dist/indexer/scan/doc-to-entry.js +3 -0
  22. package/dist/indexer/scan/drain-dir.js +33 -22
  23. package/dist/indexer/search/db-search.js +72 -14
  24. package/dist/indexer/search/name-match.js +35 -0
  25. package/dist/indexer/search/ranking-contributors.js +15 -12
  26. package/dist/indexer/search/ranking.js +42 -18
  27. package/dist/indexer/usage/show-usage.js +14 -2
  28. package/dist/llm/client.js +12 -8
  29. package/dist/llm/embedders/remote.js +3 -2
  30. package/dist/llm/graph-extract.js +18 -67
  31. package/dist/output/shapes.js +46 -1
  32. package/dist/output/text/proposal-format.js +5 -0
  33. package/dist/scripts/akm-migrate-node.js +648 -253
  34. package/dist/scripts/akm-migrate.js +648 -253
  35. package/dist/storage/repositories/index-connection.js +23 -8
  36. package/dist/storage/repositories/index-entries-repository.js +3 -2
  37. package/dist/storage/repositories/index-entry-schema.js +43 -3
  38. package/dist/storage/repositories/index-fts-repository.js +160 -14
  39. package/dist/storage/repositories/index-schema.js +8 -18
  40. package/dist/storage/repositories/workflow-runs-repository.js +118 -10
  41. package/dist/workflows/exec/run-workflow.js +1 -1
  42. package/dist/workflows/exec/step-work.js +41 -0
  43. package/dist/workflows/parser.js +1 -1
  44. package/dist/workflows/runtime/runs.js +29 -5
  45. package/docs/migration/release-notes/0.9.14.md +26 -0
  46. package/docs/migration/release-notes/README.md +2 -0
  47. package/docs/reference/cli.md +18 -0
  48. package/package.json +1 -1
@@ -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.
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { NotFoundError, UsageError } from "../../core/errors.js";
6
6
  import { openStateDatabase, withImmediateTransaction } from "../../core/state-db.js";
7
7
  import { borrowScopedStateDb, withStateDbScope } from "../../core/state-db-scope.js";
8
+ import { sleepSync } from "../../runtime.js";
8
9
  import { escapeLikePattern } from "../like-pattern.js";
9
10
  import { resolveStorageLocations } from "../locations.js";
10
11
  import { insertEventOnce, insertEventStrict } from "./events-repository.js";
@@ -21,6 +22,51 @@ function assertAttemptReservationLease(input, run) {
21
22
  throw new UsageError(`Workflow run ${input.runId} engine lease expired before durable dispatch reservation.`, "RESOURCE_ALREADY_EXISTS");
22
23
  }
23
24
  }
25
+ /**
26
+ * Whether `error` is one of the specific SQLite conditions a run-lease
27
+ * statement can throw under real cross-process contention on the same row:
28
+ * `SQLITE_BUSY`/`SQLITE_LOCKED` (both drivers), or the message text a
29
+ * transient contention blip has been observed producing, "database is
30
+ * locked", "disk I/O error", or "database disk image is malformed".
31
+ * Matching on this set alone is never sufficient to call something lease
32
+ * contention — see {@link WorkflowRunsRepository.acquireEngineLease}, which
33
+ * additionally requires a fresh read confirming a live lease before
34
+ * substituting the lease-held message for the original error.
35
+ */
36
+ function isLeaseContentionSqliteError(error) {
37
+ const code = error?.code;
38
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
39
+ return true;
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ return (message.includes("database is locked") ||
42
+ message.includes("disk I/O error") ||
43
+ message.includes("database disk image is malformed"));
44
+ }
45
+ const LEASE_RETRY_ATTEMPTS = 4;
46
+ const LEASE_RETRY_BASE_DELAY_MS = 15;
47
+ /**
48
+ * Retry a single lease statement across a short, bounded set of attempts when
49
+ * it throws one of {@link isLeaseContentionSqliteError}'s conditions —
50
+ * absorbing a blip that a fresh attempt on the same connection clears on its
51
+ * own. Any other error, or the same error surviving every attempt, propagates
52
+ * unchanged; this never converts a persistent failure into a false success.
53
+ */
54
+ function runLeaseStatementWithRetry(fn) {
55
+ let lastError;
56
+ for (let attempt = 0; attempt < LEASE_RETRY_ATTEMPTS; attempt += 1) {
57
+ try {
58
+ return fn();
59
+ }
60
+ catch (error) {
61
+ if (!isLeaseContentionSqliteError(error))
62
+ throw error;
63
+ lastError = error;
64
+ if (attempt < LEASE_RETRY_ATTEMPTS - 1)
65
+ sleepSync(LEASE_RETRY_BASE_DELAY_MS * 2 ** attempt);
66
+ }
67
+ }
68
+ throw lastError;
69
+ }
24
70
  /**
25
71
  * Repository owning every raw SQL statement against `workflow_runs` and
26
72
  * `workflow_run_steps`. It is DB-location-agnostic: the lifecycle helper
@@ -365,25 +411,57 @@ export class WorkflowRunsRepository {
365
411
  * A live lease held by anyone (including a stale copy of the same holder)
366
412
  * is NOT reclaimable through this method; the single UPDATE is the whole
367
413
  * claim, so two racing invocations cannot both win.
414
+ *
415
+ * The UPDATE can throw instead of cleanly returning `changes: 0` under real
416
+ * cross-process contention on this row: a `SQLITE_BUSY`/`SQLITE_LOCKED`
417
+ * from two engines racing the same statement, occasionally surfacing as
418
+ * "database is locked" or even "database disk image is malformed" text that
419
+ * reads as corruption but is not. `runLeaseStatementWithRetry` absorbs a
420
+ * blip that a fresh attempt clears on its own. If it is still failing after
421
+ * every retry, the row is read fresh (a plain SELECT, far less likely to
422
+ * trip whatever the write hit) to get independent evidence of what is
423
+ * actually going on: a live lease there means this really was contention,
424
+ * so the caller gets the same lease-held message `akm workflow run` already
425
+ * shows for the clean (non-throwing) case, now with `RUN_LEASE_HELD`. No
426
+ * live lease — or the verifying read itself fails — means the error was
427
+ * never actually about the lease, so it is rethrown exactly as raised.
428
+ * Nothing here invents a diagnosis from error text alone or suppresses a
429
+ * genuine SQLite failure.
368
430
  */
369
431
  acquireEngineLease(runId, holder, until, now) {
370
- const result = this.db
371
- .prepare(`UPDATE workflow_runs
372
- SET engine_lease_holder = ?, engine_lease_until = ?
373
- WHERE id = ? AND status = 'active'
374
- AND (engine_lease_holder IS NULL OR engine_lease_until IS NULL OR engine_lease_until < ?)`)
375
- .run(holder, until, runId, now);
376
- return Number(result.changes) > 0;
432
+ try {
433
+ const result = runLeaseStatementWithRetry(() => this.db
434
+ .prepare(`UPDATE workflow_runs
435
+ SET engine_lease_holder = ?, engine_lease_until = ?
436
+ WHERE id = ? AND status = 'active'
437
+ AND (engine_lease_holder IS NULL OR engine_lease_until IS NULL OR engine_lease_until < ?)`)
438
+ .run(holder, until, runId, now));
439
+ return Number(result.changes) > 0;
440
+ }
441
+ catch (error) {
442
+ if (!isLeaseContentionSqliteError(error))
443
+ throw error;
444
+ const row = this.tryReadLeaseColumns(runId);
445
+ if (row?.engine_lease_holder && row.engine_lease_until && row.engine_lease_until >= now) {
446
+ throw new UsageError(`Workflow run ${runId} is already being driven by engine ${row.engine_lease_holder} ` +
447
+ `(run lease expires ${row.engine_lease_until}). A second \`akm workflow run\` would race it — ` +
448
+ `wait for that invocation to finish or for the lease to expire.`, "RUN_LEASE_HELD");
449
+ }
450
+ throw error;
451
+ }
377
452
  }
378
453
  /**
379
454
  * Extend the lease expiry — only while `holder` still owns it. Returns
380
455
  * false when the lease was lost (expired and claimed by another engine),
381
- * so the caller can stop driving instead of racing the new owner.
456
+ * so the caller can stop driving instead of racing the new owner. Wrapped
457
+ * in the same transient-error retry as {@link acquireEngineLease}; a
458
+ * renewal that still fails after retries is rethrown as-is (no confirmed
459
+ * "lost lease" diagnosis to substitute, unlike the acquire case above).
382
460
  */
383
461
  renewEngineLease(runId, holder, until) {
384
- const result = this.db
462
+ const result = runLeaseStatementWithRetry(() => this.db
385
463
  .prepare("UPDATE workflow_runs SET engine_lease_until = ? WHERE id = ? AND engine_lease_holder = ? AND status = 'active'")
386
- .run(until, runId, holder);
464
+ .run(until, runId, holder));
387
465
  return Number(result.changes) > 0;
388
466
  }
389
467
  /**
@@ -396,6 +474,36 @@ export class WorkflowRunsRepository {
396
474
  .prepare("UPDATE workflow_runs SET engine_lease_holder = NULL, engine_lease_until = NULL WHERE id = ? AND engine_lease_holder = ? AND status <> 'failed'")
397
475
  .run(runId, holder);
398
476
  }
477
+ /**
478
+ * Self-heal an engine lease its holder crashed without releasing: once
479
+ * `engine_lease_until` has passed, clear it so a read (`workflow status`,
480
+ * `workflow list`) stops reporting a run as engine-driven when the engine is
481
+ * long gone — mirroring the maintenance barrier's self-reclaim of a wedged
482
+ * sentinel (`tryAcquireMaintenanceBarrier`) rather than a bespoke mechanism.
483
+ * The WHERE clause repeats the exact (holder, until) snapshot the caller
484
+ * read, so a lease renewed or re-acquired in between never gets clobbered —
485
+ * same compare-and-swap shape as the claim above. Never touches a lease
486
+ * that is still live.
487
+ */
488
+ reclaimExpiredEngineLease(runId, holder, until, now) {
489
+ if (until >= now)
490
+ return false;
491
+ const result = this.db
492
+ .prepare(`UPDATE workflow_runs
493
+ SET engine_lease_holder = NULL, engine_lease_until = NULL
494
+ WHERE id = ? AND engine_lease_holder = ? AND engine_lease_until = ? AND engine_lease_until < ?`)
495
+ .run(runId, holder, until, now);
496
+ return Number(result.changes) > 0;
497
+ }
498
+ /** Best-effort lease-column read used only to confirm genuine contention after {@link acquireEngineLease} exhausts its retries. `undefined` on any failure — never a diagnosis, just "couldn't confirm". */
499
+ tryReadLeaseColumns(runId) {
500
+ try {
501
+ return (this.db.prepare("SELECT engine_lease_holder, engine_lease_until FROM workflow_runs WHERE id = ?").get(runId) ?? undefined);
502
+ }
503
+ catch {
504
+ return undefined;
505
+ }
506
+ }
399
507
  // ── durable v4 append-only dispatch attempts (migration 022) ─────────────
400
508
  getUnitAttempts(runId, unitId) {
401
509
  return this.db