akm-cli 0.9.0-beta.4 → 0.9.0-beta.41

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 (132) hide show
  1. package/CHANGELOG.md +646 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +6 -2
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +22 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +22 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +24 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +22 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +25 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +21 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +21 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +23 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +22 -0
  24. package/dist/assets/templates/html/health.html +281 -111
  25. package/dist/cli.js +14 -3
  26. package/dist/commands/agent/contribute-cli.js +16 -3
  27. package/dist/commands/feedback-cli.js +15 -6
  28. package/dist/commands/graph/graph.js +75 -71
  29. package/dist/commands/health/checks.js +48 -0
  30. package/dist/commands/health/html-report.js +422 -80
  31. package/dist/commands/health.js +381 -9
  32. package/dist/commands/improve/calibration.js +161 -0
  33. package/dist/commands/improve/consolidate.js +631 -111
  34. package/dist/commands/improve/dedup.js +482 -0
  35. package/dist/commands/improve/distill.js +163 -69
  36. package/dist/commands/improve/encoding-salience.js +205 -0
  37. package/dist/commands/improve/extract-cli.js +115 -1
  38. package/dist/commands/improve/extract-prompt.js +39 -2
  39. package/dist/commands/improve/extract-watch.js +140 -0
  40. package/dist/commands/improve/extract.js +403 -40
  41. package/dist/commands/improve/feedback-valence.js +54 -0
  42. package/dist/commands/improve/homeostatic.js +467 -0
  43. package/dist/commands/improve/improve-auto-accept.js +113 -6
  44. package/dist/commands/improve/improve-profiles.js +12 -0
  45. package/dist/commands/improve/improve.js +2042 -612
  46. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  47. package/dist/commands/improve/outcome-loop.js +256 -0
  48. package/dist/commands/improve/proactive-maintenance.js +115 -0
  49. package/dist/commands/improve/procedural.js +418 -0
  50. package/dist/commands/improve/recombine.js +602 -0
  51. package/dist/commands/improve/reflect-noise.js +0 -0
  52. package/dist/commands/improve/reflect.js +46 -4
  53. package/dist/commands/improve/related-sessions.js +120 -0
  54. package/dist/commands/improve/salience.js +438 -0
  55. package/dist/commands/improve/triage.js +93 -0
  56. package/dist/commands/lint/agent-linter.js +19 -24
  57. package/dist/commands/lint/base-linter.js +173 -60
  58. package/dist/commands/lint/command-linter.js +19 -24
  59. package/dist/commands/lint/env-key-rules.js +34 -1
  60. package/dist/commands/lint/fact-linter.js +39 -0
  61. package/dist/commands/lint/index.js +31 -13
  62. package/dist/commands/lint/memory-linter.js +1 -1
  63. package/dist/commands/lint/registry.js +7 -2
  64. package/dist/commands/lint/task-linter.js +3 -3
  65. package/dist/commands/lint/workflow-linter.js +26 -1
  66. package/dist/commands/proposal/drain-policies.js +5 -0
  67. package/dist/commands/proposal/drain.js +17 -1
  68. package/dist/commands/proposal/proposal.js +5 -0
  69. package/dist/commands/proposal/propose.js +5 -0
  70. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  71. package/dist/commands/proposal/validators/proposals.js +187 -57
  72. package/dist/commands/read/curate.js +344 -80
  73. package/dist/commands/read/search-cli.js +7 -0
  74. package/dist/commands/read/search.js +1 -0
  75. package/dist/commands/read/show.js +67 -2
  76. package/dist/commands/sources/init.js +36 -9
  77. package/dist/commands/sources/installed-stashes.js +5 -1
  78. package/dist/commands/sources/schema-repair.js +13 -1
  79. package/dist/commands/sources/stash-cli.js +19 -3
  80. package/dist/commands/sources/stash-skeleton.js +23 -8
  81. package/dist/core/asset/asset-registry.js +2 -0
  82. package/dist/core/asset/asset-spec.js +14 -0
  83. package/dist/core/asset/frontmatter.js +166 -167
  84. package/dist/core/asset/markdown.js +8 -0
  85. package/dist/core/authoring-rules.js +83 -0
  86. package/dist/core/config/config-schema.js +274 -2
  87. package/dist/core/config/config.js +2 -2
  88. package/dist/core/logs-db.js +4 -3
  89. package/dist/core/paths.js +3 -0
  90. package/dist/core/standards/resolve-standards-context.js +87 -0
  91. package/dist/core/standards/resolve-stash-standards.js +99 -0
  92. package/dist/core/standards/resolve-type-conventions.js +66 -0
  93. package/dist/core/state-db.js +691 -30
  94. package/dist/indexer/db/db.js +364 -38
  95. package/dist/indexer/db/graph-db.js +129 -86
  96. package/dist/indexer/ensure-index.js +152 -17
  97. package/dist/indexer/graph/graph-boost.js +51 -41
  98. package/dist/indexer/graph/graph-extraction.js +203 -3
  99. package/dist/indexer/index-writer-lock.js +99 -0
  100. package/dist/indexer/indexer.js +114 -111
  101. package/dist/indexer/passes/memory-inference.js +10 -3
  102. package/dist/indexer/passes/staleness-detect.js +2 -5
  103. package/dist/indexer/search/db-search.js +15 -4
  104. package/dist/indexer/search/ranking-contributors.js +22 -0
  105. package/dist/indexer/search/ranking.js +4 -0
  106. package/dist/indexer/walk/matchers.js +9 -0
  107. package/dist/integrations/agent/prompts.js +33 -0
  108. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  109. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  110. package/dist/integrations/session-logs/index.js +16 -0
  111. package/dist/llm/client.js +23 -4
  112. package/dist/llm/embedder.js +27 -3
  113. package/dist/llm/embedders/local.js +66 -2
  114. package/dist/llm/feature-gate.js +8 -4
  115. package/dist/llm/graph-extract.js +2 -1
  116. package/dist/llm/memory-infer.js +4 -8
  117. package/dist/llm/metadata-enhance.js +9 -1
  118. package/dist/output/renderers.js +73 -1
  119. package/dist/output/shapes/curate.js +14 -2
  120. package/dist/output/text/helpers.js +16 -1
  121. package/dist/runtime.js +25 -1
  122. package/dist/scripts/migrate-storage.js +1378 -599
  123. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +479 -270
  124. package/dist/setup/setup.js +3 -3
  125. package/dist/sources/providers/git.js +71 -61
  126. package/dist/sources/providers/tar-utils.js +16 -8
  127. package/dist/storage/sqlite-pragmas.js +146 -0
  128. package/dist/wiki/wiki.js +37 -0
  129. package/dist/workflows/db.js +3 -4
  130. package/dist/workflows/validate-summary.js +2 -7
  131. package/docs/data-and-telemetry.md +1 -0
  132. package/package.json +8 -6
