dsh-context-memory 0.7.1 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,315 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ **A memory a check contradicts is no longer offered as guidance, Chinese queries return
6
+ results, a project's memory follows the project rather than the launch directory, the model
7
+ can ask what is remembered, a guard can read a file's structure instead of scanning its
8
+ text, and an ordinary declarative sentence is offered for confirmation instead of being
9
+ silently dropped or silently kept.**
10
+
11
+ These close defects that made the plugin quietly worse than having no memory at all, and
12
+ add the two abilities the design was missing: a structural check, and a capture rule wider
13
+ than an explicit verb.
14
+
15
+ ### A contradicted memory is filtered out, not down-weighted
16
+
17
+ `verify` has always sorted outcomes three ways, but the verdict never reached recall:
18
+ the fold recorded `verification` on the state and left `status: 'active'` alone, and
19
+ `scoreMemory` deducted 0.2 from the score. So a memory the filesystem had explicitly
20
+ denied still ranked, still fit under `Relevant memories from previous sessions`, and
21
+ still reached the model — as advice. That is worse than the plugin not existing, because
22
+ the user believes the agent has remembered something.
23
+
24
+ The population is now split. `guidance()` and `recall()` exclude contradicted memories;
25
+ `contradicted()` returns them; the panel lists them under their own heading; and the
26
+ pre-step renders them as a separate block that says not to rely on them and to correct
27
+ or supersede them. They stay in the log — a memory the world disproved is the most
28
+ informative record it holds — and they are never ranked.
29
+
30
+ A `broken` check still says nothing about the memory: an unusable check leaves the
31
+ memory in `guidance()`. Only `violated` is evidence.
32
+
33
+ Conflicts are computed over guidance only. A memory whose check denied it is not an open
34
+ question — the world already answered it — so presenting one as a live conflict would be
35
+ the opposite of resolving it.
36
+
37
+ ### Chinese recall was zero
38
+
39
+ `tokenize` split on `/[^a-z0-9_.-]+/`, which is every CJK code point, so any Chinese query
40
+ produced an empty token list and matched nothing:
41
+
42
+ ```
43
+ tokenize('这个项目构建用 pnpm') → ['pnpm']
44
+ tokenize('构建用什么') → []
45
+ ```
46
+
47
+ `capture.ts` had always accepted Chinese memory verbs, so the plugin could store Chinese
48
+ statements and never find them again.
49
+
50
+ CJK runs are now cut into overlapping 2-grams alongside the existing Latin tokens —
51
+ `构建用什么` becomes `构建 / 建用 / 用什 / 什么`. Two characters is the smallest unit that
52
+ carries meaning in Chinese, and 2-grams need no dictionary, no dependency, and no model.
53
+ Straddling grams are what let a query match a memory whose surrounding wording differs.
54
+
55
+ ### Memory follows the project, not the process
56
+
57
+ `apply()` used `resolve(process.cwd())`. dsh sessions carry their own working directory,
58
+ and it can move: `workingDirectory.set()` is called by the worktree plugin and the SDK
59
+ server. So a session started one level down inside a checkout wrote to a different log,
60
+ and a session that moved wrote somewhere else again.
61
+
62
+ The pre-step now asks the host — `ctx.workingDirectory.ensure(agent, signal)` — and falls
63
+ back to the session header's original `cwd`. `findProjectRoot` then climbs to the top of
64
+ the checkout, which is what makes `packages/app/src` and the repository root the same
65
+ project. The marker set is `.git` and `pnpm-workspace.yaml`; `package.json` is
66
+ deliberately not one, because a stray `package.json` in a home directory would merge every
67
+ unrelated folder beneath it into one project.
68
+
69
+ The CLI resolves the same way, so `cd packages/x && dsh-context-memory list` sees the
70
+ root's log. Subagents are skipped: they run in their own session and must not inherit the
71
+ parent's project just by sharing a process.
72
+
73
+ ### Two scopes instead of one
74
+
75
+ A project convention and a working preference are different claims. `remember` takes a
76
+ `scope`: `project` (default, `<projectRoot>/.memory/`) or `user`
77
+ (`$DSH_HOME/context-memory/`, resolved through `@deepseek-ai/dsh-home-paths`). The fold
78
+ reads both logs; a memory carries the scope it was written under, and the two populations
79
+ do not leak into each other. The CLI gains `--scope` and `--global`, the panel route
80
+ accepts `scope`, and a log written by 0.8.x reads as `project`, which is what it was.
81
+
82
+ The id is unchanged by scope, so promoting a statement from project to user keeps its
83
+ identity visible rather than appearing as two unrelated hashes.
84
+
85
+ ### `verify` can run without writing
86
+
87
+ `verify(now, { record, changedOnly })`: `record: false` computes results without appending;
88
+ `changedOnly: true` appends only when the outcome differs from the last recorded one. The
89
+ pre-step re-checks at most once a day and uses `changedOnly`, so guarding against staleness
90
+ no longer grows the log by one line per memory per day.
91
+
92
+ ### The plugin did not load on the runtime it was built for
93
+
94
+ The peer set was pinned to `0.2.0-rc.2`. The installed dsh is `0.2.1-rc.2` — both the
95
+ CLI and the desktop app — and `dsh` refuses to mount a bundle whose peers disagree with
96
+ the running version:
97
+
98
+ ```
99
+ dsh: skipping profile bundle "dsh-context-memory": Error: Plugin
100
+ dsh-context-memory@0.9.0 is incompatible with dsh 0.2.1-rc.2 ...
101
+ ```
102
+
103
+ That is a hard skip, not a warning: nothing in this release would have run. Peers are now
104
+ `^0.2.1-rc.2` (which `semver` reads as "≥ 0.2.1-rc.2 and < 0.3.0"), with
105
+ `@deepseek-ai/cordis` at `~4.0.5`, matching the versions the official 0.2.1-rc.2 plugins
106
+ declare.
107
+
108
+ `@deepseek-ai/dsh-working-directory` is a **devDependency only**. It is imported for its
109
+ types — the package augments cordis's `Context` with `workingDirectory`, so the call site
110
+ is checked against the signature the host was built with instead of a hand-written mirror
111
+ — and `import type {}` erases at build time, so there is nothing to resolve at runtime.
112
+ `src/working-directory.ts`, which restated that interface by hand, is gone.
113
+
114
+ ### `workingDirectory` is read opportunistically, not required
115
+
116
+ `export const inject = ['workingDirectory']` was wrong, and measurably so. That service is
117
+ mounted by a row in the `dsh-base` bundle's patch, and this machine's global install has
118
+ `@deepseek-ai/dsh-base@0.2.0-rc.2` next to `dsh@0.2.1-rc.2` — npm reports it as
119
+ `invalid`, the host boots anyway, and the row that mounts `working-directory` is simply
120
+ absent. Declaring the dependency made the whole plugin wait:
121
+
122
+ ```
123
+ dsh-context-memory (dsh-context-memory): pending (waiting for service: workingDirectory)
124
+ ```
125
+
126
+ A pending fiber is indistinguishable from an absent one: no pre-step, no service, no
127
+ recall. It now reads `ctx.get('workingDirectory')` — which returns `undefined` rather than
128
+ throwing for an unprovided service, confirmed against cordis 4.0.5 — and falls back to the
129
+ session header's `cwd`, which is what the plugin used before it knew the service existed.
130
+ `inject` is `[]`.
131
+
132
+ `tests/activation.test.mjs` is new and covers both cases: the empty declaration, and a
133
+ full pre-step on a host with no `workingDirectory` service at all. These are the checks the
134
+ rest of the suite could not make — every other test constructs `ContextMemory` directly,
135
+ so all of them pass on a plugin the host would refuse to load.
136
+
137
+ ### The model can ask, not only be told
138
+
139
+ Memory had one direction: the pre-step pushes what the plugin thinks is relevant. A model
140
+ that needed to know "what did we already decide about this" had no way to ask, so the one
141
+ question memory exists to answer was the one it could not pose.
142
+
143
+ Two tools now fill the pull half.
144
+
145
+ `memory_recall(query?, limit?, include?)` ranks stored memories and returns each with its
146
+ id, kind, scope, and verification state. `include` selects the population — `guidance`
147
+ (default), `contradicted`, `superseded`, `all` — and the default deliberately matches what
148
+ the push path may offer. The two directions want different answers to "what is true":
149
+ unsolicited text influences the turn, so it may only carry guidance; an answer to a
150
+ question someone asked may include what a check disproved, if they asked for it by name.
151
+ Omitting `query` returns the newest rather than nothing, because "what do you remember"
152
+ has no lexical content to rank.
153
+
154
+ `memory_remember(text, kind?, subject?, scope?)` records with `source: 'agent'`, so the
155
+ panel still separates what the user said from what the model wrote down. The reply carries
156
+ the compiled check, or says plainly that none could be compiled. A statement already on
157
+ file reports `updated: true` with its original id and creation time — the log is
158
+ append-only and folds by id, so the honest answer is "refreshed", not "recorded".
159
+
160
+ Both register through `ctx.inject(['tools'])`. A static `inject` entry would make the tool
161
+ registry a precondition for the whole plugin, and this plugin must keep working on a host
162
+ that mounts none — the CLI, a headless profile. There, exactly two tools are absent and
163
+ everything else runs. `tests/activation.test.mjs` asserts that.
164
+
165
+ `tests/tools.test.mjs` runs both through a real `ToolRuntime` rather than calling the
166
+ executor: `defineTool` compiles the schema at registration and validates arguments and
167
+ output per call, so a direct call would pass on a definition the registry rejects.
168
+
169
+ Writing these tests found a real gap in the injection path as well. `runGuard` reports a
170
+ reason when a check *cannot run*, but a check that ran and came back false returns an
171
+ empty `output` — so every contradicted memory was rendered as "contradicted" with no
172
+ indication of which path or string had been tested. `describeContradiction` now falls back
173
+ to the check's own description, and the pre-step block and `verify` output both use it.
174
+
175
+ ### Guards can read a file's structure, not only its text
176
+
177
+ Four predicates answered questions about a file's *text*. That made the most common
178
+ verifiable claims in a JS/TS project uncheckable, and the workaround was worse than
179
+ admitting it: `the file package.json contains tsx` compiles, passes, and verifies nothing —
180
+ `tsx` is in the description, in a `files` entry, or inside another script's command string.
181
+ A memory that has never been checked is honest; one whose check passes for the wrong reason
182
+ is not.
183
+
184
+ Four structured predicates now parse the file instead of scanning it:
185
+
186
+ | Statement | Predicate |
187
+ |---|---|
188
+ | `package.json depends on X` / `does not depend on X` | `depends` — a key in `dependencies`, `devDependencies`, `peerDependencies` or `optionalDependencies` |
189
+ | `package.json script build runs tsc --noEmit` / `has no deploy script` | `script` — exact value of `scripts.build` |
190
+ | `tsconfig enables strict` / `does not enable strict` | `option` — top level or `compilerOptions` |
191
+ | `the file X has at most N lines` / `at least N lines` | `lineLimit` — content lines |
192
+
193
+ Still no shell: `JSON.parse` and a line count are in-process. The memory still supplies
194
+ operands only — a key name, a script name, a bound — never a program.
195
+
196
+ Three decisions in there are load-bearing:
197
+
198
+ - **Exact script match, not prefix.** `build runs tsc` and `build runs tsc --noEmit` are
199
+ different claims; a prefix match would let one verify the other.
200
+ - **`compilerOptions` is searched.** Every real `tsconfig.json` puts `strict` inside it, so
201
+ a top-level-only lookup would report a correct memory as `violated` — and a false
202
+ contradiction is the one outcome this module must not produce.
203
+ - **A negated option means "not on", not "not present".** `tsconfig does not enable strict`
204
+ carries `value: true, absent: true`, so an explicit `"strict": false` satisfies it. Testing
205
+ only for key absence would call that memory violated.
206
+
207
+ `usableGuard` changed from four `||` comparisons to a whitelist. An unknown `kind` — from a
208
+ newer version, or a hand-edited log — is dropped and the memory becomes `uncheckable`,
209
+ instead of reaching `runGuard` and being answered by whichever branch its fallthrough
210
+ happens to be.
211
+
212
+ One gap surfaced while shaking out the real host: a `lineLimit` guard with neither `max`
213
+ nor `min` (only reachable from a hand-edited log) counted the lines and reported `passed`,
214
+ which is a check that verified nothing. It is `broken` now, and tested.
215
+
216
+ ### Capture notices more, but writes only what was asked for
217
+
218
+ The capture rules required an explicit verb — "记住…", "remember that…", "以后…". That
219
+ kept the log clean and made the plugin miss the most ordinary way a person states a fact:
220
+ "我们用 sqlite 存会话数据" is exactly the kind of thing a memory is for, and nothing
221
+ captured it.
222
+
223
+ Widening the regex would have been the wrong fix. The same sentence shape produces "we use
224
+ sqlite" and "we use two queues in this diagram"; a rule that cannot tell them apart and
225
+ writes both is a rule that makes recall worse than the narrow one it replaced.
226
+
227
+ So capture now has two tiers, and the wide one does not write:
228
+
229
+ | Tier | Trigger | Outcome |
230
+ |---|---|---|
231
+ | explicit | a memory verb | written to the log, as before |
232
+ | implicit | a declarative shape plus a technical token | **proposed**, in `.memory/candidates.jsonl` |
233
+
234
+ A proposal becomes a memory only when someone accepts it — from the panel's 「待确认」 card
235
+ or with `dsh-context-memory accept <id>`. Dismissing records the answer, so the same
236
+ sentence is not offered again on every step.
237
+
238
+ What the proposal rule actually requires, and why each condition is there:
239
+
240
+ - **A technical token** (`pnpm`, `@scope/pkg`, a path) — the only cheap proxy for
241
+ "something a future reader could act on". Without it, "我们用两个队列" becomes a candidate.
242
+ - **Not a question, not a directive** — "我们用 sqlite 吗?" asks, and "帮我用 pnpm 装一下"
243
+ instructs this turn; neither is a claim about the project.
244
+ - **At most three per message** — a message that trips more patterns is usually about
245
+ something else, and ten rows at once get ignored exactly like none.
246
+
247
+ The queue is a second append-only log beside `events.jsonl`, folded the same way. Two
248
+ processes reading it agree on which questions are open, and "we offered this and you
249
+ declined" stays answerable. A candidate has no scope, no guard, and no claim on recall;
250
+ `accept` is the only path from a heuristic into the log, and it writes with
251
+ `source: 'user'` because a person said yes.
252
+
253
+ `tests/candidates.test.mjs` (13) checks that boundary from both sides — pending never
254
+ appears in `recall`, accepting writes once, dismissing writes nothing, and a decided id
255
+ cannot be re-answered. `tests/routes.test.mjs` (6) covers the panel route, including the
256
+ one that matters: there is no route that remembers an unaccepted proposal.
257
+
258
+ ### Declarations
259
+
260
+ `src/scope.ts` is new (root discovery, user path). `src/tools.ts` is new.
261
+ `src/candidates.ts` is new (the append-only proposal queue). `MemoryStore`
262
+ now takes the `.memory` directory rather than the project directory, because the caller is
263
+ the one that knows which scope it is. `ContextMemory.record` returns the memory plus
264
+ whether it already existed, so the tool does not have to re-derive the id to answer that.
265
+ `recall` and `ContextMemory.recall` take a trailing `target`, defaulting to `guidance`.
266
+ `Guard` grows from four members to eleven (eight predicates; three with a negated variant).
267
+ `runGuard` is a flat dispatch per kind rather than "exists/absent, then read and search",
268
+ which is what let the structured kinds be added without each one inheriting the text
269
+ branch's assumptions. `ContextMemory` gains `propose` / `candidates` / `accept` / `dismiss`,
270
+ where `accept` is the only method that writes a candidate into the log.
271
+
272
+ 138 tests, up from 60. Of those, 110 came with the recall/guard work (four activation,
273
+ eight tool, twenty-two guard); the widening added eight capture cases, thirteen for the
274
+ queue itself, six through the real route registration, and one that runs a declarative
275
+ statement through the real `agent/pre-step` waterfall and asserts it is proposed, not
276
+ remembered.
277
+
278
+
279
+
280
+ ## 0.8.0
281
+
282
+ **Checks are evaluated against the filesystem instead of compiled into shell commands.**
283
+
284
+ A guard used to be a shell command string, run through
285
+ `spawnSync(..., { shell: true })`, with a different spelling per platform. That made the
286
+ one question this module exists to answer — did the check run at all? — undecidable on
287
+ Windows: cmd.exe reports both "the program is missing" and "the check ran and failed" as
288
+ exit status 1, and its own diagnostic is a localized resource string. On a zh-CN host it
289
+ arrives as GBK bytes decoding to mojibake under `utf8`, so a classifier reading the text
290
+ answered differently depending on the system language. It also meant a guard could not
291
+ distinguish the two outcomes the plugin is built around, so a defective check could mark
292
+ a true memory as contradicted.
293
+
294
+ `Guard` is now a predicate over the filesystem — `exists`, `absent`, `contains`,
295
+ `lacks` — with a path and, where relevant, a search string. Evaluation is `fs`, so:
296
+
297
+ - Nothing from memory text is executed. A statement supplies operands, never a program,
298
+ an argument list, or shell syntax; `$(whoami)` in a path is a filename, not a command.
299
+ - There is no quoting layer to get wrong, and no `%VAR%` expansion inside quotes.
300
+ - The result does not depend on the host language, because no output is parsed.
301
+ - `broken` (the check could not be carried out: a directory named as a file, a path the
302
+ filesystem refuses to answer for) is now a fact about a return value rather than a
303
+ guess about stderr, and it still never reaches the caller's exit code.
304
+
305
+ Guards recorded by earlier versions as `{ command }` are dropped on fold, so those
306
+ memories report as `uncheckable` — honest about not being runnable here — rather than
307
+ being read through a shape this version no longer understands. The log itself is not
308
+ rewritten.
309
+
310
+ `shellQuote` and `compileGuard`'s `platform` parameter are gone, and `classify` no longer
311
+ takes an output string. `describeGuard` renders a check for the CLI and the log.
312
+
3
313
  ## 0.7.1
