dsh-continual-evolve 0.7.6 → 0.9.0
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/README.md +20 -7
- package/README.zh.md +16 -7
- package/lib/auto.d.ts +31 -2
- package/lib/auto.js +124 -6
- package/lib/benchmark-command.js +1 -1
- package/lib/command.js +93 -1
- package/lib/fate.js +10 -10
- package/lib/index.d.ts +10 -0
- package/lib/index.js +6 -0
- package/lib/memory-agent.d.ts +8 -1
- package/lib/memory-agent.js +13 -0
- package/lib/memory-benchmark.d.ts +76 -0
- package/lib/memory-benchmark.js +170 -0
- package/lib/notify.d.ts +27 -0
- package/lib/notify.js +54 -0
- package/lib/planner.d.ts +1 -1
- package/lib/planner.js +7 -1
- package/lib/projection.d.ts +22 -0
- package/lib/projection.js +112 -0
- package/lib/recall.d.ts +85 -0
- package/lib/recall.js +142 -0
- package/lib/review-scheduler.js +3 -4
- package/lib/review.d.ts +1 -1
- package/lib/review.js +8 -3
- package/lib/service.js +14 -0
- package/lib/skill-render.js +2 -1
- package/lib/skillquality.d.ts +7 -2
- package/lib/skillquality.js +12 -3
- package/lib/tool.js +40 -0
- package/lib/types.d.ts +9 -11
- package/lib/types.js +14 -2
- package/package.json +7 -3
package/lib/index.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
14
14
|
autoReview: z<boolean, boolean, "defined">;
|
|
15
15
|
/** ZCode-style minimum lexical words in one direct user text part. */
|
|
16
16
|
memoryMinUserWords: z<number, number, "defined">;
|
|
17
|
+
/**
|
|
18
|
+
* Session-close bounded drain for in-flight extraction, in milliseconds.
|
|
19
|
+
* Absent → 15s; 0 restores the legacy immediate abort on dispose.
|
|
20
|
+
*/
|
|
21
|
+
sessionCloseDrainMs: z<number, number, "defined">;
|
|
17
22
|
/** Legacy/local-fate cadence fallback; successful-turn review is snapshot-driven. */
|
|
18
23
|
reviewIntervalTurns: z<number, number, "defined">;
|
|
19
24
|
/** Trajectory slice handed to the gate, in characters. */
|
|
@@ -114,6 +119,11 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
114
119
|
autoReview: z<boolean, boolean, "defined">;
|
|
115
120
|
/** ZCode-style minimum lexical words in one direct user text part. */
|
|
116
121
|
memoryMinUserWords: z<number, number, "defined">;
|
|
122
|
+
/**
|
|
123
|
+
* Session-close bounded drain for in-flight extraction, in milliseconds.
|
|
124
|
+
* Absent → 15s; 0 restores the legacy immediate abort on dispose.
|
|
125
|
+
*/
|
|
126
|
+
sessionCloseDrainMs: z<number, number, "defined">;
|
|
117
127
|
/** Legacy/local-fate cadence fallback; successful-turn review is snapshot-driven. */
|
|
118
128
|
reviewIntervalTurns: z<number, number, "defined">;
|
|
119
129
|
/** Trajectory slice handed to the gate, in characters. */
|
package/lib/index.js
CHANGED
|
@@ -35,6 +35,11 @@ export const Config = z.object({
|
|
|
35
35
|
autoReview: z.boolean().default(false),
|
|
36
36
|
/** ZCode-style minimum lexical words in one direct user text part. */
|
|
37
37
|
memoryMinUserWords: z.natural().default(3),
|
|
38
|
+
/**
|
|
39
|
+
* Session-close bounded drain for in-flight extraction, in milliseconds.
|
|
40
|
+
* Absent → 15s; 0 restores the legacy immediate abort on dispose.
|
|
41
|
+
*/
|
|
42
|
+
sessionCloseDrainMs: z.natural().default(15000),
|
|
38
43
|
/** Legacy/local-fate cadence fallback; successful-turn review is snapshot-driven. */
|
|
39
44
|
reviewIntervalTurns: z.natural().default(6),
|
|
40
45
|
/** Trajectory slice handed to the gate, in characters. */
|
|
@@ -192,6 +197,7 @@ export function apply(ctx, config) {
|
|
|
192
197
|
enabledByDefault: config.autoReview ?? false,
|
|
193
198
|
memoryOnly: true,
|
|
194
199
|
memoryMinUserWords: config.memoryMinUserWords ?? 3,
|
|
200
|
+
sessionCloseDrainMs: config.sessionCloseDrainMs ?? 15000,
|
|
195
201
|
maxInputChars: config.maxReviewInputChars ?? 40000,
|
|
196
202
|
budgetTokens: config.reviewBudgetTokens ?? 4096,
|
|
197
203
|
notifyOnAutoReview: config.notifyOnAutoReview ?? true,
|
package/lib/memory-agent.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ import type { EntrySource, HarnessEntry, HarnessScope, HarnessState, RefinementE
|
|
|
20
20
|
export declare const MEMORY_AGENT_MAX_TURNS = 5;
|
|
21
21
|
/** Bound one proposal so approval text and engine compensation stay reviewable. */
|
|
22
22
|
export declare const MEMORY_AGENT_MAX_EDITS = 20;
|
|
23
|
+
/**
|
|
24
|
+
* System-prompt size budget (chars): the prompt ships on EVERY extractor
|
|
25
|
+
* turn (up to five per snapshot), so each added line is recurring token
|
|
26
|
+
* spend, not one-off text. Raise this cap only in the same commit that
|
|
27
|
+
* justifies the added lines (regression pressure beats silent bloat).
|
|
28
|
+
*/
|
|
29
|
+
export declare const MEMORY_AGENT_SYSTEM_PROMPT_BUDGET_CHARS = 3000;
|
|
23
30
|
/** The complete tool-name allowlist exposed by the memory agent. */
|
|
24
31
|
export declare const MEMORY_AGENT_TOOL_NAMES: readonly ["memory_search", "memory_propose"];
|
|
25
32
|
export type MemoryAgentToolName = (typeof MEMORY_AGENT_TOOL_NAMES)[number];
|
|
@@ -54,7 +61,7 @@ export interface MemoryApplicationResult {
|
|
|
54
61
|
/** Provider-facing schemas for the closed memory-only tool set. */
|
|
55
62
|
export declare const MEMORY_AGENT_TOOL_SCHEMAS: readonly ToolSchema[];
|
|
56
63
|
/** Instructions for the dedicated loop; model text is always untrusted. */
|
|
57
|
-
export declare const MEMORY_AGENT_SYSTEM_PROMPT = "You are the dedicated background memory extraction agent for a continual-evolution harness.\n\nYou receive only an incremental conversation checkpoint and a frozen manifest of existing memory entries. Your entire job is to decide whether this checkpoint contains a durable fact worth remembering. Never propose prompt notes, skills, subagent specs, code changes, or general conversation summaries.\n\nRules:\n- Use memory_search before creating when the manifest may already cover the topic. Update an existing entry instead of creating a near-duplicate.\n- Every memory entry contains exactly one fact. Classify it with metadata.memoryType = user | feedback | project | reference.\n- feedback and project memories must state the fact, Why it matters, and How to apply it next time.\n- Never invent a user preference. Ground every proposal in the supplied conversation evidence and existing memory.\n- Every edit must have kind=memory, an explicit targetScope, and a coherent blastRadius.\n- targetScope=local is session staging. project persists within the current repository. global persists across projects. project/global require human approval before application.\n- update/delete/archive must name an existing id from the same targetScope. Prefer archive over delete when the fact is obsolete but should remain restorable.\n- You cannot read or write source files, call agents, use MCP, use the network, or mutate the harness directly. memory_propose is the only finish tool and never writes state itself.\n- If no durable memory is justified, call memory_propose with edits=[].\n- Always finish by calling memory_propose. Do not return unproposed prose.";
|
|
64
|
+
export declare const MEMORY_AGENT_SYSTEM_PROMPT = "You are the dedicated background memory extraction agent for a continual-evolution harness.\n\nYou receive only an incremental conversation checkpoint and a frozen manifest of existing memory entries. Your entire job is to decide whether this checkpoint contains a durable fact worth remembering. Never propose prompt notes, skills, subagent specs, code changes, or general conversation summaries.\n\nRules:\n- Use memory_search before creating when the manifest may already cover the topic. Update an existing entry instead of creating a near-duplicate.\n- Every memory entry contains exactly one fact. Classify it with metadata.memoryType = user | feedback | project | reference.\n- feedback and project memories must state the fact, Why it matters, and How to apply it next time.\n- Never invent a user preference. Ground every proposal in the supplied conversation evidence and existing memory.\n- Every edit must have kind=memory, an explicit targetScope, and a coherent blastRadius.\n- targetScope=local is session staging. project persists within the current repository. global persists across projects. project/global require human approval before application.\n- update/delete/archive must name an existing id from the same targetScope. Prefer archive over delete when the fact is obsolete but should remain restorable.\n- Do not remember: code structure or file paths re-readable from the repository; git history, diffs, commit hashes, or CI logs; content already written in project instruction files; temporary session state, one-off debugging trails, or current task progress; model-only speculation without conversation evidence.\n- Be specific enough to act on: prefer a fact that tells the next session what to DO over a summary of what happened. If the fact is obvious from the conversation itself with no future action attached, skip it.\n- Save only what is surprising or non-obvious: routine chatter, restated context, and facts with no bearing on future work are no-ops, not memories.\n- Granularity examples (vague \u2192 reject; sharp \u2192 save):\n - \"user communicates in Chinese\" \u2192 \"user reads English with effort \u2014 write memory and handoff content in Chinese prose\".\n - \"user works on the desktop project\" \u2192 \"desktop profile loads npm-installed plugin copies \u2014 source edits need a release before they take effect\".\n- You cannot read or write source files, call agents, use MCP, use the network, or mutate the harness directly. memory_propose is the only finish tool and never writes state itself.\n- If no durable memory is justified, call memory_propose with edits=[].\n- Always finish by calling memory_propose. Do not return unproposed prose.";
|
|
58
65
|
/** Build the frozen memory manifest from a merged harness view. */
|
|
59
66
|
export declare function buildMemoryManifest(state: HarnessState): MemoryManifestEntry[];
|
|
60
67
|
/** Render a bounded manifest index; full bodies remain available through memory_search. */
|
package/lib/memory-agent.js
CHANGED
|
@@ -15,6 +15,13 @@ import { validateBlastRadiusScope, validateEdit } from "./validate.js";
|
|
|
15
15
|
export const MEMORY_AGENT_MAX_TURNS = 5;
|
|
16
16
|
/** Bound one proposal so approval text and engine compensation stay reviewable. */
|
|
17
17
|
export const MEMORY_AGENT_MAX_EDITS = 20;
|
|
18
|
+
/**
|
|
19
|
+
* System-prompt size budget (chars): the prompt ships on EVERY extractor
|
|
20
|
+
* turn (up to five per snapshot), so each added line is recurring token
|
|
21
|
+
* spend, not one-off text. Raise this cap only in the same commit that
|
|
22
|
+
* justifies the added lines (regression pressure beats silent bloat).
|
|
23
|
+
*/
|
|
24
|
+
export const MEMORY_AGENT_SYSTEM_PROMPT_BUDGET_CHARS = 3000;
|
|
18
25
|
/** The complete tool-name allowlist exposed by the memory agent. */
|
|
19
26
|
export const MEMORY_AGENT_TOOL_NAMES = ["memory_search", "memory_propose"];
|
|
20
27
|
/** Provider-facing schemas for the closed memory-only tool set. */
|
|
@@ -84,6 +91,12 @@ Rules:
|
|
|
84
91
|
- Every edit must have kind=memory, an explicit targetScope, and a coherent blastRadius.
|
|
85
92
|
- targetScope=local is session staging. project persists within the current repository. global persists across projects. project/global require human approval before application.
|
|
86
93
|
- update/delete/archive must name an existing id from the same targetScope. Prefer archive over delete when the fact is obsolete but should remain restorable.
|
|
94
|
+
- Do not remember: code structure or file paths re-readable from the repository; git history, diffs, commit hashes, or CI logs; content already written in project instruction files; temporary session state, one-off debugging trails, or current task progress; model-only speculation without conversation evidence.
|
|
95
|
+
- Be specific enough to act on: prefer a fact that tells the next session what to DO over a summary of what happened. If the fact is obvious from the conversation itself with no future action attached, skip it.
|
|
96
|
+
- Save only what is surprising or non-obvious: routine chatter, restated context, and facts with no bearing on future work are no-ops, not memories.
|
|
97
|
+
- Granularity examples (vague → reject; sharp → save):
|
|
98
|
+
- "user communicates in Chinese" → "user reads English with effort — write memory and handoff content in Chinese prose".
|
|
99
|
+
- "user works on the desktop project" → "desktop profile loads npm-installed plugin copies — source edits need a release before they take effect".
|
|
87
100
|
- You cannot read or write source files, call agents, use MCP, use the network, or mutate the harness directly. memory_propose is the only finish tool and never writes state itself.
|
|
88
101
|
- If no durable memory is justified, call memory_propose with edits=[].
|
|
89
102
|
- Always finish by calling memory_propose. Do not return unproposed prose.`;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { HarnessEntry } from "./types.js";
|
|
2
|
+
/** One labeled fact the extractor should (or, when outdated, should not) produce. */
|
|
3
|
+
export interface MemoryBenchmarkFact {
|
|
4
|
+
id: string;
|
|
5
|
+
memoryType: string;
|
|
6
|
+
/** Key phrases; an edit matches when its title+content carry every phrase. */
|
|
7
|
+
mustContain: string[];
|
|
8
|
+
/** Outdated facts must be avoided (create/update) or removed (archive/delete). */
|
|
9
|
+
outdated?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface MemoryBenchmarkReference {
|
|
12
|
+
facts: MemoryBenchmarkFact[];
|
|
13
|
+
}
|
|
14
|
+
/** One proposed edit in the shape the memory loop emits. */
|
|
15
|
+
export interface MemoryBenchmarkEdit {
|
|
16
|
+
action: "create" | "update" | "delete" | "archive";
|
|
17
|
+
title?: string;
|
|
18
|
+
content?: string;
|
|
19
|
+
/** Target entry id for update/delete/archive. */
|
|
20
|
+
id?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ScoredMemoryBenchmarkEdit {
|
|
23
|
+
action: MemoryBenchmarkEdit["action"];
|
|
24
|
+
matchedFactIds: string[];
|
|
25
|
+
duplicateOf?: string;
|
|
26
|
+
staleReason?: string;
|
|
27
|
+
noisyReason?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface MemoryBenchmarkScore {
|
|
30
|
+
totalEdits: number;
|
|
31
|
+
matchedFacts: number;
|
|
32
|
+
precision: number;
|
|
33
|
+
recall: number;
|
|
34
|
+
duplicate: number;
|
|
35
|
+
stale: number;
|
|
36
|
+
noise: number;
|
|
37
|
+
uncoveredFactIds: string[];
|
|
38
|
+
edits: ScoredMemoryBenchmarkEdit[];
|
|
39
|
+
}
|
|
40
|
+
export interface MemoryGradeThresholds {
|
|
41
|
+
minPrecision: number;
|
|
42
|
+
minRecall: number;
|
|
43
|
+
maxDuplicate: number;
|
|
44
|
+
maxStale: number;
|
|
45
|
+
maxNoise: number;
|
|
46
|
+
}
|
|
47
|
+
/** Strict-by-default gate: duplicates and stale operations never pass unnoticed. */
|
|
48
|
+
export declare const DEFAULT_MEMORY_GRADE_THRESHOLDS: MemoryGradeThresholds;
|
|
49
|
+
/**
|
|
50
|
+
* v1 exclusion heuristics for noise ("不该记什么" in mechanical form):
|
|
51
|
+
* git history, repo paths re-readable from disk, stack traces, and
|
|
52
|
+
* explicitly temporary session state. Keep this list short and documented —
|
|
53
|
+
* every pattern here is a precision/recall tradeoff, not ground truth.
|
|
54
|
+
*/
|
|
55
|
+
export declare const MEMORY_NOISE_PATTERNS: readonly RegExp[];
|
|
56
|
+
/** Content shorter than this is too thin to be a durable fact. */
|
|
57
|
+
export declare const MEMORY_MIN_FACT_CHARS = 30;
|
|
58
|
+
/**
|
|
59
|
+
* Score one proposal against the reference and the pre-extraction manifest.
|
|
60
|
+
*
|
|
61
|
+
* @param reference Labeled facts (live + outdated).
|
|
62
|
+
* @param edits The proposal's memory edits.
|
|
63
|
+
* @param manifest Memory entries visible before extraction (duplicate source).
|
|
64
|
+
* @returns Per-dimension counts plus a per-edit breakdown.
|
|
65
|
+
*/
|
|
66
|
+
export declare function scoreMemoryExtraction(reference: MemoryBenchmarkReference, edits: readonly MemoryBenchmarkEdit[], manifest: readonly HarnessEntry[]): MemoryBenchmarkScore;
|
|
67
|
+
/**
|
|
68
|
+
* Grade a score against explicit thresholds.
|
|
69
|
+
*
|
|
70
|
+
* @returns pass plus one failure line per violated dimension (empty when passing).
|
|
71
|
+
*/
|
|
72
|
+
export declare function gradeMemoryScore(score: MemoryBenchmarkScore, thresholds?: MemoryGradeThresholds): {
|
|
73
|
+
pass: boolean;
|
|
74
|
+
failures: string[];
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=memory-benchmark.d.ts.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory-specific extraction benchmark (P1): deterministic scoring of one
|
|
3
|
+
* memory proposal against a labeled reference.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the harness benchmark (which runs subagents against rubrics), this
|
|
6
|
+
* module scores a proposal's SHAPE without any model call: precision and
|
|
7
|
+
* recall over reference facts, duplicate creates (update-first violations),
|
|
8
|
+
* stale operations (re-proposing outdated facts, or removing live ones), and
|
|
9
|
+
* noise (unmatched or exclusion-list material). Suite authors hand-build
|
|
10
|
+
* small references plus transcripts; the extraction loop under test produces
|
|
11
|
+
* the proposal, this module grades it.
|
|
12
|
+
*
|
|
13
|
+
* Matching is phrase-substring based (case-insensitive): a reference fact
|
|
14
|
+
* lists must-contain phrases, and a proposed edit matches when its title
|
|
15
|
+
* plus content carry every phrase. Deliberately mechanical — the benchmark
|
|
16
|
+
* measures extraction coverage, not paraphrase quality.
|
|
17
|
+
*/
|
|
18
|
+
import { CONFLICT_BLOCK_SCORE, mostSimilarEntry } from "./promotion.js";
|
|
19
|
+
import { isArchived } from "./types.js";
|
|
20
|
+
/** Strict-by-default gate: duplicates and stale operations never pass unnoticed. */
|
|
21
|
+
export const DEFAULT_MEMORY_GRADE_THRESHOLDS = {
|
|
22
|
+
minPrecision: 0.7,
|
|
23
|
+
minRecall: 0.7,
|
|
24
|
+
maxDuplicate: 0,
|
|
25
|
+
maxStale: 0,
|
|
26
|
+
maxNoise: 0,
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* v1 exclusion heuristics for noise ("不该记什么" in mechanical form):
|
|
30
|
+
* git history, repo paths re-readable from disk, stack traces, and
|
|
31
|
+
* explicitly temporary session state. Keep this list short and documented —
|
|
32
|
+
* every pattern here is a precision/recall tradeoff, not ground truth.
|
|
33
|
+
*/
|
|
34
|
+
export const MEMORY_NOISE_PATTERNS = [
|
|
35
|
+
/\bgit\s+(log|show|commit|diff)\b/i,
|
|
36
|
+
/\bcommit\s+[0-9a-f]{5,40}\b/i,
|
|
37
|
+
/\b[0-9a-f]{40}\b/,
|
|
38
|
+
/(?:^|[\s("'])((?:src|lib|test|tests|docs|scripts)\/[A-Za-z0-9_./-]+\.[a-z]+)/,
|
|
39
|
+
/\bat\s+[\w$.]+\s*\(.*:\d+:\d+\)/,
|
|
40
|
+
/\b(temporary|one-off debugging|current task progress|in this session only)\b/i,
|
|
41
|
+
];
|
|
42
|
+
/** Content shorter than this is too thin to be a durable fact. */
|
|
43
|
+
export const MEMORY_MIN_FACT_CHARS = 30;
|
|
44
|
+
/**
|
|
45
|
+
* Score one proposal against the reference and the pre-extraction manifest.
|
|
46
|
+
*
|
|
47
|
+
* @param reference Labeled facts (live + outdated).
|
|
48
|
+
* @param edits The proposal's memory edits.
|
|
49
|
+
* @param manifest Memory entries visible before extraction (duplicate source).
|
|
50
|
+
* @returns Per-dimension counts plus a per-edit breakdown.
|
|
51
|
+
*/
|
|
52
|
+
export function scoreMemoryExtraction(reference, edits, manifest) {
|
|
53
|
+
const live = reference.facts.filter((fact) => !fact.outdated);
|
|
54
|
+
const outdated = reference.facts.filter((fact) => fact.outdated);
|
|
55
|
+
const liveMatched = new Set();
|
|
56
|
+
const outdatedHandled = new Set();
|
|
57
|
+
const scored = [];
|
|
58
|
+
let duplicate = 0;
|
|
59
|
+
let stale = 0;
|
|
60
|
+
let noise = 0;
|
|
61
|
+
for (const edit of edits) {
|
|
62
|
+
const text = `${edit.title ?? ""}\n${edit.content ?? ""}`;
|
|
63
|
+
const liveHits = live.filter((fact) => matchesFact(text, fact));
|
|
64
|
+
const outdatedHits = outdated.filter((fact) => matchesFact(text, fact));
|
|
65
|
+
const entry = { action: edit.action, matchedFactIds: [] };
|
|
66
|
+
if (edit.action === "create" || edit.action === "update") {
|
|
67
|
+
if (outdatedHits.length > 0) {
|
|
68
|
+
stale += 1;
|
|
69
|
+
entry.staleReason = `re-proposes outdated fact(s): ${outdatedHits.map((fact) => fact.id).join(",")}`;
|
|
70
|
+
}
|
|
71
|
+
else if (edit.action === "create") {
|
|
72
|
+
const hit = mostSimilarEntry(manifest.filter((candidate) => candidate.kind === "memory"), edit.title ?? "", edit.content ?? "", CONFLICT_BLOCK_SCORE);
|
|
73
|
+
if (hit) {
|
|
74
|
+
duplicate += 1;
|
|
75
|
+
entry.duplicateOf = hit.id;
|
|
76
|
+
}
|
|
77
|
+
else if (liveHits.length > 0) {
|
|
78
|
+
for (const fact of liveHits) {
|
|
79
|
+
liveMatched.add(fact.id);
|
|
80
|
+
entry.matchedFactIds.push(fact.id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
noise += 1;
|
|
85
|
+
entry.noisyReason = noiseReason(text);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (liveHits.length > 0) {
|
|
89
|
+
for (const fact of liveHits) {
|
|
90
|
+
liveMatched.add(fact.id);
|
|
91
|
+
entry.matchedFactIds.push(fact.id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
noise += 1;
|
|
96
|
+
entry.noisyReason = noiseReason(text);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// delete/archive: correct only against outdated facts.
|
|
101
|
+
const targetHits = [...liveHits, ...outdatedHits];
|
|
102
|
+
const targetOutdated = outdatedHits.length > 0;
|
|
103
|
+
const targetEntry = edit.id !== undefined ? manifest.find((candidate) => candidate.id === edit.id) : undefined;
|
|
104
|
+
if (targetOutdated) {
|
|
105
|
+
for (const fact of outdatedHits) {
|
|
106
|
+
outdatedHandled.add(fact.id);
|
|
107
|
+
entry.matchedFactIds.push(fact.id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (targetEntry !== undefined && isArchived(targetEntry)) {
|
|
111
|
+
// Re-archiving an already archived entry is harmless housekeeping.
|
|
112
|
+
entry.matchedFactIds.push(`archived:${targetEntry.id}`);
|
|
113
|
+
}
|
|
114
|
+
else if (targetHits.length > 0 || (targetEntry !== undefined && !isArchived(targetEntry))) {
|
|
115
|
+
stale += 1;
|
|
116
|
+
entry.staleReason = `removes live fact(s): ${targetHits.map((fact) => fact.id).join(",") || targetEntry?.id || "(unknown target)"}`;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
noise += 1;
|
|
120
|
+
entry.noisyReason = "removal matches no reference fact";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
scored.push(entry);
|
|
124
|
+
}
|
|
125
|
+
const total = edits.length;
|
|
126
|
+
const preciseEdits = scored.filter((entry) => entry.matchedFactIds.length > 0).length;
|
|
127
|
+
const precision = total === 0 ? 1 : preciseEdits / total;
|
|
128
|
+
const recall = live.length === 0 ? 1 : liveMatched.size / live.length;
|
|
129
|
+
return {
|
|
130
|
+
totalEdits: total,
|
|
131
|
+
matchedFacts: liveMatched.size + outdatedHandled.size,
|
|
132
|
+
precision,
|
|
133
|
+
recall,
|
|
134
|
+
duplicate,
|
|
135
|
+
stale,
|
|
136
|
+
noise,
|
|
137
|
+
uncoveredFactIds: live.filter((fact) => !liveMatched.has(fact.id)).map((fact) => fact.id),
|
|
138
|
+
edits: scored,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Grade a score against explicit thresholds.
|
|
143
|
+
*
|
|
144
|
+
* @returns pass plus one failure line per violated dimension (empty when passing).
|
|
145
|
+
*/
|
|
146
|
+
export function gradeMemoryScore(score, thresholds = DEFAULT_MEMORY_GRADE_THRESHOLDS) {
|
|
147
|
+
const failures = [];
|
|
148
|
+
if (score.precision < thresholds.minPrecision)
|
|
149
|
+
failures.push(`precision ${score.precision.toFixed(2)} < ${thresholds.minPrecision}`);
|
|
150
|
+
if (score.recall < thresholds.minRecall)
|
|
151
|
+
failures.push(`recall ${score.recall.toFixed(2)} < ${thresholds.minRecall}`);
|
|
152
|
+
if (score.duplicate > thresholds.maxDuplicate)
|
|
153
|
+
failures.push(`duplicate ${score.duplicate} > ${thresholds.maxDuplicate}`);
|
|
154
|
+
if (score.stale > thresholds.maxStale)
|
|
155
|
+
failures.push(`stale ${score.stale} > ${thresholds.maxStale}`);
|
|
156
|
+
if (score.noise > thresholds.maxNoise)
|
|
157
|
+
failures.push(`noise ${score.noise} > ${thresholds.maxNoise}`);
|
|
158
|
+
return { pass: failures.length === 0, failures };
|
|
159
|
+
}
|
|
160
|
+
function matchesFact(text, fact) {
|
|
161
|
+
const lowered = text.toLowerCase();
|
|
162
|
+
return fact.mustContain.every((phrase) => lowered.includes(phrase.toLowerCase()));
|
|
163
|
+
}
|
|
164
|
+
function noiseReason(text) {
|
|
165
|
+
if (text.trim().length < MEMORY_MIN_FACT_CHARS)
|
|
166
|
+
return `thin content (<${MEMORY_MIN_FACT_CHARS} chars)`;
|
|
167
|
+
const hit = MEMORY_NOISE_PATTERNS.findIndex((pattern) => pattern.test(text));
|
|
168
|
+
return hit >= 0 ? `matches exclusion pattern #${hit + 1}` : "matches no reference fact";
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=memory-benchmark.js.map
|
package/lib/notify.d.ts
CHANGED
|
@@ -29,4 +29,31 @@ export declare function buildGateNotice(result: RefinementResult, turnsSinceLast
|
|
|
29
29
|
* the decision in reviews.jsonl.
|
|
30
30
|
*/
|
|
31
31
|
export declare function notifyAutoReview(ctx: Context, agent: Agent, result: RefinementResult, turnsSinceLastReview: number): void;
|
|
32
|
+
/** Unified extraction receipt: one shape for applied / declined / no-op. */
|
|
33
|
+
export interface MemoryReceipt {
|
|
34
|
+
outcome: "applied" | "declined" | "noop";
|
|
35
|
+
/** Applied refinement batches (one per scope) for applied outcomes. */
|
|
36
|
+
results?: RefinementResult[];
|
|
37
|
+
/** Scopes the human explicitly declined. */
|
|
38
|
+
declinedScopes?: string[];
|
|
39
|
+
/** Extractor loop turns / manifest searches / wall time. */
|
|
40
|
+
turns: number;
|
|
41
|
+
searches: number;
|
|
42
|
+
durationMs: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Compose the user-visible memory receipt from applied refinement batches
|
|
46
|
+
* (never model text): every landed memory edit with its rollback command.
|
|
47
|
+
* No-op and declined-only outcomes render one quiet line each — a foreground
|
|
48
|
+
* notice for zero landed edits would be nagging, so callers only queue
|
|
49
|
+
* follow-ups for applied outcomes.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildMemoryReceipt(receipt: MemoryReceipt): string;
|
|
52
|
+
/**
|
|
53
|
+
* Queue the memory receipt follow-up. Same containment as the gate notice:
|
|
54
|
+
* notification failure only logs. Callers gate on applied outcomes — no-op
|
|
55
|
+
* and declined-only runs stay audit-only (reviews.jsonl) and never wake
|
|
56
|
+
* the agent.
|
|
57
|
+
*/
|
|
58
|
+
export declare function notifyMemoryExtraction(ctx: Context, agent: Agent, receipt: MemoryReceipt): void;
|
|
32
59
|
//# sourceMappingURL=notify.d.ts.map
|
package/lib/notify.js
CHANGED
|
@@ -40,4 +40,58 @@ export function notifyAutoReview(ctx, agent, result, turnsSinceLastReview) {
|
|
|
40
40
|
.warn(`auto-review notice failed for ${agent.id}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Compose the user-visible memory receipt from applied refinement batches
|
|
45
|
+
* (never model text): every landed memory edit with its rollback command.
|
|
46
|
+
* No-op and declined-only outcomes render one quiet line each — a foreground
|
|
47
|
+
* notice for zero landed edits would be nagging, so callers only queue
|
|
48
|
+
* follow-ups for applied outcomes.
|
|
49
|
+
*/
|
|
50
|
+
export function buildMemoryReceipt(receipt) {
|
|
51
|
+
const seconds = (receipt.durationMs / 1000).toFixed(1);
|
|
52
|
+
const stats = `${receipt.turns} 轮推理 / ${receipt.searches} 次检索 / ${seconds}s`;
|
|
53
|
+
if (receipt.outcome === "noop") {
|
|
54
|
+
return `🧠 记忆提取:本轮无可沉淀的持久事实(no-op,${stats}),未写入任何条目。`;
|
|
55
|
+
}
|
|
56
|
+
const results = receipt.results ?? [];
|
|
57
|
+
const declinedScopes = receipt.declinedScopes ?? [];
|
|
58
|
+
if (receipt.outcome === "declined" || results.length === 0) {
|
|
59
|
+
const scopes = declinedScopes.join("、") || "持久化作用域";
|
|
60
|
+
return `🧠 记忆提取:${scopes}的写入未获批准,已跳过(${stats}),未写入任何条目。`;
|
|
61
|
+
}
|
|
62
|
+
const applied = results.flatMap((result) => result.appliedEdits.filter((edit) => edit.applied));
|
|
63
|
+
const lines = applied.slice(0, 12).map((edit) => `- 记忆「${edit.title ?? edit.id}」(${edit.id})`);
|
|
64
|
+
if (applied.length > 12)
|
|
65
|
+
lines.push(`- …另有 ${applied.length - 12} 条(见 /evolve history)`);
|
|
66
|
+
const declined = declinedScopes.length > 0 ? `\n另有作用域${declinedScopes.join("、")}未经批准(已跳过)。` : "";
|
|
67
|
+
const rollbacks = [...new Set(results.map((result) => result.id))].map((id) => `/evolve rollback ${id}`).join(";");
|
|
68
|
+
return [
|
|
69
|
+
`🧠 记忆提取:本轮沉淀 ${applied.length} 条记忆(${results.length} 个作用域批次,${stats}):`,
|
|
70
|
+
lines.length > 0 ? lines.join("\n") : "(无条目成功应用)",
|
|
71
|
+
declined,
|
|
72
|
+
`回看:/evolve recall <关键词>;回滚:${rollbacks}`,
|
|
73
|
+
"请用一句话简短确认即可,不要调用任何工具。",
|
|
74
|
+
]
|
|
75
|
+
.filter((part) => part !== "")
|
|
76
|
+
.join("\n");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Queue the memory receipt follow-up. Same containment as the gate notice:
|
|
80
|
+
* notification failure only logs. Callers gate on applied outcomes — no-op
|
|
81
|
+
* and declined-only runs stay audit-only (reviews.jsonl) and never wake
|
|
82
|
+
* the agent.
|
|
83
|
+
*/
|
|
84
|
+
export function notifyMemoryExtraction(ctx, agent, receipt) {
|
|
85
|
+
try {
|
|
86
|
+
agent.followup(createUserMessage({
|
|
87
|
+
content: [{ type: "text", text: buildMemoryReceipt(receipt) }],
|
|
88
|
+
source: EVOLVE_MESSAGE_SOURCE,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
ctx
|
|
93
|
+
.logger("continual-evolve")
|
|
94
|
+
.warn(`memory receipt notice failed for ${agent.id}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
43
97
|
//# sourceMappingURL=notify.js.map
|
package/lib/planner.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { Agent } from "@deepseek-ai/dsh-agent";
|
|
|
11
11
|
import type { HarnessState, RefinementProposal, RefinementResult } from "./types.js";
|
|
12
12
|
import { type PrefixCacheOptions } from "./prefix-cache.js";
|
|
13
13
|
import { type TokenUsageTarget } from "./token-usage.js";
|
|
14
|
-
export declare const PLANNER_SYSTEM_PROMPT = "You are the /evolve continual harness subsystem.\n\nYour job is to improve the editable continual harness state. Instead of\nsummarizing the conversation you emit precise Create, Update, or Delete edits\nto reusable state: prompt notes, memories, skills, and subagent specs.\n\nRules:\n- The base system prompt is immutable and MUST NOT be rewritten (never edit id \"base_system_prompt\").\n- Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.\n- prompt = narrow behavioral policy addendums; memory = ONE FACT per entry, typed via metadata.memoryType\n- (user = who the user is; feedback = a pitfall/correction or confirmed approach, MUST carry Why + How to apply;\n- project = work goal/constraint not derivable from the repo; reference = URL/dashboard/ticket pointer).\n- Memory rules: one fact per entry (title = one-line retrieval hook, content = the fact alone);\n- never save what the repo already records (code structure, past fixes, git history, AGENTS.md) or what only\n- matters to this conversation \u2014 save the non-obvious point instead; before creating, update the existing\n- entry when one already covers the fact (creates carry metadata {memoryType} or they are rejected);\n- skill = repeatable procedures (must carry a python reference {type:\"python\", import, callable}\n and an arguments object); subagent = reusable delegation roles.\n- Skill entries are authored to the DSH skill quality standard\n (skill-creator, distilled from the official deepseek-harness 11 skills;\n the full facts are provided in the <skill_quality_standard> block below):\n only for a REAL trigger scenario grounded in the trajectory\n (who, in what real task, what signal) \u2014 never invent one to pad the store;\n never duplicate the official 11 skills or existing entries; content is a\n SKILL.md document (frontmatter routing with \"use when / do not use when\"\n description, boundary declaration, prerequisites and exclusions, layered\n information, verifiable completion criteria). Self-check every proposed\n skill against the 7 structural features and state the result in its\n reason field.\n- Repeated multi-step workflows (session start/end routines, recurring\n wrap-up or handoff procedures) may be proposed as guidance skills:\n kind=skill, skill_kind=\"guidance\", content = a SKILL.md document (no\n python reference \u2014 executable skills keep requiring reference +\n arguments). Only propose with repeated evidence in the trajectory, never\n for one-off flows. Guidance skills materialize as discoverable SKILL.md\n files under <skillsRoot>/<kebab-name>/SKILL.md and are always offered to\n the user for a decision before they land.\n- Local edits are session-scoped; global edits persist across sessions.\n- Gap C2: EVERY edit MUST include a \"blastRadius\" field indicating how broadly\n the edit applies: \"general\" (cross-project tactical rule, applies everywhere),\n \"project\" (valid for the current project/repo), or \"session\" (one-off,\n specific to this session's context). The review gate validates that\n local-scope edits use \"session\" or \"project\", and global-scope edits use\n \"general\" or \"project\". When in doubt, prefer narrower blast radius.\n- Ground every edit in evidence: the session trajectory (recent direct user\n messages) is provided when available; prefer edits backed by it over\n speculation, and never invent preferences the user did not express.\n- Stale entries (superseded by newer ones, never referenced in recent\n trajectories, obsolete facts): propose action \"archive\" instead of\n \"delete\" \u2014 archive hides the entry from injection while keeping its data\n restorable; it requires only kind + id.\n- Output JSON only, exactly this shape:\n{\n \"summary\": \"one sentence\",\n \"rationale\": \"why these edits are justified by the evidence\",\n \"expectedOutcome\": \"what should improve and how to validate it\",\n \"edits\": [\n {\n \"action\": \"create|update|delete\",\n \"kind\": \"prompt|memory|skill|subagent\",\n \"id\": \"stable id for update/delete, optional for create\",\n \"title\": \"required for create/update except delete\",\n \"content\": \"required for create/update except delete\",\n \"path\": \"optional grouping path\",\n \"reference\": {\"type\":\"python\",\"import\":\"pkg.mod\",\"callable\":\"fn\"} ,\n \"arguments\": {\"name\": {\"type\":\"string\",\"required\":true,\"description\":\"...\"}},\n \"skill_kind\": \"executable|guidance (optional; skill kind only \u2014 guidance = SKILL.md document without reference)\",\n \"metadata\": {},\n \"reason\": \"why this edit is useful\",\n \"blastRadius\": \"general|project|session\"\n }\n ]\n}";
|
|
14
|
+
export declare const PLANNER_SYSTEM_PROMPT = "You are the /evolve continual harness subsystem.\n\nYour job is to improve the editable continual harness state. Instead of\nsummarizing the conversation you emit precise Create, Update, or Delete edits\nto reusable state: prompt notes, memories, skills, and subagent specs.\n\nRules:\n- The base system prompt is immutable and MUST NOT be rewritten (never edit id \"base_system_prompt\").\n- Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.\n- Repeated evidence is mandatory for prompt/skill/subagent proposals: the same\n pattern must recur at least twice in the trajectory. A single one-off\n interaction never justifies a prompt, skill, or subagent edit \u2014 return an\n empty edits array with a rationale instead. Session-scoped local prompt\n landings converge on /evolve wrapup human review; the turn pipeline must\n not silently persist one-off abstractions.\n- prompt = narrow behavioral policy addendums; memory = ONE FACT per entry, typed via metadata.memoryType\n- (user = who the user is; feedback = a pitfall/correction or confirmed approach, MUST carry Why + How to apply;\n- project = work goal/constraint not derivable from the repo; reference = URL/dashboard/ticket pointer).\n- Memory rules: one fact per entry (title = one-line retrieval hook, content = the fact alone);\n- never save what the repo already records (code structure, past fixes, git history, AGENTS.md) or what only\n- matters to this conversation \u2014 save the non-obvious point instead; before creating, update the existing\n- entry when one already covers the fact (creates carry metadata {memoryType} or they are rejected);\n- skill = repeatable procedures (must carry a python reference {type:\"python\", import, callable}\n and an arguments object); subagent = reusable delegation roles.\n- Skill entries are authored to the DSH skill quality standard\n (skill-creator, distilled from the official deepseek-harness 11 skills;\n the full facts are provided in the <skill_quality_standard> block below):\n only for a REAL trigger scenario grounded in the trajectory\n (who, in what real task, what signal) \u2014 never invent one to pad the store;\n never duplicate the official 11 skills or existing entries; content is a\n SKILL.md document (frontmatter routing with \"use when / do not use when\"\n description, boundary declaration, prerequisites and exclusions, layered\n information, verifiable completion criteria). Self-check every proposed\n skill against the 7 structural features and state the result in its\n reason field.\n- Repeated multi-step workflows (session start/end routines, recurring\n wrap-up or handoff procedures) may be proposed as guidance skills:\n kind=skill, skill_kind=\"guidance\", content = a SKILL.md document (no\n python reference \u2014 executable skills keep requiring reference +\n arguments). Only propose with repeated evidence in the trajectory, never\n for one-off flows. Guidance skills materialize as discoverable SKILL.md\n files under <skillsRoot>/<kebab-name>/SKILL.md and are always offered to\n the user for a decision before they land.\n- Local edits are session-scoped; global edits persist across sessions.\n- Gap C2: EVERY edit MUST include a \"blastRadius\" field indicating how broadly\n the edit applies: \"general\" (cross-project tactical rule, applies everywhere),\n \"project\" (valid for the current project/repo), or \"session\" (one-off,\n specific to this session's context). The review gate validates that\n local-scope edits use \"session\" or \"project\", and global-scope edits use\n \"general\" or \"project\". When in doubt, prefer narrower blast radius.\n- Ground every edit in evidence: the session trajectory (recent direct user\n messages) is provided when available; prefer edits backed by it over\n speculation, and never invent preferences the user did not express.\n- Stale entries (superseded by newer ones, never referenced in recent\n trajectories, obsolete facts): propose action \"archive\" instead of\n \"delete\" \u2014 archive hides the entry from injection while keeping its data\n restorable; it requires only kind + id.\n- Output JSON only, exactly this shape:\n{\n \"summary\": \"one sentence\",\n \"rationale\": \"why these edits are justified by the evidence\",\n \"expectedOutcome\": \"what should improve and how to validate it\",\n \"edits\": [\n {\n \"action\": \"create|update|delete\",\n \"kind\": \"prompt|memory|skill|subagent\",\n \"id\": \"stable id for update/delete, optional for create\",\n \"title\": \"required for create/update except delete\",\n \"content\": \"required for create/update except delete\",\n \"path\": \"optional grouping path\",\n \"reference\": {\"type\":\"python\",\"import\":\"pkg.mod\",\"callable\":\"fn\"} ,\n \"arguments\": {\"name\": {\"type\":\"string\",\"required\":true,\"description\":\"...\"}},\n \"skill_kind\": \"executable|guidance (optional; skill kind only \u2014 guidance = SKILL.md document without reference)\",\n \"metadata\": {},\n \"reason\": \"why this edit is useful\",\n \"blastRadius\": \"general|project|session\"\n }\n ]\n}";
|
|
15
15
|
export interface PlanOptions {
|
|
16
16
|
agent: Agent;
|
|
17
17
|
state: HarnessState;
|
package/lib/planner.js
CHANGED
|
@@ -14,6 +14,12 @@ to reusable state: prompt notes, memories, skills, and subagent specs.
|
|
|
14
14
|
Rules:
|
|
15
15
|
- The base system prompt is immutable and MUST NOT be rewritten (never edit id "base_system_prompt").
|
|
16
16
|
- Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.
|
|
17
|
+
- Repeated evidence is mandatory for prompt/skill/subagent proposals: the same
|
|
18
|
+
pattern must recur at least twice in the trajectory. A single one-off
|
|
19
|
+
interaction never justifies a prompt, skill, or subagent edit — return an
|
|
20
|
+
empty edits array with a rationale instead. Session-scoped local prompt
|
|
21
|
+
landings converge on /evolve wrapup human review; the turn pipeline must
|
|
22
|
+
not silently persist one-off abstractions.
|
|
17
23
|
- prompt = narrow behavioral policy addendums; memory = ONE FACT per entry, typed via metadata.memoryType
|
|
18
24
|
- (user = who the user is; feedback = a pitfall/correction or confirmed approach, MUST carry Why + How to apply;
|
|
19
25
|
- project = work goal/constraint not derivable from the repo; reference = URL/dashboard/ticket pointer).
|
|
@@ -85,7 +91,7 @@ export async function planWithLlm(ctx, options) {
|
|
|
85
91
|
}
|
|
86
92
|
const scopeInstruction = options.global
|
|
87
93
|
? "Requested scope: global. Only propose stable cross-session lessons, durable preferences, reusable skills/subagents, or explicitly project-qualified facts."
|
|
88
|
-
: "Requested scope: local.
|
|
94
|
+
: "Requested scope: local. Propose session-scoped edits only when the trajectory shows repeated evidence (>=2 occurrences of the same pattern); a single one-off interaction returns an empty edits array. Global entries are read-only context — do not propose update/delete for them.";
|
|
89
95
|
// Ground the plan in the caller's session: Route A carries the context
|
|
90
96
|
// as a session-derived message prefix (cache-eligible on providers with
|
|
91
97
|
// prompt caching) and drops the auto-extracted trajectory block; Route B
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HarnessState } from "./types.js";
|
|
2
|
+
/** Index file name at the store root. */
|
|
3
|
+
export declare const MEMORY_INDEX_FILE = "MEMORY.md";
|
|
4
|
+
/** Per-fact file directory inside the store. */
|
|
5
|
+
export declare const MEMORY_FACTS_DIR = "memory";
|
|
6
|
+
/** Map a memory id to a flat filename (`:` is legal on disk but hostile to tools). */
|
|
7
|
+
export declare function memoryFactFilename(id: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Rewrite the whole projection from the post-apply state: index, one file
|
|
10
|
+
* per memory entry (including archived ones, flagged), and deletion of
|
|
11
|
+
* files whose ids no longer exist. Deterministic — the same state always
|
|
12
|
+
* produces the same files.
|
|
13
|
+
*
|
|
14
|
+
* @param stateDir The store directory holding harness_state.json.
|
|
15
|
+
* @param state The freshly persisted state (post-apply, post-rollback).
|
|
16
|
+
*/
|
|
17
|
+
export declare function materializeMemoryProjection(stateDir: string, state: HarnessState): void;
|
|
18
|
+
/** Read back one projected fact file (round-trip helper for tests and tools). */
|
|
19
|
+
export declare function readMemoryFact(factsDir: string, filename: string): string;
|
|
20
|
+
/** True when the store directory carries a projection from a previous apply. */
|
|
21
|
+
export declare function hasMemoryProjection(stateDir: string): boolean;
|
|
22
|
+
//# sourceMappingURL=projection.d.ts.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readable Markdown projection (P1): a ZCode-style `MEMORY.md` index plus
|
|
3
|
+
* one file per memory fact, materialized from the JSON store.
|
|
4
|
+
*
|
|
5
|
+
* The JSON harness state stays the single source of truth (versions,
|
|
6
|
+
* rollback, audit). The projection is a derived, human-trustable view:
|
|
7
|
+
* it is rewritten from the post-apply state inside the engine, so the
|
|
8
|
+
* model can never write it directly and rollback/archive stay in sync.
|
|
9
|
+
*
|
|
10
|
+
* Layout per store directory:
|
|
11
|
+
*
|
|
12
|
+
* <stateDir>/MEMORY.md one line per memory entry
|
|
13
|
+
* <stateDir>/memory/<file>.md one fact per file with frontmatter
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { isArchived, isMemoryType, MEMORY_TYPE_KEY } from "./types.js";
|
|
18
|
+
/** Index file name at the store root. */
|
|
19
|
+
export const MEMORY_INDEX_FILE = "MEMORY.md";
|
|
20
|
+
/** Per-fact file directory inside the store. */
|
|
21
|
+
export const MEMORY_FACTS_DIR = "memory";
|
|
22
|
+
/** Map a memory id to a flat filename (`:` is legal on disk but hostile to tools). */
|
|
23
|
+
export function memoryFactFilename(id) {
|
|
24
|
+
return `${id.replace(/:/g, "_")}.md`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Rewrite the whole projection from the post-apply state: index, one file
|
|
28
|
+
* per memory entry (including archived ones, flagged), and deletion of
|
|
29
|
+
* files whose ids no longer exist. Deterministic — the same state always
|
|
30
|
+
* produces the same files.
|
|
31
|
+
*
|
|
32
|
+
* @param stateDir The store directory holding harness_state.json.
|
|
33
|
+
* @param state The freshly persisted state (post-apply, post-rollback).
|
|
34
|
+
*/
|
|
35
|
+
export function materializeMemoryProjection(stateDir, state) {
|
|
36
|
+
const entries = Object.values(state.entries.memory).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
37
|
+
const factsDir = join(stateDir, MEMORY_FACTS_DIR);
|
|
38
|
+
mkdirSync(factsDir, { recursive: true });
|
|
39
|
+
const expected = new Map();
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
let filename = memoryFactFilename(entry.id);
|
|
42
|
+
let suffix = 1;
|
|
43
|
+
while (expected.has(filename)) {
|
|
44
|
+
suffix += 1;
|
|
45
|
+
filename = `${memoryFactFilename(entry.id).replace(/\.md$/, "")}~${suffix}.md`;
|
|
46
|
+
}
|
|
47
|
+
expected.set(filename, entry);
|
|
48
|
+
}
|
|
49
|
+
for (const [filename, entry] of expected) {
|
|
50
|
+
writeFileSync(join(factsDir, filename), renderMemoryFact(entry), { encoding: "utf8", mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
// Sweep facts whose entries vanished (delete); archived entries keep
|
|
53
|
+
// their file with archived:true frontmatter.
|
|
54
|
+
let existing;
|
|
55
|
+
try {
|
|
56
|
+
existing = readdirSync(factsDir).filter((file) => file.endsWith(".md"));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
existing = [];
|
|
60
|
+
}
|
|
61
|
+
for (const file of existing) {
|
|
62
|
+
if (!expected.has(file)) {
|
|
63
|
+
try {
|
|
64
|
+
unlinkSync(join(factsDir, file));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A stale fact file must not break the apply path; the next
|
|
68
|
+
// materialization retries the sweep.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
writeFileSync(join(stateDir, MEMORY_INDEX_FILE), renderMemoryIndex([...expected]), { encoding: "utf8", mode: 0o600 });
|
|
73
|
+
}
|
|
74
|
+
/** Read back one projected fact file (round-trip helper for tests and tools). */
|
|
75
|
+
export function readMemoryFact(factsDir, filename) {
|
|
76
|
+
return readFileSync(join(factsDir, filename), "utf8");
|
|
77
|
+
}
|
|
78
|
+
/** True when the store directory carries a projection from a previous apply. */
|
|
79
|
+
export function hasMemoryProjection(stateDir) {
|
|
80
|
+
return existsSync(join(stateDir, MEMORY_INDEX_FILE)) && existsSync(join(stateDir, MEMORY_FACTS_DIR));
|
|
81
|
+
}
|
|
82
|
+
function renderMemoryFact(entry) {
|
|
83
|
+
const type = isMemoryType(entry.metadata[MEMORY_TYPE_KEY]) ? String(entry.metadata[MEMORY_TYPE_KEY]) : "untyped";
|
|
84
|
+
const frontmatter = [
|
|
85
|
+
"---",
|
|
86
|
+
`id: ${entry.id}`,
|
|
87
|
+
`scope: ${entry.scope}`,
|
|
88
|
+
`kind: memory`,
|
|
89
|
+
`memoryType: ${type}`,
|
|
90
|
+
`version: ${entry.version}`,
|
|
91
|
+
`path: ${entry.path}`,
|
|
92
|
+
`archived: ${isArchived(entry)}`,
|
|
93
|
+
`created_at: ${entry.created_at}`,
|
|
94
|
+
`updated_at: ${entry.updated_at}`,
|
|
95
|
+
"---",
|
|
96
|
+
].join("\n");
|
|
97
|
+
return `${frontmatter}\n\n# ${entry.title}\n\n${entry.content}\n`;
|
|
98
|
+
}
|
|
99
|
+
function renderMemoryIndex(files) {
|
|
100
|
+
const lines = ["# Memory Index", "", "Derived from the JSON harness state — do not edit by hand; it is rewritten on every memory apply.", ""];
|
|
101
|
+
if (files.length === 0) {
|
|
102
|
+
lines.push("(no memory entries yet)");
|
|
103
|
+
return `${lines.join("\n")}\n`;
|
|
104
|
+
}
|
|
105
|
+
for (const [file, entry] of files) {
|
|
106
|
+
const type = isMemoryType(entry.metadata[MEMORY_TYPE_KEY]) ? String(entry.metadata[MEMORY_TYPE_KEY]) : "untyped";
|
|
107
|
+
const archived = isArchived(entry) ? ", archived" : "";
|
|
108
|
+
lines.push(`- [${entry.scope}:${entry.id}] ${entry.title} (type=${type}, v${entry.version}${archived}) → ${MEMORY_FACTS_DIR}/${file}`);
|
|
109
|
+
}
|
|
110
|
+
return `${lines.join("\n")}\n`;
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=projection.js.map
|