@@ -52,22 +52,33 @@
52
52
  */
53
53
  import fs from "node:fs";
54
54
  import path from "node:path";
55
+ import distillKnowledgeSystemPrompt from "../../assets/prompts/distill-knowledge-system.md" with { type: "text" };
56
+ import distillLessonSystemPrompt from "../../assets/prompts/distill-lesson-system.md" with { type: "text" };
55
57
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
56
58
  import { assembleAssetFromString } from "../../core/asset/asset-serialize.js";
57
- import { parseFrontmatter } from "../../core/asset/frontmatter.js";
59
+ import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/frontmatter.js";
58
60
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
61
+ import { authoringRulesForType } from "../../core/authoring-rules.js";
59
62
  import { resolveStashDir, timestampForFilename } from "../../core/common.js";
60
63
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
61
64
  import { ConfigError, UsageError } from "../../core/errors.js";
62
65
  import { appendEvent, readEvents } from "../../core/events.js";
63
66
  import { lintLessonContent } from "../../core/lesson-lint.js";
67
+ import { getDbPath } from "../../core/paths.js";
68
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
69
+ import { openStateDatabase } from "../../core/state-db.js";
64
70
  import { warnVerbose } from "../../core/warn.js";
71
+ import { closeDatabase, getAllEntries, openDatabase } from "../../indexer/db/db.js";
65
72
  import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
66
73
  import { chatCompletion, parseEmbeddedJsonResponse } from "../../llm/client.js";
67
74
  import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
68
75
  import { createProposal, isProposalSkipped, listProposals, } from "../proposal/validators/proposals.js";
69
76
  import { akmSearch } from "../read/search.js";
77
+ import { stripFrontmatterBody as stripBodyForFidelity } from "./dedup.js";
70
78
  import { assessMemoryKnowledgePromotionCandidate, deriveKnowledgeRef } from "./distill-promotion-policy.js";
