akm-cli 0.9.0-beta.54 → 0.9.0-beta.56

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 (103) hide show
  1. package/dist/cli.js +5 -3
  2. package/dist/commands/agent/contribute-cli.js +2 -3
  3. package/dist/commands/env/env-cli.js +187 -202
  4. package/dist/commands/env/secret-cli.js +109 -121
  5. package/dist/commands/feedback-cli.js +152 -155
  6. package/dist/commands/health/advisories.js +151 -0
  7. package/dist/commands/health/improve-metrics.js +754 -0
  8. package/dist/commands/health/llm-usage.js +65 -0
  9. package/dist/commands/health/md-report.js +103 -0
  10. package/dist/commands/health/metrics.js +278 -0
  11. package/dist/commands/health/task-runs.js +135 -0
  12. package/dist/commands/health/types.js +18 -0
  13. package/dist/commands/health/windows.js +196 -0
  14. package/dist/commands/health.js +14 -1624
  15. package/dist/commands/improve/anti-collapse.js +170 -0
  16. package/dist/commands/improve/collapse-detector.js +3 -2
  17. package/dist/commands/improve/consolidate.js +636 -633
  18. package/dist/commands/improve/dedup.js +1 -1
  19. package/dist/commands/improve/distill/content-repair.js +202 -0
  20. package/dist/commands/improve/distill/promote-memory.js +228 -0
  21. package/dist/commands/improve/distill/quality-gate.js +233 -0
  22. package/dist/commands/improve/distill-guards.js +127 -0
  23. package/dist/commands/improve/distill.js +49 -575
  24. package/dist/commands/improve/extract-cli.js +74 -76
  25. package/dist/commands/improve/extract.js +6 -4
  26. package/dist/commands/improve/hot-probation.js +45 -0
  27. package/dist/commands/improve/improve-auto-accept.js +3 -2
  28. package/dist/commands/improve/improve-cli.js +14 -13
  29. package/dist/commands/improve/improve-result-file.js +2 -1
  30. package/dist/commands/improve/improve.js +6 -5
  31. package/dist/commands/improve/loop-stages.js +19 -21
  32. package/dist/commands/improve/preparation.js +4 -2
  33. package/dist/commands/improve/procedural.js +10 -31
  34. package/dist/commands/improve/recombine.js +19 -43
  35. package/dist/commands/improve/reflect.js +1 -1
  36. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  37. package/dist/commands/improve/shared.js +48 -0
  38. package/dist/commands/observability-cli.js +4 -4
  39. package/dist/commands/proposal/drain-policies.js +2 -2
  40. package/dist/commands/proposal/drain.js +1 -1
  41. package/dist/commands/proposal/legacy-import.js +115 -0
  42. package/dist/commands/proposal/proposal-cli.js +3 -3
  43. package/dist/commands/proposal/proposal.js +2 -1
  44. package/dist/commands/proposal/propose.js +1 -1
  45. package/dist/commands/proposal/repository.js +829 -0
  46. package/dist/commands/proposal/validators/proposals.js +5 -920
  47. package/dist/commands/read/remember-cli.js +132 -137
  48. package/dist/commands/read/search-cli.js +1 -1
  49. package/dist/commands/registry-cli.js +76 -87
  50. package/dist/commands/sources/add-cli.js +90 -94
  51. package/dist/commands/sources/history.js +1 -1
  52. package/dist/commands/sources/schema-repair.js +1 -1
  53. package/dist/commands/sources/sources-cli.js +3 -3
  54. package/dist/commands/sources/stash-cli.js +1 -1
  55. package/dist/commands/tasks/tasks-cli.js +1 -2
  56. package/dist/commands/wiki-cli.js +2 -3
  57. package/dist/core/common.js +3 -3
  58. package/dist/core/config/config-schema.js +6 -0
  59. package/dist/core/deep-merge.js +38 -0
  60. package/dist/core/events.js +2 -1
  61. package/dist/core/logs-db.js +8 -13
  62. package/dist/core/paths.js +14 -14
  63. package/dist/core/state-db.js +13 -1140
  64. package/dist/indexer/db/db.js +96 -723
  65. package/dist/indexer/db/entry-mapper.js +41 -0
  66. package/dist/indexer/db/schema.js +516 -0
  67. package/dist/indexer/feedback/utility-policy.js +75 -0
  68. package/dist/indexer/graph/graph-extraction.js +2 -1
  69. package/dist/indexer/index-writer-lock.js +9 -0
  70. package/dist/indexer/indexer.js +78 -23
  71. package/dist/indexer/search/fts-query.js +51 -0
  72. package/dist/integrations/agent/spawn.js +15 -66
  73. package/dist/llm/embedders/cache.js +3 -1
  74. package/dist/output/text/helpers.js +13 -0
  75. package/dist/registry/resolve.js +5 -0
  76. package/dist/scripts/migrate-storage.js +6908 -7447
  77. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +44 -43
  78. package/dist/setup/legacy-config.js +106 -0
  79. package/dist/setup/prompt.js +57 -0
  80. package/dist/setup/providers.js +14 -0
  81. package/dist/setup/semantic-assets.js +124 -0
  82. package/dist/setup/setup.js +24 -1607
  83. package/dist/setup/steps/connection.js +734 -0
  84. package/dist/setup/steps/output.js +31 -0
  85. package/dist/setup/steps/platforms.js +124 -0
  86. package/dist/setup/steps/semantic.js +27 -0
  87. package/dist/setup/steps/sources.js +222 -0
  88. package/dist/setup/steps/stashdir.js +42 -0
  89. package/dist/setup/steps/tasks.js +152 -0
  90. package/dist/storage/repositories/canaries-repository.js +107 -0
  91. package/dist/storage/repositories/consolidation-repository.js +38 -0
  92. package/dist/storage/repositories/embeddings-repository.js +72 -0
  93. package/dist/storage/repositories/events-repository.js +187 -0
  94. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  95. package/dist/storage/repositories/improve-runs-repository.js +130 -0
  96. package/dist/storage/repositories/index-db.js +4 -7
  97. package/dist/storage/repositories/proposals-repository.js +220 -0
  98. package/dist/storage/repositories/recombine-repository.js +213 -0
  99. package/dist/storage/repositories/task-history-repository.js +93 -0
  100. package/dist/storage/sqlite-pragmas.js +3 -3
  101. package/dist/tasks/runner.js +2 -1
  102. package/package.json +1 -1
  103. package/dist/commands/improve/homeostatic.js +0 -497