4
314
 
5
315
  **Client half now type-checks against the host's own client types.**
@@ -12,6 +322,7 @@ registration signature requires before it will accept `locale: NS`.
12
322
 
13
323
  That check immediately found missing dictionary keys: strings the panel asked for that
14
324
  neither dictionary defined (they rendered as the raw key). They are now translated in both.
325
+
15
326
  ## 0.7.0
16
327
 
17
328
  **dsh 0.2.0-rc.2 alignment: host-provided packages move to pinned peer dependencies.**
@@ -26,6 +337,7 @@ The recall message this plugin injects now declares its own `MessageSourceMap` k
26
337
  (`dsh-context-memory`, form `recall`). 0.2.0-rc.2 removed the shared catch-all `plugin` kind:
27
338
  a producer declares its own kind by augmenting the map in its own module, as the official
28
339
  `dsh-agent-loop` does for `runtime-context`.
340
+
29
341
  ## 0.5.1
30
342
 
31
343
  **Fixed: the previous release only covered `ui.tsx`.**
package/README.md CHANGED
@@ -29,15 +29,20 @@ This plugin keeps it, and — unlike a note file — re-checks the parts that ca
29
29
  answer.
30
30
  - **Recall.** Retrieval is ranked (token overlap, recency, verification strength) instead
