akm-cli 0.9.7 → 0.9.8-beta.2

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 (130) hide show
  1. package/CHANGELOG.md +310 -0
  2. package/dist/commands/env/env.js +2 -21
  3. package/dist/commands/health/checks.js +0 -25
  4. package/dist/commands/health/data-dir-usage.js +165 -0
  5. package/dist/commands/health/improve-metrics.js +8 -34
  6. package/dist/commands/health/windows.js +0 -4
  7. package/dist/commands/health.js +18 -37
  8. package/dist/commands/improve/consolidate/eligibility.js +11 -5
  9. package/dist/commands/improve/extract.js +36 -32
  10. package/dist/commands/improve/memory/memory-belief.js +15 -5
  11. package/dist/commands/improve/memory/memory-contradiction-detect.js +16 -25
  12. package/dist/commands/improve/memory/memory-improve.js +7 -19
  13. package/dist/commands/improve/preparation.js +52 -36
  14. package/dist/commands/improve/reflect.js +13 -17
  15. package/dist/commands/lint/base-linter.js +42 -2
  16. package/dist/commands/lint/index.js +4 -5
  17. package/dist/commands/migrate/config-extra-params.js +61 -0
  18. package/dist/commands/migrate/dead-residue.js +113 -0
  19. package/dist/commands/migrate/stale-txn.js +49 -0
  20. package/dist/commands/migrate-cli.js +42 -1
  21. package/dist/commands/proposal/proposal.js +1 -21
  22. package/dist/commands/proposal/repository.js +0 -4
  23. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -6
  24. package/dist/commands/read/curate.js +4 -18
  25. package/dist/commands/read/show.js +2 -1
  26. package/dist/commands/sources/installed-stashes.js +5 -1
  27. package/dist/commands/sources/self-update.js +38 -1
  28. package/dist/commands/sources/sources-cli.js +17 -1
  29. package/dist/commands/tasks/tasks.js +0 -8
  30. package/dist/commands/url-checker.js +20 -28
  31. package/dist/commands/workflow-cli.js +11 -61
  32. package/dist/core/adapter/adapters/agent-skills-adapter.js +2 -6
  33. package/dist/core/adapter/adapters/akm-adapter.js +2 -3
  34. package/dist/core/adapter/adapters/akm-lint.js +2 -20
  35. package/dist/core/adapter/adapters/akm-task-adapter.js +2 -6
  36. package/dist/core/adapter/adapters/akm-workflow-adapter.js +2 -6
  37. package/dist/core/adapter/adapters/dotenv-adapter.js +2 -22
  38. package/dist/core/adapter/adapters/generic-files-adapter.js +3 -9
  39. package/dist/core/adapter/adapters/llm-wiki-adapter.js +2 -6
  40. package/dist/core/adapter/adapters/okf-adapter.js +7 -18
  41. package/dist/core/adapter/adapters/shared.js +2 -0
  42. package/dist/core/adapter/adapters/tool-dir-shared.js +3 -9
  43. package/dist/core/adapter/adapters/website-snapshot-adapter.js +2 -6
  44. package/dist/core/adapter/validate-context.js +1 -3
  45. package/dist/core/asset/asset-placement.js +1 -3
  46. package/dist/core/asset/frontmatter.js +96 -0
  47. package/dist/core/common.js +66 -2
  48. package/dist/core/config/config-io.js +21 -8
  49. package/dist/core/config/config-walker.js +6 -10
  50. package/dist/core/config/config.js +11 -8
  51. package/dist/core/extra-params.js +17 -13
  52. package/dist/core/improve-result.js +1 -3
  53. package/dist/core/json-schema.js +9 -11
  54. package/dist/core/state/migrations.js +74 -2
  55. package/dist/core/state-db.js +2 -1
  56. package/dist/core/warn.js +15 -0
  57. package/dist/execution/executable-identity.js +1 -3
  58. package/dist/execution/guarded-source.js +1 -6
  59. package/dist/indexer/bundle-identity-guard.js +6 -1
  60. package/dist/indexer/db/graph-db.js +139 -154
  61. package/dist/indexer/ensure-index.js +11 -19
  62. package/dist/indexer/graph/graph-boost.js +23 -34
  63. package/dist/indexer/graph/graph-extraction.js +12 -2
  64. package/dist/indexer/indexer.js +43 -22
  65. package/dist/indexer/lookup/adapter-concept-owner.js +12 -9
  66. package/dist/indexer/passes/dir-staleness.js +30 -9
  67. package/dist/indexer/passes/memory-inference.js +7 -2
  68. package/dist/indexer/scan/drain-dir.js +2 -4
  69. package/dist/indexer/search/db-search.js +3 -3
  70. package/dist/indexer/search/fts-query.js +10 -15
  71. package/dist/indexer/search/search-source.js +0 -13
  72. package/dist/indexer/usage/usage-events.js +9 -1
  73. package/dist/indexer/walk/walker.js +11 -6
  74. package/dist/integrations/harnesses/claude/result-extractor.js +30 -15
  75. package/dist/integrations/harnesses/codex/result-extractor.js +43 -6
  76. package/dist/integrations/harnesses/copilot/result-extractor.js +39 -12
  77. package/dist/integrations/harnesses/gemini/result-extractor.js +40 -12
  78. package/dist/integrations/harnesses/index.js +0 -4
  79. package/dist/integrations/harnesses/openhands/result-extractor.js +51 -19
  80. package/dist/integrations/harnesses/pi/result-extractor.js +45 -12
  81. package/dist/integrations/lockfile.js +0 -14
  82. package/dist/integrations/session-logs/index.js +0 -81
  83. package/dist/llm/client.js +0 -12
  84. package/dist/llm/memory-infer.js +1 -3
  85. package/dist/llm/usage-telemetry.js +1 -3
  86. package/dist/registry/create-provider-registry.js +4 -0
  87. package/dist/registry/factory.js +4 -0
  88. package/dist/registry/resolve.js +23 -8
  89. package/dist/runtime.js +0 -12
  90. package/dist/scripts/akm-migrate-node.js +179 -248
  91. package/dist/scripts/akm-migrate.js +179 -248
  92. package/dist/setup/setup.js +6 -12
  93. package/dist/sources/providers/git-install.js +7 -2
  94. package/dist/sources/providers/tar-utils.js +1 -7
  95. package/dist/sources/snapshot-fetchers/content-extract.js +0 -17
  96. package/dist/storage/managed-db.js +20 -7
  97. package/dist/storage/repositories/events-repository.js +0 -81
  98. package/dist/storage/repositories/index-entries-repository.js +24 -28
  99. package/dist/storage/repositories/index-fts-repository.js +24 -30
  100. package/dist/storage/repositories/index-meta-repository.js +6 -4
  101. package/dist/storage/repositories/index-schema.js +16 -1
  102. package/dist/storage/repositories/index-utility-repository.js +0 -57
  103. package/dist/storage/repositories/index-vec-repository.js +25 -27
  104. package/dist/storage/repositories/proposals-repository.js +4 -1
  105. package/dist/storage/repositories/task-history-repository.js +9 -3
  106. package/dist/tasks/backends/launchd.js +8 -18
  107. package/dist/tasks/resolve-akm-bin.js +15 -0
  108. package/dist/tasks/run/task-history.js +21 -31
  109. package/dist/tasks/scheduler-sync.js +1 -6
  110. package/dist/tasks/source/bounded-document.js +1 -14
  111. package/dist/tasks/source/task-to-v3.js +10 -2
  112. package/dist/workflows/exec/child-workflow.js +1 -1
  113. package/dist/workflows/exec/native-executor.js +2 -2
  114. package/dist/workflows/exec/step-work.js +5 -17
  115. package/dist/workflows/exec/worktree.js +40 -6
  116. package/dist/workflows/freeze/task-bindings.js +2 -4
  117. package/dist/workflows/ir/compile.js +3 -14
  118. package/dist/workflows/ir/schema-v4.js +4 -6
  119. package/dist/workflows/ir/schema.js +2 -5
  120. package/dist/workflows/parser.js +23 -26
  121. package/dist/workflows/source-files.js +8 -13
  122. package/dist/workflows/source-ir/schema.js +1 -19
  123. package/docs/migration/v0.9.0-troubleshooting.md +12 -2
  124. package/docs/migration/v0.9.1-to-v0.9.2.md +18 -4
  125. package/docs/reference/cli.md +3 -1
  126. package/docs/reference/data-and-telemetry.md +1 -0
  127. package/docs/reference/tasks.md +11 -0
  128. package/docs/reference/workflow-schema.md +24 -0
  129. package/package.json +2 -9
  130. package/dist/workflows/source-ir/compare.js +0 -17