@@ -51,7 +51,6 @@
51
51
  * be invoked from CI / automation without spinning up an agent harness.
52
52
  */
53
53
  import fs from "node:fs";
54
- import path from "node:path";
55
54
  import distillKnowledgeSystemPrompt from "../../assets/prompts/distill-knowledge-system.md" with { type: "text" };
56
55
  import distillLessonSystemPrompt from "../../assets/prompts/distill-lesson-system.md" with { type: "text" };
57
56
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
@@ -59,7 +58,7 @@ import { assembleAssetFromString } from "../../core/asset/asset-serialize.js";
59
58
  import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/frontmatter.js";
60
59
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
61
60
  import { authoringRulesForType } from "../../core/authoring-rules.js";
62
- import { resolveStashDir, timestampForFilename } from "../../core/common.js";
61
+ import { resolveStashDir } from "../../core/common.js";
63
62
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
64
63
  import { ConfigError, UsageError } from "../../core/errors.js";
65
64
  import { appendEvent, readEvents } from "../../core/events.js";
@@ -72,13 +71,18 @@ import { closeDatabase, getAllEntries, openIndexDatabase } from "../../indexer/d
72
71
  import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
73
72
  import { chatCompletion, parseEmbeddedJsonResponse } from "../../llm/client.js";
74
73
  import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
75
- import { createProposal, isProposalSkipped, listProposals, } from "../proposal/validators/proposals.js";
76
- import { akmSearch } from "../read/search.js";
74
+ import { createProposal, isProposalSkipped, listProposals, } from "../proposal/repository.js";
77
75
  import { stripFrontmatterBody as stripBodyForFidelity } from "./dedup.js";
78
- import { assessMemoryKnowledgePromotionCandidate, deriveKnowledgeRef } from "./distill-promotion-policy.js";
76
+ import { autoRepairLessonFrontmatter, autoSwapDescriptionWhenToUse, collectLessonQualityFindings, repairLessonDescriptionTruncation, } from "./distill/content-repair.js";
77
+ import { promoteMemoryToKnowledge } from "./distill/promote-memory.js";
78
+ import { fetchTopSimilarLessons, persistOutputEncodingSalience, runLessonQualityJudge, writeQualityRejection, } from "./distill/quality-gate.js";
79
+ import { buildClsContext, checkDistillFidelity } from "./distill-guards.js";
80
+ import { deriveKnowledgeRef } from "./distill-promotion-policy.js";
79
81
  import { buildRefVocabulary, scoreEncodingSalience } from "./encoding-salience.js";
80
- import { buildClsContext, checkDistillFidelity } from "./homeostatic.js";
81
82
  import { computeSalience, upsertAssetSalience } from "./salience.js";
