akm-cli 0.9.0-beta.57 → 0.9.0-beta.59

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 (56) hide show
  1. package/dist/assets/prompts/extract-session.md +5 -1
  2. package/dist/cli/config-migrate.js +7 -1
  3. package/dist/commands/config-cli.js +8 -11
  4. package/dist/commands/health/stash-exposure.js +46 -0
  5. package/dist/commands/health/windows.js +6 -7
  6. package/dist/commands/health.js +31 -10
  7. package/dist/commands/improve/collapse-detector.js +2 -1
  8. package/dist/commands/improve/consolidate/eligibility.js +0 -17
  9. package/dist/commands/improve/consolidate.js +209 -167
  10. package/dist/commands/improve/distill/promote-memory.js +4 -3
  11. package/dist/commands/improve/distill/quality-gate.js +7 -4
  12. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  13. package/dist/commands/improve/distill.js +26 -12
  14. package/dist/commands/improve/extract-prompt.js +16 -2
  15. package/dist/commands/improve/extract.js +16 -8
  16. package/dist/commands/improve/improve-auto-accept.js +22 -1
  17. package/dist/commands/improve/loop-stages.js +7 -2
  18. package/dist/commands/improve/memory/memory-belief.js +14 -15
  19. package/dist/commands/improve/memory/memory-contradiction-detect.js +60 -32
  20. package/dist/commands/improve/memory/memory-improve.js +27 -27
  21. package/dist/commands/improve/preparation.js +6 -5
  22. package/dist/commands/improve/procedural.js +1 -0
  23. package/dist/commands/improve/recombine.js +3 -11
  24. package/dist/commands/improve/reflect-noise.js +1 -1
  25. package/dist/commands/improve/reflect.js +4 -3
  26. package/dist/commands/improve/shared.js +9 -6
  27. package/dist/commands/proposal/drain-policies.js +4 -2
  28. package/dist/commands/read/remember-cli.js +1 -1
  29. package/dist/commands/read/show.js +15 -0
  30. package/dist/commands/remember.js +11 -12
  31. package/dist/commands/sources/init.js +5 -1
  32. package/dist/commands/sources/stash-skeleton.js +34 -0
  33. package/dist/commands/tasks/default-tasks.js +3 -2
  34. package/dist/core/asset/frontmatter.js +22 -0
  35. package/dist/core/common.js +1 -15
  36. package/dist/core/config/config-io.js +10 -1
  37. package/dist/core/config/config-migration.js +2 -15
  38. package/dist/core/config/config-schema.js +15 -3
  39. package/dist/core/config/config.js +22 -14
  40. package/dist/core/paths.js +4 -4
  41. package/dist/core/time.js +53 -0
  42. package/dist/indexer/db/db.js +51 -46
  43. package/dist/indexer/graph/graph-extraction.js +1 -13
  44. package/dist/indexer/indexer.js +77 -65
  45. package/dist/indexer/search/db-search.js +41 -6
  46. package/dist/indexer/search/ranking-contributors.js +14 -8
  47. package/dist/indexer/search/search-source.js +15 -3
  48. package/dist/llm/feature-gate.js +4 -8
  49. package/dist/output/renderers.js +4 -0
  50. package/dist/scripts/migrate-storage.js +83 -59
  51. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +6 -0
  52. package/dist/storage/repositories/registry-cache.js +2 -1
  53. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  54. package/dist/workflows/runtime/runs.js +6 -1
  55. package/package.json +1 -1
  56. package/dist/assets/tasks/core/update-stashes.yml +0 -4
@@ -59,7 +59,7 @@ import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/f
59
59
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
60
60
  import { authoringRulesForType } from "../../core/authoring-rules.js";
61
61
  import { resolveStashDir } from "../../core/common.js";
62
- import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
62
+ import { getDefaultLlmConfig, getImproveProcessConfig, loadConfig } from "../../core/config/config.js";
63
63
  import { ConfigError, UsageError } from "../../core/errors.js";
64
64
  import { appendEvent, readEvents } from "../../core/events.js";
65
65
  import { lintLessonContent } from "../../core/lesson-lint.js";
@@ -91,6 +91,11 @@ export { runLessonQualityJudge };
91
91
  * `lesson:lesson-<name>-lesson-lesson` (double `-lesson` suffix) — the
92
92
  * recursive-ref defect observed across 323 archived rejected proposals.
93
93
  *
94
+ * 08-F2: `env` and `secret` are refused as a STRUCTURAL floor — distill reads
95
+ * the input asset's bytes via `readFileSync` and hands them to the LLM, so
96
+ * secret material must never be a distill input. This gate is code, not config:
97
+ * it holds even when `allowedTypes` config is mis-set in unattended cron.
98
+ *
94
99
  * The runtime gate inside {@link akmDistill} still refuses these inputs
95
100
  * defensively (returning an `outcome: "skipped"` envelope with `skipReason:
96
101
  * "recursive_lesson_input"`). This exported set is the planner-side companion:
@@ -102,7 +107,7 @@ export { runLessonQualityJudge };
102
107
  * type means updating this constant — the planner picks the change up for
103
108
  * free.
104
109
  */
