akm-cli 0.7.5 → 0.8.0-rc.10

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 (300) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +192 -2
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2569 -1449
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +110 -0
  12. package/dist/commands/agent-support.js +68 -0
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +130 -534
  15. package/dist/commands/consolidate.js +2122 -0
  16. package/dist/commands/curate.js +44 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +660 -0
  19. package/dist/commands/distill.js +1075 -77
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +43 -0
  22. package/dist/commands/events.js +5 -23
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +477 -0
  28. package/dist/commands/health.js +1302 -0
  29. package/dist/commands/help/help-accept.md +12 -0
  30. package/dist/commands/help/help-improve.md +69 -0
  31. package/dist/commands/help/help-proposals.md +18 -0
  32. package/dist/commands/help/help-propose.md +17 -0
  33. package/dist/commands/help/help-reject.md +11 -0
  34. package/dist/commands/history.js +54 -46
  35. package/dist/commands/improve-auto-accept.js +97 -0
  36. package/dist/commands/improve-cli.js +217 -0
  37. package/dist/commands/improve-profiles.js +166 -0
  38. package/dist/commands/improve-result-file.js +167 -0
  39. package/dist/commands/improve.js +2373 -0
  40. package/dist/commands/info.js +5 -2
  41. package/dist/commands/init.js +50 -2
  42. package/dist/commands/installed-stashes.js +102 -139
  43. package/dist/commands/knowledge.js +136 -0
  44. package/dist/commands/lint/agent-linter.js +49 -0
  45. package/dist/commands/lint/base-linter.js +479 -0
  46. package/dist/commands/lint/command-linter.js +49 -0
  47. package/dist/commands/lint/default-linter.js +16 -0
  48. package/dist/commands/lint/env-key-rules.js +154 -0
  49. package/dist/commands/lint/index.js +196 -0
  50. package/dist/commands/lint/knowledge-linter.js +16 -0
  51. package/dist/commands/lint/markdown-insertion.js +343 -0
  52. package/dist/commands/lint/memory-linter.js +61 -0
  53. package/dist/commands/lint/registry.js +36 -0
  54. package/dist/commands/lint/skill-linter.js +45 -0
  55. package/dist/commands/lint/task-linter.js +50 -0
  56. package/dist/commands/lint/types.js +4 -0
  57. package/dist/commands/lint/workflow-linter.js +56 -0
  58. package/dist/commands/lint.js +4 -0
  59. package/dist/commands/migration-help.js +5 -2
  60. package/dist/commands/proposal.js +67 -12
  61. package/dist/commands/propose.js +86 -31
  62. package/dist/commands/reflect.js +1091 -73
  63. package/dist/commands/registry-cli.js +150 -0
  64. package/dist/commands/registry-search.js +5 -2
  65. package/dist/commands/remember-cli.js +257 -0
  66. package/dist/commands/remember.js +69 -6
  67. package/dist/commands/schema-repair.js +203 -0
  68. package/dist/commands/search.js +115 -14
  69. package/dist/commands/secret.js +173 -0
  70. package/dist/commands/self-update.js +3 -0
  71. package/dist/commands/show.js +148 -25
  72. package/dist/commands/source-add.js +17 -45
  73. package/dist/commands/source-clone.js +3 -0
  74. package/dist/commands/source-manage.js +14 -19
  75. package/dist/commands/tasks.js +437 -0
  76. package/dist/commands/url-checker.js +42 -0
  77. package/dist/core/action-contributors.js +28 -0
  78. package/dist/core/asset-ref.js +17 -2
  79. package/dist/core/asset-registry.js +12 -17
  80. package/dist/core/asset-serialize.js +88 -0
  81. package/dist/core/asset-spec.js +67 -1
  82. package/dist/core/common.js +182 -0
  83. package/dist/core/concurrent.js +25 -0
  84. package/dist/core/config-io.js +347 -0
  85. package/dist/core/config-migration.js +622 -0
  86. package/dist/core/config-schema.js +534 -0
  87. package/dist/core/config-sources.js +108 -0
  88. package/dist/core/config-types.js +4 -0
  89. package/dist/core/config-walker.js +337 -0
  90. package/dist/core/config.js +364 -981
  91. package/dist/core/errors.js +42 -20
  92. package/dist/core/events.js +91 -138
  93. package/dist/core/file-lock.js +104 -0
  94. package/dist/core/frontmatter.js +75 -8
  95. package/dist/core/lesson-lint.js +3 -0
  96. package/dist/core/markdown.js +20 -0
  97. package/dist/core/memory-belief.js +62 -0
  98. package/dist/core/memory-contradiction-detect.js +274 -0
  99. package/dist/core/memory-improve.js +806 -0
  100. package/dist/core/parse.js +158 -0
  101. package/dist/core/paths.js +280 -14
  102. package/dist/core/proposal-quality-validators.js +380 -0
  103. package/dist/core/proposal-validators.js +69 -0
  104. package/dist/core/proposals.js +512 -42
  105. package/dist/core/state-db.js +1068 -0
  106. package/dist/core/text-truncation.js +107 -0
  107. package/dist/core/time.js +54 -0
  108. package/dist/core/tty.js +59 -0
  109. package/dist/core/warn.js +64 -1
  110. package/dist/core/write-source.js +3 -0
  111. package/dist/indexer/db-backup.js +391 -0
  112. package/dist/indexer/db-search.js +163 -254
  113. package/dist/indexer/db.js +975 -103
  114. package/dist/indexer/ensure-index.js +64 -0
  115. package/dist/indexer/file-context.js +3 -0
  116. package/dist/indexer/graph-boost.js +376 -101
  117. package/dist/indexer/graph-db.js +391 -0
  118. package/dist/indexer/graph-dedup.js +95 -0
  119. package/dist/indexer/graph-extraction.js +550 -124
  120. package/dist/indexer/index-context.js +4 -0
  121. package/dist/indexer/indexer.js +523 -301
  122. package/dist/indexer/llm-cache.js +52 -0
  123. package/dist/indexer/manifest.js +3 -0
  124. package/dist/indexer/matchers.js +167 -160
  125. package/dist/indexer/memory-inference.js +152 -74
  126. package/dist/indexer/metadata-contributors.js +29 -0
  127. package/dist/indexer/metadata.js +275 -196
  128. package/dist/indexer/path-resolver.js +92 -0
  129. package/dist/indexer/project-context.js +192 -0
  130. package/dist/indexer/ranking-contributors.js +331 -0
  131. package/dist/indexer/ranking.js +81 -0
  132. package/dist/indexer/search-fields.js +5 -9
  133. package/dist/indexer/search-hit-enrichers.js +111 -0
  134. package/dist/indexer/search-source.js +44 -10
  135. package/dist/indexer/semantic-status.js +6 -17
  136. package/dist/indexer/staleness-detect.js +447 -0
  137. package/dist/indexer/usage-events.js +12 -9
  138. package/dist/indexer/walker.js +28 -0
  139. package/dist/integrations/agent/builders.js +135 -0
  140. package/dist/integrations/agent/config.js +122 -230
  141. package/dist/integrations/agent/detect.js +3 -0
  142. package/dist/integrations/agent/index.js +7 -13
  143. package/dist/integrations/agent/model-aliases.js +55 -0
  144. package/dist/integrations/agent/profiles.js +70 -5
  145. package/dist/integrations/agent/prompts.js +214 -80
  146. package/dist/integrations/agent/runner.js +151 -0
  147. package/dist/integrations/agent/sdk-runner.js +126 -0
  148. package/dist/integrations/agent/spawn.js +118 -23
  149. package/dist/integrations/github.js +3 -0
  150. package/dist/integrations/lockfile.js +32 -69
  151. package/dist/integrations/session-logs/index.js +69 -0
  152. package/dist/integrations/session-logs/inline-refs.js +35 -0
  153. package/dist/integrations/session-logs/pre-filter.js +152 -0
  154. package/dist/integrations/session-logs/providers/claude-code.js +282 -0
  155. package/dist/integrations/session-logs/providers/opencode.js +258 -0
  156. package/dist/integrations/session-logs/types.js +4 -0
  157. package/dist/llm/call-ai.js +62 -0
  158. package/dist/llm/client.js +77 -124
  159. package/dist/llm/embedder.js +20 -29
  160. package/dist/llm/embedders/cache.js +3 -7
  161. package/dist/llm/embedders/local.js +42 -1
  162. package/dist/llm/embedders/remote.js +20 -8
  163. package/dist/llm/embedders/types.js +3 -7
  164. package/dist/llm/feature-gate.js +95 -48
  165. package/dist/llm/graph-extract.js +676 -70
  166. package/dist/llm/index-passes.js +44 -29
  167. package/dist/llm/memory-infer.js +77 -71
  168. package/dist/llm/metadata-enhance.js +42 -29
  169. package/dist/llm/prompts/extract-session.md +80 -0
  170. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  171. package/dist/output/cli-hints-full.md +292 -0
  172. package/dist/output/cli-hints-short.md +66 -0
  173. package/dist/output/cli-hints.js +7 -320
  174. package/dist/output/context.js +60 -8
  175. package/dist/output/renderers.js +300 -257
  176. package/dist/output/shapes/curate.js +56 -0
  177. package/dist/output/shapes/distill.js +10 -0
  178. package/dist/output/shapes/env-list.js +19 -0
  179. package/dist/output/shapes/events.js +11 -0
  180. package/dist/output/shapes/helpers.js +424 -0
  181. package/dist/output/shapes/history.js +7 -0
  182. package/dist/output/shapes/passthrough.js +102 -0
  183. package/dist/output/shapes/proposal-accept.js +7 -0
  184. package/dist/output/shapes/proposal-diff.js +7 -0
  185. package/dist/output/shapes/proposal-list.js +7 -0
  186. package/dist/output/shapes/proposal-producer.js +11 -0
  187. package/dist/output/shapes/proposal-reject.js +7 -0
  188. package/dist/output/shapes/proposal-show.js +7 -0
  189. package/dist/output/shapes/registry-search.js +6 -0
  190. package/dist/output/shapes/registry.js +30 -0
  191. package/dist/output/shapes/search.js +6 -0
  192. package/dist/output/shapes/secret-list.js +19 -0
  193. package/dist/output/shapes/show.js +6 -0
  194. package/dist/output/shapes/vault-list.js +19 -0
  195. package/dist/output/shapes.js +51 -516
  196. package/dist/output/text/add.js +6 -0
  197. package/dist/output/text/clone.js +6 -0
  198. package/dist/output/text/config.js +6 -0
  199. package/dist/output/text/curate.js +6 -0
  200. package/dist/output/text/distill.js +7 -0
  201. package/dist/output/text/enable-disable.js +7 -0
  202. package/dist/output/text/events.js +10 -0
  203. package/dist/output/text/feedback.js +6 -0
  204. package/dist/output/text/helpers.js +1039 -0
  205. package/dist/output/text/history.js +7 -0
  206. package/dist/output/text/import.js +6 -0
  207. package/dist/output/text/index.js +6 -0
  208. package/dist/output/text/info.js +6 -0
  209. package/dist/output/text/init.js +6 -0
  210. package/dist/output/text/list.js +6 -0
  211. package/dist/output/text/proposal-producer.js +8 -0
  212. package/dist/output/text/proposal.js +11 -0
  213. package/dist/output/text/registry-commands.js +11 -0
  214. package/dist/output/text/registry.js +30 -0
  215. package/dist/output/text/remember.js +6 -0
  216. package/dist/output/text/remove.js +6 -0
  217. package/dist/output/text/save.js +6 -0
  218. package/dist/output/text/search.js +6 -0
  219. package/dist/output/text/show.js +6 -0
  220. package/dist/output/text/update.js +6 -0
  221. package/dist/output/text/upgrade.js +6 -0
  222. package/dist/output/text/vault.js +16 -0
  223. package/dist/output/text/wiki.js +15 -0
  224. package/dist/output/text/workflow.js +14 -0
  225. package/dist/output/text.js +44 -1092
  226. package/dist/registry/build-index.js +3 -0
  227. package/dist/registry/create-provider-registry.js +3 -0
  228. package/dist/registry/factory.js +4 -1
  229. package/dist/registry/origin-resolve.js +3 -0
  230. package/dist/registry/providers/index.js +3 -0
  231. package/dist/registry/providers/skills-sh.js +71 -50
  232. package/dist/registry/providers/static-index.js +53 -48
  233. package/dist/registry/providers/types.js +3 -24
  234. package/dist/registry/resolve.js +11 -16
  235. package/dist/registry/types.js +3 -0
  236. package/dist/scripts/migrate-storage.js +17750 -0
  237. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  238. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  239. package/dist/setup/detect.js +3 -0
  240. package/dist/setup/ripgrep-install.js +3 -0
  241. package/dist/setup/ripgrep-resolve.js +3 -0
  242. package/dist/setup/setup.js +775 -37
  243. package/dist/setup/steps.js +3 -15
  244. package/dist/sources/include.js +3 -0
  245. package/dist/sources/provider-factory.js +5 -12
  246. package/dist/sources/provider.js +3 -20
  247. package/dist/sources/providers/filesystem.js +19 -23
  248. package/dist/sources/providers/git.js +138 -21
  249. package/dist/sources/providers/index.js +3 -0
  250. package/dist/sources/providers/install-types.js +3 -13
  251. package/dist/sources/providers/npm.js +3 -4
  252. package/dist/sources/providers/provider-utils.js +3 -0
  253. package/dist/sources/providers/sync-from-ref.js +3 -11
  254. package/dist/sources/providers/tar-utils.js +3 -0
  255. package/dist/sources/providers/website.js +18 -22
  256. package/dist/sources/resolve.js +3 -0
  257. package/dist/sources/types.js +3 -0
  258. package/dist/sources/website-ingest.js +7 -0
  259. package/dist/tasks/backends/cron.js +203 -0
  260. package/dist/tasks/backends/exec-utils.js +28 -0
  261. package/dist/tasks/backends/index.js +24 -0
  262. package/dist/tasks/backends/launchd-template.xml +19 -0
  263. package/dist/tasks/backends/launchd.js +187 -0
  264. package/dist/tasks/backends/schtasks-template.xml +29 -0
  265. package/dist/tasks/backends/schtasks.js +215 -0
  266. package/dist/tasks/parser.js +211 -0
  267. package/dist/tasks/resolveAkmBin.js +87 -0
  268. package/dist/tasks/runner.js +458 -0
  269. package/dist/tasks/schedule.js +227 -0
  270. package/dist/tasks/schema.js +15 -0
  271. package/dist/tasks/validator.js +62 -0
  272. package/dist/version.js +3 -0
  273. package/dist/wiki/index-template.md +12 -0
  274. package/dist/wiki/ingest-workflow-template.md +54 -0
  275. package/dist/wiki/log-template.md +8 -0
  276. package/dist/wiki/schema-template.md +61 -0
  277. package/dist/wiki/wiki-templates.js +15 -0
  278. package/dist/wiki/wiki.js +13 -61
  279. package/dist/workflows/authoring.js +8 -25
  280. package/dist/workflows/cli.js +3 -0
  281. package/dist/workflows/db.js +140 -10
  282. package/dist/workflows/document-cache.js +3 -10
  283. package/dist/workflows/parser.js +3 -0
  284. package/dist/workflows/renderer.js +11 -3
  285. package/dist/workflows/runs.js +77 -92
  286. package/dist/workflows/schema.js +3 -0
  287. package/dist/workflows/scope-key.js +3 -0
  288. package/dist/workflows/validator.js +4 -8
  289. package/dist/workflows/workflow-template.md +24 -0
  290. package/docs/README.md +10 -2
  291. package/docs/data-and-telemetry.md +225 -0
  292. package/docs/migration/release-notes/0.7.0.md +1 -1
  293. package/docs/migration/release-notes/0.7.5.md +2 -2
  294. package/docs/migration/release-notes/0.8.0.md +48 -0
  295. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  296. package/package.json +30 -12
  297. package/.github/LICENSE +0 -374
  298. package/dist/commands/install-audit.js +0 -381
  299. package/dist/commands/vault.js +0 -328
  300. package/dist/templates/wiki-templates.js +0 -100
