akm-cli 0.9.1 → 0.9.2-alpha.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 (350) hide show
  1. package/CHANGELOG.md +103 -28
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -18,95 +18,76 @@ import { getStateDbPath, withStateDb } from "../../core/state-db.js";
18
18
  import { warn } from "../../core/warn.js";
19
19
  import { buildSearchText } from "../../indexer/search/search-fields.js";
20
20
  import { ENTRY_COLUMNS, rowToIndexedEntry } from "./index-entry-mapper.js";
21
+ import { deleteFtsEntries, replaceFtsEntry } from "./index-fts-repository.js";
21
22
  import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
22
23
  import { deleteEntryVectors, isVecAvailable } from "./index-vec-repository.js";
23
24
  // ── Entry operations ────────────────────────────────────────────────────────
24
25
  /**
25
- * Insert or update an entry in the `entries` table. Returns the row id.
26
+ * Insert or update one canonical entry and all synchronously derived search
27
+ * state. Returns the stable row id.
26
28
  *
27
- * **Important:** This does not update the FTS index. Callers must call
28
- * `rebuildFts()` after all upserts are complete for full-text search to
29
- * reflect the changes.
29
+ * The entries row, FTS projection, and stale-vector invalidation commit as one
30
+ * SQLite transaction. Callers therefore cannot publish an entry and forget a
31
+ * second FTS maintenance step.
30
32
  */
