dsh-continual-evolve 0.7.2 → 0.7.3
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 +15 -13
- package/README.zh.md +15 -13
- package/lib/approval.d.ts +11 -4
- package/lib/approval.js +23 -7
- package/lib/auto.d.ts +24 -0
- package/lib/auto.js +141 -14
- package/lib/index.js +6 -4
- package/lib/llm-text.d.ts +48 -5
- package/lib/llm-text.js +94 -27
- package/lib/memory-agent.d.ts +111 -0
- package/lib/memory-agent.js +535 -0
- package/lib/review-scheduler.d.ts +9 -8
- package/lib/review-scheduler.js +118 -36
- package/lib/review.d.ts +1 -1
- package/lib/review.js +6 -5
- package/lib/service.d.ts +9 -0
- package/lib/service.js +46 -20
- package/lib/token-usage.d.ts +1 -1
- package/lib/token-usage.js +8 -8
- package/lib/turn-snapshot.d.ts +10 -0
- package/lib/turn-snapshot.js +34 -0
- package/package.json +1 -1
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { createAssistantMessage, createToolResultMessage, createUserMessage, } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { requestScopeApproval } from "./approval.js";
|
|
3
|
+
import { streamModelTurn } from "./llm-text.js";
|
|
4
|
+
import { EVOLVE_MESSAGE_SOURCE } from "./message-source.js";
|
|
5
|
+
import { extractJsonObject, parseJsonCandidate } from "./plan.js";
|
|
6
|
+
import { buildPrefixMessages, detectPlannerRoute, resolvePrefixCache } from "./prefix-cache.js";
|
|
7
|
+
import { compactText } from "./render.js";
|
|
8
|
+
import { buildConflictNotice, CONFLICT_WARN_SCORE, mostSimilarEntry } from "./promotion.js";
|
|
9
|
+
import { EvolutionApplyPostCommitError } from "./service.js";
|
|
10
|
+
import { tokenize } from "./search.js";
|
|
11
|
+
import { createTokenUsageObserver } from "./token-usage.js";
|
|
12
|
+
import { isArchived, isMemoryType, MEMORY_TYPE_KEY, slug } from "./types.js";
|
|
13
|
+
import { validateBlastRadiusScope, validateEdit } from "./validate.js";
|
|
14
|
+
/** ZCode parity: the extractor may inspect memory and propose at most five times. */
|
|
15
|
+
export const MEMORY_AGENT_MAX_TURNS = 5;
|
|
16
|
+
/** Bound one proposal so approval text and engine compensation stay reviewable. */
|
|
17
|
+
export const MEMORY_AGENT_MAX_EDITS = 20;
|
|
18
|
+
/** The complete tool-name allowlist exposed by the memory agent. */
|
|
19
|
+
export const MEMORY_AGENT_TOOL_NAMES = ["memory_search", "memory_propose"];
|
|
20
|
+
/** Provider-facing schemas for the closed memory-only tool set. */
|
|
21
|
+
export const MEMORY_AGENT_TOOL_SCHEMAS = [
|
|
22
|
+
{
|
|
23
|
+
name: "memory_search",
|
|
24
|
+
description: "Read full content from the frozen memory manifest. This is the only data-reading tool available to the memory extractor.",
|
|
25
|
+
parameters: {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
properties: {
|
|
29
|
+
query: { type: "string", description: "Keywords, title text, scope:id, or content terms to retrieve." },
|
|
30
|
+
},
|
|
31
|
+
required: ["query"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "memory_propose",
|
|
36
|
+
description: "Finish extraction with one auditable memory-only proposal. An empty edits array is a valid no-op.",
|
|
37
|
+
parameters: {
|
|
38
|
+
type: "object",
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
properties: {
|
|
41
|
+
summary: { type: "string" },
|
|
42
|
+
rationale: { type: "string" },
|
|
43
|
+
expectedOutcome: { type: "string" },
|
|
44
|
+
edits: {
|
|
45
|
+
type: "array",
|
|
46
|
+
maxItems: MEMORY_AGENT_MAX_EDITS,
|
|
47
|
+
items: {
|
|
48
|
+
type: "object",
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
action: { type: "string", enum: ["create", "update", "delete", "archive"] },
|
|
52
|
+
kind: { type: "string", enum: ["memory"] },
|
|
53
|
+
targetScope: { type: "string", enum: ["local", "project", "global"] },
|
|
54
|
+
blastRadius: { type: "string", enum: ["general", "project", "session"] },
|
|
55
|
+
id: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" },
|
|
56
|
+
title: { type: "string" },
|
|
57
|
+
content: { type: "string" },
|
|
58
|
+
path: { type: "string" },
|
|
59
|
+
metadata: {
|
|
60
|
+
type: "object",
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
properties: { memoryType: { type: "string", enum: ["user", "feedback", "project", "reference"] } },
|
|
63
|
+
},
|
|
64
|
+
reason: { type: "string" },
|
|
65
|
+
},
|
|
66
|
+
required: ["action", "kind", "targetScope", "blastRadius"],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ["summary", "rationale", "expectedOutcome", "edits"],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
/** Instructions for the dedicated loop; model text is always untrusted. */
|
|
75
|
+
export const MEMORY_AGENT_SYSTEM_PROMPT = `You are the dedicated background memory extraction agent for a continual-evolution harness.
|
|
76
|
+
|
|
77
|
+
You 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.
|
|
78
|
+
|
|
79
|
+
Rules:
|
|
80
|
+
- Use memory_search before creating when the manifest may already cover the topic. Update an existing entry instead of creating a near-duplicate.
|
|
81
|
+
- Every memory entry contains exactly one fact. Classify it with metadata.memoryType = user | feedback | project | reference.
|
|
82
|
+
- feedback and project memories must state the fact, Why it matters, and How to apply it next time.
|
|
83
|
+
- Never invent a user preference. Ground every proposal in the supplied conversation evidence and existing memory.
|
|
84
|
+
- Every edit must have kind=memory, an explicit targetScope, and a coherent blastRadius.
|
|
85
|
+
- targetScope=local is session staging. project persists within the current repository. global persists across projects. project/global require human approval before application.
|
|
86
|
+
- 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.
|
|
87
|
+
- 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
|
+
- If no durable memory is justified, call memory_propose with edits=[].
|
|
89
|
+
- Always finish by calling memory_propose. Do not return unproposed prose.`;
|
|
90
|
+
/** Build the frozen memory manifest from a merged harness view. */
|
|
91
|
+
export function buildMemoryManifest(state) {
|
|
92
|
+
return Object.values(state.entries.memory)
|
|
93
|
+
.map((entry) => ({ ...entry, metadata: { ...entry.metadata }, reference: {}, arguments: {} }))
|
|
94
|
+
.sort((a, b) => manifestOrder(a).localeCompare(manifestOrder(b), "en"));
|
|
95
|
+
}
|
|
96
|
+
/** Render a bounded manifest index; full bodies remain available through memory_search. */
|
|
97
|
+
export function formatMemoryManifest(entries, maxChars = 8000) {
|
|
98
|
+
if (entries.length === 0)
|
|
99
|
+
return "No saved memory entries yet.";
|
|
100
|
+
const lines = [];
|
|
101
|
+
let used = 0;
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const type = isMemoryType(entry.metadata[MEMORY_TYPE_KEY]) ? entry.metadata[MEMORY_TYPE_KEY] : "untyped";
|
|
104
|
+
const archived = isArchived(entry) ? ", archived" : "";
|
|
105
|
+
const line = `- [${entry.scope}:${entry.id}] ${entry.title} (memoryType=${type}, path=${entry.path}${archived}): ${compactText(entry.content, 240)}`;
|
|
106
|
+
if (used + line.length + 1 > maxChars)
|
|
107
|
+
break;
|
|
108
|
+
lines.push(line);
|
|
109
|
+
used += line.length + 1;
|
|
110
|
+
}
|
|
111
|
+
const omitted = entries.length - lines.length;
|
|
112
|
+
if (omitted > 0)
|
|
113
|
+
lines.push(`- +${omitted} more entries; use memory_search with a narrower query.`);
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
}
|
|
116
|
+
/** Rank frozen manifest entries with CJK-aware token overlap and stable tie-breaking. */
|
|
117
|
+
export function searchMemoryManifest(entries, query, maxResults = 8) {
|
|
118
|
+
const queryTokens = new Set(tokenize(query));
|
|
119
|
+
if (queryTokens.size === 0)
|
|
120
|
+
return [];
|
|
121
|
+
return entries
|
|
122
|
+
.map((entry) => {
|
|
123
|
+
const title = tokenize(entry.title);
|
|
124
|
+
const body = tokenize(`${entry.content} ${entry.path} ${String(entry.metadata[MEMORY_TYPE_KEY] ?? "")}`);
|
|
125
|
+
let score = 0;
|
|
126
|
+
for (const token of queryTokens) {
|
|
127
|
+
if (title.includes(token))
|
|
128
|
+
score += 2;
|
|
129
|
+
if (body.includes(token))
|
|
130
|
+
score += 1;
|
|
131
|
+
}
|
|
132
|
+
if (`${entry.scope}:${entry.id}`.toLowerCase() === query.trim().toLowerCase())
|
|
133
|
+
score += 100;
|
|
134
|
+
return { entry, score };
|
|
135
|
+
})
|
|
136
|
+
.filter((hit) => hit.score > 0)
|
|
137
|
+
.sort((a, b) => b.score - a.score || manifestOrder(a.entry).localeCompare(manifestOrder(b.entry), "en"))
|
|
138
|
+
.slice(0, maxResults)
|
|
139
|
+
.map((hit) => hit.entry);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Run the dedicated loop against a frozen manifest.
|
|
143
|
+
*
|
|
144
|
+
* @throws On provider failure, abort, malformed tool arguments, policy denial,
|
|
145
|
+
* a non-memory proposal, or exhaustion of the bounded internal turn budget.
|
|
146
|
+
*/
|
|
147
|
+
export async function runMemoryAgent(ctx, options) {
|
|
148
|
+
const events = options.trajectoryEvents ?? [];
|
|
149
|
+
const routing = resolvePrefixCache(options.prefixCache);
|
|
150
|
+
const route = detectPlannerRoute(events, routing.mode);
|
|
151
|
+
const prefixMessages = route === "A"
|
|
152
|
+
? buildPrefixMessages(events, { provider: options.provider, model: options.model, maxChars: routing.maxChars })
|
|
153
|
+
: [];
|
|
154
|
+
const conversation = prefixMessages.length > 0 ? "" : `<conversation>\n${options.trajectory}\n</conversation>`;
|
|
155
|
+
const prompt = [
|
|
156
|
+
`<memory_manifest>\n${formatMemoryManifest(options.manifest)}\n</memory_manifest>`,
|
|
157
|
+
conversation,
|
|
158
|
+
"Search before deciding, then call memory_propose with the complete memory-only proposal. Empty edits is a valid no-op.",
|
|
159
|
+
]
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join("\n\n");
|
|
162
|
+
const messages = [
|
|
163
|
+
...prefixMessages,
|
|
164
|
+
createUserMessage({
|
|
165
|
+
content: [{ type: "text", text: prompt }],
|
|
166
|
+
source: EVOLVE_MESSAGE_SOURCE,
|
|
167
|
+
}),
|
|
168
|
+
];
|
|
169
|
+
let searches = 0;
|
|
170
|
+
for (let turn = 1; turn <= MEMORY_AGENT_MAX_TURNS; turn += 1) {
|
|
171
|
+
options.signal?.throwIfAborted();
|
|
172
|
+
const blocks = await streamModelTurn(ctx, {
|
|
173
|
+
provider: options.provider,
|
|
174
|
+
model: options.model,
|
|
175
|
+
system: MEMORY_AGENT_SYSTEM_PROMPT,
|
|
176
|
+
messages,
|
|
177
|
+
tools: MEMORY_AGENT_TOOL_SCHEMAS,
|
|
178
|
+
requireTextOrToolCall: true,
|
|
179
|
+
maxTokens: options.maxOutputTokens ?? 4096,
|
|
180
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
181
|
+
...(options.tokenUsage
|
|
182
|
+
? { onUsage: createTokenUsageObserver(options.tokenUsage, "memory", options.provider, options.model) }
|
|
183
|
+
: {}),
|
|
184
|
+
});
|
|
185
|
+
const hasText = blocks.some((block) => block.type === "text" && block.text.length > 0);
|
|
186
|
+
const hasToolCall = blocks.some((block) => block.type === "tool-call");
|
|
187
|
+
if (!hasText && !hasToolCall)
|
|
188
|
+
throw new Error("memory agent produced no model output");
|
|
189
|
+
messages.push(createAssistantMessage({
|
|
190
|
+
content: blocks,
|
|
191
|
+
source: { provider: options.provider, model: options.model },
|
|
192
|
+
}));
|
|
193
|
+
const toolCalls = blocks.filter((block) => block.type === "tool-call");
|
|
194
|
+
if (toolCalls.length === 0) {
|
|
195
|
+
const text = textOf(blocks);
|
|
196
|
+
return {
|
|
197
|
+
proposal: parseMemoryExtractionProposal(extractJsonObject(text), options.manifest),
|
|
198
|
+
turns: turn,
|
|
199
|
+
searches,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
let proposal;
|
|
203
|
+
for (const call of toolCalls) {
|
|
204
|
+
const execution = executeMemoryTool(call, options.manifest);
|
|
205
|
+
if (call.name === "memory_search")
|
|
206
|
+
searches += 1;
|
|
207
|
+
proposal ??= execution.proposal;
|
|
208
|
+
messages.push(createToolResultMessage({
|
|
209
|
+
callId: call.id,
|
|
210
|
+
content: [{ type: "text", text: execution.text }],
|
|
211
|
+
isError: execution.isError,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
if (proposal)
|
|
215
|
+
return { proposal, turns: turn, searches };
|
|
216
|
+
}
|
|
217
|
+
throw new Error(`memory agent exhausted ${MEMORY_AGENT_MAX_TURNS} internal turns without a valid proposal`);
|
|
218
|
+
}
|
|
219
|
+
/** Strictly parse and policy-check the structured `memory_propose` payload. */
|
|
220
|
+
export function parseMemoryExtractionProposal(value, manifest) {
|
|
221
|
+
const record = asRecord(value);
|
|
222
|
+
if (!record)
|
|
223
|
+
throw new Error("memory proposal must be an object");
|
|
224
|
+
const summary = requiredString(record["summary"], "summary");
|
|
225
|
+
const rationale = requiredString(record["rationale"], "rationale");
|
|
226
|
+
const expectedOutcome = requiredString(record["expectedOutcome"], "expectedOutcome");
|
|
227
|
+
if (!Array.isArray(record["edits"]))
|
|
228
|
+
throw new Error("memory proposal edits must be an array");
|
|
229
|
+
if (record["edits"].length > MEMORY_AGENT_MAX_EDITS) {
|
|
230
|
+
throw new Error(`memory proposal exceeds ${MEMORY_AGENT_MAX_EDITS} edits`);
|
|
231
|
+
}
|
|
232
|
+
const byScopeId = new Map(manifest.map((entry) => [`${entry.scope}:${entry.id}`, entry]));
|
|
233
|
+
const edits = record["edits"].map((raw, index) => parseScopedMemoryEdit(raw, index, byScopeId));
|
|
234
|
+
return { summary, rationale, expectedOutcome, edits };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Render the authoritative persistent-scope diff shown to the human. Model
|
|
238
|
+
* prose is retained only as an explicitly untrusted summary; actions, targets,
|
|
239
|
+
* content previews, memory types, and conflict warnings come from code.
|
|
240
|
+
*/
|
|
241
|
+
export function renderMemoryApprovalDetails(proposal, scope, state, maxChars = 6000) {
|
|
242
|
+
const edits = proposal.edits.filter((candidate) => candidate.targetScope === scope);
|
|
243
|
+
const details = edits.map((edit) => {
|
|
244
|
+
const before = edit.id ? state.entries.memory[edit.id] : undefined;
|
|
245
|
+
const title = edit.title ?? before?.title ?? "(existing title unchanged)";
|
|
246
|
+
const path = edit.path ?? before?.path ?? "(path unchanged)";
|
|
247
|
+
const content = edit.content ?? (edit.action === "update" ? "(content unchanged)" : "(no content field)");
|
|
248
|
+
const type = edit.metadata?.[MEMORY_TYPE_KEY] ?? before?.metadata[MEMORY_TYPE_KEY] ?? "untyped";
|
|
249
|
+
const candidates = Object.values(state.entries.memory).filter((entry) => entry.id !== edit.id);
|
|
250
|
+
const hit = edit.action === "create" || edit.action === "update"
|
|
251
|
+
? mostSimilarEntry(candidates, edit.title ?? before?.title ?? "", edit.content ?? before?.content ?? "", CONFLICT_WARN_SCORE)
|
|
252
|
+
: undefined;
|
|
253
|
+
const conflict = hit ? `; conflict: ${compactText(buildConflictNotice(hit), 120)}` : "";
|
|
254
|
+
const targetId = edit.id === undefined ? "(new)" : JSON.stringify(edit.id);
|
|
255
|
+
return {
|
|
256
|
+
target: `- ${edit.action} ${scope}:${targetId} — ${compactText(title, 80)}`,
|
|
257
|
+
detail: ` path=${compactText(path, 60)}; memoryType=${String(type)}; content=${compactText(content, 100)}${conflict}`,
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
let titleLimit = 80;
|
|
261
|
+
let targets = details.map((detail) => detail.target.replace(/ — .*$/, ` — ${compactText(detail.target.split(" — ")[1] ?? "", titleLimit)}`));
|
|
262
|
+
let base = ["edits to approve:", ...targets].join("\n");
|
|
263
|
+
while (base.length > maxChars && titleLimit > 20) {
|
|
264
|
+
titleLimit = Math.floor(titleLimit / 2);
|
|
265
|
+
targets = details.map((detail) => `- ${detail.target.split(" — ")[0]} — ${compactText(detail.target.split(" — ")[1] ?? "", titleLimit)}`);
|
|
266
|
+
base = ["edits to approve:", ...targets].join("\n");
|
|
267
|
+
}
|
|
268
|
+
if (base.length > maxChars)
|
|
269
|
+
throw new Error(`memory approval diff exceeds ${maxChars} characters`);
|
|
270
|
+
const output = [base];
|
|
271
|
+
const summary = `model summary (untrusted): ${compactText(proposal.summary, 180)}`;
|
|
272
|
+
if ([...output, summary].join("\n").length <= maxChars)
|
|
273
|
+
output.push(summary);
|
|
274
|
+
for (const detail of details) {
|
|
275
|
+
const line = detail.detail;
|
|
276
|
+
if ([...output, line].join("\n").length <= maxChars)
|
|
277
|
+
output.push(line);
|
|
278
|
+
}
|
|
279
|
+
if ([...output, " (additional previews omitted; every target is listed above)"].join("\n").length <= maxChars) {
|
|
280
|
+
output.push(" (additional previews omitted; every target is listed above)");
|
|
281
|
+
}
|
|
282
|
+
return output.join("\n");
|
|
283
|
+
}
|
|
284
|
+
function memoryScopeDecisionKey(cursor, scope, edits) {
|
|
285
|
+
return `${cursor}:${scope}:${JSON.stringify(edits.map(stripTargetScope))}`;
|
|
286
|
+
}
|
|
287
|
+
class MemoryApplyBatchError extends Error {
|
|
288
|
+
result;
|
|
289
|
+
scope;
|
|
290
|
+
storeId;
|
|
291
|
+
constructor(message, result, scope, storeId) {
|
|
292
|
+
super(message);
|
|
293
|
+
this.result = result;
|
|
294
|
+
this.scope = scope;
|
|
295
|
+
this.storeId = storeId;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Apply one accepted proposal through the only mutation engine. All persistent
|
|
300
|
+
* approvals are resolved before the first write, so an unavailable approval
|
|
301
|
+
* boundary cannot leave a partially applied batch. Explicit rejection is a
|
|
302
|
+
* no-op for that scope; service/question failures remain retryable errors.
|
|
303
|
+
*/
|
|
304
|
+
export async function applyMemoryExtractionProposal(ctx, engine, proposal, options) {
|
|
305
|
+
const scopeOrder = ["local", "project", "global"];
|
|
306
|
+
const editsByScope = new Map();
|
|
307
|
+
for (const scope of scopeOrder) {
|
|
308
|
+
const edits = proposal.edits.filter((edit) => edit.targetScope === scope);
|
|
309
|
+
if (edits.length > 0)
|
|
310
|
+
editsByScope.set(scope, edits);
|
|
311
|
+
}
|
|
312
|
+
if (editsByScope.has("project") && !options.projectKey) {
|
|
313
|
+
throw new Error("memory agent cannot target project scope without a resolved project key");
|
|
314
|
+
}
|
|
315
|
+
const approvedScopes = new Set();
|
|
316
|
+
const declinedScopes = [];
|
|
317
|
+
for (const scope of ["project", "global"]) {
|
|
318
|
+
if (!editsByScope.has(scope))
|
|
319
|
+
continue;
|
|
320
|
+
if (!options.requireApproval) {
|
|
321
|
+
approvedScopes.add(scope);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const baseline = options.baselines[scope];
|
|
325
|
+
if (!baseline)
|
|
326
|
+
throw new Error(`memory agent has no ${scope} baseline`);
|
|
327
|
+
const decisionKey = options.decisionCursor === undefined
|
|
328
|
+
? undefined
|
|
329
|
+
: memoryScopeDecisionKey(options.decisionCursor, scope, proposal.edits.filter((edit) => edit.targetScope === scope));
|
|
330
|
+
const previous = decisionKey === undefined ? undefined : options.scopeDecisions?.[decisionKey];
|
|
331
|
+
if (previous === "approved") {
|
|
332
|
+
approvedScopes.add(scope);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (previous === "declined") {
|
|
336
|
+
declinedScopes.push(scope);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const approved = await requestScopeApproval(ctx, options.agent, options.signal, scope, renderMemoryApprovalDetails(proposal, scope, baseline));
|
|
340
|
+
if (decisionKey !== undefined)
|
|
341
|
+
options.onScopeDecision?.(decisionKey, approved);
|
|
342
|
+
if (approved === "approved")
|
|
343
|
+
approvedScopes.add(scope);
|
|
344
|
+
else
|
|
345
|
+
declinedScopes.push(scope);
|
|
346
|
+
}
|
|
347
|
+
const results = [];
|
|
348
|
+
const applied = [];
|
|
349
|
+
try {
|
|
350
|
+
for (const scope of scopeOrder) {
|
|
351
|
+
const edits = editsByScope.get(scope);
|
|
352
|
+
if (!edits)
|
|
353
|
+
continue;
|
|
354
|
+
if (scope !== "local" && !approvedScopes.has(scope))
|
|
355
|
+
continue;
|
|
356
|
+
options.signal?.throwIfAborted();
|
|
357
|
+
const storeId = scope === "local" ? options.agent.id : scope === "project" ? options.projectKey : undefined;
|
|
358
|
+
const baseline = options.baselines[scope];
|
|
359
|
+
if (!baseline)
|
|
360
|
+
throw new Error(`memory agent has no ${scope} baseline`);
|
|
361
|
+
const result = engine.apply(scope, storeId, {
|
|
362
|
+
summary: proposal.summary,
|
|
363
|
+
rationale: proposal.rationale,
|
|
364
|
+
expectedOutcome: proposal.expectedOutcome,
|
|
365
|
+
edits: prepareMemoryEdits(edits, baseline),
|
|
366
|
+
}, {
|
|
367
|
+
scope,
|
|
368
|
+
baselineState: baseline,
|
|
369
|
+
...(options.source ? { source: options.source } : {}),
|
|
370
|
+
});
|
|
371
|
+
const failed = result.appliedEdits.filter((edit) => !edit.applied);
|
|
372
|
+
if (failed.length > 0) {
|
|
373
|
+
throw new MemoryApplyBatchError(`${scope} batch ${result.id} left ${failed.length} edit(s) unapplied: ${failed.map((edit) => edit.error ?? "unknown").join("; ")}`, result, scope, storeId);
|
|
374
|
+
}
|
|
375
|
+
results.push(result);
|
|
376
|
+
applied.push({ scope, storeId, result });
|
|
377
|
+
}
|
|
378
|
+
return { results, declinedScopes };
|
|
379
|
+
}
|
|
380
|
+
catch (cause) {
|
|
381
|
+
const attempted = [...applied];
|
|
382
|
+
if (cause instanceof MemoryApplyBatchError && cause.result && cause.scope) {
|
|
383
|
+
attempted.push({ scope: cause.scope, storeId: cause.storeId, result: cause.result });
|
|
384
|
+
}
|
|
385
|
+
else if (cause instanceof EvolutionApplyPostCommitError) {
|
|
386
|
+
attempted.push({ scope: cause.scope, storeId: cause.storeId, result: cause.result });
|
|
387
|
+
}
|
|
388
|
+
const rollbackErrors = [];
|
|
389
|
+
for (const item of attempted.reverse()) {
|
|
390
|
+
try {
|
|
391
|
+
const rollback = engine.rollbackResult(item.scope, item.storeId, item.result);
|
|
392
|
+
const failed = rollback.appliedEdits.filter((edit) => !edit.applied);
|
|
393
|
+
if (failed.length > 0)
|
|
394
|
+
rollbackErrors.push(`${item.scope}:${item.result.id} left ${failed.length} rollback edit(s) unapplied`);
|
|
395
|
+
}
|
|
396
|
+
catch (rollbackCause) {
|
|
397
|
+
rollbackErrors.push(`${item.scope}:${item.result.id} rollback failed: ${rollbackCause instanceof Error ? rollbackCause.message : String(rollbackCause)}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const suffix = rollbackErrors.length > 0 ? `; compensation errors: ${rollbackErrors.join("; ")}` : "; prior memory writes were rolled back";
|
|
401
|
+
throw new Error(`memory agent apply failed: ${cause instanceof Error ? cause.message : String(cause)}${suffix}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function executeMemoryTool(call, manifest) {
|
|
405
|
+
if (!MEMORY_AGENT_TOOL_NAMES.includes(call.name)) {
|
|
406
|
+
return { text: `tool denied: only ${MEMORY_AGENT_TOOL_NAMES.join(" and ")} are available`, isError: true };
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const args = parseJsonCandidate(call.arguments.trim() || "{}");
|
|
410
|
+
if (call.name === "memory_search") {
|
|
411
|
+
const record = asRecord(args);
|
|
412
|
+
const query = record ? requiredString(record["query"], "query") : "";
|
|
413
|
+
const matches = searchMemoryManifest(manifest, query).map(manifestResult);
|
|
414
|
+
return { text: JSON.stringify({ matches }), isError: false };
|
|
415
|
+
}
|
|
416
|
+
return { proposal: parseMemoryExtractionProposal(args, manifest), text: JSON.stringify({ accepted: true }), isError: false };
|
|
417
|
+
}
|
|
418
|
+
catch (cause) {
|
|
419
|
+
return {
|
|
420
|
+
text: `tool error: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
421
|
+
isError: true,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function parseScopedMemoryEdit(raw, index, byScopeId) {
|
|
426
|
+
const record = asRecord(raw);
|
|
427
|
+
if (!record)
|
|
428
|
+
throw new Error(`memory edit ${index} must be an object`);
|
|
429
|
+
const action = requiredString(record["action"], `edits[${index}].action`);
|
|
430
|
+
if (!isOneOf(action, ["create", "update", "delete", "archive"])) {
|
|
431
|
+
throw new Error(`memory edit ${index} has unsupported action ${action}`);
|
|
432
|
+
}
|
|
433
|
+
if (record["kind"] !== "memory")
|
|
434
|
+
throw new Error(`memory edit ${index} must have kind=memory`);
|
|
435
|
+
const targetScope = requiredString(record["targetScope"], `edits[${index}].targetScope`);
|
|
436
|
+
if (!isOneOf(targetScope, ["local", "project", "global"])) {
|
|
437
|
+
throw new Error(`memory edit ${index} has unsupported targetScope ${targetScope}`);
|
|
438
|
+
}
|
|
439
|
+
const blastRadius = requiredString(record["blastRadius"], `edits[${index}].blastRadius`);
|
|
440
|
+
if (!isOneOf(blastRadius, ["general", "project", "session"])) {
|
|
441
|
+
throw new Error(`memory edit ${index} has unsupported blastRadius ${blastRadius}`);
|
|
442
|
+
}
|
|
443
|
+
const blastError = validateBlastRadiusScope(targetScope, blastRadius);
|
|
444
|
+
if (blastError)
|
|
445
|
+
throw new Error(`memory edit ${index}: ${blastError}`);
|
|
446
|
+
if (record["reference"] !== undefined || record["arguments"] !== undefined || record["skill_kind"] !== undefined) {
|
|
447
|
+
throw new Error(`memory edit ${index} cannot carry skill-only fields`);
|
|
448
|
+
}
|
|
449
|
+
const edit = {
|
|
450
|
+
action,
|
|
451
|
+
kind: "memory",
|
|
452
|
+
targetScope,
|
|
453
|
+
blastRadius,
|
|
454
|
+
};
|
|
455
|
+
for (const field of ["id", "title", "content", "path", "reason"]) {
|
|
456
|
+
const fieldValue = record[field];
|
|
457
|
+
if (fieldValue !== undefined) {
|
|
458
|
+
if (typeof fieldValue !== "string")
|
|
459
|
+
throw new Error(`memory edit ${index}.${field} must be a string`);
|
|
460
|
+
edit[field] = fieldValue;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (record["metadata"] !== undefined) {
|
|
464
|
+
const metadata = asRecord(record["metadata"]);
|
|
465
|
+
if (!metadata)
|
|
466
|
+
throw new Error(`memory edit ${index}.metadata must be an object`);
|
|
467
|
+
const unexpected = Object.keys(metadata).filter((key) => key !== MEMORY_TYPE_KEY);
|
|
468
|
+
if (unexpected.length > 0) {
|
|
469
|
+
throw new Error(`memory edit ${index}.metadata contains engine-owned or unsupported keys: ${unexpected.join(", ")}`);
|
|
470
|
+
}
|
|
471
|
+
edit.metadata = { ...metadata };
|
|
472
|
+
}
|
|
473
|
+
if (action === "create" && edit.id && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(edit.id)) {
|
|
474
|
+
throw new Error(`memory edit ${index}.id contains unsupported identifier characters`);
|
|
475
|
+
}
|
|
476
|
+
const before = edit.id ? byScopeId.get(`${targetScope}:${edit.id}`) : undefined;
|
|
477
|
+
if (action !== "create" && !before) {
|
|
478
|
+
throw new Error(`memory edit ${index} ${action} target does not exist in ${targetScope}: ${edit.id ?? "(missing id)"}`);
|
|
479
|
+
}
|
|
480
|
+
if (action === "create" && edit.id && before) {
|
|
481
|
+
throw new Error(`memory edit ${index} create duplicates ${targetScope}:${edit.id}; use update instead`);
|
|
482
|
+
}
|
|
483
|
+
const validationError = validateEdit(edit, edit.id ?? slug(edit.title ?? "memory", "memory"), targetScope, before);
|
|
484
|
+
if (validationError)
|
|
485
|
+
throw new Error(`memory edit ${index}: ${validationError}`);
|
|
486
|
+
return edit;
|
|
487
|
+
}
|
|
488
|
+
function prepareMemoryEdits(edits, baseline) {
|
|
489
|
+
return edits.map((edit) => {
|
|
490
|
+
const plain = stripTargetScope(edit);
|
|
491
|
+
if (plain.action !== "update" || plain.metadata === undefined || plain.id === undefined)
|
|
492
|
+
return plain;
|
|
493
|
+
const before = baseline.entries.memory[plain.id];
|
|
494
|
+
return before ? { ...plain, metadata: { ...before.metadata, ...plain.metadata } } : plain;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
function stripTargetScope(edit) {
|
|
498
|
+
const { targetScope: _targetScope, ...plain } = edit;
|
|
499
|
+
return plain;
|
|
500
|
+
}
|
|
501
|
+
function manifestResult(entry) {
|
|
502
|
+
return {
|
|
503
|
+
scope: entry.scope,
|
|
504
|
+
id: entry.id,
|
|
505
|
+
title: entry.title,
|
|
506
|
+
content: entry.content,
|
|
507
|
+
path: entry.path,
|
|
508
|
+
memoryType: entry.metadata[MEMORY_TYPE_KEY],
|
|
509
|
+
archived: isArchived(entry),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function manifestOrder(entry) {
|
|
513
|
+
const scope = entry.scope === "global" ? "0" : entry.scope === "project" ? "1" : "2";
|
|
514
|
+
return `${scope}:${entry.id}:${entry.title}`;
|
|
515
|
+
}
|
|
516
|
+
function textOf(blocks) {
|
|
517
|
+
return blocks
|
|
518
|
+
.filter((block) => block.type === "text")
|
|
519
|
+
.map((block) => block.text)
|
|
520
|
+
.join("\n");
|
|
521
|
+
}
|
|
522
|
+
function asRecord(value) {
|
|
523
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
524
|
+
return undefined;
|
|
525
|
+
return value;
|
|
526
|
+
}
|
|
527
|
+
function requiredString(value, field) {
|
|
528
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
529
|
+
throw new Error(`${field} must be a non-empty string`);
|
|
530
|
+
return value;
|
|
531
|
+
}
|
|
532
|
+
function isOneOf(value, values) {
|
|
533
|
+
return values.includes(value);
|
|
534
|
+
}
|
|
535
|
+
//# sourceMappingURL=memory-agent.js.map
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Serial latest-
|
|
2
|
+
* Serial latest-boundary scheduler for automatic memory+review snapshots.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* next snapshot retries the same durable boundary.
|
|
4
|
+
* One runner exists per session. A burst is coalesced by durable cursor rather
|
|
5
|
+
* than promise arrival: once multiple pending captures settle, the highest
|
|
6
|
+
* comparable boundary runs first. A cursor advances only after success/no-op;
|
|
7
|
+
* errors, aborts, and stale boundaries leave it unchanged.
|
|
9
8
|
*/
|
|
9
|
+
/** Compare two scheduler cursors; undefined means the cursor families differ. */
|
|
10
|
+
export declare function compareReviewCursors(left: string, right: string): number | undefined;
|
|
10
11
|
/** Terminal state of one scheduled snapshot. */
|
|
11
12
|
export type ReviewSchedulerStatus = "success" | "no-op" | "error" | "aborted";
|
|
12
13
|
/** A scheduler execution receives the exact snapshot and its owner signal. */
|
|
@@ -20,7 +21,7 @@ export interface ReviewScheduler<TSnapshot> {
|
|
|
20
21
|
drain(): Promise<void>;
|
|
21
22
|
/** Last successfully processed or skipped snapshot boundary. */
|
|
22
23
|
getCursor(): string | undefined;
|
|
23
|
-
/** Whether a run or a
|
|
24
|
+
/** Whether a run or a pending acquisition exists. */
|
|
24
25
|
hasPendingWork(): boolean;
|
|
25
26
|
/** Submit a snapshot (or an async acquisition) without blocking the caller. */
|
|
26
27
|
schedule(snapshot: TSnapshot | Promise<TSnapshot>): void;
|
|
@@ -28,7 +29,7 @@ export interface ReviewScheduler<TSnapshot> {
|
|
|
28
29
|
shutdown(): void;
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
|
-
* Create a scheduler with ZCode-compatible coalescing
|
|
32
|
+
* Create a scheduler with ZCode-compatible serial/coalescing semantics.
|
|
32
33
|
*
|
|
33
34
|
* @param execute - one serial extraction/review operation
|
|
34
35
|
* @param cursorOf - extracts the durable boundary from a snapshot
|