@@ -24,7 +24,7 @@ import { deriveBundleIds } from "../core/bundle-id.js";
24
24
  import { bundleEntryToSourceEntry, DEFAULT_CONFIG, loadUserConfig, mutateConfigWithPrecommit, parseAndValidateConfigText, primaryBundlePath, validateCompleteConfig, } from "../core/config/config.js";
25
25
  import { readConfigText } from "../core/config/config-io.js";
26
26
  import { listTopLevelConfigKeys } from "../core/config/config-schema.js";
27
- import { deepMergeConfig } from "../core/config/deep-merge.js";
27
+ import { deepMergeConfig, isPlainObject } from "../core/config/deep-merge.js";
28
28
  import { ConfigError, UsageError } from "../core/errors.js";
29
29
  import { getConfigPath, getDefaultStashDir, isTransientStashPath } from "../core/paths.js";
30
30
  import { warn } from "../core/warn.js";
@@ -131,17 +131,11 @@ export function assertSetupConfigPreflight() {
131
131
  `(e.g. \`mv ${configPath} ${configPath}.bak\`) and re-run \`akm setup\`.`);
132
132
  }
133
133
  }
134
- function isPlainRecord(value) {
135
- if (!value || typeof value !== "object" || Array.isArray(value))
136
- return false;
137
- const prototype = Object.getPrototypeOf(value);
138
- return prototype === Object.prototype || prototype === null;
139
- }
140
134
  function sameConfigValue(left, right) {
141
135
  return isDeepStrictEqual(left, right);
142
136
  }
