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.
- package/CHANGELOG.md +39 -0
- package/dist/commands/improve/eligibility.js +27 -15
- package/dist/commands/read/curate.js +3 -2
- package/dist/commands/read/show.js +26 -9
- package/dist/core/adapter/adapters/akm-adapter.js +5 -1
- package/dist/core/asset/markdown-fragments.js +146 -0
- package/dist/core/lexical-score.js +25 -0
- package/dist/core/type-presentation.js +36 -4
- package/dist/indexer/index-written-assets.js +4 -0
- package/dist/indexer/indexer.js +5 -2
- package/dist/indexer/passes/metadata.js +64 -1
- package/dist/indexer/scan/doc-to-entry.js +3 -0
- package/dist/indexer/scan/drain-dir.js +33 -22
- package/dist/indexer/search/db-search.js +72 -14
- package/dist/indexer/search/name-match.js +35 -0
- package/dist/indexer/search/ranking-contributors.js +15 -12
- package/dist/indexer/search/ranking.js +42 -18
- package/dist/indexer/usage/show-usage.js +14 -2
- package/dist/llm/graph-extract.js +18 -67
- package/dist/scripts/akm-migrate-node.js +334 -90
- package/dist/scripts/akm-migrate.js +334 -90
- package/dist/storage/repositories/index-connection.js +23 -8
- package/dist/storage/repositories/index-entries-repository.js +3 -2
- package/dist/storage/repositories/index-entry-schema.js +43 -3
- package/dist/storage/repositories/index-fts-repository.js +160 -14
- package/dist/storage/repositories/index-schema.js +8 -18
- package/docs/migration/release-notes/0.9.14.md +26 -0
- package/docs/migration/release-notes/README.md +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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,
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
"
|
|
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.
|
|
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": [
|