dsh-context-memory 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/identity.d.ts CHANGED
@@ -12,14 +12,25 @@
12
12
  *
13
13
  * @module context-memory/identity
14
14
  */
15
+ /**
16
+ * Strip the discourse padding in front of a statement.
17
+ *
18
+ * Shared with `compileGuard` on purpose. The subject is what a memory is keyed by and
19
+ * the check is what makes it verifiable; if the two disagreed about where the actual
20
+ * statement starts, "note that the file README.md exists" would get the subject of
21
+ * "file README.md exists" — and no check at all, because the guard patterns are
22
+ * anchored at the start.
23
+ *
24
+ * Applied repeatedly: fillers stack ("please note that the build uses pnpm"), and a
25
+ * single pass would leave "note that …" behind.
26
+ */
27
+ export declare function stripLeadingFillers(text: string): string;
15
28
  /**
16
29
  * Reduce a memory to the key it is stored and compared under.
17
30
  *
18
- * Lowercased, punctuation-stripped, whitespace-collapsed, and with a leading article
19
- * dropped, so "The build uses pnpm" and "build uses pnpm" are the same memory. Filler
20
- * that varies in wording but not meaning — "always", "never", "please" — is stripped
21
- * too: those words change how a memory is checked, not what it is about, and keeping
22
- * them would make the same fact conflict with itself.
31
+ * Lowercased, punctuation-stripped, whitespace-collapsed, and with discourse fillers and
32
+ * a leading article dropped, so "The build uses pnpm" and "build uses pnpm" are the same
33
+ * memory while "never commit secrets" stays a different claim from "commit secrets".
23
34
  */
24
35
  export declare function normaliseSubject(text: string): string;
25
36
  /**
package/lib/identity.js CHANGED
@@ -14,32 +14,50 @@
14
14
  */
15
15
  import { createHash } from 'node:crypto';
16
16
  /**
17
- * Reduce a memory to the key it is stored and compared under.
17
+ * Words a statement can carry in front without changing what it claims.
18
18
  *
19
- * Lowercased, punctuation-stripped, whitespace-collapsed, and with a leading article
20
- * dropped, so "The build uses pnpm" and "build uses pnpm" are the same memory. Filler
21
- * that varies in wording but not meaning — "always", "never", "please" — is stripped
22
- * too: those words change how a memory is checked, not what it is about, and keeping
23
- * them would make the same fact conflict with itself.
19
+ * Deliberately *not* included: `never` and `always`. They read like chatter but they set
20
+ * the polarity and the scope of the claim — "never commit secrets" and "commit secrets"
21
+ * are opposites, so folding them into one subject lets the second overwrite the first
22
+ * without a trace. Discourse fillers ("please", "note that", "remember that") and the
23
+ * leading article carry nothing and are stripped.
24
24
  */
