akm-cli 0.9.0-beta.9 → 0.9.0-rc.0

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 (325) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/help/help-improve.md +9 -6
  6. package/dist/assets/hints/cli-hints-full.md +6 -5
  7. package/dist/assets/profiles/default.json +9 -4
  8. package/dist/assets/profiles/frequent.json +1 -1
  9. package/dist/assets/profiles/memory-focus.json +1 -1
  10. package/dist/assets/profiles/proactive-maintenance.json +25 -0
  11. package/dist/assets/profiles/quick.json +1 -1
  12. package/dist/assets/profiles/recombine-only.json +21 -0
  13. package/dist/assets/profiles/reflect-distill.json +30 -0
  14. package/dist/assets/profiles/synthesize.json +15 -0
  15. package/dist/assets/profiles/thorough.json +1 -1
  16. package/dist/assets/prompts/consolidate-system.md +23 -0
  17. package/dist/assets/prompts/contradiction-judge.md +33 -0
  18. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  19. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  20. package/dist/assets/prompts/extract-session.md +11 -3
  21. package/dist/assets/prompts/graph-extract-system.md +1 -0
  22. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  23. package/dist/assets/prompts/memory-infer-system.md +1 -0
  24. package/dist/assets/prompts/memory-infer-user.md +5 -0
  25. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  26. package/dist/assets/prompts/procedural-system.md +44 -0
  27. package/dist/assets/prompts/recombine-system.md +40 -0
  28. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  29. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  39. package/dist/assets/templates/html/health.html +281 -111
  40. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  41. package/dist/assets/wiki/schema-template.md +4 -4
  42. package/dist/cli/clack.js +56 -0
  43. package/dist/cli/config-migrate.js +7 -1
  44. package/dist/cli/confirm.js +1 -1
  45. package/dist/cli/parse-args.js +46 -1
  46. package/dist/cli/shared.js +28 -0
  47. package/dist/cli.js +25 -21
  48. package/dist/commands/agent/agent-dispatch.js +3 -2
  49. package/dist/commands/agent/agent-support.js +0 -7
  50. package/dist/commands/agent/contribute-cli.js +26 -7
  51. package/dist/commands/config-cli.js +26 -13
  52. package/dist/commands/env/child-env.js +47 -0
  53. package/dist/commands/env/env-cli.js +220 -227
  54. package/dist/commands/env/env.js +14 -67
  55. package/dist/commands/env/secret-cli.js +140 -138
  56. package/dist/commands/feedback-cli.js +156 -155
  57. package/dist/commands/graph/graph-cli.js +5 -13
  58. package/dist/commands/graph/graph.js +3 -3
  59. package/dist/commands/health/advisories.js +151 -0
  60. package/dist/commands/health/checks.js +103 -16
  61. package/dist/commands/health/html-report.js +447 -81
  62. package/dist/commands/health/improve-metrics.js +771 -0
  63. package/dist/commands/health/llm-usage.js +65 -0
  64. package/dist/commands/health/md-report.js +103 -0
  65. package/dist/commands/health/metrics.js +278 -0
  66. package/dist/commands/health/stash-exposure.js +46 -0
  67. package/dist/commands/health/surfaces.js +216 -0
  68. package/dist/commands/health/task-runs.js +135 -0
  69. package/dist/commands/health/types.js +26 -0
  70. package/dist/commands/health/windows.js +195 -0
  71. package/dist/commands/health.js +91 -1091
  72. package/dist/commands/improve/anti-collapse.js +170 -0
  73. package/dist/commands/improve/calibration.js +161 -0
  74. package/dist/commands/improve/collapse-detector.js +421 -0
  75. package/dist/commands/improve/consolidate/chunking.js +141 -0
  76. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  77. package/dist/commands/improve/consolidate/merge.js +145 -0
  78. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  79. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  80. package/dist/commands/improve/consolidate.js +1295 -1277
  81. package/dist/commands/improve/dedup.js +482 -0
  82. package/dist/commands/improve/distill/content-repair.js +202 -0
  83. package/dist/commands/improve/distill/promote-memory.js +229 -0
  84. package/dist/commands/improve/distill/quality-gate.js +236 -0
  85. package/dist/commands/improve/distill-guards.js +127 -0
  86. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  87. package/dist/commands/improve/distill.js +228 -605
  88. package/dist/commands/improve/eligibility.js +434 -0
  89. package/dist/commands/improve/encoding-salience.js +205 -0
  90. package/dist/commands/improve/extract-cli.js +179 -59
  91. package/dist/commands/improve/extract-prompt.js +54 -3
  92. package/dist/commands/improve/extract-watch.js +140 -0
  93. package/dist/commands/improve/extract.js +409 -43
  94. package/dist/commands/improve/feedback-valence.js +54 -0
  95. package/dist/commands/improve/hot-probation.js +45 -0
  96. package/dist/commands/improve/improve-auto-accept.js +157 -10
  97. package/dist/commands/improve/improve-cli.js +115 -73
  98. package/dist/commands/improve/improve-profiles.js +28 -8
  99. package/dist/commands/improve/improve-result-file.js +15 -25
  100. package/dist/commands/improve/improve-session.js +58 -0
  101. package/dist/commands/improve/improve.js +485 -2764
  102. package/dist/commands/improve/locks.js +154 -0
  103. package/dist/commands/improve/loop-stages.js +1100 -0
  104. package/dist/commands/improve/memory/memory-belief.js +14 -15
  105. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  106. package/dist/commands/improve/memory/memory-improve.js +27 -27
  107. package/dist/commands/improve/outcome-loop.js +270 -0
  108. package/dist/commands/improve/preparation.js +2002 -0
  109. package/dist/commands/improve/proactive-maintenance.js +37 -35
  110. package/dist/commands/improve/procedural.js +398 -0
  111. package/dist/commands/improve/recombine.js +818 -0
  112. package/dist/commands/improve/reflect-noise.js +0 -0
  113. package/dist/commands/improve/reflect.js +206 -45
  114. package/dist/commands/improve/salience.js +455 -0
  115. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  116. package/dist/commands/improve/shared.js +51 -0
  117. package/dist/commands/improve/triage.js +93 -0
  118. package/dist/commands/lint/agent-linter.js +19 -24
  119. package/dist/commands/lint/base-linter.js +173 -60
  120. package/dist/commands/lint/command-linter.js +19 -24
  121. package/dist/commands/lint/env-key-rules.js +38 -1
  122. package/dist/commands/lint/fact-linter.js +39 -0
  123. package/dist/commands/lint/index.js +31 -13
  124. package/dist/commands/lint/memory-linter.js +1 -1
  125. package/dist/commands/lint/registry.js +7 -2
  126. package/dist/commands/lint/task-linter.js +3 -3
  127. package/dist/commands/lint/workflow-linter.js +26 -1
  128. package/dist/commands/observability-cli.js +4 -4
  129. package/dist/commands/proposal/drain-policies.js +13 -4
  130. package/dist/commands/proposal/drain.js +45 -51
  131. package/dist/commands/proposal/legacy-import.js +115 -0
  132. package/dist/commands/proposal/proposal-cli.js +24 -34
  133. package/dist/commands/proposal/proposal.js +2 -1
  134. package/dist/commands/proposal/propose.js +8 -3
  135. package/dist/commands/proposal/repository.js +829 -0
  136. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  137. package/dist/commands/proposal/validators/proposals.js +93 -895
  138. package/dist/commands/read/curate.js +410 -111
  139. package/dist/commands/read/knowledge.js +10 -3
  140. package/dist/commands/read/remember-cli.js +133 -138
  141. package/dist/commands/read/search-cli.js +15 -8
  142. package/dist/commands/read/search.js +22 -11
  143. package/dist/commands/read/show.js +106 -14
  144. package/dist/commands/registry-cli.js +76 -87
  145. package/dist/commands/remember.js +11 -12
  146. package/dist/commands/sources/add-cli.js +91 -95
  147. package/dist/commands/sources/history.js +1 -1
  148. package/dist/commands/sources/init.js +66 -18
  149. package/dist/commands/sources/installed-stashes.js +11 -3
  150. package/dist/commands/sources/schema-repair.js +44 -46
  151. package/dist/commands/sources/self-update.js +2 -2
  152. package/dist/commands/sources/source-add.js +7 -3
  153. package/dist/commands/sources/sources-cli.js +3 -3
  154. package/dist/commands/sources/stash-cli.js +19 -39
  155. package/dist/commands/sources/stash-skeleton.js +57 -8
  156. package/dist/commands/tasks/default-tasks.js +15 -2
  157. package/dist/commands/tasks/tasks-cli.js +20 -29
  158. package/dist/commands/tasks/tasks.js +39 -11
  159. package/dist/commands/wiki-cli.js +23 -38
  160. package/dist/commands/workflow-cli.js +15 -1
  161. package/dist/core/asset/asset-registry.js +3 -1
  162. package/dist/core/asset/asset-spec.js +21 -4
  163. package/dist/core/asset/frontmatter.js +188 -167
  164. package/dist/core/asset/markdown.js +8 -0
  165. package/dist/core/authoring-rules.js +92 -0
  166. package/dist/core/common.js +4 -23
  167. package/dist/core/concurrent.js +10 -1
  168. package/dist/core/config/config-io.js +10 -1
  169. package/dist/core/config/config-migration.js +18 -40
  170. package/dist/core/config/config-schema.js +382 -62
  171. package/dist/core/config/config-types.js +3 -3
  172. package/dist/core/config/config.js +67 -22
  173. package/dist/core/deep-merge.js +38 -0
  174. package/dist/core/errors.js +1 -0
  175. package/dist/core/eval/rank-metrics.js +113 -0
  176. package/dist/core/events.js +4 -7
  177. package/dist/core/improve-types.js +47 -8
  178. package/dist/core/logs-db.js +14 -75
  179. package/dist/core/parse.js +36 -16
  180. package/dist/core/paths.js +18 -18
  181. package/dist/core/standards/resolve-standards-context.js +87 -0
  182. package/dist/core/standards/resolve-stash-standards.js +99 -0
  183. package/dist/core/standards/resolve-type-conventions.js +66 -0
  184. package/dist/core/state/migrations.js +770 -0
  185. package/dist/core/state-db.js +132 -1126
  186. package/dist/core/structured.js +69 -0
  187. package/dist/core/time.js +53 -0
  188. package/dist/core/warn.js +21 -0
  189. package/dist/core/write-source.js +37 -0
  190. package/dist/indexer/db/db.js +259 -769
  191. package/dist/indexer/db/entry-mapper.js +41 -0
  192. package/dist/indexer/db/graph-db.js +129 -86
  193. package/dist/indexer/db/llm-cache.js +2 -2
  194. package/dist/indexer/db/schema.js +516 -0
  195. package/dist/indexer/ensure-index.js +36 -92
  196. package/dist/indexer/feedback/utility-policy.js +75 -0
  197. package/dist/indexer/graph/graph-boost.js +51 -41
  198. package/dist/indexer/graph/graph-extraction.js +207 -4
  199. package/dist/indexer/index-writer-lock.js +18 -11
  200. package/dist/indexer/index-written-assets.js +105 -0
  201. package/dist/indexer/indexer.js +182 -204
  202. package/dist/indexer/passes/dir-staleness.js +114 -0
  203. package/dist/indexer/passes/memory-inference.js +13 -5
  204. package/dist/indexer/passes/metadata.js +20 -0
  205. package/dist/indexer/read-preflight.js +23 -0
  206. package/dist/indexer/search/db-search.js +89 -13
  207. package/dist/indexer/search/fts-query.js +51 -0
  208. package/dist/indexer/search/ranking-contributors.js +95 -9
  209. package/dist/indexer/search/ranking.js +79 -3
  210. package/dist/indexer/search/search-fields.js +6 -0
  211. package/dist/indexer/search/search-source.js +32 -21
  212. package/dist/indexer/search/semantic-status.js +4 -0
  213. package/dist/indexer/walk/matchers.js +9 -0
  214. package/dist/indexer/walk/walker.js +21 -13
  215. package/dist/integrations/agent/builders.js +39 -13
  216. package/dist/integrations/agent/config.js +20 -59
  217. package/dist/integrations/agent/detect.js +9 -0
  218. package/dist/integrations/agent/index.js +3 -19
  219. package/dist/integrations/agent/model-aliases.js +7 -2
  220. package/dist/integrations/agent/profiles.js +7 -1
  221. package/dist/integrations/agent/prompts.js +75 -9
  222. package/dist/integrations/agent/runner-dispatch.js +59 -0
  223. package/dist/integrations/agent/runner.js +13 -9
  224. package/dist/integrations/agent/spawn.js +69 -67
  225. package/dist/integrations/harnesses/claude/agent-builder.js +1 -1
  226. package/dist/integrations/harnesses/claude/index.js +2 -0
  227. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  228. package/dist/integrations/harnesses/index.js +2 -3
  229. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  230. package/dist/integrations/harnesses/opencode/index.js +2 -0
  231. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  232. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  233. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +98 -17
  234. package/dist/integrations/harnesses/types.js +1 -0
  235. package/dist/integrations/session-logs/index.js +16 -0
  236. package/dist/llm/call-ai.js +2 -2
  237. package/dist/llm/client.js +34 -11
  238. package/dist/llm/embedder.js +67 -4
  239. package/dist/llm/embedders/cache.js +3 -1
  240. package/dist/llm/embedders/deterministic.js +66 -0
  241. package/dist/llm/embedders/local.js +73 -3
  242. package/dist/llm/feature-gate.js +16 -15
  243. package/dist/llm/graph-extract.js +67 -44
  244. package/dist/llm/memory-infer-impl.js +138 -0
  245. package/dist/llm/memory-infer.js +1 -127
  246. package/dist/llm/metadata-enhance.js +44 -31
  247. package/dist/llm/structured-call.js +49 -0
  248. package/dist/migrate-storage-node.mjs +8 -0
  249. package/dist/output/context.js +5 -5
  250. package/dist/output/renderers.js +85 -14
  251. package/dist/output/shapes/curate.js +14 -2
  252. package/dist/output/shapes/helpers.js +0 -3
  253. package/dist/output/shapes/passthrough.js +2 -1
  254. package/dist/output/text/helpers.js +29 -1
  255. package/dist/output/text/workflow.js +1 -0
  256. package/dist/registry/providers/skills-sh.js +21 -147
  257. package/dist/registry/providers/static-index.js +15 -157
  258. package/dist/registry/resolve.js +27 -9
  259. package/dist/runtime.js +25 -1
  260. package/dist/scripts/migrate-storage.js +2661 -2369
  261. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +883 -596
  262. package/dist/setup/detect.js +9 -0
  263. package/dist/setup/legacy-config.js +106 -0
  264. package/dist/setup/prompt.js +57 -0
  265. package/dist/setup/providers.js +14 -0
  266. package/dist/setup/registry-stash-loader.js +12 -0
  267. package/dist/setup/semantic-assets.js +124 -0
  268. package/dist/setup/setup.js +52 -1614
  269. package/dist/setup/steps/connection.js +734 -0
  270. package/dist/setup/steps/output.js +31 -0
  271. package/dist/setup/steps/platforms.js +124 -0
  272. package/dist/setup/steps/semantic.js +27 -0
  273. package/dist/setup/steps/sources.js +222 -0
  274. package/dist/setup/steps/stashdir.js +42 -0
  275. package/dist/setup/steps/tasks.js +152 -0
  276. package/dist/sources/include.js +6 -2
  277. package/dist/sources/providers/filesystem.js +0 -1
  278. package/dist/sources/providers/git-install.js +210 -0
  279. package/dist/sources/providers/git-provider.js +234 -0
  280. package/dist/sources/providers/git-stash.js +248 -0
  281. package/dist/sources/providers/git.js +10 -661
  282. package/dist/sources/providers/npm.js +2 -6
  283. package/dist/sources/providers/provider-utils.js +13 -7
  284. package/dist/sources/providers/sync-from-ref.js +9 -1
  285. package/dist/sources/providers/website.js +9 -5
  286. package/dist/sources/website-ingest.js +187 -29
  287. package/dist/sources/wiki-fetchers/registry.js +53 -0
  288. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  289. package/dist/storage/database.js +45 -10
  290. package/dist/storage/managed-db.js +82 -0
  291. package/dist/storage/repositories/canaries-repository.js +107 -0
  292. package/dist/storage/repositories/consolidation-repository.js +38 -0
  293. package/dist/storage/repositories/embeddings-repository.js +72 -0
  294. package/dist/storage/repositories/events-repository.js +187 -0
  295. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  296. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  297. package/dist/storage/repositories/index-db.js +14 -8
  298. package/dist/storage/repositories/proposals-repository.js +220 -0
  299. package/dist/storage/repositories/recombine-repository.js +213 -0
  300. package/dist/storage/repositories/registry-cache.js +93 -0
  301. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  302. package/dist/storage/repositories/task-history-repository.js +93 -0
  303. package/dist/storage/sqlite-pragmas.js +146 -0
  304. package/dist/tasks/backends/cron.js +1 -1
  305. package/dist/tasks/backends/index.js +9 -0
  306. package/dist/tasks/backends/launchd.js +1 -1
  307. package/dist/tasks/backends/schtasks.js +1 -1
  308. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  309. package/dist/tasks/runner.js +15 -13
  310. package/dist/text-import-hook.mjs +0 -0
  311. package/dist/wiki/wiki.js +52 -11
  312. package/dist/workflows/cli.js +1 -0
  313. package/dist/workflows/db.js +3 -4
  314. package/dist/workflows/runtime/runs.js +43 -118
  315. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  316. package/dist/workflows/validate-summary.js +2 -7
  317. package/docs/README.md +69 -18
  318. package/docs/data-and-telemetry.md +5 -4
  319. package/docs/migration/release-notes/0.7.0.md +1 -1
  320. package/docs/migration/release-notes/0.9.0.md +39 -0
  321. package/package.json +10 -10
  322. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  323. package/dist/commands/db-cli.js +0 -23
  324. package/dist/indexer/db/db-backup.js +0 -376
  325. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -1,47 +1,39 @@
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
- import fs from "node:fs";
5
4
  import { createRequire } from "node:module";
