akm-cli 0.9.26 → 0.9.27-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,9 +19,27 @@ import { splitMarkdownFragments } from "../../core/asset/markdown-fragments.js";
19
19
  import { warn } from "../../core/warn.js";
20
20
  import { ftsOrMatch, ftsQueryTokens } from "../../indexer/search/fts-query.js";
21
21
  import { buildSearchFields } from "../../indexer/search/search-fields.js";
22
+ import { isContentlessFtsDdl, readTableSql } from "./index-entry-schema.js";
23
+ import { deleteMeta, getMeta } from "./index-meta-repository.js";
22
24
  import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
23
25
  // `entries_fts.rowid = entries.id`, so a per-entry delete is a rowid lookup.
24
26
  const INSERT_FTS_SQL = "INSERT INTO entries_fts (rowid, entry_id, name, description, tags, hints, content) VALUES (?, ?, ?, ?, ?, ?, ?)";
27
+ /**
28
+ * `index_meta` key stamped when a delete took a row out of `entries_fts`.
29
+ *
30
+ * FTS5 cannot take a deleted row out of a contentless table's BM25 totals (its
31
+ * row count, and the token counts the average document length comes from), so
32
+ * every removal or replacement leaves them one row too high and an updated
33
+ * index scores differently from a fresh one (#1048). SQLite has no command that
34
+ * recomputes them (`delete` and `rebuild` are refused on a contentless table),
35
+ * so the next `akm index` rebuilds the table from `entries`
36
+ * ({@link rebuildFtsIfTotalsStale}).
37
+ */
38
+ const FTS_TOTALS_STALE_META = "ftsTotalsStale";
39
+ /** The content-bearing table older SQLite gets subtracts a deleted row itself. */
40
+ function isContentlessFts(db) {
41
+ return isContentlessFtsDdl(readTableSql(db, "entries_fts"));
42
+ }
25
43
  const ftsMutationStatementsByDb = new WeakMap();