31
31
  of pasting every memory into the context window. Each hit reports the breakdown of its
32
- score, so a ranking is never something you have to trust.
32
+ score, so a ranking is never something you have to trust. Chinese and English are
33
+ tokenized equally, so a Chinese question finds a Chinese memory.
33
34
  - **Forget with a reason.** A memory is superseded, never deleted. The wrong memories are
34
35
  the most valuable records in the log — they are the only thing that can explain a past
35
36
  decision.
36
37
  - **Conflict detection.** Two active memories about the same subject are reported as a
37
- conflict. The plugin does not pick a winner; the log does not say which one is right,
38
- and guessing would be the unfounded assertion this plugin exists to remove.
38
+ conflict, with both texts shown. The plugin does not pick a winner; the log does not say
39
+ which one is right, and guessing would be the unfounded assertion this plugin exists to
40
+ remove.
39
41
  - **Verification.** A memory whose statement has a checkable shape is compiled into a
40
- platform-aware guard and re-checked on demand. This is the part a notes file cannot do.
42
+ guard and re-checked — automatically at most once a day, and on demand from the CLI or
43
+ the panel. This is the part a notes file cannot do.
44
+ - **Two scopes.** A convention of this checkout and a preference that follows you are
45
+ different claims, and they are stored in different logs.
41
46
 
