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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/help/help-improve.md +9 -6
  6. package/dist/assets/hints/cli-hints-full.md +6 -5
  7. package/dist/assets/profiles/default.json +9 -4
  8. package/dist/assets/profiles/frequent.json +1 -1
  9. package/dist/assets/profiles/memory-focus.json +1 -1
  10. package/dist/assets/profiles/proactive-maintenance.json +25 -0
  11. package/dist/assets/profiles/quick.json +1 -1
  12. package/dist/assets/profiles/recombine-only.json +21 -0
  13. package/dist/assets/profiles/reflect-distill.json +30 -0
  14. package/dist/assets/profiles/synthesize.json +15 -0
  15. package/dist/assets/profiles/thorough.json +1 -1
  16. package/dist/assets/prompts/consolidate-system.md +23 -0
  17. package/dist/assets/prompts/contradiction-judge.md +33 -0
  18. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  19. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  20. package/dist/assets/prompts/extract-session.md +11 -3
  21. package/dist/assets/prompts/graph-extract-system.md +1 -0
  22. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  23. package/dist/assets/prompts/memory-infer-system.md +1 -0
  24. package/dist/assets/prompts/memory-infer-user.md +5 -0
  25. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  26. package/dist/assets/prompts/procedural-system.md +44 -0
  27. package/dist/assets/prompts/recombine-system.md +40 -0
  28. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  29. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  39. package/dist/assets/templates/html/health.html +281 -111
  40. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  41. package/dist/assets/wiki/schema-template.md +4 -4
  42. package/dist/cli/clack.js +56 -0
  43. package/dist/cli/config-migrate.js +7 -1
  44. package/dist/cli/confirm.js +1 -1
  45. package/dist/cli/parse-args.js +46 -1
  46. package/dist/cli/shared.js +28 -0
  47. package/dist/cli.js +25 -21
  48. package/dist/commands/agent/agent-dispatch.js +3 -2
  49. package/dist/commands/agent/agent-support.js +0 -7
  50. package/dist/commands/agent/contribute-cli.js +26 -7
  51. package/dist/commands/config-cli.js +26 -13
  52. package/dist/commands/env/child-env.js +47 -0
  53. package/dist/commands/env/env-cli.js +220 -227
  54. package/dist/commands/env/env.js +14 -67
  55. package/dist/commands/env/secret-cli.js +140 -138
  56. package/dist/commands/feedback-cli.js +156 -155
  57. package/dist/commands/graph/graph-cli.js +5 -13
  58. package/dist/commands/graph/graph.js +3 -3
  59. package/dist/commands/health/advisories.js +151 -0
  60. package/dist/commands/health/checks.js +103 -16
  61. package/dist/commands/health/html-report.js +447 -81
  62. package/dist/commands/health/improve-metrics.js +771 -0
  63. package/dist/commands/health/llm-usage.js +65 -0
  64. package/dist/commands/health/md-report.js +103 -0
  65. package/dist/commands/health/metrics.js +278 -0
  66. package/dist/commands/health/stash-exposure.js +46 -0
  67. package/dist/commands/health/surfaces.js +216 -0
  68. package/dist/commands/health/task-runs.js +135 -0
  69. package/dist/commands/health/types.js +26 -0
  70. package/dist/commands/health/windows.js +195 -0
  71. package/dist/commands/health.js +91 -1091
  72. package/dist/commands/improve/anti-collapse.js +170 -0
  73. package/dist/commands/improve/calibration.js +161 -0
  74. package/dist/commands/improve/collapse-detector.js +421 -0
  75. package/dist/commands/improve/consolidate/chunking.js +141 -0
  76. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  77. package/dist/commands/improve/consolidate/merge.js +145 -0
  78. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  79. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  80. package/dist/commands/improve/consolidate.js +1295 -1277
  81. package/dist/commands/improve/dedup.js +482 -0
  82. package/dist/commands/improve/distill/content-repair.js +202 -0
  83. package/dist/commands/improve/distill/promote-memory.js +229 -0
  84. package/dist/commands/improve/distill/quality-gate.js +236 -0
  85. package/dist/commands/improve/distill-guards.js +127 -0
  86. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  87. package/dist/commands/improve/distill.js +228 -605
  88. package/dist/commands/improve/eligibility.js +434 -0
  89. package/dist/commands/improve/encoding-salience.js +205 -0
  90. package/dist/commands/improve/extract-cli.js +179 -59
  91. package/dist/commands/improve/extract-prompt.js +54 -3
  92. package/dist/commands/improve/extract-watch.js +140 -0
  93. package/dist/commands/improve/extract.js +409 -43
  94. package/dist/commands/improve/feedback-valence.js +54 -0
  95. package/dist/commands/improve/hot-probation.js +45 -0
  96. package/dist/commands/improve/improve-auto-accept.js +157 -10
  97. package/dist/commands/improve/improve-cli.js +115 -73
  98. package/dist/commands/improve/improve-profiles.js +28 -8
  99. package/dist/commands/improve/improve-result-file.js +15 -25
  100. package/dist/commands/improve/improve-session.js +58 -0
  101. package/dist/commands/improve/improve.js +485 -2764
  102. package/dist/commands/improve/locks.js +154 -0
  103. package/dist/commands/improve/loop-stages.js +1100 -0
  104. package/dist/commands/improve/memory/memory-belief.js +14 -15
  105. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  106. package/dist/commands/improve/memory/memory-improve.js +27 -27
  107. package/dist/commands/improve/outcome-loop.js +270 -0
  108. package/dist/commands/improve/preparation.js +2002 -0
  109. package/dist/commands/improve/proactive-maintenance.js +37 -35
  110. package/dist/commands/improve/procedural.js +398 -0
  111. package/dist/commands/improve/recombine.js +818 -0
  112. package/dist/commands/improve/reflect-noise.js +0 -0
  113. package/dist/commands/improve/reflect.js +206 -45
  114. package/dist/commands/improve/salience.js +455 -0
  115. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  116. package/dist/commands/improve/shared.js +51 -0
  117. package/dist/commands/improve/triage.js +93 -0
  118. package/dist/commands/lint/agent-linter.js +19 -24
  119. package/dist/commands/lint/base-linter.js +173 -60
  120. package/dist/commands/lint/command-linter.js +19 -24
  121. package/dist/commands/lint/env-key-rules.js +38 -1
  122. package/dist/commands/lint/fact-linter.js +39 -0
  123. package/dist/commands/lint/index.js +31 -13
  124. package/dist/commands/lint/memory-linter.js +1 -1
  125. package/dist/commands/lint/registry.js +7 -2
  126. package/dist/commands/lint/task-linter.js +3 -3
  127. package/dist/commands/lint/workflow-linter.js +26 -1
  128. package/dist/commands/observability-cli.js +4 -4
  129. package/dist/commands/proposal/drain-policies.js +13 -4
  130. package/dist/commands/proposal/drain.js +45 -51
  131. package/dist/commands/proposal/legacy-import.js +115 -0
  132. package/dist/commands/proposal/proposal-cli.js +24 -34
  133. package/dist/commands/proposal/proposal.js +2 -1
  134. package/dist/commands/proposal/propose.js +8 -3
  135. package/dist/commands/proposal/repository.js +829 -0
  136. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  137. package/dist/commands/proposal/validators/proposals.js +93 -895
  138. package/dist/commands/read/curate.js +410 -111
  139. package/dist/commands/read/knowledge.js +10 -3
  140. package/dist/commands/read/remember-cli.js +133 -138
  141. package/dist/commands/read/search-cli.js +15 -8
  142. package/dist/commands/read/search.js +22 -11
  143. package/dist/commands/read/show.js +106 -14
  144. package/dist/commands/registry-cli.js +76 -87
  145. package/dist/commands/remember.js +11 -12
  146. package/dist/commands/sources/add-cli.js +91 -95
  147. package/dist/commands/sources/history.js +1 -1
  148. package/dist/commands/sources/init.js +66 -18
  149. package/dist/commands/sources/installed-stashes.js +11 -3
  150. package/dist/commands/sources/schema-repair.js +44 -46
  151. package/dist/commands/sources/self-update.js +2 -2
  152. package/dist/commands/sources/source-add.js +7 -3
  153. package/dist/commands/sources/sources-cli.js +3 -3
  154. package/dist/commands/sources/stash-cli.js +19 -39
  155. package/dist/commands/sources/stash-skeleton.js +57 -8
  156. package/dist/commands/tasks/default-tasks.js +15 -2
  157. package/dist/commands/tasks/tasks-cli.js +20 -29
  158. package/dist/commands/tasks/tasks.js +39 -11
  159. package/dist/commands/wiki-cli.js +23 -38
  160. package/dist/commands/workflow-cli.js +15 -1
  161. package/dist/core/asset/asset-registry.js +3 -1
  162. package/dist/core/asset/asset-spec.js +21 -4
  163. package/dist/core/asset/frontmatter.js +188 -167
  164. package/dist/core/asset/markdown.js +8 -0
  165. package/dist/core/authoring-rules.js +92 -0
  166. package/dist/core/common.js +4 -23
  167. package/dist/core/concurrent.js +10 -1
  168. package/dist/core/config/config-io.js +10 -1
  169. package/dist/core/config/config-migration.js +18 -40
  170. package/dist/core/config/config-schema.js +382 -62
  171. package/dist/core/config/config-types.js +3 -3
  172. package/dist/core/config/config.js +67 -22
  173. package/dist/core/deep-merge.js +38 -0
  174. package/dist/core/errors.js +1 -0
  175. package/dist/core/eval/rank-metrics.js +113 -0
  176. package/dist/core/events.js +4 -7
  177. package/dist/core/improve-types.js +47 -8
  178. package/dist/core/logs-db.js +14 -75
  179. package/dist/core/parse.js +36 -16
  180. package/dist/core/paths.js +18 -18
  181. package/dist/core/standards/resolve-standards-context.js +87 -0
  182. package/dist/core/standards/resolve-stash-standards.js +99 -0
  183. package/dist/core/standards/resolve-type-conventions.js +66 -0
  184. package/dist/core/state/migrations.js +770 -0
  185. package/dist/core/state-db.js +132 -1126
  186. package/dist/core/structured.js +69 -0
  187. package/dist/core/time.js +53 -0
  188. package/dist/core/warn.js +21 -0
  189. package/dist/core/write-source.js +37 -0
  190. package/dist/indexer/db/db.js +259 -769
  191. package/dist/indexer/db/entry-mapper.js +41 -0
  192. package/dist/indexer/db/graph-db.js +129 -86
  193. package/dist/indexer/db/llm-cache.js +2 -2
  194. package/dist/indexer/db/schema.js +516 -0
  195. package/dist/indexer/ensure-index.js +36 -92
  196. package/dist/indexer/feedback/utility-policy.js +75 -0
  197. package/dist/indexer/graph/graph-boost.js +51 -41
  198. package/dist/indexer/graph/graph-extraction.js +207 -4
  199. package/dist/indexer/index-writer-lock.js +18 -11
  200. package/dist/indexer/index-written-assets.js +105 -0
  201. package/dist/indexer/indexer.js +182 -204
  202. package/dist/indexer/passes/dir-staleness.js +114 -0
  203. package/dist/indexer/passes/memory-inference.js +13 -5
  204. package/dist/indexer/passes/metadata.js +20 -0
  205. package/dist/indexer/read-preflight.js +23 -0
  206. package/dist/indexer/search/db-search.js +89 -13
  207. package/dist/indexer/search/fts-query.js +51 -0
  208. package/dist/indexer/search/ranking-contributors.js +95 -9
  209. package/dist/indexer/search/ranking.js +79 -3
  210. package/dist/indexer/search/search-fields.js +6 -0
  211. package/dist/indexer/search/search-source.js +32 -21
  212. package/dist/indexer/search/semantic-status.js +4 -0
  213. package/dist/indexer/walk/matchers.js +9 -0
  214. package/dist/indexer/walk/walker.js +21 -13
  215. package/dist/integrations/agent/builders.js +39 -13
  216. package/dist/integrations/agent/config.js +20 -59
  217. package/dist/integrations/agent/detect.js +9 -0
  218. package/dist/integrations/agent/index.js +3 -19
  219. package/dist/integrations/agent/model-aliases.js +7 -2
  220. package/dist/integrations/agent/profiles.js +7 -1
  221. package/dist/integrations/agent/prompts.js +75 -9
  222. package/dist/integrations/agent/runner-dispatch.js +59 -0
  223. package/dist/integrations/agent/runner.js +13 -9
  224. package/dist/integrations/agent/spawn.js +69 -67
  225. package/dist/integrations/harnesses/claude/agent-builder.js +1 -1
  226. package/dist/integrations/harnesses/claude/index.js +2 -0
  227. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  228. package/dist/integrations/harnesses/index.js +2 -3
  229. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  230. package/dist/integrations/harnesses/opencode/index.js +2 -0
  231. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  232. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  233. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +98 -17
  234. package/dist/integrations/harnesses/types.js +1 -0
  235. package/dist/integrations/session-logs/index.js +16 -0
  236. package/dist/llm/call-ai.js +2 -2
  237. package/dist/llm/client.js +34 -11
  238. package/dist/llm/embedder.js +67 -4
  239. package/dist/llm/embedders/cache.js +3 -1
  240. package/dist/llm/embedders/deterministic.js +66 -0
  241. package/dist/llm/embedders/local.js +73 -3
  242. package/dist/llm/feature-gate.js +16 -15
  243. package/dist/llm/graph-extract.js +67 -44
  244. package/dist/llm/memory-infer-impl.js +138 -0
  245. package/dist/llm/memory-infer.js +1 -127
  246. package/dist/llm/metadata-enhance.js +44 -31
  247. package/dist/llm/structured-call.js +49 -0
  248. package/dist/migrate-storage-node.mjs +8 -0
  249. package/dist/output/context.js +5 -5
  250. package/dist/output/renderers.js +85 -14
  251. package/dist/output/shapes/curate.js +14 -2
  252. package/dist/output/shapes/helpers.js +0 -3
  253. package/dist/output/shapes/passthrough.js +2 -1
  254. package/dist/output/text/helpers.js +29 -1
  255. package/dist/output/text/workflow.js +1 -0
  256. package/dist/registry/providers/skills-sh.js +21 -147
  257. package/dist/registry/providers/static-index.js +15 -157
  258. package/dist/registry/resolve.js +27 -9
  259. package/dist/runtime.js +25 -1
  260. package/dist/scripts/migrate-storage.js +2661 -2369
  261. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +883 -596
  262. package/dist/setup/detect.js +9 -0
  263. package/dist/setup/legacy-config.js +106 -0
  264. package/dist/setup/prompt.js +57 -0
  265. package/dist/setup/providers.js +14 -0
  266. package/dist/setup/registry-stash-loader.js +12 -0
  267. package/dist/setup/semantic-assets.js +124 -0
  268. package/dist/setup/setup.js +52 -1614
  269. package/dist/setup/steps/connection.js +734 -0
  270. package/dist/setup/steps/output.js +31 -0
  271. package/dist/setup/steps/platforms.js +124 -0
  272. package/dist/setup/steps/semantic.js +27 -0
  273. package/dist/setup/steps/sources.js +222 -0
  274. package/dist/setup/steps/stashdir.js +42 -0
  275. package/dist/setup/steps/tasks.js +152 -0
  276. package/dist/sources/include.js +6 -2
  277. package/dist/sources/providers/filesystem.js +0 -1
  278. package/dist/sources/providers/git-install.js +210 -0
  279. package/dist/sources/providers/git-provider.js +234 -0
  280. package/dist/sources/providers/git-stash.js +248 -0
  281. package/dist/sources/providers/git.js +10 -661
  282. package/dist/sources/providers/npm.js +2 -6
  283. package/dist/sources/providers/provider-utils.js +13 -7
  284. package/dist/sources/providers/sync-from-ref.js +9 -1
  285. package/dist/sources/providers/website.js +9 -5
  286. package/dist/sources/website-ingest.js +187 -29
  287. package/dist/sources/wiki-fetchers/registry.js +53 -0
  288. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  289. package/dist/storage/database.js +45 -10
  290. package/dist/storage/managed-db.js +82 -0
  291. package/dist/storage/repositories/canaries-repository.js +107 -0
  292. package/dist/storage/repositories/consolidation-repository.js +38 -0
  293. package/dist/storage/repositories/embeddings-repository.js +72 -0
  294. package/dist/storage/repositories/events-repository.js +187 -0
  295. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  296. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  297. package/dist/storage/repositories/index-db.js +14 -8
  298. package/dist/storage/repositories/proposals-repository.js +220 -0
  299. package/dist/storage/repositories/recombine-repository.js +213 -0
  300. package/dist/storage/repositories/registry-cache.js +93 -0
  301. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  302. package/dist/storage/repositories/task-history-repository.js +93 -0
  303. package/dist/storage/sqlite-pragmas.js +146 -0
  304. package/dist/tasks/backends/cron.js +1 -1
  305. package/dist/tasks/backends/index.js +9 -0
  306. package/dist/tasks/backends/launchd.js +1 -1
  307. package/dist/tasks/backends/schtasks.js +1 -1
  308. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  309. package/dist/tasks/runner.js +15 -13
  310. package/dist/text-import-hook.mjs +0 -0
  311. package/dist/wiki/wiki.js +52 -11
  312. package/dist/workflows/cli.js +1 -0
  313. package/dist/workflows/db.js +3 -4
  314. package/dist/workflows/runtime/runs.js +43 -118
  315. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  316. package/dist/workflows/validate-summary.js +2 -7
  317. package/docs/README.md +69 -18
  318. package/docs/data-and-telemetry.md +5 -4
  319. package/docs/migration/release-notes/0.7.0.md +1 -1
  320. package/docs/migration/release-notes/0.9.0.md +39 -0
  321. package/package.json +10 -10
  322. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  323. package/dist/commands/db-cli.js +0 -23
  324. package/dist/indexer/db/db-backup.js +0 -376
  325. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -0,0 +1,818 @@
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
+ * #609 — recombine / synthesize pass.
6
+ *
7
+ * A whole-corpus synthesis stage that runs AFTER consolidation and is OPT-IN
8
+ * (default disabled via `IMPROVE_PROCESS_DEFAULTS.recombine`). It clusters
9
+ * memories by RELATEDNESS (shared tags / graph entities — NEVER embedding
10
+ * similarity), issues ONE bounded LLM call per cluster to induce a single
11
+ * cross-episodic generalization, and emits the result as a NORMAL pending
12
+ * proposal with frontmatter `type: hypothesis` through the existing proposal
13
+ * queue + quality gate.
14
+ *
15
+ * Two-pass contract: the first pass ONLY ever emits `type: hypothesis`
16
+ * proposals — never a `type: lesson`. Promotion to a lesson happens on a later
17
+ * confirmation run once the same generalization has been re-induced
18
+ * `confirmThreshold` times (#625). The confirmation count is persisted in the
19
+ * `recombine_hypotheses` state.db table (migration 014), keyed by the
20
+ * deterministic `deriveRecombineLessonRef` value so re-induction of the SAME
21
+ * member-set maps back to the SAME row. When the count reaches the threshold,
22
+ * the run emits ONE `type: lesson` promotion proposal through the SAME proposal
23
+ * queue + quality gate (createProposal + validateProposalFrontmatter), NEVER a
24
+ * direct stash write, then marks the row promoted (resetting its count) so it is
25
+ * not re-promoted on every subsequent run. Hypotheses NOT re-induced in a run
26
+ * have their consecutive streak reset (decay-to-zero).
27
+ *
28
+ * NAMESPACE note: the ref stays `lesson:recombined/<slug>-<hash>` for BOTH
29
+ * passes. The ref is the promotion TARGET asset (a lesson in both the hypothesis
30
+ * and promoted states), so re-induction must map to the same ref and the ref
31
+ * cannot encode the proposal type. The hypothesis-vs-lesson distinction is
32
+ * carried ONLY by the proposal frontmatter `type` field. On promotion the prior
33
+ * pending `type: hypothesis` proposal for that ref is superseded (rejected) so
34
+ * the queue never shows two proposals for one ref.
35
+ *
36
+ * A justified null (the LLM determines no defensible generalization exists) is
37
+ * an acceptable outcome: it produces no proposal and records a
38
+ * `recombine_invoked` event with `outcome: 'null_returned'`.
39
+ */
40
+ import { createHash } from "node:crypto";
41
+ import fs from "node:fs";
42
+ import recombineSystemPrompt from "../../assets/prompts/recombine-system.md" with { type: "text" };
43
+ import { assembleAssetFromString } from "../../core/asset/asset-serialize.js";
44
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
45
+ import { resolveStashDir } from "../../core/common.js";
46
+ import { loadConfig } from "../../core/config/config.js";
47
+ import { appendEvent } from "../../core/events.js";
48
+ import { parseEmbeddedJsonResponse } from "../../core/parse.js";
49
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
50
+ import { withStateDbAsync } from "../../core/state-db.js";
51
+ import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
52
+ import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, markRecombineHypothesisPromoted, recordRecombineInduction, } from "../../storage/repositories/recombine-repository.js";
53
+ import { archiveProposal, createProposal, isProposalSkipped, listProposals } from "../proposal/repository.js";
54
+ import { isValidDescription, isValidWhenToUse, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
55
+ import { isConsolidationEligibleMemoryName } from "./consolidate.js";
56
+ import { resolveImproveLlmFn } from "./shared.js";
57
+ const RECOMBINE_SYSTEM_PROMPT = recombineSystemPrompt;
58
+ const DEFAULT_MIN_CLUSTER_SIZE = 3;
59
+ const DEFAULT_MAX_CLUSTERS_PER_RUN = 5;
60
+ // #632 — slots in each run's processed budget reserved for the top TAG clusters
61
+ // when entity clusters are present, so tag-only topics (a topic with a good tag
62
+ // but no extracted graph entity) are never fully starved by entity preference.
63
+ // Entities still take the rest of the budget; whichever kind is short, the other
64
+ // backfills. UNSET of entities (tags-only stash) ignores this and takes top-N tags.
65
+ const RESERVED_TAG_SLOTS = 3;
66
+ // #632 — a tag cluster larger than this is treated as an over-broad project
67
+ // mega-bucket (low-coherence, the bland signal #632 de-emphasizes), so the
68
+ // reserved tag slots prefer TIGHTER clusters at or below this size first.
69
+ const TAG_RESERVE_SOFT_CAP = 20;
70
+ // #632 — default to the UNION of tag + graph-entity relatedness, with entity
71
+ // clusters PREFERRED at selection time (see the rank in buildRelatednessClusters).
72
+ // Entity clustering surfaces coherent, subject-scoped clusters (a tool/subsystem)
73
+ // that the coarse stash-wide tag buckets miss, while tags still cover memories the
74
+ // graph has no entity for. The `entity:` vs `tag:` signature namespaces are
75
+ // independent, so a pure tag cluster's confirmation streak is only re-baselined
76
+ // when its OWN membership changes — which is exactly what the session-capture pool
77
+ // exclusion intends for the telemetry-polluted buckets (re-baselining a noisy
78
+ // cluster's streak is correct, not a regression). A stash with no extracted graph
79
+ // entities falls through to tag-only.
80
+ const DEFAULT_RELATEDNESS_SOURCE = "both";
81
+ /** #625 — re-induction count required before a hypothesis promotes to a lesson. */
82
+ const DEFAULT_CONFIRM_THRESHOLD = 2;
83
+ /**
84
+ * #633 — Jaccard membership-overlap threshold for matching a freshly-induced
85
+ * hypothesis to an existing pending row under the SAME signature. A growing
86
+ * stash drifts the exact member set every run; an overlap >= this lets the
87
+ * confirmation streak keep accumulating under one row instead of resetting to 1.
88
+ */
89
+ const DEFAULT_RECOMBINE_OVERLAP = 0.7;
90
+ // ── Clustering by relatedness (NOT similarity) ────────────────────────────────
91
+ /**
92
+ * #632 — English stopwords that occasionally leak into frontmatter tags
93
+ * (`is`, `the`, `for`, …). They carry no topical signal, so a cluster keyed on
94
+ * one is meaningless. Lowercased; matched case-insensitively.
95
+ */
96
+ const JUNK_STOPWORD_TAGS = new Set([
97
+ "a",
98
+ "an",
99
+ "and",
100
+ "the",
101
+ "to",
102
+ "of",
103
+ "in",
104
+ "on",
105
+ "for",
106
+ "is",
107
+ "are",
108
+ "be",
109
+ "no",
110
+ "not",
111
+ "or",
112
+ "if",
113
+ "it",
114
+ "as",
115
+ "at",
116
+ "by",
117
+ "we",
118
+ "us",
119
+ "do",
120
+ "so",
121
+ "when",
122
+ "then",
123
+ "than",
124
+ "with",
125
+ "from",
126
+ "this",
127
+ "that",
128
+ "uses",
129
+ "use",
130
+ "via",
131
+ ]);
132
+ /**
133
+ * #632 — a tag carries no clustering signal (and must be skipped) when it is
134
+ * purely a number / date / hash / version string, a single char, or a common
135
+ * stopword. Unlike `excludeTags` (a fixed project list), this catches the
136
+ * OPEN-ENDED junk — every new date or commit hash — without config upkeep.
137
+ */
138
+ export function isJunkTag(tag) {
139
+ const t = tag.trim().toLowerCase();
140
+ if (t.length <= 1)
141
+ return true;
142
+ if (JUNK_STOPWORD_TAGS.has(t))
143
+ return true;
144
+ if (/^\d+$/.test(t))
145
+ return true; // pure numbers + dates: 2026, 05, 23, 20260529
146
+ if (/^v?\d+(?:\.\d+)+$/.test(t))
147
+ return true; // versions: 0.8.0, v1.2
148
+ if (/^v\d+$/.test(t))
149
+ return true; // v0, v2
150
+ if (/^[0-9a-f]{4,}$/.test(t) && /\d/.test(t))
151
+ return true; // short hex hashes: 002c624c, 192d
152
+ return false;
153
+ }
154
+ /**
155
+ * #632 — generic extraction-artefact entities the graph routinely emits: session
156
+ * bookkeeping (`session_id`, `session_checkpoint`), structured-log field names
157
+ * (`reason`, `harness`, `structured event log`), and the like. They are
158
+ * stash-wide and carry no topical signal, so an `entity:<norm>` cluster keyed on
159
+ * one is exactly the bland mega-bucket #632 aims to remove. Lowercased; matched
160
+ * against the already-normalised `entity_norm`.
161
+ */
162
+ const JUNK_ENTITY_NORMS = new Set([
163
+ "session",
164
+ "session_id",
165
+ "session_checkpoint",
166
+ "checkpoint",
167
+ "reason",
168
+ "harness",
169
+ "event",
170
+ "event log",
171
+ "structured event",
172
+ "structured event log",
173
+ "timestamp",
174
+ "metadata",
175
+ "status",
176
+ ]);
177
+ /**
178
+ * #632 — an entity carries no clustering signal (and must be skipped) when it is
179
+ * a generic extraction artefact (session / structured-log bookkeeping), a raw
180
+ * filesystem path (absolute paths the extractor lifts verbatim), or the same
181
+ * number / date / hash / version / stopword junk `isJunkTag` rejects. Mirrors
182
+ * `isJunkTag` so the graph relatedness source does not reintroduce the very
183
+ * bland buckets entity clustering is meant to replace. Unlike `excludeEntities`
184
+ * (a fixed user list), this catches the OPEN-ENDED junk without config upkeep.
185
+ */
186
+ export function isJunkEntity(entity) {
187
+ const e = entity.trim().toLowerCase();
188
+ if (e.length <= 1)
189
+ return true;
190
+ if (JUNK_ENTITY_NORMS.has(e))
191
+ return true;
192
+ if (JUNK_STOPWORD_TAGS.has(e))
193
+ return true;
194
+ if (e.includes("/") || e.includes("\\"))
195
+ return true; // raw file paths
196
+ if (/^\d+$/.test(e))
197
+ return true; // pure numbers + dates
198
+ if (/^v?\d+(?:\.\d+)+$/.test(e))
199
+ return true; // versions
200
+ if (/^v\d+$/.test(e))
201
+ return true; // v0, v2
202
+ if (/^[0-9a-f]{4,}$/.test(e) && /\d/.test(e))
203
+ return true; // short hex hashes
204
+ return false;
205
+ }
206
+ /**
207
+ * Build relatedness clusters from the memory pool. Clustering is driven purely
208
+ * by shared tags / graph entities — it MUST NOT use embedding similarity, so
209
+ * textually near-identical memories that share no relatedness signal never
210
+ * cluster together.
211
+ *
212
+ * For `relatednessSource`:
213
+ * - `"tags"` — group by each frontmatter tag.
214
+ * - `"graph"` — group by shared `graph_file_entities.entity_norm`; falls back
215
+ * to tags when the graph table is empty (fail-open).
216
+ * - `"both"` — union of the tag and entity grouping keys.
217
+ *
218
+ * A cluster is a signal whose member set is >= `minClusterSize`. Overlapping
219
+ * clusters are de-duplicated by member-set identity, and the result is RANKED
220
+ * by member-count descending (deterministic alphabetical tiebreak). The
221
+ * `maxClustersPerRun` cap is NOT applied here — call {@link selectClustersForRun}
222
+ * (entity-aware blend) or {@link capClusters} (tags-only) on the result for the
223
+ * processed slice; the full ranked list is retained so the cap-aware decay sweep
224
+ * can tell cap-displacement from corpus absence (#658).
225
+ */
226
+ export function buildRelatednessClusters(entries, opts) {
227
+ // Only consolidation-eligible memories participate (exclude `.derived`).
228
+ const memories = entries.filter((e) => e.entry.type === "memory" && isConsolidationEligibleMemoryName(e.entry.name));
229
+ // signal -> member entries
230
+ const groups = new Map();
231
+ const add = (signal, entry) => {
232
+ const key = signal.trim();
233
+ if (!key)
234
+ return;
235
+ const list = groups.get(key);
236
+ if (list) {
237
+ if (!list.includes(entry))
238
+ list.push(entry);
239
+ }
240
+ else {
241
+ groups.set(key, [entry]);
242
+ }
243
+ };
244
+ const useTags = opts.relatednessSource === "tags" || opts.relatednessSource === "both";
245
+ // Graph relatedness falls open to tags when no entities are available.
246
+ const hasEntities = !!opts.entityByEntryId && opts.entityByEntryId.size > 0;
247
+ const useGraph = (opts.relatednessSource === "graph" || opts.relatednessSource === "both") && hasEntities;
248
+ const tagsFallback = !useTags && opts.relatednessSource === "graph" && !hasEntities;
249
+ // #632 — tags/entities excluded from clustering (applies regardless of
250
+ // source). UNSET/[] leaves tag clustering byte-identical to the pre-#632 path.
251
+ const excludeTags = new Set(opts.excludeTags ?? []);
252
+ // `entity_norm` is always lowercased (graph-dedup.ts), so normalise the
253
+ // user-supplied exclusion list to match — `excludeEntities: ["OpenCode"]`
254
+ // should suppress the stored `opencode` entity (Reviewer A, #632).
255
+ const excludeEntities = new Set((opts.excludeEntities ?? []).map((e) => e.toLowerCase()));
256
+ for (const entry of memories) {
257
+ if (useTags || tagsFallback) {
258
+ for (const tag of entry.entry.tags ?? []) {
259
+ if (excludeTags.has(tag))
260
+ continue;
261
+ if (isJunkTag(tag))
262
+ continue; // #632 — skip numeric/date/hash/version/stopword junk
263
+ add(`tag:${tag}`, entry);
264
+ }
265
+ }
266
+ if (useGraph && opts.entityByEntryId) {
267
+ for (const ent of opts.entityByEntryId.get(entry.id) ?? []) {
268
+ if (excludeEntities.has(ent))
269
+ continue;
270
+ if (isJunkEntity(ent))
271
+ continue; // #632 — skip generic extraction-artefact / path entities
272
+ add(`entity:${ent}`, entry);
273
+ }
274
+ }
275
+ }
276
+ // Keep only groups at or above the minimum cluster size. #632 — when
277
+ // maxClusterSize is set, also SKIP groups strictly larger than the cap so an
278
+ // over-broad bucket never reaches (and starves) the largest-first slice.
279
+ // UNSET = no upper bound = identical to the pre-#632 behaviour.
280
+ let clusters = [];
281
+ for (const [signature, members] of groups) {
282
+ if (members.length < opts.minClusterSize)
283
+ continue;
284
+ if (opts.maxClusterSize != null && members.length > opts.maxClusterSize)
285
+ continue;
286
+ clusters.push({ signature, members });
287
+ }
288
+ // De-duplicate clusters that share the exact same member set (e.g. a tag and
289
+ // an entity that co-occur on the same trio). Keep the first by signature.
290
+ const seenMemberKeys = new Set();
291
+ clusters = clusters.filter((c) => {
292
+ const memberKey = c.members
293
+ .map((m) => m.id)
294
+ .sort((a, b) => a - b)
295
+ .join(",");
296
+ if (seenMemberKeys.has(memberKey))
297
+ return false;
298
+ seenMemberKeys.add(memberKey);
299
+ return true;
300
+ });
301
+ // #632 — rank ENTITY clusters ahead of tag clusters, then largest-first within
302
+ // each kind (deterministic alphabetical tiebreak). A graph entity is an
303
+ // EXTRACTED SUBJECT (a tool / subsystem / component), so it is a far
304
+ // higher-signal cluster key than an auto-tokenized frontmatter tag, whose
305
+ // broadest buckets (`tag:<project>` — e.g. every memory tagged `akm`) are the
306
+ // coarse, bland clusters #632 set out to kill. Largest-first ALONE let those
307
+ // tag mega-buckets fill the `maxClustersPerRun` slice every run and starve the
308
+ // coherent entity clusters this pass produces. Preferring entities keeps tag
309
+ // clustering as the fallback (a stash with no graph entities, or a topic with a
310
+ // tag but no extracted entity, still clusters) while ensuring the better signal
311
+ // wins the cap. The processed slice is chosen by the caller via
312
+ // {@link selectClustersForRun} (NOT here), so the FULL formed set stays
313
+ // available for the cap-aware decay sweep —
314
+ // a cluster displaced by the cap must not be confused with a cluster that
315
+ // vanished from the corpus (#658).
316
+ const entityRank = (sig) => (sig.startsWith("entity:") ? 0 : 1);
317
+ clusters.sort((a, b) => entityRank(a.signature) - entityRank(b.signature) ||
318
+ b.members.length - a.members.length ||
319
+ a.signature.localeCompare(b.signature));
320
+ return clusters;
321
+ }
322
+ /**
323
+ * #658 — apply the `maxClustersPerRun` cap to a largest-first ranked cluster
324
+ * list. Split out from {@link buildRelatednessClusters} so callers retain the
325
+ * full pre-cap set: the clusters BELOW the cap still re-formed this run and must
326
+ * spare their hypotheses from decay (cap-displacement is a SCHEDULING miss, not
327
+ * a substance miss). Callers that only need the processed slice call this; the
328
+ * full ranked list feeds {@link decayUnseenRecombineHypotheses}.
329
+ */
330
+ export function capClusters(ranked, maxClustersPerRun) {
331
+ return ranked.slice(0, Math.max(0, maxClustersPerRun));
332
+ }
333
+ /**
334
+ * #632 — pick the per-run PROCESSED slice from the full ranked list, BLENDING
335
+ * entity and tag clusters so neither starves the other:
336
+ *
337
+ * - No entity clusters → top `maxClustersPerRun` TAG clusters (a tags-only
338
+ * stash is byte-identical to {@link capClusters}).
339
+ * - Entity clusters present → ENTITIES LEAD the budget (the higher-signal key —
340
+ * an extracted subject vs an auto-tokenized filename tag), but RESERVE up to
341
+ * `RESERVED_TAG_SLOTS` for tag clusters so tag-only topics still surface every
342
+ * run. Entities are never starved below one slot when present and the budget
343
+ * allows. Whichever kind is short, the other backfills, so the full budget is
344
+ * always used when enough clusters exist.
345
+ *
346
+ * The reserved tag slots prefer TIGHTER tag clusters (size <= `TAG_RESERVE_SOFT_CAP`,
347
+ * largest-first within that band, then the over-cap buckets): a broad
348
+ * auto-tokenized `tag:<project>` mega-bucket is exactly the bland, low-coherence
349
+ * signal #632 de-emphasizes, so the reserve should not spend its protected slots
350
+ * on the largest tags. Entities themselves stay largest-first — an entity is a
351
+ * coherent subject at any size.
352
+ *
353
+ * Entities lead the returned order. The FULL pre-cap `ranked` list (not this
354
+ * slice) still feeds the cap-aware decay sweep (#658), so a cluster left out of
355
+ * the processed slice this run is NOT decayed as if it vanished.
356
+ */
357
+ export function selectClustersForRun(ranked, maxClustersPerRun) {
358
+ const max = Math.max(0, maxClustersPerRun);
359
+ if (max === 0)
360
+ return [];
361
+ const entities = ranked.filter((c) => c.signature.startsWith("entity:"));
362
+ if (entities.length === 0)
363
+ return ranked.slice(0, max); // tags-only → top-N (capClusters parity)
364
+ const tags = ranked.filter((c) => !c.signature.startsWith("entity:"));
365
+ // Reserve up to RESERVED_TAG_SLOTS for tags, but never below one slot for the
366
+ // leading entities when the budget allows (so a small `maxClustersPerRun` does
367
+ // not silently invert the entity preference).
368
+ const reservedForTags = Math.min(tags.length, RESERVED_TAG_SLOTS, Math.max(0, max - 1));
369
+ const entityTake = Math.min(entities.length, max - reservedForTags);
370
+ const tagTake = Math.min(tags.length, max - entityTake); // tags take their reserve + any slot entities left
371
+ // Prefer tight tags (<= soft cap) over broad mega-buckets for the reserve.
372
+ const reserveTags = [...tags]
373
+ .sort((a, b) => {
374
+ const aOver = a.members.length > TAG_RESERVE_SOFT_CAP ? 1 : 0;
375
+ const bOver = b.members.length > TAG_RESERVE_SOFT_CAP ? 1 : 0;
376
+ return aOver - bOver || b.members.length - a.members.length || a.signature.localeCompare(b.signature);
377
+ })
378
+ .slice(0, tagTake);
379
+ return [...entities.slice(0, entityTake), ...reserveTags];
380
+ }
381
+ // ── Prompt + ref derivation ───────────────────────────────────────────────────
382
+ /** Read a memory body (frontmatter stripped) for the cluster prompt. */
383
+ function readBody(entry) {
384
+ try {
385
+ const raw = fs.readFileSync(entry.filePath, "utf8");
386
+ return parseFrontmatter(raw).content.trim();
387
+ }
388
+ catch {
389
+ return "";
390
+ }
391
+ }
392
+ /** Assemble the per-cluster user prompt fed to the recombine LLM. */
393
+ export function buildClusterPrompt(cluster, standardsContext = "") {
394
+ const lines = [
395
+ `Shared signal: ${cluster.signature}`,
396
+ `Cluster of ${cluster.members.length} related memories:`,
397
+ "",
398
+ ];
399
+ if (standardsContext.trim()) {
400
+ lines.push("Standards to follow (the rulebook for this target):");
401
+ lines.push(standardsContext.trim());
402
+ lines.push("");
403
+ }
404
+ for (const m of cluster.members) {
405
+ lines.push(`[memory:${m.entry.name}]`);
406
+ if (m.entry.description)
407
+ lines.push(`Description: ${m.entry.description}`);
408
+ const body = readBody(m);
409
+ if (body)
410
+ lines.push(body);
411
+ lines.push("");
412
+ }
413
+ lines.push("Induce ONE cross-episodic generalization these memories support, or return an explicit null if none is defensible.");
414
+ return lines.join("\n");
415
+ }
416
+ /**
417
+ * Stable lesson ref for a cluster. The hash of the sorted member refs keeps the
418
+ * ref deterministic across runs (so re-induction maps to the same ref + the
419
+ * content-hash dedup in createProposal suppresses queue churn).
420
+ */
421
+ export function deriveRecombineLessonRef(cluster) {
422
+ const slug = cluster.signature
423
+ .replace(/^(tag|entity):/, "")
424
+ .toLowerCase()
425
+ .replace(/[^a-z0-9-]+/g, "-")
426
+ .replace(/-+/g, "-")
427
+ .replace(/^-|-$/g, "");
428
+ const memberKey = recombineMemberKey(cluster);
429
+ const hash = createHash("sha256").update(memberKey, "utf8").digest("hex").slice(0, 8);
430
+ return `lesson:recombined/${slug || "cluster"}-${hash}`;
431
+ }
432
+ function validatePromotedLessonFrontmatter(ref, frontmatter) {
433
+ const descCheck = isValidDescription(frontmatter.description, ref);
434
+ if (!descCheck.ok)
435
+ return { ok: false, reason: descCheck.reason };
436
+ const whenToUseCheck = isValidWhenToUse(frontmatter.when_to_use, ref);
437
+ if (!whenToUseCheck.ok)
438
+ return { ok: false, reason: whenToUseCheck.reason };
439
+ if (typeof frontmatter.description === "string" &&
440
+ typeof frontmatter.when_to_use === "string" &&
441
+ frontmatter.description.trim().toLowerCase() === frontmatter.when_to_use.trim().toLowerCase()) {
442
+ return { ok: false, reason: "description and when_to_use are identical" };
443
+ }
444
+ return { ok: true };
445
+ }
446
+ /**
447
+ * The membership fingerprint of a cluster: its member entryKeys sorted and
448
+ * joined. Single source of truth shared by {@link deriveRecombineLessonRef}'s
449
+ * hash and the `recombine_hypotheses.member_key` column, so the table key and
450
+ * the ref hash always derive from the SAME member set. Adding/removing one
451
+ * memory yields a different fingerprint → a different ref → a fresh row (the
452
+ * old streak is correctly NOT inherited).
453
+ */
454
+ export function recombineMemberKey(cluster) {
455
+ return cluster.members
456
+ .map((m) => m.entryKey)
457
+ .sort()
458
+ .join("|");
459
+ }
460
+ /** Parse the raw LLM output into a generalization, or `null` for the justified-null path. */
461
+ function parseGeneralization(raw) {
462
+ if (raw === null)
463
+ return null;
464
+ const trimmed = raw.trim();
465
+ if (!trimmed || trimmed.toLowerCase() === "null")
466
+ return null;
467
+ const parsed = parseEmbeddedJsonResponse(trimmed);
468
+ if (parsed === undefined || parsed === null)
469
+ return null;
470
+ if (typeof parsed !== "object")
471
+ return null;
472
+ const obj = parsed;
473
+ const description = typeof obj.description === "string" ? obj.description : "";
474
+ const body = typeof obj.body === "string" ? obj.body : "";
475
+ const when_to_use = typeof obj.when_to_use === "string" ? obj.when_to_use : undefined;
476
+ // An empty object / all-empty fields is treated as a justified null.
477
+ if (!description && !body)
478
+ return null;
479
+ return { description, body, ...(when_to_use ? { when_to_use } : {}) };
480
+ }
481
+ // ── Main entry point ───────────────────────────────────────────────────────────
482
+ export async function akmRecombine(opts) {
483
+ const startMs = Date.now();
484
+ const config = opts.config ?? loadConfig();
485
+ const stashDir = opts.stashDir ?? resolveStashDir();
486
+ const sourceRun = opts.sourceRun ?? `recombine-${startMs}`;
487
+ const eligibilitySource = opts.eligibilitySource ?? "recombine";
488
+ const minClusterSize = opts.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE;
489
+ const maxClustersPerRun = opts.maxClustersPerRun ?? DEFAULT_MAX_CLUSTERS_PER_RUN;
490
+ const relatednessSource = opts.relatednessSource ?? DEFAULT_RELATEDNESS_SOURCE;
491
+ const confirmThreshold = opts.confirmThreshold ?? DEFAULT_CONFIRM_THRESHOLD;
492
+ const warnings = [];
493
+ const finish = (over) => ({
494
+ schemaVersion: 1,
495
+ ok: true,
496
+ clustersFormed: 0,
497
+ proposalsEmitted: 0,
498
+ lessonsPromoted: 0,
499
+ nullsReturned: 0,
500
+ durationMs: Date.now() - startMs,
501
+ warnings,
502
+ ...over,
503
+ });
504
+ // Budget guard: an already-aborted signal short-circuits before any LLM call.
505
+ if (opts.signal?.aborted) {
506
+ return finish({ ok: false, warnings: [...warnings, "aborted-before-start"] });
507
+ }
508
+ // Load the memory pool + (optionally) graph entities from the index.
509
+ let entries = [];
510
+ let entityByEntryId;
511
+ let db;
512
+ try {
513
+ db = openExistingDatabase();
514
+ entries = getAllEntries(db, "memory");
515
+ if (relatednessSource === "graph" || relatednessSource === "both") {
516
+ try {
517
+ entityByEntryId = getEntitiesByEntryIds(db, entries.map((e) => e.id));
518
+ }
519
+ catch {
520
+ // Fail open to tag relatedness.
521
+ entityByEntryId = undefined;
522
+ }
523
+ }
524
+ }
525
+ catch (e) {
526
+ warnings.push(`recombine: failed to open index — ${String(e)}`);
527
+ return finish({ ok: false });
528
+ }
529
+ finally {
530
+ if (db)
531
+ closeDatabase(db);
532
+ }
533
+ // #658 — `rankedClusters` is the FULL set that re-formed this run (ranked,
534
+ // pre-cap); `clusters` is the processed top-`maxClustersPerRun` slice. The
535
+ // decay sweep below uses the full set so a cap-displaced (but present)
536
+ // cluster spares its hypothesis from reset.
537
+ const rankedClusters = buildRelatednessClusters(entries, {
538
+ minClusterSize,
539
+ relatednessSource,
540
+ ...(entityByEntryId ? { entityByEntryId } : {}),
541
+ ...(opts.maxClusterSize != null ? { maxClusterSize: opts.maxClusterSize } : {}),
542
+ ...(opts.excludeTags ? { excludeTags: opts.excludeTags } : {}),
543
+ ...(opts.excludeEntities ? { excludeEntities: opts.excludeEntities } : {}),
544
+ });
545
+ const clusters = selectClustersForRun(rankedClusters, maxClustersPerRun);
546
+ let clustersFormed = 0;
547
+ let proposalsEmitted = 0;
548
+ let lessonsPromoted = 0;
549
+ let nullsReturned = 0;
550
+ const llmFn = opts.recombineLlmFn ??
551
+ resolveImproveLlmFn(config, {
552
+ processKey: "recombine",
553
+ systemPrompt: RECOMBINE_SYSTEM_PROMPT,
554
+ tag: "[recombine]",
555
+ signal: opts.signal,
556
+ activeProfile: opts.improveProfile,
557
+ });
558
+ if (!llmFn) {
559
+ warnings.push("recombine: no LLM configured — skipping");
560
+ return finish({ clustersFormed: 0 });
561
+ }
562
+ // Refs re-induced (defensible generalization passed the quality gate) THIS
563
+ // run — everything else is decayed after the loop.
564
+ const seenThisRun = new Set();
565
+ // Recombine output is knowledge/lesson (non-wiki) → stash authoring
566
+ // standards. Resolved ONCE per run and passed to each cluster prompt.
567
+ const standardsContext = resolveStashStandards(stashDir);
568
+ // #625 — open the confirmation-count store once per run via the ctx seam,
569
+ // reusing a long-lived ctx.db handle when the caller provided one (mirrors
570
+ // proposals.ts). Only handles WE opened are closed by the seam.
571
+ await withStateDbAsync(async (stateDb) => {
572
+ for (const cluster of clusters) {
573
+ if (opts.signal?.aborted) {
574
+ warnings.push("aborted-mid-run");
575
+ break;
576
+ }
577
+ clustersFormed += 1;
578
+ // #9 — promotion is terminal. If this cluster Jaccard-matches a
579
+ // hypothesis that was ALREADY promoted, generating again can at best
580
+ // re-queue a redundant `type: hypothesis` for a settled ref (promotion
581
+ // gates on !alreadyPromoted below), so the LLM output is wasted. Skip the
582
+ // per-cluster call entirely. The decay sweep still spares the row — it
583
+ // matches a present cluster via `presentClusters` (independent of
584
+ // `seenThisRun`), so the promotion record is preserved.
585
+ if (stateDb) {
586
+ const promotedMatch = findMatchingRecombineHypothesis(stateDb, {
587
+ signature: cluster.signature,
588
+ memberKey: recombineMemberKey(cluster),
589
+ minOverlap: DEFAULT_RECOMBINE_OVERLAP,
590
+ });
591
+ if (promotedMatch?.hypothesis_ref &&
592
+ getRecombineHypothesis(stateDb, promotedMatch.hypothesis_ref)?.promoted_at != null) {
593
+ appendEvent({
594
+ eventType: "recombine_invoked",
595
+ ref: promotedMatch.hypothesis_ref,
596
+ metadata: {
597
+ signal: cluster.signature,
598
+ memberCount: cluster.members.length,
599
+ outcome: "skipped_promoted",
600
+ sourceRun,
601
+ },
602
+ }, opts.ctx);
603
+ continue;
604
+ }
605
+ }
606
+ const prompt = buildClusterPrompt(cluster, standardsContext);
607
+ const raw = await llmFn(prompt);
608
+ const generalization = parseGeneralization(raw);
609
+ if (!generalization) {
610
+ nullsReturned += 1;
611
+ appendEvent({
612
+ eventType: "recombine_invoked",
613
+ ref: deriveRecombineLessonRef(cluster),
614
+ metadata: {
615
+ signal: cluster.signature,
616
+ memberCount: cluster.members.length,
617
+ outcome: "null_returned",
618
+ sourceRun,
619
+ },
620
+ }, opts.ctx);
621
+ continue;
622
+ }
623
+ // #633 — the confirmation identity is decoupled from the EXACT member
624
+ // set. We first look for an existing pending hypothesis row under the
625
+ // SAME signature whose membership overlaps this cluster (Jaccard >=
626
+ // threshold) and, if found, REUSE that row's stable ref so a
627
+ // drifting-but-overlapping cluster keeps accumulating its streak under one
628
+ // row instead of spawning a fresh row (count=1) every run. With no match
629
+ // (first induction, or membership drifted past the overlap floor) we fall
630
+ // back to the deterministic member-set ref exactly as before.
631
+ const memberKey = recombineMemberKey(cluster);
632
+ const derivedRef = deriveRecombineLessonRef(cluster);
633
+ const matchedRow = stateDb
634
+ ? findMatchingRecombineHypothesis(stateDb, {
635
+ signature: cluster.signature,
636
+ memberKey,
637
+ minOverlap: DEFAULT_RECOMBINE_OVERLAP,
638
+ })
639
+ : undefined;
640
+ const lessonRef = matchedRow?.hypothesis_ref ?? derivedRef;
641
+ const sourceRefs = cluster.members.map((m) => `memory:${m.entry.name}`);
642
+ const priorRow = stateDb ? getRecombineHypothesis(stateDb, lessonRef) : undefined;
643
+ const alreadyPromoted = priorRow?.promoted_at != null;
644
+ const nextCount = stateDb == null
645
+ ? 0
646
+ : priorRow == null
647
+ ? 1
648
+ : priorRow.last_run === sourceRun
649
+ ? priorRow.consecutive_count
650
+ : priorRow.consecutive_count + 1;
651
+ // Quality gate (always-run): the frontmatter description must be present
652
+ // and non-truncated. Promotion adds the full lesson frontmatter check so
653
+ // `when_to_use` never bypasses validation on the promote=true path.
654
+ const fmCheck = validateProposalFrontmatter({ description: generalization.description });
655
+ if (!fmCheck.ok) {
656
+ appendEvent({
657
+ eventType: "recombine_invoked",
658
+ ref: lessonRef,
659
+ metadata: {
660
+ signal: cluster.signature,
661
+ memberCount: cluster.members.length,
662
+ outcome: "quality_rejected",
663
+ reason: fmCheck.reason,
664
+ sourceRun,
665
+ },
666
+ }, opts.ctx);
667
+ continue;
668
+ }
669
+ const promote = stateDb != null && !alreadyPromoted && nextCount >= confirmThreshold;
670
+ if (promote) {
671
+ const lessonFmCheck = validatePromotedLessonFrontmatter(lessonRef, {
672
+ description: generalization.description,
673
+ when_to_use: generalization.when_to_use,
674
+ });
675
+ if (!lessonFmCheck.ok) {
676
+ appendEvent({
677
+ eventType: "recombine_invoked",
678
+ ref: lessonRef,
679
+ metadata: {
680
+ signal: cluster.signature,
681
+ memberCount: cluster.members.length,
682
+ outcome: "quality_rejected",
683
+ reason: lessonFmCheck.reason,
684
+ sourceRun,
685
+ },
686
+ }, opts.ctx);
687
+ continue;
688
+ }
689
+ }
690
+ // A defensible generalization was produced this run — record it so it is
691
+ // NOT decayed by the unseen sweep below.
692
+ seenThisRun.add(lessonRef);
693
+ // #625/#633 — record the re-induction only AFTER the quality gate passed.
694
+ // Quality-rejected outputs must not advance the confirmation streak.
695
+ const count = stateDb
696
+ ? recordRecombineInduction(stateDb, {
697
+ hypothesisRef: lessonRef,
698
+ signature: cluster.signature,
699
+ memberKey,
700
+ seenAt: new Date().toISOString(),
701
+ run: sourceRun,
702
+ })
703
+ : 0;
704
+ // Promote to a `type: lesson` proposal when the confirmation streak
705
+ // reaches the threshold AND the hypothesis has not already been promoted.
706
+ const proposalType = promote ? "lesson" : "hypothesis";
707
+ const frontmatter = {
708
+ type: proposalType,
709
+ description: generalization.description,
710
+ ...(generalization.when_to_use ? { when_to_use: generalization.when_to_use } : {}),
711
+ source_refs: sourceRefs,
712
+ };
713
+ const content = assembleContent(frontmatter, generalization.body);
714
+ if (promote && stateDb) {
715
+ // Supersede the prior pending `type: hypothesis` proposal for this ref so
716
+ // the queue never shows two proposals for one ref. The promoted lesson
717
+ // proposal has different content (type changed), so content-hash dedup
718
+ // would otherwise let both co-exist.
719
+ for (const stale of listProposals(stashDir, { status: "pending", ref: lessonRef }, opts.ctx)) {
720
+ if (stale.source === "recombine") {
721
+ archiveProposal(stashDir, stale.id, "rejected", "superseded by recombine lesson promotion", opts.ctx);
722
+ }
723
+ }
724
+ }
725
+ const proposalResult = createProposal(stashDir, {
726
+ ref: lessonRef,
727
+ source: "recombine",
728
+ sourceRun,
729
+ payload: { content, frontmatter },
730
+ eligibilitySource,
731
+ // The promotion is a distinct asset (lesson) for the same ref; force
732
+ // past the duplicate-pending guard (the stale hypothesis was just
733
+ // superseded, but force keeps the path robust to ordering).
734
+ ...(promote ? { force: true } : {}),
735
+ }, opts.ctx);
736
+ if (isProposalSkipped(proposalResult)) {
737
+ appendEvent({
738
+ eventType: "recombine_invoked",
739
+ ref: lessonRef,
740
+ metadata: {
741
+ signal: cluster.signature,
742
+ memberCount: cluster.members.length,
743
+ outcome: "skipped",
744
+ skipReason: proposalResult.reason,
745
+ sourceRun,
746
+ },
747
+ }, opts.ctx);
748
+ continue;
749
+ }
750
+ if (promote && stateDb) {
751
+ markRecombineHypothesisPromoted(stateDb, lessonRef, new Date().toISOString());
752
+ lessonsPromoted += 1;
753
+ appendEvent({
754
+ eventType: "recombine_invoked",
755
+ ref: lessonRef,
756
+ metadata: {
757
+ signal: cluster.signature,
758
+ memberCount: cluster.members.length,
759
+ outcome: "promoted",
760
+ proposalId: proposalResult.id,
761
+ confirmationCount: count,
762
+ sourceRun,
763
+ },
764
+ }, opts.ctx);
765
+ }
766
+ else {
767
+ proposalsEmitted += 1;
768
+ appendEvent({
769
+ eventType: "recombine_invoked",
770
+ ref: lessonRef,
771
+ metadata: {
772
+ signal: cluster.signature,
773
+ memberCount: cluster.members.length,
774
+ outcome: "queued",
775
+ proposalId: proposalResult.id,
776
+ confirmationCount: count,
777
+ sourceRun,
778
+ },
779
+ }, opts.ctx);
780
+ }
781
+ }
782
+ // #625 — decay hypotheses NOT re-induced this run (reset their consecutive
783
+ // streak) so confirmation is per-consecutive-run and conservative (AC4).
784
+ // #658 — but a hypothesis whose cluster genuinely re-formed this run and was
785
+ // merely cap-displaced (outside the top-`maxClustersPerRun` slice) must NOT
786
+ // be decayed — that is a scheduling miss, not a substance miss. We pass
787
+ // EVERY cluster that formed this run (the full pre-cap `rankedClusters`) as
788
+ // `presentClusters`; decay spares any row that Jaccard-matches a present
789
+ // cluster under the SAME overlap rule used for re-induction. Only rows with
790
+ // no matching current cluster (the corpus stopped supporting them) decay.
791
+ if (stateDb) {
792
+ const presentClusters = rankedClusters.map((c) => ({
793
+ signature: c.signature,
794
+ memberKey: recombineMemberKey(c),
795
+ }));
796
+ const decayedCount = decayUnseenRecombineHypotheses(stateDb, sourceRun, [...seenThisRun], {
797
+ presentClusters,
798
+ minOverlap: DEFAULT_RECOMBINE_OVERLAP,
799
+ });
800
+ if (decayedCount > 0) {
801
+ appendEvent({
802
+ eventType: "recombine_invoked",
803
+ metadata: { outcome: "decayed", decayedCount, sourceRun },
804
+ }, opts.ctx);
805
+ }
806
+ }
807
+ }, { path: opts.ctx?.dbPath, borrowed: opts.ctx?.db });
808
+ return finish({ clustersFormed, proposalsEmitted, lessonsPromoted, nullsReturned });
809
+ }
810
+ /** Serialize frontmatter + body into a markdown asset string. */
811
+ function assembleContent(frontmatter, body) {
812
+ const fmLines = Object.entries(frontmatter)
813
+ .map(([key, value]) => Array.isArray(value)
814
+ ? `${key}: [${value.map((v) => JSON.stringify(v)).join(", ")}]`
815
+ : `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`)
816
+ .join("\n");
817
+ return assembleAssetFromString(fmLines, body);
818
+ }