79
+ import { buildRefVocabulary, scoreEncodingSalience } from "./encoding-salience.js";
80
+ import { buildClsContext, checkDistillFidelity } from "./homeostatic.js";
81
+ import { computeSalience, upsertAssetSalience } from "./salience.js";
71
82
  /**
72
83
  * Asset-ref types that `akm distill` structurally refuses as inputs.
73
84
  *
@@ -122,68 +133,8 @@ import { repairTruncatedDescription } from "../../core/text-truncation.js";
122
133
  import { detectDoubleFrontmatter, isValidDescription, isValidWhenToUse, } from "../proposal/validators/proposal-quality-validators.js";
123
134
  export { detectDoubleFrontmatter, isValidDescription, isValidWhenToUse };
124
135
  // ── Prompt assembly ─────────────────────────────────────────────────────────
125
- const LESSON_SYSTEM_PROMPT = [
126
- "You are the akm `distill` distiller.",
127
- "Given an asset and recent feedback events about it, produce a single",
128
- "concise *lesson* an agent should remember next time it works on this",
129
- "asset's domain.",
130
- "",
131
- "YOUR RESPONSE MUST START EXACTLY WITH `---` ON THE VERY FIRST LINE.",
132
- "DO NOT output any prose, explanation, or code fences before or after.",
133
- "",
134
- "Required output format — copy this structure exactly:",
135
- "---",
136
- "description: <one complete sentence (ending with `.`) summarising what the lesson teaches>",
137
- "when_to_use: <one complete sentence describing the concrete trigger condition>",
138
- "---",
139
- "",
140
- "<lesson body — plain markdown, 1–3 short paragraphs of practical guidance>",
141
- "",
142
- "## description field (MANDATORY)",
143
- "- A single complete sentence in present tense, 80-200 chars, NO markdown.",
144
- "- Self-contained: a reviewer must understand the lesson from this field alone.",
145
- '- DO NOT start with "When ", "If ", or a connector word — that belongs in when_to_use.',
146
- '- DO NOT copy a section heading ("Key takeaways", "For example", "Key pitfalls").',
147
- "- DO NOT begin with a numbered list marker, code fence, or markdown heading.",
148
- "",
149
- 'GOOD: "Always validate ref existence before promoting a memory to knowledge; missing refs surface as silent 404s during accept."',
150
- 'BAD: "Key pitfalls"',
151
- 'BAD: "When working with the akm CLI"',
152
- 'BAD: "For example, you might..."',
153
- 'BAD: "1. Check the file"',
154
- "",
155
- "RULES:",
156
- "- `when_to_use` MUST be a complete sentence describing a concrete trigger. Never write `When working with <asset-name>` — that is circular and useless.",
157
- "- `description` and `when_to_use` MUST differ from each other.",
158
- "- 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).",
159
- "- 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.",
160
- "- Do NOT reproduce the source asset verbatim — distil what a caller needs to know.",
161
- "- Output ONLY the lesson file. No preamble, no code fences, no trailing prose.",
162
- ].join("\n");
163
- const KNOWLEDGE_SYSTEM_PROMPT = [
164
- "You are the akm `distill` distiller.",
165
- "Given an asset and recent feedback events about it, produce a concise",
166
- "*knowledge* markdown document capturing the durable, reusable facts.",
167
- "Prefer stable guidance over narrative recap.",
168
- "",
169
- "YOUR RESPONSE MUST START EXACTLY WITH `---` ON THE VERY FIRST LINE.",
170
- "DO NOT output any prose, explanation, or code fences before or after.",
171
- "",
172
- "Required output format:",
173
- "---",
174
- "description: <one-line summary of the knowledge asset>",
175
- "tags: [<tag1>, <tag2>]",
176
- "---",
177
- "",
178
- "# <Title>",
179
- "",
180
- "<body — structured markdown, durable facts only>",
181
- "",
182
- "RULES:",
183
- "- `description` MUST be a non-empty single-line string.",
184
- "- Include a meaningful markdown body with a `# Title` heading.",
185
- "- Output ONLY the knowledge file. No preamble, no code fences, no trailing prose.",
186
- ].join("\n");
136
+ const LESSON_SYSTEM_PROMPT = distillLessonSystemPrompt;
137
+ const KNOWLEDGE_SYSTEM_PROMPT = distillKnowledgeSystemPrompt;
187
138
  // ── Structured-output schemas (responseSchema lift) ─────────────────────────
188
139
  //
189
140
  // PR 1 of the asset-writers decision (see knowledge:projects/akm/
@@ -358,6 +309,18 @@ export function buildDistillPrompt(input) {
358
309
  const lines = [];
359
310
  lines.push(`Asset ref: ${input.inputRef}`);
360
311
  lines.push("");
312
+ if (input.standardsContext?.trim()) {
313
+ lines.push("Standards to follow (the rulebook for this target):");
314
+ lines.push(input.standardsContext.trim());
315
+ lines.push("");
316
+ }
317
+ {
318
+ const authoringRules = authoringRulesForType(input.proposalKind ?? "lesson");
319
+ if (authoringRules) {
320
+ lines.push(authoringRules);
321
+ lines.push("");
322
+ }
323
+ }
361
324
  lines.push("Asset content:");
362
325
  if (input.assetContent) {
363
326
  const body = input.assetContent.trim().slice(0, 3000);
@@ -586,7 +549,7 @@ similarLessons) {
586
549
  * @param reason - Human-readable rejection reason.
587
550
  * @param extraMeta - Optional additional metadata for the event.
588
551
  */