83
+ // Re-exported for `reflect.ts`, which applies the same LLM-as-judge gate to
84
+ // reflect proposals (R-5 / #374).
85
+ export { runLessonQualityJudge };
82
86
  /**
83
87
  * Asset-ref types that `akm distill` structurally refuses as inputs.
84
88
  *
@@ -123,7 +127,6 @@ export function deriveLessonRef(inputRef) {
123
127
  .replace(/^-|-$/g, "");
124
128
  return `lesson:${safe}-lesson`;
125
129
  }
126
- import { repairTruncatedDescription } from "../../core/text-truncation.js";
127
130
  // ── Content quality validators ──────────────────────────────────────────────
128
131
  //
129
132
  // The actual implementations now live in `core/proposal-quality-validators.ts`
@@ -406,218 +409,6 @@ export function buildDistillPrompt(input) {
406
409
  }
407
410
  return lines.join("\n");
408
411
  }
409
- // ── D-4 / #390: Top-3 similar lessons retrieval ──────────────────────────────
410
- /**
411
- * Default implementation: use akmSearch to find top-N similar lesson assets.
412
- * Returns empty array when search fails or returns no results.
413
- * Requires embedding configured for semantic similarity; degrades gracefully.
414
- */
415
- async function fetchTopSimilarLessons(query, n, _stashDir) {
416
- try {
417
- const result = await akmSearch({
418
- query,
419
- type: "lesson",
420
- limit: n,
421
- skipLogging: true,
422
- eventSource: "improve",
423
- });
424
- const hits = result?.hits ?? [];
425
- return hits
426
- .filter((h) => "path" in h && typeof h.path === "string")
427
- .slice(0, n)
428
- .map((h) => {
429
- let content = "";
430
- try {
431
- if (h.path && fs.existsSync(h.path)) {
432
- content = fs.readFileSync(h.path, "utf8");
433
- }
434
- }
435
- catch {
436
- /* best-effort */
437
- }
438
- return { ref: h.ref, content };
439
- });
440
- }
441
- catch {
442
- return [];
443
- }
444
- }
445
- // ── LLM-as-judge quality gate (P2-B) ────────────────────────────────────────
446
- /**
447
- * D-4 / #390: Build the LLM-as-judge prompt.
448
- *
449
- * When similarLessons are provided (top-3 by embedding similarity), they are
450
- * included in the context so the judge can lower the score for near-duplicates.
451
- * Voyager arXiv:2305.16291 — skill library admission requires similarity check
452
- * against the existing library. A-MEM arXiv:2502.12110 — new notes are checked
453
- * against existing notes before linking.
454
- */
455
- function buildJudgePrompt(lessonContent, sourceContent, similarLessons) {
456
- const lines = [
457
- "You are evaluating a proposed lesson asset for an akm knowledge base.",
458
- "",
459
- "Score this lesson on each criterion from 1 (poor) to 5 (excellent):",
460
- "1. NOVELTY: Does the lesson add information not already present in the source asset?",
461
- "2. ACTIONABILITY: Can an agent follow this lesson without additional context?",
462
- "3. NON-REDUNDANCY: Is this lesson meaningfully different from what the source already says?",
463
- "",
464
- "Source asset content:",
465
- "```",
466
- sourceContent.slice(0, 2000),
467
- "```",
468
- ];
469
- if (similarLessons && similarLessons.length > 0) {
470
- lines.push("");
471
- lines.push("Existing similar lessons (top-3 by similarity). Rate lower if the proposed lesson is substantially similar to any of these:");
472
- for (const sl of similarLessons) {
473
- lines.push(`\nExisting lesson ref: ${sl.ref}`);
474
- lines.push("```");
475
- lines.push(sl.content.slice(0, 500));
476
- lines.push("```");
477
- }
478
- }
479
- lines.push("");
480
- lines.push("Proposed lesson content:");
481
- lines.push("```");
482
- lines.push(lessonContent.slice(0, 1000));
483
- lines.push("```");
484
- lines.push("");
485
- lines.push('Return ONLY valid JSON, no prose: {"score": <average score 1-5 as float>, "reason": "<one sentence>"}');
486
- return lines.join("\n");
487
- }
488
- /**
489
- * Run the LLM-as-judge quality gate on a proposal's content.
490
- *
491
- * Exported so reflect.ts can apply the same gate to reflect proposals (R-5 / #374).
492
- * Gated by the flag name `lesson_quality_gate` (or its alias
493
- * `proposal_quality_gate`) via {@link isLlmFeatureEnabled} — which reads
494
- * `profiles.improve.default.processes.distill.qualityGate.enabled` (and the
495
- * corresponding `.reflect.qualityGate.enabled` for proposals).
496
- *
497
- * Fail-open: returns `pass: true` on timeout, parse failure, or missing LLM.
498
- */
499
- export async function runLessonQualityJudge(config, lessonContent, sourceContent, chat,
500
- /** D-4 / #390: top-3 similar existing lessons for dedup check. */
501
- similarLessons) {
502
- const llmConfig = getDefaultLlmConfig(config);
503
- if (!llmConfig) {
504
- return { pass: true, score: -1, reason: "no LLM configured — passing through" };
505
- }
506
- const judgeLlmConfig = llmConfig.judgeModel ? { ...llmConfig, model: llmConfig.judgeModel } : llmConfig;
507
- const JUDGE_TIMEOUT_MS = 8_000;
508
- try {
509
- const raw = await Promise.race([
510
- chat(judgeLlmConfig, [
511
- { role: "system", content: "Return only valid JSON. No prose." },
512
- { role: "user", content: buildJudgePrompt(lessonContent, sourceContent, similarLessons) },
513
- ]),
514
- new Promise((_, reject) => setTimeout(() => reject(new Error("judge timeout")), JUDGE_TIMEOUT_MS)),
515
- ]);
516
- const parsed = parseEmbeddedJsonResponse(raw);
517
- if (!parsed || typeof parsed.score !== "number") {
518
- return { pass: true, score: -1, reason: "judge parse failed — passing through" };
519
- }
520
- // D-5 / #388: Three-band system (MT-Bench arXiv:2306.05685 — ~±0.5 judge variance).
521
- // >= 3.5: auto-queue as pending (pass: true)
522
- // 2.5–3.5: review-needed band — uncertain, escalate to human (reviewNeeded: true)
523
- // < 2.5: auto-reject (pass: false)
524
- const score = parsed.score;
525
- const reason = parsed.reason ?? "";
526
- if (score >= 3.5) {
527
- return { pass: true, score, reason };
528
- }
529
- if (score >= 2.5) {
530
- // Uncertainty band: treat as failed for auto-queuing but flag for review.
531
- return { pass: false, score, reason, reviewNeeded: true };
532
- }
533
- return { pass: false, score, reason };
534
- }
535
- catch {
536
- return { pass: true, score: -1, reason: "judge failed — passing through" };
537
- }
538
- }
539
- // ── Quality-rejection helper ─────────────────────────────────────────────────
540
- /**
541
- * Write a rejected lesson to `.akm/distill-rejected/`, append a `distill_invoked`
542
- * quality-rejected event, and return the `quality_rejected` envelope.
543
- *
544
- * @param stash - Root stash directory.
545
- * @param inputRef - The original input ref (for the event).
546
- * @param lessonRef - The proposed lesson/knowledge ref.
547
- * @param content - The raw content that failed the quality gate.
548
- * @param score - Quality score from the judge.
549
- * @param reason - Human-readable rejection reason.
550
- * @param extraMeta - Optional additional metadata for the event.
551
- */
552
- function writeQualityRejection(stash, inputRef, lessonRef, content, score, reason, extraMeta = {}, eligibilitySource) {
553
- // D-5 / #388: reviewNeeded flag selects "review_needed" vs "quality_rejected" outcome.
554
- const outcome = extraMeta.reviewNeeded ? "review_needed" : "quality_rejected";
555
- const rejectDir = path.join(stash, ".akm", "distill-rejected");
556
- fs.mkdirSync(rejectDir, { recursive: true });
557
- const ts = timestampForFilename();
558
- fs.writeFileSync(path.join(rejectDir, `${ts}-${lessonRef}.md`), `---\nscore: ${score}\nreason: ${reason}\noutcome: ${outcome}\n---\n\n${content}`, "utf8");
559
- appendEvent({
560
- eventType: "distill_invoked",
561
- ref: inputRef,
562
- metadata: {
563
- outcome,
564
- lessonRef,
565
- score,
566
- reason,
567
- ...extraMeta,
568
- // Attribution tagging: stamp the eligibility lane so distill_invoked can be
569
- // sliced by lane downstream. See EligibilitySource.
570
- ...(eligibilitySource ? { eligibilitySource } : {}),
571
- },
572
- });
573
- return {
574
- schemaVersion: 1,
575
- ok: true,
576
- outcome,
577
- inputRef,
578
- lessonRef,
579
- score,
580
- reason,
581
- ...extraMeta,
582
- };
583
- }
584
- /**
585
- * G4 — content-score a distilled OUTPUT (lesson/knowledge proposal body) and
586
- * persist it to state.db :: asset_salience with `encoding_source: "content"`.
587
- *
588
- * Lessons are refused as distill INPUTS (`DISTILL_REFUSED_INPUT_TYPES`), so
589
- * this creation-time write is their only chance to earn a real content-derived
590
- * encoding score instead of sitting on the type-weight stub forever. Best-effort:
591
- * never blocks or fails the proposal flow.
592
- */
593
- function persistOutputEncodingSalience(ref, body, existingRefVocabulary,
594
- // Operator opt-out (improve.salience.outcomeWeightEnabled: false) must apply
595
- // here too, or distill-written rank_score rows would use WS-2 weights while
596
- // preparation uses parity weights — inconsistent salience semantics.
597
- outcomeWeightEnabled) {
598
- try {
599
- const parsedRef = parseAssetRef(ref);
600
- const salienceResult = scoreEncodingSalience({
601
- body,
602
- type: parsedRef.type,
603
- existingRefVocabulary,
604
- revisionCount: 0, // a freshly distilled output IS a first encounter
605
- });
606
- withStateDb((stateDb) => {
607
- const vector = computeSalience({
608
- ref,
609
- type: parsedRef.type,
610
- retrievalFreq: 0,
611
- encodingSalience: salienceResult.score,
612
- outcomeWeightEnabled,
613
- });
614
- upsertAssetSalience(stateDb, ref, vector);
615
- });
616
- }
617
- catch {
618
- // Best-effort — scoring must never block proposal creation.
619
- }
620
- }
621
412
  // ── Main entry point ────────────────────────────────────────────────────────
