akm-cli 0.9.0-beta.6 → 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 (326) hide show
  1. package/CHANGELOG.md +663 -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 -14
  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 +153 -147
  57. package/dist/commands/graph/graph-cli.js +5 -13
  58. package/dist/commands/graph/graph.js +76 -72
  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 -1083
  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 +1313 -1278
  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 +243 -599
  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 +55 -4
  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 +160 -7
  97. package/dist/commands/improve/improve-cli.js +115 -73
  98. package/dist/commands/improve/improve-profiles.js +32 -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 +510 -2537
  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 +115 -0
  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 +212 -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 +7 -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 -882
  138. package/dist/commands/read/curate.js +419 -103
  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 +29 -41
  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 +389 -58
  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 +21 -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 +142 -1091
  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 +356 -780
  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 +103 -24
  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 +106 -0
  200. package/dist/indexer/index-written-assets.js +105 -0
  201. package/dist/indexer/indexer.js +291 -310
  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 +11 -1
  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 +57 -15
  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 +2718 -2354
  261. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +891 -597
  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/tar-utils.js +16 -8
  286. package/dist/sources/providers/website.js +9 -5
  287. package/dist/sources/website-ingest.js +187 -29
  288. package/dist/sources/wiki-fetchers/registry.js +53 -0
  289. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  290. package/dist/storage/database.js +45 -10
  291. package/dist/storage/managed-db.js +82 -0
  292. package/dist/storage/repositories/canaries-repository.js +107 -0
  293. package/dist/storage/repositories/consolidation-repository.js +38 -0
  294. package/dist/storage/repositories/embeddings-repository.js +72 -0
  295. package/dist/storage/repositories/events-repository.js +187 -0
  296. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  297. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  298. package/dist/storage/repositories/index-db.js +14 -8
  299. package/dist/storage/repositories/proposals-repository.js +220 -0
  300. package/dist/storage/repositories/recombine-repository.js +213 -0
  301. package/dist/storage/repositories/registry-cache.js +93 -0
  302. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  303. package/dist/storage/repositories/task-history-repository.js +93 -0
  304. package/dist/storage/sqlite-pragmas.js +146 -0
  305. package/dist/tasks/backends/cron.js +1 -1
  306. package/dist/tasks/backends/index.js +9 -0
  307. package/dist/tasks/backends/launchd.js +1 -1
  308. package/dist/tasks/backends/schtasks.js +1 -1
  309. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  310. package/dist/tasks/runner.js +15 -13
  311. package/dist/text-import-hook.mjs +0 -0
  312. package/dist/wiki/wiki.js +52 -11
  313. package/dist/workflows/cli.js +1 -0
  314. package/dist/workflows/db.js +3 -4
  315. package/dist/workflows/runtime/runs.js +43 -118
  316. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  317. package/dist/workflows/validate-summary.js +2 -7
  318. package/docs/README.md +69 -18
  319. package/docs/data-and-telemetry.md +5 -4
  320. package/docs/migration/release-notes/0.7.0.md +1 -1
  321. package/docs/migration/release-notes/0.9.0.md +39 -0
  322. package/package.json +10 -10
  323. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  324. package/dist/commands/db-cli.js +0 -23
  325. package/dist/indexer/db/db-backup.js +0 -376
  326. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -0,0 +1,421 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * R5 — Longitudinal collapse/churn detector