589
- function writeQualityRejection(stash, inputRef, lessonRef, content, score, reason, extraMeta = {}) {
552
+ function writeQualityRejection(stash, inputRef, lessonRef, content, score, reason, extraMeta = {}, eligibilitySource) {
590
553
  // D-5 / #388: reviewNeeded flag selects "review_needed" vs "quality_rejected" outcome.
591
554
  const outcome = extraMeta.reviewNeeded ? "review_needed" : "quality_rejected";
592
555
  const rejectDir = path.join(stash, ".akm", "distill-rejected");
@@ -602,6 +565,9 @@ function writeQualityRejection(stash, inputRef, lessonRef, content, score, reaso
602
565
  score,
603
566
  reason,
604
567
  ...extraMeta,
568
+ // Attribution tagging: stamp the eligibility lane so distill_invoked can be
569
+ // sliced by lane downstream. See EligibilitySource.
570
+ ...(eligibilitySource ? { eligibilitySource } : {}),
605
571
  },
606
572
  });
607
573
  return {
@@ -629,6 +595,12 @@ export async function akmDistill(options) {
629
595
  // Validate the ref shape up front so a typo never reaches the LLM.
630
596
  const parsedInputRef = parseAssetRef(inputRef);
631
597
  const targetKind = options.proposalKind ?? "lesson";
598
+ // Attribution tagging: spread into every distill_invoked event's metadata so
599
+ // the lane that selected this asset is recorded uniformly across all outcome
600
+ // branches. Empty object when no lane was supplied (direct `akm distill`).
601
+ const eligMeta = options.eligibilitySource
602
+ ? { eligibilitySource: options.eligibilitySource }
603
+ : {};
632
604
  // Recursive-distillation guard. Distill produces *lessons* from non-lesson
633
605
  // sources (memory, skill, knowledge, etc.). Calling distill on an existing
634
606
  // lesson would derive `lesson:lesson-<name>-lesson-lesson` (double `-lesson`
@@ -650,6 +622,7 @@ export async function akmDistill(options) {
650
622
  lessonRef: skippedRef,
651
623
  message: "distill refuses lesson inputs — lessons are the distilled form, not a source",
652
624
  skipReason: "recursive_lesson_input",
625
+ ...eligMeta,
653
626
  },
654
627
  });
655
628
  return {
@@ -671,15 +644,78 @@ export async function akmDistill(options) {
671
644
  // Best-effort load: when the asset is not yet indexed we still proceed —
672
645
  // the LLM is asked to distil from "available signal" (feedback alone).
673
646
  let assetContent = null;
647
+ let assetFilePath = null;
674
648
  try {
675
649
  const filePath = await lookup(inputRef);
676
650
  if (filePath && fs.existsSync(filePath)) {
651
+ assetFilePath = filePath;
677
652
  assetContent = fs.readFileSync(filePath, "utf8");
678
653
  }
679
654
  }
680
655
  catch {
681
656
  assetContent = null;
682
657
  }
658
+ // ── #608: Encoding-time salience scoring ────────────────────────────────
659
+ // Score the source asset with the three-signal model (novelty × 0.40 +
660
+ // magnitude × 0.35 + predictionError × 0.25) and persist the result to:
661
+ // 1. The asset's frontmatter (human-readable mirror; idempotent delta gate).
662
+ // 2. state.db :: asset_salience (canonical; feeds improve's high-salience gate).
663
+ // Both writes are best-effort — a DB error never blocks distillation.
664
+ if (assetContent && assetFilePath) {
665
+ try {
666
+ const parsedRef = parseAssetRef(inputRef);
667
+ // Build bigram vocabulary from currently-indexed refs for novelty signal.
668
+ let existingRefVocabulary = new Set();
669
+ try {
670
+ const embCfg = config?.embedding;
671
+ const indexDb = openDatabase(getDbPath(), embCfg?.dimension ? { embeddingDim: embCfg.dimension } : undefined);
672
+ try {
673
+ const allRefs = getAllEntries(indexDb).map((e) => e.entryKey);
674
+ existingRefVocabulary = buildRefVocabulary(allRefs);
675
+ }
676
+ finally {
677
+ closeDatabase(indexDb);
678
+ }
679
+ }
680
+ catch {
681
+ // Index not available — novelty defaults to type-floor.
682
+ }
683
+ const salienceResult = scoreEncodingSalience({
684
+ body: assetContent,
685
+ type: parsedRef.type,
686
+ existingRefVocabulary,
687
+ revisionCount: 0,
688
+ });
689
+ // 1. Write salience to the source asset frontmatter (idempotent).
690
+ const updatedContent = writeSalienceToFrontmatter(assetContent, salienceResult.score, salienceResult);
691
+ if (updatedContent !== assetContent) {
692
+ fs.writeFileSync(assetFilePath, updatedContent, "utf8");
693
+ assetContent = updatedContent;
694
+ }
695
+ // 2. Persist encoding_salience to state.db.
696
+ try {
697
+ const stateDb = openStateDatabase();
698
+ try {
699
+ const vector = computeSalience({
700
+ ref: inputRef,
701
+ type: parsedRef.type,
702
+ retrievalFreq: 0,
703
+ encodingSalience: salienceResult.score,
704
+ });
705
+ upsertAssetSalience(stateDb, inputRef, vector);
706
+ }
707
+ finally {
708
+ stateDb.close();
709
+ }
710
+ }
711
+ catch {
712
+ // State DB unavailable — frontmatter mirror is the only persistence.
713
+ }
714
+ }
715
+ catch {
716
+ // Scoring errors never block distillation.
717
+ }
718
+ }
683
719
  const { events } = readEventsImpl({
684
720
  ref: inputRef,
685
721
  type: "feedback",
@@ -766,6 +802,7 @@ export async function akmDistill(options) {
766
802
  outcome: "skipped",
767
803
  lessonRef: promotion.knowledgeRef,
768
804
  message: "D-1: LLM resolved destination conflict as NOOP — existing content kept",
805
+ ...eligMeta,
769
806
  },
770
807
  });
771
808
  return {
@@ -814,9 +851,9 @@ export async function akmDistill(options) {
814
851
  if (!judgeResult.pass) {
815
852
  if (judgeResult.reviewNeeded) {
816
853
  // Uncertainty band (2.5–3.5): queue as review_needed instead of rejecting.
817
- return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, { reviewNeeded: true });
854
+ return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, { reviewNeeded: true }, options.eligibilitySource);
818
855
  }
819
- return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason);
856
+ return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, {}, options.eligibilitySource);
820
857
  }