42
47
  ## The point
43
48
 
@@ -62,32 +67,111 @@ passed 1, violated 1, broken 1, uncheckable 1
62
67
 
63
68
  Four outcomes, and the distinction between them is the whole point:
64
69
 
65
- | Outcome | Meaning |
66
- |---|---|
67
- | `passed` | The check ran and the memory holds. |
68
- | `violated` | The check ran and the world disagrees with the memory. |
69
- | `broken` | The check itself could not run — a typo, a missing binary. **Not** evidence about the memory. |
70
- | `uncheckable` | No check could be compiled. The memory is still stored and recalled; it is just not verified. |
70
+ | Outcome | Meaning | Reaches the model as guidance? |
71
+ |---|---|---|
72
+ | `passed` | The check ran and the memory holds. | yes, and it ranks higher |
73
+ | `violated` | The check ran and the world disagrees with the memory. | **no** — reported separately as contradicted |
74
+ | `broken` | The check itself could not run — its operand is unreadable. **Not** evidence about the memory. | yes |
75
+ | `uncheckable` | No check could be compiled. The memory is still stored and recalled; it is just not verified. | yes |
76
+
77
+ A `violated` memory stays in the log and in the panel, but recall skips it and the
78
+ injected context puts it in its own block, marked as not to be relied on. A claim the
79
+ filesystem denies has no score at which it is safe to hand to the agent, so it is filtered
80
+ rather than down-weighted.
71
81
 