31
- export function upsertEntry(db, entryKey, dirPath, filePath, stashDir, entry, searchText, provenance, contentHash) {
33
+ export function upsertEntry(db, filePath, entry, searchText, provenance, contentHash) {
32
34
  // Hot path during indexing — cache prepared statements per database
33
35
  // connection so we don't pay the SQL parse/compile cost on every call.
34
36
  const stmts = getUpsertStmts(db);
35
- const previous = (provenance?.itemRef
36
- ? stmts.findByItemRef.get(provenance.itemRef)
37
- : undefined) ?? stmts.findByEntryKey.get(entryKey);
38
37
  // Phase 5A / Advantage D5: surface derived memory parent ref into the
39
38
  // dedicated `derived_from` column so retrieval-time lookup (parent→child)
40
39
  // does not have to scan + JSON-decode every memory row.
41
40
  const derivedFrom = typeof entry.derivedFrom === "string" && entry.derivedFrom.trim() ? entry.derivedFrom.trim() : null;
42
- // Chunk-5 Step 2 (spec §14.4): populate the additive identity/provenance
43
- // columns alongside the legacy ones. `type` mirrors `entry_type` (the open
44
- // token) unconditionally; `item_ref`/bundle/component/concept/adapter come
45
- // from the write-boundary derivation when available (NULL otherwise, healed
46
- // by the next full index). `content_hash` (F4a M-core-2) is `doc.hash` from
47
- // the diff-persist writer; a NULL passed here PRESERVES any existing hash (the
48
- // ON CONFLICT COALESCE below) so the LLM-enrichment re-upsert cannot wipe it.
41
+ // `content_hash` is optional on the LLM-enrichment re-upsert; a missing hash
42
+ // preserves the scan writer's current value.
49
43
  const apply = () => {
50
- const result = stmts.upsert.get(entryKey, dirPath, filePath, stashDir, JSON.stringify(entry), searchText, entry.type, derivedFrom, provenance?.itemRef ?? null, provenance?.bundleId ?? null, provenance?.componentId ?? null, provenance?.conceptId ?? null, provenance?.adapterId ?? null, entry.type, contentHash ?? null);
44
+ const previous = stmts.findByItemRef.get(provenance.itemRef);
45
+ const result = stmts.upsert.get(provenance.itemRef, provenance.bundleId, provenance.componentId, provenance.conceptId, provenance.adapterId, entry.type, filePath, contentHash ?? null, JSON.stringify(entry), searchText, derivedFrom);
51
46
  if (!result)
52
- throw new Error("upsertEntry: entry_key not found after upsert");
47
+ throw new Error("upsertEntry: item_ref not found after upsert");
53
48
  if (previous?.id === result.id && previous.search_text !== searchText)
54
49
  deleteEntryVectors(db, result.id);
55
- // Mark this entry as FTS-dirty so `rebuildFts({ incremental: true })`
56
- // only revisits entries that actually changed. INSERT OR IGNORE is
57
- // idempotent across multiple upserts of the same row.
58
- stmts.markDirty.run(result.id);
50
+ replaceFtsEntry(db, result.id, entry);
59
51
  return result.id;
60
52
  };
61
- return previous && previous.search_text !== searchText ? db.transaction(apply)() : apply();
53
+ // Always enter the driver's transaction wrapper. Both supported SQLite
54
+ // drivers lower a transaction opened inside another transaction to a
55
+ // savepoint, so a caller that catches this mutation's error cannot commit a
56
+ // partial entries row through its outer transaction.
57
+ return db.transaction(apply)();
62
58
  }
63
59
  const upsertStmtsByDb = new WeakMap();
64
- // The ON CONFLICT DO UPDATE column assignments factored out so the two
65
- // conflict targets below stay byte-identical. `item_ref` is durable identity;
66
- // update `entry_key` when adapter ownership changes its
67
- // spelling so directory pruning retains the upserted row.
68
- // `content_hash` COALESCEs so a NULL passed by the LLM-enhance re-upsert cannot
69
- // wipe a previously-persisted hash.
60
+ // item_ref is the sole durable conflict target. `content_hash` COALESCEs so a
61
+ // metadata-only enrichment pass cannot wipe a scan hash.
70
62
  const UPSERT_SET_CLAUSE = `SET
71
- entry_key = excluded.entry_key,
72
- dir_path = excluded.dir_path,
73
- file_path = excluded.file_path,
74
- stash_dir = excluded.stash_dir,
75
- entry_json = excluded.entry_json,
76
- search_text = excluded.search_text,
77
- entry_type = excluded.entry_type,
78
- derived_from = excluded.derived_from,
79
- item_ref = excluded.item_ref,
80
63
  bundle_id = excluded.bundle_id,
81
64
  component_id = excluded.component_id,
82
65
  concept_id = excluded.concept_id,
83
66
  adapter_id = excluded.adapter_id,
84
67
  type = excluded.type,
68
+ file_path = excluded.file_path,
69
+ document_json = excluded.document_json,
70
+ search_text = excluded.search_text,
71
+ derived_from = excluded.derived_from,
85
72
  content_hash = COALESCE(excluded.content_hash, content_hash)`;
86
73
  function getUpsertStmts(db) {
87
74
  const existing = upsertStmtsByDb.get(db);
88
75
  if (existing)
89
76
  return existing;
90
- // Durable identity is the primary conflict target. `entry_key` remains the
91
- // internal conflict key for low-level entries without provenance.
92
- const conflictClause = `ON CONFLICT(item_ref) DO UPDATE ${UPSERT_SET_CLAUSE}
93
- ON CONFLICT(entry_key) DO UPDATE ${UPSERT_SET_CLAUSE}`;
94
77
  const stmts = {
95
78
  // RETURNING id handles ON CONFLICT DO UPDATE correctly — no second
96
79
  // SELECT round-trip needed (last_insert_rowid() is unreliable for
97
80
  // ON CONFLICT). Use `.get()` so a single row comes back.
98
81
  upsert: db.prepare(`
99
82
  INSERT INTO entries (
100
- entry_key, dir_path, file_path, stash_dir, entry_json, search_text, entry_type, derived_from,
101
- item_ref, bundle_id, component_id, concept_id, adapter_id, type, content_hash
83
+ item_ref, bundle_id, component_id, concept_id, adapter_id, type,
84
+ file_path, content_hash, document_json, search_text, derived_from
102
85
  )
103
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
104
- ${conflictClause}
86
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
87
+ ON CONFLICT(item_ref) DO UPDATE ${UPSERT_SET_CLAUSE}
105
88
  RETURNING id
106
89
  `),
107
- markDirty: db.prepare("INSERT OR IGNORE INTO entries_fts_dirty (entry_id) VALUES (?)"),
108
- findByItemRef: db.prepare("SELECT id, search_text FROM entries WHERE item_ref = ? ORDER BY id ASC LIMIT 1"),
109
- findByEntryKey: db.prepare("SELECT id, search_text FROM entries WHERE entry_key = ? LIMIT 1"),
90
+ findByItemRef: db.prepare("SELECT id, search_text FROM entries WHERE item_ref = ?"),
110
91
  };
111
92
  upsertStmtsByDb.set(db, stmts);
112
93
  return stmts;
@@ -121,10 +102,10 @@ function getUpsertStmts(db) {
121
102
  * ordering keeps results deterministic). Returns `null` when no derived
122
103
  * child has been indexed for this parent.
123
104
  */
124
- export function getDerivedForParent(db, parentRef, stashDir) {
105
+ export function getDerivedForParent(db, parentRef, bundleId) {
125
106
  if (!parentRef)
126
107
  return null;
127
- const sourceScope = stashDir ? "AND stash_dir = ?" : "";
108
+ const sourceScope = bundleId ? "AND bundle_id = ?" : "";
128
109
  const row = db
129
110
  .prepare(`SELECT ${ENTRY_COLUMNS}
130
111
  FROM entries
@@ -132,7 +113,7 @@ export function getDerivedForParent(db, parentRef, stashDir) {
132
113
  ${sourceScope}
133
114
  ORDER BY id DESC
134
115
  LIMIT 1`)
135
- .get(parentRef, ...(stashDir ? [stashDir] : []));
116
+ .get(parentRef, ...(bundleId ? [bundleId] : []));
136
117
  if (!row)
137
118
  return null;
138
119
  return rowToIndexedEntry(row, "getDerivedForParent");
@@ -143,10 +124,9 @@ export function getDerivedForParent(db, parentRef, stashDir) {
143
124
  *
144
125
  * Used by the derived-twin belief inheritance in search ranking: a `.derived`
145
126
  * twin has no belief state of its own, so it inherits its base memory's
146
- * demoting state (contradicted/superseded/…) at search time. A twin's
147
- * `entry_key` is exactly its base's `entry_key` plus the `.derived` suffix
148
- * (same stash + type prefix, `<name>` vs `<name>.derived`), so the base is
149
- * found by stripping that suffix — no ref/prefix reconstruction. Returns a map
127
+ * demoting state (contradicted/superseded/…) at search time. A twin's durable
128
+ * `item_ref` is exactly its base ref plus the `.derived` suffix, so the base is
129
+ * found by stripping that suffix. Returns a map
150
130
  * of twin id → base beliefState for bases that carry a non-empty state.
151
131
  * Best-effort: any query error (e.g. legacy DB) yields no inheritance rather
152
132
  * than failing the search.
@@ -163,14 +143,14 @@ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
163
143
  const placeholders = chunk.map(() => "?").join(",");
164
144
  bestEffort(() => {
165
145
  const rows = db
166
- .prepare(`SELECT twin.id AS twin_id, json_extract(base.entry_json, '$.beliefState') AS belief
146
+ .prepare(`SELECT twin.id AS twin_id, json_extract(base.document_json, '$.beliefState') AS belief
167
147
  FROM entries twin
168
148
  JOIN entries base
169
- ON base.entry_type = 'memory'
149
+ ON base.type = 'memory'
170
150
  AND base.item_ref = substr(twin.item_ref, 1, length(twin.item_ref) - length('.derived'))
171
151
  WHERE twin.id IN (${placeholders})
172
152
  AND twin.item_ref LIKE '%.derived'
173
- AND json_extract(base.entry_json, '$.beliefState') IS NOT NULL`)
153
+ AND json_extract(base.document_json, '$.beliefState') IS NOT NULL`)
174
154
  .all(...chunk);
175
155
  for (const r of rows) {
176
156
  if (typeof r.belief === "string" && r.belief.trim().length > 0)
@@ -181,18 +161,18 @@ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
181
161
  return out;
182
162
  }
183
163
  /**
184
- * SPEC-7 (`akm mv`): re-key an entries row IN PLACE after an on-disk rename.
164
+ * Re-key an entries row in place for the opt-in source-maintenance script.
185
165
  *
186
166
  * The row id is preserved on purpose — `utility_scores`,
187
167
  * `utility_scores_scoped`, and `embeddings` are keyed by `entry_id`, so an
188
- * UPDATE (rather than a delete + insert under the new `entry_key`) is what
168
+ * UPDATE (rather than a delete + insert under the new `item_ref`) is what
189
169
  * keeps the asset's accumulated usage-ranking history attached across a
190
170
  * rename. (`asset_salience` / `asset_outcome` live in state.db keyed by
191
171
  * `asset_ref` TEXT and are re-keyed separately by `akm mv` — see
192
- * mv-cli.ts `rekeyStateDbForMove`.) `entry_json.name` (and `filename`, when
172
+ * the state rekey helper.) `document_json.name` (and `filename`, when
193
173
  * present) is patched and `search_text` rebuilt so search reflects the new
194
- * name; the row is marked FTS-dirty for the caller's
195
- * `rebuildFts({incremental: true})`.
174
+ * name. Its FTS projection and stale vector are updated in the same
175
+ * transaction as the canonical identity.
196
176
  *
197
177
  * Bundle-qualified `usage_events.entry_ref` rows for the old conceptId are
198
178
  * rewritten to the new item ref. Without this, events keep the old
@@ -203,7 +183,7 @@ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
203
183
  * never adopts them (live asset's history wins, matching the stale-row
204
184
  * eviction below).
205
185
  *
206
- * A stale row already occupying `newEntryKey` (the caller has verified no
186
+ * A stale row already occupying the new item ref (the caller has verified no
207
187
  * FILE exists at the target, so such a row can only be a leftover for a
208
188
  * deleted file) is evicted first — through {@link deleteRelatedRows}, so its
209
189
  * child rows (embeddings, entries_vec, utility scores, usage events) go with
@@ -211,44 +191,48 @@ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
211
191
  * FK under `PRAGMA foreign_keys = ON` and roll back the whole re-key.
212
192
  * The moved row keeps its id.
213
193
  *
214
- * Returns the surviving row id, or `null` when no row matches `oldEntryKey`
194
+ * Returns the surviving row id, or `null` when no row matches the old item ref
215
195
  * (nothing indexed under the old name — the caller falls open and the next
216
196
  * full `akm index` picks the file up as a fresh entry).
217
197
  */
218
198
  export function rekeyEntryInPlace(db, opts) {
219
199
  const oldItemRef = `${opts.sourceName}//${opts.oldRef}`;
220
200
  const row = db
221
- .prepare("SELECT id, stash_dir, entry_json, search_text, entry_type FROM entries WHERE item_ref = ?")
201
+ .prepare("SELECT id, file_path, document_json, search_text, type FROM entries WHERE item_ref = ?")
222
202
  .get(oldItemRef);
223
203
  if (!row)
224
204
  return null;
225
- if (path.resolve(row.stash_dir) !== path.resolve(opts.sourceRoot)) {
226
- throw new Error(`Refusing to re-key entry ${opts.oldEntryKey}: source root does not match.`);
205
+ const sourceRoot = path.resolve(opts.sourceRoot);
206
+ const currentPath = path.resolve(row.file_path);
207
+ if (currentPath !== sourceRoot && !currentPath.startsWith(`${sourceRoot}${path.sep}`)) {
208
+ throw new Error(`Refusing to re-key entry ${oldItemRef}: source root does not match.`);
227
209
  }
228
- // Patch the JSON payload. On corrupt entry_json still re-key key + paths so
210
+ // Patch the JSON payload. On corrupt document_json still re-key identity/path so
229
211
  // the utility history survives; the next full index heals the JSON.
230
- let entryJson = row.entry_json;
212
+ let documentJson = row.document_json;
231
213
  let searchText = row.search_text;
214
+ let document;
232
215
  try {
233
- const entry = JSON.parse(row.entry_json);
216
+ const entry = JSON.parse(row.document_json);
234
217
  entry.name = opts.newName;
235
218
  if (typeof entry.filename === "string")
236
219
  entry.filename = path.basename(opts.newFilePath);
237
220
  if (opts.newDerivedFrom !== undefined)
238
221
  entry.derivedFrom = opts.newDerivedFrom;
239
- entryJson = JSON.stringify(entry);
222
+ documentJson = JSON.stringify(entry);
240
223
  searchText = buildSearchText(entry);
224
+ document = entry;
241
225
  }
242
226
  catch {
243
- /* corrupt entry_jsonkey/path-only re-key */
227
+ /* corrupt document_jsonidentity/path-only re-key */
244
228
  }
245
- const expectedNewRef = conceptIdFromTypeName(row.entry_type, opts.newName);
229
+ const expectedNewRef = conceptIdFromTypeName(row.type, opts.newName);
246
230
  if (opts.newRef !== expectedNewRef) {
247
- throw new Error(`Refusing to re-key entry ${opts.oldEntryKey}: target ref does not match the entry type and name.`);
231
+ throw new Error(`Refusing to re-key entry ${oldItemRef}: target ref does not match the entry type and name.`);
248
232
  }
249
233
  const newItemRef = `${opts.sourceName}//${opts.newRef}`;
250
234
  db.transaction(() => {
251
- const stale = db.prepare("SELECT id FROM entries WHERE entry_key = ?").get(opts.newEntryKey);
235
+ const stale = db.prepare("SELECT id FROM entries WHERE item_ref = ?").get(newItemRef);
252
236
  if (stale && stale.id !== row.id) {
253
237
  // Full child-row cleanup (embeddings, entries_vec, utility scores,
254
238
  // usage events, FTS + dirty marks) BEFORE the entries delete: the
@@ -258,11 +242,16 @@ export function rekeyEntryInPlace(db, opts) {
258
242
  deleteRelatedRows(db, [{ id: stale.id }]);
259
243
  db.prepare("DELETE FROM entries WHERE id = ?").run(stale.id);
260
244
  }
261
- db.prepare("UPDATE entries SET entry_key = ?, dir_path = ?, file_path = ?, entry_json = ?, search_text = ?, item_ref = ?, concept_id = ? WHERE id = ?").run(opts.newEntryKey, path.dirname(opts.newFilePath), opts.newFilePath, entryJson, searchText, newItemRef, opts.newRef, row.id);
245
+ db.prepare("UPDATE entries SET file_path = ?, document_json = ?, search_text = ?, item_ref = ?, concept_id = ? WHERE id = ?").run(opts.newFilePath, documentJson, searchText, newItemRef, opts.newRef, row.id);
262
246
  if (opts.newDerivedFrom !== undefined) {
263
247
  db.prepare("UPDATE entries SET derived_from = ? WHERE id = ?").run(opts.newDerivedFrom, row.id);
264
248
  }
265
- db.prepare("INSERT OR IGNORE INTO entries_fts_dirty (entry_id) VALUES (?)").run(row.id);
249
+ if (row.search_text !== searchText)
250
+ deleteEntryVectors(db, row.id);
251
+ if (document)
252
+ replaceFtsEntry(db, row.id, document);
253
+ else
254
+ deleteFtsEntries(db, [row.id]);
266
255
  })();
267
256
  // Re-point usage history at the new ref. Chunk-8 WI-8.3: usage_events lives in
268
257
  // state.db now, so this is a SEPARATE cross-DB transaction (best-effort — the
@@ -347,68 +336,65 @@ export function getPositiveFeedbackCountsByIds(ids) {
347
336
  }, "positive feedback counts are best-effort");
348
337
  return result;
349
338
  }
350
- function deleteEntriesWhere(db, column, value) {
351
- db.transaction(() => {
352
- const ids = db.prepare(`SELECT id FROM entries WHERE ${column} = ?`).all(value);
353
- deleteRelatedRows(db, ids);
354
- db.prepare(`DELETE FROM entries WHERE ${column} = ?`).run(value);
355
- })();
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] : []));
343
+ const resolvedDir = path.resolve(dirPath);
344
+ return rows.filter((row) => path.dirname(path.resolve(row.file_path)) === resolvedDir);
345
+ }
346
+ function deleteEntryRows(db, rows, options = {}) {
347
+ if (rows.length === 0)
348
+ return [];
349
+ deleteRelatedRows(db, rows, options);
350
+ for (let i = 0; i < rows.length; i += SQLITE_CHUNK_SIZE) {
351
+ const chunk = rows.slice(i, i + SQLITE_CHUNK_SIZE);
352
+ const placeholders = chunk.map(() => "?").join(",");
353
+ db.prepare(`DELETE FROM entries WHERE id IN (${placeholders})`).run(...chunk.map((row) => row.id));
354
+ }
355
+ return rows.map((row) => row.id);
356
356
  }
357
357
  export function deleteEntriesByDir(db, dirPath) {
358
- deleteEntriesWhere(db, "dir_path", dirPath);
358
+ db.transaction(() => deleteEntryRows(db, rowsInDirectory(db, dirPath)))();
359
359
  }
360
- export function deleteEntriesByDirAndStash(db, dirPath, stashDir, options = {}) {
361
- return db.transaction(() => {
362
- const ids = db
363
- .prepare("SELECT id FROM entries WHERE dir_path = ? AND stash_dir = ?")
364
- .all(dirPath, stashDir);
365
- deleteRelatedRows(db, ids, options);
366
- db.prepare("DELETE FROM entries WHERE dir_path = ? AND stash_dir = ?").run(dirPath, stashDir);
367
- return ids.map((row) => row.id);
360
+ export function deleteEntriesByDirAndBundle(db, dirPath, bundleId, options = {}) {
361
+ return db.transaction(() => deleteEntryRows(db, rowsInDirectory(db, dirPath, bundleId), options))();
362
+ }
363
+ /** Delete every entry and child row belonging to one canonical bundle. */
364
+ export function deleteEntriesByBundle(db, bundleId) {
365
+ db.transaction(() => {
366
+ const rows = db.prepare("SELECT id FROM entries WHERE bundle_id = ?").all(bundleId);
367
+ deleteEntryRows(db, rows);
368
368
  })();
369
369
  }
370
370
  /**
371
- * Delete every entry (and its child rows) under a source's stash root.
372
- *
373
- * Chunk-5 flip F1: this keys on `stash_dir` the physical source-root path,
374
- * which is orthogonal to the ref grammar and identifies rows regardless of
375
- * whether their `item_ref` is populated or NULL. No item_ref predicate is
376
- * needed (or correct) here; both grammars' rows are removed by location.
371
+ * Delete the complete regenerable entry generation through the same child-row
372
+ * authority used by targeted deletes. The caller may retain cross-database
373
+ * usage events so the finalize pass can relink them to the new row ids.
377
374
  */
378
- export function deleteEntriesByStashDir(db, stashDir) {
379
- deleteEntriesWhere(db, "stash_dir", stashDir);
375
+ export function deleteAllEntries(db, options = {}) {
376
+ return db.transaction(() => {
377
+ const rows = db.prepare("SELECT id FROM entries").all();
378
+ return deleteEntryRows(db, rows, options);
379
+ })();
380
380
  }
381
381
  /**
382
- * Diff-persist orphan delete (Chunk-5 flip F4a M-core-2): remove every entry
383
- * (and its child rows, via {@link deleteRelatedRows}) under `dirPath` whose
384
- * `entry_key` is NOT in `keepKeys` — the rows for files that vanished from a
385
- * rescanned directory.
382
+ * Diff-persist orphan delete: remove every entry under `dirPath` whose durable
383
+ * `item_ref` is not in `keepRefs`.
386
384
  *
387
385
  * Replaces the old per-dir `deleteEntriesByDir` + full re-insert: the caller
388
- * upserts the current file set FIRST (ON CONFLICT preserving `entries.id`, so
386
+ * upserts the current file set first (ON CONFLICT preserving `entries.id`, so
389
387
  * embeddings / utility / usage stay attached to unchanged rows), then calls this
390
- * to prune only the DEPARTED rows. The net row-state of `dir_path` is identical
388
+ * to prune only the departed rows. The net row-state for the directory is identical
391
389
  * to delete-then-reinsert; the win is that unchanged rows keep their id.
392
390
  *
393
- * Keyed on `entry_key` because it mirrors the directory upsert key precisely.
394
- * Both directory- and source-scoped so overlapping source roots cannot prune
395
- * one another's rows.
391
+ * Both directory- and bundle-scoped so overlapping physical roots cannot
392
+ * prune one another's rows.
396
393
  */
397
- export function deleteEntriesByDirExceptKeys(db, dirPath, stashDir, keepKeys, options = {}) {
394
+ export function deleteEntriesByDirExceptRefs(db, dirPath, bundleId, keepRefs, options = {}) {
398
395
  return db.transaction(() => {
399
- const rows = db
400
- .prepare("SELECT id, entry_key FROM entries WHERE dir_path = ? AND stash_dir = ?")
401
- .all(dirPath, stashDir);
402
- const doomed = rows.filter((r) => !keepKeys.has(r.entry_key));
403
- if (doomed.length === 0)
404
- return [];
405
- deleteRelatedRows(db, doomed, options);
406
- for (let i = 0; i < doomed.length; i += SQLITE_CHUNK_SIZE) {
407
- const chunk = doomed.slice(i, i + SQLITE_CHUNK_SIZE);
408
- const placeholders = chunk.map(() => "?").join(",");
409
- db.prepare(`DELETE FROM entries WHERE id IN (${placeholders})`).run(...chunk.map((r) => r.id));
410
- }
411
- return doomed.map((row) => row.id);
396
+ const doomed = rowsInDirectory(db, dirPath, bundleId).filter((row) => !keepRefs.has(row.item_ref));
397
+ return deleteEntryRows(db, doomed, options);
412
398
  })();
413
399
  }
414
400
  function deleteRelatedRows(db, ids, options = {}) {
@@ -416,15 +402,9 @@ function deleteRelatedRows(db, ids, options = {}) {
416
402
  return;
417
403
  const numericIds = ids.map((r) => r.id);
418
404
  const vecAvail = isVecAvailable(db);
419
- // Drop matching FTS rows + dirty markers immediately so an incremental
420
- // rebuild after a deletion doesn't try to re-index entries that no longer
421
- // exist (and so a full scan after deletion sees a consistent FTS).
422
- for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
423
- const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
424
- const placeholders = chunk.map(() => "?").join(",");
425
- bestEffort(() => db.prepare(`DELETE FROM entries_fts WHERE entry_id IN (${placeholders})`).run(...chunk), "fts table may not exist on a brand-new db");
426
- bestEffort(() => db.prepare(`DELETE FROM entries_fts_dirty WHERE entry_id IN (${placeholders})`).run(...chunk), "fts dirty table is created lazily by upsertEntry");
427
- }
405
+ // FTS is part of the canonical mutation boundary, not a caller-maintained
406
+ // dirty queue. Delete it before the parent row inside this transaction.
407
+ deleteFtsEntries(db, numericIds);
428
408
  // Process in chunks to stay within SQLITE_MAX_VARIABLE_NUMBER
429
409
  for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
430
410
  const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
@@ -437,43 +417,40 @@ function deleteRelatedRows(db, ids, options = {}) {
437
417
  bestEffort(() => db.prepare(`DELETE FROM utility_scores WHERE entry_id IN (${placeholders})`).run(...chunk), "delete utility_scores for entries");
438
418
  bestEffort(() => db.prepare(`DELETE FROM utility_scores_scoped WHERE entry_id IN (${placeholders})`).run(...chunk), "delete utility_scores_scoped for entries");
439
419
  }
440
- // usage_events lives in state.db, outside this transaction. Index persistence
441
- // disables this cleanup and runs it only after its index.db transaction
442
- // commits; standalone delete callers retain the immediate behavior.
443
- if (options.cleanupUsageEvents !== false)
444
- deleteUsageEventsByEntryIds(numericIds);
445
- // #624-P1: graph_files is NO LONGER keyed on entries.id, so deleting an
446
- // entries row must NOT wipe the extracted graph (that is the whole point —
447
- // the graph survives a reindex when body_hash is unchanged). We therefore do
448
- // NOT delete graph_files here. We DO, however, recompute graph_meta counts
449
- // for the stash roots touched by the deleted entries so the summary numbers
450
- // stay consistent with the live child rows (the counts are derived, and the
451
- // entries delete may have changed which files are considered/indexed).
452
- //
453
- // Resolve the affected stash roots from the entries rows BEFORE deletion.
454
- const affectedStashRoots = new Set();
420
+ // Graph rows are independently keyed and intentionally survive entry
421
+ // deletion. Resolve their owning roots through the canonical physical path
422
+ // before entries disappear, then refresh the derived summary counts.
423
+ const affectedGraphRoots = new Set();
455
424
  for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
456
425
  const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
457
426
  const placeholders = chunk.map(() => "?").join(",");
458
427
  bestEffort(() => {
459
428
  const rows = db
460
- .prepare(`SELECT DISTINCT stash_dir FROM entries WHERE id IN (${placeholders})`)
429
+ .prepare(`SELECT DISTINCT gf.stash_root
430
+ FROM graph_files gf
431
+ JOIN entries e ON e.file_path = gf.file_path
432
+ WHERE e.id IN (${placeholders})`)
461
433
  .all(...chunk);
462
- for (const row of rows) {
463
- if (row.stash_dir)
464
- affectedStashRoots.add(row.stash_dir);
465
- }
466
- }, "resolve stash roots for graph_meta recompute");
434
+ for (const row of rows)
435
+ affectedGraphRoots.add(row.stash_root);
436
+ }, "resolve graph roots for graph_meta recompute");
467
437
  }
468
- for (const stashRoot of affectedStashRoots) {
438
+ for (const stashRoot of affectedGraphRoots) {
469
439
  bestEffort(() => db
470
440
  .prepare(`UPDATE graph_meta
471
- SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
472
- entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
473
- relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
474
- WHERE stash_root = ?`)
441
+ SET extracted_files = (SELECT COUNT(*) FROM graph_files WHERE stash_root = ?),
442
+ entity_count = (SELECT COUNT(*) FROM graph_file_entities WHERE stash_root = ?),
443
+ relation_count = (SELECT COUNT(*) FROM graph_file_relations WHERE stash_root = ?)
444
+ WHERE stash_root = ?`)
475
445
  .run(stashRoot, stashRoot, stashRoot, stashRoot), "sync graph_meta counts after entries delete");
476
446
  }
447
+ // usage_events lives in state.db, outside this transaction. Index persistence
448
+ // disables this cleanup and runs it only after its index.db transaction
449
+ // commits; standalone delete callers retain the immediate behavior.
450
+ if (options.cleanupUsageEvents !== false)
451
+ deleteUsageEventsByEntryIds(numericIds);
452
+ // graph_files is keyed by its own stash_root/file_path/body_hash identity,
453
+ // so deleting an entry row intentionally leaves extracted graph data intact.
477
454
  }
478
455
  export function deleteUsageEventsByEntryIds(entryIds) {
479
456
  if (entryIds.length === 0 || !fs.existsSync(getStateDbPath()))
@@ -490,10 +467,10 @@ export function deleteUsageEventsByEntryIds(entryIds) {
490
467
  }
491
468
  /**
492
469
  * Delete entries by their primary key IDs, along with all related rows
493
- * (embeddings, entries_vec, entries_fts, utility_scores, usage_events).
470
+ * (embeddings, entries_vec, entries_fts, utility scores, usage_events).
494
471
  *
495
- * Used by the `--clean` post-pass to remove stale entries whose source files
496
- * no longer exist on disk.
472
+ * Used by explicit `--clean` reconciliation before embeddings and final
473
+ * verification to remove stale entries whose source files no longer exist.
497
474
  */
498
475
  export function deleteEntriesByIds(db, ids) {
499
476
  if (ids.length === 0)
@@ -525,11 +502,11 @@ export function getAllEntries(db, entryType, excludeTypes) {
525
502
  // list skips the clause (never `NOT IN ()`).
526
503
  const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
527
504
  if (entryType && entryType !== "any") {
528
- sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE entry_type = ?`;
505
+ sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE type = ?`;
529
506
  params = [entryType];
530
507
  }
531
508
  else if (excludes.length > 0) {
532
- sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE entry_type NOT IN (${excludes.map(() => "?").join(", ")})`;
509
+ sql = `SELECT ${ENTRY_COLUMNS} FROM entries WHERE type NOT IN (${excludes.map(() => "?").join(", ")})`;
533
510
  params = [...excludes];
534
511
  }
535
512
  else {
@@ -542,11 +519,10 @@ export function getAllEntries(db, entryType, excludeTypes) {
542
519
  /**
543
520
  * Resolve a single `entries.id` from a new-grammar `[bundle//]conceptId` ref,
544
521
  * keying on the canonical stored `item_ref` (ref-grammar decision D-R1/D-R4).
545
- * The optional `stashDir` scopes the
546
- * match to one source root.
522
+ * The optional `bundleId` scopes the match to one indexed bundle.
547
523
  */
548
- export function findEntryIdByRef(db, ref, stashDir) {
549
- return findEntryIdByBundleRef(db, ref, stashDir);
524
+ export function findEntryIdByRef(db, ref, bundleId) {
525
+ return findEntryIdByBundleRef(db, ref, bundleId);
550
526
  }
551
527
  /** `name` plus its `.md`-toggled sibling — the markdown ext-keep/strip ambiguity. */
552
528
  function withMdVariants(name) {
@@ -556,12 +532,12 @@ function withMdVariants(name) {
556
532
  * Current (`[bundle//]conceptId`) id lookup: match `item_ref` exactly when
557
533
  * bundle-qualified or by `//conceptId` suffix when short.
558
534
  */
559
- function findEntryIdByBundleRef(db, ref, stashDir) {
535
+ function findEntryIdByBundleRef(db, ref, bundleId) {
560
536
  const parsed = parseBundleRef(ref);
561
537
  const conceptVariants = withMdVariants(parsed.conceptId);
562
538
  // item_ref is the canonical stored spelling post-flip.
563
539
  for (const conceptId of conceptVariants) {
564
- const id = matchIdByItemRef(db, parsed.bundle, conceptId, stashDir);
540
+ const id = matchIdByItemRef(db, parsed.bundle, conceptId, bundleId);
565
541
  if (id !== undefined)
566
542
  return id;
567
543
  }
@@ -591,24 +567,23 @@ function findEntryIdByBundleRef(db, ref, stashDir) {
591
567
  * `resolveRef`'s job; this DB helper takes no config handle). The exact-bundle
592
568
  * arm is single-row under the UNIQUE `item_ref` index.
593
569
  */
594
- function matchIdByItemRef(db, bundle, conceptId, stashDir) {
595
- const scope = stashDir ? "AND stash_dir = ?" : "";
570
+ function matchIdByItemRef(db, bundle, conceptId, bundleId) {
571
+ const scope = bundleId ? "AND bundle_id = ?" : "";
596
572
  if (bundle !== undefined) {
597
573
  const itemRef = `${bundle}//${conceptId}`;
598
574
  const row = db
599
575
  .prepare(`SELECT id FROM entries WHERE item_ref = ? ${scope} ORDER BY id ASC LIMIT 1`)
600
- .get(itemRef, ...(stashDir ? [stashDir] : []));
576
+ .get(itemRef, ...(bundleId ? [bundleId] : []));
601
577
  return row?.id;
602
578
  }
603
579
  const suffix = `//${conceptId}`;
604
580
  const row = db
605
581
  .prepare(`SELECT id FROM entries
606
- WHERE item_ref IS NOT NULL
607
- AND substr(item_ref, length(item_ref) - length(?) + 1) = ?
582
+ WHERE substr(item_ref, length(item_ref) - length(?) + 1) = ?
608
583
  ${scope}
609
584
  ORDER BY id ASC
610
585
  LIMIT 1`)
611
- .get(suffix, suffix, ...(stashDir ? [stashDir] : []));
586
+ .get(suffix, suffix, ...(bundleId ? [bundleId] : []));
612
587
  return row?.id;
613
588
  }
614
589
  export function getEntryCount(db) {
@@ -616,15 +591,12 @@ export function getEntryCount(db) {
616
591
  return row.cnt;
617
592
  }
618
593
  /**
619
- * Per-asset-type entry counts (keyed by `entry_type`, e.g. "skill",
594
+ * Per-asset-type entry counts (keyed by `type`, e.g. "skill",
620
595
  * "knowledge", "memory"). Used by `akm info` to break down the aggregate
621
- * `indexStats.entryCount` (R-057). Rows with a null/empty `entry_type` are
622
- * omitted rather than surfaced under a synthetic key.
596
+ * `indexStats.entryCount` (R-057).
623
597
  */
624
598
  export function getEntryCountByType(db) {
625
- const rows = db
626
- .prepare("SELECT entry_type AS type, COUNT(*) AS cnt FROM entries WHERE entry_type IS NOT NULL GROUP BY entry_type")
627
- .all();
599
+ const rows = db.prepare("SELECT type, COUNT(*) AS cnt FROM entries GROUP BY type").all();
628
600
  const out = {};
629
601
  for (const row of rows) {
630
602
  out[row.type] = row.cnt;
@@ -636,41 +608,44 @@ export function getEmbeddableEntryCount(db) {
636
608
  }
637
609
  export function getEntryById(db, id) {
638
610
  const row = db
639
- .prepare("SELECT file_path, stash_dir, entry_json, item_ref, bundle_id, concept_id FROM entries WHERE id = ?")
611
+ .prepare("SELECT file_path, document_json, item_ref, bundle_id, component_id, concept_id, adapter_id FROM entries WHERE id = ?")
640
612
  .get(id);
641
613
  if (!row)
642
614
  return undefined;
643
615
  // Guard against corrupt JSON
644
616
  let entry;
645
617
  try {
646
- entry = JSON.parse(row.entry_json);
618
+ entry = JSON.parse(row.document_json);
647
619
  }
648
620
  catch {
649
- warn(`[db] getEntryById: skipping entry id=${id} — corrupt entry_json`);
621
+ warn(`[db] getEntryById: skipping entry id=${id} — corrupt document_json`);
650
622
  return undefined;
651
623
  }
652
624
  return {
653
625
  filePath: row.file_path,
654
- stashDir: row.stash_dir,
655
626
  entry,
656
627
  itemRef: row.item_ref,
657
628
  bundleId: row.bundle_id,
629
+ componentId: row.component_id,
658
630
  conceptId: row.concept_id,
631
+ adapterId: row.adapter_id,
659
632
  };
660
633
  }
661
634
  export function getEntriesByDir(db, dirPath) {
662
- const rows = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries WHERE dir_path = ?`).all(dirPath);
635
+ const ids = new Set(rowsInDirectory(db, dirPath).map((row) => row.id));
636
+ const rows = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries`).all().filter((row) => ids.has(row.id));
663
637
  return parseEntryRows(rows, "getEntriesByDir");
664
638
  }
665
- /** Return every directory previously indexed for one physical source root. */
666
- export function getIndexedDirPathsByStashDir(db, stashDir) {
667
- const rows = db.prepare("SELECT DISTINCT dir_path FROM entries WHERE stash_dir = ?").all(stashDir);
668
- return rows.map((row) => row.dir_path);
639
+ /** Return every directory previously indexed for one canonical bundle. */
640
+ export function getIndexedDirPathsByBundleId(db, bundleId) {
641
+ const rows = db.prepare("SELECT file_path FROM entries WHERE bundle_id = ?").all(bundleId);
642
+ return [...new Set(rows.map((row) => path.dirname(row.file_path)))];
669
643
  }
670
- /** Return every persisted source owner for one physical directory. */
671
- export function getIndexedStashDirsByDir(db, dirPath) {
672
- const rows = db.prepare("SELECT DISTINCT stash_dir FROM entries WHERE dir_path = ?").all(dirPath);
673
- return rows.map((row) => row.stash_dir);
644
+ /** Return every persisted bundle owner for one physical directory. */
645
+ export function getIndexedBundleIdsByDir(db, dirPath) {
646
+ const ids = new Set(rowsInDirectory(db, dirPath).map((row) => row.id));
647
+ const rows = db.prepare("SELECT id, bundle_id FROM entries").all();
648
+ return [...new Set(rows.filter((row) => ids.has(row.id)).map((row) => row.bundle_id))];
674
649
  }
675
650
  /**
676
651
  * Resolve a single `entries.id` by exact `file_path` (the canonical on-disk
@@ -704,7 +679,7 @@ export function getIndexedFilePaths(db) {
704
679
  *
705
680
  * Lifted verbatim (WS5) from the inline `SELECT file_path FROM entries WHERE
706
681
  * id = ?` in commands/feedback-cli.ts. Unlike {@link getEntryById}, this does
707
- * NOT parse `entry_json`, so a row with corrupt JSON still yields its path —
682
+ * NOT parse `document_json`, so a row with corrupt JSON still yields its path —
708
683
  * preserving feedback-cli's pre-extraction behaviour byte-for-byte.
709
684
  */
710
685
  export function getEntryFilePathById(db, id) {
@@ -712,8 +687,9 @@ export function getEntryFilePathById(db, id) {
712
687
  return row?.file_path;
713
688
  }
714
689
  /**
715
- * Fetch every `(file_path, entry_json)` row whose entry belongs to a given
716
- * stash root matched either by exact `stash_dir` OR by `file_path` prefix.
690
+ * Fetch every `(file_path, document_json)` row whose path is under a source
691
+ * root. The path containment filter is applied in JS so SQLite LIKE wildcard
692
+ * characters in filesystem paths cannot widen ownership.
717
693
  *
718
694
  * Lifted verbatim (WS5) from the inline query in commands/graph.ts'
719
695
  * `buildRefByPath`. The full result set is materialised with `.all()` before
@@ -721,27 +697,13 @@ export function getEntryFilePathById(db, id) {
721
697
  * connection-lifetime rule). JSON parsing stays with the caller, unchanged.
722
698
  */
723
699
  export function getEntryRefRowsForStashRoot(db, stashRoot) {
724
- return db
725
- .prepare("SELECT file_path, entry_json FROM entries WHERE stash_dir = ? OR file_path LIKE ?")
726
- .all(stashRoot, `${stashRoot}%`);
700
+ const root = path.resolve(stashRoot);
701
+ return db.prepare("SELECT file_path, document_json FROM entries").all().filter((row) => {
702
+ const file = path.resolve(row.file_path);
703
+ return file === root || file.startsWith(`${root}${path.sep}`);
704
+ });
727
705
  }
728
706
  // ── Indexer-phase helpers (moved from indexer.ts) ────────────────────────────
729
- /**
730
- * Upsert a workflow document record for an indexed entry.
731
- * Persists the parsed workflow AST as JSON alongside a FNV-1a hash of the
732
- * source content for future incremental fast-paths.
733
- */
734
- export function upsertWorkflowDocument(db, entryId, doc, content) {
735
- const sourceHash = computeSourceHash(content);
736
- db.prepare(`INSERT INTO workflow_documents (entry_id, schema_version, document_json, source_path, source_hash, updated_at)
737
- VALUES (?, ?, ?, ?, ?, ?)
738
- ON CONFLICT(entry_id) DO UPDATE SET
739
- schema_version = excluded.schema_version,
740
- document_json = excluded.document_json,
741
- source_path = excluded.source_path,
742
- source_hash = excluded.source_hash,
743
- updated_at = excluded.updated_at`).run(entryId, doc.schemaVersion, JSON.stringify(doc), doc.source.path, sourceHash, new Date().toISOString());
744
- }
745
707
  /**
746
708
  * Compute a cheap FNV-1a hash of a buffer for source-identity tracking.
747
709
  * Not security-sensitive; used as an incremental fast-path skip key.
@@ -791,8 +753,7 @@ export function getEntryByRef(db, ref) {
791
753
  /**
792
754
  * The fully-qualified `item_ref` (`<bundle>//<conceptId>`, the durable stored
793
755
  * spelling — spec §11.1 D-R3) for an entry `id`, or `null` when the row is gone
794
- * or its `item_ref` is still NULL (a write-back straggler, healed on the next
795
- * full index). The usage-event / salience / feedback writers derive the durable
756
+ * The usage-event / salience / feedback writers derive the durable
796
757
  * key from this so a stored key is always the resolved entry's canonical ref,
797
758
  * never raw input (D-R3: durable keys are never derived from input).
798
759
  */
@@ -823,7 +784,15 @@ function resolveUsageEventEntryId(db, ref) {
823
784
  * distinct linked entry_ids in usage_events is small — and the re-resolution
824
785
  * reads `entries` from `indexDb`.
825
786
  */
826
- export function relinkUsageEvents(indexDb, stateDb, _options = {}) {
787
+ function qualifiedUsageEventsTable(stateSchema) {
788
+ if (stateSchema === undefined)
789
+ return "usage_events";
790
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(stateSchema))
791
+ throw new Error("Invalid attached state schema name.");
792
+ return `"${stateSchema}".usage_events`;
793
+ }
794
+ export function relinkUsageEvents(indexDb, stateDb, options = {}) {
795
+ const usageEvents = qualifiedUsageEventsTable(options.stateSchema);
827
796
  bestEffort(() => {
828
797
  // Step 1: null out stale entry_ids (entry was deleted, re-keyed, etc).
829
798
  // Leaving them in place would let `recomputeUtilityScores` aggregate by an
@@ -831,23 +800,28 @@ export function relinkUsageEvents(indexDb, stateDb, _options = {}) {
831
800
  // on the utility_scores INSERT and roll back the entire finalize
832
801
  // transaction. Nulled rows can be re-resolved by step 2 below; events whose
833
802
  // entry is permanently gone simply stay null and age out via retention.
834
- const linkedIds = stateDb.prepare("SELECT DISTINCT entry_id AS id FROM usage_events WHERE entry_id IS NOT NULL").all().map((r) => r.id);
835
- const entryExists = indexDb.prepare("SELECT 1 FROM entries WHERE id = ?");
836
- const staleIds = linkedIds.filter((id) => entryExists.get(id) == null);
837
- if (staleIds.length > 0) {
838
- const nullOut = stateDb.prepare("UPDATE usage_events SET entry_id = NULL WHERE entry_id = ?");
803
+ const linkedRows = stateDb
804
+ .prepare(`SELECT DISTINCT entry_id AS id, entry_ref AS ref FROM ${usageEvents} WHERE entry_id IS NOT NULL`)
805
+ .all();
806
+ const entryIdentity = indexDb.prepare("SELECT item_ref AS itemRef FROM entries WHERE id = ?");
807
+ const staleLinks = linkedRows.filter(({ id, ref }) => {
808
+ const live = entryIdentity.get(id);
809
+ return live == null || (ref !== null && live.itemRef !== ref);
810
+ });
811
+ if (staleLinks.length > 0) {
812
+ const nullOut = stateDb.prepare(`UPDATE ${usageEvents} SET entry_id = NULL WHERE entry_id = ? AND entry_ref IS ?`);
839
813
  const nullTx = stateDb.transaction(() => {
840
- for (const id of staleIds)
841
- nullOut.run(id);
814
+ for (const { id, ref } of staleLinks)
815
+ nullOut.run(id, ref);
842
816
  });
843
817
  nullTx();
844
818
  }
845
819
  // Step 2: re-resolve each fully-qualified ref. Bare rows are not current
846
820
  // durable identities and remain detached.
847
821
  const refs = stateDb
848
- .prepare("SELECT DISTINCT entry_ref AS ref FROM usage_events WHERE entry_id IS NULL AND entry_ref IS NOT NULL")
822
+ .prepare(`SELECT DISTINCT entry_ref AS ref FROM ${usageEvents} WHERE entry_id IS NULL AND entry_ref IS NOT NULL`)
849
823
  .all();
850
- const update = stateDb.prepare("UPDATE usage_events SET entry_id = ? WHERE entry_ref = ? AND entry_id IS NULL");
824
+ const update = stateDb.prepare(`UPDATE ${usageEvents} SET entry_id = ? WHERE entry_ref = ? AND entry_id IS NULL`);
851
825
  const relinkTx = stateDb.transaction(() => {
852
826
  for (const { ref } of refs) {
853
827
  let id;