@@ -1,3 +1,6 @@
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/.
1
4
  /**
2
5
  * `akm distill <ref>` — feedback distillation into lesson proposals (#228).
3
6
  *
@@ -11,8 +14,10 @@
11
14
  * # Architectural seams
12
15
  *
13
16
  * - **Single bounded in-tree LLM call.** Wrapped in {@link tryLlmFeature}
14
- * under the `feedback_distillation` gate (v1 spec §14). The wrapper
15
- * enforces the 30 s hard timeout and converts disable / throw / timeout
17
+ * under the `distill` gate (v1 spec §14; 0.8.0 unified the orchestration
18
+ * and LLM-call gates under `processes.distill.enabled`). The wrapper
19
+ * enforces a hard timeout (default 600s / 10 min — overridable via
20
+ * `opts.timeoutMs`) and converts disable / throw / timeout
16
21
  * into a `null` return from `fn`, which we treat as a graceful
17
22
  * "skipped" outcome (exit 0, no proposal, `distill_invoked` event with
18
23
  * `outcome: "skipped"`).
@@ -46,17 +51,50 @@
46
51
  * be invoked from CI / automation without spinning up an agent harness.
47
52
  */
48
53
  import fs from "node:fs";
54
+ import path from "node:path";
49
55
  import { parseAssetRef } from "../core/asset-ref";
50
- import { resolveStashDir } from "../core/common";
51
- import { loadConfig } from "../core/config";
56
+ import { assembleAssetFromString } from "../core/asset-serialize";
57
+ import { resolveStashDir, timestampForFilename } from "../core/common";
58
+ import { getDefaultLlmConfig, loadConfig } from "../core/config";
52
59
  import { ConfigError, UsageError } from "../core/errors";
53
60
  import { appendEvent, readEvents } from "../core/events";
54
61
  import { parseFrontmatter } from "../core/frontmatter";
55
62
  import { lintLessonContent } from "../core/lesson-lint";
56
- import { createProposal } from "../core/proposals";
57
- import { lookup as indexerLookup } from "../indexer/indexer";
58
- import { chatCompletion } from "../llm/client";
59
- import { tryLlmFeature } from "../llm/feature-gate";
63
+ import { stripMarkdownFences } from "../core/markdown";
64
+ import { createProposal, isProposalSkipped, listProposals, } from "../core/proposals";
65
+ import { warnVerbose } from "../core/warn";
66
+ import { resolveAssetPath } from "../indexer/path-resolver";
67
+ import { chatCompletion, parseEmbeddedJsonResponse } from "../llm/client";
68
+ import { isLlmFeatureEnabled, tryLlmFeature } from "../llm/feature-gate";
69
+ import { assessMemoryKnowledgePromotionCandidate, deriveKnowledgeRef } from "./distill-promotion-policy";
70
+ import { akmSearch } from "./search";
71
+ /**
72
+ * Asset-ref types that `akm distill` structurally refuses as inputs.
73
+ *
74
+ * Distill *produces* lessons from non-lesson sources (memory, skill, knowledge,
75
+ * etc.). Calling distill on an existing `lesson:*` ref would derive
76
+ * `lesson:lesson-<name>-lesson-lesson` (double `-lesson` suffix) — the
77
+ * recursive-ref defect observed across 323 archived rejected proposals.
78
+ *
79
+ * The runtime gate inside {@link akmDistill} still refuses these inputs
80
+ * defensively (returning an `outcome: "skipped"` envelope with `skipReason:
81
+ * "recursive_lesson_input"`). This exported set is the planner-side companion:
82
+ * callers that schedule distill attempts (e.g. `akm improve`'s distill queue)
83
+ * import it so refs of these types never enter the queue in the first place.
84
+ *
85
+ * Source of truth: this set drives the gate in `akmDistill` and is consumed
86
+ * directly by the improve planner. Adding a new structurally-refused input
87
+ * type means updating this constant — the planner picks the change up for
88
+ * free.
89
+ */
90
+ export const DISTILL_REFUSED_INPUT_TYPES = new Set(["lesson"]);
91
+ /**
92
+ * Returns true when `type` is structurally refused as an input by
93
+ * {@link akmDistill}. See {@link DISTILL_REFUSED_INPUT_TYPES}.
94
+ */
95
+ export function isDistillRefusedInputType(type) {
96
+ return DISTILL_REFUSED_INPUT_TYPES.has(type);
97
+ }
60
98
  // ── Lesson-ref derivation ───────────────────────────────────────────────────
61
99
  /** Derive the proposed lesson ref from the input ref. See module docblock. */