821
858
  // Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
822
859
  // (no LLM / timeout / parse failure) — leave confidence undefined so
@@ -834,6 +871,8 @@ export async function akmDistill(options) {
834
871
  ...(Object.keys(knowledgeParsed.data).length > 0 ? { frontmatter: knowledgeParsed.data } : {}),
835
872
  },
836
873
  ...(knowledgeJudgeConfidence !== undefined ? { confidence: knowledgeJudgeConfidence } : {}),
874
+ // Attribution tagging: persist the eligibility lane on the proposal.
875
+ ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
837
876
  }, options.ctx);
838
877
  if (isProposalSkipped(proposalResult)) {
839
878
  appendEvent({
@@ -844,6 +883,7 @@ export async function akmDistill(options) {
844
883
  lessonRef: promotion.knowledgeRef,
845
884
  message: proposalResult.message,
846
885
  skipReason: proposalResult.reason,
886
+ ...eligMeta,
847
887
  },
848
888
  });
849
889
  return {
@@ -867,6 +907,7 @@ export async function akmDistill(options) {
867
907
  proposalId: proposal.id,
868
908
  ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
869
909
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
910
+ ...eligMeta,
870
911
  },
871
912
  });
872
913
  return {
@@ -894,13 +935,36 @@ export async function akmDistill(options) {
894
935
  reason: p.review?.reason ?? "no reason given",
895
936
  contentPreview: p.payload.content.slice(0, 500),
896
937
  }));
