dsh-continual-evolve 0.7.5 → 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/lib/command.js CHANGED
@@ -1,5 +1,6 @@
1
- import { ARCHIVED_AT_KEY } from "./types.js";
1
+ import { ARCHIVED_AT_KEY, MEMORY_TYPE_KEY } from "./types.js";
2
2
  import { formatHarnessStateForPrompt, historyForPrompt } from "./render.js";
3
+ import { compactText } from "./render.js";
3
4
  import { planWithLlm } from "./planner.js";
4
5
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
6
  import { join } from "node:path";
@@ -18,6 +19,7 @@ import { projectKeyOf } from "./project.js";
18
19
  import { loadUsage, getUsageCount } from "./usage.js";
19
20
  import { loadGateRuntime, saveGateRuntime } from "./runtime.js";
20
21
  import { planConsolidation } from "./consolidate.js";
22
+ import { formatRecallResult, recallMemories, RECALL_MEMORY_TYPES } from "./recall.js";
21
23
  import { loadTokenUsage, renderTokenUsageReport } from "./token-usage.js";
22
24
  const USAGE = `Usage:
23
25
  /evolve show this help and the current local store
@@ -30,6 +32,12 @@ const USAGE = `Usage:
30
32
  /evolve archive <id> [global] hide an entry from injection (data kept, restorable)
31
33
  /evolve unarchive <id> [global] restore an archived entry
32
34
  /evolve demote <id> hide a (global) entry from injection, keep data
35
+ /evolve recall <query...> [scope] targeted memory recall: full content with version,
36
+ source, and staleness (same engine as the evolve_recall tool)
37
+ /evolve remember <type> [scope] <text...>
38
+ immediately persist one memory (type = user|feedback|project|reference;
39
+ feedback/project text must carry Why + How; default scope local)
40
+ /evolve forget [scope] <query...> locate one memory by query and archive it (restorable via /evolve unarchive)
33
41
  /evolve consolidate [apply] [merge]
34
42
  report (or apply) a batch archive of conflict-hinted
35
43
  and stale zero-use global entries; "merge" folds
@@ -206,6 +214,90 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
206
214
  }, { scope });
207
215
  return success(renderResult(result));
208
216
  }