62
100
  export function deriveLessonRef(inputRef) {
@@ -74,53 +112,508 @@ export function deriveLessonRef(inputRef) {
74
112
  .replace(/^-|-$/g, "");
75
113
  return `lesson:${safe}-lesson`;
76
114
  }
115
+ // ── Content quality validators ──────────────────────────────────────────────
116
+ //
117
+ // The actual implementations now live in `core/proposal-quality-validators.ts`
118
+ // so the same checks run inside `runProposalValidators` on `proposal accept`.
119
+ // We re-export the public-facing helpers here so existing imports
120
+ // (`from "../src/commands/distill"`) continue to resolve.
121
+ import { detectDoubleFrontmatter, isValidDescription, isValidWhenToUse } from "../core/proposal-quality-validators";
122
+ export { detectDoubleFrontmatter, isValidDescription, isValidWhenToUse };
77
123
  // ── Prompt assembly ─────────────────────────────────────────────────────────
78
- const SYSTEM_PROMPT = [
124
+ const LESSON_SYSTEM_PROMPT = [
79
125
  "You are the akm `distill` distiller.",
80
126
  "Given an asset and recent feedback events about it, produce a single",
81
127
  "concise *lesson* an agent should remember next time it works on this",
82
128
  "asset's domain.",
83
129
  "",
84
- "Output MUST be a complete markdown file with YAML frontmatter:",
85
- " ---",
86
- " description: <one-line summary of what the lesson teaches>",
87
- " when_to_use: <one-line trigger that should make a caller apply it>",
88
- " ---",
130
+ "YOUR RESPONSE MUST START EXACTLY WITH `---` ON THE VERY FIRST LINE.",
131
+ "DO NOT output any prose, explanation, or code fences before or after.",
132
+ "",
133
+ "Required output format copy this structure exactly:",
134
+ "---",
135
+ "description: <one complete sentence (ending with `.`) summarising what the lesson teaches>",
136
+ "when_to_use: <one complete sentence describing the concrete trigger condition>",
137
+ "---",
138
+ "",
139
+ "<lesson body — plain markdown, 1–3 short paragraphs of practical guidance>",
140
+ "",
141
+ "## description field (MANDATORY)",
142
+ "- A single complete sentence in present tense, 80-200 chars, NO markdown.",
143
+ "- Self-contained: a reviewer must understand the lesson from this field alone.",
144
+ '- DO NOT start with "When ", "If ", or a connector word — that belongs in when_to_use.',
145
+ '- DO NOT copy a section heading ("Key takeaways", "For example", "Key pitfalls").',
146
+ "- DO NOT begin with a numbered list marker, code fence, or markdown heading.",
147
+ "",
148
+ 'GOOD: "Always validate ref existence before promoting a memory to knowledge; missing refs surface as silent 404s during accept."',
149
+ 'BAD: "Key pitfalls"',
150
+ 'BAD: "When working with the akm CLI"',
151
+ 'BAD: "For example, you might..."',
152
+ 'BAD: "1. Check the file"',
89
153
  "",
90
- " <lesson body, plain markdown, 1–3 short paragraphs>",
154
+ "RULES:",
155
+ "- `when_to_use` MUST be a complete sentence describing a concrete trigger. Never write `When working with <asset-name>` — that is circular and useless.",
156
+ "- `description` and `when_to_use` MUST differ from each other.",
157
+ "- The lesson body MUST be non-empty markdown prose. Do NOT restate `description:` or `when_to_use:` inside the body (no `**description:** ...` or `**when_to_use:** ...` lines — the frontmatter is the only place those keys belong).",
158
+ "- Do NOT emit a second `---` fence after the opening frontmatter — there are exactly two `---` lines in the output, both belonging to the single frontmatter block at the top.",
159
+ "- Do NOT reproduce the source asset verbatim — distil what a caller needs to know.",
160
+ "- Output ONLY the lesson file. No preamble, no code fences, no trailing prose.",
161
+ ].join("\n");
162
+ const KNOWLEDGE_SYSTEM_PROMPT = [
163
+ "You are the akm `distill` distiller.",
164
+ "Given an asset and recent feedback events about it, produce a concise",
165
+ "*knowledge* markdown document capturing the durable, reusable facts.",
166
+ "Prefer stable guidance over narrative recap.",
167
+ "",
168
+ "YOUR RESPONSE MUST START EXACTLY WITH `---` ON THE VERY FIRST LINE.",
169
+ "DO NOT output any prose, explanation, or code fences before or after.",
170
+ "",
171
+ "Required output format:",
172
+ "---",
173
+ "description: <one-line summary of the knowledge asset>",
174
+ "tags: [<tag1>, <tag2>]",
175
+ "---",
176
+ "",
177
+ "# <Title>",
178
+ "",
179
+ "<body — structured markdown, durable facts only>",
91
180
  "",
92
- "Both `description` and `when_to_use` MUST be non-empty single-line strings.",
93
- "Output ONLY the lesson file contents no prose, no fences, no preamble.",
181
+ "RULES:",
182
+ "- `description` MUST be a non-empty single-line string.",
183
+ "- Include a meaningful markdown body with a `# Title` heading.",
184
+ "- Output ONLY the knowledge file. No preamble, no code fences, no trailing prose.",
94
185
  ].join("\n");
95
- /** Pure: build the user-prompt body. Exported for tests. */
186
+ // ── Structured-output schemas (responseSchema lift) ─────────────────────────
187
+ //
188
+ // PR 1 of the asset-writers decision (see knowledge:projects/akm/
189
+ // asset-writers-investigation/00-synthesis): on providers that honour
190
+ // `response_format: json_schema`, ask the LLM for a typed JSON object and
191
+ // re-assemble the markdown locally. The previous "emit raw markdown with
192
+ // embedded frontmatter" path remains as a fallback for providers that ignore
193
+ // the schema (and for the `chat` test seam, which is wired to return strings
194
+ // today). Shape-level rejection codes — MALFORMED_FRONTMATTER_BLOCK,
195
+ // FRONTMATTER_NOT_OBJECT, INVALID_YAML, UNBALANCED_CODE_FENCE — become
196
+ // unreachable on the structured path. Content-quality validators
197
+ // (isValidDescription / isValidWhenToUse) keep firing post-assembly because
198
+ // the LLM still controls the string contents of typed fields.
199
+ /**
200
+ * JSON Schema for structured lesson distillation. Mirrors the LESSON_SYSTEM_PROMPT
201
+ * frontmatter contract. Required: description, when_to_use, body. Optional:
202
+ * tags (string array) so providers that volunteer categorisation hints survive
203
+ * the round-trip without being rejected as additionalProperties.
204
+ */
205
+ export const DISTILL_LESSON_JSON_SCHEMA = {
206
+ type: "object",
207
+ required: ["description", "when_to_use", "body"],
208
+ additionalProperties: false,
209
+ properties: {
210
+ description: {
211
+ type: "string",
212
+ minLength: 10,
213
+ description: "Single complete sentence (80-200 chars) summarising what the lesson teaches. No markdown, no leading 'When'/'If'.",
214
+ },
215
+ when_to_use: {
216
+ type: "string",
217
+ minLength: 10,
218
+ description: "Single complete sentence describing the concrete trigger condition for the lesson.",
219
+ },
220
+ body: {
221
+ type: "string",
222
+ minLength: 1,
223
+ description: "Lesson body — plain markdown, 1-3 short paragraphs of practical guidance.",
224
+ },
225
+ tags: {
226
+ type: "array",
227
+ items: { type: "string" },
228
+ description: "Optional tag list. Empty array is allowed; the post-processor drops it if empty.",
229
+ },
230
+ },
231
+ };
232
+ /**
233
+ * JSON Schema for structured knowledge distillation. Mirrors the
234
+ * KNOWLEDGE_SYSTEM_PROMPT contract. Required: description, body. Optional:
235
+ * tags, sources.
236
+ */
237
+ export const DISTILL_KNOWLEDGE_JSON_SCHEMA = {
238
+ type: "object",
239
+ required: ["description", "body"],
240
+ additionalProperties: false,
241
+ properties: {
242
+ description: {
243
+ type: "string",
244
+ minLength: 1,
245
+ description: "One-line summary of the knowledge asset.",
246
+ },
247
+ body: {
248
+ type: "string",
249
+ minLength: 1,
250
+ description: "Knowledge body — structured markdown with a `# Title` heading and durable facts only.",
251
+ },
252
+ tags: {
253
+ type: "array",
254
+ items: { type: "string" },
255
+ description: "Optional tag list. Empty array is allowed; the post-processor drops it if empty.",
256
+ },
257
+ sources: {
258
+ type: "array",
259
+ items: { type: "string" },
260
+ description: "Optional list of source refs the knowledge was distilled from.",
261
+ },
262
+ },
263
+ };
264
+ /**
265
+ * Assemble a markdown asset from a structured-output payload. Returns `null`
266
+ * when the payload is missing required fields — the caller then falls through
267
+ * to the prompt-contract markdown path. We deliberately do NOT validate
268
+ * content quality here (isValidDescription / isValidWhenToUse run downstream
269
+ * on the assembled content); this helper only catches shape-level emptiness
270
+ * that the schema may not have rejected (e.g. a provider that ignored
271
+ * `minLength` but still returned the field).
272
+ */
273
+ export function assembleStructuredDistillMarkdown(payload, kind) {
274
+ if (payload === null || typeof payload !== "object")
275
+ return null;
276
+ const description = typeof payload.description === "string" ? payload.description.trim() : "";
277
+ const body = typeof payload.body === "string" ? payload.body.trim() : "";
278
+ if (description.length === 0 || body.length === 0)
279
+ return null;
280
+ const fm = { description };
281
+ if (kind === "lesson") {
282
+ const whenToUse = typeof payload.when_to_use === "string" ? payload.when_to_use.trim() : "";
283
+ if (whenToUse.length === 0)
284
+ return null;
285
+ fm.when_to_use = whenToUse;
286
+ }
287
+ if (Array.isArray(payload.tags)) {
288
+ const tags = payload.tags.filter((t) => typeof t === "string" && t.trim().length > 0);
289
+ if (tags.length > 0)
290
+ fm.tags = tags;
291
+ }
292
+ if (kind === "knowledge" && Array.isArray(payload.sources)) {
293
+ const sources = payload.sources.filter((s) => typeof s === "string" && s.trim().length > 0);
294
+ if (sources.length > 0)
295
+ fm.sources = sources;
296
+ }
297
+ const fmLines = Object.entries(fm)
298
+ .map(([k, v]) => {
299
+ if (Array.isArray(v))
300
+ return `${k}: [${v.map((s) => JSON.stringify(s)).join(", ")}]`;
301
+ return `${k}: ${JSON.stringify(v)}`;
302
+ })
303
+ .join("\n");
304
+ return assembleAssetFromString(fmLines, body);
305
+ }
306
+ function validateKnowledgeContent(content, inputRef) {
307
+ const findings = [];
308
+ const parsed = parseFrontmatter(content);
309
+ if (parsed.content.trim().length === 0) {
310
+ findings.push({
311
+ kind: "missing-body",
312
+ field: "body",
313
+ message: `Distilled knowledge for ${inputRef} must include a non-empty markdown body.`,
314
+ });
315
+ }
316
+ // Knowledge proposals don't strictly require a description, but if one is
317
+ // present it must be a real summary — not a placeholder like `---` or a
318
+ // truncated heading. Without this check, distill can land knowledge assets
319
+ // with `description: ---` (observed in the wild when the LLM has nothing
320
+ // meaningful to say about a session-checkpoint memory).
321
+ const fm = (parsed.data ?? {});
322
+ if (fm.description !== undefined) {
323
+ // Knowledge can legitimately mention the topic name in its description, so
324
+ // suppress the ref-restatement heuristic that's tuned for lesson assets.
325
+ const descCheck = isValidDescription(fm.description, inputRef, { skipRefTailCheck: true });
326
+ if (!descCheck.ok) {
327
+ findings.push({
328
+ kind: "invalid-description",
329
+ field: "description",
330
+ message: `Distilled knowledge for ${inputRef} has an invalid description: ${descCheck.reason}.`,
331
+ });
332
+ }
333
+ }
334
+ // Double-frontmatter pollution shows up in knowledge too — the LLM sometimes
335
+ // re-emits the source asset's frontmatter inside its own response, leaving
336
+ // two `---`-delimited blocks back-to-back.
337
+ const dfm = detectDoubleFrontmatter(content);
338
+ if (dfm) {
339
+ findings.push({
340
+ kind: dfm.kind,
341
+ field: "body",
342
+ message: `Distilled knowledge for ${inputRef}: ${dfm.message}`,
343
+ });
344
+ }
345
+ return findings;
346
+ }
347
+ /**
348
+ * Pure: build the user-prompt body. Exported for tests.
349
+ *
350
+ * D-3 (#371): restructures the feedback section from raw JSON event lines into
351
+ * a Reflexion-style verbal contrast (`## What worked` / `## What failed`).
352
+ * The verbal format allows LLMs to use feedback as gradient signal rather than
353
+ * just metadata — capturing the +8% AlfWorld lift from arXiv:2303.11366 and
354
+ * the contrast-based rule-learning gain from ExpeL arXiv:2308.10144.
355
+ */
96
356
  export function buildDistillPrompt(input) {
97
357
  const lines = [];
98
358
  lines.push(`Asset ref: ${input.inputRef}`);
99
359
  lines.push("");
100
360
  lines.push("Asset content:");
101
361
  if (input.assetContent) {
362
+ const body = input.assetContent.trim().slice(0, 3000);
102
363
  lines.push("```");
103
- lines.push(input.assetContent.trim());
364
+ lines.push(body);
104
365
  lines.push("```");
105
366
  }
106
367
  else {
107
368
  lines.push("(asset is not currently indexed; distil from feedback signal alone)");
108
369
  }
109
370
  lines.push("");
110
- lines.push("Recent feedback events (most recent last):");
111
371
  if (input.feedback.length === 0) {
112
- lines.push("(no feedback events recorded — distil from the asset itself)");
372
+ lines.push("Recent feedback: (no feedback events recorded — distil from the asset itself)");
113
373
  }
114
374
  else {
375
+ // D-3 (#371): verbal contrast format for Reflexion verbal-gradient lift.
376
+ // Partition events into positive ("what worked") and negative ("what failed").
377
+ const positive = [];
378
+ const negative = [];
379
+ const neutral = [];
115
380
  for (const event of input.feedback) {
116
- const meta = event.metadata ? ` ${JSON.stringify(event.metadata)}` : "";
117
- lines.push(`- ${event.ts} ${event.eventType}${meta}`);
381
+ const meta = (event.metadata ?? {});
382
+ const signal = typeof meta.signal === "string" ? meta.signal : undefined;
383
+ const reason = typeof meta.reason === "string" ? meta.reason : "";
384
+ const note = typeof meta.note === "string" ? meta.note : "";
385
+ const detail = reason || note;
386
+ const line = detail ? `- ${event.ts}: ${detail}` : `- ${event.ts}: feedback received`;
387
+ if (signal === "positive")
388
+ positive.push(line);
389
+ else if (signal === "negative")
390
+ negative.push(line);
391
+ else
392
+ neutral.push(`- ${event.ts} ${event.eventType}${event.metadata ? ` ${JSON.stringify(event.metadata)}` : ""}`);
393
+ }
394
+ if (positive.length > 0 || negative.length > 0) {
395
+ if (positive.length > 0) {
396
+ lines.push("## What worked");
397
+ for (const l of positive)
398
+ lines.push(l);
399
+ lines.push("");
400
+ }
401
+ if (negative.length > 0) {
402
+ lines.push("## What failed");
403
+ for (const l of negative)
404
+ lines.push(l);
405
+ lines.push("");
406
+ }
407
+ if (neutral.length > 0) {
408
+ lines.push("## Other signals");
409
+ for (const l of neutral)
410
+ lines.push(l);
411
+ lines.push("");
412
+ }
413
+ }
414
+ else {
415
+ // No positive/negative signals — fall back to the pre-D3 flat format for
416
+ // non-feedback event types (e.g. reflect_invoked, distill_invoked).
417
+ lines.push("Recent feedback events (most recent last):");
418
+ for (const event of input.feedback) {
419
+ const meta = event.metadata ? ` ${JSON.stringify(event.metadata)}` : "";
420
+ lines.push(`- ${event.ts} ${event.eventType}${meta}`);
421
+ }
422
+ lines.push("");
423
+ }
424
+ }
425
+ if (input.rejectedProposals && input.rejectedProposals.length > 0) {
426
+ lines.push("");
427
+ lines.push("Previously rejected proposals for this ref (Reflexion context):");
428
+ lines.push("The following proposals were already reviewed and rejected. " +
429
+ "Your new proposal MUST differ meaningfully in approach, framing, or evidence.");
430
+ for (const rp of input.rejectedProposals) {
431
+ lines.push(`- Rejection reason: ${rp.reason}`);
432
+ if (rp.contentPreview) {
433
+ lines.push(` Content preview: ${rp.contentPreview.slice(0, 200).replace(/\n/g, " ")}`);
434
+ }
435
+ }
436
+ }
437
+ if (input.proposalKind === "knowledge") {
438
+ lines.push("Produce the knowledge markdown file now. Start your response with `---` on the first line, followed by a `description:` field whose value is a 1-sentence summary (20–400 chars). Never use placeholder values like `---`, `tbd`, `n/a`, or a single dash. If the source has nothing meaningful to summarize, do NOT produce a proposal — return an empty response instead. The frontmatter block ends with a second `---` line; do not emit any additional `---` fences in the body.");
439
+ }
440
+ else {
441
+ lines.push("Produce the lesson markdown file now. Start your response with `---` on the first line, followed by `description:` and `when_to_use:` fields. Both must be real one-sentence summaries (20–400 chars) — never placeholder values like `---`, `tbd`, or `n/a`. The frontmatter block ends with a second `---` line; do not emit any additional `---` fences in the body.");
442
+ }
443
+ return lines.join("\n");
444
+ }
445
+ // ── D-4 / #390: Top-3 similar lessons retrieval ──────────────────────────────
446
+ /**
447
+ * Default implementation: use akmSearch to find top-N similar lesson assets.
448
+ * Returns empty array when search fails or returns no results.
449
+ * Requires embedding configured for semantic similarity; degrades gracefully.
450
+ */
451
+ async function fetchTopSimilarLessons(query, n, _stashDir) {
452
+ try {
453
+ const result = await akmSearch({
454
+ query,
455
+ type: "lesson",
456
+ limit: n,
457
+ skipLogging: true,
458
+ eventSource: "improve",
459
+ });
460
+ const hits = result?.hits ?? [];
461
+ return hits
462
+ .filter((h) => "path" in h && typeof h.path === "string")
463
+ .slice(0, n)
464
+ .map((h) => {
465
+ let content = "";
466
+ try {
467
+ if (h.path && fs.existsSync(h.path)) {
468
+ content = fs.readFileSync(h.path, "utf8");
469
+ }
470
+ }
471
+ catch {
472
+ /* best-effort */
473
+ }
474
+ return { ref: h.ref, content };
475
+ });
476
+ }
477
+ catch {
478
+ return [];
479
+ }
480
+ }
481
+ // ── LLM-as-judge quality gate (P2-B) ────────────────────────────────────────
482
+ /**
483
+ * D-4 / #390: Build the LLM-as-judge prompt.
484
+ *
485
+ * When similarLessons are provided (top-3 by embedding similarity), they are
486
+ * included in the context so the judge can lower the score for near-duplicates.
487
+ * Voyager arXiv:2305.16291 — skill library admission requires similarity check
488
+ * against the existing library. A-MEM arXiv:2502.12110 — new notes are checked
489
+ * against existing notes before linking.
490
+ */
491
+ function buildJudgePrompt(lessonContent, sourceContent, similarLessons) {
492
+ const lines = [
493
+ "You are evaluating a proposed lesson asset for an akm knowledge base.",
494
+ "",
495
+ "Score this lesson on each criterion from 1 (poor) to 5 (excellent):",
496
+ "1. NOVELTY: Does the lesson add information not already present in the source asset?",
497
+ "2. ACTIONABILITY: Can an agent follow this lesson without additional context?",
498
+ "3. NON-REDUNDANCY: Is this lesson meaningfully different from what the source already says?",
499
+ "",
500
+ "Source asset content:",
501
+ "```",
502
+ sourceContent.slice(0, 2000),
503
+ "```",
504
+ ];
505
+ if (similarLessons && similarLessons.length > 0) {
506
+ lines.push("");
507
+ lines.push("Existing similar lessons (top-3 by similarity). Rate lower if the proposed lesson is substantially similar to any of these:");
508
+ for (const sl of similarLessons) {
509
+ lines.push(`\nExisting lesson ref: ${sl.ref}`);
510
+ lines.push("```");
511
+ lines.push(sl.content.slice(0, 500));
512
+ lines.push("```");
118
513
  }
119
514
  }
120
515
  lines.push("");
121
- lines.push("Produce the lesson markdown file now.");
516
+ lines.push("Proposed lesson content:");
517
+ lines.push("```");
518
+ lines.push(lessonContent.slice(0, 1000));
519
+ lines.push("```");
520
+ lines.push("");
521
+ lines.push('Return ONLY valid JSON, no prose: {"score": <average score 1-5 as float>, "reason": "<one sentence>"}');
122
522
  return lines.join("\n");
123
523
  }
524
+ /**
525
+ * Run the LLM-as-judge quality gate on a proposal's content.
526
+ *
527
+ * Exported so reflect.ts can apply the same gate to reflect proposals (R-5 / #374).
528
+ * Gated by the flag name `lesson_quality_gate` (or its alias
529
+ * `proposal_quality_gate`) via {@link isLlmFeatureEnabled} — which reads
530
+ * `profiles.improve.default.processes.distill.qualityGate.enabled` (and the
531
+ * corresponding `.reflect.qualityGate.enabled` for proposals).
532
+ *
533
+ * Fail-open: returns `pass: true` on timeout, parse failure, or missing LLM.
534
+ */
535
+ export async function runLessonQualityJudge(config, lessonContent, sourceContent, chat,
536
+ /** D-4 / #390: top-3 similar existing lessons for dedup check. */
537
+ similarLessons) {
538
+ const llmConfig = getDefaultLlmConfig(config);
539
+ if (!llmConfig) {
540
+ return { pass: true, score: -1, reason: "no LLM configured — passing through" };
541
+ }
542
+ const judgeLlmConfig = llmConfig.judgeModel ? { ...llmConfig, model: llmConfig.judgeModel } : llmConfig;
543
+ const JUDGE_TIMEOUT_MS = 8_000;
544
+ try {
545
+ const raw = await Promise.race([
546
+ chat(judgeLlmConfig, [
547
+ { role: "system", content: "Return only valid JSON. No prose." },
548
+ { role: "user", content: buildJudgePrompt(lessonContent, sourceContent, similarLessons) },
549
+ ]),
550
+ new Promise((_, reject) => setTimeout(() => reject(new Error("judge timeout")), JUDGE_TIMEOUT_MS)),
551
+ ]);
552
+ const parsed = parseEmbeddedJsonResponse(raw);
553
+ if (!parsed || typeof parsed.score !== "number") {
554
+ return { pass: true, score: -1, reason: "judge parse failed — passing through" };
555
+ }
556
+ // D-5 / #388: Three-band system (MT-Bench arXiv:2306.05685 — ~±0.5 judge variance).
557
+ // >= 3.5: auto-queue as pending (pass: true)
558
+ // 2.5–3.5: review-needed band — uncertain, escalate to human (reviewNeeded: true)
559
+ // < 2.5: auto-reject (pass: false)
560
+ const score = parsed.score;
561
+ const reason = parsed.reason ?? "";
562
+ if (score >= 3.5) {
563
+ return { pass: true, score, reason };
564
+ }
565
+ if (score >= 2.5) {
566
+ // Uncertainty band: treat as failed for auto-queuing but flag for review.
567
+ return { pass: false, score, reason, reviewNeeded: true };
568
+ }
569
+ return { pass: false, score, reason };
570
+ }
571
+ catch {
572
+ return { pass: true, score: -1, reason: "judge failed — passing through" };
573
+ }
574
+ }
575
+ // ── Quality-rejection helper ─────────────────────────────────────────────────
576
+ /**
577
+ * Write a rejected lesson to `.akm/distill-rejected/`, append a `distill_invoked`
578
+ * quality-rejected event, and return the `quality_rejected` envelope.
579
+ *
580
+ * @param stash - Root stash directory.
581
+ * @param inputRef - The original input ref (for the event).
582
+ * @param lessonRef - The proposed lesson/knowledge ref.
583
+ * @param content - The raw content that failed the quality gate.
584
+ * @param score - Quality score from the judge.
585
+ * @param reason - Human-readable rejection reason.
586
+ * @param extraMeta - Optional additional metadata for the event.
587
+ */
588
+ function writeQualityRejection(stash, inputRef, lessonRef, content, score, reason, extraMeta = {}) {
589
+ // D-5 / #388: reviewNeeded flag selects "review_needed" vs "quality_rejected" outcome.
590
+ const outcome = extraMeta.reviewNeeded ? "review_needed" : "quality_rejected";
591
+ const rejectDir = path.join(stash, ".akm", "distill-rejected");
592
+ fs.mkdirSync(rejectDir, { recursive: true });
593
+ const ts = timestampForFilename();
594
+ fs.writeFileSync(path.join(rejectDir, `${ts}-${lessonRef}.md`), `---\nscore: ${score}\nreason: ${reason}\noutcome: ${outcome}\n---\n\n${content}`, "utf8");
595
+ appendEvent({
596
+ eventType: "distill_invoked",
597
+ ref: inputRef,
598
+ metadata: {
599
+ outcome,
600
+ lessonRef,
601
+ score,
602
+ reason,
603
+ ...extraMeta,
604
+ },
605
+ });
606
+ return {
607
+ schemaVersion: 1,
608
+ ok: true,
609
+ outcome,
610
+ inputRef,
611
+ lessonRef,
612
+ score,
613
+ reason,
614
+ ...extraMeta,
615
+ };
616
+ }
124
617
  // ── Main entry point ────────────────────────────────────────────────────────
125
618
  /**
126
619
  * Run a single bounded distillation pass for `ref`. Always emits exactly one
@@ -133,13 +626,47 @@ export async function akmDistill(options) {
133
626
  throw new UsageError("Asset ref is required. Usage: akm distill <ref>", "MISSING_REQUIRED_ARGUMENT");
134
627
  }
135
628
  // Validate the ref shape up front so a typo never reaches the LLM.
136
- parseAssetRef(inputRef);
137
- const lessonRef = deriveLessonRef(inputRef);
629
+ const parsedInputRef = parseAssetRef(inputRef);
630
+ const targetKind = options.proposalKind ?? "lesson";
631
+ // Recursive-distillation guard. Distill produces *lessons* from non-lesson
632
+ // sources (memory, skill, knowledge, etc.). Calling distill on an existing
633
+ // lesson would derive `lesson:lesson-<name>-lesson-lesson` (double `-lesson`
634
+ // suffix) and route a "lesson of a lesson" through the proposal queue —
635
+ // observed in 323 reviewed archived proposals as the recursive-ref defect.
636
+ // Refuse the input here so the improve loop (or other callers) get a clean
637
+ // skipped outcome instead of producing nonsense refs.
638
+ //
639
+ // The refused-type set is exported as {@link DISTILL_REFUSED_INPUT_TYPES} so
640
+ // the improve planner can skip these refs before queuing distill attempts;
641
+ // this runtime check stays as a defensive backstop for direct callers.
642
+ if (isDistillRefusedInputType(parsedInputRef.type)) {
643
+ const skippedRef = `lesson:${parsedInputRef.name}`;
644
+ appendEvent({
645
+ eventType: "distill_invoked",
646
+ ref: inputRef,
647
+ metadata: {
648
+ outcome: "skipped",
649
+ lessonRef: skippedRef,
650
+ message: "distill refuses lesson inputs — lessons are the distilled form, not a source",
651
+ skipReason: "recursive_lesson_input",
652
+ },
653
+ });
654
+ return {
655
+ schemaVersion: 1,
656
+ ok: true,
657
+ outcome: "skipped",
658
+ inputRef,
659
+ lessonRef: skippedRef,
660
+ message: "Distill refuses lesson inputs — lessons are the distilled form, not a source.",
661
+ };
662
+ }
138
663
  const config = options.config ?? loadConfig();
139
664
  const stash = options.stashDir ?? resolveStashDir();
140
665
  const chat = options.chat ?? chatCompletion;
141
666
  const lookup = options.lookupFn ?? defaultLookup;
142
667
  const readEventsImpl = options.readEventsFn ?? readEvents;
668
+ // D-4 / #390: similar-lessons retrieval seam (test-injectable).
669
+ const fetchSimilarLessonsFn = options.fetchSimilarLessonsFn ?? ((query, n) => fetchTopSimilarLessons(query, n, options.stashDir));
143
670
  // Best-effort load: when the asset is not yet indexed we still proceed —
144
671
  // the LLM is asked to distil from "available signal" (feedback alone).
145
672
  let assetContent = null;
@@ -175,85 +702,573 @@ export async function akmDistill(options) {
175
702
  eventType: e.eventType,
176
703
  ...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
177
704
  }));
178
- const userPrompt = buildDistillPrompt({ inputRef, assetContent, feedback });
705
+ const promotion = targetKind === "lesson"
706
+ ? null
707
+ : assessMemoryKnowledgePromotionCandidate({
708
+ inputRef,
709
+ assetContent,
710
+ feedbackEvents: filteredEvents.map((event) => ({
711
+ ...(event.metadata !== undefined ? { metadata: event.metadata } : {}),
712
+ })),
713
+ });
714
+ if (promotion?.promote && promotion.content && (targetKind === "knowledge" || targetKind === "auto")) {
715
+ // D-1 / #369: When the destination knowledge file already exists, route
716
+ // through the LLM for contradiction resolution instead of silently
717
+ // overwriting. Follows mem0 ADD/UPDATE/DELETE/NOOP pattern (arXiv:2504.19413 §3.2)
718
+ // and A-MEM dynamic linking (arXiv:2502.12110).
719
+ let resolvedPromotionContent = promotion.content;
720
+ const existingKnowledgePath = await lookup(promotion.knowledgeRef);
721
+ const existingKnowledgeContent = existingKnowledgePath && fs.existsSync(existingKnowledgePath)
722
+ ? (() => {
723
+ try {
724
+ return fs.readFileSync(existingKnowledgePath, "utf8");
725
+ }
726
+ catch {
727
+ return null;
728
+ }
729
+ })()
730
+ : null;
731
+ if (existingKnowledgeContent && config && getDefaultLlmConfig(config)) {
732
+ // Existing content found: call LLM for contradiction-resolution merge.
733
+ const mergePrompt = [
734
+ "You are merging two versions of a knowledge document.",
735
+ "Existing content is already committed; new content comes from a memory distillation run.",
736
+ "Choose one of: ADD (combine both), UPDATE (replace existing with new), NOOP (keep existing unchanged).",
737
+ 'Return ONLY valid JSON: {"action": "ADD"|"UPDATE"|"NOOP", "content": "<merged markdown if ADD/UPDATE, empty string if NOOP>"}',
738
+ "",
739
+ "## Existing knowledge content",
740
+ "```",
741
+ existingKnowledgeContent.slice(0, 3000),
742
+ "```",
743
+ "",
744
+ "## New content from distillation",
745
+ "```",
746
+ promotion.content.slice(0, 3000),
747
+ "```",
748
+ ].join("\n");
749
+ try {
750
+ const mergeLlm = getDefaultLlmConfig(config);
751
+ if (!mergeLlm) {
752
+ throw new ConfigError("LLM is not configured for distillation merge.", "LLM_NOT_CONFIGURED");
753
+ }
754
+ const mergeResponse = await chat(mergeLlm, [
755
+ { role: "system", content: "Return only valid JSON. No prose." },
756
+ { role: "user", content: mergePrompt },
757
+ ]);
758
+ const mergeResult = parseEmbeddedJsonResponse(mergeResponse);
759
+ if (mergeResult?.action === "NOOP") {
760
+ // Existing content is authoritative — no update needed.
761
+ appendEvent({
762
+ eventType: "distill_invoked",
763
+ ref: inputRef,
764
+ metadata: {
765
+ outcome: "skipped",
766
+ lessonRef: promotion.knowledgeRef,
767
+ message: "D-1: LLM resolved destination conflict as NOOP — existing content kept",
768
+ },
769
+ });
770
+ return {
771
+ schemaVersion: 1,
772
+ ok: true,
773
+ outcome: "skipped",
774
+ inputRef,
775
+ lessonRef: promotion.knowledgeRef,
776
+ message: "Existing knowledge content unchanged (contradiction resolution: NOOP)",
777
+ };
778
+ }
779
+ if (mergeResult?.action && (mergeResult.action === "ADD" || mergeResult.action === "UPDATE")) {
780
+ if (mergeResult.content?.trim()) {
781
+ resolvedPromotionContent = mergeResult.content;
782
+ }
783
+ }
784
+ }
785
+ catch {
786
+ // LLM merge failed — fall through with the original promotion content.
787
+ // The reviewer will see both versions in the proposal diff.
788
+ }
789
+ }
790
+ else if (existingKnowledgeContent && config && !getDefaultLlmConfig(config)) {
791
+ // No LLM configured: include existing content as context in the proposal
792
+ // so the reviewer can do the contradiction resolution manually.
793
+ resolvedPromotionContent = [
794
+ promotion.content,
795
+ "",
796
+ "---",
797
+ "<!-- D-1 / #369: Existing knowledge content is shown below for reviewer reference. -->",
798
+ "<!-- Review: decide whether to ADD (merge), UPDATE (replace), or NOOP (keep existing). -->",
799
+ "",
800
+ "## Existing content (for reviewer reference)",
801
+ "",
802
+ existingKnowledgeContent,
803
+ ].join("\n");
804
+ }
805
+ // Apply quality gate to fast-path knowledge promotion (Risk 4 fix).
806
+ // D-5 / #388: Three-band system — review_needed band queues to proposal
807
+ // queue with review_needed outcome rather than auto-rejecting.
808
+ let knowledgeJudgeConfidence;
809
+ if (isLlmFeatureEnabled(config, "lesson_quality_gate")) {
810
+ // D-4 / #390: retrieve top-3 similar lessons for dedup check in judge.
811
+ const similarLessons = await fetchSimilarLessonsFn(resolvedPromotionContent.slice(0, 500), 3);
812
+ const judgeResult = await runLessonQualityJudge(config, resolvedPromotionContent, assetContent ?? "", chat, similarLessons.length > 0 ? similarLessons : undefined);
813
+ if (!judgeResult.pass) {
814
+ if (judgeResult.reviewNeeded) {
815
+ // Uncertainty band (2.5–3.5): queue as review_needed instead of rejecting.
816
+ return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, { reviewNeeded: true });
817
+ }
818
+ return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason);
819
+ }
820
+ // Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
821
+ // (no LLM / timeout / parse failure) — leave confidence undefined so
822
+ // the auto-accept gate treats the proposal as unscored and skips it.
823
+ if (judgeResult.score > 0)
824
+ knowledgeJudgeConfidence = judgeResult.score / 5;
825
+ }
826
+ const knowledgeParsed = parseFrontmatter(resolvedPromotionContent);
827
+ const proposalResult = createProposal(stash, {
828
+ ref: promotion.knowledgeRef,
829
+ source: "distill",
830
+ ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
831
+ payload: {
832
+ content: resolvedPromotionContent,
833
+ ...(Object.keys(knowledgeParsed.data).length > 0 ? { frontmatter: knowledgeParsed.data } : {}),
834
+ },
835
+ ...(knowledgeJudgeConfidence !== undefined ? { confidence: knowledgeJudgeConfidence } : {}),
836
+ }, options.ctx);
837
+ if (isProposalSkipped(proposalResult)) {
838
+ appendEvent({
839
+ eventType: "distill_invoked",
840
+ ref: inputRef,
841
+ metadata: {
842
+ outcome: "skipped",
843
+ lessonRef: promotion.knowledgeRef,
844
+ message: proposalResult.message,
845
+ skipReason: proposalResult.reason,
846
+ },
847
+ });
848
+ return {
849
+ schemaVersion: 1,
850
+ ok: true,
851
+ outcome: "skipped",
852
+ inputRef,
853
+ lessonRef: promotion.knowledgeRef,
854
+ message: proposalResult.message,
855
+ };
856
+ }
857
+ const proposal = proposalResult;
858
+ appendEvent({
859
+ eventType: "distill_invoked",
860
+ ref: inputRef,
861
+ metadata: {
862
+ outcome: "queued",
863
+ lessonRef: promotion.knowledgeRef,
864
+ proposalRef: promotion.knowledgeRef,
865
+ proposalKind: "knowledge",
866
+ proposalId: proposal.id,
867
+ ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
868
+ ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
869
+ },
870
+ });
871
+ return {
872
+ schemaVersion: 1,
873
+ ok: true,
874
+ outcome: "queued",
875
+ inputRef,
876
+ lessonRef: promotion.knowledgeRef,
877
+ proposalRef: promotion.knowledgeRef,
878
+ proposalKind: "knowledge",
879
+ proposalId: proposal.id,
880
+ proposal,
881
+ ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
882
+ };
883
+ }
884
+ const effectiveProposalKind = targetKind === "knowledge" ? "knowledge" : "lesson";
885
+ const effectiveLessonRef = effectiveProposalKind === "knowledge" ? deriveKnowledgeRef(inputRef) : deriveLessonRef(inputRef);
886
+ // Inject last 1–3 rejected proposals for this ref as Reflexion-style
887
+ // verbal-RL context so the LLM avoids regenerating refused proposals.
888
+ const MAX_REJECTED_PROPOSALS = 3;
889
+ const rejectedForRef = listProposals(stash, { ref: inputRef, status: "rejected", includeArchive: true })
890
+ .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
891
+ .slice(0, MAX_REJECTED_PROPOSALS)
892
+ .map((p) => ({
893
+ reason: p.review?.reason ?? "no reason given",
894
+ contentPreview: p.payload.content.slice(0, 500),
895
+ }));
896
+ const userPrompt = buildDistillPrompt({
897
+ inputRef,
898
+ assetContent,
899
+ feedback,
900
+ proposalKind: effectiveProposalKind,
901
+ ...(rejectedForRef.length > 0 ? { rejectedProposals: rejectedForRef } : {}),
902
+ });
179
903
  const messages = [
180
- { role: "system", content: SYSTEM_PROMPT },
904
+ { role: "system", content: effectiveProposalKind === "knowledge" ? KNOWLEDGE_SYSTEM_PROMPT : LESSON_SYSTEM_PROMPT },
181
905
  { role: "user", content: userPrompt },
182
906
  ];
183
- // Single bounded LLM call. The wrapper handles the gate-check, 30 s
184
- // timeout, and error fallback (returning `null`).
185
- const raw = await tryLlmFeature("feedback_distillation", config, async () => {
186
- if (!config.llm) {
907
+ // Single bounded LLM call. The wrapper handles the gate-check, 600s
908
+ // (10 min) default timeout, and error fallback (returning `null`).
909
+ //
910
+ // Capture the fallback reason so we can distinguish "config gate is off"
911
+ // (no LLM was called — operator action required) from "LLM call was made
912
+ // but returned no usable output" (transport/timeout/empty — observability).
913
+ // The previous conflated message ("disabled or the LLM call failed") gave
914
+ // operators no signal to act on; a 108-run audit found 100% of skipped
915
+ // outcomes were actually the config-gate-off branch.
916
+ //
917
+ // responseSchema lift (PR 1, asset-writers-investigation §5): on the
918
+ // production path (no test `chat` seam) we pass the lesson/knowledge JSON
919
+ // schema to `chatCompletion`. Providers with `supportsJsonSchema: true`
920
+ // return a typed JSON object the post-call code re-assembles into markdown,
921
+ // bypassing the four shape-level rejection codes the validator log catches.
922
+ // The test seam keeps its two-arg signature, so injected fakes still pin
923
+ // markdown responses verbatim and the existing assertion suite is unchanged.
924
+ const distillSchema = effectiveProposalKind === "knowledge" ? DISTILL_KNOWLEDGE_JSON_SCHEMA : DISTILL_LESSON_JSON_SCHEMA;
925
+ let fallbackReason;
926
+ const raw = await tryLlmFeature("distill", config, async () => {
927
+ const distillLlm = getDefaultLlmConfig(config);
928
+ if (!distillLlm) {
187
929
  // No LLM connection configured — treat as gate-disabled. Throwing
188
930
  // here lets `tryLlmFeature` route us through the "error" fallback,
189
931
  // which is the same graceful skipped path.
190
- throw new ConfigError("No LLM connection configured. Set `llm.endpoint` and `llm.model` in the akm config.", "LLM_NOT_CONFIGURED");
932
+ throw new ConfigError("No LLM connection configured. Set `defaults.llm` and a profile under `profiles.llm`.", "LLM_NOT_CONFIGURED");
933
+ }
934
+ // Production path: pass the JSON schema so providers that honour
935
+ // `response_format: json_schema` enforce shape upstream. Providers that
936
+ // ignore the option fall through to the prompt-contract markdown path.
937
+ if (options.chat === undefined) {
938
+ return chatCompletion(distillLlm, messages, { responseSchema: distillSchema });
191
939
  }
192
- return chat(config.llm, messages);
193
- }, null);
940
+ // Test seam: preserve the two-arg signature so existing fake `chat`
941
+ // functions (which return markdown strings) continue to work.
942
+ return chat(distillLlm, messages);
943
+ }, null, {
944
+ onFallback: (evt) => {
945
+ fallbackReason = evt.reason;
946
+ // Log the fallback reason; the caller (raw === null path) handles
947
+ // emitting the distill_invoked event so we don't double-emit here.
948
+ warnVerbose(`[akm] LLM fallback for ${evt.feature}: ${evt.reason}`);
949
+ },
950
+ });
194
951
  if (raw === null || raw.trim() === "") {
952
+ // Distinguish "config gate disabled" from "LLM call failed". For the
953
+ // config-disabled branch, we ALSO suppress the `distill_invoked` event
954
+ // because no LLM work was actually invoked — emitting the event causes
955
+ // the planner to accumulate phantom invocations that drown out real
956
+ // signal.
957
+ if (fallbackReason === "disabled") {
958
+ return {
959
+ schemaVersion: 1,
960
+ ok: true,
961
+ outcome: "config_disabled",
962
+ inputRef,
963
+ lessonRef: effectiveLessonRef,
964
+ proposalRef: effectiveLessonRef,
965
+ proposalKind: effectiveProposalKind,
966
+ message: "distill is disabled in config; enable processes.distill.enabled to activate.",
967
+ ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
968
+ };
969
+ }
970
+ // LLM was actually invoked but produced nothing usable (transport error,
971
+ // timeout, or empty/whitespace response). Emit the event so the failure
972
+ // is observable.
195
973
  appendEvent({
196
974
  eventType: "distill_invoked",
197
975
  ref: inputRef,
198
976
  metadata: {
199
- outcome: "skipped",
200
- lessonRef,
977
+ outcome: "llm_failed",
978
+ lessonRef: effectiveLessonRef,
979
+ proposalKind: effectiveProposalKind,
201
980
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
202
981
  },
203
982
  });
204
983
  return {
205
984
  schemaVersion: 1,
206
985
  ok: true,
207
- outcome: "skipped",
986
+ outcome: "llm_failed",
208
987
  inputRef,
209
- lessonRef,
210
- message: "feedback distillation is disabled or the LLM call failed; no proposal created.",
988
+ lessonRef: effectiveLessonRef,
989
+ proposalRef: effectiveLessonRef,
990
+ proposalKind: effectiveProposalKind,
991
+ message: "LLM call returned no usable output (timeout, empty, or error).",
211
992
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
212
993
  };
213
994
  }
214
- // Strip any stray fence the LLM might have added around the markdown.
215
- const content = stripMarkdownFences(raw);
995
+ // Structured-output path: when the provider honoured the JSON schema, `raw`
996
+ // is a JSON object string (not a markdown blob). Try to parse it and assemble
997
+ // the canonical `---\nfm\n---\n\nbody` form before falling through to the
998
+ // legacy markdown pipeline. Failure here (non-JSON response, missing
999
+ // required field, unexpected types) is non-fatal — we drop down to the
1000
+ // markdown path which has its own auto-repair + lint pass.
1001
+ let content;
1002
+ const structuredCandidate = parseEmbeddedJsonResponse(raw);
1003
+ const structuredAssembled = structuredCandidate && !Array.isArray(structuredCandidate)
1004
+ ? assembleStructuredDistillMarkdown(structuredCandidate, effectiveProposalKind)
1005
+ : null;
1006
+ if (structuredAssembled !== null) {
1007
+ content = structuredAssembled;
1008
+ }
1009
+ else {
1010
+ // Strip any stray fence the LLM might have added around the markdown.
1011
+ content = stripMarkdownFences(raw);
1012
+ }
1013
+ // Auto-repair missing frontmatter fields before hard-failing. Small models
1014
+ // frequently produce a good lesson body but omit the YAML header entirely.
1015
+ // Rather than discarding valid content, we extract description/when_to_use
1016
+ // from the body and prepend the required frontmatter block.
1017
+ //
1018
+ // IMPORTANT: We do NOT synthesise placeholder strings here. If the body
1019
+ // does not contain text that passes the post-LLM validators
1020
+ // (`isValidDescription` / `isValidWhenToUse`), we leave the field missing
1021
+ // and let the lesson lint reject the proposal as `validation_failed`.
1022
+ // Emitting placeholders like `"Lesson distilled from <ref>"` or
1023
+ // `"When working with <slug>"` is what produced the systematic broken
1024
+ // proposals observed across 323 archived rejections.
1025
+ if (effectiveProposalKind !== "knowledge") {
1026
+ const parsed = parseFrontmatter(content);
1027
+ const fm = (parsed.data ?? {});
1028
+ const missingDesc = typeof fm.description !== "string" || !fm.description.trim();
1029
+ const missingWtu = typeof fm.when_to_use !== "string" || !fm.when_to_use.trim();
1030
+ if (missingDesc || missingWtu) {
1031
+ const body = parsed.content.trim();
1032
+ // Strip markdown formatting tokens from a line so extracted text is clean.
1033
+ const stripMd = (l) => l
1034
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1035
+ .replace(/\*([^*]+)\*/g, "$1")
1036
+ .replace(/`([^`]+)`/g, "$1")
1037
+ .replace(/^[#*\->_]+\s*/, "")
1038
+ .replace(/:\s*$/, "")
1039
+ .trim();
1040
+ // Skip lines that look like YAML field assignments (key: value) or frontmatter delimiters.
1041
+ // These appear when the LLM leaks frontmatter content into the body, causing
1042
+ // auto-repair to produce description: "description: Key Takeaways".
1043
+ const isYamlLike = (l) => /^---/.test(l) || /^[a-z_]+:\s/i.test(l);
1044
+ const bodyLines = body.split("\n").map(stripMd);
1045
+ // Extract description: first body line that BOTH looks like prose AND
1046
+ // passes isValidDescription. If nothing qualifies, leave the field
1047
+ // missing — the lint pass will reject the proposal cleanly.
1048
+ let descLine;
1049
+ for (const l of bodyLines) {
1050
+ if (isYamlLike(l))
1051
+ continue;
1052
+ if (l.length <= 10 || l.length >= 400)
1053
+ continue;
1054
+ if (isValidDescription(l, inputRef).ok) {
1055
+ descLine = l;
1056
+ break;
1057
+ }
1058
+ }
1059
+ // Extract when_to_use: a line starting with "When" / "Use when" / "Apply when"
1060
+ // that ALSO passes isValidWhenToUse (rejects circular fallbacks).
1061
+ let wtuLine;
1062
+ for (const l of bodyLines) {
1063
+ if (!/^(when |use when|apply when)/i.test(l))
1064
+ continue;
1065
+ if (l.length >= 400)
1066
+ continue;
1067
+ if (isValidWhenToUse(l, inputRef).ok) {
1068
+ wtuLine = l;
1069
+ break;
1070
+ }
1071
+ }
1072
+ const repairedFm = {
1073
+ ...fm,
1074
+ ...(missingDesc && descLine ? { description: descLine } : {}),
1075
+ ...(missingWtu && wtuLine ? { when_to_use: wtuLine } : {}),
1076
+ };
1077
+ const fmLines = Object.entries(repairedFm)
1078
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
1079
+ .join("\n");
1080
+ // Only rewrite content if we actually have at least one field to write.
1081
+ // Otherwise leave the original content for the lint pass to reject.
1082
+ if (Object.keys(repairedFm).length > 0) {
1083
+ content = assembleAssetFromString(fmLines, body);
1084
+ }
1085
+ }
1086
+ }
1087
+ // Description ↔ when_to_use auto-swap normalization (recover ~93% of
1088
+ // qwen-9b's `^when\b/i` rejections at zero LLM cost). When the LLM emits
1089
+ // a conditional-framed description ("When X happens, do Y") and the
1090
+ // when_to_use field looks like a declarative description (or is empty),
1091
+ // the two fields are mis-fielded — exactly what `isValidDescription`'s
1092
+ // error message says ("that pattern belongs in when_to_use"). We swap
1093
+ // them and revalidate; the swap is committed only if BOTH fields pass
1094
+ // their respective validators afterwards. If revalidation still fails,
1095
+ // we fall through to the existing reject path.
1096
+ let descriptionSwapped = 0;
1097
+ if (effectiveProposalKind !== "knowledge") {
1098
+ const parsedSwap = parseFrontmatter(content);
1099
+ const fmSwap = (parsedSwap.data ?? {});
1100
+ const descRaw = typeof fmSwap.description === "string" ? fmSwap.description.trim() : "";
1101
+ const wtuRaw = typeof fmSwap.when_to_use === "string" ? fmSwap.when_to_use.trim() : "";
1102
+ const descStartsConditional = /^(when|if)\b/i.test(descRaw);
1103
+ const wtuStartsConditional = /^(when|if)\b/i.test(wtuRaw);
1104
+ if (descStartsConditional && !wtuStartsConditional && wtuRaw.length > 0) {
1105
+ // Try the swap and revalidate. The when_to_use validator requires the
1106
+ // value not match `/^when working with\b/i` (the circular fallback) —
1107
+ // a real description rarely does, so this usually passes.
1108
+ const swappedDescCheck = isValidDescription(wtuRaw, inputRef);
1109
+ const swappedWtuCheck = isValidWhenToUse(descRaw, inputRef);
1110
+ if (swappedDescCheck.ok && swappedWtuCheck.ok) {
1111
+ const swappedFm = {
1112
+ ...fmSwap,
1113
+ description: wtuRaw,
1114
+ when_to_use: descRaw,
1115
+ };
1116
+ const swappedFmLines = Object.entries(swappedFm)
1117
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
1118
+ .join("\n");
1119
+ content = assembleAssetFromString(swappedFmLines, parsedSwap.content);
1120
+ descriptionSwapped = 1;
1121
+ }
1122
+ }
1123
+ }
216
1124
  // Parse + lint the lesson before creating the proposal. The lint is the
217
1125
  // canonical gate for required frontmatter (v1 spec §13). On failure we
218
1126
  // surface a structured error and exit non-zero — but still emit
219
1127
  // `distill_invoked` so the failure is observable.
220
- const lintReport = lintLessonContent(content, `distill:${inputRef}`);
221
- if (lintReport.findings.length > 0) {
1128
+ const findings = effectiveProposalKind === "knowledge"
1129
+ ? validateKnowledgeContent(content, inputRef)
1130
+ : lintLessonContent(content, `distill:${inputRef}`).findings;
1131
+ // Additional quality validators run only on lessons. lesson-lint checks
1132
+ // "field is present and non-empty"; these reject the systematic failure
1133
+ // modes observed across 323 archived rejected proposals:
1134
+ // - description is a body fragment, section heading, or placeholder
1135
+ // - when_to_use is the circular "When working with <ref>" fallback
1136
+ // - description == when_to_use (LLM duplicated a single sentence)
1137
+ // - body contains a second pseudo-frontmatter block
1138
+ if (effectiveProposalKind !== "knowledge" && findings.length === 0) {
1139
+ const parsedQC = parseFrontmatter(content);
1140
+ const fmQC = (parsedQC.data ?? {});
1141
+ const descCheck = isValidDescription(fmQC.description, inputRef);
1142
+ if (!descCheck.ok) {
1143
+ findings.push({
1144
+ kind: "invalid-description",
1145
+ field: "description",
1146
+ message: `Distilled lesson for ${inputRef} has an invalid description: ${descCheck.reason}.`,
1147
+ });
1148
+ }
1149
+ const wtuCheck = isValidWhenToUse(fmQC.when_to_use, inputRef);
1150
+ if (!wtuCheck.ok) {
1151
+ findings.push({
1152
+ kind: "invalid-when_to_use",
1153
+ field: "when_to_use",
1154
+ message: `Distilled lesson for ${inputRef} has an invalid when_to_use: ${wtuCheck.reason}.`,
1155
+ });
1156
+ }
1157
+ // description and when_to_use must say different things.
1158
+ if (descCheck.ok &&
1159
+ wtuCheck.ok &&
1160
+ typeof fmQC.description === "string" &&
1161
+ typeof fmQC.when_to_use === "string" &&
1162
+ fmQC.description.trim().toLowerCase() === fmQC.when_to_use.trim().toLowerCase()) {
1163
+ findings.push({
1164
+ kind: "description-equals-when_to_use",
1165
+ field: "description",
1166
+ message: `Distilled lesson for ${inputRef} has identical description and when_to_use.`,
1167
+ });
1168
+ }
1169
+ // Double-frontmatter / pseudo-frontmatter pollution in the body.
1170
+ const dfm = detectDoubleFrontmatter(content);
1171
+ if (dfm) {
1172
+ findings.push({ kind: dfm.kind, field: "body", message: `Distilled lesson for ${inputRef}: ${dfm.message}` });
1173
+ }
1174
+ }
1175
+ if (findings.length > 0) {
222
1176
  appendEvent({
223
1177
  eventType: "distill_invoked",
224
1178
  ref: inputRef,
225
1179
  metadata: {
226
1180
  outcome: "validation_failed",
227
- lessonRef,
228
- findingKinds: lintReport.findings.map((f) => f.kind),
1181
+ lessonRef: effectiveLessonRef,
1182
+ proposalKind: effectiveProposalKind,
1183
+ findingKinds: findings.map((f) => f.kind),
229
1184
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
230
1185
  },
231
1186
  });
232
- const message = lintReport.findings.map((f) => f.message).join("\n");
233
- throw new UsageError(`Distilled lesson failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Lessons require non-empty `description` and `when_to_use` frontmatter fields. See v1 spec §13.");
1187
+ const message = findings.map((f) => f.message).join("\n");
1188
+ throw new UsageError(`Distilled ${effectiveProposalKind} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", effectiveProposalKind === "knowledge"
1189
+ ? "Knowledge proposals require a non-empty markdown body."
1190
+ : "Lessons require non-empty `description` and `when_to_use` frontmatter fields. See v1 spec §13.");
1191
+ }
1192
+ // LLM-as-judge quality gate (P2-B). Only active when the feature flag is
1193
+ // explicitly enabled. Fail-open: judge failures always pass through.
1194
+ // D-5 / #388: Three-band system — review_needed band queues a proposal
1195
+ // with review_needed outcome rather than auto-rejecting.
1196
+ let lessonJudgeConfidence;
1197
+ if (isLlmFeatureEnabled(config, "lesson_quality_gate")) {
1198
+ // D-4 / #390: retrieve top-3 similar lessons for dedup check in judge.
1199
+ const similarLessons = await fetchSimilarLessonsFn(content.slice(0, 500), 3);
1200
+ const judgeResult = await runLessonQualityJudge(config, content, assetContent ?? "", chat, similarLessons.length > 0 ? similarLessons : undefined);
1201
+ if (!judgeResult.pass) {
1202
+ if (judgeResult.reviewNeeded) {
1203
+ return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, {
1204
+ reviewNeeded: true,
1205
+ ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
1206
+ });
1207
+ }
1208
+ return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {});
1209
+ }
1210
+ // Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
1211
+ // (no LLM / timeout / parse failure) — leave confidence undefined so
1212
+ // the auto-accept gate treats the proposal as unscored and skips it.
1213
+ if (judgeResult.score > 0)
1214
+ lessonJudgeConfidence = judgeResult.score / 5;
234
1215
  }
235
1216
  // Round-trip the parsed frontmatter so the proposal carries it as a
236
1217
  // structured payload alongside the raw content (matches the shape used by
237
1218
  // other proposal sources).
1219
+ //
1220
+ // D-7 / #398: Inject `sources: [inputRef]` into the LLM-path proposal
1221
+ // frontmatter when the field is absent, providing reviewers with provenance
1222
+ // without requiring them to open event history. A-MEM arXiv:2502.12110 —
1223
+ // all notes carry explicit provenance links.
238
1224
  const parsed = parseFrontmatter(content);
239
- const proposal = createProposal(stash, {
240
- ref: lessonRef,
1225
+ const frontmatterWithSources = { ...parsed.data };
1226
+ if (!Array.isArray(frontmatterWithSources.sources) || frontmatterWithSources.sources.length === 0) {
1227
+ frontmatterWithSources.sources = [inputRef];
1228
+ }
1229
+ const proposalResult2 = createProposal(stash, {
1230
+ ref: effectiveLessonRef,
241
1231
  source: "distill",
242
1232
  ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
243
1233
  payload: {
244
1234
  content,
245
- ...(Object.keys(parsed.data).length > 0 ? { frontmatter: parsed.data } : {}),
1235
+ frontmatter: frontmatterWithSources,
246
1236
  },
1237
+ ...(lessonJudgeConfidence !== undefined ? { confidence: lessonJudgeConfidence } : {}),
247
1238
  }, options.ctx);
1239
+ if (isProposalSkipped(proposalResult2)) {
1240
+ appendEvent({
1241
+ eventType: "distill_invoked",
1242
+ ref: inputRef,
1243
+ metadata: {
1244
+ outcome: "skipped",
1245
+ lessonRef: effectiveLessonRef,
1246
+ message: proposalResult2.message,
1247
+ skipReason: proposalResult2.reason,
1248
+ },
1249
+ });
1250
+ return {
1251
+ schemaVersion: 1,
1252
+ ok: true,
1253
+ outcome: "skipped",
1254
+ inputRef,
1255
+ lessonRef: effectiveLessonRef,
1256
+ message: proposalResult2.message,
1257
+ };
1258
+ }
1259
+ const proposal2 = proposalResult2;
248
1260
  appendEvent({
249
1261
  eventType: "distill_invoked",
250
1262
  ref: inputRef,
251
1263
  metadata: {
252
1264
  outcome: "queued",
253
- lessonRef,
254
- proposalId: proposal.id,
1265
+ lessonRef: effectiveLessonRef,
1266
+ proposalRef: effectiveLessonRef,
1267
+ proposalKind: effectiveProposalKind,
1268
+ proposalId: proposal2.id,
255
1269
  ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
256
1270
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
1271
+ ...(descriptionSwapped > 0 ? { descriptionSwapped } : {}),
257
1272
  },
258
1273
  });
259
1274
  return {
@@ -261,33 +1276,16 @@ export async function akmDistill(options) {
261
1276
  ok: true,
262
1277
  outcome: "queued",
263
1278
  inputRef,
264
- lessonRef,
265
- proposalId: proposal.id,
266
- proposal,
1279
+ lessonRef: effectiveLessonRef,
1280
+ proposalRef: effectiveLessonRef,
1281
+ proposalKind: effectiveProposalKind,
1282
+ proposalId: proposal2.id,
1283
+ proposal: proposal2,
267
1284
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
1285
+ ...(descriptionSwapped > 0 ? { descriptionSwapped } : {}),
268
1286
  };
269
1287
  }
270
1288
  // ── Helpers ─────────────────────────────────────────────────────────────────
271
1289
  async function defaultLookup(ref) {
272
- try {
273
- const entry = await indexerLookup(parseAssetRef(ref));
274
- return entry?.filePath ?? null;
275
- }
276
- catch {
277
- return null;
278
- }
279
- }
280
- /** Best-effort fence stripping. Keeps the body intact when no fence is present. */
281
- function stripMarkdownFences(raw) {
282
- // Strip <think>…</think> reasoning blocks first — local LLMs (e.g. Qwen3)
283
- // emit these before the content, which breaks YAML frontmatter detection.
284
- const stripped = raw
285
- .trim()
286
- .replace(/<think>[\s\S]*?<\/think>/gi, "")
287
- .trim();
288
- // Only strip outer triple-fence pairs — leave inner code blocks alone.
289
- const fence = stripped.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/i);
290
- if (fence)
291
- return fence[1].trim();
292
- return stripped;
1290
+ return resolveAssetPath(ref, { mode: "index-only" });
293
1291
  }