dsh-anima 0.0.0-stage → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README_EN.md ADDED
@@ -0,0 +1,1000 @@
1
+ # dsh-anima
2
+
3
+ > 中文文档见 [README.md](./README.md)
4
+
5
+ The **Assistant Persona & External Memory Layer** for DSH — a standalone plugin: install it and it works.
6
+
7
+ It governs "**who you — the assistant — are**": the AI's own name and role, how you address it, its reply style, and any behavioural rules you write yourself; plus a **long-term memory layer with a pending-review buffer**, and UI capabilities (input-box light trail, input-box quick toggle, tool-confirmation gate). The field set depends on no other plugin and reads config from nowhere else.
8
+
9
+ > **Why the tool is named `set_assistant_persona`**: `tools.register()` throws outright on a same-level duplicate name
10
+ > (`dsh-tools/lib/index.js:2634`). Installed alongside any same-named tool, one of them necessarily fails to register
11
+ > and gets swallowed by `.catch` into a single `warn` line — that is a release-grade silent failure.
12
+ > Similar features do not require a name collision.
13
+
14
+ **It is not only about persona and memory.** This plugin also carries an **answer check layer**: before each
15
+ turn ends, code checks the shape of the answer against twelve fixed rules and, on a catch, inserts a notice
16
+ into the next message. The accompanying **ledger block** requires that whenever numbers, self-claims or
17
+ attribution appear, the answer states "what was checked / what was not".
18
+
19
+ The reason this layer exists: **a "self-check" written only into the prompt becomes a new surface for
20
+ performance** — the model will emit "checked, nothing wrong", and that sentence costs nothing. So the verdict
21
+ comes from code, and code can only judge **shape** (is there a source, is there a ledger block), never whether
22
+ the content is right. See "[Answer check layer](#answer-check-layer)" below.
23
+
24
+ ---
25
+
26
+ ## What it solves
27
+
28
+ | Capability | Detail |
29
+ |---|---|
30
+ | **The AI's own name and role** | `agentName` / `agentRole` — how it refers to itself and in what capacity it speaks |
31
+ | How the user is addressed / background / tone / length / language | `addressUserAs` / `userOccupation` / `userBio` / `style` / `replyLength` / `language` |
32
+ | **Two-level per-workspace overrides** | Only the declared fields are overridden; the rest inherit from global. The override file travels with the directory and can be committed |
33
+ | **Long-term memory** | Auto-capture → pending buffer → user adjudication → injection |
34
+ | Input-box light trail / quick toggle / tool confirmation | The UI and safety switches |
35
+ | Persona presets | 6 built-in + custom; the current config can be saved as a preset |
36
+ | **All twelve fields are free text** | It stores the exact sentence that gets injected, plus a "one-click fill + examples" button group |
37
+ | **Memory layered by workspace** | Global memory always applies; workspace memory injects only on a match |
38
+ | **Explainable injection / character budget** | Why each entry is or is not injected, constrained by both count and characters |
39
+ | **Local clustering and conflict hints** | Pure-local bigram similarity; hints only, never auto-merges |
40
+ | **Persona change audit log** | Who changed which field to what, and when |
41
+ | **One-click import from another AI** | Copy the prompt → paste the result → mechanical splitting → per-entry layer adjudication |
42
+ | **Memory metadata** | Type / lifecycle / review date / source — recording it must be visible, or it may as well not be recorded |
43
+ | **Memory checkup** | Only what a machine can verify: does the path still exist, has the review date passed. Produces candidates, never adjudicates for you |
44
+ | **Answer check (twelve rules)** | Each turn, **code** (not the model) checks the shape of the answer against fixed rules; a catch inserts a notice into the next message |
45
+ | **Ledger block (three-tier trigger)** | Whenever numbers / self-claims / attribution appear, "what was checked · what was not" must be written |
46
+ | Automatic writing of long-term memory | ❌ Deliberately not implemented (see below) |
47
+ | Conclusions such as "the check passed" | ❌ Deliberately unsupported (see the "Answer check layer" section) |
48
+
49
+ ### On the relationship with `某同类人设插件`
50
+
51
+ The capability grouping above **overlaps** the field set of `某同类人设插件` 0.7.0, and the implementation deliberately stays consistent with it (tool-confirmation semantics, light-trail animation parameters, presets preserved on reset). But this plugin **does not depend on it**: the field set is fully self-contained, it reads config from nowhere else, the two can be installed side by side, and all identifiers are kept apart. The full account is in [Acknowledgements and differentiation statement](#acknowledgements-and-differentiation-statement) below.
52
+
53
+ ## Four differentiating design choices
54
+
55
+ Feature parity does not mean the absence of independent judgement. The four points below are what this plugin **genuinely adds** over mainstream memory plugins; each one answers a concrete failure mode.
56
+
57
+ ### 1. Dual-constrained injection: count and characters both apply
58
+
59
+ Most implementations enforce only a "inject at most N entries" cap. The problem is that 40 long memories can blow up the context just as easily, while the user's perspective is "but I only set 40".
60
+
61
+ This plugin's `selectForInjection` is constrained by both `memory.maxInjected` (count, default 40) and `memory.maxInjectedChars` (characters, default 4000), **whichever is hit first** winning. The two caps serve different scenarios: count governs "don't make the model read too many fragments", characters govern "don't let a single injection eat half the context window".
62
+
63
+ ### 2. Explainable injection: see why each entry is or is not included
64
+
65
+ Injection is not a black box. The "Injection status" view in the settings page lists the fate and the reason for **every adopted memory**:
66
+
67
+ | Reason code | Meaning |
68
+ |---|---|
69
+ | `included` | Injected into the prompt |
70
+ | `other-workspace` | Belongs to another workspace; not injected in this session |
71
+ | `no-workspace-context` | The current session carries no workspace information, so workspace memories cannot be matched |
72
+ | `workspace-scope-unknown` | Flagged as workspace-scoped but without recording which workspace it belongs to |
73
+ | `over-max` | The number of adopted entries exceeds the injection cap; the newest by timestamp are taken |
74
+ | `over-budget` | This single entry alone exceeds the character budget and cannot fit |
75
+ | `dropped-by-budget` | Earlier entries have already consumed the character budget |
76
+ | `no-injection` | The memory layer is disabled or its cap is 0 |
77
+
78
+ Distinguishing `over-budget` from `dropped-by-budget` is intentional: the former means that entry should be deleted, the latter means room must be made — reporting the same code would leave the user unsure which one to act on. The reason text is delivered by the host (`INJECTION_REASON_TEXT` exists exactly once); the client never re-translates it.
79
+
80
+ ### 3. Local clustering: suggest only, never auto-merge
81
+
82
+ When several entries in the pending buffer are obviously about the same thing, the plugin computes similarity with the **Jaccard coefficient over character bigrams** (purely local, zero dependencies, zero model calls) and puts "these 3 look like the same thing" in front of the user.
83
+
84
+ **Auto-merging is deliberately not implemented**: merging means generating a sentence the user never said. This plugin's position is "let the model decide what is worth remembering, let the user decide what actually takes effect" — auto-merging violates exactly that. Clustering only makes adjudication faster; it never adjudicates on the user's behalf. By the same principle, "a candidate colliding with an already-adopted memory" is only reported as a conflict, without modifying any data.
85
+
86
+ ### 4. Persona change audit log
87
+
88
+ This plugin has three entry points that can change the persona: the settings page, the `/anima` command, and the model-invoked `set_assistant_persona` tool. And "tool confirmation" is **off by default** — meaning the model can change what it is called and in what tone it replies, with no way for you to know who did it and when.
89
+
90
+ The audit log records only "what was changed". It makes no judgements and takes no part in the prompt. It hangs off the **single** chokepoint `StateStore.updateConfig` rather than being instrumented separately at each call site: every write path ultimately goes through here, and scattered instrumentation will eventually miss one — and the one it misses may be precisely a silent rewrite by the model. `resetConfig` also goes through the audit — otherwise "it was fine yesterday and empty today" would leave no trace in the log.
91
+
92
+ The record cap is 200 entries, stored in a **separate table** (not stuffed into `config`): `config` is read into memory in its entirety on every startup, while the audit will grow to hundreds of entries — mixing them together would make startup cost rise with usage time. Long values are truncated to 200 characters on write with a "N characters total" note appended, and arrays/objects record only their size — the audit is an index, not a backup.
93
+
94
+ You can see it in two places: the always-visible "Change log" strip at the top of the settings page, and the **"Latest change" line** in `/anima show` — one line only, in the form `[MM-DD HH:mm] <source>: <field name>`, which **deliberately carries no value**: values can be private, and `show` output is exactly the kind of thing people paste into a chat when asking for help.
95
+
96
+ The settings-page strip sits right below the section tabs, so it is visible whichever section you are on. Collapsed it shows the last 3 entries (from the tail records shipped with `/state` — **no extra request**); clicking "expand" fetches the full list from `GET /api/anima/audit`. It used to be a collapsed card inside the "Advanced" section: invisible when the page opened, requiring a section switch, then one more click to expand — three steps away from being seen at all. The user's words were "I have no idea what actually got changed". A check entry point should not live somewhere only people who already know it exists can find it.
97
+
98
+ Field names are rendered as plain language in the UI (`agentName` → "My name", `memory.maxInjected` → "How many to carry"), mapped by `AUDIT_FIELD_KEY` in `client/index.mjs`. When a key has no mapping it **falls back to the raw key name** — showing an ugly truth beats hiding a change.
99
+
100
+
101
+ ## Acknowledgements and differentiation statement
102
+
103
+ This plugin's feature set was designed with reference to several persona plugins already in the same ecosystem (all MIT licensed),
104
+ and deliberately keeps behaviour consistent with them in several places (lazy storage resolution, fail-closed confirmation semantics, preserving user presets across a reset, and the light-trail animation parameters).
105
+
106
+ To be explicit:
107
+
108
+ - **All code is independently implemented.** No source code, comments, prompt wording, or graphic assets were copied from the aforementioned projects.
109
+ - Two **animation parameters are deliberately given identical values** (8 staggered stroke layers, dash length 12 / step 2.2 / corner radius 22, etc.). The reason is to avoid two divergent looks on the same machine. Such numeric values are ideas and parameters, not protected by copyright; they are recorded here so that readers do not mistake them for copying.
110
+ - **The navigation icon is deliberately drawn differently**: other persona plugins use "light-trail arc + spark", while this plugin uses "person (head + shoulders) + spark", in monochrome `currentColor`, with a separately tightened viewBox.
111
+ - **All identifiers are kept apart**, so that coexistence cannot displace or fail to register against the other:
112
+
113
+ | Category | Other plugins | This plugin |
114
+ |---|---|---|
115
+ | Tool name | their own same-named tools | `set_assistant_persona` |
116
+ | Settings page id | their own page ids | `anima-settings` |
117
+ | Light-trail slot id | their own slot ids | `anima-trail` |
118
+ | Toggle slot id | their own slot ids | `anima-quick-toggle` |
119
+ | Prompt section | the context they register | `anima:identity` / `:memory` / `:pending` |
120
+ | Storage domain | — (its own file) | `anima` |
121
+ | Injection source kind | their own prefix | `plugin:dsh-anima` |
122
+
123
+ Keeping tool names apart is not mere courtesy: `dsh-tools`' `register()` **throws outright** on a same-level duplicate name
124
+ (the `NamedEntries` conflict factory in `lib/index.js`). With both installed, one necessarily fails to register
125
+ and gets swallowed by `.catch` into a single `warn` line — a release-grade silent failure.
126
+
127
+ This section summarises the acknowledgement without naming individual projects — the published package
128
+ contains no third-party project names at all. Under `docs/` there are **read-only research notes on a dozen
129
+ further plugins in the same ecosystem** (mechanisms, line numbers, quoted pitfalls), with no code copied from
130
+ any of them; their repository URLs, SPDX licences and the date the figures were taken are recorded in
131
+ `docs/THIRD-PARTY-NOTES.md`. `docs/` is not listed in `package.json`'s `files`, so it **does not ship in the package**.
132
+
133
+ ## Core design: three-stage memory
134
+
135
+ This is where this plugin differs most from the 15+ memory plugins on the market.
136
+
137
+ ```
138
+ Delivered during conversation via the remember tool
139
+ ↓
140
+ Pending buffer (not in effect)
141
+ ↓ Nightly triage: dedup / eviction / comparison against adopted entries
142
+ ↓
143
+ Notify the user "N entries await your adjudication"
144
+ ↓
145
+ The user adopts / discards / deletes each one in the settings page
146
+ ↓
147
+ Adopted → injected into the prompt
148
+ ```
149
+
150
+ **Why not write silently and automatically**: once a memory is written silently it immediately affects every subsequent answer, and a bad write is very hard for the user to notice. This plugin gives "deciding what is worth remembering" to the model and "deciding what actually takes effect" to the user.
151
+
152
+ The triage step is **purely local and calls no model** — it only deduplicates, evicts, and compares against adopted entries; it performs no semantic summarisation.
153
+
154
+ Memory has **two layers**: `global` applies everywhere, while `workspace` is injected only when the normalised `cwd` matches exactly.
155
+ There is no "subdirectory inheritance" — workspace overrides use exact-match semantics, and if memory used inheritance semantics the user would see the parent directory's memory from a subdirectory, which is extremely hard to diagnose.
156
+ Beyond layering, the injection explanation, budget, and clustering are covered in "Four differentiating design choices" above.
157
+
158
+ ## Memory metadata: recording it must be visible
159
+
160
+ Besides its body, every memory carries four fields. They are **not a taxonomy hobby** — without them the
161
+ user can only choose to trust the whole store:
162
+
163
+ | Field | Values | What it decides |
164
+ |---|---|---|
165
+ | `type` | `fact` / `preference` / `stance` / `event` | Sets the decay window (see "Decay only affects how an entry is shown" below). A fact has window 0, so it never ages out; a stance is archived as "was true" when you change your mind |
166
+ | `state` | `current` / `superseded` / `contradicted` / `dropped` | Whether the injected line carries a "was true" / "contested" / "removed (tombstone)" tag |
167
+ | `verifyAt` | `YYYY-MM-DD` | Review date. **Only after it passes** does the injected line say "possibly outdated" — repeating it daily before then just manufactures noise |
168
+ | `sourceRef` | message id / `file:line` / URL | Why this entry should be believed. `user's own words@session-xxx#seq=11` and "assistant paraphrase" are two different levels of trust |
169
+
170
+ All four are **visible in the settings page** (badges, review date, source). Old memories that lack these
171
+ fields render **nothing at all** — falling back to "fact" would make the user think that is data when the
172
+ cell is in fact empty.
173
+
174
+ There are three ways to change them, deliberately not merged:
175
+
176
+ - `/anima mark <id> type=… state=… verifyAt=… sourceRef=… supersededBy=…`
177
+ - `POST /api/anima/memory/meta` (single) / `POST /api/anima/memory/meta/batch` (≤500 entries, per-entry receipt)
178
+ - The memory card in the settings page
179
+
180
+ `supersededBy=<id>` automatically sets `state` to `superseded` — otherwise the fact gets recorded while the
181
+ injected line still renders as current. `verifyAt` **rejects dates in the past**: a past date immediately
182
+ renders "possibly outdated", so the user thinks they set a reminder when they only stamped it expired.
183
+
184
+ ### Memory checkup: only what a machine can verify
185
+
186
+ `/anima checkup` (`POST /api/anima/memory/audit`) does exactly two things:
187
+
188
+ 1. Do the Windows paths written in the memory still exist (`fs.existsSync`)
189
+ 2. Has `verifyAt` passed (pure calendar, no semantics)
190
+
191
+ **It does not decide "whether a newer entry has overturned an older one".** That is a semantic judgement,
192
+ and a memory store that makes semantic judgements automatically will produce statements the user never made.
193
+ So the checkup produces **candidates**, each with a ready-to-paste
194
+ `/anima mark <id> state=contradicted`; scanning may be automatic, **adjudication must be a human click**.
195
+
196
+ Path extraction has a real trap: `D:\Windows Kits` is truncated by the regex at the space into `D:\Windows`,
197
+ which produces a false alarm saying "this path no longer exists". The fix is to accept a longer path **only
198
+ when that longer path really exists** on disk — a false alarm is worse than no check, because it teaches the
199
+ user to ignore the whole report.
200
+
201
+ ## Answer check layer
202
+
203
+ ### Why not "let the model check itself"
204
+
205
+ The cheapest approach is to write "please check yourself before answering" into the prompt. That route has
206
+ been shown not to work: **once a self-check exists only in the prompt, it becomes a new surface for
207
+ performance** — the model writes "checked, nothing wrong", and that sentence costs nothing.
208
+
209
+ So the division of labour is:
210
+
211
+ | Who | Does what |
212
+ |---|---|
213
+ | The prompt | Lays out the twelve rules and the ledger rules for the model to read (`anima:quality`, order 30) |
214
+ | Code | Reads the **already-persisted answer text** and judges shape against fixed regexes; on a catch, inserts a notice into the next message |
215
+ | The human | Judges whether the content is right — the part code can never do |
216
+
217
+ ### The twelve rules
218
+
219
+ | # | Rule | Who can judge |
220
+ |---|---|---|
221
+ | R01 | Asserting without checking (including pseudo-citations "according to …") | Machine judges shape, human judges content |
222
+ | R02 | Misreporting a check ("passed" with no command and output) | Machine judges shape, human judges content |
223
+ | R03 | Answering a different question / shifting the question | **Human only** |
224
+ | R04 | Filler | Machine judges density, human judges substance |
225
+ | R05 | Repeating a known mistake | **Human only** (needs a memory store and a conclusion log to compare) |
226
+ | R06 | Pretending to forget | **Human only** (the burden of proof is the user's) |
227
+ | R07 | Selective presentation | **Human only** (only you know what you did not hear) |
228
+ | R08 | Confidence mismatch | Machine judges shape, human judges content |
229
+ | R09 | Pretending to have time ("I'll keep an eye on it") | Machine judges, and almost never mis-fires |
230
+ | R10 | Self-contradiction | **Human only** (needs a conclusion log) |
231
+ | R11 | Straw man (attributing to the user words they never said) | Machine judges shape, human judges content |
232
+ | R12 | Overreach | Machine judges shape, human judges content |
233
+
234
+ **R03 / R05 / R06 / R07 / R10 can never be judged by a machine.** The code lists them separately as
235
+ `UNCOVERABLE_RULES` and states in every verdict that "these five were not checked" —
236
+ "not checked" is never reported as "nothing wrong".
237
+
238
+ ### The ledger block
239
+
240
+ Three mechanical triggers; on a hit, the answer must end with:
241
+
242
+ ```text
243
+ 【对账】
244
+ 查了:<what was actually done> → <command / file:line / link>
245
+ 未查:<what was not done but a reader might assume was>
246
+ ```
247
+
248
+ | Tier | Trigger |
249
+ |---|---|
250
+ | 1 | Concrete numbers / dates / ratios / versions (including "I think … about …" followed by a concrete number) |
251
+ | 2 | Self-claims such as "I checked / confirmed / verified / no problems / passed" |
252
+ | 3 | Attribution: "according to … / the docs say / officially / research shows / I remember you said" |
253
+
254
+ **Tier 3 matters most**: a wrong number you will go and verify, a wrong date you will go and verify,
255
+ but "according to a certain study" you almost never will — an assertion of that shape, once fabricated,
256
+ can hardly ever be caught.
257
+
258
+ The `未查` line **cannot be omitted**. A system that reports only what it checked will lie; a system that
259
+ must also report what it did not check makes lying far more expensive.
260
+
261
+ ### What the hook can and cannot do
262
+
263
+ **Can**: at the **start of the next turn**, discover a violation in the previous turn and insert a notice into
264
+ the next message. The implementation is `ctx.on('agent/pre-step', …)` plus
265
+ `agent.session.snapshotEvents()`, with the listener registered `prepend: true` and the whole body wrapped in
266
+ `try/catch` — the check is an enhancement and must never break the conversation loop.
267
+
268
+ **Cannot**:
269
+
270
+ - **It cannot stop the current turn.** By the time the answer is visible, it has already been sent.
271
+ - **It cannot judge whether the content is right.** Only shape (is there a source, is there a ledger block),
272
+ never "is this number correct".
273
+ - **It does not know whether the model complied afterwards.** The notice goes into the next message, and the
274
+ code cannot read whether the model took it.
275
+
276
+ One design lesson is worth recording: the reminder budget (`MAX_REMINDERS_PER_TURN`, default 2)
277
+ **was originally placed before "fetch the event, run the rules"** — so the budget suppressed the **check**,
278
+ not the **notice**. Those answers were never checked at all, and the statistics only left a vague
279
+ "budget exhausted N times". "Not checked" looked exactly like "checked and clean", which is precisely what
280
+ this project forbids. The order is now: **check first, then decide whether to speak**.
281
+
282
+ ### Do the notices actually work? Measured: no.
283
+
284
+ This must be written down, or the design above reads as "notices correct behaviour".
285
+
286
+ Measured from the session log, comparing only answers that **already tripped the ledger tier**
287
+ (the rest were never supposed to carry a block):
288
+
289
+ | Group | Wrote a ledger block |
290
+ | --- | --- |
291
+ | A quality notice preceded it | 10 / 15 = 67% |
292
+ | No notice preceded it | 14 / 24 = 58% |
293
+
294
+ An 8-point gap — still inside the noise, since a denominator of 15 flips on two answers.
295
+ Blunter still: the answers that wrote blocks most consistently (the long 2000+ character ones)
296
+ had **no** notice in front of them.
297
+
298
+ The number drifts as the session grows, so do not treat one reading as the conclusion. Re-measure with:
299
+
300
+ ```bash
301
+ node scripts/remind-effect.mjs
302
+ ```
303
+
304
+ The script states its own limits: temporal adjacency is not causation (it can only rule out "no effect
305
+ at all", never prove an effect), and "wrote a block" is not "wrote it correctly".
306
+
307
+ The measurement itself only stood up after three mistakes, each found the hard way:
308
+
309
+ 1. **Running the trigger check on raw text** → counted numbers inside inline code
310
+ (`` `actual reminders injected: 0` ``) as assertions. The hook calls `checkResponse`, which runs
311
+ `stripCodeBlocks` first and then strips quoted mentions. A probe that skips the real pipeline
312
+ produces fake readings.
313
+ 2. **Using the `checkResponse` R02 finding as the "should have a block" condition** → circular.
314
+ R02 *is* "should have a block but doesn't", so the set can never contain a written block; the
315
+ result is necessarily 0%. The condition must use only **input-side** facts (did the trigger fire),
316
+ independent of the output-side fact (was a block written).
317
+ 3. **Counting one answer once per notice when several notices pointed at it** → inflated denominator.
318
+ Deduplicate by answer first.
319
+
320
+ Conclusion: the hook works correctly (`checks` climbs, the chain is wired), but it **does not change
321
+ writing behaviour**. What makes ledger blocks appear is an explicit user request, not a machine verdict.
322
+
323
+ So do not treat notices as a correction mechanism. Their real function is **leaving a trace**: a
324
+ queryable record that "this answer once violated the rule", rather than an expectation that the next one
325
+ will be written correctly. Correctness comes from hard constraints in the prompt and the user calling it
326
+ out on the spot.
327
+
328
+ ### Run counters: so that "zero notices" is no longer ambiguous
329
+
330
+ `/api/anima/state` exposes `qualityStats`:
331
+
332
+ | Field | Meaning |
333
+ |---|---|
334
+ | `checks` | How many times the rules actually ran |
335
+ | `reminders` | How many notices were actually inserted into the conversation |
336
+ | `suppressed` | Findings that were caught but held back by the per-turn budget |
337
+ | `skipped` | Counts by reason for not running (`empty-text` / `already-checked` / `disabled` …) |
338
+ | `last` | The most recent seq, character count, findings, and uncovered rules |
339
+
340
+ Without it, "zero notices" has two opposite readings: (a) checked and clean, (b) never ran. The settings
341
+ card writes "none of this is running" outright when `checks === 0` — it is **not allowed to look as if
342
+ everything is fine**.
343
+
344
+ ## The patrol: it speaks only when there is news
345
+
346
+ Once a night (at `memory.nightlyHour`) it triages the buffer, runs the memory checkup, and then **decides whether to
347
+ say anything**. You can also run it by hand: the "Run patrol" button in the settings page, `/anima patrol`, or
348
+ `POST /api/anima/patrol` — all three use the **same criteria**. If the manual path could bypass them, you could never
349
+ observe what the scheduled path actually does.
350
+
351
+ The criteria are only the three things a machine can count (`lib/patrol.mjs`):
352
+
353
+ 1. The **set** of pending candidates changed (new candidates, or some adjudicated away).
354
+ 2. The **set** of flagged memories changed (dead path / review date passed).
355
+ 3. This triage actually touched data (deduplicated or evicted).
356
+
357
+ It compares **sets**, not counts: three candidates sitting unadjudicated for a week are pure noise if re-reported
358
+ nightly — the count did not change, so there is no new information. Two old ones replaced by two new ones keeps the
359
+ count identical but changes the fingerprint, and that *is* news.
360
+
361
+ ### Silence must be auditable
362
+
363
+ Speaking only when there is news has one hole that must be plugged on the spot: **silence means two things at once** —
364
+ "it ran and there was nothing" and "it never ran". This is the same problem the quality hook solved (`checks=0` has
365
+ two opposite readings).
366
+
367
+ The plug: **the report may be skipped, the record may not.** Every scheduled run writes a `meta.patrol` row, exposed as
368
+ `patrolMeta` in `/api/anima/state`:
369
+
370
+ | Field | Meaning |
371
+ |---|---|
372
+ | `at` / `day` / `reason` | When it ran, and whether scheduled or manual |
373
+ | `signature` | Fingerprint of this run's situation (set digest) |
374
+ | `reported` | Whether anything was actually delivered |
375
+ | `wouldReport` | Whether the criteria wanted to report (separate from `reported`, so you can see what blocked it) |
376
+ | `facts` | pending / flagged / triage-touched counts |
377
+ | `flaggedItems` | The checkup's findings **item by item** (id / one-line detail / evidence / kind); `null` when nothing was flagged |
378
+ | `flaggedDropped` | How many findings were cut by the 20-item cap (any non-zero value must be surfaced in the UI) |
379
+ | `reasons` | Why it decided as it did (`pending:3` / `flagged:1` / `removed:2`) |
380
+
381
+ `flaggedItems` keeps the first 20 findings, one paragraph each, and does not duplicate the memory text — the point of the
382
+ detail is to know which entry broke and to be able to act on it on the spot, not to store a second copy of the checkup.
383
+ The count that got cut is written into `flaggedDropped` and the UI must say "N more not listed": silently showing the
384
+ first 20 of 23 findings reports 23 problems as 1.
385
+
386
+ The line in the settings page has three distinct renderings and they **must look different**: no record = "has never
387
+ run — this is not 'all clear', it is 'not run'"; `reported: false` = "ran, judged nothing new, did not report";
388
+ `reported: true` = "ran, reported". Drawing "not run" as "all clear" is exactly the class of error this project exists
389
+ to prevent.
390
+
391
+ The report text itself carries three hard requirements (`patrolNoticeText`): list the findings, state **what was not
392
+ reported** this time, and give an exit you can recompute yourself (`GET /api/anima/memory/audit` or the checkup
393
+ button in the settings page).
394
+
395
+ ## Retrospective review: going back over old sessions
396
+
397
+ The worst memory failure is not "it remembered something wrong", it is **"it never wrote it down in the first place"** —
398
+ you said something three days ago and today it repeats the same mistake, so you have to complain all over again.
399
+ That cannot be fixed by watching every turn: measured over 925 real user messages and 937 turns on this machine, the
400
+ cheapest resident gate (a two-stage funnel) matched only 4 items (0.4%) on its first stage, while its scoring stage sent
401
+ 44.6% of all turns to review — either too strict or too loose, with no usable setting in between.
402
+
403
+ So this goes the other way: **do not touch the live conversation, go back over old sessions.**
404
+
405
+ `/anima retrospect` (`/anima 复盘`, `POST /api/anima/retrospect`, or the "Run a retrospect" button in the settings page)
406
+ scans the session logs of the last `memory.retrospectDays` days (default 14, range 1–365) and picks out sentences where
407
+ you were correcting, complaining, explicitly asking it to remember, or stating a prohibition. Once the switch is on it
408
+ also runs with the **nightly timer** and the **startup catch-up**, gated by its own switch and date (turning patrol off
409
+ does not stop it):
410
+
411
+ | Kind | What it looks for | Measured over 925 messages here |
412
+ |---|---|---|
413
+ | `complaint` | "I said this before / have I not said" — you are already angry, so something was not remembered | 2 (0.2%) |
414
+ | `explicit` | "remember this / don't forget" (English or Chinese) | 1 (0.1%) |
415
+ | `correction` | "wrong / you got it backwards / who told you to / what I said was", excluding "I clicked wrong" | 6 (0.6%) |
416
+ | `constraint` | "never / must not … always / every / all" — negated long-term constraints | 2 (0.2%) |
417
+
418
+ On the final 593 real messages this yields **9 leads, each with a traceable origin** (which session file, which event),
419
+ and a full scan of 53 sessions takes about 3 seconds (slowest single file 711ms).
420
+
421
+ Three hard rules:
422
+
423
+ 1. **It only produces leads, it never writes to the memory store.** Not one character — that would be words you never said.
424
+ Whether to record it, and as fact or stance, is your call in the pending area.
425
+ 2. **Whether or not there are leads, it writes a `meta.retrospect` row.** Same discipline as the patrol: reporting only
426
+ when there is news would make "silent" mean both "scanned, nothing found" and "never scanned". `/api/anima/state`
427
+ exposes it as `retrospectMeta`; on a failed run `files` is `null` rather than `0` — writing 0 would claim "scanned 0
428
+ sessions", which looks exactly like "scanned and found nothing".
429
+ 3. **Disabled by default.** It reads session logs on disk, which is a new opening, so it defaults to `false` and is turned
430
+ on in Settings → 灵台. While disabled the endpoint says plainly that it is off and writes no record — "you turned it
431
+ off" and "it ran and failed" are two different facts, and merging them would later make you read "last retrospect
432
+ failed" and think something broke.
433
+
434
+ ## Credential gate
435
+
436
+ Any content matching a credential pattern is **rejected before it enters the buffer** (never written to disk, leaving no trace), covering 11 pattern classes including private-key blocks, OpenAI/AWS/GitHub tokens, generic api_key / secret / password assignments, Bearer tokens, and credentials embedded in URLs.
437
+
438
+ The rationale: once content lands in the buffer it sits in a state of "waiting to enter the prompt", and credentials should not travel that path.
439
+
440
+ The credential check is mechanical matching over 11 regex classes — it has no semantic understanding and will produce false positives. So when an entry is blocked, the UI names the pattern that matched, and you can delete those characters and paste again.
441
+
442
+ ## Importing memories from another AI
443
+
444
+ The memory card in the settings page has an "Import memories from another AI" button. Expanded, it is two steps:
445
+
446
+ 1. **Copy the prompt** — send a profile-organising prompt to whichever AI you use most, and have it turn your preferences into a list. The button only shows "Copied" after the text really reaches the clipboard; with no clipboard API it says "copy failed, please select and copy manually" instead of pretending to succeed.
447
+ 2. **Paste the result** — the box accepts an AI's output, and also accepts notes you wrote yourself (it does not have to be AI-generated).
448
+
449
+ After parsing you get a per-entry preview. Every entry can have its layer changed (global / this workspace) or be dropped outright, and nothing is written until you confirm — then it lands in the **pending-review buffer**, the same path as manual additions and model submissions, and still requires per-entry adjudication before it takes effect.
450
+
451
+ **Splitting is mechanical and does no semantic interpretation**: it cuts on three boundaries — Markdown headings, list items, and blank lines — and deliberately does not split on full stops, because in a Chinese profile "偏好:结论先行。" split into two entries reads as nonsense. Section labels (such as "我的使用画像") do not become candidates: they look like memories but carry nothing you can adopt.
452
+
453
+ **Classification is a suggestion, not a verdict**: `suggestLayer` only reads wording — a concrete disk path or "this project / 本项目" suggests `workspace`; "all projects / always / 每次都要" suggests `global`. When neither is present it defaults to `global`, because treating "memories that keep failing to apply" as the default experience is worse than cross-project contamination. Every suggestion carries a one-line reason shown under the entry, so seeing "this workspace" immediately tells you why.
454
+
455
+ **Entries are triaged at parse time**: after splitting, every entry goes through five gates in order; whatever fails lands in a distinct bucket,
456
+ and every bucket is shown in the UI — **nothing is silently dropped**:
457
+
458
+ | Disposition | Test | In the UI |
459
+ | --- | --- | --- |
460
+ | Too short (`< 4` information weight) | The fragment is a numbering leftover | Only counted in the summary |
461
+ | Too long (`> 500` characters) | A single entry exceeds the memory cap | Not truncated; reported as "split it into several" |
462
+ | Credential | Matches one of 11 regex classes | Blocked whole, naming the class that matched |
463
+ | Duplicate | Identical text to an existing memory | Listed separately |
464
+ | Over the count limit | Beyond the per-import cap (default 60) | The remainder does not enter this batch; run another import |
465
+ | Near-duplicate | Similarity ≥ 0.6 with an entry already in the review buffer | Flagged under the entry as "already looks like this", **never auto-merged** |
466
+
467
+ The source text cap is 40000 characters, and exceeding it is an error rather than a truncation (truncating means quietly discarding the second half of the user's profile).
468
+ The near-duplicate hint deliberately only hints and never merges: auto-merging would produce a sentence the user never wrote.
469
+
470
+ ---
471
+
472
+ ## Installation
473
+
474
+ ### From npm (recommended)
475
+
476
+ Published to npm as `dsh-anima`. Install by package name:
477
+
478
+ ```
479
+ dsh plugin add dsh-anima@2.2.2
480
+ ```
481
+
482
+ Or edit the profile's `package.json` by hand:
483
+
484
+ ```jsonc
485
+ {
486
+ "dependencies": {
487
+ "dsh-anima": "2.2.2"
488
+ },
489
+ "dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
490
+ }
491
+ ```
492
+
493
+ `@2.2.2` can be any published version, or drop it entirely to install the latest. **Pin a version** —
494
+ otherwise a new upstream release replaces it on the next install.
495
+
496
+ Then `pnpm install` and **restart DSH**. Any host-side change requires a restart to take effect.
497
+
498
+ ### From GitHub (to follow unreleased changes)
499
+
500
+ To install a version not yet on npm, or track `main` directly:
501
+
502
+ ```
503
+ dsh plugin add github:Melody-cx/dsh-anima#v2.2.2
504
+ ```
505
+
506
+ Or edit the profile's `package.json` by hand:
507
+
508
+ ```jsonc
509
+ {
510
+ "dependencies": {
511
+ "dsh-anima": "github:Melody-cx/dsh-anima#v2.2.2"
512
+ },
513
+ "dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
514
+ }
515
+ ```
516
+
517
+ `#v2.2.2` is the tag (a commit SHA works too, or drop it entirely to follow `main`). **Pin a version** —
518
+ otherwise any upstream change lands in your profile on the next install. A git source downloads the
519
+ **whole repository tarball** (this one is small), so the first install may be slower than an npm source;
520
+ retry once if it times out.
521
+
522
+ Then `pnpm install` and **restart DSH**. Any host-side change requires a restart to take effect.
523
+
524
+ ### From source (for development)
525
+
526
+ Add the dependency to the profile's `package.json` (the `/plugins` UI works too):
527
+
528
+ ```jsonc
529
+ {
530
+ "dependencies": {
531
+ "dsh-anima": "file:<absolute path to the repository>"
532
+ },
533
+ "dsh": { "profile": { "bundles": [ /* ... */ "dsh-anima" ] } }
534
+ }
535
+ ```
536
+
537
+ A local `file:` dependency has **copy** semantics: after editing the source you must **reinstall** for it to take effect, and a re-run is skipped outright when pnpm decides
538
+ `changed:false` — so the correct sequence is `remove_bundle` followed by `install_bundle` (or compare the SHA256 of the `node_modules` copy against the source).
539
+
540
+ **Prerequisites** (already declared in `package.json`; check these first when installation fails):
541
+
542
+ - `engines.node` = `^22.19.0 || >=24.0.0`, `engines.dsh` = `>=0.1.6-0 <0.2.0 || >=0.2.0-0 <0.3.0-0`
543
+ - Four `peerDependencies`: `@deepseek-ai/cordis` (`^4.0.1 || ^4.0.5-alpha.1`),
544
+ `@deepseek-ai/dsh-llm`, `@deepseek-ai/dsh-storage-domain`, `@deepseek-ai/dsh-tools`
545
+ — these are provided by the host; the plugin does not bundle them
546
+ - `dependencies.zod` (`^4.4.3`) is the only runtime dependency actually installed
547
+ - The storage stack must be composed in the profile: `@deepseek-ai/dsh-storage` + `@deepseek-ai/dsh-storage-json` (with `root`)
548
+ + `@deepseek-ai/dsh-storage-domain` (with `backend: json`). Missing it does not crash anything, but the plugin degrades to
549
+ "storage unavailable" and the settings page states the reason (see the storage section below)
550
+
551
+ ## Configuration
552
+
553
+ The settings page gains a "**灵台**" (Persona & Memory) section (`settings.section` id = `anima-settings`, order 51).
554
+
555
+ ### What the settings page looks like
556
+
557
+ One page cannot hold all of it, so it is split into **four sections** behind a persistent tab strip at the top:
558
+ **Persona / Memory / Behavior / Advanced**.
559
+
560
+ - **Persona**: one-click presets, assistant identity, about you, reply style (presets come first — the first thing people usually do is apply an existing set and then tweak it)
561
+ - **Memory**: long-term memory (including pending proposals, the remembered list, the checkup, importing memory from other AIs, and injection status)
562
+ - **Behavior**: answer check, input-box light trail
563
+ - **Advanced**: workspace overrides, injection report
564
+
565
+ Pinned below the tabs there is also an always-visible **"Change log"** strip that belongs to no section — it stays put
566
+ whichever tab you are on. Collapsed it shows the last 3 entries ("which field went from what to what, by whom, when");
567
+ "expand" fetches the full list and is also the only place the clear button appears. It exists because of one sentence
568
+ from the user: "I have no idea what actually got changed". A check entry point should not live somewhere only people who
569
+ already know it exists can find it.
570
+
571
+ Clicking a tab only switches **which cards are visible**; the others are still rendered and merely get `display:none`.
572
+ This is deliberate: actually unmounting them would throw away unsaved form values and card collapse state, so a user who
573
+ peeks at Memory and comes back would lose whatever they had just typed.
574
+
575
+ The active section is stored in `localStorage` under `dsh-anima:active-section` (a bare string, not JSON), and the next
576
+ visit reopens on that tab. If the stored value is not one of the five (corrupted, or left over from an older version) it
577
+ falls back to the **first** section — an unrecognised string must never blank the page.
578
+
579
+ **A single save bar sits at the bottom**, always visible, and does not scroll away with the cards:
580
+
581
+ - the left half states the situation: "No unsaved changes" / "Unsaved changes" (in the warning colour when dirty)
582
+ - the right half has two buttons: "Reset" and "Save", with Save greyed out when there is nothing to save
583
+
584
+ This one entry point is the **only** save/reset control on the whole page. Previously the save buttons were scattered
585
+ across four cards for a total of six places, and four of them called the same function — the user had to work out *which
586
+ card am I on* before knowing which button to press, and after the section split that card may not even be on screen.
587
+ **Workspace overrides have their own dirty state** (they write a workspace file, not the global config), but from the
588
+ user's point of view both mean "I changed something and it is not on disk yet", so one bar reports and saves both.
589
+
590
+ The "Save overrides" / "Clear overrides" buttons stay on the card — that is a separate pair of workspace-only actions
591
+ with different wording, so it is not a duplicate.
592
+
593
+ - **Assistant identity** (`## 助手身份`): `enabled` / `agentName` / `agentRole` / `addressUserAs` / `userOccupation` / `userBio`
594
+ - **Reply style** (`## 回复风格`): `style` / `headingLists` / `emoji` / `tables` / `replyLength` / `language` / `customInstructions`
595
+ - **Workspace overrides**: `workspaceOverridesEnabled` / `workspaceFileDir`
596
+ The override file is written to `<workspace>/.dsh-anima/override.json` by default (the directory name is configurable through `workspaceFileDir`), travels with the directory, can be committed to git, and can be handed to someone else;
597
+ that directory is automatically added to `.gitignore` (with the content `*`), so that private tone settings are not pushed to a remote.
598
+ - **Input-box light trail**: `trailEnabled` / `trailColor` (`#RRGGBB`, normalised to uppercase on disk) / `trailSpeed` (`slow|normal|fast`) / `trailWidth` (`thin|normal|thick`)
599
+ - **Tool confirmation**: `requireToolConfirmation` — when enabled, the model calling `set_assistant_persona` only **submits a proposal** and does not write the config
600
+ - **Long-term memory**: `memory.enabled` / `autoCapture` / `nightlyTriage` / `nightlyHour` / `maxPending` /
601
+ `maxInjected` / `maxInjectedChars` / `notifyOnSessionStart` / `defaultLayer`
602
+ - **Answer check**: `quality.enabled` / `quality.ledgerLevel` (`off|three|five`) / `quality.strict` /
603
+ `quality.preStepCheck` / `quality.maxFindingsInReminder`
604
+
605
+ All five `quality.*` keys can be changed with `/anima set quality.ledgerLevel=five`, and all five appear
606
+ on the "Answer check" card in the settings page. The UI is not decoration: this layer **inserts messages
607
+ into your conversation on its own initiative**, so the user needs a kill switch that requires neither
608
+ hand-edited JSON nor a restart. When false positives pile up the right move is to turn off `strict`
609
+ (which only adds R08 / R11 / R12, the three partial rules) rather than the whole layer — but if there are
610
+ enough false positives that you want the layer off, **turning it off is correct**: a checker that cries
611
+ wolf is worse than no checker.
612
+
613
+ ### Card collapse: the default depends on whether you are here to change or to look
614
+
615
+ Every card on the settings page has a "Collapse / Expand" button in its top-right corner. **The default is not
616
+ uniformly open or uniformly closed** — it follows whether the card is there to **change** something or to **look**
617
+ something up:
618
+
619
+ - Cards you come here to change open by default: identity, about you, reply style, presets, input trail, workspace overrides, long-term memory, answer check
620
+ - Cards you come here to read stay closed: what the assistant actually sees
621
+
622
+ The reasoning comes straight from the user: "every normal human opening these settings is here to change the thing
623
+ the setting is about, e.g. the persona tab to change the persona". A card that is closed by default should not be
624
+ the first thing the user wants to touch, and a card that is open by default should not be a read-only list.
625
+
626
+ In code it is a one-line table: `const DEFAULT_COLLAPSED = { mem: false, preview: true }`.
627
+ Any card not listed opens — a newly added card being visible beats being hidden.
628
+
629
+ **The long-term memory card itself opens by default**, because its switches, caps and the nightly tidy all live there —
630
+ that is "something you change". Inside it are three independent panels, each with its own toggle, unaffected by the
631
+ card's collapse state:
632
+
633
+ - the "Remembered (N)" list: **no toggle at all**, rendered inline
634
+ - "Injection status": closed by default (`state.showInjection: false`); clicking it is what fetches the data
635
+ - "Import external memories": closed by default (it only opens when `memoryImport.open` is `true`)
636
+
637
+ That is two levels — the card opens, the panels toggle — not three. Wrapping the panels inside a collapsed card too
638
+ would make it three clicks deep and *harder* to reach, not tidier.
639
+
640
+ **Once the user flips it by hand, the user wins.** The toggle records the state **after** the flip rather than
641
+ "unsetting" it — deleting the key means "back to the default", which only looks like a flip when the default happens
642
+ to be the opposite. The intent behind pressing the button is always "flip it". So flipping back to the default state
643
+ is **written down** as well (the value is `false`, not a deleted key):
644
+
645
+ - a preview card that is closed by default, once expanded by hand, records `preview: false` and stays expanded next visit
646
+ - a card that opens by default, once collapsed by hand, records `mem: true` and stays collapsed next visit
647
+
648
+ The state lives in `localStorage` under `dsh-anima:card-state`, shaped like `{"preview":false,"mem":true}`.
649
+ That key **only records cards the user changed by hand**; untouched cards take no entry, and their default comes from
650
+ `DEFAULT_COLLAPSED` in the code, so adding a new card later needs no migration — as long as its default is open it is
651
+ simply visible.
652
+
653
+ Dirty values are discarded: anything that is not a boolean (`0`, `"yes"`, a nested object) is treated as unset and
654
+ falls back to the card default — one hand edit of `localStorage` must not make a card permanently unopenable. The key
655
+ was renamed from the older `dsh-anima:collapsed-cards` to `dsh-anima:card-state`: the old key recorded "collapsed",
656
+ and under the new rule "collapsed" is the default state, so keeping the old key would leave cards that were manually
657
+ expanded before this version permanently collapsed.
658
+
659
+ **Collapsing affects only that card's content, never its form values.** While collapsed the `children` are not
660
+ rendered, but form values live at the settings-page top level, so collapsing cannot lose what you just typed. Section
661
+ switching uses `display:none` rather than unmounting; the two mechanisms differ, but the outcome is the same: peek at
662
+ Memory, come back, and whatever you had edited is still there.
663
+
664
+ The two ways of hiding things have **opposite** semantics, and a code comment says so to stop a later reader from
665
+ unifying them: a card's `collapsed` renders nothing at all, while section switching's `hidden` is `display:none`.
666
+ The first is a deliberate collapse by the user (the card header has to stay visible so they can expand it again);
667
+ the second is a tab switch, which must keep the tree or every unsaved form value is lost.
668
+
669
+ ### No migrations at all
670
+
671
+ This plugin **reads no config belonging to another plugin, nor to an earlier version of itself**. It released 1.0.0, and every release from 1.0.0 through 2.2.1 uses exactly the same storage format (`DOMAIN_VERSION` is 1 in both, with identical specs for the config / memories / meta / audit tables) — there is no "existing user's medium" to stay compatible with, so the code carries neither one-shot migrations nor storage-format fallback. Both existed during development and were deleted; the only thing keeping them would achieve is making later readers believe a population of legacy users exists.
672
+
673
+ If a storage format bump ever becomes necessary: change only `DOMAIN_VERSION` in `lib/store.mjs`, and **decide first what happens to existing users' media** — that decision must not be improvised after release, because by then the media is already on their machines. For the shape of such a solution, see `某同生态插件` in the same ecosystem (dual-spec serial fallback).
674
+
675
+ ### All twelve fields are free text
676
+
677
+ The six fields `style` / `headingLists` / `emoji` / `tables` / `replyLength` / `language` are **no longer dropdowns**;
678
+ what they store is the very sentence to be injected into the prompt. Before the change they were enums (`style: 'roast'`); after it,
679
+ `style: '毒舌吐槽;直白指出问题、不留情面,但始终对事不对人。'` — write it however you like,
680
+ as long as you stay within the field cap (`agentName` / `addressUserAs` / `userOccupation` 50 characters each, `userBio` 500,
681
+ `agentRole` / `style` 800 each, `headingLists` / `emoji` / `tables` / `replyLength` / `language` 300 each,
682
+ `customInstructions` 2000 — see `FIELD_LIMITS` in `lib/config.mjs` for the exact per-field values).
683
+
684
+ Leaving a field empty is a legitimate state, and **an empty string means "say nothing about this" in the prompt**: the model uses its own judgement, instead of having its attention consumed by "please handle headings in the default manner". This is also how the `default` level of the three traits is implemented — it is not an enum whose value is `'default'`, but an empty string.
685
+
686
+ An empty input box discourages people, so every field has a row of buttons beneath it:
687
+
688
+ | Button group | Source | What clicking does |
689
+ |---|---|---|
690
+ | **One-click fill** | `WORDING_TABLES` in `lib/wording.mjs` | Fills in a **finalised sentence** (the sentence of the original level); you can keep editing afterwards |
691
+ | **Example** | `FIELD_EXAMPLES` in `lib/wording.mjs` | Fills in a passage of **first-person text that is obviously someone else's**, for you to rewrite as your own |
692
+
693
+ Both groups are delivered to the settings page by `GET /api/anima/state`; the client **does not keep its own copy of the wording table** — otherwise, changing the host and forgetting the client would put two different phrasings on screen, while the one actually injected is the host's.
694
+
695
+ `agentName` deliberately **gets no example**: a button that fills in a name is very likely to be left unchanged by the user, leaving the AI permanently using a name someone else chose; this field relies on a placeholder to indicate the format instead. The `default` level likewise generates no button — turning an empty wording into a button would suggest that clicking it yields something, when in fact it only clears the input box.
696
+
697
+ **Backward compatibility with old configs**: upgrading does not mean "quietly reverting the user's style to the default". `migrateConfig` automatically translates the level keys stored by older versions
698
+ (`'professional'` / `'more'` / `'default'` …) into the corresponding wording, so the text injected into the prompt is unchanged character for character;
699
+ sentences the user wrote themselves do not match any level key and are preserved as-is.
700
+
701
+ `requireToolConfirmation` and the four `trail*` fields **deliberately do not take part in workspace overrides**: one is a safety switch and the others are UI preferences (and they must be closed sets — they are keys handed to CSS). Neither should be silently swapped just because "you changed workspace".
702
+
703
+ `maxInjectedChars` (default 4000, in characters) and `maxInjected` (default 40 entries) **take whichever comes first**:
704
+ with a count constraint alone, 40 long memories can still blow up the context. The two `0` values do **not** mean the same thing:
705
+
706
+ | Field | Meaning of `0` |
707
+ | --- | --- |
708
+ | `maxInjectedChars` | No character limit (only the count cap applies) |
709
+ | `maxInjected` | **Inject nothing at all** — this also disables every memory injection; use `memory.enabled` to pause memory |
710
+ `defaultLayer` decides whether new memories are recorded as `global` or `workspace`; the default of `global` is deliberate —
711
+ treating "memory keeps losing an entry" as the default experience is harder to diagnose than cross-workspace contamination.
712
+
713
+ The remaining 12 fields can be overridden per workspace, and the override is **field-level** — a workspace declares only the fields it cares about and inherits the rest from global.
714
+
715
+ Config persistence: **only** through `storageDomain` (domain `anima` — note the underscore; the host's domain-name validation accepts only `^[a-z][a-z0-9_]*$`, so a hyphen is rejected).
716
+
717
+ **Lazy resolution** (matching `某同生态插件`'s approach): the service is not resolved during `apply`, but only via `ctx.get('storageDomain')` on first real use.
718
+ `dsh-storage-domain`'s `apply` body is `ctx.inject(backendServices, …)` plus `provide('storageDomain', …)` inside the callback,
719
+ where `backendServices` comes from `storage.backend.json` (provided by `storage-json`). If this plugin loads before `storage-json`,
720
+ that single `ctx.get()` during `apply` is bound to return `undefined` — and this is precisely the root cause of the silent storage-backend degradation on the 2026-10-10 cold start:
721
+ the older version degraded to the file backend after a null check, and also **memoised that outcome permanently**, so the user's config sat in the old file and could not be read.
722
+
723
+ The contract now: a missing service ⇒ throw `StorageUnavailableError('storage-not-composed')`, and **never switch persistence locations**;
724
+ failures are **not** memoised (so a later call succeeds once the service becomes ready); only success caches the domain handle.
725
+ When `index.mjs`'s `apply` hits it on the first `reload()`, it attaches cordis' native readiness signal
726
+ `ctx.inject(['storageDomain'], …)` and retries after the service is provided (while a dependency is not ready the fiber stays PENDING, and the callback fires the moment the service is provided — verified in this repository with a real cordis probe). During that window the five sections return an empty string — better not to inject at all than to impersonate the default level.
727
+
728
+ **No old-data rescue**: when `storageDomain` becomes available depends on load order, so the same person may have written config to a different landing spot in the early days. But every release from 1.0.0 through 2.2.1 shares the same storage format (DOMAIN_VERSION is 1, the same four tables), so there is no orphaned medium to rescue — so opening the domain imports nothing and archives nothing; it just opens its own domain. If a migration ever does become necessary, the only safe shape is: act **only when the new domain has no `config` record**, and let every failure inside it be a `warn` rather than a throw (this code sits inside the domain-open `try`, so a throw would be wrapped as the *new* domain's `open-failed` and stall a plugin that should work fine).
729
+
730
+ **The unavailability reason is inspectable**: the `source` section of `GET /api/anima/state` carries `reason` (an error code) and `message` (the raw error),
731
+ with values `storage-not-composed` / `vocabulary-unavailable` / `open-failed: <raw error>`;
732
+ on the normal storage path both are `null` and `kind === 'storage'`. The settings page turns this into plain language and shows it below "Storage location".
733
+ When storage is unavailable that route returns **503 + `code`** rather than an empty 500 shell — the settings page needs it to distinguish "the plugin isn't running" from
734
+ "it is running but the storage stack isn't composed": the former calls for a reinstall, the latter is fixed by adding the three lines `storage` / `storage-json` / `storage-domain`.
735
+
736
+ **A single format stamp, with no fallback**: the `single` layout of `storage-json` **hard-compares** the version stamp when opening —
737
+ `unit.version !== descriptor.version` throws `version-mismatch` outright, with no automatic migration whatsoever. This plugin's domain version is **always 1**
738
+ (`DOMAIN_VERSION` in `lib/store.mjs`) and declares exactly one spec with no serial fallback: 1.0.0 through 2.2.1 carry the same format stamp, so there is no such thing as
739
+ "an existing user's v2/v3 medium" to read.
740
+
741
+ During development there was a compatibility fallback (`open()` attempted **serially** over `[v3, v2, v1]`, continuing only on `version-mismatch`);
742
+ it was deleted together with the unreleased v2/v3 formats. The lesson from that code is kept here because it will be needed the day the format does change:
743
+ **declaring `compatibleVersions` amounts to writing nothing** — it is read only by `acceptedStamps()` of the per-record layout, and the single layout's
744
+ `parse()` does not look at it at all. Genuine multi-version support means declaring **two specs** the way `某同生态插件` does (primary + tolerant),
745
+ and the tolerant spec must declare **all four tables** — `DomainImpl.table()` throws outright for an undeclared table, and missing one blows up at the next
746
+ `table('audit')` call.
747
+
748
+ **Two numbering schemes — do not confuse them**: the plugin's own version is the semver in `package.json` (`2.2.1`, the badge in the top-right of the settings page);
749
+ the storage format stamp is a single digit (currently 1). The two are unrelated. Wherever the format stamp appears in the UI it is written "storage format vN",
750
+ and wherever the plugin version appears it carries a decimal point — mixing them as "this build's v3" leaves the user unable to tell which number means their
751
+ installed plugin is out of date. The zero-reference `CONFIG_VERSION = 3` in `lib/config.mjs` was deleted for exactly that reason: to avoid manufacturing a second "v3".
752
+
753
+ **Two numbering schemes — do not confuse them**: the plugin's own version is the semver in `package.json` (`2.2.1`, the badge in the settings page header), while the storage format stamp is a single digit (1 / 2 / 3). The two are unrelated. Everywhere the format stamp appears the UI says "storage format vN", and everywhere the plugin version appears it carries a decimal point — mixing them as "this build is v3" leaves the user unable to tell which number is telling them their plugin is old. The unreferenced `CONFIG_VERSION = 3` in `lib/config.mjs` was removed precisely so a second "v3" is not manufactured.
754
+
755
+ ## Prompt sections
756
+
757
+ | section name | order | Content |
758
+ |---|---|---|
759
+ | `anima:identity` | 10 | The assistant's own name and role (workspace-overridable; evaluated live at assembly time) |
760
+ | `anima:memory` | 20 | Adopted long-term memories |
761
+ | `anima:pending` | 21 | Notice of how many entries await review (**count only, no content**) |
762
+ | `anima:remember` | 22 | When to call the remember tool and which fields it takes (without this the model does not know the entry point exists) |
763
+ | `anima:quality` | 30 | The twelve rules plus the three-tier ledger rules (this whole section disappears when `quality.enabled` is off) |
764
+
765
+ Deliberately placed before `PLAN_POLICY` (500): persona is "who I am", and it should be established before behavioural policy.
766
+
767
+ ## Commands and tools
768
+
769
+ - `/anima [show|pending|adopt <id>|discard <id>|mark <id> type=… state=…|triage|patrol|retrospect|checkup|audit|name <名字>|enable|disable|set <key>=<value>|confirm|reject|reset]`
770
+ - Tool `remember(text, note?, scope?, type?, sourceRef?, verifyAt?)` — delivers a candidate to the pending buffer
771
+ - Tool `set_assistant_persona(...)` — lets the model change the assistant persona; with "tool confirmation" enabled it only records a proposal,
772
+ which takes effect only after the user runs `/anima confirm` or clicks "Confirm" in the settings page
773
+
774
+ Four commands are easy to confuse; they are distinguished by **what they inspect**:
775
+
776
+ | Command | What it inspects |
777
+ |---|---|
778
+ | `/anima show` | The plugin's state right now (config, storage, injection, check counters) |
779
+ | `/anima audit` (`/anima 检查`) | Whether **my last answer** broke any of the twelve rules |
780
+ | `/anima checkup` (`/anima 体检`) | Whether **what has been recorded** still holds (paths / review dates) |
781
+ | `/anima patrol` (`/anima 巡夜`) | Runs a full patrol and states on the spot why it stayed silent or what it reported |
782
+ | `/anima retrospect` (`/anima 复盘`) | **Scans old sessions** for things you said that were never written down |
783
+
784
+ The first two inspect "what I said"; the third inspects "the data in the store"; the fourth decides whether to report to you;
785
+ the fifth looks back at old sessions and only produces leads. The first three change no state at all; a patrol does run triage
786
+ (it may touch the buffer), a retrospect reads session logs on disk, but neither ever makes a decision for you.
787
+
788
+ `/anima show` is a read-only diagnostic command: **when the storage stack is not composed, it is precisely the command that most needs to work** — it is how the user learns
789
+ "the plugin is here, it's storage that isn't connected", so it tolerates load failure and prints the reason together with the "the above are built-in defaults" notice.
790
+
791
+ ## UI mount points
792
+
793
+ | Slot | id | order | Content |
794
+ |---|---|---|---|
795
+ | `settings.section` | `anima-settings` | 51 | The "灵台" settings page |
796
+ | `conversation.input.left` | `anima-quick-toggle` | 51 | The "persona on / persona off" quick toggle at the left of the input box |
797
+ | `conversation.input.overlay` | `anima-trail` | 51 | The input-box light trail (lights up only while the assistant is running) |
798
+
799
+ All three ids are kept apart from their `某同类人设插件` counterparts: `settings.section`'s documentation states that "reusing an existing id replaces that cell",
800
+ so a collision would displace the other's settings page.
801
+
802
+ **The settings navigation icon** is implemented by DOM replacement (constant prefixes `data-an-settings-nav` / `data-an-icon-replaced`):
803
+ the shell's navigation icon is a hard-coded id → icon mapping, and a plugin cannot declare its own icon through a slot.
804
+ This is a nice-to-have capability, so the code checks at the entry point whether `document.querySelectorAll` exists and, if not, takes over nothing at all
805
+ — the whole client must never fail to load just because an icon cannot be drawn.
806
+
807
+ **Why the light trail carries three self-guards**: it hangs off the input-box card, and a miscalculated size or position could feed back into the host's layout,
808
+ so the implementation has three gates — an inline `cssText` that pins 12 key properties (against third-party stylesheet overrides),
809
+ `disable()` outright when a dimension exceeds `TRAIL_MAX_SIDE` (against size positive feedback), and a post-mount self-check on
810
+ `getComputedStyle(svg).position === 'absolute'` (against style overrides).
811
+
812
+ ## Exposed service
813
+
814
+ ```js
815
+ const svc = ctx.get('animaConfig')
816
+ await svc.getEffective() // the effective persona after merging global + the current workspace
817
+ await svc.getConfig()
818
+ await svc.updateConfig(patch)
819
+ await svc.resetConfig() // same path as the settings page's "Restore defaults", and it preserves user presets too
820
+ await svc.getSystemPrompt(workspace) // the persona text the model actually sees right now (aligned with all five sections)
821
+ await svc.listPending()
822
+ await svc.runTriage()
823
+ ```
824
+
825
+ `getSystemPrompt(workspacePath)` gates exactly like the `anima:identity` / `anima:memory` / `anima:pending` /
826
+ `anima:remember` / `anima:quality` **five** section functions — otherwise this service would answer with text the **model cannot actually see**,
827
+ which is worse than not offering it at all. (This really was missed once: when the quality section was added only
828
+ `registerSystemPrompt` was updated, and `getSystemPrompt` plus `/api/anima/preview` were forgotten, so the preview did not show the
829
+ rules the model would actually receive. Two places made the same mistake independently — which means this kind of "one piece of text
830
+ with several exits" must be locked by tests, not by memory.)
831
+
832
+ ## Development
833
+
834
+ These scripts run **in the source repository** (`scripts/` and `pnpm-lock.yaml` are not part of the npm release —
835
+ the published package holds 20 files: `index.mjs` / `lib` (12 `.mjs` files) / `client/index.mjs` / `assets/icon.svg` / `cordis.patch.yml` / `package.json` /
836
+ `LICENSE` / `README.md` / `README_EN.md` — that number comes from `npm pack --dry-run`, not from counting by hand):
837
+
838
+ ```bash
839
+ node scripts/verify-core.mjs # pure-function regression (188 cases)
840
+ node scripts/smoke-host.mjs # host-side smoke test (100 cases; runs apply for real with a fake ctx)
841
+ node scripts/check-client-i18n.mjs # client dictionary completeness + zh/en symmetry (zh=en=316 keys)
842
+ node scripts/smoke-client.mjs # client render smoke test (52 cases; fake __ModuleLoader__ + the real hooks runtime)
843
+ node scripts/acceptance.mjs [--url <host>] # acceptance: every suite + live state + source/install SHA256 parity
844
+ node scripts/check-hook.mjs [session file] [--since <ISO>] # quality-hook liveness (ALIVE / DEAD / UNDETERMINED)
845
+ node scripts/remind-effect.mjs [session file] # measure whether notices change writing behaviour (noisy)
846
+ node scripts/audit-response.mjs --text-file <file> # judge a text against the twelve rules (for humans)
847
+ node scripts/trace-sources.mjs [--apply] # trace memory sources (LCS matching, not exact matching)
848
+ node scripts/backfill-types.mjs [--apply] # batch backfill of memory type / verifyAt
849
+ node scripts/check-live.mjs # live acceptance (older; superseded by acceptance.mjs)
850
+ ```
851
+
852
+ The five verdict scripts each have an honest boundary; do not conflate them:
853
+
854
+ - **UNDETERMINED from `check-hook.mjs` is not a pass.** Without a `--since` boundary it cannot tell "answers written under
855
+ the old code" from "the new code should have caught it but did not", so it refuses to rule — **unknown is not broken**.
856
+ Only injected messages carrying the `[dsh-anima 质检]` prefix count as evidence; triage notices do not
857
+ (an earlier version counted every anima-injected message as a notice, so an unrelated background notice could flip the
858
+ verdict to ALIVE).
859
+ - `remind-effect.mjs` ratios **drift as the session grows**, and the denominator is usually only one or two dozen, so two
860
+ answers can flip the sign. It can only rule out "no effect at all"; it cannot prove an effect, and "wrote a block" is not
861
+ "wrote it correctly".
862
+ - `audit-response.mjs` is for humans; the plugin itself **does not call it**. Its exit codes 0/1/2 mean clean / findings /
863
+ unreadable input, and `--json` gives a machine-readable verdict.
864
+ - `trace-sources.mjs` uses the **longest common substring** (threshold 10 characters) rather than exact matching — the memory
865
+ store holds a rewritten, compressed version, so a byte-identical match would never succeed. Its output has three tiers:
866
+ `user's own words` / `assistant paraphrase (original not found)` / `unverifiable`. **"Assistant paraphrase" does not mean
867
+ false**, only that no original could be found to check against.
868
+ - `acceptance.mjs` always prints what it did **not** verify at the end. That section is not a disclaimer but a list for you to
869
+ audit against — everything listed there is precisely what it did not prove.
870
+
871
+ The first five run out of process and can only prove "the code is written this way". **`acceptance.mjs --url` and `check-hook.mjs`
872
+ prove "the copy of the code in the process actually takes effect"** — there has been a case where the installed copy was stuck on an
873
+ old version and not one of the newly added routes was registered, while every unit test was still green; and an earlier case where
874
+ the unit-test fixture copied the wrong event shape, so the hook always received an empty string and the tests were still green.
875
+ **A fixture must copy reality, never copy the implementation.**
876
+
877
+ The assertions in `check-live.mjs` deliberately **do not pin specific values**: the name is your asset, and what the script guards against is "the data is gone",
878
+ not "you renamed it". Likewise, for contract changes such as "which fields the model may change", the assertion counts entries rather than enumerating their content;
879
+ route counts work the same way — it names the key routes and checks they are registered, but **does not pin a total**, otherwise every new route would mean coming back to edit that number.
880
+
881
+ `verify-core.mjs` and `smoke-host.mjs` both resolve the host's built-in packages
882
+ (`@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-storage-domain`) to local stand-ins through `scripts/tools-loader.mjs` — those packages exist only inside the host's
883
+ asar and cannot be resolved outside DSH; once installed into a profile the real packages are used instead, which `check-live.mjs` covers.
884
+
885
+ ## Route guard
886
+
887
+ This plugin's HTTP routes read and write persona configuration and memory content, while the host webserver **carries no authentication of its own**
888
+ (`@deepseek-ai/dsh-host-webserver`'s README, verbatim: *the server carries no TLS,
889
+ authentication, or origin policy of its own*), and the default configuration may be bound to `0.0.0.0`.
890
+ Each route therefore adds its own guard (`index.mjs:467` onwards), with three gates — failing any one yields 403:
891
+ a **non-loopback origin** is always rejected; a request carrying `Sec-Fetch-Site` whose value is not `same-origin` is rejected (browsers send this header on cross-site requests, it takes effect earlier than `Origin`, and it also covers cases such as `no-cors`); and a request carrying `Origin` must additionally have the http(s) protocol, a loopback host, and an `Origin` exactly equal to `Host`.
892
+ **A loopback request without `Origin` is allowed through** — local scripts (curl / PowerShell) fall into this category, and this is deliberate: the guard blocks **browser cross-site** requests, not local users. The approach matches the ecosystem
893
+ (`@michengai/dsh-skills-manager` and `@linxin666/dsh-remote-web-ui` use comparable implementations).
894
+
895
+ ## Confirmation for destructive actions
896
+
897
+ `POST /api/anima/config/reset` requires the request body to carry an explicit `{"confirm": true}`; an empty body always yields
898
+ **400 + `code: "confirmation-required"`**, without touching a single field. The "Restore defaults" button in the settings page first shows a confirmation dialog and only carries that field once it is accepted. The semantics match `某同生态插件`'s fail-closed behaviour:
899
+ without an unambiguous confirmation, do nothing — and explain what would make the request valid.
900
+
901
+ A reset **preserves user-created persona presets** — behaviour aligned with `某同类人设插件`'s 重置时保留用户自建人设的函数;
902
+ presets are assets the user accumulated one by one, and resetting tone settings should not wipe them out as well.
903
+
904
+ > This safeguard comes from a real incident: to check whether the routes were registered, each write route was probed with `POST {}`, and
905
+ > `/config/reset` returned 200 on the spot and cleared `agentName` / `agentRole` / `addressUserAs`.
906
+ > There are now three lines of defence: the route demands an explicit confirmation, `smoke-host.mjs` asserts that an empty body must not change any field, and
907
+ > `check-live.mjs` probes it at runtime and restores in place if the fields were really cleared.
908
+
909
+ ## Known boundaries
910
+
911
+ - Configuration writes are **non-atomic** (no tmp+rename); on the `storageDomain` path this is guaranteed by the backend.
912
+ - `anima:pending` reports only the count and does not expose candidate content — this is deliberate, to keep unadjudicated content from influencing answers.
913
+ - **A message without `id` does not mean "this message was dropped" — it means "the whole session will not open".**
914
+ When loading a session the host's `assertMessageEventShape` requires a non-empty `id` and throws
915
+ `session event at seq N lacks an identified message` (`_tmp_sessionfix/_extract/dsh-session__lib__index.js:1201`).
916
+ Nothing validates at write time, so a malformed message lands silently and only blows up the next time the session is opened.
917
+ The plugin now owns the `id` via `randomUUID()` (`createNoticeMessage` in `lib/injection.mjs`), pinned by three
918
+ regressions in `verify-core` (has id / two calls differ / role and source still compliant).
919
+ Historical bad data (seq 6104 and 6301 on this machine) was repaired by **rewriting the session archive**, not by the plugin
920
+ healing itself — users who already have bad data will not be fixed by upgrading, which is recorded here so nobody misjudges it later.
921
+ - The upward search for the workspace override file is limited to 32 levels.
922
+ - The route guard assumes requests pass through the host webserver; if the host later gains a reverse proxy in front, `remoteAddress`
923
+ will become the proxy's address and this guard needs adjusting accordingly.
924
+
925
+ **What the check layer cannot do** (written here because "assuming it does" is far more dangerous than it not doing so):
926
+
927
+ - **It cannot stop the current turn.** The hook sees an answer that has already been persisted; it can only remind in the next message.
928
+ - **It judges shape, not content.** It can find "there is a number but no source"; it cannot find "this number is wrong".
929
+ - **R03 / R05 / R06 / R07 / R10 can never be judged by a machine**, and the code always states "these five were not checked".
930
+ - **"Zero notices" does not mean "nothing wrong".** Look at `qualityStats.checks`: `checks === 0` means none of it is running.
931
+ - **There is no solution that minimises false positives and false negatives at once.** The current trade-off prefers missing
932
+ (turning `strict` off drops the three partial rules) over producing false alarms — false alarms make the user switch the
933
+ whole layer off, at which point a miss in one place becomes a miss everywhere.
934
+
935
+ **What the memory layer cannot do**:
936
+
937
+ - **It does not decide which memory is superseded by which.** That is a semantic judgement, and doing it automatically
938
+ would produce statements the user never made; measured on the real 21 memories the maximum pairwise similarity is
939
+ 0.094 (threshold 0.6), so an automatic detector would never fire anyway.
940
+ - **Decay only affects how an entry is shown and where it sorts — it never changes the data.** `lib/decay.mjs`
941
+ derives a tier from `type` + the calendar: facts have a window of 0 (never age out; environment changes are
942
+ `verifyAt`'s job), preferences 540 days, stances 365, events 180. An entry past its window gets a tag at the end of
943
+ its injected line ("not updated in a long time" / "an earlier event") and sorts last, so it is the first squeezed out
944
+ when the budget is tight. **It does not set `state` to superseded and it does not delete** — whether an entry still
945
+ holds is yours to decide.
946
+ Those three numbers have **no data behind them**; they are order-of-magnitude guesses from each type's semantics.
947
+ After some real usage, decide whether they should become configurable.
948
+ - **Deletion is a hard delete** (`deleteMemory` → backend `delete`), not a tombstone.
949
+ Records with `state: 'dropped'` are skipped outright by `selectForInjection` (`lib/memory.mjs:578`), so the
950
+ "removed (tombstone)" tag **never reaches the prompt** — that branch in `formatMemoryLine` only completes the
951
+ formatter; the injection path cannot reach it. That is not a defect: pushing deleted content back into context is
952
+ far worse than omitting a label.
953
+ - **The patrol speaks only when there is news, and none of its criteria involve semantics.** It runs once a night
954
+ (`scheduleNightly` → `runPatrol`): triage the buffer, run the memory checkup, then decide whether to say anything.
955
+ The criteria live in `lib/patrol.mjs`: the **set** of pending candidates changed, the **set** of flagged memories
956
+ changed, or this triage actually touched data. An unchanged count stays silent — three candidates sitting for a
957
+ week re-reported nightly is pure noise. It cannot judge whether a memory still holds; that needs semantics.
958
+
959
+ **What the patrol cannot do** (written down because believing you will be told is more dangerous than not being told):
960
+
961
+ - **It does not guarantee it ran tonight.** A powered-off machine, a closed host, or storage that was not ready will
962
+ all leave it unrun for the whole night. Silence therefore means one of two things: **it ran and found nothing**, or
963
+ **it never ran**. Only the `meta.patrol` line separates them — an `at` from last night means it ran, an `at` stuck
964
+ at the night before means it did not. That is what the "last patrol" line in settings is for. Conversely: **if that
965
+ line is missing, it is not "all clear", it is "never ran"** (the client has dedicated copy for that case).
966
+ - **It does not read content for truth.** Every criterion is a set comparison (pending ids, flagged ids, whether
967
+ triage touched data); not one of them parses what the memory says. "This memory is now wrong" will never be
968
+ volunteered by it.
969
+ - **It does not decide which memory superseded which.** Semantic, see above.
970
+ - **It does not reschedule because you are busy.** The hour comes from `nightlyHour`. If the machine is asleep at that
971
+ instant, or the timer is dragged to another hour by the OS, it **skips that night silently** (`index.mjs:807` only
972
+ writes a `warn` log and returns when `nowHour !== hour`). To be precise: **the "logged as `nightly-late`" catch-up
973
+ was never implemented** — the only mentions of it are a comment at `lib/patrol.mjs:206` and prose in this README;
974
+ no code ever writes that value. What does exist is `reason: 'catch-up'` (`index.mjs:2561`): five seconds after the
975
+ host starts, if today has not run yet, it runs once. That covers "today", never "last Wednesday". It does not know
976
+ you are rushing a deadline and will neither go quiet nor wait for you.
977
+ - **It gets one shot per night.** One run; if it says nothing that night there is no second pass. For a definite
978
+ answer, run it yourself (settings button or `/anima patrol`), otherwise you can only infer from `meta.patrol`.
979
+ Note that a manual run **overwrites** that row: `at` becomes the moment you clicked and `reason` becomes `manual`,
980
+ so "did it run last night" is only answerable before you run it by hand.
981
+ - **It reports "the set changed", not "this matters".** An incidental new candidate triggers a report; a genuinely
982
+ dangerous entry that has been sitting in the list since last week does not. There is no notion of weight in the
983
+ criteria.
984
+ - **It keeps only the latest 20 findings.** `flaggedItems` holds the compacted first 20, with the cut count in
985
+ `flaggedDropped` surfaced in the UI. The full list requires clicking the checkup on the spot
986
+ (`GET /api/anima/memory/audit`), which is computed live and uncapped — but it still will not rank them for you.
987
+ - **The findings do not contain the memory text — with one exception.** Normally only id, one-line detail, one line
988
+ of evidence. The exception: when a checkup finding has no `detail`, it falls back to the memory body
989
+ (`first.detail ?? item?.text` at `lib/patrol.mjs:189`). Every finding currently in `lib/memory-audit.mjs` supplies a
990
+ `detail`, so that fallback is unreachable on the normal path — but the code path exists.
991
+ - **Its notice only reaches sessions that are alive at that moment.** `injectToAgents` iterates the current agent
992
+ list; a session that was closed then never gets a backfill. "I opened it later and saw nothing" therefore does not
993
+ mean it did not report — `delivered` and `reported` are the evidence.
994
+ - **"No report tonight" does not mean "yesterday's cleanup was noticed."** Triage-touched data is reported only when
995
+ the previous run had not touched it (`prevTouched === 0` at `lib/patrol.mjs:139`) — several consecutive nights of
996
+ automatic cleanup report only the first. The later counts are still recorded in `facts.removed`; they just are not
997
+ announced separately.
998
+ - **It cannot decide whether a memory deserves to stay.** Triage only deduplicates and enforces the pending cap; it
999
+ never deletes on "the content is useless". Everything counted in `facts.removed` comes from those two mechanical
1000
+ reasons.