217
+ case "recall": {
218
+ // Human counterpart of the evolve_recall tool: same engine,
219
+ // same filters (memory kind, one scope or all three).
220
+ const { scope, rest: after } = scopeArg(rest);
221
+ const explicitScope = rest.length > 0 && (rest[0] === "global" || rest[0] === "project" || rest[0] === "local");
222
+ const query = after.join(" ");
223
+ const result = recallMemories(engine, { sessionId, projectKey: projectKeyOf(invocation.agent) ?? undefined }, { kinds: ["memory"], ...(explicitScope ? { scopes: [scope] } : {}), ...(query ? { query } : {}), limit: 10 });
224
+ return success(formatRecallResult(result, query || undefined));
225
+ }
226
+ case "remember": {
227
+ // Immediate manual memory path (P1 §6.6): one fact, explicit
228
+ // type, straight into the engine — validation, snapshot,
229
+ // versioning, and approval behave exactly like any other
230
+ // write. feedback/project text must already carry Why + How;
231
+ // the engine rejects it loudly otherwise.
232
+ const type = rest[0] ?? "";
233
+ if (!RECALL_MEMORY_TYPES.includes(type)) {
234
+ return error(`remember requires a memory type: user|feedback|project|reference.\nExample: /evolve remember feedback local 对方偏好深色主题。Why:…… How to apply:……\n${USAGE}`);
235
+ }
236
+ const maybeScope = rest[1] ?? "";
237
+ const scoped = maybeScope === "global" || maybeScope === "project" || maybeScope === "local";
238
+ const scope = scoped ? maybeScope : "local";
239
+ const text = (scoped ? rest.slice(2) : rest.slice(1)).join(" ").trim();
240
+ if (!text) {
241
+ return error(`remember requires the memory text after the type.\n${USAGE}`);
242
+ }
243
+ const storeId = storeIdForCommand(scope, invocation);
244
+ if ((scope === "global" || scope === "project") && opts.requireGlobalApproval) {
245
+ await requireGlobalApproval(ctx, invocation.agent, invocation.signal, `/evolve remember ${type} → ${scope} store: ${compactText(text, 120)}`);
246
+ }
247
+ const result = engine.apply(scope, storeId, {
248
+ summary: `remember: ${compactText(text, 80)}`,
249
+ rationale: "Human-invoked immediate memory via /evolve remember.",
250
+ expectedOutcome: "The fact is persisted as a typed memory entry.",
251
+ edits: [{
252
+ action: "create",
253
+ kind: "memory",
254
+ title: compactText(text, 80),
255
+ content: text,
256
+ metadata: { [MEMORY_TYPE_KEY]: type },
257
+ }],
258
+ }, { scope });
259
+ return success(renderResult(result));
260
+ }
261
+ case "forget": {
262
+ // Manual forget path (P1 §6.6): locate by query, archive the
263
+ // single match (restorable via unarchive). An ambiguous query
264
+ // lists candidates instead of archiving — a silent multi-
265
+ // archive would look like success while hiding too much.
266
+ const { scope, rest: after } = scopeArg(rest);
267
+ const explicitScope = rest.length > 0 && (rest[0] === "global" || rest[0] === "project" || rest[0] === "local");
268
+ const query = after.join(" ").trim();
269
+ if (!query) {
270
+ return error(`forget requires a query to locate the memory.\n${USAGE}`);
271
+ }
272
+ const recalled = recallMemories(engine, { sessionId, projectKey: projectKeyOf(invocation.agent) ?? undefined }, { query, kinds: ["memory"], ...(explicitScope ? { scopes: [scope] } : {}), limit: 6, includeArchived: false });
273
+ if (recalled.hits.length === 0) {
274
+ return error(`no memory matches "${query}"${recalled.notes.length > 0 ? ` (${recalled.notes.join("; ")})` : ""} — try fewer words, or /evolve list to browse.`);
275
+ }
276
+ if (recalled.hits.length > 1) {
277
+ const list = recalled.hits.map((hit) => `- [${hit.scope}:${hit.id}] ${hit.title}`).join("\n");
278
+ return success(`"${query}" matches ${recalled.hits.length} memories — refine the query so exactly one matches, then forget archives it:\n${list}`);
279
+ }
280
+ const hit = recalled.hits[0];
281
+ const state = engine.load(hit.scope, hit.scope === "local" ? sessionId : hit.scope === "project" ? (projectKeyOf(invocation.agent) ?? undefined) : undefined);
282
+ const entry = state.entries.memory[hit.id];
283
+ if (!entry) {
284
+ return error(`memory ${hit.scope}:${hit.id} vanished since recall — run /evolve forget again.`);
285
+ }
286
+ const result = engine.apply(hit.scope, hit.scope === "local" ? sessionId : hit.scope === "project" ? (projectKeyOf(invocation.agent) ?? undefined) : undefined, {
287
+ summary: `forget: archive memory ${hit.scope}:${hit.id}`,
288
+ rationale: `Human-invoked forget via /evolve forget "${query}".`,
289
+ expectedOutcome: "The entry is hidden from injection (data kept; restorable via /evolve unarchive).",
290
+ edits: [{
291
+ action: "update",
292
+ kind: "memory",
293
+ id: hit.id,
294
+ title: entry.title,
295
+ content: entry.content,
296
+ metadata: { ...entry.metadata, [ARCHIVED_AT_KEY]: new Date().toISOString() },
297
+ }],
298
+ }, { scope: hit.scope });
299
+ return success(`forgot [${hit.scope}:${hit.id}] ${hit.title} (archived — restore with /evolve unarchive ${hit.id}${hit.scope === "local" ? "" : ` ${hit.scope}`})\n${renderResult(result)}`);
300
+ }
209
301
  case "consolidate": {
210
302
  // R3: deterministic global-store hygiene. Report by default;
211
303
  // `apply` re-scans fresh state and lands the whole batch as ONE
@@ -376,10 +468,6 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
376
468
  }
377
469
  case "pause":