105
- export const DISTILL_REFUSED_INPUT_TYPES = new Set(["lesson"]);
110
+ export const DISTILL_REFUSED_INPUT_TYPES = new Set(["lesson", "env", "secret"]);
106
111
  /**
107
112
  * Returns true when `type` is structurally refused as an input by
108
113
  * {@link akmDistill}. See {@link DISTILL_REFUSED_INPUT_TYPES}.
@@ -441,15 +446,21 @@ export async function akmDistill(options) {
441
446
  // the improve planner can skip these refs before queuing distill attempts;
442
447
  // this runtime check stays as a defensive backstop for direct callers.
443
448
  if (isDistillRefusedInputType(parsedInputRef.type)) {
444
- const skippedRef = `lesson:${parsedInputRef.name}`;
449
+ // 08-F2: env/secret are a secret-material refusal (never read the bytes);
450
+ // lesson is the recursive-form refusal. Both skip BEFORE any readFileSync.
451
+ const isSecretInput = parsedInputRef.type === "env" || parsedInputRef.type === "secret";
452
+ const skippedRef = isSecretInput ? inputRef : `lesson:${parsedInputRef.name}`;
453
+ const message = isSecretInput
454
+ ? `Distill refuses ${parsedInputRef.type} inputs — secret material must never be sent to the LLM.`
455
+ : "Distill refuses lesson inputs — lessons are the distilled form, not a source.";
445
456
  appendEvent({
446
457
  eventType: "distill_invoked",
447
458
  ref: inputRef,
448
459
  metadata: {
449
460
  outcome: "skipped",
450
461
  lessonRef: skippedRef,
451
- message: "distill refuses lesson inputs — lessons are the distilled form, not a source",
452
- skipReason: "recursive_lesson_input",
462
+ message,
463
+ skipReason: isSecretInput ? "refused_secret_input" : "recursive_lesson_input",
453
464
  ...eligMeta,
454
465
  },
455
466
  });
@@ -459,7 +470,7 @@ export async function akmDistill(options) {
459
470
  outcome: "skipped",
460
471
  inputRef,
461
472
  lessonRef: skippedRef,
462
- message: "Distill refuses lesson inputs — lessons are the distilled form, not a source.",
473
+ message,
463
474
  };
464
475
  }
465
476
  const config = options.config ?? loadConfig();
@@ -623,7 +634,7 @@ export async function akmDistill(options) {
623
634
  // When cls.enabled, inject embedding-retrieved adjacent lessons/knowledge
624
635
  // into the distill prompt so the LLM avoids overwriting prior generalizations
625
636
  // (catastrophic interference). DEFAULT OFF.
626
- const clsConfig = config.profiles?.improve?.default?.processes?.distill?.cls ?? {};
637
+ const clsConfig = getImproveProcessConfig(config, "distill", options.improveProfile)?.cls ?? {};
627
638
  let clsContext = "";
628
639
  if (clsConfig.enabled) {
629
640
  try {
@@ -806,7 +817,8 @@ export async function akmDistill(options) {
806
817
  : "Lessons require non-empty `description` and `when_to_use` frontmatter fields. See v1 spec §13.");
807
818
  }
808
819
  // LLM-as-judge quality gate (P2-B). Only active when the feature flag is
809
- // explicitly enabled. Fail-open: judge failures always pass through.
820
+ // explicitly enabled. Fail-CLOSED (07 P0-2): an unjudgeable proposal (no LLM
821
+ // / timeout / parse failure) is rejected, not passed through.
810
822
  // D-5 / #388: Three-band system — review_needed band queues a proposal
811
823
  // with review_needed outcome rather than auto-rejecting.
812
824
  let lessonJudgeConfidence;
@@ -823,9 +835,11 @@ export async function akmDistill(options) {
823
835
  }
824
836
  return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}, options.eligibilitySource);
825
837
  }
826
- // Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
827
- // (no LLM / timeout / parse failure) leave confidence undefined so
828
- // the auto-accept gate treats the proposal as unscored and skips it.
838
+ // Normalize 1-5 judge score to [0, 1]. Only a real passing verdict
839
+ // reaches here (07 P0-2: the judge now fails CLOSED on no-LLM / timeout /
840
+ // parse failure, so those return pass:false and never fall through to
841
+ // this line). A defensive score>0 guard keeps confidence undefined for any
842
+ // non-positive score the auto-accept gate should treat as unscored.
829
843
  if (judgeResult.score > 0)
830
844
  lessonJudgeConfidence = judgeResult.score / 5;
831
845
  }
@@ -833,7 +847,7 @@ export async function akmDistill(options) {
833
847
  // When fidelityCheck.enabled, check the distill proposal against its cited
834
848
  // source memories. A contradiction flag routes to human review (not auto-accept).
835
849
  // DEFAULT OFF. Fail-open: any error is treated as no-contradiction.
836
- const fidelityConfig = config.profiles?.improve?.default?.processes?.distill?.fidelityCheck ?? {};
850
+ const fidelityConfig = getImproveProcessConfig(config, "distill", options.improveProfile)?.fidelityCheck ?? {};
837
851
  if (fidelityConfig.enabled && assetContent) {
838
852
  try {
839
853
  const proposalBody = stripBodyForFidelity(content);
@@ -123,22 +123,36 @@ function formatAlreadyPreserved(inlineRefs) {
123
123
  })
124
124
  .join("\n");
125
125
  }
126
+ /**
127
+ * Delimiters that fence the untrusted session transcript in the extract prompt.
128
+ * Mirrors the `=== ASSET N ===` convention (`graph-extract.ts`): everything
129
+ * between the markers is DATA to analyze, never instructions to obey. The
130
+ * transcript is external, attacker-influenceable content, so an explicit,
131
+ * greppable boundary defuses prompt-injection that tries to pose as a command.
132
+ */
133
+ export const TRANSCRIPT_FENCE_BEGIN = "=== BEGIN UNTRUSTED SESSION TRANSCRIPT ===";
134
+ export const TRANSCRIPT_FENCE_END = "=== END UNTRUSTED SESSION TRANSCRIPT ===";
126
135
  /**
127
136
  * Format pre-filtered events as a transcript snippet. Each event becomes:
128
137
  * [<role> @ <iso>] <text>
129
138
  * Events are already truncated/cleaned by the pre-filter; this is purely
130
139
  * a render step.
140
+ *
141
+ * Anti-spoof: any occurrence of the fence markers inside the transcript text is
142
+ * neutralised so a crafted session cannot forge the boundary and "escape" the
143
+ * fence to inject trusted-looking instructions.
131
144
  */
