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,482 @@
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
+ // ── Deterministic near-duplicate MEMORY dedup (#617) ─────────────────────────
5
+ //
6
+ // A CHEAP, DETERMINISTIC fast path that collapses the obvious duplicates with
7
+ // NO LLM call, run in front of the embedding-clustered LLM consolidation pass
8
+ // (see consolidate.ts). The working stash accumulates near-duplicates faster
9
+ // than the capped/rotating LLM consolidate pass can merge them; this pre-pass
10
+ // clears the unambiguous twins so the (expensive) LLM only ever sees genuinely
11
+ // distinct-but-related memories.
12
+ //
13
+ // Two collapse classes, both safe and reversible (archive + backup at the call
14
+ // site in consolidate.ts; this module only computes the plan and applies the
15
+ // file writes/deletes):
16
+ //
17
+ // 1. `.derived` ↔ origin pairs — a memory-inference `<parent>.derived` child
18
+ // whose normalized body is identical to (or, with embeddings, ≥ the strict
19
+ // cosine threshold of) its origin. Keep the canonical (non-derived) origin;
20
+ // drop the derived variant; preserve the variant's provenance on the
21
+ // canonical (`dedupedFrom`).
22
+ //
23
+ // 2. Content twins — two non-derived memories with identical normalized body
24
+ // hash, or ≥ the strict cosine threshold. Keep the deterministic canonical
25
+ // (lexicographically smallest name); merge the other into it, preserving
26
+ // provenance.
27
+ //
28
+ // Determinism: the plan is a pure function of the on-disk memory set + config.
29
+ // Nothing here reads Date.now()/Math.random(); ordering is by memory name so the
30
+ // canonical choice and op order are stable across runs.
31
+ //
32
+ // Gating: DEFAULT OFF. The pre-pass only runs when `dedup.enabled === true`. The
33
+ // cosine path additionally requires an embedding config; absent embeddings the
34
+ // pass still collapses exact normalized-hash twins. `cosineThreshold` defaults
35
+ // to 0.97 — a strict floor chosen so distinct-but-related memories fall through
36
+ // untouched to the LLM consolidation.
37
+ import { createHash } from "node:crypto";
38
+ import fs from "node:fs";
39
+ import path from "node:path";
40
+ import { parseAssetRef } from "../../core/asset/asset-ref.js";
41
+ import { assembleAssetFromString, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
42
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
43
+ import { warn } from "../../core/warn.js";
44
+ import { cosineSimilarity, embedBatch, resolveEmbeddingModelId } from "../../llm/embedder.js";
45
+ import { getBodyEmbeddings, upsertBodyEmbeddings } from "../../storage/repositories/embeddings-repository.js";
46
+ /** Default strict cosine floor — high enough to skip distinct-but-related memories. */
47
+ export const DEFAULT_DEDUP_COSINE_THRESHOLD = 0.97;
48
+ /**
49
+ * Strip frontmatter from raw memory content, returning the body text trimmed.
50
+ * Case and whitespace are preserved — this is the shared primitive used by
51
+ * both hash wrappers below. Falls back to `raw.trim()` on unparseable
52
+ * frontmatter (consistent with the pre-existing load-time hot guard).
53
+ */
54
+ export function stripFrontmatterBody(raw) {
55
+ try {
56
+ return parseFrontmatter(raw).content.trim();
57
+ }
58
+ catch {
59
+ return raw.trim();
60
+ }
61
+ }
62
+ /**
63
+ * Normalize a memory body for content-twin equality. Strips frontmatter,
64
+ * lowercases, trims, and collapses all runs of whitespace to a single space so
65
+ * trivial reformatting (extra blank lines, trailing spaces, case) does not
66
+ * defeat the hash. Deterministic and pure.
67
+ *
68
+ * Use this for the DEDUP path only (exact-twin detection). For the change-
69
+ * detection / embedding-cache path use `cacheHash` instead.
70
+ */
71
+ export function normalizeMemoryBody(raw) {
72
+ return stripFrontmatterBody(raw).toLowerCase().replace(/\s+/g, " ");
73
+ }
74
+ /**
75
+ * Hash used for content-twin detection: lowercase + whitespace-collapsed body.
76
+ * Two memories that differ only in case or whitespace produce the same hash
77
+ * and are considered identical twins. Use this key for the dedup buckets.
78
+ */
79
+ export function dedupHash(raw) {
80
+ return createHash("sha256").update(normalizeMemoryBody(raw), "utf8").digest("hex");
81
+ }
82
+ /**
83
+ * Hash used for change-detection and the body-embedding cache: case-/whitespace-
84
+ * preserving stripped body. Two memories with the same wording but different
85
+ * casing produce DIFFERENT hashes here, which is intentional — we embed the
86
+ * exact text and cache by its precise content.
87
+ *
88
+ * This is the `content_hash` stored in `body_embeddings` and
89
+ * `consolidation_judged`. Do NOT reuse the `dedupHash` for those tables.
90
+ */
91
+ export function cacheHash(raw) {
92
+ return createHash("sha256").update(stripFrontmatterBody(raw), "utf8").digest("hex");
93
+ }
94
+ /**
95
+ * Load every memory `.md` file (including `.derived` children) from the stash
96
+ * memories directory. Unlike the consolidate loader, this DOES include derived
97
+ * children — the whole point of class 1 is to collapse a derived child into its
98
+ * origin, so both must be visible here.
99
+ */
100
+ export function loadDedupMemories(stashDir) {
101
+ const memoriesDir = path.join(stashDir, "memories");
102
+ if (!fs.existsSync(memoriesDir))
103
+ return [];
104
+ const out = [];
105
+ for (const fname of fs.readdirSync(memoriesDir).sort()) {
106
+ if (!fname.endsWith(".md"))
107
+ continue;
108
+ const name = fname.replace(/\.md$/, "");
109
+ const filePath = path.join(memoriesDir, fname);
110
+ let raw;
111
+ try {
112
+ raw = fs.readFileSync(filePath, "utf8");
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ let fm = {};
118
+ try {
119
+ fm = parseFrontmatter(raw).data ?? {};
120
+ }
121
+ catch {
122
+ fm = {};
123
+ }
124
+ const normalizedBody = normalizeMemoryBody(raw);
125
+ out.push({
126
+ name,
127
+ filePath,
128
+ derived: name.endsWith(".derived"),
129
+ derivedFrom: typeof fm.derivedFrom === "string" ? fm.derivedFrom : undefined,
130
+ raw,
131
+ normalizedBody,
132
+ bodyHash: dedupHash(raw),
133
+ hot: fm.captureMode === "hot",
134
+ });
135
+ }
136
+ return out;
137
+ }
138
+ /** Default cap on the O(n²) cosine-compare pool size. */
139
+ export const DEFAULT_COSINE_CANDIDATE_LIMIT = 500;
140
+ /**
141
+ * Build the deterministic collapse plan. Pure over (memories, similarities,
142
+ * threshold) — `embeddings` is optional; when absent only exact normalized-hash
143
+ * twins are matched.
144
+ *
145
+ * Pass invariants:
146
+ * - A memory is consumed (collapsed) at most once.
147
+ * - Hot (captureMode: hot) memories are never collapsed, as canonical OR
148
+ * variant — user-explicit, only the user retires them.
149
+ * - Class 1 (`.derived` ↔ origin) is matched first so a derived child is
150
+ * always folded into its origin (never the reverse, never twin-matched).
151
+ */
152
+ export function planDedup(memories, opts) {
153
+ const collapses = [];
154
+ const warnings = [];
155
+ const consumed = new Set();
156
+ const threshold = opts.cosineThreshold;
157
+ const byName = new Map();
158
+ for (const m of memories)
159
+ byName.set(m.name, m);
160
+ const sim = (a, b) => {
161
+ if (!opts.embeddings)
162
+ return undefined;
163
+ const ea = opts.embeddings.get(a.name);
164
+ const eb = opts.embeddings.get(b.name);
165
+ if (!ea || !eb)
166
+ return undefined;
167
+ return cosineSimilarity(ea, eb);
168
+ };
169
+ // ── Class 1: `.derived` children ↔ their origin ────────────────────────────
170
+ // Iterate derived children in sorted order (loadDedupMemories sorts), folding
171
+ // each into its origin when near-identical.
172
+ const derived = memories.filter((m) => m.derived).sort((a, b) => a.name.localeCompare(b.name));
173
+ for (const child of derived) {
174
+ if (consumed.has(child.name))
175
+ continue;
176
+ if (child.hot)
177
+ continue;
178
+ const originName = child.derivedFrom ?? child.name.replace(/\.derived$/, "");
179
+ const origin = byName.get(originName);
180
+ if (!origin || consumed.has(origin.name) || origin.derived || origin.hot)
181
+ continue;
182
+ let via;
183
+ let similarity;
184
+ if (child.bodyHash === origin.bodyHash) {
185
+ via = "derived-hash";
186
+ }
187
+ else {
188
+ const s = sim(child, origin);
189
+ if (s !== undefined && s >= threshold) {
190
+ via = "derived-cosine";
191
+ similarity = s;
192
+ }
193
+ }
194
+ if (!via)
195
+ continue;
196
+ collapses.push({ canonical: origin.name, variant: child.name, via, similarity });
197
+ consumed.add(child.name);
198
+ }
199
+ // ── Class 2: content twins among non-derived memories ──────────────────────
200
+ // Bucket by exact normalized-hash first (cheap, transitive, deterministic).
201
+ const remaining = memories
202
+ .filter((m) => !m.derived && !m.hot && !consumed.has(m.name))
203
+ .sort((a, b) => a.name.localeCompare(b.name));
204
+ const hashBuckets = new Map();
205
+ for (const m of remaining) {
206
+ const list = hashBuckets.get(m.bodyHash);
207
+ if (list)
208
+ list.push(m);
209
+ else
210
+ hashBuckets.set(m.bodyHash, [m]);
211
+ }
212
+ for (const bucket of hashBuckets.values()) {
213
+ if (bucket.length < 2)
214
+ continue;
215
+ // Canonical = lexicographically smallest name (already sorted).
216
+ const canonical = bucket[0];
217
+ for (let i = 1; i < bucket.length; i++) {
218
+ const variant = bucket[i];
219
+ if (consumed.has(variant.name))
220
+ continue;
221
+ collapses.push({ canonical: canonical.name, variant: variant.name, via: "twin-hash" });
222
+ consumed.add(variant.name);
223
+ }
224
+ consumed.add(canonical.name); // canonical kept but no longer a twin candidate
225
+ }
226
+ // Cosine twins (only when embeddings are available). O(n²) over the still-
227
+ // unconsumed non-derived pool; deterministic greedy: for each canonical in
228
+ // sorted order, claim every unconsumed later memory whose similarity ≥ floor.
229
+ // The pool is capped at `cosineCandidateLimit` (default 500) to bound the
230
+ // O(n²) cost (~0.1 s at 500; ~3 s at 2.6 k; ~85 s at 13 k). Exact-hash
231
+ // matches above always run over the full pool and are unaffected.
232
+ if (opts.embeddings) {
233
+ const limit = opts.cosineCandidateLimit ?? DEFAULT_COSINE_CANDIDATE_LIMIT;
234
+ const fullPool = remaining.filter((m) => !consumed.has(m.name));
235
+ const pool = fullPool.length > limit ? fullPool.slice(0, limit) : fullPool;
236
+ if (fullPool.length > limit) {
237
+ warnings.push(`dedup: cosine compare pool (${fullPool.length}) exceeds cosineCandidateLimit (${limit}); capping to first ${limit} memories (exact-hash matches unaffected).`);
238
+ }
239
+ for (let i = 0; i < pool.length; i++) {
240
+ const canonical = pool[i];
241
+ if (consumed.has(canonical.name))
242
+ continue;
243
+ for (let j = i + 1; j < pool.length; j++) {
244
+ const variant = pool[j];
245
+ if (consumed.has(variant.name))
246
+ continue;
247
+ const s = sim(canonical, variant);
248
+ if (s !== undefined && s >= threshold) {
249
+ collapses.push({ canonical: canonical.name, variant: variant.name, via: "twin-cosine", similarity: s });
250
+ consumed.add(variant.name);
251
+ }
252
+ }
253
+ consumed.add(canonical.name);
254
+ }
255
+ }
256
+ return { collapses, warnings };
257
+ }
258
+ /**
259
+ * Fold a dropped variant's provenance into the canonical's frontmatter. Appends
260
+ * the variant ref to a `dedupedFrom` list (deduplicated, sorted) and carries
261
+ * any `source`/`sources` references the variant held that the canonical lacks.
262
+ * Pure string→string; never invents timestamps.
263
+ */
264
+ export function applyProvenance(canonicalRaw, variant) {
265
+ let parsed;
266
+ try {
267
+ parsed = parseFrontmatter(canonicalRaw);
268
+ }
269
+ catch {
270
+ // Canonical frontmatter unparseable — leave content untouched (the file is
271
+ // never collapsed AS a canonical when hot/unparseable is the variant, but
272
+ // the canonical itself can still be odd; preserve bytes).
273
+ return canonicalRaw;
274
+ }
275
+ const fm = { ...parsed.data };
276
+ const variantRef = `memory:${variant.name}`;
277
+ const existing = Array.isArray(fm.dedupedFrom)
278
+ ? fm.dedupedFrom.filter((v) => typeof v === "string")
279
+ : typeof fm.dedupedFrom === "string"
280
+ ? [fm.dedupedFrom]
281
+ : [];
282
+ const next = Array.from(new Set([...existing, variantRef])).sort();
283
+ fm.dedupedFrom = next;
284
+ return assembleAssetFromString(serializeFrontmatter(fm), parsed.content);
285
+ }
286
+ /**
287
+ * Apply a collapse plan to disk: rewrite each canonical with merged provenance,
288
+ * delete each variant. Returns counts + consumed refs so the consolidate pass
289
+ * can prune them from the LLM pool. NO LLM call.
290
+ *
291
+ * `onArchive` (optional) is invoked with the variant file path before deletion
292
+ * so the caller can archive/back up exactly as it does for LLM merges.
293
+ */
294
+ export function applyDedupPlan(plan, memories, onArchive) {
295
+ const byName = new Map();
296
+ for (const m of memories)
297
+ byName.set(m.name, m);
298
+ const warnings = [...plan.warnings];
299
+ const consumedRefs = [];
300
+ let collapsed = 0;
301
+ // Accumulate provenance per canonical so multiple variants folding into the
302
+ // same canonical produce a single rewrite.
303
+ const provByCanonical = new Map();
304
+ for (const c of plan.collapses) {
305
+ const list = provByCanonical.get(c.canonical);
306
+ if (list)
307
+ list.push(byName.get(c.variant));
308
+ else
309
+ provByCanonical.set(c.canonical, [byName.get(c.variant)]);
310
+ }
311
+ // 1. Rewrite canonicals with merged provenance.
312
+ for (const [canonicalName, variants] of provByCanonical) {
313
+ const canonical = byName.get(canonicalName);
314
+ if (!canonical)
315
+ continue;
316
+ let content = canonical.raw;
317
+ for (const v of variants.filter((x) => x).sort((a, b) => a.name.localeCompare(b.name))) {
318
+ content = applyProvenance(content, v);
319
+ }
320
+ try {
321
+ fs.writeFileSync(canonical.filePath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
322
+ }
323
+ catch (e) {
324
+ warnings.push(`dedup: failed to rewrite canonical ${canonicalName}: ${String(e)}`);
325
+ }
326
+ }
327
+ // 2. Delete variants (archive first if requested).
328
+ for (const c of plan.collapses) {
329
+ const variant = byName.get(c.variant);
330
+ if (!variant)
331
+ continue;
332
+ if (!fs.existsSync(variant.filePath)) {
333
+ warnings.push(`dedup: variant ${c.variant} already absent — skipping delete.`);
334
+ continue;
335
+ }
336
+ if (onArchive) {
337
+ try {
338
+ onArchive(variant);
339
+ }
340
+ catch {
341
+ // best-effort archival; deletion proceeds
342
+ }
343
+ }
344
+ try {
345
+ fs.unlinkSync(variant.filePath);
346
+ collapsed++;
347
+ consumedRefs.push(`memory:${variant.name}`);
348
+ }
349
+ catch (e) {
350
+ warnings.push(`dedup: failed to delete variant ${c.variant}: ${String(e)}`);
351
+ }
352
+ }
353
+ return { collapsed, consumedRefs, warnings };
354
+ }
355
+ /**
356
+ * Top-level entry point for the consolidate pre-pass. Loads memories, optionally
357
+ * embeds them (only when a cosine path is reachable), plans, and applies the
358
+ * collapse. DEFAULT OFF — returns a no-op result when `config.enabled !== true`.
359
+ *
360
+ * `onArchive` lets the caller archive/back up each dropped variant before
361
+ * deletion (consolidate.ts wires this to its existing archive helper).
362
+ *
363
+ * `signal` (optional): an AbortSignal forwarded from the caller's budget
364
+ * controller. When aborted before the embedding call the function returns a
365
+ * no-op result immediately; the signal is also forwarded into `embedBatch`
366
+ * so a mid-embedding abort is handled cleanly.
367
+ */
368
+ export async function runDeterministicDedup(stashDir, dedupConfig, akmConfig, onArchive, signal,
369
+ /** Optional open state.db handle for the body-embedding cache (WS-3a). */
370
+ stateDb) {
371
+ if (!dedupConfig?.enabled) {
372
+ return { collapsed: 0, consumedRefs: [], warnings: [] };
373
+ }
374
+ if (signal?.aborted) {
375
+ return { collapsed: 0, consumedRefs: [], warnings: ["dedup: aborted before start"] };
376
+ }
377
+ const threshold = dedupConfig.cosineThreshold ?? DEFAULT_DEDUP_COSINE_THRESHOLD;
378
+ const candidateLimit = dedupConfig.cosineCandidateLimit ?? DEFAULT_COSINE_CANDIDATE_LIMIT;
379
+ const memories = loadDedupMemories(stashDir);
380
+ if (memories.length === 0) {
381
+ return { collapsed: 0, consumedRefs: [], warnings: [] };
382
+ }
383
+ // Embed only when embeddings are configured — exact-hash collapse still works
384
+ // without them. Fail-open: any embedding error degrades to hash-only matching.
385
+ // NOTE: embedBatch embeds the case-preserving stripped body (cacheHash domain),
386
+ // not the lowercase dedupHash body, so dedup cosine and the body_embeddings
387
+ // cache share the same canonical embedding input.
388
+ let embeddings;
389
+ if (akmConfig.embedding) {
390
+ try {
391
+ const eligible = memories.filter((m) => !m.hot);
392
+ // Use the case-preserving stripped body for embeddings (matching cacheHash
393
+ // canonical input) so the embedding cache can be shared with consolidate.
394
+ const modelId = resolveEmbeddingModelId(akmConfig.embedding);
395
+ // WS-3a: body-embedding cache — look up all content_hashes in one query,
396
+ // embed only the misses, then upsert the new vectors in one transaction.
397
+ const contentHashes = eligible.map((m) => cacheHash(m.raw));
398
+ const hashToName = new Map();
399
+ for (let i = 0; i < eligible.length; i++) {
400
+ hashToName.set(contentHashes[i], eligible[i].name);
401
+ }
402
+ let cachedVecs = new Map();
403
+ if (stateDb) {
404
+ try {
405
+ cachedVecs = getBodyEmbeddings(stateDb, contentHashes, modelId);
406
+ }
407
+ catch {
408
+ // Fail open: cache read errors degrade to full embed.
409
+ cachedVecs = new Map();
410
+ }
411
+ }
412
+ const missIndices = [];
413
+ const missTexts = [];
414
+ for (let i = 0; i < eligible.length; i++) {
415
+ const hash = contentHashes[i];
416
+ if (!cachedVecs.has(hash)) {
417
+ missIndices.push(i);
418
+ missTexts.push(stripFrontmatterBody(eligible[i].raw) || eligible[i].name);
419
+ }
420
+ }
421
+ let missVecs = [];
422
+ if (missTexts.length > 0) {
423
+ missVecs = await embedBatch(missTexts, akmConfig.embedding, signal);
424
+ // Upsert new vectors into cache.
425
+ if (stateDb && missVecs.length === missTexts.length) {
426
+ try {
427
+ const toUpsert = missIndices.map((idx, pos) => ({
428
+ contentHash: contentHashes[idx],
429
+ embedding: missVecs[pos],
430
+ modelId,
431
+ }));
432
+ upsertBodyEmbeddings(stateDb, toUpsert);
433
+ }
434
+ catch {
435
+ // Fail open: cache write errors are non-fatal.
436
+ }
437
+ }
438
+ }
439
+ // Assemble the full embeddings map (cache hits + freshly embedded misses).
440
+ if (missVecs.length === missTexts.length || cachedVecs.size > 0) {
441
+ embeddings = new Map();
442
+ // Add cache hits.
443
+ for (let i = 0; i < eligible.length; i++) {
444
+ const hash = contentHashes[i];
445
+ const cached = cachedVecs.get(hash);
446
+ if (cached)
447
+ embeddings.set(eligible[i].name, cached);
448
+ }
449
+ // Add freshly embedded misses.
450
+ for (let pos = 0; pos < missIndices.length; pos++) {
451
+ const idx = missIndices[pos];
452
+ const vec = missVecs[pos];
453
+ if (vec)
454
+ embeddings.set(eligible[idx].name, vec);
455
+ }
456
+ }
457
+ }
458
+ catch {
459
+ embeddings = undefined;
460
+ }
461
+ }
462
+ const plan = planDedup(memories, { cosineThreshold: threshold, embeddings, cosineCandidateLimit: candidateLimit });
463
+ if (plan.collapses.length === 0) {
464
+ return { collapsed: 0, consumedRefs: [], warnings: plan.warnings };
465
+ }
466
+ // Sanity: every variant ref must be a parseable memory ref before we touch
467
+ // disk (defends against a malformed name slipping through).
468
+ for (const c of plan.collapses) {
469
+ try {
470
+ parseAssetRef(`memory:${c.variant}`);
471
+ parseAssetRef(`memory:${c.canonical}`);
472
+ }
473
+ catch {
474
+ plan.warnings.push(`dedup: unparseable ref in collapse ${c.canonical} ← ${c.variant} — dropping op.`);
475
+ }
476
+ }
477
+ const result = applyDedupPlan(plan, memories, (v) => onArchive?.(v.filePath, v.name));
478
+ if (result.collapsed > 0) {
479
+ warn(`[consolidate] deterministic dedup collapsed ${result.collapsed} near-duplicate memor${result.collapsed === 1 ? "y" : "ies"} (no LLM).`);
480
+ }
481
+ return result;
482
+ }
@@ -0,0 +1,202 @@
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
+ * Pure content-repair + quality-validation stages for `akm distill`.
6
+ *
7
+ * Extracted verbatim from the inline body of `akmDistill` so each normalization
8
+ * pass is an independently testable unit. Every function is a pure transform of
9
+ * `(content, inputRef) → content | findings` with no I/O — logic is
10
+ * byte-identical to the pre-extraction inline code. The lesson-path guard
11
+ * (`effectiveProposalKind !== "knowledge"`) stays in the caller; these helpers
12
+ * assume the lesson path.
13
+ */
14
+ import { assembleAssetFromString } from "../../../core/asset/asset-serialize.js";
15
+ import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
16
+ import { repairTruncatedDescription } from "../../../core/text-truncation.js";
17
+ import { detectDoubleFrontmatter, isValidDescription, isValidWhenToUse, } from "../../proposal/validators/proposal-quality-validators.js";
18
+ /**
19
+ * Auto-repair missing frontmatter fields before hard-failing. Small models
20
+ * frequently produce a good lesson body but omit the YAML header entirely.
21
+ * Rather than discarding valid content, we extract description/when_to_use
22
+ * from the body and prepend the required frontmatter block.
23
+ *
24
+ * IMPORTANT: We do NOT synthesise placeholder strings here. If the body
25
+ * does not contain text that passes the post-LLM validators
26
+ * (`isValidDescription` / `isValidWhenToUse`), we leave the field missing
27
+ * and let the lesson lint reject the proposal as `validation_failed`.
28
+ * Emitting placeholders like `"Lesson distilled from <ref>"` or
29
+ * `"When working with <slug>"` is what produced the systematic broken
30
+ * proposals observed across 323 archived rejections.
31
+ */
32
+ export function autoRepairLessonFrontmatter(content, inputRef) {
33
+ const parsed = parseFrontmatter(content);
34
+ const fm = (parsed.data ?? {});
35
+ const missingDesc = typeof fm.description !== "string" || !fm.description.trim();
36
+ const missingWtu = typeof fm.when_to_use !== "string" || !fm.when_to_use.trim();
37
+ if (!missingDesc && !missingWtu)
38
+ return content;
39
+ const body = parsed.content.trim();
40
+ // Strip markdown formatting tokens from a line so extracted text is clean.
41
+ const stripMd = (l) => l
42
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
43
+ .replace(/\*([^*]+)\*/g, "$1")
44
+ .replace(/`([^`]+)`/g, "$1")
45
+ .replace(/^[#*\->_]+\s*/, "")
46
+ .replace(/:\s*$/, "")
47
+ .trim();
48
+ // Skip lines that look like YAML field assignments (key: value) or frontmatter delimiters.
49
+ // These appear when the LLM leaks frontmatter content into the body, causing
50
+ // auto-repair to produce description: "description: Key Takeaways".
51
+ const isYamlLike = (l) => /^---/.test(l) || /^[a-z_]+:\s/i.test(l);
52
+ const bodyLines = body.split("\n").map(stripMd);
53
+ // Extract description: first body line that BOTH looks like prose AND
54
+ // passes isValidDescription. If nothing qualifies, leave the field
55
+ // missing — the lint pass will reject the proposal cleanly.
56
+ let descLine;
57
+ for (const l of bodyLines) {
58
+ if (isYamlLike(l))
59
+ continue;
60
+ if (l.length <= 10 || l.length >= 400)
61
+ continue;
62
+ if (isValidDescription(l, inputRef).ok) {
63
+ descLine = l;
64
+ break;
65
+ }
66
+ }
67
+ // Extract when_to_use: a line starting with "When" / "Use when" / "Apply when"
68
+ // that ALSO passes isValidWhenToUse (rejects circular fallbacks).
69
+ let wtuLine;
70
+ for (const l of bodyLines) {
71
+ if (!/^(when |use when|apply when)/i.test(l))
72
+ continue;
73
+ if (l.length >= 400)
74
+ continue;
75
+ if (isValidWhenToUse(l, inputRef).ok) {
76
+ wtuLine = l;
77
+ break;
78
+ }
79
+ }
80
+ const repairedFm = {
81
+ ...fm,
82
+ ...(missingDesc && descLine ? { description: descLine } : {}),
83
+ ...(missingWtu && wtuLine ? { when_to_use: wtuLine } : {}),
84
+ };
85
+ const fmLines = Object.entries(repairedFm)
86
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
87
+ .join("\n");
88
+ // Only rewrite content if we actually have at least one field to write.
89
+ // Otherwise leave the original content for the lint pass to reject.
90
+ if (Object.keys(repairedFm).length > 0) {
91
+ return assembleAssetFromString(fmLines, body);
92
+ }
93
+ return content;
94
+ }
95
+ /**
96
+ * Description ↔ when_to_use auto-swap normalization (recover ~93% of
97
+ * qwen-9b's `^when\b/i` rejections at zero LLM cost). When the LLM emits
98
+ * a conditional-framed description ("When X happens, do Y") and the
99
+ * when_to_use field looks like a declarative description (or is empty),
100
+ * the two fields are mis-fielded — exactly what `isValidDescription`'s
101
+ * error message says ("that pattern belongs in when_to_use"). We swap
102
+ * them and revalidate; the swap is committed only if BOTH fields pass
103
+ * their respective validators afterwards. If revalidation still fails,
104
+ * we fall through returning the original content (swapped: 0).
105
+ */
106
+ export function autoSwapDescriptionWhenToUse(content, inputRef) {
107
+ const parsedSwap = parseFrontmatter(content);
108
+ const fmSwap = (parsedSwap.data ?? {});
109
+ const descRaw = typeof fmSwap.description === "string" ? fmSwap.description.trim() : "";
110
+ const wtuRaw = typeof fmSwap.when_to_use === "string" ? fmSwap.when_to_use.trim() : "";
111
+ const descStartsConditional = /^(when|if)\b/i.test(descRaw);
112
+ const wtuStartsConditional = /^(when|if)\b/i.test(wtuRaw);
113
+ if (descStartsConditional && !wtuStartsConditional && wtuRaw.length > 0) {
114
+ // Try the swap and revalidate. The when_to_use validator requires the
115
+ // value not match `/^when working with\b/i` (the circular fallback) —
116
+ // a real description rarely does, so this usually passes.
117
+ const swappedDescCheck = isValidDescription(wtuRaw, inputRef);
118
+ const swappedWtuCheck = isValidWhenToUse(descRaw, inputRef);
119
+ if (swappedDescCheck.ok && swappedWtuCheck.ok) {
120
+ const swappedFm = {
121
+ ...fmSwap,
122
+ description: wtuRaw,
123
+ when_to_use: descRaw,
124
+ };
125
+ const swappedFmLines = Object.entries(swappedFm)
126
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
127
+ .join("\n");
128
+ return { content: assembleAssetFromString(swappedFmLines, parsedSwap.content), swapped: 1 };
129
+ }
130
+ }
131
+ return { content, swapped: 0 };
132
+ }
133
+ /**
134
+ * Post-generation truncation repair (#556): if the LLM sliced the
135
+ * description mid-sentence, deterministically complete it from its own text
136
+ * / the lesson body BEFORE the lint + quality validators run. No-op
137
+ * (byte-identical) for already-complete descriptions, so this never alters
138
+ * a valid proposal.
139
+ */
140
+ export function repairLessonDescriptionTruncation(content) {
141
+ const parsedRepair = parseFrontmatter(content);
142
+ const fmRepair = (parsedRepair.data ?? {});
143
+ const descRepairRaw = typeof fmRepair.description === "string" ? fmRepair.description : "";
144
+ if (!descRepairRaw)
145
+ return content;
146
+ const repaired = repairTruncatedDescription(descRepairRaw, parsedRepair.content);
147
+ if (repaired === descRepairRaw)
148
+ return content;
149
+ const repairedFmLines = Object.entries({ ...fmRepair, description: repaired })
150
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
151
+ .join("\n");
152
+ return assembleAssetFromString(repairedFmLines, parsedRepair.content);
153
+ }
154
+ /**
155
+ * Additional quality validators that run only on lessons whose lesson-lint
156
+ * pass was clean. lesson-lint checks "field is present and non-empty"; these
157
+ * reject the systematic failure modes observed across 323 archived rejected
158
+ * proposals:
159
+ * - description is a body fragment, section heading, or placeholder
160
+ * - when_to_use is the circular "When working with <ref>" fallback
161
+ * - description == when_to_use (LLM duplicated a single sentence)
162
+ * - body contains a second pseudo-frontmatter block
163
+ */
164
+ export function collectLessonQualityFindings(content, inputRef) {
165
+ const findings = [];
166
+ const parsedQC = parseFrontmatter(content);
167
+ const fmQC = (parsedQC.data ?? {});
168
+ const descCheck = isValidDescription(fmQC.description, inputRef);
169
+ if (!descCheck.ok) {
170
+ findings.push({
171
+ kind: "invalid-description",
172
+ field: "description",
173
+ message: `Distilled lesson for ${inputRef} has an invalid description: ${descCheck.reason}.`,
174
+ });
175
+ }
176
+ const wtuCheck = isValidWhenToUse(fmQC.when_to_use, inputRef);
177
+ if (!wtuCheck.ok) {
178
+ findings.push({
179
+ kind: "invalid-when_to_use",
180
+ field: "when_to_use",
181
+ message: `Distilled lesson for ${inputRef} has an invalid when_to_use: ${wtuCheck.reason}.`,
182
+ });
183
+ }
184
+ // description and when_to_use must say different things.
185
+ if (descCheck.ok &&
186
+ wtuCheck.ok &&
187
+ typeof fmQC.description === "string" &&
188
+ typeof fmQC.when_to_use === "string" &&
189
+ fmQC.description.trim().toLowerCase() === fmQC.when_to_use.trim().toLowerCase()) {
190
+ findings.push({
191
+ kind: "description-equals-when_to_use",
192
+ field: "description",
193
+ message: `Distilled lesson for ${inputRef} has identical description and when_to_use.`,
194
+ });
195
+ }
196
+ // Double-frontmatter / pseudo-frontmatter pollution in the body.
197
+ const dfm = detectDoubleFrontmatter(content);
198
+ if (dfm) {
199
+ findings.push({ kind: dfm.kind, field: "body", message: `Distilled lesson for ${inputRef}: ${dfm.message}` });
200
+ }
201
+ return findings;
202
+ }