897
- const userPrompt = buildDistillPrompt({
938
+ // WS-3b CLS interleaving (step 9).
939
+ // When cls.enabled, inject embedding-retrieved adjacent lessons/knowledge
940
+ // into the distill prompt so the LLM avoids overwriting prior generalizations
941
+ // (catastrophic interference). DEFAULT OFF.
942
+ const clsConfig = config.profiles?.improve?.default?.processes?.distill?.cls ?? {};
943
+ let clsContext = "";
944
+ if (clsConfig.enabled) {
945
+ try {
946
+ const adjacentCount = clsConfig.adjacentCount ?? 3;
947
+ // Use the asset content or input ref as the query for adjacent retrieval.
948
+ const clsQuery = assetContent ? assetContent.slice(0, 500) : inputRef;
949
+ const adjacentItems = await fetchSimilarLessonsFn(clsQuery, adjacentCount);
950
+ clsContext = buildClsContext(adjacentItems, clsConfig);
951
+ }
952
+ catch {
953
+ // Fail open — CLS is supplemental, never required.
954
+ }
955
+ }
956
+ // Distill output is a lesson/knowledge (non-wiki) → stash authoring
957
+ // standards. Resolved once for this single call.
958
+ const standardsContext = resolveStashStandards(stash);
959
+ const baseUserPrompt = buildDistillPrompt({
898
960
  inputRef,
899
961
  assetContent,
900
962
  feedback,
901
963
  proposalKind: effectiveProposalKind,
902
964
  ...(rejectedForRef.length > 0 ? { rejectedProposals: rejectedForRef } : {}),
965
+ ...(standardsContext.trim() ? { standardsContext } : {}),
903
966
  });
967
+ const userPrompt = clsContext ? `${baseUserPrompt}${clsContext}` : baseUserPrompt;
904
968
  const messages = [
905
969
  { role: "system", content: effectiveProposalKind === "knowledge" ? KNOWLEDGE_SYSTEM_PROMPT : LESSON_SYSTEM_PROMPT },
906
970
  { role: "user", content: userPrompt },
@@ -979,6 +1043,7 @@ export async function akmDistill(options) {
979
1043
  lessonRef: effectiveLessonRef,
980
1044
  proposalKind: effectiveProposalKind,
981
1045
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
1046
+ ...eligMeta,
982
1047
  },
983
1048
  });
984
1049
  return {
@@ -1203,6 +1268,7 @@ export async function akmDistill(options) {
1203
1268
  proposalKind: effectiveProposalKind,
1204
1269
  findingKinds: findings.map((f) => f.kind),
1205
1270
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
1271
+ ...eligMeta,
1206
1272
  },
1207
1273
  });
1208
1274
  const message = findings.map((f) => f.message).join("\n");
@@ -1224,9 +1290,9 @@ export async function akmDistill(options) {
1224
1290
  return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, {
1225
1291
  reviewNeeded: true,
1226
1292
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
1227
- });
1293
+ }, options.eligibilitySource);
1228
1294
  }
1229
- return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {});
1295
+ return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}, options.eligibilitySource);
1230
1296
  }
1231
1297
  // Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
1232
1298
  // (no LLM / timeout / parse failure) — leave confidence undefined so
@@ -1234,6 +1300,30 @@ export async function akmDistill(options) {
1234
1300
  if (judgeResult.score > 0)
1235
1301
  lessonJudgeConfidence = judgeResult.score / 5;
1236
1302
  }
1303
+ // WS-3b: Distill→source fidelity check (step 10).
1304
+ // When fidelityCheck.enabled, check the distill proposal against its cited
1305
+ // source memories. A contradiction flag routes to human review (not auto-accept).
1306
+ // DEFAULT OFF. Fail-open: any error is treated as no-contradiction.
1307
+ const fidelityConfig = config.profiles?.improve?.default?.processes?.distill?.fidelityCheck ?? {};
1308
+ if (fidelityConfig.enabled && assetContent) {
1309
+ try {
1310
+ const proposalBody = stripBodyForFidelity(content);
1311
+ const sourceBodies = [stripBodyForFidelity(assetContent)];
1312
+ const fidelityResult = checkDistillFidelity(proposalBody, sourceBodies, fidelityConfig);
1313
+ if (fidelityResult.contradictionDetected) {
1314
+ // Route to human review by writing a quality rejection with reviewNeeded=true.
1315
+ return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, 2.0, // below auto-accept threshold, signals review needed
1316
+ fidelityResult.reason ?? "Proposal may contradict cited source memories.", {
1317
+ reviewNeeded: true,
1318
+ fidelityContradiction: true,
1319
+ ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
1320
+ }, options.eligibilitySource);
1321
+ }
1322
+ }
1323
+ catch {
1324
+ // Fail open — fidelity check is supplemental.
1325
+ }
1326
+ }
1237
1327
  // Round-trip the parsed frontmatter so the proposal carries it as a
1238
1328
  // structured payload alongside the raw content (matches the shape used by
1239
1329
  // other proposal sources).
@@ -1256,6 +1346,8 @@ export async function akmDistill(options) {
1256
1346
  frontmatter: frontmatterWithSources,
1257
1347
  },
1258
1348
  ...(lessonJudgeConfidence !== undefined ? { confidence: lessonJudgeConfidence } : {}),
1349
+ // Attribution tagging: persist the eligibility lane on the proposal.
1350
+ ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
1259
1351
  }, options.ctx);
1260
1352
  if (isProposalSkipped(proposalResult2)) {
1261
1353
  appendEvent({
@@ -1266,6 +1358,7 @@ export async function akmDistill(options) {
1266
1358
  lessonRef: effectiveLessonRef,
1267
1359
  message: proposalResult2.message,
1268
1360
  skipReason: proposalResult2.reason,
1361
+ ...eligMeta,
1269
1362
  },
1270
1363
  });
1271
1364
  return {
@@ -1290,6 +1383,7 @@ export async function akmDistill(options) {
1290
1383
  ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
1291
1384
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
1292
1385
  ...(descriptionSwapped > 0 ? { descriptionSwapped } : {}),
1386
+ ...eligMeta,
1293
1387
  },
1294
1388
  });
