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.
- package/dist/assets/prompts/extract-session.md +5 -1
- package/dist/cli/config-migrate.js +7 -1
- package/dist/commands/config-cli.js +8 -11
- package/dist/commands/health/stash-exposure.js +46 -0
- package/dist/commands/health/windows.js +6 -7
- package/dist/commands/health.js +31 -10
- package/dist/commands/improve/collapse-detector.js +2 -1
- package/dist/commands/improve/consolidate/eligibility.js +0 -17
- package/dist/commands/improve/consolidate.js +209 -167
- package/dist/commands/improve/distill/promote-memory.js +4 -3
- package/dist/commands/improve/distill/quality-gate.js +7 -4
- package/dist/commands/improve/distill-promotion-policy.js +826 -167
- package/dist/commands/improve/distill.js +26 -12
- package/dist/commands/improve/extract-prompt.js +16 -2
- package/dist/commands/improve/extract.js +16 -8
- package/dist/commands/improve/improve-auto-accept.js +22 -1
- package/dist/commands/improve/loop-stages.js +7 -2
- package/dist/commands/improve/memory/memory-belief.js +14 -15
- package/dist/commands/improve/memory/memory-contradiction-detect.js +60 -32
- package/dist/commands/improve/memory/memory-improve.js +27 -27
- package/dist/commands/improve/preparation.js +6 -5
- package/dist/commands/improve/procedural.js +1 -0
- package/dist/commands/improve/recombine.js +3 -11
- package/dist/commands/improve/reflect-noise.js +1 -1
- package/dist/commands/improve/reflect.js +4 -3
- package/dist/commands/improve/shared.js +9 -6
- package/dist/commands/proposal/drain-policies.js +4 -2
- package/dist/commands/read/remember-cli.js +1 -1
- package/dist/commands/read/show.js +15 -0
- package/dist/commands/remember.js +11 -12
- package/dist/commands/sources/init.js +5 -1
- package/dist/commands/sources/stash-skeleton.js +34 -0
- package/dist/commands/tasks/default-tasks.js +3 -2
- package/dist/core/asset/frontmatter.js +22 -0
- package/dist/core/common.js +1 -15
- package/dist/core/config/config-io.js +10 -1
- package/dist/core/config/config-migration.js +2 -15
- package/dist/core/config/config-schema.js +15 -3
- package/dist/core/config/config.js +22 -14
- package/dist/core/paths.js +4 -4
- package/dist/core/time.js +53 -0
- package/dist/indexer/db/db.js +51 -46
- package/dist/indexer/graph/graph-extraction.js +1 -13
- package/dist/indexer/indexer.js +77 -65
- package/dist/indexer/search/db-search.js +41 -6
- package/dist/indexer/search/ranking-contributors.js +14 -8
- package/dist/indexer/search/search-source.js +15 -3
- package/dist/llm/feature-gate.js +4 -8
- package/dist/output/renderers.js +4 -0
- package/dist/scripts/migrate-storage.js +83 -59
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +6 -0
- package/dist/storage/repositories/registry-cache.js +2 -1
- package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
- package/dist/workflows/runtime/runs.js +6 -1
- package/package.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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].
|
|
827
|
-
// (
|
|
828
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 {
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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
|
|
257
|
-
|
|
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 ${
|
|
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 {
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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 =
|
|
584
|
-
const 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 =
|
|
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(
|
|
654
|
+
const fromSource = parseMemoryRef(asNonEmptyString(frontmatter.source));
|
|
655
655
|
if (fromSource)
|
|
656
656
|
return fromSource;
|
|
657
|
-
const 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
|
|
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
|
-
|
|
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");
|