25
- export function normaliseSubject(text) {
26
- const stripped = text
27
- .toLowerCase()
28
- .replace(/[`"'.,;:!?()[\]]/g, ' ')
29
- .replace(/\s+/g, ' ')
30
- .replace(/^(?:always|never|please|note that|remember that|the)\s+/i, '')
31
- .trim();
32
- // Applied repeatedly: fillers stack ("please note that the build uses pnpm"), and a
33
- // single pass would leave "note that …" behind, so the same fact said politely and
34
- // said plainly would be two subjects instead of one.
35
- let result = stripped;
25
+ const LEADING_FILLER = /^(?:please|note that|remember that|the)\s+/i;
26
+ /**
27
+ * Strip the discourse padding in front of a statement.
28
+ *
29
+ * Shared with `compileGuard` on purpose. The subject is what a memory is keyed by and
30
+ * the check is what makes it verifiable; if the two disagreed about where the actual
31
+ * statement starts, "note that the file README.md exists" would get the subject of
32
+ * "file README.md exists" — and no check at all, because the guard patterns are
33
+ * anchored at the start.
34
+ *
35
+ * Applied repeatedly: fillers stack ("please note that the build uses pnpm"), and a
36
+ * single pass would leave "note that …" behind.
37
+ */
38
+ export function stripLeadingFillers(text) {
39
+ let result = text;
36
40
  let previous;
37
41
  do {
38
42
  previous = result;
39
- result = result.replace(/^(?:always|never|please|note that|remember that|the)\s+/i, '');
43
+ result = result.replace(LEADING_FILLER, '');
40
44
  } while (result !== previous);
41
45
  return result;
42
46
  }
47
+ /**
48
+ * Reduce a memory to the key it is stored and compared under.
49
+ *
50
+ * Lowercased, punctuation-stripped, whitespace-collapsed, and with discourse fillers and
51
+ * a leading article dropped, so "The build uses pnpm" and "build uses pnpm" are the same
52
+ * memory while "never commit secrets" stays a different claim from "commit secrets".
53
+ */
54
+ export function normaliseSubject(text) {
55
+ return stripLeadingFillers(text
56
+ .toLowerCase()
57
+ .replace(/[`"'.,;:!?()[\]]/g, ' ')
58
+ .replace(/\s+/g, ' ')
59
+ .trim()).trim();
60
+ }
43
61
  /**
44
62
  * A stable short id for a memory, derived from its subject *and* its text.
45
63
  *
package/lib/index.d.ts CHANGED
@@ -9,8 +9,23 @@
9
9
  */
10
10
  import type { Context } from '@deepseek-ai/cordis';
11
11
  import { ContextMemory, type RememberInput } from './memory.js';
12
- import type { MemoryKind, MemorySource, PanelPayload, RecallHit, VerifySummary } from './types.js';
12
+ import type { MemoryKind, MemoryScope, MemorySource, PanelPayload, RecallHit, VerifySummary } from './types.js';
13
13
  export declare const name = "dsh-context-memory";
14
+ /**
15
+ * What this plugin needs from the host before it can run.
16
+ *
17
+ * Deliberately empty. The only host service it reads is `workingDirectory`, and that
18
+ * one is read opportunistically through `ctx.get`, not declared here.
19
+ *
20
+ * The reason is measurable: `working-directory` is a row in the `dsh-base` bundle's
21
+ * patch, and a profile whose `dsh-base` is older than the running dsh does not have
22
+ * that row — npm reports the mismatch (`@deepseek-ai/dsh-base@0.2.0-rc.2 invalid`) but
23
+ * the host still boots. Declaring the service here makes the whole plugin wait for it:
24
+ * `pending (waiting for service: workingDirectory)` and nothing runs at all, including
25
+ * the CLI-visible memory. Reading it optionally degrades to the session header's `cwd`,
26
+ * which is what the plugin used before it knew about the service.
27
+ */
28
+ export declare const inject: string[];
14
29
  /** Panel-facing feature switches, mirrored by the prefs route. */
15
30
  export interface MemoryPrefs {
16
31
  autoCapture: boolean;
@@ -19,14 +34,30 @@ export interface MemoryPrefs {
19
34
  /** The service shape other plugins and the panel use. */
20
35
  export interface MemoryService {
21
36
  remember(input: RememberInput): ReturnType<ContextMemory['remember']>;
22
- forget(id: string, reason: string, replacedBy?: string): boolean;
37
+ forget(id: string, reason: string, options?: {
38
+ replacedBy?: string;
39
+ scope?: MemoryScope;
40
+ }): boolean;
23
41
  recall(query: string, limit?: number): RecallHit[];
24
42
  conflicts(): ReturnType<ContextMemory['conflicts']>;
25
43
  verify(): VerifySummary;
26
44
  active(): ReturnType<ContextMemory['active']>;
27
45
  panel(): PanelPayload;
46
+ /** Statements the capture heuristics noticed but never had permission to keep. */
47
+ candidates(): ReturnType<ContextMemory['candidates']>;
48
+ accept(id: string, overrides?: {
49
+ text?: string;
50
+ kind?: MemoryKind;
51
+ scope?: 'project' | 'user';
52
+ }): ReturnType<ContextMemory['accept']>;
53
+ dismiss(id: string): boolean;
28
54
  prefs(): MemoryPrefs;
29
55
  setPrefs(patch: Partial<MemoryPrefs>): MemoryPrefs;
56
+ /** Where each scope is stored, so the panel can say which log it is showing. */
57
+ locations(): {
58
+ project: string;
59
+ user: string | undefined;
60
+ };
30
61
  }
31
62
  export declare function apply(ctx: Context): void;
32
63
  export type { MemoryKind, MemorySource };
package/lib/index.js CHANGED
@@ -7,24 +7,119 @@
7
7
  *
8
8
  * @module context-memory/index
9
9
  */
10
- import { resolve } from 'node:path';
11
10
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
12
11
  import { ContextMemory } from './memory.js';
13
- import { distill } from './capture.js';
12
+ import { candidates, distill } from './capture.js';
13
+ import { describeContradiction } from './guard.js';
14
14
  import { registerMemoryRoutes } from './routes.js';
15
+ import { registerMemoryTools } from './tools.js';
16
+ import { resolveProjectStore, userStoreDir } from './scope.js';
15
17
  import { isRecallMessage, recallSource } from './source.js';
16
18
  export const name = 'dsh-context-memory';
19
+ /**
20
+ * What this plugin needs from the host before it can run.
21
+ *
22
+ * Deliberately empty. The only host service it reads is `workingDirectory`, and that
23
+ * one is read opportunistically through `ctx.get`, not declared here.
24
+ *
25
+ * The reason is measurable: `working-directory` is a row in the `dsh-base` bundle's
26
+ * patch, and a profile whose `dsh-base` is older than the running dsh does not have
27
+ * that row — npm reports the mismatch (`@deepseek-ai/dsh-base@0.2.0-rc.2 invalid`) but
28
+ * the host still boots. Declaring the service here makes the whole plugin wait for it:
29
+ * `pending (waiting for service: workingDirectory)` and nothing runs at all, including
30
+ * the CLI-visible memory. Reading it optionally degrades to the session header's `cwd`,
31
+ * which is what the plugin used before it knew about the service.
32
+ */
33
+ export const inject = [];
34
+ /**
35
+ * How often the pre-step re-checks memories, in milliseconds.
36
+ *
37
+ * A day is chosen against the failure it prevents: a memory that quietly stopped being
38
+ * true. Checking less often lets a stale claim survive whole sessions; checking every
39
+ * step spends filesystem work on an answer that cannot have changed between two
40
+ * consecutive prompts.
41
+ */
42
+ const REVERIFY_INTERVAL_MS = 24 * 60 * 60 * 1000;
17
43
  export function apply(ctx) {
18
- const root = resolve(process.cwd());
19
- const memory = new ContextMemory(root);
44
+ // One instance per project root, created on demand. The launch directory is only the
45
+ // starting point: a session may begin below the project root, and its directory may
46
+ // move. The pre-step resolves the session's own directory per request; the panel reads
47
+ // whichever project a step most recently resolved, which is the one the user is in.
48
+ const instances = new Map();
49
+ const memoryFor = (cwd) => {
50
+ const root = resolveProjectStore(cwd);
51
+ const existing = instances.get(root);
52
+ if (existing)
53
+ return existing;
54
+ const created = new ContextMemory(root, { userDir: userStoreDir() });
55
+ instances.set(root, created);
56
+ return created;
57
+ };
58
+ let memory = memoryFor();
59
+ /**
60
+ * The directory a session is working in.
61
+ *
62
+ * The session's own directory, not the launch directory: a session can start below the
63
+ * project root and can be moved mid-life, and both must keep the same memory. `ensure`
64
+ * is the host's authoritative answer (it follows `workingDirectory.set`).
65
+ *
66
+ * Read through `ctx.get`, not `ctx.workingDirectory`: the service is optional here (see
67
+ * `inject` above), and a host without it must fall back rather than refuse to load. The
68
+ * header's original `cwd` is the fallback, and the launch directory the last resort.
69
+ *
70
+ * A silent fallback would be the wrong kind of resilience. If a future `dsh-base` stops
71
+ * mounting the service — a renamed row, a bundle that drops it — every session would
72
+ * quietly resolve to its *initial* cwd, and memories written after a `workingDirectory.set`
73
+ * would land in the wrong project's log with nothing to show it happened. So each
74
+ * degraded path reports itself once per process; `warn` being unavailable is the only
75
+ * thing that may fail silently, because logging must never break a step.
76
+ */
77
+ let warnedNoService = false;
78
+ let warnedEnsureFailed = false;
79
+ const warn = (message) => {
80
+ try {
81
+ ctx.logger('dsh-context-memory').warn(message);
82
+ }
83
+ catch {
84
+ // A Context without a logger service (a bare test context) still gets the fallback.
85
+ }
86
+ };
87
+ const cwdFor = async (agent, signal) => {
88
+ if (agent === undefined)
89
+ return undefined;
90
+ try {
91
+ const service = ctx.get('workingDirectory');
92
+ if (service === undefined) {
93
+ if (!warnedNoService) {
94
+ warnedNoService = true;
95
+ warn('no workingDirectory service on this host; using the session header cwd. '
96
+ + 'Memory written after a directory change will be filed under the original directory.');
97
+ }
98
+ return agent.session.header.cwd;
99
+ }
100
+ return (await service.ensure(agent, signal)) ?? agent.session.header.cwd;
101
+ }
102
+ catch (error) {
103
+ if (!warnedEnsureFailed) {
104
+ warnedEnsureFailed = true;
105
+ warn(`workingDirectory.ensure failed; using the session header cwd: ${error.message}`);
106
+ }
107
+ return agent.session.header.cwd;
108
+ }
109
+ };
110
+ /** When the last automatic re-check ran, so a day's worth of steps do not each run one. */
111
+ let lastReverifyAt = 0;
20
112
  const service = {
21
113
  remember: (input) => memory.remember(input),
22
- forget: (id, reason, replacedBy) => memory.forget(id, reason, replacedBy),
114
+ forget: (id, reason, options) => memory.forget(id, reason, options),
23
115
  recall: (query, limit) => memory.recall(query, limit),
24
116
  conflicts: () => memory.conflicts(),
25
117
  verify: () => memory.verify(),
26
118
  active: () => memory.active(),
27
119
  panel: () => memory.panel(),
120
+ candidates: () => memory.candidates(),
121
+ accept: (id, overrides) => memory.accept(id, overrides),
122
+ dismiss: (id) => memory.dismiss(id),
28
123
  prefs: () => ({ autoCapture: captureEnabled, recallInject: injectEnabled }),
29
124
  setPrefs: (patch) => {
30
125
  if (typeof patch.autoCapture === 'boolean')
@@ -33,9 +128,23 @@ export function apply(ctx) {
33
128
  injectEnabled = patch.recallInject;
34
129
  return { autoCapture: captureEnabled, recallInject: injectEnabled };
35
130
  },
131
+ locations: () => memory.locations,
36
132
  };
37
133
  ctx.provide('contextMemory', service);
38
134
  registerMemoryRoutes(ctx, service);
135
+ // The pull half. Registered through `ctx.inject(['tools'])` inside, so a host with no
136
+ // tool registry (the CLI, a headless profile) loses the two tools and nothing else.
137
+ registerMemoryTools(ctx, {
138
+ // A tool call belongs to the agent that issued it, and an agent belongs to a session
139
+ // with its own directory. Answering with whatever the last pre-step resolved would
140
+ // let one project's question be answered from another project's log whenever two
141
+ // sessions are open — so the instance is resolved per call, from the caller's own
142
+ // directory, and the pre-step's "current" instance is not consulted at all.
143
+ resolve: async (agent, signal) => {
144
+ const cwd = await cwdFor(agent, signal);
145
+ return memoryFor(cwd);
146
+ },
147
+ });
39
148
  // ─── Recall injection + auto capture (durable plugin context) ─
40
149
  //
41
150
  // Before each step the active memories whose subject overlaps the incoming
@@ -53,7 +162,7 @@ export function apply(ctx) {
53
162
  let injectEnabled = true;
54
163
  let captureEnabled = true;
55
164
  let injectedMessageIds = [];
56
- const contextMessageFor = (query) => {
165
+ const contextMessageFor = (memory, query) => {
57
166
  const hits = memory.recall(query, 5);
58
167
  const lines = [];
59
168
  if (hits.length > 0) {
@@ -61,12 +170,33 @@ export function apply(ctx) {
61
170
  for (const hit of hits)
62
171
  lines.push(`- [${hit.memory.kind}] ${hit.memory.text}`);
63
172
  }
173
+ // Contradicted memories are shown as a problem, never as guidance. Silently dropping
174
+ // them would hide the fact that the log now contains a claim the world denies; the
175
+ // model can resolve it (and should), while a user reading the transcript learns that
176
+ // something needs superseding.
177
+ const contradicted = memory.contradicted().slice(0, 3);
178
+ if (contradicted.length > 0) {
179
+ if (lines.length > 0)
180
+ lines.push('');
181
+ lines.push('Memories a check has contradicted (do not rely on these; correct or supersede them):');
182
+ for (const item of contradicted) {
183
+ // A check that ran and came back false reports no output of its own, so the
184
+ // fallback names the operand that no longer matches. Without it the line reads
185
+ // "contradicted" with no way to tell what was tested.
186
+ lines.push(`- [${item.kind}] ${item.text} ← ${describeContradiction(item.guard, item.verification?.output)}`);
187
+ }
188
+ }
64
189
  const conflicts = memory.conflicts();
65
190
  if (conflicts.length > 0) {
66
- lines.push('');
191
+ if (lines.length > 0)
192
+ lines.push('');
67
193
  lines.push('Unresolved memory conflicts (verify before relying on either side):');
194
+ // Both sides are shown, not just the count: a conflict the reader cannot see the
195
+ // two texts of is a question they cannot answer.
68
196
  for (const conflict of conflicts.slice(0, 3)) {
69
- lines.push(`- Subject "${conflict.subject}" has ${conflict.memories.length} disagreeing records`);
197
+ lines.push(`- Subject "${conflict.subject}" has ${conflict.memories.length} disagreeing records:`);
198
+ for (const item of conflict.memories)
199
+ lines.push(` · ${item.id} ${item.text}`);
70
200
  }
71
201
  }
72
202
  return createUserMessage({
@@ -99,18 +229,50 @@ export function apply(ctx) {
99
229
  const trimmed = prompt.trim();
100
230
  if (trimmed.length === 0)
101
231
  return decision;
232
+ // A subagent runs in its own session with its own header; it must not inherit the
233
+ // parent's project just because it shares a process. Sessions with no declared origin
234
+ // are ordinary user sessions.
235
+ const origin = agent.session.header.origin;
236
+ if (origin === 'subagent')
237
+ return decision;
238
+ // The session's own directory, not the launch directory: a session can start below
239
+ // the project root and can be moved mid-life, and both must keep the same memory.
240
+ memory = memoryFor(await cwdFor(agent, signal));
241
+ // Re-check before composing, so a memory the world has since disproved is never
242
+ // offered as guidance in the very step that would have used it. Throttled: the
243
+ // answer cannot change between two prompts, and the guards are filesystem reads
244
+ // that should not run on every step.
245
+ if (Date.now() - lastReverifyAt >= REVERIFY_INTERVAL_MS) {
246
+ lastReverifyAt = Date.now();
247
+ try {
248
+ memory.verify(Date.now(), { record: true, changedOnly: true });
249
+ }
250
+ catch { /* a check that cannot be persisted must not stop the step */ }
251
+ }
102
252
  // Auto capture FIRST (see comment above): explicit "记住…" statements from
103
253
  // this very request are persisted with session provenance, then excluded
104
254
  // from the recall composed below.
105
255
  if (captureEnabled) {
106
- for (const distilled of distill(trimmed, agent.id)) {
256
+ const distilled = distill(trimmed, agent.id);
257
+ for (const statement of distilled) {
107
258
  try {
108
- memory.remember(distilled);
259
+ memory.remember(statement);
109
260
  }
110
261
  catch { /* empty statement cannot occur here */ }
111
262
  }
263
+ // Then the wide net, which does not write: a declarative sentence that looks like a
264
+ // durable fact becomes a question in the panel. Keeping the two apart is the whole
265
+ // point of P5 — the explicit rule is what the user asked for, the implicit one is
266
+ // only what the plugin noticed, and conflating them is how a memory log fills with
267
+ // things nobody wanted.
268
+ for (const candidate of candidates(trimmed, agent.id, distilled.map((statement) => statement.text))) {
269
+ try {
270
+ memory.propose(candidate);
271
+ }
272
+ catch { /* a queue that cannot be written must not stop the step */ }
273
+ }
112
274
  }
113
- const desired = contextMessageFor(trimmed);
275
+ const desired = contextMessageFor(memory, trimmed);
114
276
  // Nothing relevant and nothing conflicting → empty content: skip entirely
115
277
  // rather than ship an empty row into the transcript.
116
278
  if (desired.content.length === 0)
package/lib/memory.d.ts CHANGED
@@ -6,9 +6,16 @@
6
6
  * what is remembered, and a cache would let one process's view of history outlive the
7
7
  * events that contradict it.
8
8
  *
9
+ * There are two logs, one per scope. They are folded together rather than merged on
10
+ * disk: a project convention and a working preference are different statements kept in
11
+ * different places for different reasons, and collapsing them would make "this checkout"
12
+ * and "everywhere" the same claim. Which log an event is appended to follows the
13
+ * memory's own scope, never the caller's convenience.
14
+ *
9
15
  * @module context-memory/memory
10
16
  */
11
- import type { Conflict, Memory, MemoryKind, MemorySource, MemoryState, PanelPayload, RecallHit, VerifySummary } from './types.js';
17
+ import { type ProposeInput } from './candidates.js';
18
+ import type { Candidate, CandidateState, Conflict, Memory, MemoryKind, MemoryScope, MemorySource, MemoryState, MemoryTarget, PanelPayload, RecallHit, VerifySummary } from './types.js';
12
19
  export interface RememberInput {
13
20
  text: string;
14
21
  /**
@@ -18,25 +25,71 @@ export interface RememberInput {
18
25
  subject?: string;
19
26
  kind?: MemoryKind;
20
27
  source?: MemorySource;
28
+ /** Which log to write. Defaults to `project` — the narrow claim is the safe default. */
29
+ scope?: MemoryScope;
21
30
  sessionId?: string;
22
31
  step?: number;
23
32
  }
33
+ /** Construction options. */
34
+ export interface ContextMemoryOptions {
35
+ /**
36
+ * Directory of the user-global log.
37
+ *
38
+ * Omitted means the instance is project-only: `all()` folds one log, and asking to
39
+ * remember something user-global throws rather than quietly writing it to the project.
40
+ * Tests rely on that — an omitted directory must never reach the real home.
41
+ */
42
+ userDir?: string;
43
+ }
24
44
  export declare class ContextMemory {
25
45
  private readonly projectDir;
26
- private readonly store;
27
- constructor(projectDir: string);
46
+ private readonly projectStore;
47
+ private readonly userStore;
48
+ private readonly queue;
49
+ /**
50
+ * The last fold, keyed by the identity of both log files.
51
+ *
52
+ * The pre-step asks the same question several times in one pass — who is relevant,
53
+ * who is contradicted, who disagrees — and each answer used to re-fold both logs. The
54
+ * fold is still recomputed whenever either file changes, so two processes reading one
55
+ * log still agree: everything below re-derives from the files, this only avoids
56
+ * deriving the same state from the same bytes more than once.
57
+ */
58
+ private folded;
59
+ constructor(projectDir: string, options?: ContextMemoryOptions);
60
+ /** Where each scope is written, for the panel and the CLI. */
61
+ get locations(): {
62
+ project: string;
63
+ user: string | undefined;
64
+ };
65
+ /** The project log's path. Kept for callers that only know about the project scope. */
28
66
  get logPath(): string;
67
+ private storeFor;
29
68
  /**
30
- * Fold the log into current state.
69
+ * Fold the logs into current state.
31
70
  *
32
71
  * Events are applied in log order and a memory is always addressable by id, so a
33
72
  * supersede or verify that names a memory not yet seen is skipped rather than
34
73
  * creating a phantom entry — a log that lost its `remember` line must not resurrect
35
74
  * the memory from its consequences.
75
+ *
76
+ * A memory's scope is part of its identity here: the same sentence can legitimately
77
+ * exist in both logs, and they are two claims about two different domains, so both
78
+ * are kept rather than one shadowing the other.
36
79
  */
37
80
  all(): MemoryState[];
81
+ private scopes;
38
82
  active(): MemoryState[];
39
- get(id: string): MemoryState | undefined;
83
+ /**
84
+ * Active memories no check has contradicted — the population recall ranks and the
85
+ * pre-step may offer the model.
86
+ */
87
+ guidance(): MemoryState[];
88
+ /** Active memories a check ran against and failed — visible, never offered. */
89
+ contradicted(): MemoryState[];
90
+ get(id: string, scope?: MemoryScope): MemoryState | undefined;
91
+ /** Whether a memory with this id exists in either scope — the candidate queue's dedupe. */
92
+ has(id: string): boolean;
40
93
  /**
41
94
  * Record a memory.
42
95
  *
@@ -51,6 +104,19 @@ export declare class ContextMemory {
51
104
  * command would be an injection path.
52
105
  */
53
106
  remember(input: RememberInput, now?: number): Memory;
107
+ /**
108
+ * `remember`, plus whether the statement was already on file.
109
+ *
110
+ * The distinction exists for the model-facing tool: "recorded" and "already knew that"
111
+ * are different answers, and a caller reporting the first when the second happened is
112
+ * claiming work it did not do. It is computed here rather than by the caller because
113
+ * the id is derived from the subject and text, and a second copy of that derivation
114
+ * would drift from this one.
115
+ */
116
+ record(input: RememberInput, now?: number): {
117
+ memory: Memory;
118
+ updated: boolean;
119
+ };
54
120
  /**
55
121
  * Retire a memory, with a reason.
56
122
  *
@@ -59,7 +125,11 @@ export declare class ContextMemory {
59
125
  * marked superseded and kept, and the reason is required because "why did this stop
60
126
  * being true" is the question that will be asked later.
61
127
  */
62
- forget(id: string, reason: string, replacedBy?: string, now?: number): boolean;
128
+ forget(id: string, reason: string, options?: {
129
+ replacedBy?: string;
130
+ scope?: MemoryScope;
131
+ now?: number;
132
+ }): boolean;
63
133
  /**
64
134
  * Group active memories that share a subject but say different things.
65
135
  *
@@ -67,10 +137,53 @@ export declare class ContextMemory {
67
137
  * and different text means one of them is wrong and the log does not say which; a
68
138
  * plausible-looking choice would be exactly the unfounded assertion this plugin exists
69
139
  * to eliminate.
140
+ *
141
+ * A contradicted memory is left out: it is not an open question, the world already
142
+ * answered it. Keeping it here would present a settled matter as undecided.
70
143
  */
71
144
  conflicts(): Conflict[];
72
- /** Rank active memories against a query. */
73
- recall(query: string, limit?: number, now?: number): RecallHit[];
145
+ /**
146
+ * Rank memories against a query.
147
+ *
148
+ * Defaults to guidance — the population the pre-step may offer. The pull tool widens
149
+ * the target explicitly when the model asks what is *recorded* rather than what is
150
+ * *true*, and that difference is carried by the argument instead of by a second
151
+ * implementation.
152
+ */
153
+ recall(query: string, limit?: number, now?: number, target?: MemoryTarget): RecallHit[];
154
+ /**
155
+ * The newest memories in one population, for a caller that asked without a query.
156
+ *
157
+ * "What do you remember" has no lexical content to rank, and answering it with an
158
+ * empty result would be pedantically correct and useless. Newest-first is the one
159
+ * ordering that needs no claim about relevance — it is simply the log's own order,
160
+ * read backwards.
161
+ */
162
+ recent(limit?: number, target?: MemoryTarget): MemoryState[];
163
+ /**
164
+ * Offer a statement.
165
+ *
166
+ * Returns undefined when it is already settled — an active memory, an open candidate,
167
+ * or one already dismissed. That check is against the *log*, not against the message,
168
+ * so a sentence repeated across a session is asked once rather than on every step.
169
+ */
170
+ propose(input: ProposeInput): Candidate | undefined;
171
+ candidates(): CandidateState[];
172
+ dismiss(id: string, now?: number): boolean;
173
+ /**
174
+ * Turn a candidate into a memory.
175
+ *
176
+ * The decision is recorded *before* the memory is written, so a write that throws
177
+ * leaves a dismissed-looking proposal rather than a candidate that reappears beside a
178
+ * memory it duplicates. The candidate keeps the kind and provenance it was proposed
179
+ * with, and the user's acceptance is the reason it exists — which is why the memory is
180
+ * written with `source: 'user'`: a person said yes to it.
181
+ */
182
+ accept(id: string, overrides?: {
183
+ text?: string;
184
+ kind?: MemoryKind;
185
+ scope?: MemoryScope;
186
+ }, now?: number): Memory | undefined;
74
187
  /**
75
188
  * Re-run every compiled guard.
76
189
  *
@@ -78,8 +191,17 @@ export declare class ContextMemory {
78
191
  * disagreeing with a memory, `broken` is the check itself being unusable, and
79
192
  * `uncheckable` is there being no check. Only the first is evidence about the memory,
80
193
  * so only the first may influence a caller's exit code.
194
+ *
195
+ * @param options.record - when false, results are computed but not appended. The
196
+ * pre-step re-checks often; a log entry per check per step would grow the file
197
+ * without saying anything new.
198
+ * @param options.changedOnly - append only when the outcome differs from the last
199
+ * recorded one, so a memory that keeps passing does not add a line per run.
81
200
  */
82
- verify(now?: number): VerifySummary;
201
+ verify(now?: number, options?: {
202
+ record?: boolean;
203
+ changedOnly?: boolean;
204
+ }): VerifySummary;
83
205
  /**
84
206
  * The exit code a caller should use.
85
207
  *