1295
1389
  return {
@@ -0,0 +1,205 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Encoding-time salience scoring for issue #608.
6
+ *
7
+ * Pure module — no I/O. All inputs are pre-fetched by the caller.
8
+ * Implements the three-signal model: novelty × 0.40 + magnitude × 0.35 +
9
+ * predictionError × 0.25, clamped to [0, 1].
10
+ */
11
+ // ── Weights ────────────────────────────────────────────────────────────────────
12
+ const W_NOVELTY = 0.4;
13
+ const W_MAGNITUDE = 0.35;
14
+ const W_PREDICTION_ERROR = 0.25;
15
+ // Guard: weights must sum to 1.0 (consistent with the pattern in salience.ts).
16
+ // This check fires at module load and catches any future re-tuning mistakes.
17
+ if (Math.abs(W_NOVELTY + W_MAGNITUDE + W_PREDICTION_ERROR - 1.0) > 1e-9) {
18
+ throw new Error(`encoding-salience.ts: sub-signal weights must sum to 1.0 (got ${W_NOVELTY + W_MAGNITUDE + W_PREDICTION_ERROR})`);
19
+ }
20
+ // ── Novelty type floors ────────────────────────────────────────────────────────
21
+ const TYPE_NOVELTY_FLOOR = Object.freeze({
22
+ skill: 0.8,
23
+ agent: 0.8,
24
+ memory: 0.4,
25
+ });
26
+ const DEFAULT_NOVELTY_FLOOR = 0.5;
27
+ // ── Magnitude keyword sets ──────────────────────────────────────────────────────
28
+ const SEVERITY_BUCKET = new Set(["error", "critical", "warning", "incident", "regression"]);
29
+ // Note: "fails" and "urgent" are from the spec's initial keyword list and are included
30
+ // here as strong constraint signals, even though the spec's final bucket table omits them.
31
+ const CONSTRAINT_BUCKET = new Set(["must", "never", "always", "blocked", "breaking", "deprecated", "fails", "urgent"]);
32
+ const ALL_MAGNITUDE_KEYWORDS = new Set([...SEVERITY_BUCKET, ...CONSTRAINT_BUCKET]);
33
+ // Number of distinct keyword matches to reach full magnitude score.
34
+ const MAGNITUDE_FULL_SCORE_THRESHOLD = 4;
35
+ // Single-bucket magnitude cap: when only one semantic bucket matches.
36
+ const MAGNITUDE_SINGLE_BUCKET_CAP = 0.5;
37
+ // ── Stop-words (excluded from bigram tokenization) ────────────────────────────
38
+ const STOP_WORDS = new Set([
39
+ "a",
40
+ "an",
41
+ "the",
42
+ "and",
43
+ "or",
44
+ "but",
45
+ "in",
46
+ "on",
47
+ "at",
48
+ "to",
49
+ "for",
50
+ "of",
51
+ "with",
52
+ "by",
53
+ "from",
54
+ "as",
55
+ "is",
56
+ "it",
57
+ "its",
58
+ "be",
59
+ "are",
60
+ "was",
61
+ "were",
62
+ "has",
63
+ "have",
64
+ "had",
65
+ "do",
66
+ "does",
67
+ "did",
68
+ "not",
69
+ "this",
70
+ "that",
71
+ "these",
72
+ "those",
73
+ "i",
74
+ "we",
75
+ "you",
76
+ "he",
77
+ "she",
78
+ "they",
79
+ "my",
80
+ "our",
81
+ "your",
82
+ "his",
83
+ "her",
84
+ "their",
85
+ "can",
86
+ "will",
87
+ "would",
88
+ "should",
89
+ "could",
90
+ "may",
91
+ "might",
92
+ "shall",
93
+ "so",
94
+ "if",
95
+ "then",
96
+ "than",
97
+ "when",
98
+ "while",
99
+ "what",
100
+ "which",
101
+ "who",
102
+ "how",
103
+ "no",
104
+ "any",
105
+ "all",
106
+ "more",
107
+ "also",
108
+ ]);
109
+ // ── Tokenization helpers ──────────────────────────────────────────────────────
110
+ /**
111
+ * Tokenize text into lowercase words, splitting on whitespace and punctuation,
112
+ * dropping stop-words. Used for body bigram extraction (novelty signal).
113
+ */
114
+ function tokenizeBody(text) {
115
+ return text
116
+ .toLowerCase()
117
+ .split(/[\s\p{P}\p{S}\-_/]+/u)
118
+ .filter((t) => t.length > 1 && !STOP_WORDS.has(t));
119
+ }
120
+ /**
121
+ * Tokenize a ref name or tag into lowercase words without stop-word filtering.
122
+ * Ref names are identifiers, not prose — all tokens are significant.
123
+ */
124
+ function tokenizeRef(ref) {
125
+ return ref
126
+ .toLowerCase()
127
+ .split(/[\s\p{P}\p{S}\-_/]+/u)
128
+ .filter((t) => t.length > 0);
129
+ }
130
+ /**
131
+ * Generate all consecutive bigrams from an array of tokens.
132
+ * Returns them as "token1 token2" strings.
133
+ */
134
+ function bigrams(tokens) {
135
+ const result = [];
136
+ for (let i = 0; i < tokens.length - 1; i++) {
137
+ result.push(`${tokens[i]} ${tokens[i + 1]}`);
138
+ }
139
+ return result;
140
+ }
141
+ /**
142
+ * Build the existing-ref vocabulary: a Set of bigrams derived from tokenizing
143
+ * the provided ref names (and tags). Pass this to `scoreEncodingSalience` as
144
+ * `existingRefVocabulary` so the novelty signal can measure how much of the
145
+ * asset body is already represented in the stash vocabulary.
146
+ */
147
+ export function buildRefVocabulary(refs) {
148
+ const vocab = new Set();
149
+ for (const ref of refs) {
150
+ const tokens = tokenizeRef(ref);
151
+ for (const bg of bigrams(tokens)) {
152
+ vocab.add(bg);
153
+ }
154
+ }
155
+ return vocab;
156
+ }
157
+ // ── Sub-signal computations ────────────────────────────────────────────────────
158
+ function computeNovelty(body, type, vocab) {
159
+ const tokens = tokenizeBody(body);
160
+ const bgs = bigrams(tokens);
161
+ const floor = TYPE_NOVELTY_FLOOR[type] ?? DEFAULT_NOVELTY_FLOOR;
162
+ if (bgs.length === 0)
163
+ return floor;
164
+ const novelCount = bgs.filter((bg) => !vocab.has(bg)).length;
165
+ const bigramNoveltyFraction = novelCount / bgs.length;
166
+ return Math.max(floor, bigramNoveltyFraction);
167
+ }
168
+ function computeMagnitude(body) {
169
+ const lowerBody = body.toLowerCase();
170
+ // Split on word boundaries to avoid partial matches (e.g. "errors" matching "error").
171
+ const words = new Set(lowerBody.split(/\W+/).filter((w) => w.length > 0));
172
+ const matched = [...ALL_MAGNITUDE_KEYWORDS].filter((kw) => words.has(kw));
173
+ if (matched.length === 0)
174
+ return 0;
175
+ const distinctCount = matched.length;
176
+ const rawMagnitude = Math.min(1.0, distinctCount / MAGNITUDE_FULL_SCORE_THRESHOLD);
177
+ // Require at least one match from each bucket to lift the single-bucket cap.
178
+ const hasSeverity = matched.some((kw) => SEVERITY_BUCKET.has(kw));
179
+ const hasConstraint = matched.some((kw) => CONSTRAINT_BUCKET.has(kw));
180
+ if (hasSeverity && hasConstraint)
181
+ return rawMagnitude;
182
+ return Math.min(rawMagnitude, MAGNITUDE_SINGLE_BUCKET_CAP);
183
+ }
184
+ function computePredictionError(revisionCount) {
185
+ if (revisionCount === 0)
186
+ return 1.0;
187
+ return 1 / (1 + Math.log(1 + revisionCount));
188
+ }
189
+ // ── Main export ────────────────────────────────────────────────────────────────
190
+ /**
191
+ * Compute the encoding-time salience score for an asset.
192
+ *
193
+ * Three sub-signals:
194
+ * novelty (0.40) — how much of the body is absent from the stash vocabulary.
195
+ * magnitude (0.35) — presence of severity/constraint keywords.
196
+ * predictionError (0.25) — surprise: high for new assets, decays with revisions.
197
+ */
198
+ export function scoreEncodingSalience(inputs) {
199
+ const novelty = computeNovelty(inputs.body, inputs.type, inputs.existingRefVocabulary);
200
+ const magnitude = computeMagnitude(inputs.body);
201
+ const predictionError = computePredictionError(inputs.revisionCount);
202
+ const raw = W_NOVELTY * novelty + W_MAGNITUDE * magnitude + W_PREDICTION_ERROR * predictionError;
203
+ const score = Math.min(1, Math.max(0, raw));
204
+ return { score, novelty, magnitude, predictionError };
205
+ }