72
82
  `verify` exits non-zero **only** for `violated`. A broken guard is a defect in the check,
73
83
  and letting it turn a pipeline red is how a typo would get a true memory marked false —
74
84
  after which you learn to ignore the output.
75
85
 
76
86
  Nothing is ever marked as verified because it was stored, and a memory supplied by the
77
- agent is never executed as a command: guards come from templates that only ever
78
- interpolate quoted file paths and literal search strings.
87
+ agent is never executed: a guard supplies a path and a search string to `fs`, never a
88
+ program or an argument list.
89
+
90
+ ### What can be checked
91
+
92
+ Eight statement shapes compile to a guard. Four are about a file's text; four are about a
93
+ file's structure, which is answered by `JSON.parse` and a line count — still in-process,
94
+ still with no program taken from the memory.
95
+
96
+ | Statement | Predicate | Read as |
97
+ |---|---|---|
98
+ | `the file X exists` / `never commit X` | `exists` / `absent` | `fs.stat` |
99
+ | `the file X contains Y` / `the file X must not contain Y` | `contains` / `lacks` | substring |
100
+ | `package.json depends on X` / `package.json does not depend on X` | `depends` | key in `dependencies`, `devDependencies`, `peerDependencies` or `optionalDependencies` |
101
+ | `package.json script build runs tsc --noEmit` / `package.json has no deploy script` | `script` | exact value in `scripts` |
102
+ | `tsconfig enables strict` / `tsconfig does not enable strict` | `option` | top-level or `compilerOptions`; `jsconfig` works too |
103
+ | `the file X has at most N lines` / `at least N lines` | `lineLimit` | content lines, not a trailing newline |
104
+
105
+ The structural predicates exist because the text ones cannot answer structural questions
106
+ honestly. `the file package.json contains tsx` passes when `tsx` appears in a description,
107
+ a `files` entry, or another script's command string — a check that reports `passed` while
108
+ verifying nothing. `package.json depends on tsx` reads the key. Likewise a script command
109
+ is matched exactly, not by prefix: `build runs tsc` and `build runs tsc --noEmit` are
110
+ different claims and must not verify each other.
111
+
112
+ Everything else stays `uncheckable`, deliberately. `the build uses pnpm` needs a
113
+ definition of "build tool"; any approximation would produce a check that passes for some
114
+ other reason. A statement with no unambiguous predicate is more honest left unverified.
115
+
116
+ ## Where memories live
117
+
118
+ | Scope | Directory | Use it for |
119
+ |---|---|---|
120
+ | `project` (default) | `<projectRoot>/.memory/` | build tooling, layout, pitfalls of this checkout |
121
+ | `user` | `$DSH_HOME/context-memory/` | preferences that follow you across every project |
122
+
123
+ The project root is found by climbing to the nearest `.git` or `pnpm-workspace.yaml`, and
124
+ the session's own working directory is what the climb starts from — so a session opened in
125
+ `packages/app/src` and one opened at the root share a log. A directory with no marker above
126
+ it is its own project.
127
+
128
+ The session directory comes from the host's `workingDirectory` service when one is
129
+ mounted, and from the session header's `cwd` when it is not. The plugin does **not**
130
+ declare that service as a dependency: it is only mounted by hosts whose `dsh-base` bundle
131
+ includes it, and declaring it would leave the whole plugin `pending (waiting for service:
132
+ workingDirectory)` on a host that does not — which looks exactly like the plugin not being
133
+ installed.
79
134
 
