dsh-continual-evolve 0.7.6 → 0.8.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 +16 -7
- package/README.zh.md +12 -7
- package/lib/auto.d.ts +31 -2
- package/lib/auto.js +124 -6
- package/lib/command.js +93 -1
- package/lib/index.d.ts +10 -0
- package/lib/index.js +6 -0
- package/lib/memory-agent.d.ts +1 -1
- package/lib/memory-agent.js +1 -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 +53 -0
- 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/service.js +14 -0
- package/lib/tool.js +40 -0
- package/package.json +1 -1
|
@@ -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,57 @@ 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
|
+
if (receipt.outcome === "declined" || (receipt.results ?? []).length === 0) {
|
|
57
|
+
const scopes = (receipt.declinedScopes ?? []).join("、") || "持久化作用域";
|
|
58
|
+
return `🧠 记忆提取:${scopes}的写入未获批准,已跳过(${stats}),未写入任何条目。`;
|
|
59
|
+
}
|
|
60
|
+
const results = receipt.results ?? [];
|
|
61
|
+
const applied = results.flatMap((result) => result.appliedEdits.filter((edit) => edit.applied));
|
|
62
|
+
const lines = applied.slice(0, 12).map((edit) => `- 记忆「${edit.title ?? edit.id}」(${edit.id})`);
|
|
63
|
+
if (applied.length > 12)
|
|
64
|
+
lines.push(`- …另有 ${applied.length - 12} 条(见 /evolve history)`);
|
|
65
|
+
const declined = (receipt.declinedScopes ?? []).length > 0 ? `\n另有作用域${(receipt.declinedScopes ?? []).join("、")}未经批准(已跳过)。` : "";
|
|
66
|
+
const rollbacks = [...new Set(results.map((result) => result.id))].map((id) => `/evolve rollback ${id}`).join(";");
|
|
67
|
+
return [
|
|
68
|
+
`🧠 记忆提取:本轮沉淀 ${applied.length} 条记忆(${results.length} 个作用域批次,${stats}):`,
|
|
69
|
+
lines.length > 0 ? lines.join("\n") : "(无条目成功应用)",
|
|
70
|
+
declined,
|
|
71
|
+
`回看:/evolve recall <关键词>;回滚:${rollbacks}`,
|
|
72
|
+
"请用一句话简短确认即可,不要调用任何工具。",
|
|
73
|
+
]
|
|
74
|
+
.filter((part) => part !== "")
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Queue the memory receipt follow-up. Same containment as the gate notice:
|
|
79
|
+
* notification failure only logs. Callers gate on applied outcomes — no-op
|
|
80
|
+
* and declined-only runs stay audit-only (reviews.jsonl) and never wake
|
|
81
|
+
* the agent.
|
|
82
|
+
*/
|
|
83
|
+
export function notifyMemoryExtraction(ctx, agent, receipt) {
|
|
84
|
+
try {
|
|
85
|
+
agent.followup(createUserMessage({
|
|
86
|
+
content: [{ type: "text", text: buildMemoryReceipt(receipt) }],
|
|
87
|
+
source: EVOLVE_MESSAGE_SOURCE,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
ctx
|
|
92
|
+
.logger("continual-evolve")
|
|
93
|
+
.warn(`memory receipt notice failed for ${agent.id}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
43
96
|
//# sourceMappingURL=notify.js.map
|
|
@@ -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
|
package/lib/recall.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Targeted memory recall (P1): the precise-reading counterpart of the cheap
|
|
3
|
+
* directory index.
|
|
4
|
+
*
|
|
5
|
+
* The injected directory view (`inject.ts`) shows one line per entry and the
|
|
6
|
+
* full `evolve_list` dumps whole stores; neither answers "give me everything
|
|
7
|
+
* about X". Recall fills that gap: filter by query/kind/scope/memory-type,
|
|
8
|
+
* rank with the same CJK BM25 machinery as injection, and return full
|
|
9
|
+
* content plus source, version, and staleness signals. Read-only — it never
|
|
10
|
+
* mutates a store.
|
|
11
|
+
*/
|
|
12
|
+
import type { EvolutionEngine } from "./service.js";
|
|
13
|
+
import type { HarnessScope, RefinementKind } from "./types.js";
|
|
14
|
+
/** Memory recall types (the `memoryType` taxonomy, not entry kinds). */
|
|
15
|
+
export declare const RECALL_MEMORY_TYPES: readonly ["user", "feedback", "project", "reference"];
|
|
16
|
+
export type RecallMemoryType = (typeof RECALL_MEMORY_TYPES)[number];
|
|
17
|
+
/** Default and maximum hits returned per recall call. */
|
|
18
|
+
export declare const DEFAULT_RECALL_LIMIT = 10;
|
|
19
|
+
export declare const MAX_RECALL_LIMIT = 50;
|
|
20
|
+
export interface RecallFilters {
|
|
21
|
+
/** Free-text relevance query; empty means "most recently updated first". */
|
|
22
|
+
query?: string;
|
|
23
|
+
/** Entry kinds to search; default is memory only. */
|
|
24
|
+
kinds?: RefinementKind[];
|
|
25
|
+
/** Stores to search; default is local + project + global. */
|
|
26
|
+
scopes?: HarnessScope[];
|
|
27
|
+
/** Memory-type filter (applies to memory entries only). */
|
|
28
|
+
memoryTypes?: RecallMemoryType[];
|
|
29
|
+
/** Maximum hits; clamped to 1..50, defaults to 10. */
|
|
30
|
+
limit?: number;
|
|
31
|
+
/** Include archived entries; default false. */
|
|
32
|
+
includeArchived?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** One recalled entry with full content and provenance. */
|
|
35
|
+
export interface RecallHit {
|
|
36
|
+
scope: HarnessScope;
|
|
37
|
+
kind: RefinementKind;
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
content: string;
|
|
41
|
+
path: string;
|
|
42
|
+
version: number;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
updatedAt: string;
|
|
45
|
+
archived: boolean;
|
|
46
|
+
/** Memory-type hook for memory entries; undefined for other kinds. */
|
|
47
|
+
memoryType?: string;
|
|
48
|
+
/** Conflict-hint stamp left by the promotion guard, when present. */
|
|
49
|
+
staleHint?: string;
|
|
50
|
+
/** Session the entry was distilled from, when recorded. */
|
|
51
|
+
sourceSession?: string;
|
|
52
|
+
/** Source user-message seqs, when recorded. */
|
|
53
|
+
sourceSeqs?: number[];
|
|
54
|
+
}
|
|
55
|
+
export interface RecallResult {
|
|
56
|
+
hits: RecallHit[];
|
|
57
|
+
/** Skipped scopes with reasons (read-path honesty: never silently drop a store). */
|
|
58
|
+
notes: string[];
|
|
59
|
+
/** Candidates seen before ranking/truncation. */
|
|
60
|
+
totalCandidates: number;
|
|
61
|
+
}
|
|
62
|
+
export interface RecallContext {
|
|
63
|
+
/** Live session id for the local store; absent → local is skipped with a note. */
|
|
64
|
+
sessionId?: string | undefined;
|
|
65
|
+
/** Resolved project key; absent → project is skipped with a note. */
|
|
66
|
+
projectKey?: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Search the harness stores with mechanical filters and BM25 ranking.
|
|
70
|
+
*
|
|
71
|
+
* @param engine The evolution engine (read-only use).
|
|
72
|
+
* @param context Store identities for the session-scoped stores.
|
|
73
|
+
* @param filters Query/kind/scope/type/limit/archive filters.
|
|
74
|
+
* @returns Ranked hits plus a note for every scope that could not be read.
|
|
75
|
+
* @throws On an unknown kind, scope, or memory type — fail loud instead of
|
|
76
|
+
* silently narrowing the search.
|
|
77
|
+
*/
|
|
78
|
+
export declare function recallMemories(engine: EvolutionEngine, context: RecallContext, filters?: RecallFilters): RecallResult;
|
|
79
|
+
/**
|
|
80
|
+
* Render recall hits for a model or human reader: full content, version,
|
|
81
|
+
* source, and staleness on every hit; skipped scopes are always shown so
|
|
82
|
+
* "no results" can never hide an unread store.
|
|
83
|
+
*/
|
|
84
|
+
export declare function formatRecallResult(result: RecallResult, query?: string): string;
|
|
85
|
+
//# sourceMappingURL=recall.d.ts.map
|