622
413
  /**
623
414
  * Run a single bounded distillation pass for `ref`. Always emits exactly one
@@ -788,196 +579,34 @@ export async function akmDistill(options) {
788
579
  eventType: e.eventType,
789
580
  ...(e.metadata !== undefined ? { metadata: e.metadata } : {}),
790
581
  }));
791
- const promotion = targetKind === "lesson"
792
- ? null
793
- : assessMemoryKnowledgePromotionCandidate({
794
- inputRef,
795
- assetContent,
796
- feedbackEvents: filteredEvents.map((event) => ({
797
- ...(event.metadata !== undefined ? { metadata: event.metadata } : {}),
798
- })),
799
- });
800
- if (promotion?.promote && promotion.content && (targetKind === "knowledge" || targetKind === "auto")) {
801
- // D-1 / #369: When the destination knowledge file already exists, route
802
- // through the LLM for contradiction resolution instead of silently
803
- // overwriting. Follows mem0 ADD/UPDATE/DELETE/NOOP pattern (arXiv:2504.19413 §3.2)
804
- // and A-MEM dynamic linking (arXiv:2502.12110).
805
- let resolvedPromotionContent = promotion.content;
806
- const existingKnowledgePath = await lookup(promotion.knowledgeRef);
807
- const existingKnowledgeContent = existingKnowledgePath && fs.existsSync(existingKnowledgePath)
808
- ? (() => {
809
- try {
810
- return fs.readFileSync(existingKnowledgePath, "utf8");
811
- }
812
- catch {
813
- return null;
814
- }
815
- })()
816
- : null;
817
- if (existingKnowledgeContent && config && getDefaultLlmConfig(config)) {
818
- // Existing content found: call LLM for contradiction-resolution merge.
819
- const mergePrompt = [
820
- "You are merging two versions of a knowledge document.",
821
- "Existing content is already committed; new content comes from a memory distillation run.",
822
- "Choose one of: ADD (combine both), UPDATE (replace existing with new), NOOP (keep existing unchanged).",
823
- 'Return ONLY valid JSON: {"action": "ADD"|"UPDATE"|"NOOP", "content": "<merged markdown if ADD/UPDATE, empty string if NOOP>"}',
824
- "",
825
- "## Existing knowledge content",
826
- "```",
827
- existingKnowledgeContent.slice(0, 3000),
828
- "```",
829
- "",
830
- "## New content from distillation",
831
- "```",
832
- promotion.content.slice(0, 3000),
833
- "```",
834
- ].join("\n");
835
- try {
836
- const mergeLlm = getDefaultLlmConfig(config);
837
- if (!mergeLlm) {
838
- throw new ConfigError("LLM is not configured for distillation merge.", "LLM_NOT_CONFIGURED");
839
- }
840
- const mergeResponse = await chat(mergeLlm, [
841
- { role: "system", content: "Return only valid JSON. No prose." },
842
- { role: "user", content: mergePrompt },
843
- ]);
844
- const mergeResult = parseEmbeddedJsonResponse(mergeResponse);
845
- if (mergeResult?.action === "NOOP") {
846
- // Existing content is authoritative — no update needed.
847
- appendEvent({
848
- eventType: "distill_invoked",
849
- ref: inputRef,
850
- metadata: {
851
- outcome: "skipped",
852
- lessonRef: promotion.knowledgeRef,
853
- message: "D-1: LLM resolved destination conflict as NOOP — existing content kept",
854
- ...eligMeta,
855
- },
856
- });
857
- return {
858
- schemaVersion: 1,
859
- ok: true,
860
- outcome: "skipped",
861
- inputRef,
862
- lessonRef: promotion.knowledgeRef,
863
- message: "Existing knowledge content unchanged (contradiction resolution: NOOP)",
864
- };
865
- }
866
- if (mergeResult?.action && (mergeResult.action === "ADD" || mergeResult.action === "UPDATE")) {
867
- if (mergeResult.content?.trim()) {
868
- resolvedPromotionContent = mergeResult.content;
869
- }
870
- }
871
- }
872
- catch {
873
- // LLM merge failed — fall through with the original promotion content.
874
- // The reviewer will see both versions in the proposal diff.
875
- }
876
- }
877
- else if (existingKnowledgeContent && config && !getDefaultLlmConfig(config)) {
878
- // No LLM configured: include existing content as context in the proposal
879
- // so the reviewer can do the contradiction resolution manually.
880
- resolvedPromotionContent = [
881
- promotion.content,
882
- "",
883
- "---",
884
- "<!-- D-1 / #369: Existing knowledge content is shown below for reviewer reference. -->",
885
- "<!-- Review: decide whether to ADD (merge), UPDATE (replace), or NOOP (keep existing). -->",
886
- "",
887
- "## Existing content (for reviewer reference)",
888
- "",
889
- existingKnowledgeContent,
890
- ].join("\n");
891
- }
892
- // Apply quality gate to fast-path knowledge promotion (Risk 4 fix).
893
- // D-5 / #388: Three-band system — review_needed band queues to proposal
894
- // queue with review_needed outcome rather than auto-rejecting.
895
- let knowledgeJudgeConfidence;
896
- if (isLlmFeatureEnabled(config, "lesson_quality_gate")) {
897
- // D-4 / #390: retrieve top-3 similar lessons for dedup check in judge.
898
- const similarLessons = await fetchSimilarLessonsFn(resolvedPromotionContent.slice(0, 500), 3);
899
- const judgeResult = await runLessonQualityJudge(config, resolvedPromotionContent, assetContent ?? "", chat, similarLessons.length > 0 ? similarLessons : undefined);
900
- if (!judgeResult.pass) {
901
- if (judgeResult.reviewNeeded) {
902
- // Uncertainty band (2.5–3.5): queue as review_needed instead of rejecting.
903
- return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, { reviewNeeded: true }, options.eligibilitySource);
904
- }
905
- return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, {}, options.eligibilitySource);
906
- }
907
- // Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
908
- // (no LLM / timeout / parse failure) — leave confidence undefined so
909
- // the auto-accept gate treats the proposal as unscored and skips it.
910
- if (judgeResult.score > 0)
911
- knowledgeJudgeConfidence = judgeResult.score / 5;
912
- }
913
- const knowledgeParsed = parseFrontmatter(resolvedPromotionContent);
914
- const proposalResult = createProposal(stash, {
915
- ref: promotion.knowledgeRef,
916
- source: "distill",
917
- ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
918
- payload: {
919
- content: resolvedPromotionContent,
920
- ...(Object.keys(knowledgeParsed.data).length > 0 ? { frontmatter: knowledgeParsed.data } : {}),
921
- },
922
- ...(knowledgeJudgeConfidence !== undefined ? { confidence: knowledgeJudgeConfidence } : {}),
923
- // Attribution tagging: persist the eligibility lane on the proposal.
924
- ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
925
- }, options.ctx);
926
- if (isProposalSkipped(proposalResult)) {
927
- appendEvent({
928
- eventType: "distill_invoked",
929
- ref: inputRef,
930
- metadata: {
931
- outcome: "skipped",
932
- lessonRef: promotion.knowledgeRef,
933
- message: proposalResult.message,
934
- skipReason: proposalResult.reason,
935
- ...eligMeta,
936
- },
937
- });
938
- return {
939
- schemaVersion: 1,
940
- ok: true,
941
- outcome: "skipped",
942
- inputRef,
943
- lessonRef: promotion.knowledgeRef,
944
- message: proposalResult.message,
945
- };
946
- }
947
- const proposal = proposalResult;
948
- // G4: content-score the distilled OUTPUT so it carries a real encoding
949
- // salience (encoding_source='content') from creation.
950
- persistOutputEncodingSalience(promotion.knowledgeRef, resolvedPromotionContent, existingRefVocabulary, outcomeWeightEnabled);
951
- appendEvent({
952
- eventType: "distill_invoked",
953
- ref: inputRef,
954
- metadata: {
955
- outcome: "queued",
956
- lessonRef: promotion.knowledgeRef,
957
- proposalRef: promotion.knowledgeRef,
958
- proposalKind: "knowledge",
959
- proposalId: proposal.id,
960
- // R3: judge verdicts are longitudinally queryable, not just a one-shot
961
- // proposal.confidence write (normalized 1–5 score / 5).
962
- ...(knowledgeJudgeConfidence !== undefined ? { judgeConfidence: knowledgeJudgeConfidence } : {}),
963
- ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
964
- ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
965
- ...eligMeta,
966
- },
967
- });
968
- return {
969
- schemaVersion: 1,
970
- ok: true,
971
- outcome: "queued",
972
- inputRef,
973
- lessonRef: promotion.knowledgeRef,
974
- proposalRef: promotion.knowledgeRef,
975
- proposalKind: "knowledge",
976
- proposalId: proposal.id,
977
- proposal,
978
- ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
979
- };
980
- }
582
+ // Memory→knowledge promotion branch (D-1/#369). When the target ref is a
583
+ // reinforced memory, distill graduates it into a knowledge proposal instead
584
+ // of a lesson — the whole branch (LLM contradiction-merge, quality gate,
585
+ // proposal creation, event emit) lives in `promoteMemoryToKnowledge` and is
586
+ // terminal when it fires. A `null` return means "not a promotion candidate";
587
+ // fall through to the ordinary lesson/knowledge distillation path.
588
+ const promotionResult = await promoteMemoryToKnowledge({
589
+ targetKind,
590
+ inputRef,
591
+ assetContent,
592
+ filteredEvents,
593
+ config,
594
+ chat,
595
+ stash,
596
+ lookup,
597
+ fetchSimilarLessonsFn,
598
+ existingRefVocabulary,
599
+ outcomeWeightEnabled,
600
+ eligMeta,
601
+ eligibilitySource: options.eligibilitySource,
602
+ sourceRun: options.sourceRun,
603
+ proposalsCtx: options.ctx,
604
+ exclusionSetSize: exclusionSet.size,
605
+ filteredFeedbackCount,
606
+ feedbackFullyFiltered,
607
+ });
608
+ if (promotionResult)
609
+ return promotionResult;
981
610
  const effectiveProposalKind = targetKind === "knowledge" ? "knowledge" : "lesson";
982
611
  const effectiveLessonRef = effectiveProposalKind === "knowledge" ? deriveKnowledgeRef(inputRef) : deriveLessonRef(inputRef);
983
612
  // Inject last 1–3 rejected proposals for this ref as Reflexion-style
@@ -1131,136 +760,20 @@ export async function akmDistill(options) {
1131
760
  // Strip any stray fence the LLM might have added around the markdown.
1132
761
  content = stripMarkdownFences(raw);
1133
762
  }
1134
- // Auto-repair missing frontmatter fields before hard-failing. Small models
1135
- // frequently produce a good lesson body but omit the YAML header entirely.
1136
- // Rather than discarding valid content, we extract description/when_to_use
1137
- // from the body and prepend the required frontmatter block.
1138
- //
1139
- // IMPORTANT: We do NOT synthesise placeholder strings here. If the body
1140
- // does not contain text that passes the post-LLM validators
1141
- // (`isValidDescription` / `isValidWhenToUse`), we leave the field missing
1142
- // and let the lesson lint reject the proposal as `validation_failed`.
1143
- // Emitting placeholders like `"Lesson distilled from <ref>"` or
1144
- // `"When working with <slug>"` is what produced the systematic broken
1145
- // proposals observed across 323 archived rejections.
763
+ // Lesson-path content normalization (see distill/content-repair): auto-repair
764
+ // missing frontmatter, description↔when_to_use auto-swap, and truncation
765
+ // repair. Knowledge output skips all three (no lesson frontmatter contract).
1146
766
  if (effectiveProposalKind !== "knowledge") {
1147
- const parsed = parseFrontmatter(content);
1148
- const fm = (parsed.data ?? {});
1149
- const missingDesc = typeof fm.description !== "string" || !fm.description.trim();
1150
- const missingWtu = typeof fm.when_to_use !== "string" || !fm.when_to_use.trim();
1151
- if (missingDesc || missingWtu) {
1152
- const body = parsed.content.trim();
1153
- // Strip markdown formatting tokens from a line so extracted text is clean.
1154
- const stripMd = (l) => l
1155
- .replace(/\*\*([^*]+)\*\*/g, "$1")
1156
- .replace(/\*([^*]+)\*/g, "$1")
1157
- .replace(/`([^`]+)`/g, "$1")
1158
- .replace(/^[#*\->_]+\s*/, "")
1159
- .replace(/:\s*$/, "")
1160
- .trim();
1161
- // Skip lines that look like YAML field assignments (key: value) or frontmatter delimiters.
1162
- // These appear when the LLM leaks frontmatter content into the body, causing
1163
- // auto-repair to produce description: "description: Key Takeaways".
1164
- const isYamlLike = (l) => /^---/.test(l) || /^[a-z_]+:\s/i.test(l);
1165
- const bodyLines = body.split("\n").map(stripMd);
1166
- // Extract description: first body line that BOTH looks like prose AND
1167
- // passes isValidDescription. If nothing qualifies, leave the field
1168
- // missing — the lint pass will reject the proposal cleanly.
1169
- let descLine;
1170
- for (const l of bodyLines) {
1171
- if (isYamlLike(l))
1172
- continue;
1173
- if (l.length <= 10 || l.length >= 400)
1174
- continue;
1175
- if (isValidDescription(l, inputRef).ok) {
1176
- descLine = l;
1177
- break;
1178
- }
1179
- }
1180
- // Extract when_to_use: a line starting with "When" / "Use when" / "Apply when"
1181
- // that ALSO passes isValidWhenToUse (rejects circular fallbacks).
1182
- let wtuLine;
1183
- for (const l of bodyLines) {
1184
- if (!/^(when |use when|apply when)/i.test(l))
1185
- continue;
1186
- if (l.length >= 400)
1187
- continue;
1188
- if (isValidWhenToUse(l, inputRef).ok) {
1189
- wtuLine = l;
1190
- break;
1191
- }
1192
- }
1193
- const repairedFm = {
1194
- ...fm,
1195
- ...(missingDesc && descLine ? { description: descLine } : {}),
1196
- ...(missingWtu && wtuLine ? { when_to_use: wtuLine } : {}),
1197
- };
1198
- const fmLines = Object.entries(repairedFm)
1199
- .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
1200
- .join("\n");
1201
- // Only rewrite content if we actually have at least one field to write.
1202
- // Otherwise leave the original content for the lint pass to reject.
1203
- if (Object.keys(repairedFm).length > 0) {
1204
- content = assembleAssetFromString(fmLines, body);
1205
- }
1206
- }
767
+ content = autoRepairLessonFrontmatter(content, inputRef);
1207
768
  }
1208
- // Description ↔ when_to_use auto-swap normalization (recover ~93% of
1209
- // qwen-9b's `^when\b/i` rejections at zero LLM cost). When the LLM emits
1210
- // a conditional-framed description ("When X happens, do Y") and the
1211
- // when_to_use field looks like a declarative description (or is empty),
1212
- // the two fields are mis-fielded — exactly what `isValidDescription`'s
1213
- // error message says ("that pattern belongs in when_to_use"). We swap
1214
- // them and revalidate; the swap is committed only if BOTH fields pass
1215
- // their respective validators afterwards. If revalidation still fails,
1216
- // we fall through to the existing reject path.
1217
769
  let descriptionSwapped = 0;
1218
770
  if (effectiveProposalKind !== "knowledge") {
1219
- const parsedSwap = parseFrontmatter(content);
1220
- const fmSwap = (parsedSwap.data ?? {});
1221
- const descRaw = typeof fmSwap.description === "string" ? fmSwap.description.trim() : "";
1222
- const wtuRaw = typeof fmSwap.when_to_use === "string" ? fmSwap.when_to_use.trim() : "";
1223
- const descStartsConditional = /^(when|if)\b/i.test(descRaw);
1224
- const wtuStartsConditional = /^(when|if)\b/i.test(wtuRaw);
1225
- if (descStartsConditional && !wtuStartsConditional && wtuRaw.length > 0) {
1226
- // Try the swap and revalidate. The when_to_use validator requires the
1227
- // value not match `/^when working with\b/i` (the circular fallback) —
1228
- // a real description rarely does, so this usually passes.
1229
- const swappedDescCheck = isValidDescription(wtuRaw, inputRef);
1230
- const swappedWtuCheck = isValidWhenToUse(descRaw, inputRef);
1231
- if (swappedDescCheck.ok && swappedWtuCheck.ok) {
1232
- const swappedFm = {
1233
- ...fmSwap,
1234
- description: wtuRaw,
1235
- when_to_use: descRaw,
1236
- };
1237
- const swappedFmLines = Object.entries(swappedFm)
1238
- .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
1239
- .join("\n");
1240
- content = assembleAssetFromString(swappedFmLines, parsedSwap.content);
1241
- descriptionSwapped = 1;
1242
- }
1243
- }
771
+ const swapResult = autoSwapDescriptionWhenToUse(content, inputRef);
772
+ content = swapResult.content;
773
+ descriptionSwapped = swapResult.swapped;
1244
774
  }
1245
- // Post-generation truncation repair (#556): if the LLM sliced the
1246
- // description mid-sentence, deterministically complete it from its own text
1247
- // / the lesson body BEFORE the lint + quality validators run. No-op
1248
- // (byte-identical) for already-complete descriptions, so this never alters
1249
- // a valid proposal. Runs on the lesson path only (knowledge has no
1250
- // description field gate here).
1251
775
  if (effectiveProposalKind !== "knowledge") {
1252
- const parsedRepair = parseFrontmatter(content);
1253
- const fmRepair = (parsedRepair.data ?? {});
1254
- const descRepairRaw = typeof fmRepair.description === "string" ? fmRepair.description : "";
1255
- if (descRepairRaw) {
1256
- const repaired = repairTruncatedDescription(descRepairRaw, parsedRepair.content);
1257
- if (repaired !== descRepairRaw) {
1258
- const repairedFmLines = Object.entries({ ...fmRepair, description: repaired })
1259
- .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
1260
- .join("\n");
1261
- content = assembleAssetFromString(repairedFmLines, parsedRepair.content);
1262
- }
1263
- }
776
+ content = repairLessonDescriptionTruncation(content);
1264
777
  }
1265
778
  // Parse + lint the lesson before creating the proposal. The lint is the
1266
779
  // canonical gate for required frontmatter (v1 spec §13). On failure we
@@ -1269,49 +782,10 @@ export async function akmDistill(options) {
1269
782
  const findings = effectiveProposalKind === "knowledge"
1270
783
  ? validateKnowledgeContent(content, inputRef)
1271
784
  : lintLessonContent(content, `distill:${inputRef}`).findings;
1272
- // Additional quality validators run only on lessons. lesson-lint checks
1273
- // "field is present and non-empty"; these reject the systematic failure
1274
- // modes observed across 323 archived rejected proposals:
1275
- // - description is a body fragment, section heading, or placeholder
1276
- // - when_to_use is the circular "When working with <ref>" fallback
1277
- // - description == when_to_use (LLM duplicated a single sentence)
1278
- // - body contains a second pseudo-frontmatter block
785
+ // Additional lesson-only quality validators reject the systematic failure
786
+ // modes seen across 323 archived rejected proposals (see distill/content-repair).
1279
787
  if (effectiveProposalKind !== "knowledge" && findings.length === 0) {
1280
- const parsedQC = parseFrontmatter(content);
1281
- const fmQC = (parsedQC.data ?? {});
1282
- const descCheck = isValidDescription(fmQC.description, inputRef);
1283
- if (!descCheck.ok) {
1284
- findings.push({
1285
- kind: "invalid-description",
1286
- field: "description",
1287
- message: `Distilled lesson for ${inputRef} has an invalid description: ${descCheck.reason}.`,
1288
- });
1289
- }
1290
- const wtuCheck = isValidWhenToUse(fmQC.when_to_use, inputRef);
1291
- if (!wtuCheck.ok) {
1292
- findings.push({
1293
- kind: "invalid-when_to_use",
1294
- field: "when_to_use",
1295
- message: `Distilled lesson for ${inputRef} has an invalid when_to_use: ${wtuCheck.reason}.`,
1296
- });
1297
- }
1298
- // description and when_to_use must say different things.
1299
- if (descCheck.ok &&
1300
- wtuCheck.ok &&
1301
- typeof fmQC.description === "string" &&
1302
- typeof fmQC.when_to_use === "string" &&
1303
- fmQC.description.trim().toLowerCase() === fmQC.when_to_use.trim().toLowerCase()) {
1304
- findings.push({
1305
- kind: "description-equals-when_to_use",
1306
- field: "description",
1307
- message: `Distilled lesson for ${inputRef} has identical description and when_to_use.`,
1308
- });
1309
- }
1310
- // Double-frontmatter / pseudo-frontmatter pollution in the body.
1311
- const dfm = detectDoubleFrontmatter(content);
1312
- if (dfm) {
1313
- findings.push({ kind: dfm.kind, field: "body", message: `Distilled lesson for ${inputRef}: ${dfm.message}` });
1314
- }
788
+ findings.push(...collectLessonQualityFindings(content, inputRef));
1315
789
  }
1316
790
  if (findings.length > 0) {
1317
791
  appendEvent({