132
145
  function formatTranscript(events) {
133
146
  if (events.length === 0)
134
147
  return "(empty — pre-filter removed all events as noise)";
135
- return events
148
+ const body = events
136
149
  .map((e) => {
137
150
  const tsLabel = e.ts ? new Date(e.ts).toISOString() : "unknown-ts";
138
151
  const roleLabel = e.role ?? "unknown";
139
152
  return `[${roleLabel} @ ${tsLabel}] ${e.text}`;
140
153
  })
141
154
  .join("\n\n");
155
+ return body.split(TRANSCRIPT_FENCE_BEGIN).join("=== (fence) ===").split(TRANSCRIPT_FENCE_END).join("=== (fence) ===");
142
156
  }
143
157
  /**
144
158
  * Build the user-prompt body for the extract LLM call by interpolating
@@ -162,7 +176,7 @@ export function buildExtractPrompt(input) {
162
176
  .replace("{{PROJECT_HINT}}", ref.projectHint ?? "(no project hint)")
163
177
  .replace("{{ALREADY_PRESERVED}}", formatAlreadyPreserved(input.inlineRefs))
164
178
  .replace("{{STANDARDS}}", standards)
165
- .replace("{{TRANSCRIPT}}", formatTranscript(input.events));
179
+ .replace("{{TRANSCRIPT}}", `${TRANSCRIPT_FENCE_BEGIN}\n${formatTranscript(input.events)}\n${TRANSCRIPT_FENCE_END}`);
166
180
  }
167
181
  /**
168
182
  * Parse the LLM's JSON response into a structured {@link ExtractPayload}.
@@ -27,7 +27,7 @@ import fs from "node:fs";
27
27
  import path from "node:path";
28
28
  import { assembleAsset } from "../../core/asset/asset-serialize.js";
29
29
  import { resolveStashDir, timestampForFilename } from "../../core/common.js";
30
- import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
30
+ import { getDefaultLlmConfig, getImproveProcessConfig, loadConfig } from "../../core/config/config.js";
31
31
  import { ConfigError, UsageError } from "../../core/errors.js";
32
32
  import { appendEvent } from "../../core/events.js";
33
33
  import { probeLock, releaseLock, tryAcquireLockSync } from "../../core/file-lock.js";
@@ -451,8 +451,12 @@ standardsContext) {
451
451
  // When enabled, system-generated extractions enter captureMode: hot-probation
452
452
  // so they spend ONE consolidation cycle in probation before the deterministic
453
453
  // dedup+quality pass promotes them. Default OFF.
454
- const hotProbationEnabled = config.profiles?.improve?.default?.processes?.extract?.hotProbation
455
- ?.enabled === true;
454
+ // Reads the `default` profile deliberately: this per-session hot-probation
455
+ // flag lives inside the 18-arg `processSession`, which has no handle to the
456
+ // active profile, and threading a 19th positional arg for a DEFAULT-OFF flag
457
+ // isn't worth the param bloat. The extract STAGE toggle (in `akmExtract`,
458
+ // below) already honors `--profile`.
459
+ const hotProbationEnabled = getImproveProcessConfig(config, "extract")?.hotProbation?.enabled === true;
456
460
  for (const candidate of payload.candidates) {
457
461
  if (dryRun) {
458
462
  proposalIds.push(`dry-run:${candidate.type}:${candidate.name}`);
@@ -557,9 +561,7 @@ export async function akmExtract(options) {
557
561
  // is what stops a non-default profile's `extract.enabled` from being silently
558
562
  // overridden by the default profile and vice-versa.
559
563
  const activeProfile = options.improveProfile;
560
- const extractProcess = activeProfile
561
- ? activeProfile.processes?.extract
562
- : config.profiles?.improve?.default?.processes?.extract;
564
+ const extractProcess = activeProfile ? activeProfile.processes?.extract : getImproveProcessConfig(config, "extract");
563
565
  // The `extract.enabled` process toggle gates extract as a STAGE of `akm improve`
564
566
  // (the activeProfile path) — consistent with #593/#594 where the active profile,
565
567
  // not `default`, is the source of truth. An EXPLICIT `akm extract` invocation
@@ -870,7 +872,13 @@ export async function akmExtract(options) {
870
872
  // #602 — persist the freshly computed content hash so the NEXT run
871
873
  // can compare byte-for-byte. read_failed (before hash) → null, which
872
874
  // keeps the row eligible for retry (matches failed-row semantics).
873
- contentHash: result.contentHash ?? null,
875
+ // R4 llm_unavailable (LLM was down) and triaged_out (deferred by the
876
+ // triage gate) are transient outcomes: persist null so the null-hash
877
+ // retry re-processes them on a later run instead of pinning them as
878
+ // "seen" forever against the current byte content.
879
+ contentHash: result.skipReason === "llm_unavailable" || result.skipReason === "triaged_out"
880
+ ? null
881
+ : (result.contentHash ?? null),
874
882
  metadata: {
875
883
  preFilterInputCount: result.preFilter.inputCount,
876
884
  preFilterOutputCount: result.preFilter.outputCount,
@@ -968,7 +976,7 @@ export async function akmExtract(options) {
968
976
  * changed session is actually re-processed.
969
977
  */
