akm-cli 0.7.5 → 0.8.0-rc.6

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