378
470
  case "resume": {
379
- if (runtime.autoReview === false) {
380
- return success("automatic evolution wiring is disabled by project policy; no automatic Memory Agent listener is registered. Manual evolve_* tools and /evolve commands keep working.");
381
- }
382
- const memoryOnly = runtime.memoryOnly !== false;
383
471
  // The switch is independent of the static registration flag. A
384
472
  // resume explicitly enables the Memory Agent; a pause leaves the
385
473
  // enable bit intact so status explains the paused state.
@@ -390,7 +478,7 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
390
478
  return success("automatic Memory Agent is already paused (no change).");
391
479
  }
392
480
  if (!pausing && current.enabled && !current.paused) {
393
- return success(memoryOnly ? "automatic Memory Agent is already running (no change)." : "automatic evolution is already running (no change).");
481
+ return success("automatic Memory Agent is already running (no change).");
394
482
  }
395
483
  saveGateRuntime(engine.baseDir, pausing, pausing ? current.enabled : true);
396
484
  return success(pausing
@@ -468,16 +556,12 @@ function demoteEntry(engine, id, sessionId, projectKey) {
468
556
  function renderGateStatus(engine, sessionId, projectKey, runtime) {
469
557
  const configuredDefault = runtime.autoReview === undefined ? "unknown" : runtime.autoReview ? "on" : "off";
470
558
  const current = loadGateRuntime(engine.baseDir, runtime.autoReview ?? false);
471
- const memoryOnly = runtime.memoryOnly !== false;
472
- const effective = runtime.autoReview === false
473
- ? "disabled by project policy"
474
- : current.paused
475
- ? "PAUSED (resume with /evolve resume)"
476
- : current.enabled
477
- ? memoryOnly ? "Memory Agent running" : "running"
478
- : memoryOnly ? "Memory Agent off (enable with /evolve resume)" : "off (enable with /evolve resume)";
479
- const listener = runtime.autoReview === false ? "not wired" : memoryOnly ? "Memory Agent-only listener registered" : "listener registered";
480
- const gateLine = `gate: ${listener} · config default ${configuredDefault} · runtime ${effective}`;
559
+ const effective = current.paused
560
+ ? "PAUSED (resume with /evolve resume)"
561
+ : current.enabled
562
+ ? "Memory Agent running"
563
+ : "Memory Agent off (enable with /evolve resume)";
564
+ const gateLine = `gate: Memory Agent-only listener registered · config default ${configuredDefault} · runtime ${effective}`;
481
565
  const countEntries = (state) => Object.values(state.entries).reduce((n, byKind) => n + Object.keys(byKind).length, 0);
482
566
  const lines = [gateLine];
483
567
  lines.push(`stores: global ${countEntries(engine.load("global", undefined))} entries · local(${sessionId}) ${countEntries(engine.load("local", sessionId))} entries`);
package/lib/index.d.ts CHANGED
@@ -10,10 +10,15 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
10
10
  baseDir: z<string, string, "plain">;
11
11
  /** System-prompt section order for the evolution guidance. */
12
12
  sectionOrder: z<number, number, "defined">;
13
- /** Explicit opt-in for the dedicated Memory Agent listener (off by default). */
13
+ /** Initial Memory Agent default when no `evolve/runtime.json` exists; not a registration gate. */
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. */
@@ -110,10 +115,15 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
110
115
  baseDir: z<string, string, "plain">;
111
116
  /** System-prompt section order for the evolution guidance. */
112
117
  sectionOrder: z<number, number, "defined">;
113
- /** Explicit opt-in for the dedicated Memory Agent listener (off by default). */
118
+ /** Initial Memory Agent default when no `evolve/runtime.json` exists; not a registration gate. */
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. */
@@ -215,7 +225,5 @@ export interface EvolutionService {
215
225
  readonly engine: EvolutionEngine;
216
226
  readonly baseDir: string;
217
227
  }
218
- /** True only when the project explicitly opts into automatic evolution wiring. */
219
- export declare function automaticEvolutionWired(config: Pick<EvolveConfig, "autoReview">): boolean;
220
228
  export declare function apply(ctx: Context, config: EvolveConfig): void;
221
229
  //# sourceMappingURL=index.d.ts.map
package/lib/index.js CHANGED
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Mounts the evolution engine, registers the model-facing evolve_* tools,
5
5
  * the human-facing /evolve command, the system-prompt guidance section, and
6
- * (only under an explicit `autoReview: true` opt-in) the dedicated Memory
7
- * Agent listener. With the default `autoReview: false`, the automatic
8
- * turn/compaction listeners are not registered; manual tools and commands
9
- * remain available.
6
+ * the dedicated Memory Agent listener. The listener is always registered so
7
+ * an install works without profile edits; `autoReview` only supplies the
8
+ * initial default when `evolve/runtime.json` does not exist yet, and
9
+ * `/evolve pause|resume` own it afterwards. Generic review/planner/fate are
10
+ * not reachable from this listener.
10
11
  */
11
12
  import { join } from "node:path";
12
13
  import z from "@deepseek-ai/schemastery";
@@ -30,10 +31,15 @@ export const Config = z.object({
30
31
  baseDir: z.string(),
31
32
  /** System-prompt section order for the evolution guidance. */
32
33
  sectionOrder: z.natural().default(118),
33
- /** Explicit opt-in for the dedicated Memory Agent listener (off by default). */
34
+ /** Initial Memory Agent default when no `evolve/runtime.json` exists; not a registration gate. */
34
35
  autoReview: z.boolean().default(false),
35
36
  /** ZCode-style minimum lexical words in one direct user text part. */
36
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),
37
43
  /** Legacy/local-fate cadence fallback; successful-turn review is snapshot-driven. */
38
44
  reviewIntervalTurns: z.natural().default(6),
39
45
  /** Trajectory slice handed to the gate, in characters. */
@@ -121,10 +127,6 @@ export const Config = z.object({
121
127
  tokenUsage: z.natural(),
122
128
  }),
123
129
  });
124
- /** True only when the project explicitly opts into automatic evolution wiring. */
125
- export function automaticEvolutionWired(config) {
126
- return config.autoReview === true;
127
- }
128
130
  export function apply(ctx, config) {
129
131
  const baseDir = resolveDshHome(config.baseDir);
130
132
  const skillsRoot = config.skillsDir ? expandHomePath(config.skillsDir) : join(baseDir, "skills");
@@ -171,7 +173,6 @@ export function apply(ctx, config) {
171
173
  autoCase: config.autoCase ?? true,
172
174
  promotionPolicy,
173
175
  autoReview: config.autoReview ?? false,
174
- memoryOnly: config.autoReview === true,
175
176
  });
176
177
  // Plugin-owned file logging: every cordis log message lands in
177
178
  // <baseDir>/evolve/plugin.log regardless of how dsh web was launched —
@@ -186,36 +187,33 @@ export function apply(ctx, config) {
186
187
  void restoreMounted(ctx, baseDir).catch((cause) => {
187
188
  ctx.logger("continual-evolve").warn(`mount restore failed: ${cause instanceof Error ? cause.message : String(cause)}`);
188
189
  });
189
- // `autoReview` is an explicit opt-in for the dedicated Memory Agent only.
190
- // The general review/planner, prompt/skill writes, and local-fate phases
191
- // are not reachable from this listener. Manual tools and commands remain
192
- // available below.
193
- if (automaticEvolutionWired(config)) {
194
- registerAutoReview(ctx, engine, {
195
- intervalTurns: config.reviewIntervalTurns ?? 6,
196
- enabledByDefault: true,
197
- memoryOnly: true,
198
- memoryMinUserWords: config.memoryMinUserWords ?? 3,
199
- maxInputChars: config.maxReviewInputChars ?? 40000,
200
- budgetTokens: config.reviewBudgetTokens ?? 4096,
201
- notifyOnAutoReview: config.notifyOnAutoReview ?? true,
202
- localFate: config.localFate ?? false,
203
- fateIntervalTurns: config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6,
204
- goalBlockedWrapupTurns: config.goalBlockedWrapupTurns ?? 3,
205
- promotionPolicy,
206
- autoCase: config.autoCase ?? true,
207
- rubricKey,
208
- ...(config.historyRetain?.reviews !== undefined ? { reviewsRetain: config.historyRetain.reviews } : {}),
209
- ...(config.reviewModel ? { reviewModel: config.reviewModel } : {}),
210
- requireGlobalApproval: config.requireGlobalApproval ?? true,
211
- ...(config.plannerPrefixCache ? { prefixCacheMode: config.plannerPrefixCache } : {}),
212
- ...(config.plannerPrefixMaxChars !== undefined ? { prefixMaxChars: config.plannerPrefixMaxChars } : {}),
213
- });
214
- ctx.logger("continual-evolve").info("continual-evolve automatic listener registered (Memory Agent only; generic review/planner/fate disconnected)");
215
- }
216
- else {
217
- ctx.logger("continual-evolve").info("continual-evolve automatic evolution wiring disabled (autoReview is not explicitly true; manual tools and commands remain available)");
218
- }
190
+ // The Memory Agent listener is always registered: an install must work
191
+ // without profile edits, so `autoReview` is only the initial default when
192
+ // no `evolve/runtime.json` exists yet — `/evolve pause|resume` own the
193
+ // switch afterwards. The general review/planner, prompt/skill writes, and
194
+ // local-fate phases are not reachable from this listener.
195
+ registerAutoReview(ctx, engine, {
196
+ intervalTurns: config.reviewIntervalTurns ?? 6,
197
+ enabledByDefault: config.autoReview ?? false,
198
+ memoryOnly: true,
199
+ memoryMinUserWords: config.memoryMinUserWords ?? 3,
200
+ sessionCloseDrainMs: config.sessionCloseDrainMs ?? 15000,
201
+ maxInputChars: config.maxReviewInputChars ?? 40000,
202
+ budgetTokens: config.reviewBudgetTokens ?? 4096,
203
+ notifyOnAutoReview: config.notifyOnAutoReview ?? true,
204
+ localFate: config.localFate ?? false,
205
+ fateIntervalTurns: config.fateIntervalTurns ?? config.reviewIntervalTurns ?? 6,
206
+ goalBlockedWrapupTurns: config.goalBlockedWrapupTurns ?? 3,
207
+ promotionPolicy,
208
+ autoCase: config.autoCase ?? true,
209
+ rubricKey,
210
+ ...(config.historyRetain?.reviews !== undefined ? { reviewsRetain: config.historyRetain.reviews } : {}),
211
+ ...(config.reviewModel ? { reviewModel: config.reviewModel } : {}),
212
+ requireGlobalApproval: config.requireGlobalApproval ?? true,
213
+ ...(config.plannerPrefixCache ? { prefixCacheMode: config.plannerPrefixCache } : {}),
214
+ ...(config.plannerPrefixMaxChars !== undefined ? { prefixMaxChars: config.plannerPrefixMaxChars } : {}),
215
+ });
216
+ ctx.logger("continual-evolve").info("continual-evolve automatic listener registered (Memory Agent only; generic review/planner/fate disconnected)");
219
217
  ctx.logger("continual-evolve").info(`continual-evolve mounted (baseDir=${baseDir})`);
220
218
  }
221
219
  //# sourceMappingURL=index.js.map
@@ -54,7 +54,7 @@ export interface MemoryApplicationResult {
54
54
  /** Provider-facing schemas for the closed memory-only tool set. */
55
55
  export declare const MEMORY_AGENT_TOOL_SCHEMAS: readonly ToolSchema[];
56
56
  /** 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.";
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- 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- 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
58
  /** Build the frozen memory manifest from a merged harness view. */
59
59
  export declare function buildMemoryManifest(state: HarnessState): MemoryManifestEntry[];
60
60
  /** Render a bounded manifest index; full bodies remain available through memory_search. */
@@ -84,6 +84,7 @@ Rules:
84
84
  - Every edit must have kind=memory, an explicit targetScope, and a coherent blastRadius.
85
85
  - targetScope=local is session staging. project persists within the current repository. global persists across projects. project/global require human approval before application.
86
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
+ - 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.
87
88
  - 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
89
  - If no durable memory is justified, call memory_propose with edits=[].
89
90
  - 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