dsh-context-memory 0.7.1 → 0.10.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 +312 -0
- package/README.md +151 -24
- package/icon.svg +16 -0
- package/lib/candidates.d.ts +71 -0
- package/lib/candidates.js +150 -0
- package/lib/capture.d.ts +21 -0
- package/lib/capture.js +86 -0
- package/lib/cli.js +88 -9
- package/lib/client/locales.d.ts +6 -0
- package/lib/client/locales.js +12 -0
- package/lib/client/view.d.ts +2 -0
- package/lib/client/view.js +18 -3
- package/lib/guard.d.ts +92 -30
- package/lib/guard.js +539 -73
- package/lib/identity.d.ts +16 -5
- package/lib/identity.js +36 -18
- package/lib/index.d.ts +35 -2
- package/lib/index.js +206 -16
- package/lib/memory.d.ts +131 -9
- package/lib/memory.js +296 -46
- package/lib/memory.web.js +48 -7
- package/lib/memory.web.js.map +2 -2
- package/lib/recall.d.ts +62 -8
- package/lib/recall.js +176 -16
- package/lib/routes.d.ts +1 -0
- package/lib/routes.js +50 -1
- package/lib/scope.d.ts +45 -0
- package/lib/scope.js +75 -0
- package/lib/store.d.ts +25 -2
- package/lib/store.js +45 -7
- package/lib/tools.d.ts +44 -0
- package/lib/tools.js +241 -0
- package/lib/types.d.ts +182 -3
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +25 -18
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
|
|
38
|
-
and guessing would be the unfounded assertion this plugin exists to
|
|
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
|
-
|
|
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 —
|
|
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
|
|
78
|
-
|
|
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 "
|
|
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`).
|
|
103
|
-
|
|
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,
|
|
113
|
-
status per memory, conflicts, provenance, and integration toggles. The host
|
|
114
|
-
`GET /api/context-memory.panel` and rebuilds the payload from the log on every
|
|
115
|
-
so the panel shows what is on disk rather than what was in memory at startup. A
|
|
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
|
|
124
|
-
| `src/store.ts` | Append-only `.memory/events.jsonl` |
|
|
125
|
-
| `src/
|
|
126
|
-
| `src/
|
|
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
|
+
}
|