80
135
  ## CLI
81
136
 
82
137
  ```sh
83
138
  dsh-context-memory remember "the file package.json exists"
139
+ dsh-context-memory remember "always answer in Chinese" --global
84
140
  dsh-context-memory list
85
- dsh-context-memory recall "how do we install"
141
+ dsh-context-memory recall "构建用什么"
86
142
  dsh-context-memory conflicts
143
+ dsh-context-memory candidates # statements noticed but not remembered
144
+ dsh-context-memory accept 4f2a9c1b8e30
145
+ dsh-context-memory dismiss 9c1e77b0d3aa
87
146
  dsh-context-memory forget 4f2a9c1b8e30 "moved to bun"
88
147
  dsh-context-memory verify # exit 1 only on a real violation
89
148
  ```
90
149
 
150
+ ## Model tools (pull)
151
+
152
+ Injection is one direction only. Two tools give the model the other one, so it can ask
153
+ rather than wait to be told:
154
+
155
+ | Tool | Arguments | Returns |
156
+ |---|---|---|
157
+ | `memory_recall` | `query?`, `limit?` (1–20, default 5), `include?` | Matching memories with id, kind, scope, and verification state |
158
+ | `memory_remember` | `text`, `kind?`, `subject?`, `scope?` | The new id, and the check that will re-verify it (or an explicit "none could be compiled") |
159
+
160
+ `memory_recall`'s `include` chooses the population: `guidance` (default — active and not
161
+ contradicted), `contradicted`, `superseded`, or `all`. The default matches what the push
162
+ path may offer, because unsolicited text and an answer to a question are not the same
163
+ thing; widening it is a per-call decision the caller makes by name. Omit `query` to list
164
+ the newest memories instead of the most relevant — "what do you remember" has no lexical
165
+ content to rank.
166
+
167
+ `memory_remember` writes with `source: 'agent'`, so the panel can tell what the user said
168
+ from what the model decided to write down. A statement already on file reports
169
+ `updated: true` with its original id and creation time, rather than claiming a new record.
170
+
171
+ Both tools are registered through `ctx.inject(['tools'])`, not a static dependency. A host
172
+ with no tool registry — the CLI, a headless profile — loses exactly these two tools and
173
+ nothing else; the pre-step, the service, and the panel keep working.
174
+
91
175
  ## Auto capture and recall injection (0.6.0+)
92
176
 
93
177
  The plugin hooks into `agent/pre-step` to close the loop automatically:
@@ -99,36 +183,79 @@ The plugin hooks into `agent/pre-step` to close the loop automatically:
99
183
 
100
184
  - **Recall injection.** Before each agent step, the top-5 memories whose subject
101
185
  overlaps the user's text are folded into the request as a collapsible context message
102
- (source `plugin/dsh-context-memory`, form `recall`). Unresolved conflicts are included
103
- with a warning. The injection position is right after the claimed batch — the same
186
+ (source `plugin/dsh-context-memory`, form `recall`). Contradicted memories go into a
187
+ separate block marked as not to be relied on, and unresolved conflicts are listed with
188
+ both sides. The injection position is right after the claimed batch — the same
104
189
  slot the host's own context plugins use.