6
5
  import path from "node:path";
7
6
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
8
7
  import { bestEffort } from "../../core/best-effort.js";
9
8
  import { getDbPath } from "../../core/paths.js";
10
- import { REGISTRY_INDEX_CACHE_DDL } from "../../core/state-db.js";
11
9
  import { warn } from "../../core/warn.js";
12
10
  import { cosineSimilarity } from "../../llm/embedders/types.js";
13
11
  import { sha256Hex } from "../../runtime.js";
14
- import { openDatabase as openSqlite } from "../../storage/database.js";
12
+ import { openManagedDatabase } from "../../storage/managed-db.js";
13
+ import { computeNextUtility, HIGH_UTILITY_THRESHOLD, UTILITY_REVIEW_THRESHOLD, } from "../feedback/utility-policy.js";
14
+ import { buildPrefixQuery, sanitizeFtsQuery } from "../search/fts-query.js";
15
15
  import { buildSearchFields } from "../search/search-fields.js";
16
- import { ensureUsageEventsSchema } from "../usage/usage-events.js";
17
- import { backupDataDir, EMBEDDING_DIM_CHANGE_REASON } from "./db-backup.js";
18
- // ── Constants ───────────────────────────────────────────────────────────────
19
- export const DB_VERSION = 17;
20
- export const EMBEDDING_DIM = 384;
21
- export const GRAPH_SCHEMA_VERSION = 3;
16
+ import { ENTRY_COLUMNS, rowToIndexedEntry } from "./entry-mapper.js";
17
+ import { ensureSchema } from "./schema.js";
18
+ export { HIGH_UTILITY_THRESHOLD, sanitizeFtsQuery, UTILITY_REVIEW_THRESHOLD };
22
19
  // ── Database lifecycle ──────────────────────────────────────────────────────