143
137
  function configArrayItemKey(value) {
144
- if (!isPlainRecord(value))
138
+ if (!isPlainObject(value))
145
139
  return `value:${JSON.stringify(value)}`;
146
140
  if (typeof value.id === "string")
147
141
  return `id:${value.id}`;
@@ -199,14 +193,14 @@ export function rebaseSetupChanges(original, desired, latest, pathParts = []) {
199
193
  }
200
194
  return result;
201
195
  }
202
- if (!isPlainRecord(original) || !isPlainRecord(desired)) {
196
+ if (!isPlainObject(original) || !isPlainObject(desired)) {
203
197
  if (!sameConfigValue(latest, original) && !sameConfigValue(latest, desired))
204
198
  setupConflict(pathParts);
205
199
  return desired;
206
200
  }
207
- if (latest !== undefined && !isPlainRecord(latest))
201
+ if (latest !== undefined && !isPlainObject(latest))
208
202
  setupConflict(pathParts);
209
- const result = isPlainRecord(latest) ? { ...latest } : {};
203
+ const result = isPlainObject(latest) ? { ...latest } : {};
210
204
  for (const key of new Set([...Object.keys(original), ...Object.keys(desired)])) {
211
205
  if (!Object.hasOwn(desired, key)) {
212
206
  if (Object.hasOwn(result, key) && !sameConfigValue(result[key], original[key]))
@@ -855,7 +849,7 @@ export async function runSetupFromConfig(opts) {
855
849
  catch (e) {
856
850
  throw new UsageError(`Invalid JSON in --config: ${e.message}`, "INVALID_FLAG_VALUE");
857
851
  }
858
- if (!isPlainRecord(parsed)) {
852
+ if (!isPlainObject(parsed)) {
859
853
  throw new ConfigError("Setup config must contain a top-level object.", "INVALID_CONFIG_FILE");
860
854
  }
861
855
  const incoming = parsed;
@@ -48,8 +48,13 @@ export function inspectGitUpstream(repoDir) {
48
48
  * commit SHA peels to itself.
49
49
  */
50
50
  export function verifyClonedRevision(cloneDir, url, expectedRevision) {
51
- if (!expectedRevision)
52
- return;
51
+ if (!expectedRevision) {
52
+ // `resolveGitArtifact` (registry/resolve.ts) throws when it cannot resolve
53
+ // a revision via `git ls-remote`, so a plain git install never reaches
54
+ // this call with `undefined` — treat it as a bug, not a silently-skipped
55
+ // check that would defeat the R-011 post-clone integrity verification.
56
+ throw new UsageError(`No revision was resolved from ${url}; refusing to install without a verifiable checkout.`);
57
+ }
53
58
  const head = runGit(["-C", cloneDir, "rev-parse", "HEAD"]);
54
59
  if (head.status !== 0 || !head.stdout.trim()) {
55
60
  throw new UsageError(`Failed to read cloned HEAD at ${cloneDir}: ${head.stderr.trim() || "rev-parse failed"}`);
@@ -91,13 +91,7 @@ export function extractTarGzSecure(archivePath, destinationDir) {
91
91
  scanExtractedFiles(destinationDir, destinationDir);
92
92
  }
93
93
  function scanExtractedFiles(dir, root) {
94
- let entries;
95
- try {
96
- entries = fs.readdirSync(dir, { withFileTypes: true });
97
- }
98
- catch {
99
- return;
100
- }
94
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
101
95
  for (const entry of entries) {
102
96
  const fullPath = path.join(dir, entry.name);
103
97
  // Reject only entries whose name is exactly the parent-traversal segment
@@ -609,20 +609,3 @@ export function htmlToMarkdownAndLinks(html, pageUrl) {
609
609
  const markdown = root ? markdownFromRoot(root, html, pageUrl) : plainTextFallback(html);
610
610
  return { markdown: finalizeMarkdown(markdown), links };
611
611
  }
612
- /**
613
- * Collect links from the WHOLE document, not the extracted content region.
614
- *
615
- * Deliberate and load-bearing: nav/header/footer links are how a crawl
616
- * discovers pages. Narrowing this to the content region would silently shrink
617
- * every crawl to whatever the first page happens to link inline.
618
- */
619
- export function extractDocumentLinks(html, pageUrl) {
620
- let root;
621
- try {
622
- root = parse(scrubDangerousMarkup(html), { comment: false });
623
- }
624
- catch {
625
- return [];
626
- }
627
- return collectLinksFromRoot(root, pageUrl);
628
- }
@@ -61,13 +61,19 @@ export function openManagedDatabase(spec) {
61
61
  // Initializers may open a transaction (source update does so before index
62
62
  // schema work). Never strand that transaction/handle when later setup
63
63
  // fails; closing rolls it back and releases its writer lock.
64
- if (db.inTransaction) {
65
- try {
64
+ //
65
+ // `db.inTransaction` is INSIDE the try: reading it on an already-closed
66
+ // handle throws "Database has closed", and it used to sit outside, so that
67
+ // throw escaped and replaced `error` — masking the real initializer failure
68
+ // this whole block exists to preserve. Every other step here was already
69
+ // guarded for exactly that reason; this one was not, and a concurrent
70
+ // close (WAL contention between two writers) reached it.
71
+ try {
72
+ if (db.inTransaction)
66
73
  db.exec("ROLLBACK");
67
- }
68
- catch {
69
- // Closing remains the final rollback backstop.
70
- }
74
+ }
75
+ catch {
76
+ // Closing remains the final rollback backstop.
71
77
  }
72
78
  try {
73
79
  db.close();
@@ -99,6 +105,13 @@ export function withManagedDb(open, fn, opts) {
99
105
  return fn(db);
100
106
  }
101
107
  finally {
102
- db.close();
108
+ try {
109
+ db.close();
110
+ }
111
+ catch {
112
+ // A close() throw here would REPLACE fn's in-flight exception — the same
113
+ // masking openManagedDatabase above was hardened against. The handle is
114
+ // being discarded either way; fn's error is the one that matters.
115
+ }
103
116
  }
104
117
  }
@@ -147,84 +147,3 @@ export function purgeOldEvents(db, retentionDays = 90) {
147
147
  const changes = result.changes ?? 0;
148
148
  return typeof changes === "bigint" ? Number(changes) : changes;
149
149
  }
150
- /**
151
- * Import all events from an `events.jsonl` file into the `events` table.
152
- *
153
- * The old byte-offset `id` is NOT preserved — the database assigns new
154
- * monotonic integer ids. Callers that persisted a byte-offset cursor must
155
- * discard it after migration and use the returned `maxId` as the new cursor.
156
- *
157
- * **Idempotency**: each line is pre-checked against the `events` table using
158
- * `(event_type, ts, ref, metadata_json)` as the duplicate key. Lines whose
159
- * exact tuple is already present are skipped and reported as `skipped` in the
160
- * return value. This makes the migration safe to re-run (the v0.7→v0.8
161
- * migration guide recommends re-running the script as a recovery path; without
162
- * this guard, every re-run would double-import the entire event log).
163
- *
164
- * Duplicate detection is per-import-tuple, not a table-wide UNIQUE constraint:
165
- * the events table has no UNIQUE constraint at runtime so that
166
- * `appendEvent` can write multiple events with the same ts (sub-millisecond
167
- * bursts produce identical `(event_type, ts, ref)` triples in practice). The
168
- * SELECT-first check is scoped to the import path only.
169
- *
170
- * The import is wrapped in a single transaction for atomicity.
171
- *
172
- * @param db - Open state.db connection.
173
- * @param jsonlPath - Absolute path to the events.jsonl file to import.
174
- * @returns Number of rows inserted, the max id assigned, and the
175
- * count of rows skipped because an identical event already
176
- * existed in the table.
177
- */
178
- export async function importEventsJsonl(db, jsonlPath) {
179
- const { readFileSync, existsSync } = await import("node:fs");
180
- if (!existsSync(jsonlPath)) {
181
- return { imported: 0, maxId: 0, skipped: 0 };
182
- }
183
- const text = readFileSync(jsonlPath, "utf8");
184
- const lines = text.split("\n").filter((l) => l.trim().length > 0);
185
- let imported = 0;
186
- let maxId = 0;
187
- let skipped = 0;
188
- const insertStmt = db.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
189
- VALUES (?, ?, ?, ?)
190
- RETURNING id`);
191
- // Dedup pre-check: matches by the full tuple including metadata_json so an
192
- // import is idempotent over identical rows but does not collide with two
193
- // genuinely different events that happen to share (event_type, ts, ref).
194
- //
195
- // Uses IS for ref so two NULL refs compare equal (a plain `=` would treat
196
- // NULL = NULL as NULL and the row would be re-inserted on every run).
197
- const existsStmt = db.prepare(`SELECT 1 FROM events
198
- WHERE event_type = ?
199
- AND ts = ?
200
- AND ref IS ?
201
- AND metadata_json = ?
202
- LIMIT 1`);
203
- db.transaction(() => {
204
- for (const line of lines) {
205
- let parsed;
206
- try {
207
- parsed = JSON.parse(line);
208
- }
209
- catch {
210
- continue; // skip malformed lines — same behaviour as readEvents()
211
- }
212
- const eventType = typeof parsed.eventType === "string" ? parsed.eventType : "unknown";
213
- const ts = typeof parsed.ts === "string" ? parsed.ts : new Date().toISOString();
214
- const ref = typeof parsed.ref === "string" ? parsed.ref : null;
215
- const metadata = parsed.metadata !== undefined && typeof parsed.metadata === "object" ? JSON.stringify(parsed.metadata) : "{}";
216
- const duplicate = existsStmt.get(eventType, ts, ref, metadata);
217
- if (duplicate) {
218
- skipped++;
219
- continue;
220
- }
221
- const result = insertStmt.get(eventType, ts, ref, metadata);
222
- if (result) {
223
- imported++;
224
- if (result.id > maxId)
225
- maxId = result.id;
226
- }
227
- }
228
- })();
229
- return { imported, maxId, skipped };
230
- }
@@ -336,12 +336,28 @@ export function getPositiveFeedbackCountsByIds(ids) {
336
336
  }, "positive feedback counts are best-effort");
337
337
  return result;
338
338
  }
339
- function rowsInDirectory(db, dirPath, bundleId) {
340
- const rows = db
341
- .prepare(`SELECT id, item_ref, file_path FROM entries${bundleId ? " WHERE bundle_id = ?" : ""}`)
342
- .all(...(bundleId ? [bundleId] : []));
339
+ /**
340
+ * Rows whose `file_path` sits directly in `dirPath`. A half-open byte range
341
+ * over `idx_entries_file_path` (`[dir + sep, dir + sep + 1)`) turns the lookup
342
+ * into an index seek; the range is exact for "starts with `dir/`" but also
343
+ * admits nested subdirectories (`/a/b/c/x.md` for `/a/b`), so the dirname
344
+ * post-filter stays.
345
+ */
346
+ function selectRowsInDirectory(db, dirPath, columns, bundleId) {
343
347
  const resolvedDir = path.resolve(dirPath);
344
- return rows.filter((row) => path.dirname(path.resolve(row.file_path)) === resolvedDir);
348
+ const prefix = resolvedDir + path.sep;
349
+ const upperBound = resolvedDir + String.fromCharCode(path.sep.charCodeAt(0) + 1);
350
+ const params = [prefix, upperBound];
351
+ let sql = `SELECT ${columns} FROM entries WHERE file_path >= ? AND file_path < ?`;
352
+ if (bundleId) {
353
+ sql += " AND bundle_id = ?";
354
+ params.push(bundleId);
355
+ }
356
+ const rows = db.prepare(sql).all(...params);
357
+ return rows.filter((row) => path.dirname(row.file_path) === resolvedDir);
358
+ }
359
+ function rowsInDirectory(db, dirPath, bundleId) {
360
+ return selectRowsInDirectory(db, dirPath, "id, item_ref, file_path", bundleId);
345
361
  }
346
362
  function deleteEntryRows(db, rows, options = {}) {
347
363
  if (rows.length === 0)
@@ -629,9 +645,7 @@ export function getEntryById(db, id) {
629
645
  };
630
646
  }
631
647
  export function getEntriesByDir(db, dirPath) {
632
- const ids = new Set(rowsInDirectory(db, dirPath).map((row) => row.id));
633
- const rows = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries`).all().filter((row) => ids.has(row.id));
634
- return parseEntryRows(rows, "getEntriesByDir");
648
+ return parseEntryRows(selectRowsInDirectory(db, dirPath, ENTRY_COLUMNS), "getEntriesByDir");
635
649
  }
636
650
  /** Return every directory previously indexed for one canonical bundle. */
637
651
  export function getIndexedDirPathsByBundleId(db, bundleId) {
@@ -640,9 +654,8 @@ export function getIndexedDirPathsByBundleId(db, bundleId) {
640
654
  }
641
655
  /** Return every persisted bundle owner for one physical directory. */
642
656
  export function getIndexedBundleIdsByDir(db, dirPath) {
643
- const ids = new Set(rowsInDirectory(db, dirPath).map((row) => row.id));
644
- const rows = db.prepare("SELECT id, bundle_id FROM entries").all();
645
- return [...new Set(rows.filter((row) => ids.has(row.id)).map((row) => row.bundle_id))];
657
+ const rows = selectRowsInDirectory(db, dirPath, "bundle_id, file_path");
658
+ return [...new Set(rows.map((row) => row.bundle_id))];
646
659
  }
647
660
  /**
648
661
  * Resolve a single `entries.id` by exact `file_path` (the canonical on-disk
@@ -683,23 +696,6 @@ export function getEntryFilePathById(db, id) {
683
696
  const row = db.prepare("SELECT file_path FROM entries WHERE id = ?").get(id);
684
697
  return row?.file_path;
685
698
  }
686
- /**
687
- * Fetch every `(file_path, document_json)` row whose path is under a source
688
- * root. The path containment filter is applied in JS so SQLite LIKE wildcard
689
- * characters in filesystem paths cannot widen ownership.
690
- *
691
- * Lifted verbatim (WS5) from the inline query in commands/graph.ts'
692
- * `buildRefByPath`. The full result set is materialised with `.all()` before
693
- * return so callers can iterate it after the connection closes (WS5
694
- * connection-lifetime rule). JSON parsing stays with the caller, unchanged.
695
- */
696
- export function getEntryRefRowsForStashRoot(db, stashRoot) {
697
- const root = path.resolve(stashRoot);
698
- return db.prepare("SELECT file_path, document_json FROM entries").all().filter((row) => {
699
- const file = path.resolve(row.file_path);
700
- return file === root || file.startsWith(`${root}${path.sep}`);
701
- });
702
- }
703
699
  // ── Indexer-phase helpers (moved from indexer.ts) ────────────────────────────
704
700
  /**
705
701
  * Return distinct zero-result search queries from the `usage_events` table
@@ -91,38 +91,32 @@ function runFtsQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludeTypes)
91
91
  ORDER BY bm25Score, e.id ASC
92
92
  LIMIT ?
93
93
  `;
94
- try {
95
- const rows = db.prepare(sql).all(...params);
96
- // Guard against corrupt JSON — skip the row rather than crashing
97
- const results = [];
98
- for (const row of rows) {
99
- let entry;
100
- try {
101
- entry = JSON.parse(row.documentJson);
102
- }
103
- catch {
104
- warn(`[db] searchFts: skipping entry id=${row.id} — corrupt document_json`);
105
- continue;
106
- }
107
- results.push({
108
- id: row.id,
109
- filePath: row.filePath,
110
- entry,
111
- searchText: row.searchText,
112
- bm25Score: row.bm25Score,
113
- itemRef: row.itemRef,
114
- bundleId: row.bundleId,
115
- conceptId: row.conceptId,
116
- adapterId: row.adapterId,
117
- lexicalMatch,
118
- });
94
+ const rows = db.prepare(sql).all(...params);
95
+ // Guard against corrupt JSON — skip the row rather than crashing
96
+ const results = [];
97
+ for (const row of rows) {
98
+ let entry;
99
+ try {
100
+ entry = JSON.parse(row.documentJson);
119
101
  }
120
- return results;
121
- }
122
- catch (err) {
123
- warn("[db] runFtsQuery failed:", err instanceof Error ? err.message : String(err));
124
- return [];
102
+ catch {
103
+ warn(`[db] searchFts: skipping entry id=${row.id} — corrupt document_json`);
104
+ continue;
105
+ }
106
+ results.push({
107
+ id: row.id,
108
+ filePath: row.filePath,
109
+ entry,
110
+ searchText: row.searchText,
111
+ bm25Score: row.bm25Score,
112
+ itemRef: row.itemRef,
113
+ bundleId: row.bundleId,
114
+ conceptId: row.conceptId,
115
+ adapterId: row.adapterId,
116
+ lexicalMatch,
117
+ });
125
118
  }
119
+ return results;
126
120
  }
127
121
  /**
128
122
  * Explicitly rebuild the complete FTS5 projection from canonical entries.
@@ -23,7 +23,7 @@ export function deleteMeta(db, key) {
23
23
  // ── Per-directory index state ───────────────────────────────────────────────
24
24
  export function getIndexDirState(db, dirPath) {
25
25
  const row = db
26
- .prepare("SELECT dir_path, file_set_hash, file_mtime_max_ms, reason, updated_at FROM index_dir_state WHERE dir_path = ?")
26
+ .prepare("SELECT dir_path, file_set_hash, file_mtime_max_ms, reason, updated_at, row_count FROM index_dir_state WHERE dir_path = ?")
27
27
  .get(dirPath);
28
28
  if (!row)
29
29
  return undefined;
@@ -33,16 +33,18 @@ export function getIndexDirState(db, dirPath) {
33
33
  fileMtimeMaxMs: row.file_mtime_max_ms,
34
34
  reason: row.reason,
35
35
  updatedAt: row.updated_at,
36
+ rowCount: row.row_count ?? undefined,
36
37
  };
37
38
  }
38
39
  export function upsertIndexDirState(db, state) {
39
- db.prepare(`INSERT INTO index_dir_state (dir_path, file_set_hash, file_mtime_max_ms, reason, updated_at)
40
- VALUES (?, ?, ?, ?, ?)
40
+ db.prepare(`INSERT INTO index_dir_state (dir_path, file_set_hash, file_mtime_max_ms, reason, updated_at, row_count)
41
+ VALUES (?, ?, ?, ?, ?, ?)
41
42
  ON CONFLICT(dir_path) DO UPDATE SET
42
43
  file_set_hash = excluded.file_set_hash,
43
44
  file_mtime_max_ms = excluded.file_mtime_max_ms,
44
45
  reason = excluded.reason,
45
- updated_at = excluded.updated_at`).run(state.dirPath, state.fileSetHash, state.fileMtimeMaxMs, state.reason, new Date().toISOString());
46
+ updated_at = excluded.updated_at,
47
+ row_count = excluded.row_count`).run(state.dirPath, state.fileSetHash, state.fileMtimeMaxMs, state.reason, new Date().toISOString(), state.rowCount ?? null);
46
48
  }
47
49
  export function deleteIndexDirState(db, dirPath) {
48
50
  db.prepare("DELETE FROM index_dir_state WHERE dir_path = ?").run(dirPath);
@@ -268,9 +268,11 @@ export function ensureSchema(db, embeddingDim) {
268
268
  file_set_hash TEXT NOT NULL,
269
269
  file_mtime_max_ms REAL NOT NULL,
270
270
  reason TEXT NOT NULL,
271
- updated_at TEXT NOT NULL
271
+ updated_at TEXT NOT NULL,
272
+ row_count INTEGER
272
273
  );
273
274
  `);
275
+ ensureIndexDirStateRowCountColumn(db);
274
276
  // LLM enrichment result cache. Stores a SHA-256 body hash and the JSON
275
277
  // result for each asset so that subsequent `akm index --enrich` runs can
276
278
  // skip the LLM call when the body hasn't changed. The cache is keyed by
@@ -379,3 +381,16 @@ function tableExists(db, name) {
379
381
  const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1").get(name);
380
382
  return row !== undefined && row !== null;
381
383
  }
384
+ /**
385
+ * #900: `row_count` was added after the table's first release, so a database
386
+ * created before it needs an `ALTER TABLE` (`CREATE TABLE IF NOT EXISTS` only
387
+ * shapes a fresh table). Idempotent. Pre-existing rows keep NULL until their
388
+ * directory is next drained; index.db is a regenerable cache, so nothing is
389
+ * backfilled.
390
+ */
391
+ function ensureIndexDirStateRowCountColumn(db) {
392
+ const columns = db.prepare("PRAGMA table_info(index_dir_state)").all();
393
+ if (!columns.some((column) => column.name === "row_count")) {
394
+ db.exec("ALTER TABLE index_dir_state ADD COLUMN row_count INTEGER");
395
+ }
396
+ }
@@ -263,63 +263,6 @@ function getSourceScopedRetrievalCounts(indexDb, stateDb, refs, options) {
263
263
  }
264
264
  return result;
265
265
  }
266
- /**
267
- * Apply a MemRL reward signal to a batch of entries via exponential moving
268
- * average (EMA): next = clamp(current + lr * (reward - current), 0, 1).
269
- *
270
- * Wrapped in a single transaction so all bumps succeed or fail together.
271
- * The indexer (`akm index`) will overwrite these values at next reindex run;
272
- * bumps are intentionally temporary hints between index runs, not permanent
273
- * overrides.
274
- *
275
- * When `scopeKey` is provided, also writes a scoped bump to
276
- * `utility_scores_scoped` so per-project usage signals accumulate alongside
277
- * the global ones. The global table is always updated regardless.
278
- */
279
- export function bumpUtilityScoresBatch(db, entryIds, reward, lr = 0.1, scopeKey) {
280
- if (entryIds.length === 0)
281
- return;
282
- db.transaction(() => {
283
- const { global: scoreMap } = getUtilityScoresByIds(db, entryIds);
284
- const now = new Date().toISOString();
285
- const nowMs = Date.now();
286
- const stmt = db.prepare(`INSERT INTO utility_scores (entry_id, utility, show_count, search_count, select_rate, last_used_at, updated_at)
287
- VALUES (?, ?, 0, 0, 0, ?, ?)
288
- ON CONFLICT(entry_id) DO UPDATE SET
289
- utility = excluded.utility,
290
- updated_at = excluded.updated_at`);
291
- // Prepare scoped upsert once outside the loop when scopeKey is present.
292
- const scopedStmt = scopeKey
293
- ? db.prepare(`INSERT INTO utility_scores_scoped (entry_id, scope_key, utility, last_used_at)
294
- VALUES (?, ?, ?, ?)
295
- ON CONFLICT(entry_id, scope_key) DO UPDATE SET
296
- utility = excluded.utility,
297
- last_used_at = excluded.last_used_at`)
298
- : null;
299
- for (const entryId of entryIds) {
300
- const existing = scoreMap.get(entryId);
301
- const current = existing?.utility ?? 0;
302
- const next = Math.max(0, Math.min(1, current + lr * (reward - current)));
303
- stmt.run(entryId, next, now, now);
304
- if (scopedStmt && scopeKey) {
305
- // Retrieve the current scoped utility so we can apply the same EMA.
306
- const scopedCurrent = getScopedUtility(db, entryId, scopeKey);
307
- const scopedNext = Math.max(0, Math.min(1, scopedCurrent + lr * (reward - scopedCurrent)));
308
- scopedStmt.run(entryId, scopeKey, scopedNext, nowMs);
309
- }
310
- }
311
- })();
312
- }
313
- /**
314
- * Return the current utility value for a single (entry_id, scope_key) pair.
315
- * Returns 0 when no row exists yet.
316
- */
317
- function getScopedUtility(db, entryId, scopeKey) {
318
- const row = db
319
- .prepare("SELECT utility FROM utility_scores_scoped WHERE entry_id = ? AND scope_key = ?")
320
- .get(entryId, scopeKey);
321
- return row?.utility ?? 0;
322
- }
323
266
  /**
324
267
  * Apply accumulated feedback counts to the utility score of an entry, persisting
325
268
  * the result. The bounded-step EMA policy itself (MemRL, F-5 / #386,
@@ -229,9 +229,14 @@ export function searchVec(db, queryEmbedding, k) {
229
229
  .all(buf, k);
230
230
  }
231
231
  catch (err) {
232
- // Log the failure so it's visible in diagnostics
233
- warn("[db] searchVec (sqlite-vec path) failed:", err instanceof Error ? err.message : String(err));
234
- return [];
232
+ // A dimension mismatch (e.g. the embedding provider/model changed since
233
+ // the fast-path table was built) is a real, expected reason this query
234
+ // specifically cannot use the vec table — the complete BLOB table below
235
+ // is unaffected, so fall back to it rather than either silently
236
+ // returning [] (masking a genuinely corrupt index) or failing the whole
237
+ // search over one degraded index.
238
+ warn("[db] searchVec (sqlite-vec path) failed, falling back to JS-cosine scan:", err instanceof Error ? err.message : String(err));
239
+ return searchBlobVec(db, queryEmbedding, k);
235
240
  }
236
241
  }
237
242
  // Fallback: JS-based cosine similarity over BLOB table
@@ -281,32 +286,25 @@ function bufferToFloat32(buf, expectedDim) {
281
286
  return Array.from(f32);
282
287
  }
283
288
  function searchBlobVec(db, queryEmbedding, k) {
284
- try {
285
- const rows = db.prepare("SELECT id, embedding FROM embeddings").all();
286
- if (rows.length === 0)
287
- return [];
288
- const expectedDim = queryEmbedding.length;
289
- const scored = [];
290
- for (const row of rows) {
291
- const embedding = bufferToFloat32(row.embedding, expectedDim);
292
- if (embedding === null)
293
- continue;
294
- const similarity = cosineSimilarity(queryEmbedding, embedding);
295
- scored.push({ id: row.id, similarity });
296
- }
297
- scored.sort((a, b) => b.similarity - a.similarity);
298
- // Convert cosine similarity to L2 distance for compatibility with sqlite-vec interface
299
- // For normalized vectors: L2² = 2(1 - cos_sim)
300
- return scored.slice(0, k).map(({ id, similarity }) => ({
301
- id,
302
- distance: Math.sqrt(2 * Math.max(0, 1 - similarity)),
303
- }));
304
- }
305
- catch (err) {
306
- // MD-5: Log the failure so it's visible in diagnostics
307
- warn("[db] searchBlobVec (JS fallback) failed:", err instanceof Error ? err.message : String(err));
289
+ const rows = db.prepare("SELECT id, embedding FROM embeddings").all();
290
+ if (rows.length === 0)
308
291
  return [];
292
+ const expectedDim = queryEmbedding.length;
293
+ const scored = [];
294
+ for (const row of rows) {
295
+ const embedding = bufferToFloat32(row.embedding, expectedDim);
296
+ if (embedding === null)
297
+ continue;
298
+ const similarity = cosineSimilarity(queryEmbedding, embedding);
299
+ scored.push({ id: row.id, similarity });
309
300
  }
301
+ scored.sort((a, b) => b.similarity - a.similarity);
302
+ // Convert cosine similarity to L2 distance for compatibility with sqlite-vec interface
303
+ // For normalized vectors: L2² = 2(1 - cos_sim)
304
+ return scored.slice(0, k).map(({ id, similarity }) => ({
305
+ id,
306
+ distance: Math.sqrt(2 * Math.max(0, 1 - similarity)),
307
+ }));
310
308
  }
311
309
  /**
312
310
  * Return all entries that do not yet have an embedding row.
@@ -12,6 +12,7 @@
12
12
  import path from "node:path";
13
13
  import { stashDirFor } from "../../core/asset/asset-placement.js";
14
14
  import { bundleRefToString, isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
15
+ import { warnOnce } from "../../core/warn.js";
15
16
  /** Serialize `Proposal.changes` for `metadata_json` (see {@link StoredFileChange}). */
16
17
  function changesToStored(changes) {
17
18
  return changes.map((c, i) => ({
@@ -361,8 +362,10 @@ export function listStateProposals(db, options = {}) {
361
362
  proposals.push(proposalRowToProposal(row));
362
363
  }
363
364
  catch (error) {
365
+ // Once per row per process (#898): health alone reads this table several
366
+ // times per invocation.
364
367
  const message = error instanceof Error ? error.message : String(error);
365
- console.warn(`[akm] Skipping unparseable proposal row (id=${row.id}, ref=${row.ref}): ${message}`);
368
+ warnOnce(`unparseable-proposal-row:${row.id}`, `[akm] Skipping unparseable proposal row (id=${row.id}, ref=${row.ref}): ${message}`);
366
369
  }
367
370
  }
368
371
  return proposals;
@@ -1,12 +1,18 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Repository for the state.db `task_history` table (per-task run ledger,
6
+ * replaces the old per-task JSONL files). Extracted verbatim from
7
+ * core/state-db.ts — queries unchanged, only relocated behind the repository
8
+ * boundary. Re-exported by core/state-db.ts so existing importers resolve.
9
+ *
10
+ * @module task-history-repository
11
+ */
12
+ import { isRecord } from "../../core/common.js";
4
13
  function metadataError(message) {
5
14
  throw new Error(`invalid task_history metadata_json: ${message}`);
6
15
  }
7
- function isRecord(value) {
8
- return typeof value === "object" && value !== null && !Array.isArray(value);
9
- }
10
16
  function validateDetail(value) {
11
17
  if (value === undefined || value === null)
12
18
  return;