190
+ - **Automatic re-verification.** At most once a day, the guards are re-run before the
191
+ recall is composed, so a memory the world has since disproved is never offered in the
192
+ very step that would have used it. Only a changed outcome is appended to the log.
193
+
194
+ - **Candidate proposals.** Statements that look like durable facts but were never asked to
195
+ be kept — "我们用 sqlite 存会话数据" with no "记住" — are proposed to the panel instead of
196
+ being written to the log. Accepting one turns it into a memory; dismissing it records
197
+ the answer so it is not asked again. See below.
105
198
 
106
199
  Both features can be toggled from the panel's 「集成」 card. When auto capture is off,
107
200
  memories are still stored via the CLI and service API. When recall injection is off,
108
201
  the plugin still records but does not inject.
109
202
 
203
+ ## Candidates: what the plugin noticed but was not allowed to keep
204
+
205
+ Explicit verbs are safe to act on because the user asked. Widening the patterns to
206
+ ordinary declarative sentences is not: the same shape produces "we use sqlite" and "we use
207
+ two queues in this diagram", and writing both to the log makes recall worse than the
208
+ narrow rule it replaced.
209
+
210
+ So the wide net **proposes** and does not write:
211
+
212
+ | Step | Where it lives | Effect on recall |
213
+ |---|---|---|
214
+ | A declarative sentence with a technical token is noticed | `.memory/candidates.jsonl` | none — it is not a memory |
215
+ | You accept it (panel, `accept <id>`) | `.memory/events.jsonl` | it becomes a memory, `source: 'user'` |
216
+ | You dismiss it (`dismiss <id>`) | stays in `candidates.jsonl` as dismissed | none, and it is not proposed again |
217
+
218
+ What the proposal rule requires, in order: not a question, not a directive to the agent,
219
+ at least six characters, and a token that looks like a name (`pnpm`, `@scope/pkg`, a path).
220
+ That last condition is what separates "we use pnpm" from "我们用两个队列" — the first names
221
+ something a future reader could act on, the second is ordinary conversation. At most three
222
+ are proposed per message; a message that trips more patterns is usually about something
223
+ else.
224
+
225
+ The queue is append-only and folded the same way the memory log is, so two processes
226
+ reading it agree on which questions are still open, and "we offered this and you declined"
227
+ stays answerable.
228
+
110
229
  ## Panel
111
230
 
112
- A `Context memory` page inside Settings: active and superseded memories, verification
113
- status per memory, conflicts, provenance, and integration toggles. The host registers
114
- `GET /api/context-memory.panel` and rebuilds the payload from the log on every request,
115
- so the panel shows what is on disk rather than what was in memory at startup. A headless
116
- host has no web connection and simply skips the route.
231
+ A `Context memory` page inside Settings: active, contradicted, and superseded memories,
232
+ verification status per memory, conflicts, provenance, and integration toggles. The host
233
+ registers `GET /api/context-memory.panel` and rebuilds the payload from the log on every
234
+ request, so the panel shows what is on disk rather than what was in memory at startup. A
235
+ headless host has no web connection and simply skips the route.
117
236
 
118
237
  ## Layout
119
238
 
120
239
  | Path | Role |
121
240
  |---|---|
122
241
  | `src/index.ts` | Host half — `apply(ctx)`, exposes the `contextMemory` service |
123
- | `src/memory.ts` | Fold the log, remember, supersede, conflicts, verify |
124
- | `src/store.ts` | Append-only `.memory/events.jsonl` |
125
- | `src/guard.ts` | Statement → platform-aware check; run it; classify pass / violated / broken |
126
- | `src/recall.ts` | Relevance ranking with an explainable score |
242
+ | `src/memory.ts` | Fold the logs, remember, supersede, conflicts, verify |
243
+ | `src/store.ts` | Append-only `.memory/events.jsonl` for one scope |
244
+ | `src/scope.ts` | Project-root discovery and the user-global directory |
245
+ | `src/guard.ts` | Statement → filesystem predicate; run it; classify pass / violated / broken |
246
+ | `src/recall.ts` | Relevance ranking with an explainable score; CJK 2-gram tokenizer |
127
247
  | `src/identity.ts` | Id derived from the normalised subject, so repeats fold |
248
+ | `src/capture.ts` | Pure distiller: explicit verbs in, statements out; wide-net proposals |
249
+ | `src/candidates.ts` | Append-only proposal queue — noticed, decided, never auto-remembered |
128
250
  | `src/cli.ts` / `src/bin.ts` | Command-line entry, usable without dsh |
251
+ | `src/tools.ts` | Model-facing `memory_recall` / `memory_remember` |
129
252
  | `src/routes.ts` | Panel and verify routes on the host's web connection |
130
253
  | `src/client/view.tsx` | Browser half — pure rendering, statically tested in Node |
131
254
  | `src/client/index.tsx` | Browser half — fetch and settings registration |