23
- export function openDatabase(dbPath, options) {
24
- const resolvedPath = dbPath ?? getDbPath();
25
- const dir = path.dirname(resolvedPath);
26
- if (!fs.existsSync(dir)) {
27
- fs.mkdirSync(dir, { recursive: true });
28
- }
29
- const db = openSqlite(resolvedPath);
30
- db.exec("PRAGMA journal_mode = WAL");
31
- db.exec("PRAGMA busy_timeout = 30000");
32
- db.exec("PRAGMA foreign_keys = ON");
33
- // Try to load sqlite-vec extension
34
- loadVecExtension(db);
35
- // Dim resolution: explicit option wins; otherwise consult the on-disk
36
- // config so unparameterised opens (registry providers, graph helpers,
37
- // ad-hoc CLI subcommands) honour the operator-declared dimension. Only if
38
- // both are absent do we fall through to the no-clobber path, which keeps
39
- // ensureSchema from touching `index_meta.embeddingDim` at all.
40
- const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
41
- ensureSchema(db, resolvedDim, { dataDir: dir });
42
- // Warn once at init if using JS fallback with many entries
43
- warnIfVecMissing(db, { once: true });
44
- return db;
20
+ export function openIndexDatabase(dbPath, options) {
21
+ return openManagedDatabase({
22
+ path: dbPath ?? getDbPath(),
23
+ init: (db) => {
24
+ // Try to load sqlite-vec extension
25
+ loadVecExtension(db);
26
+ // Dim resolution: explicit option wins; otherwise consult the on-disk
27
+ // config so unparameterised opens (registry providers, graph helpers,
28
+ // ad-hoc CLI subcommands) honour the operator-declared dimension. Only if
29
+ // both are absent do we fall through to the no-clobber path, which keeps
30
+ // ensureSchema from touching `index_meta.embeddingDim` at all.
31
+ const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
32
+ ensureSchema(db, resolvedDim);
33
+ // Warn once at init if using JS fallback with many entries
34
+ warnIfVecMissing(db, { once: true });
35
+ },
36
+ });
45
37
  }
46
38
  /**
47
39
  * Read the operator-configured embedding dimension from the on-disk config.
@@ -66,15 +58,10 @@ function resolveConfiguredEmbeddingDim() {
66
58
  }
67
59
  }
68
60
  export function openExistingDatabase(dbPath) {
69
- const resolvedPath = dbPath ?? getDbPath();
70
- const db = openSqlite(resolvedPath);
71
- db.exec("PRAGMA journal_mode = WAL");
72
- db.exec("PRAGMA busy_timeout = 30000");
73
- db.exec("PRAGMA foreign_keys = ON");
74
61
  // Existing-DB callers must not mutate schema or embedding metadata on open,
75
- // but some paths still need write access to usage_events and other tables.
76
- loadVecExtension(db);
77
- return db;
62
+ // but some paths still need write access to usage_events and other tables
63
+ // so init only loads the vec extension, it does not run ensureSchema.
64
+ return openManagedDatabase({ path: dbPath ?? getDbPath(), init: loadVecExtension });
78
65
  }
79
66
  export function closeDatabase(db) {
80
67
  db.close();
@@ -123,480 +110,25 @@ export function warnIfVecMissing(db, { once } = { once: false }) {
123
110
  }
124
111
  }, "embeddings table may not exist yet during init");
125
112
  }
126
- function ensureSchema(db, embeddingDim, options) {
127
- // Create meta table first so we can check version
128
- db.exec(`
129
- CREATE TABLE IF NOT EXISTS index_meta (
130
- key TEXT PRIMARY KEY,
131
- value TEXT NOT NULL
132
- );
133
- `);
134
- // MVP DB-backup hook (0.8.x): when the stored DB version differs from the
135
- // running binary's DB_VERSION, snapshot the data directory BEFORE
136
- // `handleVersionUpgrade()` drops tables. This is best-effort —
137
- // `backupDataDir` returns null on opt-out, missing data dir, low free
138
- // space, or copy errors, and we proceed with the upgrade in all cases.
139
- // The proper migration framework lands in 0.9.0; until then this lets
140
- // operators recover with `scripts/migrations/restore-data-dir.sh`.
141
- if (options?.dataDir) {
142
- const storedVersionRaw = getMeta(db, "version");
143
- const storedVersion = storedVersionRaw !== undefined && storedVersionRaw !== "" ? Number.parseInt(storedVersionRaw, 10) : null;
144
- const willUpgrade = storedVersionRaw !== undefined && storedVersionRaw !== "" && storedVersionRaw !== String(DB_VERSION);
145
- if (willUpgrade) {
146
- try {
147
- // Pass env explicitly so tests can override AKM_DB_BACKUP / AKM_DB_BACKUP_RETAIN
148
- // without mutating process.env. Production callers default to process.env.
149
- const result = backupDataDir({
150
- dataDir: options.dataDir,
151
- sourceVersion: storedVersion !== null && !Number.isNaN(storedVersion) ? storedVersion : null,
152
- targetVersion: DB_VERSION,
153
- env: process.env,
154
- });
155
- if (result) {
156
- warn("[akm] data directory backed up to %s before v%s→v%d upgrade", result.path, storedVersionRaw, DB_VERSION);
157
- }
158
- }
159
- catch (err) {
160
- // Defensive — backupDataDir already swallows most errors, but if it
161
- // throws for an unexpected reason we must still proceed with the
162
- // upgrade so the user isn't locked out of their binary.
163
- warn("[akm] pre-upgrade data dir backup raised an unexpected error — %s; upgrade will proceed without a snapshot", err instanceof Error ? err.message : String(err));
164
- }
165
- }
166
- }
167
- // Check stored version — if it differs from DB_VERSION, drop and recreate all tables.
168
- // Usage events are preserved across version upgrades so that utility score
169
- // history is not silently lost. The backup is captured here and threaded
170
- // explicitly to `restoreUsageEventsBackup` below — the previous version
171
- // attached `__usageBackup` to the Database instance via a typeless property
172
- // injection, which was a source of fragile coupling.
173
- const usageBackup = handleVersionUpgrade(db);
174
- db.exec(`
175
- CREATE TABLE IF NOT EXISTS entries (
176
- id INTEGER PRIMARY KEY AUTOINCREMENT,
177
- entry_key TEXT NOT NULL UNIQUE,
178
- dir_path TEXT NOT NULL,
179
- file_path TEXT NOT NULL,
180
- stash_dir TEXT NOT NULL,
181
- entry_json TEXT NOT NULL,
182
- search_text TEXT NOT NULL,
183
- entry_type TEXT NOT NULL,
184
- derived_from TEXT
185
- );
186
-
187
- CREATE INDEX IF NOT EXISTS idx_entries_dir ON entries(dir_path);
188
- CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(entry_type);
189
- CREATE INDEX IF NOT EXISTS idx_entries_file_path ON entries(file_path);
190
- `);
191
- // Phase 5A / DB v17: backfill `derived_from` column + index on databases
192
- // that were created at v17 fresh OR carry a partial v17 schema (a DB whose
193
- // `index_meta.version` was bumped to 17 but whose `entries` table still
194
- // lacks the column — this happens when a previous v17 binary opened a
195
- // pre-v17 DB without taking the upgrade path because no version mismatch
196
- // was seen at boot). The PRAGMA-then-ALTER guard runs unconditionally so
197
- // both fresh and partial schemas converge. The CREATE INDEX for
198
- // `derived_from` MUST run after this helper so we never reference a
199
- // column that has not yet been added on partial schemas.
200
- ensureDerivedFromColumn(db);
201
- // Validated WorkflowDocument JSON, one row per indexed workflow entry.
202
- // Pure index data — fully rebuilt on each `akm index`. ON DELETE CASCADE
203
- // means clearing entries (full rebuild or per-dir delete) drops these too.
204
- db.exec(`
205
- CREATE TABLE IF NOT EXISTS workflow_documents (
206
- entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
207
- schema_version INTEGER NOT NULL,
208
- document_json TEXT NOT NULL,
209
- source_path TEXT NOT NULL,
210
- source_hash TEXT NOT NULL,
211
- updated_at TEXT NOT NULL
212
- );
213
-
214
- CREATE INDEX IF NOT EXISTS idx_workflow_documents_source_path
215
- ON workflow_documents(source_path);
216
- `);
217
- // Set version immediately after table creation so a crash before the end of
218
- // ensureSchema() does not leave the database in a versionless state on next open.
219
- const versionAfterCreate = getMeta(db, "version");
220
- if (!versionAfterCreate) {
221
- setMeta(db, "version", String(DB_VERSION));
222
- }
223
- // BLOB-based embedding storage (always available, no sqlite-vec needed)
224
- db.exec(`
225
- CREATE TABLE IF NOT EXISTS embeddings (
226
- id INTEGER PRIMARY KEY,
227
- embedding BLOB NOT NULL,
228
- FOREIGN KEY (id) REFERENCES entries(id)
229
- );
230
- `);
231
- // FTS5 table — multi-column with per-field weighting via bm25()
232
- const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_fts'").get();
233
- if (!ftsExists) {
234
- db.exec(`
235
- CREATE VIRTUAL TABLE entries_fts USING fts5(
236
- entry_id UNINDEXED,
237
- name,
238
- description,
239
- tags,
240
- hints,
241
- content,
242
- tokenize='porter unicode61'
243
- );
244
- `);
245
- }
246
- // Usage events table — created by ensureUsageEventsSchema() at runtime.
247
- // Utility scores table (aggregated per-entry utility metrics)
248
- db.exec(`
249
- CREATE TABLE IF NOT EXISTS utility_scores (
250
- entry_id INTEGER PRIMARY KEY,
251
- utility REAL NOT NULL DEFAULT 0,
252
- show_count INTEGER NOT NULL DEFAULT 0,
253
- search_count INTEGER NOT NULL DEFAULT 0,
254
- select_rate REAL NOT NULL DEFAULT 0,
255
- last_used_at TEXT,
256
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
257
- FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
258
- );
259
- `);
260
- // Per-project scoped utility scores — tracks usage per (entry, cwd-anchor)
261
- // so assets useful in project A don't pollute rankings in project B.
262
- // The global utility_scores table is preserved as a fallback / cold-start aid.
263
- db.exec(`
264
- CREATE TABLE IF NOT EXISTS utility_scores_scoped (
265
- entry_id INTEGER NOT NULL,
266
- scope_key TEXT NOT NULL,
267
- utility REAL NOT NULL DEFAULT 0,
268
- last_used_at INTEGER NOT NULL,
269
- PRIMARY KEY (entry_id, scope_key)
270
- );
271
- CREATE INDEX IF NOT EXISTS idx_utility_scores_scoped_entry_id
272
- ON utility_scores_scoped(entry_id);
273
- `);
274
- db.exec(`
275
- CREATE TABLE IF NOT EXISTS index_dir_state (
276
- dir_path TEXT PRIMARY KEY,
277
- file_set_hash TEXT NOT NULL,
278
- file_mtime_max_ms REAL NOT NULL,
279
- reason TEXT NOT NULL,
280
- updated_at TEXT NOT NULL
281
- );
282
- `);
283
- // LLM enrichment result cache. Stores a SHA-256 body hash and the JSON
284
- // result for each asset so that subsequent `akm index --enrich` runs can
285
- // skip the LLM call when the body hasn't changed. The cache is keyed by
286
- // a stable asset_ref string (e.g. the absolute file path for graph/memory
287
- // passes, or `entryKey:passId` for the metadata-enhance pass).
288
- // Entries are cleaned up when assets are removed or --re-enrich is used.
289
- db.exec(`
290
- CREATE TABLE IF NOT EXISTS llm_enrichment_cache (
291
- asset_ref TEXT NOT NULL,
292
- cache_variant TEXT NOT NULL,
293
- body_hash TEXT NOT NULL,
294
- result_json TEXT NOT NULL,
295
- updated_at INTEGER NOT NULL,
296
- PRIMARY KEY (asset_ref, cache_variant)
297
- );
298
-
299
- CREATE INDEX IF NOT EXISTS idx_llm_cache_updated
300
- ON llm_enrichment_cache(updated_at);
301
- `);
302
- // Graph extraction tables — schema v2 (entry_id PK).
303
- //
304
- // graph_files is keyed on entries.id so child tables cascade-delete cleanly
305
- // when an entry is removed, and so JOINs from graph rows to entries are a
306
- // direct PK lookup. (stash_root, file_path) is retained as UNIQUE so the
307
- // extractor's path-based upsert still works.
308
- //
309
- // graph_file_entities and graph_file_relations no longer duplicate file_path;
310
- // they reference entry_id and inherit stash scoping via graph_files.
311
- db.exec(`
312
- CREATE TABLE IF NOT EXISTS graph_meta (
313
- stash_root TEXT PRIMARY KEY,
314
- schema_version INTEGER NOT NULL,
315
- generated_at TEXT NOT NULL,
316
- considered_files INTEGER NOT NULL DEFAULT 0,
317
- extracted_files INTEGER NOT NULL DEFAULT 0,
318
- entity_count INTEGER NOT NULL DEFAULT 0,
319
- relation_count INTEGER NOT NULL DEFAULT 0,
320
- extraction_coverage REAL NOT NULL DEFAULT 0,
321
- density REAL NOT NULL DEFAULT 0,
322
- extractor_id TEXT,
323
- extraction_run_id TEXT,
324
- model TEXT,
325
- prompt_version TEXT,
326
- batch_size INTEGER,
327
- cache_hits INTEGER NOT NULL DEFAULT 0,
328
- cache_misses INTEGER NOT NULL DEFAULT 0,
329
- truncation_count INTEGER NOT NULL DEFAULT 0,
330
- failure_count INTEGER NOT NULL DEFAULT 0
331
- );
332
-
333
- CREATE TABLE IF NOT EXISTS graph_files (
334
- entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
335
- stash_root TEXT NOT NULL,
336
- file_path TEXT NOT NULL,
337
- file_order INTEGER NOT NULL,
338
- file_type TEXT NOT NULL,
339
- body_hash TEXT NOT NULL,
340
- confidence REAL,
341
- status TEXT NOT NULL DEFAULT 'extracted',
342
- reason TEXT,
343
- extraction_run_id TEXT,
344
- UNIQUE(stash_root, file_path)
345
- );
346
-
347
- CREATE INDEX IF NOT EXISTS idx_graph_files_stash_order
348
- ON graph_files(stash_root, file_order);
349
-
350
- CREATE TABLE IF NOT EXISTS graph_file_entities (
351
- entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
352
- entity_order INTEGER NOT NULL,
353
- stash_root TEXT NOT NULL,
354
- entity_norm TEXT NOT NULL,
355
- entity TEXT NOT NULL,
356
- PRIMARY KEY (entry_id, entity_order)
357
- );
358
-
359
- CREATE INDEX IF NOT EXISTS idx_graph_file_entities_entity_norm
360
- ON graph_file_entities(stash_root, entity_norm);
361
-
362
- CREATE TABLE IF NOT EXISTS graph_file_relations (
363
- entry_id INTEGER NOT NULL REFERENCES graph_files(entry_id) ON DELETE CASCADE,
364
- relation_order INTEGER NOT NULL,
365
- from_entity_norm TEXT NOT NULL,
366
- from_entity TEXT NOT NULL,
367
- to_entity_norm TEXT NOT NULL,
368
- to_entity TEXT NOT NULL,
369
- relation_type TEXT,
370
- confidence REAL,
371
- PRIMARY KEY (entry_id, relation_order)
372
- );
373
- `);
374
- // FTS-dirty queue. Created here (not lazily on first upsert) so the
375
- // per-entry write path doesn't issue a CREATE TABLE IF NOT EXISTS on
376
- // every call — that DDL would fire thousands of times during a full
377
- // index. See `markFtsDirty` and `rebuildFts({ incremental: true })`.
378
- db.exec(`
379
- CREATE TABLE IF NOT EXISTS entries_fts_dirty (
380
- entry_id INTEGER PRIMARY KEY
381
- );
382
- `);
383
- // sqlite-vec table
384
- //
385
- // Dimension contract:
386
- // - When `embeddingDim` is `undefined`, the caller did NOT request a
387
- // specific dim. Do not touch `index_meta.embeddingDim` and do not run
388
- // the dim-change wipe — fall back to the stored dim (or the static
389
- // default) only when we have to materialise the vec table for the
390
- // first time. Without this guard, registry-side and other dim-unaware
391
- // `openDatabase()` callers would silently overwrite the dim-aware
392
- // improve/index value and oscillate the stored dim.
393
- // - When `embeddingDim` is a number, the caller explicitly asked for
394
- // that dim and owns the dim-change/backup/wipe semantics.
395
- const dimExplicit = embeddingDim !== undefined;
396
- const effectiveDim = embeddingDim ?? (Number(getMeta(db, "embeddingDim")) || EMBEDDING_DIM);
397
- if (isVecAvailable(db)) {
398
- // Check if stored embedding dimension differs from configured one
399
- if (dimExplicit) {
400
- const storedDim = getMeta(db, "embeddingDim");
401
- if (storedDim && storedDim !== String(embeddingDim)) {
402
- // Re-embedding the whole stash is expensive (LLM API calls + cache
403
- // misses), so snapshot the data dir before we drop the vec table and
404
- // wipe `embeddings`. This is the SAME hook the version-upgrade path
405
- // uses earlier in this function, just gated on embedding-dim mismatch
406
- // and tagged so operators can tell the two backup kinds apart.
407
- backupBeforeEmbeddingDimChange(options?.dataDir, storedDim, String(embeddingDim));
408
- bestEffort(() => db.exec("DROP TABLE IF EXISTS entries_vec"), "drop entries_vec on dim change");
409
- // Delete stale BLOB embeddings so they don't produce silently wrong
410
- // similarity scores against the new-dimension vec table.
411
- bestEffort(() => db.exec("DELETE FROM embeddings"), "delete stale embeddings on dim change");
412
- setMeta(db, "hasEmbeddings", "0");
413
- }
414
- }
415
- const vecExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_vec'").get();
416
- if (!vecExists) {
417
- if (!Number.isInteger(effectiveDim) || effectiveDim <= 0 || effectiveDim > 4096) {
418
- throw new Error(`Invalid embedding dimension: ${effectiveDim}`);
419
- }
420
- db.exec(`
421
- CREATE VIRTUAL TABLE entries_vec USING vec0(
422
- id INTEGER PRIMARY KEY,
423
- embedding FLOAT[${effectiveDim}]
424
- );
425
- `);
426
- }
427
- if (dimExplicit) {
428
- setMeta(db, "embeddingDim", String(embeddingDim));
429
- }
430
- }
431
- else {
432
- // Also purge BLOB embeddings on dimension change (JS fallback path).
433
- // When sqlite-vec is unavailable, entries_vec doesn't exist but the BLOB
434
- // embeddings table still stores vectors. If the configured dimension
435
- // changes, those stored BLOBs become silently incompatible.
436
- if (dimExplicit) {
437
- const storedDim = getMeta(db, "embeddingDim");
438
- if (storedDim && storedDim !== String(embeddingDim)) {
439
- backupBeforeEmbeddingDimChange(options?.dataDir, storedDim, String(embeddingDim));
440
- bestEffort(() => db.exec("DELETE FROM embeddings"), "delete embeddings on explicit dim change");
441
- setMeta(db, "hasEmbeddings", "0");
442
- }
443
- setMeta(db, "embeddingDim", String(embeddingDim));
444
- }
445
- }
446
- // Usage telemetry table
447
- ensureUsageEventsSchema(db);
448
- // Registry index cache table — caches remote registry index documents so
449
- // `akm search` does not hit the network on every invocation. The DDL is
450
- // defined in state-db.ts and shared here to avoid duplication.
451
- db.exec(REGISTRY_INDEX_CACHE_DDL);
452
- // Restore usage_events backed up by the version-upgrade path above.
453
- restoreUsageEventsBackup(db, usageBackup);
454
- }
455
113
  /**
456
- * Detect a stored DB version that differs from {@link DB_VERSION}, drop the
457
- * old schema, and return a backup of the previous `usage_events` rows so the
458
- * rest of `ensureSchema()` can restore them once the new table exists.
114
+ * Purge stored embeddings (BLOB rows in `embeddings`, plus the `entries_vec`
115
+ * virtual table) and mark the index as embedding-free. The single place that
116
+ * invalidates embeddings used on a dimension change, a model/provider change,
117
+ * and a full rebuild.
459
118
  *
460
- * Returns an empty array when no upgrade is needed or when the previous
461
- * `usage_events` table is unreadable.
462
- */
463
- function handleVersionUpgrade(db) {
464
- const storedVersion = getMeta(db, "version");
465
- // BUG-L4: distinguish "missing" (undefined) from "present but empty" — both
466
- // were previously coerced through `!storedVersion` and treated as "no
467
- // upgrade needed", which caused fresh databases (with no version row) to
468
- // skip the upgrade path correctly, but also caused the upgrade path to be
469
- // taken when a corrupted/empty version string was persisted. The current
470
- // tables get dropped only when the stored version exists AND differs from
471
- // DB_VERSION; missing or empty version means a fresh DB and no upgrade.
472
- if (storedVersion === undefined || storedVersion === "" || storedVersion === String(DB_VERSION))
473
- return [];
474
- let usageBackup = [];
475
- bestEffort(() => {
476
- usageBackup = db.prepare("SELECT * FROM usage_events").all();
477
- }, "usage_events table may not exist in older versions");
478
- db.exec("DROP TABLE IF EXISTS utility_scores");
479
- db.exec("DROP TABLE IF EXISTS utility_scores_scoped");
480
- db.exec("DROP INDEX IF EXISTS idx_utility_scores_scoped_entry_id");
481
- db.exec("DROP TABLE IF EXISTS usage_events");
482
- db.exec("DROP TABLE IF EXISTS embeddings");
483
- db.exec("DROP TABLE IF EXISTS entries_vec");
484
- db.exec("DROP TABLE IF EXISTS entries_fts");
485
- db.exec("DROP TABLE IF EXISTS index_dir_state");
486
- db.exec("DROP TABLE IF EXISTS llm_enrichment_cache");
487
- db.exec("DROP INDEX IF EXISTS idx_llm_cache_updated");
488
- db.exec("DROP TABLE IF EXISTS graph_file_relations");
489
- db.exec("DROP TABLE IF EXISTS graph_file_entities");
490
- db.exec("DROP TABLE IF EXISTS graph_files");
491
- db.exec("DROP TABLE IF EXISTS graph_meta");
492
- db.exec("DROP TABLE IF EXISTS graph_relations");
493
- db.exec("DROP TABLE IF EXISTS graph_entities");
494
- db.exec("DROP TABLE IF EXISTS graph_nodes");
495
- db.exec("DROP TABLE IF EXISTS graph_stashes");
496
- db.exec("DROP INDEX IF EXISTS idx_entries_dir");
497
- db.exec("DROP INDEX IF EXISTS idx_entries_type");
498
- db.exec("DROP TABLE IF EXISTS entries");
499
- db.exec("DELETE FROM index_meta");
500
- warn("[akm] Index rebuilt due to version upgrade. Run 'akm index' to repopulate.");
501
- return usageBackup;
502
- }
503
- /**
504
- * Snapshot the data directory before the embedding-dimension drop path wipes
505
- * `embeddings` and recreates `entries_vec`. Re-embedding a real-world stash
506
- * is expensive (LLM calls + cache misses), so we capture the pre-drop state
507
- * here using the same MVP backup helper the version-upgrade hook uses
508
- * earlier in {@link ensureSchema}.
119
+ * No backup: embeddings are a derived cache, fully regenerable from the markdown
120
+ * by the next `akm index`. (Recovery model decided 2026-06-25.)
509
121
  *
510
- * The backup is tagged with the `embedding-dim-change` reason so it lands in
511
- * `<dataDir>/backups/<timestamp>-embedding-dim-change/` instead of the
512
- * version-upgrade-flavored `<timestamp>-pre-v<N>/` directory. Restoration
513
- * works identically via `scripts/migrations/restore-data-dir.sh`.
514
- *
515
- * Failures are non-fatal — they downgrade to a warning and the destructive
516
- * ops run anyway, matching the version-upgrade hook's behavior so a broken
517
- * backup cannot brick a binary that bumped the configured dim. Likewise,
518
- * `AKM_DB_BACKUP=0` opts out via the same path.
122
+ * `dropVecTable: true` DROPs `entries_vec` used on a DIMENSION change, where
123
+ * the vec0 table must be recreated at the new width by the caller. The default
124
+ * clears its rows in place (same dimension, stale vectors).
519
125
  */
520
- function backupBeforeEmbeddingDimChange(dataDir, fromDim, toDim) {
521
- if (!dataDir)
522
- return;
523
- try {
524
- const result = backupDataDir({
525
- dataDir,
526
- // The DB version isn't changing here — pass the current DB_VERSION for
527
- // both source and target so the metadata sidecar still records the
528
- // running binary's version for forensic context.
529
- sourceVersion: DB_VERSION,
530
- targetVersion: DB_VERSION,
531
- reason: EMBEDDING_DIM_CHANGE_REASON,
532
- env: process.env,
533
- });
534
- if (result) {
535
- warn("[akm] embedding dimension changed %s→%s; data directory backed up to %s; embeddings will be regenerated", fromDim, toDim, result.path);
536
- }
537
- }
538
- catch (err) {
539
- // Defensive — backupDataDir already swallows most errors, but if it
540
- // throws for an unexpected reason we must still proceed with the drop
541
- // so the user isn't locked out of their binary on a changed dim.
542
- warn("[akm] pre-embedding-dim-change data dir backup raised an unexpected error — %s; embeddings will be regenerated without a snapshot", err instanceof Error ? err.message : String(err));
543
- }
544
- }
545
- /**
546
- * Re-insert backed-up `usage_events` rows into the freshly-created table.
547
- *
548
- * Wrapped in an outer try/catch because schema changes across versions may
549
- * make the backup incompatible with the new table definition; in that case
550
- * the backup is discarded silently rather than blocking startup.
551
- */
552
- function restoreUsageEventsBackup(db, backup) {
553
- if (backup.length === 0)
554
- return;
555
- try {
556
- // BUG-H4: introspect the *target* table's columns rather than relying on
557
- // `row[0]`'s keys. The backup may carry columns the new schema dropped,
558
- // and the new schema may have NOT-NULL columns without DEFAULT that the
559
- // old backup never carried. Project the backup onto the intersection so
560
- // we don't silently lose every row to per-row INSERT errors, and warn
561
- // once if any backup column was dropped from the new schema.
562
- const targetCols = db.prepare("PRAGMA table_info(usage_events)").all().map((c) => c.name);
563
- if (targetCols.length === 0) {
564
- warn("[db] restoreUsageEventsBackup: usage_events table missing — discarding %d backup row(s)", backup.length);
565
- return;
566
- }
567
- const targetSet = new Set(targetCols);
568
- const backupCols = Object.keys(backup[0] ?? {});
569
- const projectedCols = backupCols.filter((c) => targetSet.has(c));
570
- const droppedCols = backupCols.filter((c) => !targetSet.has(c));
571
- if (projectedCols.length === 0) {
572
- warn("[db] restoreUsageEventsBackup: no overlapping columns between backup and current schema — discarding %d row(s); dropped: %s", backup.length, droppedCols.join(", ") || "(none)");
573
- return;
574
- }
575
- if (droppedCols.length > 0) {
576
- warn("[db] restoreUsageEventsBackup: dropping columns no longer in usage_events schema: %s", droppedCols.join(", "));
577
- }
578
- let restored = 0;
579
- let failed = 0;
580
- db.transaction(() => {
581
- const placeholders = projectedCols.map(() => "?").join(", ");
582
- const insert = db.prepare(`INSERT INTO usage_events (${projectedCols.join(", ")}) VALUES (${placeholders})`);
583
- for (const row of backup) {
584
- try {
585
- insert.run(...projectedCols.map((c) => row[c]));
586
- restored++;
587
- }
588
- catch {
589
- failed++;
590
- }
591
- }
592
- })();
593
- if (failed > 0) {
594
- warn("[db] restoreUsageEventsBackup: restored %d row(s); skipped %d incompatible row(s)", restored, failed);
595
- }
596
- }
597
- catch (err) {
598
- warn("[db] restoreUsageEventsBackup: discarded %d backup row(s) — %s", backup.length, err instanceof Error ? err.message : String(err));
126
+ export function purgeEmbeddings(db, opts) {
127
+ bestEffort(() => db.exec("DELETE FROM embeddings"), "purge embeddings");
128
+ if (isVecAvailable(db)) {
129
+ bestEffort(() => db.exec(opts?.dropVecTable ? "DROP TABLE IF EXISTS entries_vec" : "DELETE FROM entries_vec"), "purge entries_vec");
599
130
  }
131
+ setMeta(db, "hasEmbeddings", "0");
600
132
  }
601
133
  // ── Meta helpers ────────────────────────────────────────────────────────────
602
134
  export function getMeta(db, key) {
@@ -695,26 +227,6 @@ function getUpsertStmts(db) {
695
227
  upsertStmtsByDb.set(db, stmts);
696
228
  return stmts;
697
229
  }
698
- /**
699
- * Phase 5A / DB v17 schema guard.
700
- *
701
- * Ensures the `entries.derived_from` column + index exist on the open
702
- * connection. Called from `ensureSchema()` after the entries CREATE so that
703
- * legacy databases (created against a pre-v17 binary but reopened without
704
- * triggering `handleVersionUpgrade()`) still gain the new column without
705
- * data loss. Idempotent: a `PRAGMA table_info` lookup gates the ALTER.
706
- */
707
- function ensureDerivedFromColumn(db) {
708
- bestEffort(() => {
709
- const cols = db.prepare("PRAGMA table_info(entries)").all();
710
- const hasColumn = cols.some((c) => c.name === "derived_from");
711
- if (!hasColumn) {
712
- db.exec("ALTER TABLE entries ADD COLUMN derived_from TEXT");
713
- }
714
- // Index creation is idempotent on its own; safe to call unconditionally.
715
- db.exec("CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from)");
716
- }, "entries table may not exist on a brand-new DB before CREATE — caller is responsible");
717
- }
718
230
  /**
719
231
  * Phase 5A / Advantage D5: look up the derived-memory child row whose
720
232
  * `derived_from` column matches `parentRef` (e.g. `"memory:claude-prefs"`).
@@ -729,7 +241,7 @@ export function getDerivedForParent(db, parentRef) {
729
241
  return null;
730
242
  try {
731
243
  const row = db
732
- .prepare(`SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text
244
+ .prepare(`SELECT ${ENTRY_COLUMNS}
733
245
  FROM entries
734
246
  WHERE derived_from = ?
735
247
  ORDER BY id DESC
@@ -737,23 +249,7 @@ export function getDerivedForParent(db, parentRef) {
737
249
  .get(parentRef);
738
250
  if (!row)
739
251
  return null;
740
- let entry;
741
- try {
742
- entry = JSON.parse(row.entry_json);
743
- }
744
- catch {
745
- warn(`[db] getDerivedForParent: skipping entry id=${row.id} — corrupt entry_json`);
746
- return null;
747
- }
748
- return {
749
- id: row.id,
750
- entryKey: row.entry_key,
751
- dirPath: row.dir_path,
752
- filePath: row.file_path,
753
- stashDir: row.stash_dir,
754
- entry,
755
- searchText: row.search_text,
756
- };
252
+ return rowToIndexedEntry(row, "getDerivedForParent");
757
253
  }
758
254
  catch {
759
255
  /* `derived_from` column may not exist on legacy DBs that haven't been
@@ -761,6 +257,49 @@ export function getDerivedForParent(db, parentRef) {
761
257
  return null;
762
258
  }
763
259
  }
260
+ /**
261
+ * 03-R3: for the given derived-twin row ids, fetch each twin's BASE memory
262
+ * `beliefState`, keyed by twin id.
263
+ *
264
+ * Used by the derived-twin belief inheritance in search ranking: a `.derived`
265
+ * twin has no belief state of its own, so it inherits its base memory's
266
+ * demoting state (contradicted/superseded/…) at search time. A twin's
267
+ * `entry_key` is exactly its base's `entry_key` plus the `.derived` suffix
268
+ * (same stash + type prefix, `<name>` vs `<name>.derived`), so the base is
269
+ * found by stripping that suffix — no ref/prefix reconstruction. Returns a map
270
+ * of twin id → base beliefState for bases that carry a non-empty state.
271
+ * Best-effort: any query error (e.g. legacy DB) yields no inheritance rather
272
+ * than failing the search.
273
+ */
274
+ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
275
+ const out = new Map();
276
+ if (twinIds.length === 0)
277
+ return out;
278
+ // Chunk at SQLITE_CHUNK_SIZE like the sibling bulk-by-id helpers, so a large
279
+ // `--limit` candidate set never trips SQLITE_MAX_VARIABLE_NUMBER (which would
280
+ // otherwise fall into the best-effort catch and silently disable the feature).
281
+ for (let i = 0; i < twinIds.length; i += SQLITE_CHUNK_SIZE) {
282
+ const chunk = twinIds.slice(i, i + SQLITE_CHUNK_SIZE);
283
+ const placeholders = chunk.map(() => "?").join(",");
284
+ bestEffort(() => {
285
+ const rows = db
286
+ .prepare(`SELECT twin.id AS twin_id, json_extract(base.entry_json, '$.beliefState') AS belief
287
+ FROM entries twin
288
+ JOIN entries base
289
+ ON base.entry_type = 'memory'
290
+ AND base.entry_key = substr(twin.entry_key, 1, length(twin.entry_key) - length('.derived'))
291
+ WHERE twin.id IN (${placeholders})
292
+ AND twin.entry_key LIKE '%.derived'
293
+ AND json_extract(base.entry_json, '$.beliefState') IS NOT NULL`)
294
+ .all(...chunk);
295
+ for (const r of rows) {
296
+ if (typeof r.belief === "string" && r.belief.trim().length > 0)
297
+ out.set(r.twin_id, r.belief.trim());
298
+ }
299
+ }, "legacy DB / entry_json without beliefState — treat as no twin inheritance");
300
+ }
301
+ return out;
302
+ }
764
303
  /**
765
304
  * Phase 2A / Rec 5: bulk-load positive feedback event counts for the given
766
305
  * entry ids. Used by the utility-decay forgetting curve to stabilize
@@ -839,6 +378,38 @@ function deleteRelatedRows(db, ids) {
839
378
  // Clean up usage events before deleting entries
840
379
  bestEffort(() => db.prepare(`DELETE FROM usage_events WHERE entry_id IN (${placeholders})`).run(...chunk), "delete usage_events for entries");
841
380
  }
381
+ // #624-P1: graph_files is NO LONGER keyed on entries.id, so deleting an
382
+ // entries row must NOT wipe the extracted graph (that is the whole point —
383
+ // the graph survives a reindex when body_hash is unchanged). We therefore do
384
+ // NOT delete graph_files here. We DO, however, recompute graph_meta counts
385
+ // for the stash roots touched by the deleted entries so the summary numbers
386
+ // stay consistent with the live child rows (the counts are derived, and the
387
+ // entries delete may have changed which files are considered/indexed).
388
+ //
389
+ // Resolve the affected stash roots from the entries rows BEFORE deletion.
390
+ const affectedStashRoots = new Set();
391
+ for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
392
+ const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
393
+ const placeholders = chunk.map(() => "?").join(",");
394
+ bestEffort(() => {
395
+ const rows = db
396
+ .prepare(`SELECT DISTINCT stash_dir FROM entries WHERE id IN (${placeholders})`)
397
+ .all(...chunk);
398
+ for (const row of rows) {
399
+ if (row.stash_dir)
400
+ affectedStashRoots.add(row.stash_dir);
401
+ }
402
+ }, "resolve stash roots for graph_meta recompute");
403
+ }
404
+ for (const stashRoot of affectedStashRoots) {
405
+ bestEffort(() => db
406
+ .prepare(`UPDATE graph_meta
407
+ SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
408
+ entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
409
+ relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
410
+ WHERE stash_root = ?`)
411
+ .run(stashRoot, stashRoot, stashRoot, stashRoot), "sync graph_meta counts after entries delete");
412
+ }
842
413
  }
843
414
  /**
844
415
  * Delete entries by their primary key IDs, along with all related rows
@@ -1048,12 +619,12 @@ function searchBlobVec(db, queryEmbedding, k) {
1048
619
  }
1049
620
  }
1050
621
  // ── FTS5 search ─────────────────────────────────────────────────────────────
1051
- export function searchFts(db, query, limit, entryType) {
622
+ export function searchFts(db, query, limit, entryType, excludeTypes) {
1052
623
  const ftsQuery = sanitizeFtsQuery(query);
1053
624
  if (!ftsQuery)
1054
625
  return [];
1055
626
  // Try the exact AND query first
1056
- const exactResults = runFtsQuery(db, ftsQuery, limit, entryType);
627
+ const exactResults = runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes);
1057
628
  if (exactResults.length > 0)
1058
629
  return exactResults;
1059
630
  // Exact match returned zero results — try prefix fallback.
@@ -1063,59 +634,40 @@ export function searchFts(db, query, limit, entryType) {
1063
634
  const prefixQuery = buildPrefixQuery(ftsQuery);
1064
635
  if (!prefixQuery)
1065
636
  return [];
1066
- return runFtsQuery(db, prefixQuery, limit, entryType);
1067
- }
1068
- /**
1069
- * Build a prefix query from an FTS5 query string by appending `*` to each
1070
- * token that is 3+ characters long. Tokens shorter than 3 characters are
1071
- * kept as-is (no prefix expansion) to avoid overly broad matches.
1072
- *
1073
- * Returns null if no tokens qualify for prefix expansion.
1074
- */
1075
- function buildPrefixQuery(ftsQuery) {
1076
- const tokens = ftsQuery.split(/\s+/).filter(Boolean);
1077
- let hasPrefix = false;
1078
- const prefixTokens = tokens.map((t) => {
1079
- if (t.length >= 3) {
1080
- hasPrefix = true;
1081
- return `${t}*`;
1082
- }
1083
- return t;
1084
- });
1085
- if (!hasPrefix)
1086
- return null;
1087
- return prefixTokens.join(" ");
1088
- }
1089
- function runFtsQuery(db, ftsQuery, limit, entryType) {
1090
- let sql;
637
+ return runFtsQuery(db, prefixQuery, limit, entryType, excludeTypes);
638
+ }
639
+ function runFtsQuery(db, ftsQuery, limit, entryType, excludeTypes) {
640
+ // #627 exclude-type clause. Only applies on the untyped ('any') path; an
641
+ // explicit include filter (entryType) already narrows to a single type, so
642
+ // exclusion is redundant there. An empty list skips the clause entirely
643
+ // (never emit `NOT IN ()`, which is a SQL error / always-false).
644
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
645
+ // The typed and untyped paths differ ONLY by one WHERE clause (an entry_type
646
+ // equality vs. an optional NOT IN exclusion) and their param order — the
647
+ // SELECT/JOIN/ORDER/LIMIT is shared, so build it once. Join on integer
648
+ // entry_id directly (no CAST; we store integer). bm25() per-column weights:
649
+ // entry_id(0), name(10), description(5), tags(3), hints(2), content(1).
650
+ let filterClause;
1091
651
  let params;
1092
- // Join on integer entry_id directly (no CAST needed; we store integer)
1093
- // Use bm25() with per-column weights: entry_id(0), name(10), description(5), tags(3), hints(2), content(1)
1094
652
  if (entryType && entryType !== "any") {
1095
- sql = `
1096
- SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
1097
- bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
1098
- FROM entries_fts f
1099
- JOIN entries e ON e.id = f.entry_id
1100
- WHERE entries_fts MATCH ?
1101
- AND e.entry_type = ?
1102
- ORDER BY bm25Score, e.id ASC
1103
- LIMIT ?
1104
- `;
653
+ filterClause = "AND e.entry_type = ?";
1105
654
  params = [ftsQuery, entryType, limit];
1106
655
  }
1107
656
  else {
1108
- sql = `
1109
- SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
1110
- bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
1111
- FROM entries_fts f
1112
- JOIN entries e ON e.id = f.entry_id
1113
- WHERE entries_fts MATCH ?
1114
- ORDER BY bm25Score, e.id ASC
1115
- LIMIT ?
1116
- `;
1117
- params = [ftsQuery, limit];
657
+ filterClause = excludes.length > 0 ? `AND e.entry_type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
658
+ // Param order: MATCH, then the NOT IN values, then LIMIT.
659
+ params = [ftsQuery, ...excludes, limit];
1118
660
  }
661
+ const sql = `
662
+ SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
663
+ bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
664
+ FROM entries_fts f
665
+ JOIN entries e ON e.id = f.entry_id
666
+ WHERE entries_fts MATCH ?
667
+ ${filterClause}
668
+ ORDER BY bm25Score, e.id ASC
669
+ LIMIT ?
670
+ `;
1119
671
  try {
1120
672
  const rows = db.prepare(sql).all(...params);
1121
673
  // Guard against corrupt JSON — skip the row rather than crashing
@@ -1139,65 +691,83 @@ function runFtsQuery(db, ftsQuery, limit, entryType) {
1139
691
  }
1140
692
  return results;
1141
693
  }
1142
- catch {
694
+ catch (err) {
695
+ warn("[db] runFtsQuery failed:", err instanceof Error ? err.message : String(err));
1143
696
  return [];
1144
697
  }
1145
698
  }
1146
- export function sanitizeFtsQuery(query) {
1147
- // Allow only characters safe in FTS5 queries: letters, digits, underscores,
1148
- // and whitespace. Everything else (hyphens, dots, quotes, parens, asterisks,
1149
- // colons, carets, @, !, etc.) is replaced with a space so that compound
1150
- // identifiers like "code-review" or "k8s.setup" become AND-joined tokens
1151
- // ("code review", "k8s setup") rather than triggering FTS5 syntax errors.
1152
- let sanitized = query.replace(/[^a-zA-Z0-9_\s]/g, " ");
1153
- // Neutralize the NEAR operator (FTS5 proximity syntax)
1154
- sanitized = sanitized.replace(/\bNEAR\b/g, " ");
1155
- const tokens = sanitized.split(/\s+/).filter((t) => t.length >= 1);
1156
- if (tokens.length === 0)
1157
- return "";
1158
- // Use implicit AND (space-separated tokens) for precision. FTS5 treats
1159
- // space-separated tokens as an implicit AND, matching only rows that
1160
- // contain ALL terms.
1161
- return tokens.join(" ");
1162
- }
699
+ // ── All entries ─────────────────────────────────────────────────────────────
1163
700
  function parseEntryRows(rows, context) {
1164
701
  const entries = [];
1165
702
  for (const row of rows) {
1166
- let entry;
1167
- try {
1168
- entry = JSON.parse(row.entry_json);
1169
- }
1170
- catch {
1171
- warn(`[db] ${context}: skipping entry id=${row.id} — corrupt entry_json`);
1172
- continue;
1173
- }
1174
- entries.push({
1175
- id: row.id,
1176
- entryKey: row.entry_key,
1177
- dirPath: row.dir_path,
1178
- filePath: row.file_path,
1179
- stashDir: row.stash_dir,
1180
- entry,
1181
- searchText: row.search_text,
1182
- });
703
+ const mapped = rowToIndexedEntry(row, context);
704
+ if (mapped)
705
+ entries.push(mapped);
1183
706
  }
1184
707
  return entries;
1185
708
  }
1186
- export function getAllEntries(db, entryType) {
709
+ export function getAllEntries(db, entryType, excludeTypes) {
1187
710
  let sql;
1188
711
  let params;
712
+ // #627 — exclude-type clause applies only on the untyped ('any') path. Empty
713
+ // list skips the clause (never `NOT IN ()`).
714
+ const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
1189
715
  if (entryType && entryType !== "any") {
1190
- sql =
1191
- "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE entry_type = ?";
716
+ sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE entry_type = ?`;
1192
717
  params = [entryType];
1193
718
  }
719
+ else if (excludes.length > 0) {
720
+ sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE entry_type NOT IN (${excludes.map(() => "?").join(", ")})`;
721
+ params = [...excludes];
722
+ }
1194
723
  else {
1195
- sql = "SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries";
724
+ sql = `SELECT ${ENTRY_COLUMNS} FROM entries`;
1196
725
  params = [];
1197
726
  }
1198
727
  const rows = db.prepare(sql).all(...params);
1199
728
  return parseEntryRows(rows, "getAllEntries");
1200
729
  }
730
+ /**
731
+ * #609 — read graph entities (normalized) for a set of entry ids. Used by the
732
+ * recombine pass to cluster memories by shared graph entity ("graph"
733
+ * relatedness source). Returns a map of `entry_id -> entity_norm[]`. Entries
734
+ * with no graph entities (graph extraction has not run, or the file produced
735
+ * no entities) are simply absent from the map — callers must fail open
736
+ * (fall back to tag relatedness) when the map is empty.
737
+ */
738
+ export function getEntitiesByEntryIds(db, entryIds) {
739
+ const result = new Map();
740
+ if (entryIds.length === 0)
741
+ return result;
742
+ // #624-P1: graph_file_entities no longer carries entry_id. Re-derive the
743
+ // entry_id -> entity_norm[] contract by JOINing through entries on
744
+ // (stash_dir, file_path) -> graph_files. Chunk the IN(?) list because the
745
+ // recombine pass can pass 10k+ entry ids (well over the SQLite param limit).
746
+ for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
747
+ const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
748
+ const placeholders = chunk.map(() => "?").join(", ");
749
+ const rows = db
750
+ .prepare(`SELECT e.id AS entry_id, gfe.entity_norm AS entity_norm
751
+ FROM entries e
752
+ JOIN graph_files gf
753
+ ON gf.stash_root = e.stash_dir AND gf.file_path = e.file_path
754
+ JOIN graph_file_entities gfe
755
+ ON gfe.stash_root = gf.stash_root
756
+ AND gfe.file_path = gf.file_path
757
+ AND gfe.body_hash = gf.body_hash
758
+ WHERE e.id IN (${placeholders})
759
+ ORDER BY e.id, gfe.entity_order`)
760
+ .all(...chunk);
761
+ for (const row of rows) {
762
+ const list = result.get(row.entry_id);
763
+ if (list)
764
+ list.push(row.entity_norm);
765
+ else
766
+ result.set(row.entry_id, [row.entity_norm]);
767
+ }
768
+ }
769
+ return result;
770
+ }
1201
771
  export function findEntryIdByRef(db, ref) {
1202
772
  const parsed = parseAssetRef(ref);
1203
773
  const nameVariants = [parsed.name];
@@ -1221,8 +791,7 @@ export function getEntryCount(db) {
1221
791
  return row.cnt;
1222
792
  }
1223
793
  export function getEmbeddableEntryCount(db) {
1224
- const row = db.prepare("SELECT COUNT(*) AS cnt FROM entries").get();
1225
- return row.cnt;
794
+ return getEntryCount(db);
1226
795
  }
1227
796
  export function getEmbeddingCount(db) {
1228
797
  const row = db.prepare("SELECT COUNT(*) AS cnt FROM embeddings").get();
@@ -1244,9 +813,7 @@ export function getEntryById(db, id) {
1244
813
  return { filePath: row.file_path, entry };
1245
814
  }
1246
815
  export function getEntriesByDir(db, dirPath) {
1247
- const rows = db
1248
- .prepare("SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE dir_path = ?")
1249
- .all(dirPath);
816
+ const rows = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries WHERE dir_path = ?`).all(dirPath);
1250
817
  return parseEntryRows(rows, "getEntriesByDir");
1251
818
  }
1252
819
  /**
@@ -1527,6 +1094,11 @@ function bareRef(ref) {
1527
1094
  * entry_ref populated (see logCurateEvent), so curation is a real retrieval
1528
1095
  * signal here. Legacy summary-only curate rows with a NULL entry_ref simply
1529
1096
  * contribute nothing.
1097
+ *
1098
+ * Machine-sourced events (`source` = 'improve' or 'task') are EXCLUDED: this
1099
+ * count feeds salience/ranking, and pipeline probe traffic counting as demand
1100
+ * creates a self-reinforcing loop (meta-review 05 DRIFT-6). NULL sources
1101
+ * (pre-column rows) count as user demand.
1530
1102
  */
1531
1103
  export function getRetrievalCounts(db, refs) {
1532
1104
  if (refs.length === 0)
@@ -1565,6 +1137,7 @@ export function getRetrievalCounts(db, refs) {
1565
1137
  FROM usage_events
1566
1138
  WHERE event_type IN ('search','show','curate')
1567
1139
  AND entry_ref IS NOT NULL
1140
+ AND (source IS NULL OR source NOT IN ('improve','task'))
1568
1141
  AND CASE
1569
1142
  WHEN instr(entry_ref, '//') > 0
1570
1143
  THEN substr(entry_ref, instr(entry_ref, '//') + 2)
@@ -1717,84 +1290,24 @@ export function getEntryByRef(db, type, name) {
1717
1290
  return db.prepare("SELECT id FROM entries WHERE entry_type = ? AND entry_key = ?").get(type, `${type}:${name}`);
1718
1291
  }
1719
1292
  /**
1720
- * MemRL learning rate for feedback-driven utility updates (F-5 / #386).
1721
- *
1722
- * Follows the bounded-step formula from MemRL (arXiv:2601.03192):
1723
- * next = clamp(current + lr × (reward − current), 0, 1)
1724
- *
1725
- * This replaces the unbounded `-0.03 × negativeCount` delta that could
1726
- * silently remove high-utility assets from the improvement loop.
1727
- */
1728
- const FEEDBACK_LR = 0.1;
1729
- /**
1730
- * Positive reward signal for a single positive feedback event.
1731
- * Reward 1.0 means "fully correct / helpful".
1732
- */
1733
- const FEEDBACK_REWARD_POSITIVE = 1.0;
1734
- /**
1735
- * Negative reward signal for a single negative feedback event.
1736
- * Reward 0.0 means "not helpful" (lowest MemRL signal).
1737
- */
1738
- const FEEDBACK_REWARD_NEGATIVE = 0.0;
1739
- /**
1740
- * Maximum total negative utility delta allowed in a single
1741
- * `applyFeedbackToUtilityScore` call regardless of negativeCount.
1742
- *
1743
- * This caps the per-day negative impact (the function is called once per
1744
- * feedback event — spamming 10 negatives in one session can move utility
1745
- * at most `MAX_NEG_DELTA_PER_CALL`). The cap prevents a noisy negative-
1746
- * feedback stream from silently destroying a high-utility asset's ranking.
1747
- */
1748
- const MAX_NEG_DELTA_PER_CALL = 0.15;
1749
- /**
1750
- * Utility threshold below which a review-needed escalation is triggered.
1751
- * When a previously high-utility asset (≥ HIGH_UTILITY_THRESHOLD) drops
1752
- * below this value, the caller should create an escalation proposal.
1753
- */
1754
- export const UTILITY_REVIEW_THRESHOLD = 0.5;
1755
- /**
1756
- * Utility level considered "high" — assets above this are tracked for
1757
- * threshold-crossing escalation.
1758
- */
1759
- export const HIGH_UTILITY_THRESHOLD = 0.5;
1760
- /**
1761
- * Apply accumulated feedback counts to the utility score of an entry using the
1762
- * MemRL bounded-step EMA formula (F-5 / #386, arXiv:2601.03192).
1763
- *
1764
- * Replaces the previous unbounded `-0.03 × negativeCount` formula with:
1765
- *
1766
- * reward = weighted average of positive and negative signals
1767
- * nextUtil = clamp(currentUtil + lr × (reward − currentUtil), 0, 1)
1768
- *
1769
- * The negative impact is additionally capped at {@link MAX_NEG_DELTA_PER_CALL}
1770
- * to prevent a noisy feedback stream from silently erasing a high-utility asset.
1293
+ * Apply accumulated feedback counts to the utility score of an entry, persisting
1294
+ * the result. The bounded-step EMA policy itself (MemRL, F-5 / #386,
1295
+ * arXiv:2601.03192) lives in {@link computeNextUtility} (feedback/utility-policy);
1296
+ * this function only reads the current utility, applies the policy, and writes
1297
+ * the new value.
1771
1298
  *
1772
1299
  * A new entry starts at 0.5 (neutral midpoint) before the EMA step is applied.
1773
- *
1774
- * Returns a {@link FeedbackUtilityResult} so the caller can detect when a
1775
- * previously high-utility asset crosses below the review threshold and create
1776
- * an escalation proposal.
1300
+ * When there is no feedback (both counts zero) the score is left untouched — no
1301
+ * DB write. Returns a {@link FeedbackUtilityResult} so the caller can detect a
1302
+ * previously high-utility asset crossing below the review threshold and escalate.
1777
1303
  */
1778
1304
  export function applyFeedbackToUtilityScore(db, entryId, positiveCount, negativeCount) {
1779
1305
  const existing = getUtilityScore(db, entryId);
1780
1306
  const previousUtility = existing?.utility ?? 0.5;
1307
+ const result = computeNextUtility(previousUtility, positiveCount, negativeCount);
1781
1308
  if (positiveCount === 0 && negativeCount === 0) {
1782
- return { previousUtility, nextUtility: previousUtility, crossedReviewThreshold: false };
1783
- }
1784
- const total = positiveCount + negativeCount;
1785
- // Weighted reward: proportion of positive signals.
1786
- const reward = positiveCount > 0 && negativeCount === 0
1787
- ? FEEDBACK_REWARD_POSITIVE
1788
- : negativeCount > 0 && positiveCount === 0
1789
- ? FEEDBACK_REWARD_NEGATIVE
1790
- : (positiveCount * FEEDBACK_REWARD_POSITIVE + negativeCount * FEEDBACK_REWARD_NEGATIVE) / total;
1791
- // MemRL bounded-step EMA: lr × (reward − current)
1792
- let delta = FEEDBACK_LR * (reward - previousUtility);
1793
- // Per-call negative cap: if delta is negative (net negative feedback), cap it.
1794
- if (delta < 0) {
1795
- delta = Math.max(delta, -MAX_NEG_DELTA_PER_CALL);
1309
+ return result;
1796
1310
  }
1797
- const nextUtility = Math.max(0, Math.min(1, previousUtility + delta));
1798
1311
  const now = new Date().toISOString();
1799
1312
  db.prepare(`
1800
1313
  INSERT INTO utility_scores (entry_id, utility, show_count, search_count, select_rate, last_used_at, updated_at)
@@ -1802,16 +1315,14 @@ export function applyFeedbackToUtilityScore(db, entryId, positiveCount, negative
1802
1315
  ON CONFLICT(entry_id) DO UPDATE SET
1803
1316
  utility = ?,
1804
1317
  updated_at = ?
1805
- `).run(entryId, nextUtility, now, now, nextUtility, now);
1806
- const crossedReviewThreshold = previousUtility >= HIGH_UTILITY_THRESHOLD && nextUtility < UTILITY_REVIEW_THRESHOLD;
1807
- return { previousUtility, nextUtility, crossedReviewThreshold };
1318
+ `).run(entryId, result.nextUtility, now, now, result.nextUtility, now);
1319
+ return result;
1808
1320
  }
1809
1321
  /**
1810
1322
  * Re-link detached usage_events to their current entry_ids via entry_ref.
1811
1323
  *
1812
- * After a full rebuild, entry IDs change. This query matches events to their
1813
- * new entry rows using the stable `entry_ref` ("type:name") column so usage
1814
- * history survives a full reindex.
1324
+ * After a full rebuild, entry IDs change. This restores each event's link
1325
+ * using the stable `entry_ref` column so usage history survives a reindex.
1815
1326
  */
1816
1327
  export function relinkUsageEvents(db) {
1817
1328
  bestEffort(() => {
@@ -1828,63 +1339,42 @@ export function relinkUsageEvents(db) {
1828
1339
  WHERE entry_id IS NOT NULL
1829
1340
  AND entry_id NOT IN (SELECT id FROM entries)
1830
1341
  `);
1831
- // Step 2: re-resolve any null entry_id from entry_ref against the
1832
- // current entries table. Picks up entries that were re-created with
1833
- // the same ref (e.g. an asset moved between sources).
1834
- db.exec(`
1835
- UPDATE usage_events SET entry_id = (
1836
- SELECT e.id FROM entries e
1837
- WHERE substr(e.entry_key, length(e.entry_key) - length(usage_events.entry_ref)) = ':' || usage_events.entry_ref
1838
- LIMIT 1
1839
- )
1840
- WHERE entry_id IS NULL AND entry_ref IS NOT NULL
1841
- `);
1342
+ // Step 2: re-resolve any null entry_id from entry_ref against the current
1343
+ // entries table, reusing the SAME canonical resolver the read path uses at
1344
+ // insert time (`findEntryIdByRef` `parseAssetRef`). Resolving per DISTINCT
1345
+ // ref keeps this O(distinct-refs) indexed lookups instead of the previous
1346
+ // O(events × entries) non-indexable `substr(entry_key, …)` scan. It also
1347
+ // fixes a silent correctness bug: the old suffix match compared the RAW
1348
+ // `entry_ref`, so origin-qualified refs ("source//type:name") never matched
1349
+ // an `entry_key` and lost their usage history on every full rebuild.
1350
+ const refs = db
1351
+ .prepare("SELECT DISTINCT entry_ref AS ref FROM usage_events WHERE entry_id IS NULL AND entry_ref IS NOT NULL")
1352
+ .all();
1353
+ const update = db.prepare("UPDATE usage_events SET entry_id = ? WHERE entry_ref = ? AND entry_id IS NULL");
1354
+ const relinkTx = db.transaction(() => {
1355
+ for (const { ref } of refs) {
1356
+ let id;
1357
+ try {
1358
+ id = findEntryIdByRef(db, ref);
1359
+ }
1360
+ catch (err) {
1361
+ if (err instanceof Error && err.name === "UsageError")
1362
+ continue;
1363
+ throw err;
1364
+ }
1365
+ if (id !== undefined)
1366
+ update.run(id, ref);
1367
+ }
1368
+ });
1369
+ relinkTx();
1842
1370
  }, "usage_events table may not exist yet during entry_id re-resolution");
1843
1371
  }
1844
1372
  // ── registry_index_cache helpers ─────────────────────────────────────────────
1845
- /**
1846
- * Upsert a registry index cache entry in index.db.
1847
- *
1848
- * @param db - Open index.db connection (from openDatabase / openExistingDatabase).
1849
- * @param registryUrl - Canonical URL of the registry (used as primary key).
1850
- * @param indexJson - Serialised registry index document (JSON string).
1851
- * @param opts.etag - HTTP ETag from the response (optional).
1852
- * @param opts.lastModified - HTTP Last-Modified from the response (optional).
1853
- */
1854
- export function upsertRegistryIndexCache(db, registryUrl, indexJson, opts) {
1855
- db.prepare(`
1856
- INSERT INTO registry_index_cache (registry_url, fetched_at, etag, last_modified, index_json)
1857
- VALUES (?, ?, ?, ?, ?)
1858
- ON CONFLICT(registry_url) DO UPDATE SET
1859
- fetched_at = excluded.fetched_at,
1860
- etag = excluded.etag,
1861
- last_modified = excluded.last_modified,
1862
- index_json = excluded.index_json
1863
- `).run(registryUrl, new Date().toISOString(), opts?.etag ?? null, opts?.lastModified ?? null, indexJson);
1864
- }
1865
- /**
1866
- * Look up a cached registry index entry from index.db.
1867
- * Returns undefined when not found or when the entry is older than `maxAgeMs`.
1868
- *
1869
- * TTL check: if `Date.now() - new Date(fetched_at).getTime() > maxAgeMs` the
1870
- * entry is considered a cache miss and undefined is returned.
1871
- *
1872
- * @param db - Open index.db connection.
1873
- * @param registryUrl - Canonical URL of the registry (primary key).
1874
- * @param maxAgeMs - Maximum age in milliseconds before the entry is stale (default: 1 hour).
1875
- */
1876
- export function getRegistryIndexCache(db, registryUrl, maxAgeMs = 3_600_000 /* 1 hour */) {
1877
- const row = db
1878
- .prepare(`SELECT fetched_at, etag, last_modified, index_json
1879
- FROM registry_index_cache WHERE registry_url = ?`)
1880
- .get(registryUrl);
1881
- if (!row)
1882
- return undefined;
1883
- const fetchedAt = Date.parse(row.fetched_at);
1884
- if (Number.isNaN(fetchedAt) || Date.now() - fetchedAt > maxAgeMs)
1885
- return undefined;
1886
- return { indexJson: row.index_json, etag: row.etag, lastModified: row.last_modified };
1887
- }
1373
+ // The raw SQL for the `registry_index_cache` table now lives in the storage
1374
+ // layer (`src/storage/repositories/registry-index-cache-repository.ts`) so the
1375
+ // dependency arrow points indexer → storage. These thin re-exports preserve the
1376
+ // previously-public symbols for any importer of this module.
1377
+ export { getRegistryIndexCache, upsertRegistryIndexCache, } from "../../storage/repositories/registry-index-cache-repository.js";
1888
1378
  /**
1889
1379
  * Walk indexed entries and collect a deduplicated set of tags. When
1890
1380
  * `entryType` is provided, only entries of that type contribute tags.