970
978
  export function countNewExtractCandidates(config, options = {}) {
971
- const extractProcess = config.profiles?.improve?.default?.processes?.extract;
979
+ const extractProcess = getImproveProcessConfig(config, "extract", options.improveProfile);
972
980
  const effectiveSince = options.since ?? extractProcess?.defaultSince;
973
981
  // Mirror akmExtract: when no explicit window is set, default per-harness to
974
982
  // "since the last run" (floored at 48h) instead of a fixed 24h. Keeps this
@@ -2,11 +2,12 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { loadConfig } from "../../core/config/config.js";
5
+ import { UsageError } from "../../core/errors.js";
5
6
  import { appendEvent } from "../../core/events.js";
6
7
  import { withStateDb } from "../../core/state-db.js";
7
8
  import { info, warn } from "../../core/warn.js";
8
9
  import { getPhaseThreshold } from "../../storage/repositories/improve-runs-repository.js";
9
- import { getProposal, promoteProposal, recordGateDecision } from "../proposal/repository.js";
10
+ import { archiveProposal, getProposal, promoteProposal, recordGateDecision } from "../proposal/repository.js";
10
11
  async function sha256Hex(input) {
11
12
  const data = new TextEncoder().encode(input);
12
13
  const digest = await crypto.subtle.digest("SHA-256", data);
@@ -177,6 +178,26 @@ export async function runAutoAcceptGate(candidates, cfg, promoteFn = promoteProp
177
178
  ...(currentContentHash !== undefined ? { contentHash: currentContentHash } : {}),
178
179
  gate: gateLabel,
179
180
  });
181
+ // A validation failure is permanent — the minted content can never satisfy
182
+ // the asset schema, so archive it as rejected instead of leaving it pending
183
+ // to be retried on every future run (the 90-day TTL is otherwise the first
184
+ // thing ever to touch these zombies). Best-effort; ctx=undefined mirrors the
185
+ // promoteFn call above so the archive hits the same stashDir-derived DB.
186
+ //
187
+ // Discriminate on the STRUCTURED error, not the `reason` string: promoteProposal
188
+ // throws UsageError("MISSING_REQUIRED_ARGUMENT") only for the validateProposal
189
+ // failure branch. Message-sniffing (`reason` = "validation:<kind>") would also
190
+ // match a transient git-push rejection ("[rejected] ... non-fast-forward") from
191
+ // a git-backed write target and permanently archive a valid, retryable proposal.
192
+ const isPermanentValidationFailure = err instanceof UsageError && err.code === "MISSING_REQUIRED_ARGUMENT";
193
+ if (isPermanentValidationFailure) {
194
+ try {
195
+ archiveProposal(cfg.stashDir, proposalId, "rejected", `auto-accept ${reason}`, undefined);
196
+ }
197
+ catch (archiveErr) {
198
+ warn(`[improve] ${cfg.phase} failed to archive validation-failed proposal ${proposalId}: ${archiveErr instanceof Error ? archiveErr.message : String(archiveErr)}`);
199
+ }
200
+ }
180
201
  // If exploration budget was consumed but promotion failed, restore the slot
181
202
  // so the budget isn't exhausted on errors.
182
203
  if (isExploration)
@@ -215,6 +215,8 @@ export async function runImproveLoopStage(args) {
215
215
  const reflectCallArgs = {
216
216
  ref: planned.ref,
217
217
  task: options.task,
218
+ // Active profile so reflect's per-process reads honor `--profile`.
219
+ ...(improveProfile ? { improveProfile } : {}),
218
220
  ...(options.stashDir ? { stashDir: options.stashDir } : {}),
219
221
  ...(reflectErrors.length > 0 ? { avoidPatterns: [...reflectErrors] } : {}),
220
222
  agentProcess: options.agentProcess ?? "reflect",
@@ -475,6 +477,8 @@ export async function runImproveLoopStage(args) {
475
477
  ref: planned.ref,
476
478
  ...(parsedPlannedRef.type === "memory" ? { proposalKind: "auto" } : {}),
477
479
  ...(options.stashDir ? { stashDir: options.stashDir } : {}),
480
+ // Active profile so distill's per-process reads honor `--profile`.
481
+ ...(improveProfile ? { improveProfile } : {}),
478
482
  // Attribution: carry the eligibility lane so distill stamps it on the
479
483
  // distill_invoked event and the persisted proposal.
480
484
  ...(planned.eligibilitySource ? { eligibilitySource: planned.eligibilitySource } : {}),
@@ -646,9 +650,9 @@ export async function runImprovePostLoopStage(args) {
646
650
  recombination = await recombineFn({
647
651
  stashDir: primaryStashDir,
648
652
  config: options.config ?? loadConfig(),
653
+ improveProfile,
649
654
  ...(options.runId ? { sourceRun: options.runId } : {}),
650
655
  ...(budgetSignal ? { signal: budgetSignal } : {}),
651
- ...(options.autoAccept !== undefined ? { autoAccept: options.autoAccept } : {}),
652
656
  eligibilitySource: "recombine",
653
657
  ...(eventsCtx ? { ctx: eventsCtx } : {}),
654
658
  minClusterSize: improveProfile.processes?.recombine?.minClusterSize,
@@ -680,9 +684,9 @@ export async function runImprovePostLoopStage(args) {
680
684
  proceduralCompilation = await proceduralFn({
681
685
  stashDir: primaryStashDir,
682
686
  config: options.config ?? loadConfig(),
687
+ ...(improveProfile ? { improveProfile } : {}),
683
688
  ...(options.runId ? { sourceRun: options.runId } : {}),
684
689
  ...(budgetSignal ? { signal: budgetSignal } : {}),
685
- ...(options.autoAccept !== undefined ? { autoAccept: options.autoAccept } : {}),
686
690
  eligibilitySource: "procedural",
687
691
  ...(eventsCtx ? { ctx: eventsCtx } : {}),
688
692
  minRecurrence: improveProfile.processes?.procedural?.minRecurrence,
@@ -704,6 +708,7 @@ export async function runImprovePostLoopStage(args) {
704
708
  if (!options.dryRun && (consolidationRan || recombineWorked)) {
705
709
  cycleMetrics = runCollapseDetector({
706
710
  runId: options.runId ?? "improve-adhoc",
711
+ ...(improveProfile ? { improveProfile } : {}),
707
712
  pass: consolidationRan && recombineWorked ? "both" : consolidationRan ? "consolidate" : "recombine",
708
713
  // prep+loop gate accepts, PLUS recombine's confirmed-lesson promotions —
709
714
  // recombine churn is the historically observed failure mode and its
@@ -27,9 +27,7 @@
27
27
  * - Zep / Graphiti (arXiv:2501.13956 §3) — unified belief-revision pipeline
28
28
  * - MemOS (arXiv:2507.03724) — formal archive/merge/transition with shared state model
29
29
  */
30
- import fs from "node:fs";
31
- import { assembleAsset } from "../../../core/asset/asset-serialize.js";
32
- import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
30
+ import { mutateFrontmatter } from "../../../core/asset/frontmatter.js";
33
31
  // ── Contradiction edge writer ─────────────────────────────────────────────────
34
32
  /**
35
33
  * Write `contradictedBy` and `beliefState: contradicted` edges to a memory
@@ -47,16 +45,17 @@ import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
47
45
  * @param contradictedByRef - The ref that contradicts this memory.
48
46
  */
49
47
  export function writeContradictEdge(filePath, contradictedByRef) {
50
- const raw = fs.readFileSync(filePath, "utf8");
51
- const parsed = parseFrontmatter(raw);
52
- const existing = Array.isArray(parsed.data.contradictedBy) ? parsed.data.contradictedBy : [];
53
- if (existing.includes(contradictedByRef))
54
- return; // Already written — idempotent.
55
- const nextContradictedBy = [...new Set([...existing, contradictedByRef])].sort();
56
- const nextFrontmatter = {
57
- ...parsed.data,
58
- contradictedBy: nextContradictedBy,
59
- beliefState: "contradicted",
60
- };
61
- fs.writeFileSync(filePath, assembleAsset(nextFrontmatter, parsed.content), "utf8");
48
+ mutateFrontmatter(filePath, (parsed) => {
49
+ const existing = Array.isArray(parsed.data.contradictedBy)
50
+ ? parsed.data.contradictedBy
51
+ : [];
52
+ if (existing.includes(contradictedByRef))
53
+ return null; // Already written — idempotent.
54
+ const nextContradictedBy = [...new Set([...existing, contradictedByRef])].sort();
55
+ return {
56
+ ...parsed.data,
57
+ contradictedBy: nextContradictedBy,
58
+ beliefState: "contradicted",
59
+ };
60
+ });
62
61
  }
@@ -36,8 +36,7 @@
36
36
  import fs from "node:fs";
37
37
  import path from "node:path";
38
38
  import contradictionJudgeTemplate from "../../../assets/prompts/contradiction-judge.md" with { type: "text" };
39
- import { assembleAsset } from "../../../core/asset/asset-serialize.js";
40
- import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
39
+ import { mutateFrontmatter, parseFrontmatter } from "../../../core/asset/frontmatter.js";
41
40
  import { getDefaultLlmConfig } from "../../../core/config/config.js";
42
41
  import { chatCompletion, parseEmbeddedJsonResponse } from "../../../llm/client.js";
43
42
  import { tryLlmFeature } from "../../../llm/feature-gate.js";
@@ -126,19 +125,42 @@ function resolveParentRef(filePath, frontmatter, memoriesRootDir) {
126
125
  */
127
126
  /** Returns true if the edge was newly written, false if it already existed. */
128
127
  function writeContradictedByEdge(filePath, contradictedByRef) {
129
- const raw = fs.readFileSync(filePath, "utf8");
130
- const parsed = parseFrontmatter(raw);
131
- const existing = Array.isArray(parsed.data.contradictedBy) ? parsed.data.contradictedBy : [];
132
- if (existing.includes(contradictedByRef))
133
- return false; // Edge already written.
134
- const updatedContradictedBy = [...new Set([...existing, contradictedByRef])].sort();
135
- const nextFrontmatter = {
136
- ...parsed.data,
137
- contradictedBy: updatedContradictedBy,
138
- beliefState: "contradicted",
139
- };
140
- fs.writeFileSync(filePath, assembleAsset(nextFrontmatter, parsed.content), "utf8");
141
- return true;
128
+ return mutateFrontmatter(filePath, (parsed) => {
129
+ const existing = Array.isArray(parsed.data.contradictedBy)
130
+ ? parsed.data.contradictedBy
131
+ : [];
132
+ if (existing.includes(contradictedByRef))
133
+ return null; // Edge already written.
134
+ const updatedContradictedBy = [...new Set([...existing, contradictedByRef])].sort();
135
+ return {
136
+ ...parsed.data,
137
+ contradictedBy: updatedContradictedBy,
138
+ beliefState: "contradicted",
139
+ };
140
+ });
141
+ }
142
+ /**
143
+ * Deterministically pick, for a confirmed-contradiction pair, the LOSER memory
144
+ * that receives the single directed `contradictedBy` edge (SCC-resolved to
145
+ * `contradicted`) and the WINNER ref that survives as the current belief.
146
+ *
147
+ * A SINGLE directed edge is essential. Writing mutual A↔B edges forms a 2-cycle
148
+ * that {@link resolveFamilyContradictions} collapses into one strongly-connected
149
+ * SINK component and refreshes BOTH members back to active — erasing the
150
+ * contradiction on every run (the self-erasing bug this fix removes).
151
+ *
152
+ * Direction = lexicographic ref order: the ref that sorts LATER is the loser.
153
+ * This is a **total order** over the family's (distinct) refs, so the induced
154
+ * edges are always acyclic — a family of any size resolves to a DAG with a
155
+ * single sink, never a cycle that the resolver would refresh back to active.
156
+ * It is also immutable across runs (unlike file mtime, which the resolver
157
+ * bumps when it rewrites loser files), so detection is idempotent. Ref order
158
+ * carries no recency meaning — no derived-memory writer sets a `createdAt`/
159
+ * timestamp today — but the mechanism only needs a stable, acyclic direction;
160
+ * eliminating worst-case self-erasure, not ranking by recency, is the goal.
161
+ */
162
+ function pickContradictionLoser(a, b) {
163
+ return a.ref < b.ref ? { loser: b, winnerRef: a.ref } : { loser: a, winnerRef: b.ref };
142
164
  }
143
165
  // ── Main entry point ──────────────────────────────────────────────────────────
144
166
  /**
@@ -209,18 +231,22 @@ export async function detectAndWriteContradictions(stashDir, config, chat = chat
209
231
  const b = family[j];
210
232
  if (!a || !b)
211
233
  continue;
212
- // Skip pairs where edges already exist in BOTH directions (no new information).
213
- const aRaw = fs.readFileSync(a.filePath, "utf8");
214
- const aParsed = parseFrontmatter(aRaw);
215
- const aCB = Array.isArray(aParsed.data.contradictedBy)
216
- ? aParsed.data.contradictedBy
217
- : [];
218
- const bRaw = fs.readFileSync(b.filePath, "utf8");
219
- const bParsed = parseFrontmatter(bRaw);
220
- const bCB = Array.isArray(bParsed.data.contradictedBy)
221
- ? bParsed.data.contradictedBy
222
- : [];
223
- if (aCB.includes(b.ref) && bCB.includes(a.ref))
234
+ // Resolve the directed edge up front (independent of the judge it is
235
+ // decided by lexicographic ref order). Skip when that single loser→winner
236
+ // edge already exists (no new information; avoids re-judging resolved
237
+ // pairs across runs).
238
+ //
239
+ // Legacy mutual A↔B edges written by the pre-fix pass self-heal: the
240
+ // skip fires this run, but the SCC resolver treats the 2-cycle as a sink
241
+ // and refreshes both to active — DELETING both `contradictedBy` arrays
242
+ // (memory-improve.ts persistBeliefStateTransition). The next detection
243
+ // run then sees no edge, re-judges, and writes the single canonical edge.
244
+ const aParsed = parseFrontmatter(fs.readFileSync(a.filePath, "utf8"));
245
+ const bParsed = parseFrontmatter(fs.readFileSync(b.filePath, "utf8"));
246
+ const { loser, winnerRef } = pickContradictionLoser(a, b);
247
+ const loserData = loser === a ? aParsed.data : bParsed.data;
248
+ const loserCB = Array.isArray(loserData.contradictedBy) ? loserData.contradictedBy : [];
249
+ if (loserCB.includes(winnerRef))
224
250
  continue;
225
251
  const prompt = buildContradictionJudgePrompt(a, b);
226
252
  const judgeResult = await tryLlmFeature("memory_contradiction_detection", config, async () => {
@@ -251,14 +277,16 @@ export async function detectAndWriteContradictions(stashDir, config, chat = chat
251
277
  result.warnings.push(`Pair ${a.ref} / ${b.ref}: confidence ${confidence.toFixed(2)} below ${CONTRADICT_CONFIDENCE_THRESHOLD} threshold — skipped.`);
252
278
  continue;
253
279
  }
254
- // Write contradiction edges: both members get contradictedBy pointing to each other.
280
+ // Write a SINGLE directed contradiction edge: the losing (older) memory
281
+ // gets `contradictedBy` pointing to the winner. A mutual A↔B pair forms
282
+ // a 2-cycle that the SCC resolver refreshes back to active, erasing the
283
+ // contradiction every run (see pickContradictionLoser).
255
284
  try {
256
- const wroteA = writeContradictedByEdge(a.filePath, b.ref);
257
- const wroteB = writeContradictedByEdge(b.filePath, a.ref);
258
- result.edgesWritten += (wroteA ? 1 : 0) + (wroteB ? 1 : 0);
285
+ const wrote = writeContradictedByEdge(loser.filePath, winnerRef);
286
+ result.edgesWritten += wrote ? 1 : 0;
259
287
  }
260
288
  catch (err) {
261
- result.warnings.push(`Failed to write contradiction edge ${a.ref} <-> ${b.ref}: ${err instanceof Error ? err.message : String(err)}`);
289
+ result.warnings.push(`Failed to write contradiction edge ${loser.ref} -> ${winnerRef}: ${err instanceof Error ? err.message : String(err)}`);
262
290
  }
263
291
  }
264
292
  if (totalPairsChecked >= MAX_PAIRS_PER_RUN)
@@ -5,8 +5,8 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { makeAssetRef, parseAssetRef } from "../../../core/asset/asset-ref.js";
7
7
  import { assembleAsset } from "../../../core/asset/asset-serialize.js";
8
- import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
9
- import { firstString, groupBy, stringArray } from "../../../core/common.js";
8
+ import { mutateFrontmatter, parseFrontmatter } from "../../../core/asset/frontmatter.js";
9
+ import { asNonEmptyString, groupBy, stringArray } from "../../../core/common.js";
10
10
  const DERIVED_SUFFIX = ".derived";
11
11
  export function analyzeMemoryCleanup(stashDir, options = {}) {
12
12
  const records = collectDerivedMemories(stashDir, options.parentRef);
@@ -491,27 +491,27 @@ function archiveCleanupCandidate(stashDir, candidate, filePath) {
491
491
  };
492
492
  }
493
493
  function persistBeliefStateTransition(filePath, transition) {
494
- const raw = fs.readFileSync(filePath, "utf8");
495
- const parsed = parseFrontmatter(raw);
496
- const nextFrontmatter = {
497
- ...parsed.data,
498
- beliefState: transition.toState,
499
- };
500
- const currentBeliefRefs = [...new Set(transition.currentBeliefRefs ?? [])].sort();
501
- if (transition.toState === "contradicted") {
502
- nextFrontmatter.contradictedBy = [...currentBeliefRefs];
503
- }
504
- else {
505
- delete nextFrontmatter.contradictedBy;
506
- if (parsed.data.supersededBy !== undefined && refArray(parsed.data.supersededBy).length === 0) {
507
- delete nextFrontmatter.supersededBy;
494
+ mutateFrontmatter(filePath, (parsed) => {
495
+ const nextFrontmatter = {
496
+ ...parsed.data,
497
+ beliefState: transition.toState,
498
+ };
499
+ const currentBeliefRefs = [...new Set(transition.currentBeliefRefs ?? [])].sort();
500
+ if (transition.toState === "contradicted") {
501
+ nextFrontmatter.contradictedBy = [...currentBeliefRefs];
508
502
  }
509
- }
510
- if (currentBeliefRefs.length > 0)
511
- nextFrontmatter.currentBeliefRefs = [...currentBeliefRefs];
512
- else
513
- delete nextFrontmatter.currentBeliefRefs;
514
- fs.writeFileSync(filePath, assembleAsset(nextFrontmatter, parsed.content), "utf8");
503
+ else {
504
+ delete nextFrontmatter.contradictedBy;
505
+ if (parsed.data.supersededBy !== undefined && refArray(parsed.data.supersededBy).length === 0) {
506
+ delete nextFrontmatter.supersededBy;
507
+ }
508
+ }
509
+ if (currentBeliefRefs.length > 0)
510
+ nextFrontmatter.currentBeliefRefs = [...currentBeliefRefs];
511
+ else
512
+ delete nextFrontmatter.currentBeliefRefs;
513
+ return nextFrontmatter;
514
+ });
515
515
  }
516
516
  function appendBeliefStateTransitionLog(stashDir, transitions) {
517
517
  const logDir = path.join(stashDir, ".akm", "memory-cleanup");
@@ -580,8 +580,8 @@ function collectDerivedMemories(stashDir, parentRefFilter) {
580
580
  continue;
581
581
  if (!isDerivedMemory(name, parsed.data))
582
582
  continue;
583
- const title = firstString(parsed.data.title) ?? extractHeading(parsed.content) ?? "";
584
- const description = firstString(parsed.data.description) ?? "";
583
+ const title = asNonEmptyString(parsed.data.title) ?? extractHeading(parsed.content) ?? "";
584
+ const description = asNonEmptyString(parsed.data.description) ?? "";
585
585
  const tags = stringArray(parsed.data.tags);
586
586
  const searchHints = stringArray(parsed.data.searchHints);
587
587
  const body = parsed.content.trim();
@@ -637,7 +637,7 @@ function isFrozenHistoricalBeliefState(state) {
637
637
  return state === "deprecated";
638
638
  }
639
639
  function resolveBeliefState(frontmatter) {
640
- const explicit = firstString(frontmatter.beliefState);
640
+ const explicit = asNonEmptyString(frontmatter.beliefState);
641
641
  if (explicit === "active" ||
642
642
  explicit === "asserted" ||
643
643
  explicit === "deprecated" ||
@@ -651,10 +651,10 @@ function isDerivedMemory(name, frontmatter) {
651
651
  return frontmatter.inferred === true || name.endsWith(DERIVED_SUFFIX);
652
652
  }
653
653
  function resolveParentRef(name, frontmatter) {
654
- const fromSource = parseMemoryRef(firstString(frontmatter.source));
654
+ const fromSource = parseMemoryRef(asNonEmptyString(frontmatter.source));
655
655
  if (fromSource)
656
656
  return fromSource;
657
- const derivedFrom = firstString(frontmatter.derivedFrom);
657
+ const derivedFrom = asNonEmptyString(frontmatter.derivedFrom);
658
658
  if (derivedFrom)
659
659
  return makeAssetRef("memory", derivedFrom);
660
660
  if (name.endsWith(DERIVED_SUFFIX)) {
@@ -21,7 +21,7 @@ import { akmLint } from "../lint/index.js";
21
21
  import { getProposal, listProposals } from "../proposal/repository.js";
22
22
  import { runSchemaRepairPass } from "../sources/schema-repair.js";
23
23
  import { computeThresholdAutoTune, gateDecisionsToSamples, summarizeCalibration, } from "./calibration.js";
24
- import { akmConsolidate, isSessionCaptureMemoryName } from "./consolidate.js";
24
+ import { akmConsolidate } from "./consolidate.js";
25
25
  // Eligibility / candidate-selection predicates live in ./eligibility.
26
26
  import { buildLatestFeedbackTsMap, buildLatestProposalTsMap, buildUtilityMap, dedupeRefs, findAssetFilePath, isDistillCandidateRef, isLessonCandidate, isSignalDeltaEligible, } from "./eligibility.js";
27
27
  import { akmExtract, countNewExtractCandidates } from "./extract.js";
@@ -320,6 +320,9 @@ export async function runConsolidationPass(args) {
320
320
  ...options.consolidateOptions,
321
321
  config: consolidationConfig,
322
322
  stashDir: options.stashDir,
323
+ // Active profile for this improve run — lets consolidate's secondary
324
+ // process-config reads honor `--profile <name>` instead of `default`.
325
+ improveProfile,
323
326
  autoTriggered: volumeTriggered,
324
327
  // Tie consolidate proposals back to this improve invocation so
325
328
  // accept-rate-per-run aggregation works. Mirrors reflect/propose/extract.
@@ -485,6 +488,7 @@ async function runSessionExtractPass(args) {
485
488
  const countFn = options.extractCandidateCountFn ?? countNewExtractCandidates;
486
489
  const newCandidateCount = countFn(extractConfig, {
487
490
  ...(options.extractHarnesses ? { harnesses: options.extractHarnesses } : {}),
491
+ improveProfile,
488
492
  // Use the ACTIVE profile's discovery window so the gate counts over the
489
493
  // same window akmExtract will scan (not always `default`).
490
494
  ...(improveProfile.processes?.extract?.defaultSince
@@ -1152,10 +1156,7 @@ export async function runImprovePreparationStage(args) {
1152
1156
  // collapse the lane to exactly 1 ref via the bare `?? 10` fallback.
1153
1157
  const effectiveLimit = options.limit ?? improveProfile?.processes?.reflect?.limit ?? improveProfile.limit ?? 10;
1154
1158
  const highSalienceCap = Math.max(1, Math.floor(effectiveLimit * 0.1));
1155
- // #632/#4 session-capture telemetry (checkpoints) must never consume
1156
- // the scarce high-salience budget. Even with a content-scored row, these
1157
- // are pipeline bookkeeping, not assets worth reflecting/rewriting.
1158
- const candidates = noFeedbackCandidates.filter((r) => !proactiveAndRetrievalSet.has(r.ref) && !isSessionCaptureMemoryName(parseAssetRef(r.ref).name));
1159
+ const candidates = noFeedbackCandidates.filter((r) => !proactiveAndRetrievalSet.has(r.ref));
1159
1160
  // Collect ALL qualifying candidates, then take the top-N BY SCORE — the
1160
1161
  // previous first-N-in-scan-order break meant a higher-salience candidate
1161
1162
  // found later in the scan lost its slot to an earlier lower-scoring one.
@@ -292,6 +292,7 @@ export async function akmProcedural(opts) {
292
292
  systemPrompt: PROCEDURAL_SYSTEM_PROMPT,
293
293
  tag: "[procedural]",
294
294
  signal: opts.signal,
295
+ activeProfile: opts.improveProfile,
295
296
  });
296
297
  if (!llmFn) {
297
298
  warnings.push("procedural: no LLM configured — skipping");