255
+ | `tests/activation.test.mjs` | Boots the plugin in a real cordis `Context` — the checks the rest of the suite cannot make |
256
+ | `tests/tools.test.mjs` | Runs both tools through a real `ToolRuntime` — registration, argument validation, output validation |
257
+ | `tests/candidates.test.mjs` | Proposals are questions: they never reach recall without an acceptance |
258
+ | `tests/routes.test.mjs` | The candidate route table on a real connection registration |
132
259
 
133
260
  ## Why the log is append-only
134
261
 
package/icon.svg ADDED
@@ -0,0 +1,16 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <rect x="5" y="6" width="26" height="24" rx="6" stroke="url(#memory-frame)" stroke-width="2.5" />
3
+ <path d="M10.5 12.5h15M10.5 18h15M10.5 23.5h8" stroke="#658EFF" stroke-width="2.5" stroke-linecap="round" />
4
+ <circle cx="26" cy="24.5" r="6" fill="url(#memory-check)" />
5
+ <path d="M23.2 24.4l1.9 1.9 3.7-3.8" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
6
+ <defs>
7
+ <linearGradient id="memory-frame" x1="5" y1="6" x2="31" y2="30" gradientUnits="userSpaceOnUse">
8
+ <stop stop-color="#41E1AC" />
9
+ <stop offset="1" stop-color="#658EFF" />
10
+ </linearGradient>
11
+ <linearGradient id="memory-check" x1="20" y1="18.5" x2="32" y2="30.5" gradientUnits="userSpaceOnUse">
12
+ <stop stop-color="#658EFF" />
13
+ <stop offset="1" stop-color="#41E1AC" />
14
+ </linearGradient>
15
+ </defs>
16
+ </svg>
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The candidate queue: statements worth offering, not worth remembering unasked.
3
+ *
4
+ * A capture rule can be generous or be safe. Explicit verbs ("记住…", "remember that…")
5
+ * are safe because the user asked; widening the patterns to ordinary declarative
6
+ * sentences is not, because the same shape produces "we use sqlite" and "we use two
7
+ * queues in this diagram". Writing both to the log would make recall worse than the
8
+ * narrower rule it replaced.
9
+ *
10
+ * So the wider rule produces a *question*. A candidate is proposed to the panel, and
11
+ * only a person turns it into a memory. The queue is append-only for the same reason the
12
+ * log is: "we offered this and you dismissed it" has to stay answerable, and a mutable
13
+ * queue would let a later run silently re-ask what was already declined.
14
+ *
15
+ * A dismissed candidate is remembered as dismissed rather than deleted, so the same
16
+ * sentence in the same session is not proposed again on every step.
17
+ *
18
+ * @module context-memory/candidates
19
+ */
20
+ import type { Candidate, CandidateState, MemoryKind } from './types.js';
21
+ /** What a caller proposes, before an id exists. */
22
+ export interface ProposeInput {
23
+ text: string;
24
+ kind?: MemoryKind;
25
+ sessionId?: string;
26
+ }
27
+ /** Enough of `ContextMemory` to answer "is this already remembered". */
28
+ export interface KnownMemories {
29
+ has(id: string): boolean;
30
+ }
31
+ export declare class CandidateQueue {
32
+ private readonly dir;
33
+ private readonly logPath;
34
+ /**
35
+ * The last parsed log, revalidated against the file's size and mtime on every read.
36
+ *
37
+ * Same reasoning as `MemoryStore`: the pre-step asks the queue one question per
38
+ * noticed statement (`propose` → `has`/`get`), and re-parsing an unchanged file for
39
+ * each of them is work that answers nothing new. A change on disk invalidates it, so
40
+ * two processes still agree.
41
+ */
42
+ private cached;
43
+ constructor(dir: string);
44
+ get path(): string;
45
+ private signature;
46
+ private append;
47
+ /**
48
+ * Fold the queue's log.
49
+ *
50
+ * The same shape as the memory fold, and the same reason: two processes reading one
51
+ * file must agree on which questions are still open. A decision naming a proposal that
52
+ * is not in the log is skipped rather than inventing a candidate.
53
+ *
54
+ * The returned array is shared with the cache: treat it as read-only.
55
+ */
56
+ all(): CandidateState[];
57
+ private parse;
58
+ pending(): CandidateState[];
59
+ get(id: string): CandidateState | undefined;
60
+ /**
61
+ * Offer a statement, unless it is already settled.
62
+ *
63
+ * Three suppressions, each for a different noise source: an active memory (the user
64
+ * already has it), a pending candidate (this step and the last one said the same
65
+ * thing), and a dismissed one (a question already answered — re-asking it every step
66
+ * is how a prompt becomes nagging).
67
+ */
68
+ propose(input: ProposeInput, known: KnownMemories): Candidate | undefined;
69
+ /** Record a decision. Returns false when there is no such open candidate. */
70
+ decide(id: string, status: 'accepted' | 'dismissed', at?: number): boolean;
71
+ }