6
+ * (docs/design/improve-collapse-churn-detector-design.md).
7
+ *
8
+ * Detects the two measured failure modes of LLM-consolidated memory stores:
9
+ *
10
+ * COLLAPSE — repeated merges destroy information: canary retrieval recall
11
+ * downtrends, distinct-content entropy downtrends, or the store shrinks
12
+ * while generation counts rise.
13
+ * CHURN — real accepted-change volume with zero retrieval-visible or
14
+ * shape-visible movement (LLM budget burned rewriting to no effect).
15
+ *
16
+ * Hard invariants: deterministic only (FTS BM25 + hashing — never an LLM,
17
+ * never an embedding model); bounded storage (< 2 KB per qualifying cycle,
18
+ * 365-day retention); fail-open (an error warns and skips, never breaks an
19
+ * improve run); runs only on cycles where consolidate/recombine did work.
20
+ *
21
+ * Observe-only in v1: alerts land in `improve_cycle_metrics.alerts_json`, the
22
+ * events log (`collapse_detector_alert`), and the `akm health` advisory —
23
+ * nothing is ever blocked.
24
+ *
25
+ * @module collapse-detector
26
+ */
27
+ import { randomBytes } from "node:crypto";
28
+ import { makeAssetRef } from "../../core/asset/asset-ref.js";
29
+ import { getImproveProcessConfig } from "../../core/config/config.js";
30
+ import { appendEvent } from "../../core/events.js";
31
+ import { withStateDb } from "../../core/state-db.js";
32
+ import { warn } from "../../core/warn.js";
33
+ import { closeDatabase, getAllEntries, openExistingDatabase, searchFts, } from "../../indexer/db/db.js";
34
+ import { deactivateCanarySet, getActiveCanaries, getCanariesBySetId, insertCanaries, insertCycleMetrics, listActiveCanarySetIds, queryRecentCycleMetrics, } from "../../storage/repositories/canaries-repository.js";
35
+ import { computeBigramDiversity, DEFAULT_MAX_GENERATION } from "./anti-collapse.js";
36
+ import { getAllRankScores } from "./salience.js";
37
+ // ── Defaults (mirrored in config-schema.ts ImproveCollapseDetectorSchema) ────
38
+ export const DEFAULT_CANARY_COUNT = 40; // owner-approved 30–50 range
39
+ export const DEFAULT_CANARY_K = 10;
40
+ export const DEFAULT_WINDOW_CYCLES = 5;
41
+ export const DEFAULT_RECALL_DROP_THRESHOLD = 0.15;
42
+ export const DEFAULT_ENTROPY_DROP_THRESHOLD = 0.05;
43
+ export const DEFAULT_CHURN_MIN_ACCEPTED = 25;
44
+ export const DEFAULT_RETENTION_DAYS = 365;
45
+ /** Deterministic bigram-diversity sample cap (cost bound at 10k assets). */
46
+ const DIVERSITY_SAMPLE_CAP = 2000;
47
+ /**
48
+ * Minimum merge-floor violations in one cycle before the advisory alert fires.
49
+ * The specificity floor is deliberately strict (Phase-1 tuning pending), so a
50
+ * couple of borderline merges per cycle must not flip `akm health` to warn —
51
+ * that alert fatigue would drown the real collapse signals.
52
+ */
53
+ const MERGE_FLOOR_ALERT_MIN = 3;
54
+ /** The learning-store types the detector measures. */
55
+ const LEARNING_TYPES = new Set(["memory", "lesson", "knowledge"]);
56
+ // ── Canary set ────────────────────────────────────────────────────────────────
57
+ /** Deterministic query string for one anchor entry: name tokens + top tags + description head. */
58
+ function buildCanaryQuery(entry) {
59
+ const nameTokens = entry.entry.name.split(/[-_/.]+/).filter((t) => t.length > 1);
60
+ const tags = (entry.entry.tags ?? []).slice(0, 3);
61
+ const descriptionHead = (entry.entry.description ?? "").split(/\s+/).slice(0, 6);
62
+ const parts = [...nameTokens, ...tags, ...descriptionHead].filter((t) => t.length > 0);
63
+ return [...new Set(parts)].join(" ");
64
+ }
65
+ /** Build the mint candidate list (deterministic given index + salience tables). */
66
+ function buildMintList(stateDb, entries, cfg) {
67
+ const canaryCount = cfg.canaryCount ?? DEFAULT_CANARY_COUNT;
68
+ const rankScores = getAllRankScores(stateDb);
69
+ // NOTE: entryKey is stash-prefixed ("<stashDir>:type:name"); asset_salience
70
+ // and the canary scoring both key on the bare "type:name" ref.
71
+ const candidates = entries
72
+ .filter((e) => LEARNING_TYPES.has(e.entry.type))
73
+ .map((e) => {
74
+ const ref = makeAssetRef(e.entry.type, e.entry.name);
75
+ return { e, ref, score: rankScores.get(ref) ?? 0 };
76
+ })
77
+ .sort((a, b) => b.score - a.score || (a.ref < b.ref ? -1 : 1));
78
+ // Type-stratified top slice: ⅓ per learning type, backfill from global order.
79
+ const perType = Math.ceil(canaryCount / 3);
80
+ const picked = new Map();
81
+ for (const type of LEARNING_TYPES) {
82
+ let taken = 0;
83
+ for (const c of candidates) {
84
+ if (taken >= perType || picked.size >= canaryCount)
85
+ break;
86
+ if (c.e.entry.type === type && !picked.has(c.ref)) {
87
+ picked.set(c.ref, c);
88
+ taken++;
89
+ }
90
+ }
91
+ }
92
+ for (const c of candidates) {
93
+ if (picked.size >= canaryCount)
94
+ break;
95
+ if (!picked.has(c.ref))
96
+ picked.set(c.ref, c);
97
+ }
98
+ return [...picked.values()]
99
+ .map((c) => ({ anchorRef: c.ref, query: buildCanaryQuery(c.e) }))
100
+ .filter((c) => c.query.length > 0);
101
+ }
102
+ /** Collision-safe mint token (same-millisecond mints happen in tests + concurrent runs). */
103
+ function newCanarySetId() {
104
+ return `canary-${Date.now().toString(36)}-${randomBytes(2).toString("hex")}`;
105
+ }
106
+ /**
107
+ * Mint (or return) the active canary set. Deterministic given the index +
108
+ * salience tables: rank the three learning types by `asset_salience.rank_score`
109
+ * (fallback 0, tie-broken by ref), take a type-stratified top slice
110
+ * (⅓ per type, backfilled from the global ranking when a type is short).
111
+ *
112
+ * Returns `null` when the index has no mintable learning entries — a cycle
113
+ * with no canary set is NOT recorded (a fresh unused set id every cycle would
114
+ * mean the trend window never fills and recall reads as a fake 0).
115
+ *
116
+ * NEVER auto-refreshes: once minted the set is frozen until an explicit
117
+ * `akm improve canary --refresh` — silent re-baselining is how a slow collapse
118
+ * hides. Rows are read back BY OUR OWN set id (never "newest active") so a
119
+ * concurrent mint in another process cannot relabel this run's metrics.
120
+ */
121
+ export function ensureCanarySet(stateDb, indexDb, cfg, preloadedEntries) {
122
+ const existing = getActiveCanaries(stateDb);
123
+ if (existing.length > 0) {
124
+ return { canarySetId: existing[0].canary_set_id, canaries: existing };
125
+ }
126
+ const minted = buildMintList(stateDb, preloadedEntries ?? getAllEntries(indexDb), cfg);
127
+ if (minted.length === 0)
128
+ return null;
129
+ const canarySetId = newCanarySetId();
130
+ insertCanaries(stateDb, canarySetId, minted);
131
+ return { canarySetId, canaries: getCanariesBySetId(stateDb, canarySetId) };
132
+ }
133
+ /**
134
+ * Explicit canary re-mint (the ONLY refresh path — `akm improve canary
135
+ * --refresh`). Mint-first, deactivate-after: when the index is empty or
136
+ * unreadable the current baseline is left untouched instead of destroyed.
137
+ * Deactivates ALL other active sets (not just the newest) so stragglers from
138
+ * an interrupted refresh can never resurrect.
139
+ */
140
+ export function refreshCanarySet(stateDb, indexDb, cfg) {
141
+ const minted = buildMintList(stateDb, getAllEntries(indexDb), cfg);
142
+ if (minted.length === 0)
143
+ return null; // nothing mintable — keep the old baseline
144
+ const canarySetId = newCanarySetId();
145
+ insertCanaries(stateDb, canarySetId, minted);
146
+ for (const oldSetId of listActiveCanarySetIds(stateDb)) {
147
+ if (oldSetId !== canarySetId)
148
+ deactivateCanarySet(stateDb, oldSetId);
149
+ }
150
+ return { canarySetId, canaries: getCanariesBySetId(stateDb, canarySetId) };
151
+ }
152
+ // ── Cycle metrics ─────────────────────────────────────────────────────────────
153
+ /**
154
+ * Name-free content fingerprint text for entropy metrics. The indexed
155
+ * search_text EMBEDS the (unique) entry name, which would pin the
156
+ * distinct-content ratio at 1.0 forever; convergence shows up in the
157
+ * description/tags/heading fields, so those are what get hashed. (The raw body
158
+ * is not in the index at all — search_text covers metadata + TOC headings —
159
+ * so v1 entropy is measured over the searchable surface, which is also what
160
+ * generic merged assets converge on.)
161
+ */
162
+ function contentFingerprint(entry) {
163
+ const parts = [entry.description ?? "", (entry.tags ?? []).join(" "), (entry.toc ?? []).map((h) => h.text).join(" ")];
164
+ return parts.filter((t) => t.length > 0).join(" ");
165
+ }
166
+ /** FNV-1a 64-bit over lowercased whitespace-collapsed text (distinct-content hashing). */
167
+ export function normHash(text) {
168
+ const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
169
+ let hash = 0xcbf29ce484222325n;
170
+ const prime = 0x100000001b3n;
171
+ for (let i = 0; i < normalized.length; i++) {
172
+ hash ^= BigInt(normalized.charCodeAt(i));
173
+ hash = (hash * prime) & 0xffffffffffffffffn;
174
+ }
175
+ return hash.toString(16);
176
+ }
177
+ /**
178
+ * Score one canary against the live index, merge-following via `source_refs`:
179
+ * a hit is the anchor ref itself OR any returned entry whose `source_refs`
180
+ * frontmatter contains the anchor (ONE level — provenance dropped on a
181
+ * second-generation merge is a miss by design; that IS the information loss).
182
+ * Returns the 0-based rank of the first hit, or -1.
183
+ */
184
+ function scoreCanary(indexDb, canary, k) {
185
+ const results = searchFts(indexDb, canary.query, k);
186
+ for (let i = 0; i < Math.min(results.length, k); i++) {
187
+ const r = results[i];
188
+ const ref = makeAssetRef(r.entry.type, r.entry.name);
189
+ if (ref === canary.anchor_ref)
190
+ return i;
191
+ if (r.entry.sourceRefs?.includes(canary.anchor_ref))
192
+ return i;
193
+ }
194
+ return -1;
195
+ }
196
+ /**
197
+ * Compute one qualifying cycle's store-health snapshot. One `entries` scan +
198
+ * `canaryCount` FTS queries; no LLM, no embedding model, no filesystem reads.
199
+ * Returns `null` when no canary set exists AND none is mintable (empty index)
200
+ * — such a cycle is not measurable and must not be recorded.
201
+ */
202
+ export function computeCycleMetrics(stateDb, indexDb, args) {
203
+ const k = args.cfg.k ?? DEFAULT_CANARY_K;
204
+ const maxGeneration = args.maxGeneration ?? DEFAULT_MAX_GENERATION;
205
+ // Single entries scan — shared by the canary mint (if one is needed) and
206
+ // the store-shape metrics below.
207
+ const all = getAllEntries(indexDb);
208
+ const canarySet = ensureCanarySet(stateDb, indexDb, args.cfg, all);
209
+ if (canarySet === null)
210
+ return null;
211
+ const { canarySetId, canaries } = canarySet;
212
+ // ── Canary retrieval metrics ───────────────────────────────────────────────
213
+ const ranks = [];
214
+ let recallSum = 0;
215
+ let ndcgSum = 0;
216
+ let mrrSum = 0;
217
+ for (const canary of canaries) {
218
+ const rank = scoreCanary(indexDb, canary, k);
219
+ ranks.push([canary.id, rank]);
220
+ if (rank >= 0) {
221
+ recallSum += 1;
222
+ mrrSum += 1 / (rank + 1);
223
+ // Single-relevant nDCG@k closed form: ideal DCG is 1, so the score is
224
+ // just the discount at the hit rank.
225
+ ndcgSum += 1 / Math.log2(rank + 2);
226
+ }
227
+ }
228
+ const n = Math.max(1, canaries.length);
229
+ // ── Store-shape metrics (same single entries scan) ────────────────────────
230
+ const byType = new Map();
231
+ const contentHashes = new Set();
232
+ let learningTotal = 0;
233
+ let overGeneration = 0;
234
+ const learningTexts = [];
235
+ for (const e of all) {
236
+ byType.set(e.entry.type, (byType.get(e.entry.type) ?? 0) + 1);
237
+ if (!LEARNING_TYPES.has(e.entry.type))
238
+ continue;
239
+ learningTotal++;
240
+ const fingerprint = contentFingerprint(e.entry);
241
+ contentHashes.add(normHash(fingerprint));
242
+ if ((e.entry.generation ?? 0) > maxGeneration)
243
+ overGeneration++;
244
+ learningTexts.push({ key: e.entryKey, text: fingerprint });
245
+ }
246
+ // Deterministic diversity sample: sort by entryKey, take every ⌈N/cap⌉-th row.
247
+ learningTexts.sort((a, b) => (a.key < b.key ? -1 : 1));
248
+ const step = Math.max(1, Math.ceil(learningTexts.length / DIVERSITY_SAMPLE_CAP));
249
+ let diversitySum = 0;
250
+ let diversityCount = 0;
251
+ for (let i = 0; i < learningTexts.length; i += step) {
252
+ diversitySum += computeBigramDiversity(learningTexts[i].text);
253
+ diversityCount++;
254
+ }
255
+ return {
256
+ run_id: args.runId,
257
+ ts: (args.now ?? new Date()).toISOString(),
258
+ pass: args.pass,
259
+ canary_set_id: canarySetId,
260
+ mean_recall: recallSum / n,
261
+ mean_ndcg: ndcgSum / n,
262
+ mean_mrr: mrrSum / n,
263
+ canary_ranks_json: JSON.stringify(ranks),
264
+ store_total: learningTotal,
265
+ store_by_type_json: JSON.stringify(Object.fromEntries([...byType.entries()].sort())),
266
+ distinct_content_ratio: learningTotal === 0 ? 1 : contentHashes.size / learningTotal,
267
+ mean_bigram_diversity: diversityCount === 0 ? 1 : diversitySum / diversityCount,
268
+ over_generation_count: overGeneration,
269
+ accepted_actions: args.acceptedActions,
270
+ merge_floor_violations: args.mergeFloorViolations,
271
+ alerts_json: "[]",
272
+ };
273
+ }
274
+ // ── Alert evaluation (pure) ───────────────────────────────────────────────────
275
+ function median(values) {
276
+ const sorted = [...values].sort((a, b) => a - b);
277
+ const mid = Math.floor(sorted.length / 2);
278
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
279
+ }
280
+ /**
281
+ * Evaluate the §1 alert definitions. PURE — history rows (oldest-first, NOT
282
+ * including `current`) plus the current row in, alerts out. A window shorter
283
+ * than `windowCycles` never fires (no baseline yet); the merge-floor advisory
284
+ * is per-cycle and fires regardless of window depth.
285
+ */
286
+ export function evaluateCollapseAlerts(history, current, cfg) {
287
+ const alerts = [];
288
+ // MERGE-FLOOR advisory: per-cycle, window-independent. Gated on a minimum
289
+ // count — the specificity floor is deliberately strict pre-tuning, and one
290
+ // or two borderline merges per cycle must not generate alert fatigue.
291
+ if (current.merge_floor_violations >= MERGE_FLOOR_ALERT_MIN) {
292
+ alerts.push({
293
+ kind: "merge-floor",
294
+ detail: `${current.merge_floor_violations} merge(s) failed the information floor this cycle (provenance shrank or specificity below threshold)`,
295
+ metrics: { mergeFloorViolations: current.merge_floor_violations },
296
+ });
297
+ }
298
+ const W = cfg.windowCycles ?? DEFAULT_WINDOW_CYCLES;
299
+ const hist = history.slice(-W);
300
+ if (hist.length < W)
301
+ return alerts; // no baseline yet
302
+ const recallDrop = cfg.recallDropThreshold ?? DEFAULT_RECALL_DROP_THRESHOLD;
303
+ const entropyDrop = cfg.entropyDropThreshold ?? DEFAULT_ENTROPY_DROP_THRESHOLD;
304
+ const churnMin = cfg.churnMinAcceptedActions ?? DEFAULT_CHURN_MIN_ACCEPTED;
305
+ // COLLAPSE 1 — canary recall drop vs window median (median, not previous
306
+ // cycle, so one noisy cycle can neither fire nor mask the alert).
307
+ const medianRecall = median(hist.map((h) => h.mean_recall));
308
+ if (current.mean_recall <= medianRecall - recallDrop) {
309
+ alerts.push({
310
+ kind: "collapse-recall",
311
+ detail: `mean canary recall ${current.mean_recall.toFixed(3)} dropped ≥${recallDrop} below the ${W}-cycle median ${medianRecall.toFixed(3)}`,
312
+ metrics: { currentRecall: current.mean_recall, medianRecall, threshold: recallDrop },
313
+ });
314
+ }
315
+ // COLLAPSE 2 — monotonic distinct-content-ratio decline over the window.
316
+ const series = [...hist.map((h) => h.distinct_content_ratio), current.distinct_content_ratio];
317
+ const monotonicNonIncreasing = series.every((v, i) => i === 0 || v <= series[i - 1]);
318
+ const totalDecline = hist[0].distinct_content_ratio - current.distinct_content_ratio;
319
+ if (monotonicNonIncreasing && totalDecline >= entropyDrop) {
320
+ alerts.push({
321
+ kind: "collapse-entropy",
322
+ detail: `distinct-content ratio declined monotonically by ${totalDecline.toFixed(3)} (≥${entropyDrop}) over ${W} cycles — store content is converging`,
323
+ metrics: {
324
+ windowStart: hist[0].distinct_content_ratio,
325
+ current: current.distinct_content_ratio,
326
+ decline: totalDecline,
327
+ },
328
+ });
329
+ }
330
+ // COLLAPSE 3 — store shrinking BECAUSE of re-merging (not deletion hygiene).
331
+ const maxStore = Math.max(...hist.map((h) => h.store_total));
332
+ if (current.store_total < 0.8 * maxStore && current.over_generation_count > hist[0].over_generation_count) {
333
+ alerts.push({
334
+ kind: "collapse-shrink",
335
+ detail: `store shrank >20% (${current.store_total} vs window max ${maxStore}) while over-generation count rose (${hist[0].over_generation_count} → ${current.over_generation_count})`,
336
+ metrics: {
337
+ storeTotal: current.store_total,
338
+ windowMax: maxStore,
339
+ overGeneration: current.over_generation_count,
340
+ },
341
+ });
342
+ }
343
+ // CHURN — real write volume, zero retrieval- or shape-visible movement.
344
+ // Flatness is measured against the window MEDIAN (consistent with the
345
+ // recall rule): endpoint-only comparison would call a window that swung
346
+ // wildly but happened to land near its start "flat".
347
+ const acceptedSum = hist.reduce((a, h) => a + h.accepted_actions, 0);
348
+ const scoreFlat = Math.abs(current.mean_ndcg - median(hist.map((h) => h.mean_ndcg))) < 0.02;
349
+ const entropyFlat = Math.abs(current.distinct_content_ratio - median(hist.map((h) => h.distinct_content_ratio))) < 0.02;
350
+ if (acceptedSum >= churnMin && scoreFlat && entropyFlat) {
351
+ alerts.push({
352
+ kind: "churn",
353
+ detail: `${acceptedSum} accepted actions over ${W} cycles with flat canary score and flat entropy — write volume with no retrieval-visible effect`,
354
+ metrics: { acceptedSum, ndcgDelta: current.mean_ndcg - hist[0].mean_ndcg },
355
+ });
356
+ }
357
+ return alerts;
358
+ }
359
+ // ── Orchestrator ─────────────────────────────────────────────────────────────
360
+ /**
361
+ * Run the detector for one qualifying cycle: ensure canaries → compute →
362
+ * evaluate against stored history → persist the row → append one
363
+ * `collapse_detector_alert` event per fired alert. FAIL-OPEN: any error warns
364
+ * and returns undefined — an improve run is never broken by its own
365
+ * instrumentation.
366
+ */
367
+ export function runCollapseDetector(args) {
368
+ const cfg = args.config.improve?.collapseDetector ?? {};
369
+ if (cfg.enabled === false)
370
+ return undefined;
371
+ try {
372
+ let indexDb;
373
+ try {
374
+ indexDb = openExistingDatabase(args.indexDbPath);
375
+ const db = indexDb;
376
+ // Over-generation threshold mirrors the guard actually in effect —
377
+ // reading the same config key keeps the two aligned when tuned.
378
+ const antiCollapse = getImproveProcessConfig(args.config, "consolidate", args.improveProfile)?.antiCollapse;
379
+ const maxGeneration = antiCollapse?.maxGeneration ?? DEFAULT_MAX_GENERATION;
380
+ return withStateDb((stateDb) => {
381
+ const row = computeCycleMetrics(stateDb, db, {
382
+ runId: args.runId,
383
+ pass: args.pass,
384
+ acceptedActions: args.acceptedActions,
385
+ mergeFloorViolations: args.mergeFloorViolations,
386
+ cfg,
387
+ maxGeneration,
388
+ });
389
+ if (row === null)
390
+ return undefined; // empty index — nothing to measure
391
+ const windowCycles = cfg.windowCycles ?? DEFAULT_WINDOW_CYCLES;
392
+ const history = queryRecentCycleMetrics(stateDb, row.canary_set_id, windowCycles);
393
+ const alerts = evaluateCollapseAlerts(history, row, cfg);
394
+ row.alerts_json = JSON.stringify(alerts.map((a) => a.kind));
395
+ insertCycleMetrics(stateDb, row);
396
+ for (const alert of alerts) {
397
+ appendEvent({
398
+ eventType: "collapse_detector_alert",
399
+ ref: undefined,
400
+ metadata: {
401
+ kind: alert.kind,
402
+ detail: alert.detail,
403
+ metrics: alert.metrics,
404
+ canarySetId: row.canary_set_id,
405
+ runId: args.runId,
406
+ },
407
+ }, args.eventsCtx);
408
+ }
409
+ return row;
410
+ }, { path: args.eventsCtx?.dbPath, borrowed: args.eventsCtx?.db });
411
+ }
412
+ finally {
413
+ if (indexDb)
414
+ closeDatabase(indexDb);
415
+ }
416
+ }
417
+ catch (err) {
418
+ warn(`[collapse-detector] skipped (fail-open): ${err instanceof Error ? err.message : String(err)}`);
419
+ return undefined;
420
+ }
421
+ }
@@ -0,0 +1,141 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ // Chunk sizing + per-chunk prompt assembly for consolidate. Pure token math and
5
+ // prompt-string construction over MemoryEntry inputs — no LLM call, no embedder,
6
+ // no orchestrator coupling.
7
+ import fs from "node:fs";
8
+ import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
9
+ import { cacheHash } from "../dedup.js";
10
+ /**
11
+ * Conservative chars-per-token estimate used when computing prompt budgets.
12
+ * English text averages roughly 4 chars/token for most LLM tokenizers. We use
13
+ * 3 to stay conservative (shorter tokens = more tokens per char).
14
+ */
15
+ const CHARS_PER_TOKEN = 3;
16
+ /**
17
+ * Overhead budget reserved for the system prompt, chunk header lines, and per-
18
+ * memory metadata lines (name, description, tags, separator). Measured at
19
+ * roughly 600 chars for the system prompt + ~100 chars of header + ~50 chars
20
+ * per memory × chunk size. We round up to 2 000 tokens to leave room for the
21
+ * model's own output.
22
+ */
23
+ const PROMPT_OVERHEAD_TOKENS = 2_000;
24
+ /**
25
+ * Default effective token budget used when the default LLM profile's
26
+ * `contextLength` is not set. This is intentionally conservative (4 096)
27
+ * rather than being set to the model's actual context window, because:
28
+ *
29
+ * - When the agent path is used, the agent CLI (e.g. opencode)
30
+ * prepends its own large system prompt + conversation history before
31
+ * forwarding to the model. That overhead easily consumes 30K+ tokens on
32
+ * a model with a 16K context window, leaving very little room for
33
+ * chunk content.
34
+ * - When the HTTP path is used (an LLM profile is selected), only the akm
35
+ * system prompt and user prompt are sent, so the budget can be set to the
36
+ * model's actual context length via profiles.llm[defaults.llm].contextLength.
37
+ *
38
+ * Set profiles.llm[defaults.llm].contextLength in your config file to the
39
+ * model's actual context window to allow larger chunks on the HTTP path.
40
+ */
41
+ export const DEFAULT_CONTEXT_LENGTH_TOKENS = 4_096;
42
+ /**
43
+ * Given the model's context window and the per-memory body truncation limit,
44
+ * return the maximum number of memories that can safely fit in one chunk
45
+ * without the prompt overflowing the context window.
46
+ *
47
+ * The formula is:
48
+ * usableTokens = contextLength - PROMPT_OVERHEAD_TOKENS
49
+ * tokensPerMemory = ceil(bodyTruncation / CHARS_PER_TOKEN)
50
+ * chunkSize = floor(usableTokens / tokensPerMemory)
51
+ *
52
+ * Result is clamped between 1 and 50 to avoid degenerate values.
53
+ *
54
+ * @param contextLength - Model context window in tokens.
55
+ * @param bodyTruncation - Max chars per memory body included in the prompt.
56
+ * @param maxChunkSize - Optional override for the hardcoded cap of 50 (1–50).
57
+ */
58
+ export function computeSafeChunkSize(contextLength, bodyTruncation, maxChunkSize) {
59
+ const usableTokens = Math.max(contextLength - PROMPT_OVERHEAD_TOKENS, 0);
60
+ const tokensPerMemory = Math.max(Math.ceil(bodyTruncation / CHARS_PER_TOKEN), 1);
61
+ const raw = Math.floor(usableTokens / tokensPerMemory);
62
+ return Math.max(1, Math.min(maxChunkSize ?? 50, raw));
63
+ }
64
+ /**
65
+ * Build the per-chunk user prompt fed to the consolidate LLM.
66
+ *
67
+ * Each memory is annotated with two flags that drive the system-prompt
68
+ * rules at lines 181-186:
69
+ * - `(captureMode: hot)` — user-explicit memory; system prompt rule 2
70
+ * forbids proposing delete. ~60 wasted LLM verdicts/4h on this user's
71
+ * stack before this annotation.
72
+ * - `(already queued)` — the memory's body hash matches a pending
73
+ * consolidate proposal; system prompt rule 3 forbids proposing
74
+ * promote/merge/contradict. ~107/4h before this annotation.
75
+ *
76
+ * Both annotations are visible to the LLM. `pendingProposalBodyHashes`
77
+ * is precomputed once per run by `loadPendingConsolidateProposalHashes`
78
+ * so the cost stays O(memories) inside the chunk loop.
79
+ */
80
+ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set(), standardsContext = "") {
81
+ const start = memories[0] ? `memory:${memories[0].name}` : "";
82
+ const end = memories[memories.length - 1] ? `memory:${memories[memories.length - 1].name}` : "";
83
+ const annotationsByIndex = [];
84
+ const hotRefs = [];
85
+ for (const m of memories) {
86
+ let body = "";
87
+ try {
88
+ body = fs.readFileSync(m.filePath, "utf8");
89
+ }
90
+ catch {
91
+ body = "(unreadable)";
92
+ }
93
+ const parsed = parseFrontmatter(body);
94
+ const isHot = parsed.data.captureMode === "hot";
95
+ // Use cacheHash (case-preserving stripped body) to match the domain used
96
+ // by loadPendingConsolidateProposalHashes and the body-embedding cache.
97
+ const bodyHash = cacheHash(body);
98
+ const isAlreadyQueued = pendingProposalBodyHashes.has(bodyHash);
99
+ annotationsByIndex.push({ isHot, isAlreadyQueued, body });
100
+ if (isHot)
101
+ hotRefs.push(`memory:${m.name}`);
102
+ }
103
+ const lines = [
104
+ `Source: ${sourceName}`,
105
+ `Chunk ${chunkIndex + 1} of ${totalChunks}, memories ${start}–${end}:`,
106
+ "",
107
+ ];
108
+ if (standardsContext.trim()) {
109
+ lines.push("Standards to follow (the rulebook for this target):");
110
+ lines.push(standardsContext.trim());
111
+ lines.push("");
112
+ }
113
+ // Top-of-prompt protection block for hot refs. Neutral phrasing — avoid
114
+ // op-words like "promote", "merge", "contradict" so the model doesn't
115
+ // accidentally treat the warning as a hint to use that op elsewhere
116
+ // (variant B leaked the word "contradict" into the control sample
117
+ // during the diagnostic).
118
+ if (hotRefs.length > 0) {
119
+ lines.push("⛔ DO NOT propose any `delete` operation for these refs — they are user-explicit (captureMode: hot) and the downstream guard refuses them regardless. Proposing delete for any of these only wastes tokens.");
120
+ for (const ref of hotRefs)
121
+ lines.push(` - ${ref}`);
122
+ lines.push("");
123
+ }
124
+ for (let i = 0; i < memories.length; i++) {
125
+ const m = memories[i];
126
+ const { isHot, isAlreadyQueued, body } = annotationsByIndex[i];
127
+ const annotations = [];
128
+ if (isHot)
129
+ annotations.push("captureMode: hot");
130
+ if (isAlreadyQueued)
131
+ annotations.push("already queued");
132
+ const annotationSuffix = annotations.length > 0 ? ` (${annotations.join("; ")})` : "";
133
+ lines.push(`[${i + 1}] memory:${m.name}${annotationSuffix}`);
134
+ lines.push(`Description: ${m.description || "(none)"}`);
135
+ lines.push(`Tags: ${m.tags.length > 0 ? m.tags.join(", ") : "(none)"}`);
136
+ lines.push("---");
137
+ lines.push(body.slice(0, bodyTruncation));
138
+ lines.push("");
139
+ }
140
+ return lines.join("\n");
141
+ }
@@ -0,0 +1,64 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ // Eligibility / safety predicates for consolidate: "may we touch this memory?"
5
+ // One reason to change — the policy for what consolidate is allowed to act on.
6
+ import fs from "node:fs";
7
+ import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
8
+ import { hasHotCaptureMode } from "../../proposal/validators/proposal-quality-validators.js";
9
+ export function isConsolidationEligibleMemoryName(name) {
10
+ return !name.endsWith(".derived");
11
+ }
12
+ /**
13
+ * Returns true when the memory file has `captureMode: hot` in its frontmatter.
14
+ *
15
+ * Hot memories are USER-EXPLICIT (written via `akm remember` on the hot path).
16
+ * The consolidate LLM is forbidden from deleting or auto-merging them — the
17
+ * user wrote them on purpose and only the user can decide to retire them.
18
+ *
19
+ * Reads the file once per check; consolidate runs against ~10 memories per
20
+ * chunk so the IO cost is trivial. Returns false on any read/parse error
21
+ * (fail-safe: an unparseable file is treated as not-hot, but the broader
22
+ * consolidate flow already guards against unparseable memories elsewhere).
23
+ *
24
+ * Defends against four observed defect classes (see
25
+ * `memory:akm-improve-critical-review-2026-05-20`):
26
+ * - LLM marks a memory contradicted then deletes (dangling contradictedBy)
27
+ * - LLM merges two unrelated memories sharing a topic keyword
28
+ * - LLM judges a recent durable design memo as "redundant"
29
+ * - Cascade deletes (LLM uses ref:X as `contradictedBy` for ref:Y then deletes both)
30
+ */
31
+ export function isHotCapturedMemory(filePath) {
32
+ try {
33
+ if (!fs.existsSync(filePath))
34
+ return false;
35
+ const content = fs.readFileSync(filePath, "utf8");
36
+ const parsed = parseFrontmatter(content);
37
+ return hasHotCaptureMode(parsed.data);
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ export function consolidateGuardStatus(filePath) {
44
+ if (!fs.existsSync(filePath))
45
+ return "missing";
46
+ let content;
47
+ try {
48
+ content = fs.readFileSync(filePath, "utf8");
49
+ }
50
+ catch {
51
+ return "unparseable";
52
+ }
53
+ let parsed;
54
+ try {
55
+ parsed = parseFrontmatter(content);
56
+ }
57
+ catch {
58
+ return "unparseable";
59
+ }
60
+ const data = parsed.data;
61
+ if (!data || Object.keys(data).length === 0)
62
+ return "unparseable";
63
+ return hasHotCaptureMode(data) ? "hot" : "safe";
64
+ }