26
44
  function getFtsMutationStatements(db) {
27
45
  const existing = ftsMutationStatementsByDb.get(db);
@@ -29,6 +47,7 @@ function getFtsMutationStatements(db) {
29
47
  return existing;
30
48
  const statements = {
31
49
  deleteOne: db.prepare("DELETE FROM entries_fts WHERE rowid = ?"),
50
+ markTotalsStale: db.prepare(`INSERT OR IGNORE INTO index_meta (key, value) VALUES ('${FTS_TOTALS_STALE_META}', '1')`),
32
51
  insert: db.prepare(INSERT_FTS_SQL),
33
52
  upsertFragmentSource: db.prepare("INSERT INTO entry_fragments (entry_id, safe_markdown) VALUES (?, ?) ON CONFLICT(entry_id) DO UPDATE SET safe_markdown = excluded.safe_markdown"),
34
53
  deleteFragmentSource: db.prepare("DELETE FROM entry_fragments WHERE entry_id = ?"),
@@ -36,6 +55,11 @@ function getFtsMutationStatements(db) {
36
55
  ftsMutationStatementsByDb.set(db, statements);
37
56
  return statements;
38
57
  }
58
+ /** A delete that removed a row leaves the table's BM25 totals too high until it is rebuilt. */
59
+ function noteRemoval(statements, removed) {
60
+ if (removed.changes > 0)
61
+ statements.markTotalsStale.run();
62
+ }
39
63
  /**
40
64
  * Replace one entry's derived FTS row, and its fragment source when the scan
41
65
  * read Markdown, inside the caller's transaction.
@@ -43,7 +67,7 @@ function getFtsMutationStatements(db) {
43
67
  export function replaceFtsEntry(db, entryId, entry, fragmentContent) {
44
68
  const fields = buildSearchFields(entry);
45
69
  const statements = getFtsMutationStatements(db);
46
- statements.deleteOne.run(entryId);
70
+ noteRemoval(statements, statements.deleteOne.run(entryId));
47
71
  statements.insert.run(entryId, entryId, fields.name, fields.description, fields.tags, fields.hints, fields.content);
48
72
  if (fragmentContent === undefined) {
49
73
  // Metadata-only re-upserts and re-keys deserialize the public document
@@ -61,7 +85,7 @@ export function deleteFtsEntries(db, entryIds) {
61
85
  for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
62
86
  const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
63
87
  const placeholders = chunk.map(() => "?").join(",");
64
- db.prepare(`DELETE FROM entries_fts WHERE rowid IN (${placeholders})`).run(...chunk);
88
+ noteRemoval(getFtsMutationStatements(db), db.prepare(`DELETE FROM entries_fts WHERE rowid IN (${placeholders})`).run(...chunk));
65
89
  db.prepare(`DELETE FROM entry_fragments WHERE entry_id IN (${placeholders})`).run(...chunk);
66
90
  }
67
91
  }
@@ -143,7 +167,9 @@ function materializeIndexedMarkdownFragment(fragment, fragments, parentChars) {
143
167
  */
144
168
  export function rebuildFts(db) {
145
169
  db.transaction(() => {
146
- db.exec("DELETE FROM entries_fts");
170
+ // `delete-all` also resets the BM25 totals, which a plain DELETE leaves as they were (#1048).
171
+ db.exec(isContentlessFts(db) ? "INSERT INTO entries_fts(entries_fts) VALUES('delete-all')" : "DELETE FROM entries_fts");
172
+ deleteMeta(db, FTS_TOTALS_STALE_META);
147
173
  // Keyset pages, so a large index is never held in memory at once.
148
174
  const page = db.prepare("SELECT id, document_json FROM entries WHERE id > ? ORDER BY id LIMIT 500");
149
175
  const insertStmt = db.prepare(INSERT_FTS_SQL);
@@ -171,3 +197,18 @@ export function rebuildFts(db) {
171
197
  }
172
198
  })();
173
199
  }
200
+ /**
201
+ * Rebuild `entries_fts` from `entries` when rows have left it since its BM25
202
+ * totals were last taken (#1048): about a second at 25,000 entries. Returns
203
+ * whether it did.
204
+ */
205
+ export function rebuildFtsIfTotalsStale(db) {
206
+ if (getMeta(db, FTS_TOTALS_STALE_META) === undefined)
207
+ return false;
208
+ if (!isContentlessFts(db)) {
209
+ deleteMeta(db, FTS_TOTALS_STALE_META);
210
+ return false;
211
+ }
212
+ rebuildFts(db);
213
+ return true;
214
+ }
@@ -25,7 +25,7 @@ import path from "node:path";
25
25
  import { ConfigError } from "../../core/errors.js";
26
26
  import { warn } from "../../core/warn.js";
27
27
  import { sha256Hex } from "../../runtime.js";
28
- import { CANONICAL_ENTRY_SCHEMA_SQL, CANONICAL_INDEX_DB_VERSION, entriesFtsDdl, isContentlessFtsDdl, missingEntryColumns, readTableSql, supportsContentlessDelete, tableExists, } from "./index-entry-schema.js";
28
+ import { CANONICAL_ENTRY_SCHEMA_SQL, CANONICAL_INDEX_DB_VERSION, entriesFtsDdl, isContentlessFtsDdl, missingEntryColumns, readTableSql, retiredEntryColumns, supportsContentlessDelete, tableExists, } from "./index-entry-schema.js";
29
29
  import { rebuildFts } from "./index-fts-repository.js";
30
30
  import { rebuildAllEntryLinks } from "./index-links-repository.js";
31
31
  import { getMeta, setMeta } from "./index-meta-repository.js";
@@ -67,11 +67,13 @@ const REGISTRY_INDEX_CACHE_DDL = `
67
67
  ON registry_index_cache(fetched_at);
68
68
  `;
69
69
  /**
70
- * An `entries` table missing a required column cannot be read or written by
71
- * this release (the last such change was v20→v21, which removed the
72
- * transitional `entry_key`/`dir_path`/... columns and made `item_ref` the
73
- * key). Recreate only the tables keyed by `entries.id` — their ids are about
74
- * to be re-minted, so the rows would dangle anyway. The LLM enrichment cache
70
+ * An `entries` table missing a required column, or still carrying a retired
71
+ * one, cannot be read or written by this release (the last such change was
72
+ * v20→v21, which removed the transitional `entry_key`/`dir_path`/... columns
73
+ * and made `item_ref` the key). Layouts 18–20 carried the current columns
74
+ * beside the retired ones, so only the retired ones give them away.
75
+ * Recreate only the tables keyed by `entries.id` — their ids are about to be
76
+ * re-minted, so the rows would dangle anyway. The LLM enrichment cache
75
77
  * (keyed by ref) is kept. The LLM entity-graph tables are unconditionally
76
78
  * dropped elsewhere in this file regardless of this recreation (retired
77
79
  * 0.9.17-alpha.9), not kept. The next index run re-walks every source.
@@ -80,9 +82,13 @@ function ensureEntriesLayout(db) {
80
82
  if (!tableExists(db, "entries"))
81
83
  return;
82
84
  const missing = missingEntryColumns(db);
83
- if (missing.length === 0)
85
+ const retired = retiredEntryColumns(db);
86
+ if (missing.length === 0 && retired.length === 0)
84
87
  return;
85
- warn(`Index database entries table predates the ${missing.join(", ")} column${missing.length === 1 ? "" : "s"} — ` +
88
+ const why = missing.length > 0
89
+ ? `predates the ${missing.join(", ")} column${missing.length === 1 ? "" : "s"}`
90
+ : `still has the retired ${retired.join(", ")} column${retired.length === 1 ? "" : "s"}`;
91
+ warn(`Index database entries table ${why} — ` +
86
92
  "recreating the entries-keyed tables (entries, full-text, embeddings, utility scores); the " +
87
93
  "LLM enrichment cache is kept. The next index run re-walks every source.");
88
94
  db.transaction(() => {
@@ -2780,6 +2780,7 @@ harvest" branch on `skipReasons`, `warnings`, or `sessionsProcessed` /
2780
2780
  | `engineKind` | `"llm"`, `"sdk"`, or `"agent"` — the kind of runner `engine` resolved to. Same absence condition as `engine`. |
2781
2781
  | `skipReasons` | Per-`skipReason` count across `sessions[]` (e.g. `{ "llm_unavailable": 25 }`). Present only when `sessionsSkipped > 0`. |
2782
2782
  | `warnings` | Includes one aggregate line per infrastructure skip reason that fired (`llm_unavailable`, `read_failed`, `exception`, `locked_concurrent`) — e.g. `25 of 25 sessions skipped: llm_unavailable (engine "default")` — so an engine outage is visible without inspecting `sessions[]`. Session-content skips (`already_extracted`, `too_short`, `triaged_out`) are counted in `skipReasons` but never produce a warning line. |
2783
+ | `sessions[].warnings` | One line per candidate the model wrote that the output contract refuses, as `<type>:<name> dropped: <reason>` — a lesson without a `when_to_use` of 15 characters, a description under 20, a name that is not a kebab-case slug — and one per candidate held back by the improve ledger. A session whose every candidate was dropped has `candidateCount: 0` and no `rationaleIfEmpty`, so this is where the loss shows. |
2783
2784
 
2784
2785
  #### proposal new
2785
2786
 
@@ -110,8 +110,11 @@ distill quality-gate judges do too unless the judge's engine sets
110
110
  `reasoning_effort` when set. Backend support: llama.cpp direct honors both forms
111
111
  (`reasoning_effort` from build ≥ b10644); vLLM honors
112
112
  `chat_template_kwargs`; Bifrost drops `chat_template_kwargs` and passes
113
- `reasoning_effort` through, so also set `reasoningEffort: "none"` behind it; a
114
- strict hosted API may 400 on unrecognized keys. Both fields are AKM-owned, not
113
+ `reasoning_effort` through, so also set `reasoningEffort: "none"` behind it. A
114
+ strict hosted API that rejects the two fields (OpenAI answers 400 `Unknown
115
+ parameter: 'chat_template_kwargs'`) gets one retry without both, and AKM stops
116
+ sending them to that endpoint and model for the rest of the process, as it
117
+ does for `response_format` below. Both fields are AKM-owned, not
115
118
  settable via `extraParams`. A response with reasoning tokens despite
116
119
  `enableThinking: false` triggers a runtime warning and the `akm health`
117
120
  `thinking-control` advisory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.26",
3
+ "version": "0.9.27-alpha.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": [