sage-mem 2.4.3 → 2.5.1
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 +650 -0
- package/README.md +364 -347
- package/lib/client.js +701 -328
- package/package.json +1 -1
- package/README.zh.md +0 -639
package/README.en.md
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
# sage-mem · file-based memory for DeepSeek Harness _(sage-mem)_
|
|
2
|
+
|
|
3
|
+
> Chinese version: [README.md](https://github.com/gezi-wen/sage-mem/blob/main/README.md)
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/sage-mem)
|
|
6
|
+
[](https://www.npmjs.com/package/sage-mem)
|
|
7
|
+
[](https://github.com/gezi-wen/sage-mem/actions/workflows/test.yml)
|
|
8
|
+
[](#compatibility)
|
|
9
|
+
[](#compatibility)
|
|
10
|
+
[](https://github.com/gezi-wen/sage-mem/blob/main/LICENSE)
|
|
11
|
+
[](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)
|
|
12
|
+
|
|
13
|
+
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
|
+
|
|
15
|
+
**Current release: `2.5.1`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
|
+
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
|
+
|
|
18
|
+
If it is useful to you, a Star is the most direct feedback.
|
|
19
|
+
|
|
20
|
+
## Table of Contents
|
|
21
|
+
|
|
22
|
+
- [What This Is](#what-this-is)
|
|
23
|
+
- [Why Files, Not a Database](#why-files-not-a-database)
|
|
24
|
+
- [Core Features](#core-features)
|
|
25
|
+
- [Quick Start](#quick-start)
|
|
26
|
+
- [UI Preview](#ui-preview)
|
|
27
|
+
- [Memory File Format](#memory-file-format)
|
|
28
|
+
- [Retrieval and Injection](#retrieval-and-injection)
|
|
29
|
+
- [Write Safety](#write-safety)
|
|
30
|
+
- [Autodream](#autodream)
|
|
31
|
+
- [Migrating from Claude Code](#migrating-from-claude-code)
|
|
32
|
+
- [Configuration Reference](#configuration-reference)
|
|
33
|
+
- [Stability and Versioning Policy](#stability-and-versioning-policy)
|
|
34
|
+
- [Permissions and Privacy](#permissions-and-privacy)
|
|
35
|
+
- [Compatibility](#compatibility)
|
|
36
|
+
- [Architecture](#architecture)
|
|
37
|
+
- [Contributing](#contributing)
|
|
38
|
+
- [FAQ](#faq)
|
|
39
|
+
- [Uninstall](#uninstall)
|
|
40
|
+
- [Maintainer](#maintainer)
|
|
41
|
+
- [Credits](#credits)
|
|
42
|
+
- [Related Projects](#related-projects)
|
|
43
|
+
- [Legacy](#legacy)
|
|
44
|
+
- [License](#license)
|
|
45
|
+
|
|
46
|
+
## What This Is
|
|
47
|
+
|
|
48
|
+
DSH has no native memory system. Every new session is a blank sheet: it does not remember who you are, where you got to yesterday, or what stage the project is at, so you have to brief it again every time.
|
|
49
|
+
|
|
50
|
+
sage-mem adds that layer, like this: **write down the things worth remembering as cards, and store them on your own computer**. Before DSH hands the question to the model, the plugin picks out the few most relevant cards and **puts them in front of the model**.
|
|
51
|
+
|
|
52
|
+
So the accurate statement is not "the model remembers you", but "**before it opens its mouth, somebody has handed it the relevant slips of paper**". Take the slips away and it is that amnesiac colleague again.
|
|
53
|
+
|
|
54
|
+
A memory takes the form of one `.md` file per memory (YAML frontmatter + body): openable, editable and deletable in an editor, and manageable with git directly. **This memory stays in your own hands the whole time, not in some database.**
|
|
55
|
+
|
|
56
|
+
## Why Files, Not a Database
|
|
57
|
+
|
|
58
|
+
Most memory schemes take the "SQLite + a long-running worker" route: once memories are written into a database they are **opaque** (to see what is stored you have to go query tables), capturing every event **inflates** the store (even "the user said hello" counts as a row), and you have to keep an extra process and an extra port alive.
|
|
59
|
+
|
|
60
|
+
| | File-based (sage-mem) | Database + worker |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| Seeing what is stored | Open the directory | Query tables / write a script |
|
|
63
|
+
| Editing one by hand | Edit it in an editor | Go through an API or SQL |
|
|
64
|
+
| Migration / backup | Copy the directory (travels with git) | Export tooling |
|
|
65
|
+
| Extra process and port | None | Usually yes |
|
|
66
|
+
| Who decides what gets stored | The agent judges by rules | Commonly indiscriminate capture of everything |
|
|
67
|
+
|
|
68
|
+
## Core Features
|
|
69
|
+
|
|
70
|
+
| Feature | Description |
|
|
71
|
+
|---|---|
|
|
72
|
+
| **File-based storage** | One `.md` per memory: frontmatter holds metadata, the body holds content. No database, no hidden format |
|
|
73
|
+
| **Automatic injection** | Every new question rescans the memory directory and re-picks the most relevant few for that question, injecting them into context before the model speaks — no file reading needed |
|
|
74
|
+
| **The model can look things up too** | Two retrieval tools are also given to the model (`search_memory` / `read_memory`), so it can go through memories on its own and read the original text by line |
|
|
75
|
+
| **Writes are not overwritten** | While you are editing and the model is changing things at the same time, the later write does not silently clobber the earlier one; the conflict is left as a pending record for you to decide which side to keep |
|
|
76
|
+
| **Zero extra services** | No worker, no SQLite, no port, no long-running process, and no extra API key or cloud service (the only runtime dependency is the ordinary npm library `zod`) |
|
|
77
|
+
| **Anti-bloat** | Rules guide the model in judging "is this worth storing": nothing without substance is stored, nothing derivable by reading the code / git is stored |
|
|
78
|
+
| **Tidyable** | The optional "autodream" reads your memories through once: merges duplicates, fixes the index and broken links, and creates a rollback point before changing anything |
|
|
79
|
+
| **Migration from Claude Code** | The persona (`CLAUDE.md` → `AGENTS.md`) is copied as-is; memories just need organizing into `.md` files under four types (see [the next section](#migrating-from-claude-code)) |
|
|
80
|
+
| **Ships its own usage guide** | The package carries a skill (`skills/sage-mem/`): what a memory looks like, the four types, what not to store, the frontmatter pitfalls, how retrieval actually works, how to audit and fix a broken index. Installing the plugin is enough — the agent gets the instructions, no manual setup |
|
|
81
|
+
|
|
82
|
+
The UI also carries four more capabilities (all of them optional): the memory management page, type and tag filtering, the timeline (an entries list by default, with an optional star-map view), and audit & archiving.
|
|
83
|
+
|
|
84
|
+
**Since 1.8.0, memory management is also reachable from the sidebar**: a 「记忆管理」(memory management) icon appears in the sidebar and switches the central panel to this page (with a "back to conversation" control at the top).
|
|
85
|
+
It can be turned off in the settings page under 「记忆管理 → 自动做梦 → 设置」(on by default; applies immediately, no restart);
|
|
86
|
+
**since 1.8.2 the same toggle also lives on the plugin's detail page** (「插件 → sage-mem」card) and applies immediately too;
|
|
87
|
+
**turning it off only removes the sidebar icon — the 「设置 → 记忆管理」 entry always stays**.
|
|
88
|
+
Since 1.8.2 the plugin also ships its own icon (a golden stack of memory cards) instead of the generic square.
|
|
89
|
+
|
|
90
|
+
The memory management page's file list also has an **Open memory folder** button: it opens the
|
|
91
|
+
memory directory in the system file manager (Explorer / Finder / the Linux file manager) through a
|
|
92
|
+
detached host-side process and returns without waiting for the window.
|
|
93
|
+
|
|
94
|
+
While the memory directory is empty, the empty state carries a **Create index skeleton** button: one
|
|
95
|
+
click writes a starter `MEMORY.md` (index format plus the four memory categories). When `MEMORY.md`
|
|
96
|
+
already exists it only says so and changes nothing; it uses the existing reserved-file read/write
|
|
97
|
+
calls rather than a new remote.
|
|
98
|
+
|
|
99
|
+
## Quick Start
|
|
100
|
+
|
|
101
|
+
Requires **DSH `0.1.2-rc.1` or higher** (see [Compatibility](#compatibility)) and **Node.js `>= 18`**.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# replace web with your own profile name
|
|
105
|
+
dsh plugin --profile web add sage-mem
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`dsh plugin add` puts the package into this profile and registers its bundle — no hand-editing `package.json` (manual install below).
|
|
109
|
+
|
|
110
|
+
After installing, restart DSH and confirm it is in:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
dsh plugin --profile web list
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Then **open a new session** and try three steps:
|
|
117
|
+
|
|
118
|
+
1. Tell the model "remember: my cat is called Zhima, and it likes sunbathing" — it writes this into the memory directory by its rules
|
|
119
|
+
2. Open another **new session** and ask "what is my cat called"
|
|
120
|
+
3. Its first reply should answer "Zhima" — the memory was retrieved and injected automatically by the plugin; the model did not go and read files itself
|
|
121
|
+
|
|
122
|
+
<details>
|
|
123
|
+
<summary>Manual install (if you do not want the CLI, or want to install a local development build)</summary>
|
|
124
|
+
|
|
125
|
+
Add the dependency to the profile's `package.json` and write the package name into `dsh.profile.bundles`:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"dependencies": {
|
|
130
|
+
"sage-mem": "^1.0.0"
|
|
131
|
+
},
|
|
132
|
+
"dsh": {
|
|
133
|
+
"profile": {
|
|
134
|
+
"bundles": ["sage-mem"]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For local development, use the cloned source:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"dependencies": {
|
|
145
|
+
"sage-mem": "link:../sage-mem"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Then run `pnpm install` and restart DSH.
|
|
151
|
+
|
|
152
|
+
</details>
|
|
153
|
+
|
|
154
|
+
### Where Memory Lives
|
|
155
|
+
|
|
156
|
+
By default `~/.sage-mem/memory`. To put it somewhere else (inside a project repository, so it travels with git, for example), set the environment variable:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
SAGE_MEM_DIR=/path/to/your/memory
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The state directory (configuration, ledger, rollback points, conflict queue) defaults to the parent of the memory directory: `<memory directory>/.sage-mem`. All environment variables are in [Configuration Reference](#configuration-reference).
|
|
163
|
+
|
|
164
|
+
## UI Preview
|
|
165
|
+
|
|
166
|
+
Every image below is **rendered from the UI code in this repository** (the same CSS, the same component tree; the data is demo data for illustration, not a real memory directory).
|
|
167
|
+
|
|
168
|
+
Since 2.5.0 the panel has **four tabs: Files | Timeline | Automation | Health** — split by what you want to do, not by which data source feeds it: autodream and auto-archive are two cards on one "Automation" screen, conflicts and the audit are two cards on one "Health" screen. Editing opens a right-hand drawer, and every action that touches a file on disk asks in a modal dialog.
|
|
169
|
+
|
|
170
|
+
Since 1.9.0 the memory-management surface follows the harness language (**Settings → General → Language**); memory content itself stays in whatever language it was written in, 2.1.0 extended that to the autodream (run / settings / records) and auto-archive cards; the timeline / star-map screen is the only panel still Chinese until the next i18n batch.
|
|
171
|
+
|
|
172
|
+

|
|
173
|
+
|
|
174
|
+
*File list — the header always shows 「体检 N 个问题」(audit: N issues) and 「待重启生效」(restart to apply), so you know whether you should deal with it without opening anything; greyed rows are archived and can be restored at any time, or deleted for good with 「彻底删除」(irreversible, asks once more).*
|
|
175
|
+
|
|
176
|
+

|
|
177
|
+
|
|
178
|
+
*Tools drawer — closed it takes up a single button; expanded, each of the two items carries its own status.*
|
|
179
|
+
|
|
180
|
+

|
|
181
|
+
|
|
182
|
+
*Lock — one click locks an important memory: **it no longer appears among the automatic archive candidates; the archive button in the UI is disabled, and even if you bypass the UI, the archive implementation refuses explicitly with `locked`**. The lock lives in the state directory and is not written into the memory's own frontmatter — that would be rewritten by automatic consolidation, and once the lock is lost it would be back among the candidates.*
|
|
183
|
+
|
|
184
|
+

|
|
185
|
+
|
|
186
|
+
*Edit a memory — editing is a right-hand drawer now. Same fields, same validation; the only difference is that it no longer pushes the list down. Three ways out: 「取消」(cancel), clicking the scrim, or Escape.*
|
|
187
|
+
|
|
188
|
+

|
|
189
|
+
|
|
190
|
+
*Destructive confirmations — archiving, purging an archived memory, deleting a memory, rolling back, archiving candidates (one or a batch), invalidating a conflict, undoing that invalidation, and removing a conflict record all ask in this one dialog. **The two-stage design is unchanged**: the trigger button is stage one, the modal is stage two, and they are never merged.*
|
|
191
|
+
|
|
192
|
+

|
|
193
|
+
|
|
194
|
+
*Health — one screen answers "is anything wrong with this library": the **conflict queue on top** (a finite list that needs your decision: which of two contradictory statements is void) and the **audit below** (dangling index entries, missing index entries, broken links and the rest, reported in red by category). "Points to an archived memory" is only a hint — it does not count as a problem, and there is no one-click fix: which one to change is your call. The audit only reports and never modifies.*
|
|
195
|
+
|
|
196
|
+

|
|
197
|
+
|
|
198
|
+
*Update notice — a single banner appears only when there **really is a new version**, naming the new version and the current one; 「看更新内容」(see what's new) opens a new tab, and "×" just means not this time.*
|
|
199
|
+
|
|
200
|
+

|
|
201
|
+
|
|
202
|
+
*Archive candidates — each states "why archiving is suggested" (idle days vs the threshold for that type); files are touched only after you confirm one by one or select all. Archiving moves a file into `archive/`, it **does not delete**; to actually delete an archived entry, click 「彻底删除」(delete permanently) in the archive view and confirm once more.*
|
|
203
|
+
|
|
204
|
+

|
|
205
|
+
|
|
206
|
+
*Timeline — **this is what the tab opens on**: a list of entries sorted newest-first, most recently changed at the top. Want the picture? Switch to 「星空」(star map) in the top-right corner.*
|
|
207
|
+
|
|
208
|
+

|
|
209
|
+
|
|
210
|
+
*After switching to the star-map view — colour maps to type, size to volume, brightness to freshness; dark stars are archived memories, and opening one shows the archive time and reason.*
|
|
211
|
+
|
|
212
|
+

|
|
213
|
+
|
|
214
|
+
*Autodream · settings — run / settings / history as secondary groups; the five injection parameters laid out in two columns, with both 「当前生效」(currently in effect) and 「重启后」(after restart) written out.*
|
|
215
|
+
|
|
216
|
+

|
|
217
|
+
|
|
218
|
+
*Auto-archive — it is not the same thing as autodream: archiving is a **deterministic rule** (computed from idle days in the access ledger, no model call), which is why it is its own card. Since 2.5.0 it has a **switch**, and that switch is the only way to turn it off (the old third mode 「关闭」 is gone); the mode is either 「只出报告」(report only) or 「满足条件自动跑」(run when the threshold is hit). The card has its own 「运行 / 设置」 segments, same shape as autodream: the run segment explains what it does and holds the mode, the settings segment holds the three idle thresholds. Turning it off stops the running, not the settings — thresholds and history stay editable. Thresholds take effect on save, no restart needed.*
|
|
219
|
+
|
|
220
|
+

|
|
221
|
+
|
|
222
|
+
*The star-map view in full — the canvas draws only relations and distribution; the file name of a single memory appears only when you hover over or open that star. Rendering is redrawn on demand; no drawing is initiated while the view is static.*
|
|
223
|
+
|
|
224
|
+
## Memory File Format
|
|
225
|
+
|
|
226
|
+
````markdown
|
|
227
|
+
---
|
|
228
|
+
name: optional short name
|
|
229
|
+
description: one sentence saying exactly what this memory is (retrieval depends on it, so write it accurately)
|
|
230
|
+
metadata:
|
|
231
|
+
type: user # user / feedback / project / reference
|
|
232
|
+
tags: [project, todo] # optional, for filtering on the list page (at most 20, 40 characters each)
|
|
233
|
+
aliases: [todo, backlog] # optional: an alternative wording that also retrieves it
|
|
234
|
+
baseline: true # optional: injected unconditionally on a new session's first step (limit 8)
|
|
235
|
+
baselinePriority: 10 # optional: among several baseline memories, which is injected first (higher = earlier)
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
The memory body.
|
|
239
|
+
````
|
|
240
|
+
|
|
241
|
+
The four memory types:
|
|
242
|
+
|
|
243
|
+
| Type | What it stores |
|
|
244
|
+
|---|---|
|
|
245
|
+
| `user` | who the user is |
|
|
246
|
+
| `feedback` | guidance on how to work (recommended to carry `**Why:**` and `**How to apply:**` lines, to make the boundaries easy to judge) |
|
|
247
|
+
| `project` | work in progress and its state |
|
|
248
|
+
| `reference` | pointers to where external information can be found |
|
|
249
|
+
|
|
250
|
+
Single-file limit 512 KB, **validated on both the read and the write side** (on reading, the size is checked first; if it is over, the file is not read in).
|
|
251
|
+
|
|
252
|
+
## Retrieval and Injection
|
|
253
|
+
|
|
254
|
+
Something transparent deserves a clear account of how it selects its material, because that directly determines how many tokens get injected.
|
|
255
|
+
|
|
256
|
+
**Two-stage retrieval: summary first, then body.** The first stage scores by `description` + file name + `aliases`, taking at most **10 candidates**; the second stage runs **body paragraph retrieval** over those candidates (BM25, with Chinese tokenized as two-character units) and injects the most relevant paragraph. What is compared is a **paragraph**, not a whole file, so one file contributes at most one paragraph, and a long file cannot hog candidate slots through sheer paragraph count.
|
|
257
|
+
|
|
258
|
+
**What the summary does not cover, the body can find again.** The summary is one precise sentence, at the cost that body details are inherently unretrievable by it; when not a single summary hits, body retrieval takes over as the fallback. Body retrieval only runs when the question is **at least 4 characters** long — a two-character question like `继续` ("continue") fed into body matching hits nothing but the word `继续` itself.
|
|
259
|
+
|
|
260
|
+
**A different wording hits too:** scoring is **lexical overlap**, not semantic — ask "我有什么待办" ("what todos do I have") while the file says "代办清单" ("todo list") and one differing character means no hit at all. Adding a line `aliases: [待办, todo]` to such a memory covers it.
|
|
261
|
+
|
|
262
|
+
**What gets injected is the matching paragraph, not the start of the file.** A matching paragraph is labelled `### 项目_部署.md:40-52`, so the model can jump straight to those lines to check the source; a line with that memory's `description` is prepended, saying which memory the passage was cut from. `baseline` memories and files with no body hit are still injected from the start; a single body over the character limit (1500 characters by default) is truncated and marked `…(截断)`.
|
|
263
|
+
|
|
264
|
+
**Adjacent repeated questions are not injected twice** (only one signature is kept per session), so asking twice in a row does not append it again. Note that this is **not** "the same question is given once for the whole session": ask A → ask B → ask A, and the third one injects again — when recall is requested again, supplying the evidence again is not necessarily a bad thing.
|
|
265
|
+
|
|
266
|
+
**A single session has a budget cap** (60 KB by default), split into two ledgers: the **body pool** = the total cap − the **status-card reservation** (5%, clamped between 1 and 4 KB). Hitting the top does not mean "not a single character is injected"; the body is simply not injected as before while the gap status card is still sent, telling you what was left out and how to look it up next. **After the context is compacted or cleared, both ledgers and the card signatures reset to zero together**, and injection can happen again.
|
|
267
|
+
|
|
268
|
+
> ⚠️ **This cap is soft, not a hard gate.** `baseline` memories matched by the question are injected regardless and take no part in the degradation, so the cumulative body over one session **may exceed the body pool** (9.6% overrun was measured in evaluation, all of it from this kind of memory). This is a deliberate tradeoff: the semantics of that kind of memory are "it should be present every session". Do not read it as "a clean cut-off at 60 KB".
|
|
269
|
+
|
|
270
|
+
**The gap status card speaks only when there really is a gap**: it appears only when there are candidates that were not read, or candidates omitted by the budget, and it states the scope, the count, the best clue and **one action you can take**; when not a single thing matched, it speaks only if you **asked about your own history**, or this task **has a checkpoint** (otherwise the hint would be ignored as noise). A single card is capped at 512 bytes and deduplicated by content signature.
|
|
271
|
+
|
|
272
|
+
**"This claim no longer holds" becomes a fact you can check in the file (from 2.0.0).** A memory's frontmatter may carry three optional fields: `valid_at` (when the claim starts to hold), `invalid_at` (when it stops counting), and `superseded_by` (which memory holds the current wording). **The injection side reads them**: a memory whose `invalid_at` has passed is no longer injected as body text (reason `expired_source`); an **unresolved contradiction** (an open record with `kind: contradiction`) keeps **both sides** out of the body (`conflict_pending`) until you resolve it on the 「记忆管理 → 冲突」(Memory management → Conflicts) tab. **Write-failure conflicts are not suppressed** (stale version / the file was changed elsewhere / a half-finished merge) — what is on disk is still the current content, and hiding it too would report "this write did not land" as "this memory is untrustworthy".
|
|
273
|
+
|
|
274
|
+
**Suppression is never silent.** While contradictions are pending, the status card adds a line — "另有 N 条矛盾待裁决,相关记忆本轮未注入正文" (N contradictions are pending; the related memories were not injected this round) — with the concrete count, so a change in the count can send a fresh card, and the panel header carries a permanent badge. **The tools are unaffected**: `search_memory` / `read_memory` still find and read invalid memories, marking them "已失效" and carrying `invalidAt` / `supersededBy` — when you ask about history, the old wording is evidence, not noise. To go back to the old behaviour temporarily, set `SAGE_MEM_VALIDITY=0` (that disables only this filter; the rest of this release stays).
|
|
275
|
+
|
|
276
|
+
**There is a relevance threshold on the summary side too.** In Chinese, units like "怎么" and "什么" appear in a great many `description`s, so an unrelated "今天天气怎么样" can hit a dozen memories off a single "怎么". The threshold is normalized against **the corpus's own IDF scale**, which brings the candidate count for unrelated questions down to about one seventh; what gets cleared out is mainly the weak summary candidates that should have lost anyway.
|
|
277
|
+
|
|
278
|
+
The comparison on the offline evaluation set (before and after the threshold): **Recall@5 84.1% → 87.0%, spurious injection (memories injected that are unrelated to the question) 14.1% → 5.6%**.
|
|
279
|
+
|
|
280
|
+
> ⚠️ **These are measurements, not promises**: what transfers is the **relative improvement**; the absolute level **cannot be extrapolated** to a real memory directory — the summaries of that 30-item corpus were written by hand precisely so as to "be retrievable", whereas real memories' summaries are often written casually or are missing entirely. Method and limitations are in the [evaluation report](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-eval-report.md).
|
|
281
|
+
|
|
282
|
+
**Intent detection never suppresses repetition on three kinds of explicit request**: "we settled on A last time, right?" (verification), "we are not using A any more" (correction), "why did we not pick A before" (asking for the basis) must be given the original text; if it cannot be judged, it does not judge, and it does not guess.
|
|
283
|
+
|
|
284
|
+
### The Model Can Browse Memories Too: Two Tools
|
|
285
|
+
|
|
286
|
+
The trigger right for retrieval is split in two: half with the plugin (automatic injection — it guesses what you want to see), half with the model (the two tools below — it looks things up on its own).
|
|
287
|
+
|
|
288
|
+
| Tool | Purpose |
|
|
289
|
+
|---|---|
|
|
290
|
+
| `search_memory(query, scope?, limit?, cursor?)` | Compares summaries first, then searches bodies by paragraph; returns candidates, matching passages, line numbers, and **coverage information** (how many files were scanned, how many paragraphs were filtered out for insufficient relevance, whether there is a next page) |
|
|
291
|
+
| `read_memory(name, startLine?, startByte?, maxBytes?, revision?)` | Reads the body by line, paginated. Pass the `revision` you last read, and if the content has been changed by someone it returns `revision_changed`, **and does not return the content** — otherwise you would read the wrong thing with stale line numbers |
|
|
292
|
+
|
|
293
|
+
Both tools go through **the same** safe read path (file name checks → symlink and reserved-name identity checks → size gate); no separate entrance that bypasses the gate was opened for the tools. They register through the tool service the host provides — that is **not a hard dependency**: when the host does not provide that service, the plugin works as normal, only the status card switches to a "read the memory directory directly" wording.
|
|
294
|
+
|
|
295
|
+
### Relaying the Baton on Long Tasks: Task Checkpoints
|
|
296
|
+
|
|
297
|
+
When the host compacts context is not something the plugin controls — so it **does not promise "save before compaction"**. What it can do is: if `<state directory>/tasks/<task id>/WORKING.md` exists, a short handle is appended to the injection, stating the task, the revision and where to read it. **After compaction the ledgers reset to zero and that clears the deduplication along with them**, so the handle reappears — that is the recovery path.
|
|
298
|
+
|
|
299
|
+
The task id comes from `SAGE_MEM_TASK_ID` (for the same task across sessions), otherwise the session id is used. **Your raw question text is never used to decide which directory to read.** If a "done" item in a checkpoint carries no evidence marker at all (path, command, test result), parsing reports it as such — handover material that is perfectly formatted but factually wrong is still wrong.
|
|
300
|
+
|
|
301
|
+
## Write Safety
|
|
302
|
+
|
|
303
|
+
Memories are **files**, so "you are editing" and "the model is changing things" can perfectly well happen at the same time. The default behaviour is **the later write wins and the earlier one quietly disappears** — the worst kind, because you never find out.
|
|
304
|
+
|
|
305
|
+
sage-mem adds a **version guard** to writes into the memory directory: the current version is noted before writing, and the write requires that the version has not changed. If it has, the write is **explicitly refused** instead of being clobbered — turning "a silent bad result" into "an explicit failure".
|
|
306
|
+
|
|
307
|
+
The guard has two paths, with **different** coverage, spelled out:
|
|
308
|
+
|
|
309
|
+
| Who is writing | How it is protected | When refused |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| The model changing a memory with the **built-in `write` / `edit`** | The plugin supplies the "current version" in a pre-event, and the host refuses stale overwrites on that basis | The host refuses outright (reporting `FS_STALE_VERSION`). **A refusal on this path leaves no conflict record** — the write gate only supplies the version, it does not perform refusals |
|
|
312
|
+
| **The plugin itself** writing (autodream consolidation) | The caller passes the version it read at write time | A pending record `<state directory>/conflicts/<id>.json` is written, keeping **the version on disk** and **the version it wanted to write** — with only one side there is nothing to judge |
|
|
313
|
+
|
|
314
|
+
- **Conflicts are not merged automatically**; which side to keep is your decision (the record is JSON, open it directly when you need to). Silent retry and silent abandonment both decide on your behalf.
|
|
315
|
+
- **From 2.0.0 the injection side does read this queue**: only the **two sides of a `contradiction`** (status `open`) are suppressed; the three write-failure kinds (`stale_version` / `external_change` / `unresolved_merge`) are still injected — what is on disk there is the current content. The resolution entry and the suppression ship together, so there is no longer a state where "suppression is wired but there is no entry point".
|
|
316
|
+
- **Resolution lives on the 「记忆管理 → 库健康」(Memory management → Health) tab** (the conflict queue sits at the top of that screen, with a permanent badge in the panel header): you look at both bodies one by one, and the actions are "mark this one invalid", "undo invalidation" and "delete record", with two-step confirmation for the dangerous ones. "Mark invalid" only adds one line `invalid_at` to the memory's frontmatter (optionally plus `superseded_by`) — **the file stays in place, the body does not change by a single byte, and `updated` is not touched**; the write is idempotent (clicking twice does not rewrite), it can be undone, and a failure returns an explicit error while the record stays pending.
|
|
317
|
+
- **Conflict records only ever gain fields**: 2.0.0 appends `otherFile` / `decision` / `decidedAt` / `appliedVersion` / `invalidatedFile` at the tail, and the record version stays 1; 1.9.x readers ignore keys they do not know, so no migration is needed in either direction.
|
|
318
|
+
- **Manual edits are outside the guard**: editing the file directly takes effect too (delete `invalid_at` and the memory is injected again); the plugin does not police hand edits — that is the tradeoff of file-based memory, see boundary C11.
|
|
319
|
+
- **The same conflict is not duplicated**: if a failure matches an unresolved record field for field (same file, both bodies, both versions), no extra copy is appended.
|
|
320
|
+
- **The host file sandbox**: autodream runs **outside any session**, so it cannot inherit a session workspace. On a `workspace-write` host the plugin passes a per-call sandbox policy **narrowed to the memory directory** (narrower than the fallback root); on a `read-only` host it passes nothing, the write is still refused, and the run is recorded as a **failure** (not "success, nothing changed").
|
|
321
|
+
- Known boundary: **editing memories in a shell does not go through this path**. The write gate guards "memory writes that go through the tools", not "all modifications to files"; redirection and the like inside `pwsh` are not recognized. If it cannot be sure, it does not claim it — so that "cannot see it" is not mistakenly recorded as fact.
|
|
322
|
+
|
|
323
|
+
### Access Ledger: Three Kinds of "Used" Recorded Separately
|
|
324
|
+
|
|
325
|
+
`<state directory>/access.json` records three things:
|
|
326
|
+
|
|
327
|
+
| Field | Meaning |
|
|
328
|
+
|---|---|
|
|
329
|
+
| `injected` | the plugin **passively** injected this memory |
|
|
330
|
+
| `read` | the model **actively** read it |
|
|
331
|
+
| `use` | the number of times it was explicitly adopted (self-reported) |
|
|
332
|
+
|
|
333
|
+
They are recorded separately because the three mean completely different things: **being injected** only says the plugin thought it was relevant, **being actively read** only says the model picked it, and neither means the content was useful, let alone that it was correct. Merge them into one "heat" score and they can never be separated again.
|
|
334
|
+
|
|
335
|
+
**This ledger is currently used only for diagnostics and the auto-archive threshold; it takes no part in retrieval ranking.** The observation has known blind spots: reading memories in a shell is not recognized, so it is not claimed.
|
|
336
|
+
|
|
337
|
+
### Freshness and Citation Discipline
|
|
338
|
+
|
|
339
|
+
Every memory's heading carries its freshness: `(保存于 N 天前)`, and **the frontmatter's `updated:` takes priority, falling back to the file mtime only when it is missing** — a hand-written timestamp is authoritative; filesystem times get changed wholesale by copying / restoring from a backup / checkout.
|
|
340
|
+
|
|
341
|
+
The reason is: when a stale memory is cited as fact, the act of "citing" itself makes it look more authoritative rather than more suspect. So the injected text also opens with a **citation discipline** section (confirm a path exists before mentioning it, grep before mentioning a function, verify the current code before acting), and it is **a section of its own** — what it constrains is how this batch of content is to be used, and compressing it into one list item makes it easy to skip over together with the content.
|
|
342
|
+
|
|
343
|
+
**Memories with `baseline: true` are injected unconditionally on the first step of every session**, at most 8 — for memories like "who you are" and "the todo list" that ought to be present at every opening. ⚠️ **"Not subject to paragraph replacement" does not mean the whole file**: it is equally truncated by the single-item character limit (1500 characters by default), **so key content that falls at the tail will not be seen**. baseline suits short summaries; leave the full body for the tools to read. The order among several baseline memories is decided by `baselinePriority` (higher number = earlier, default 0), not by file name.
|
|
344
|
+
|
|
345
|
+
If the memory directory cannot be read, or permissions are wrong → **it degrades to no injection and writes a log**; it does not fail silently. Broken frontmatter is **handled leniently**: fields that cannot be parsed fall back to defaults (`type` missing is recorded as `reference`), and the file itself is still readable — one wrong line does not make a whole memory disappear.
|
|
346
|
+
|
|
347
|
+
## Autodream
|
|
348
|
+
|
|
349
|
+
The optional memory-consolidation feature (identifier `autodream`): it reads through the memory directory once — merging duplicates, deleting the overturned, fixing the index and broken links, converting relative dates into absolute dates — so that the next session can find its bearings quickly.
|
|
350
|
+
|
|
351
|
+
**Off by default**; turn it on in the settings page under 「记忆管理 → 自动做梦」(Memory management → Autodream). Three things are each optional:
|
|
352
|
+
|
|
353
|
+
| Option | Values | Description |
|
|
354
|
+
|---|---|---|
|
|
355
|
+
| Trigger | Manual only / run automatically when conditions are met | Automatic mode goes through three levels of gating, **checking the cheapest first**: time gate (≥ N hours since last time, one read/write) → session gate (≥ M sessions with updates in between, one directory scan) → lock (against concurrency). A 10-minute scan throttle is sandwiched in between, so that while "the time gate passed but the session gate did not" it does not rescan for nothing every turn |
|
|
356
|
+
| Mode of change | Report only / rewrite directly | In "report only" mode **the write tools are simply not attached for the model at all** — it is not constrained by a sentence saying "please do not write", it has no hands |
|
|
357
|
+
| Input source | Memory directory only / memory + session records | The latter decompresses session records (zstd) for targeted keyword search; the cost is that private conversations enter the model context, and the token cost is higher too |
|
|
358
|
+
|
|
359
|
+
Automatic triggering runs on the plugin's own timer (checks the gates every 30 minutes by default). Consolidation **deliberately does not run through your session** — the tool set is given by the plugin, the step count is capped, and it is not written into your session records.
|
|
360
|
+
|
|
361
|
+
### The Consolidation Model Can Be Specified Separately
|
|
362
|
+
|
|
363
|
+
Consolidation is a background batch job and need not be the same model as the one used for chatting. The settings page under 「高级设置 → 模型」(Advanced settings → Model) lets you pick one from **the model routes currently configured** (the options come from the same catalog as in 「模型选择」/ model selection).
|
|
364
|
+
|
|
365
|
+
- Once chosen, **only that one is accepted**: if the route does not exist in the host, **it errors before starting work** (it compares only against the host's registered catalog and sends no request at all), so it does not fail halfway through, and it **does not silently fall back to the default model**.
|
|
366
|
+
- The panel always shows **which one is actually in effect**; if the route named in the configuration happens to disappear from the catalog, the dropdown still shows it rather than quietly jumping back to the default.
|
|
367
|
+
- When the model catalog cannot be obtained (host differences), it degrades to typing `provider` and `model` by hand.
|
|
368
|
+
|
|
369
|
+
### When the Report Does Not Fit
|
|
370
|
+
|
|
371
|
+
The consolidation report is written by the model in one go, and a single output has a length cap. **The cap, the retry and the truncated label** all have defined behaviour:
|
|
372
|
+
|
|
373
|
+
- **The single-output cap is configurable**: Settings → Autodream → Settings → Advanced → "single output cap" (default 32768 tokens, range 1024–131072). For reasoning models the reasoning and the body **share** that budget; when the closing turn spends it all on reasoning, no body is left — raise the cap and run again.
|
|
374
|
+
- **Retry when the body is empty**: if an output hits the cap with **no body at all** (reasoning only), one retry asks for "the body directly, do not re-analyse"; up to 2 times, and only then is the report marked truncated. When there is a partial body, the loop asks it to continue from where it stopped (up to 3 rounds).
|
|
375
|
+
- **Truncation is stated**: the report's first screen, the consolidation manifest, the machine-readable record and the Settings → Autodream "last run" line all say "report truncated (fragment)" — a half report is never reported as "success". If the closing step produced no text, the report says "the model gave no textual summary" instead of reusing narration from an earlier step.
|
|
376
|
+
|
|
377
|
+
### Rollback Points
|
|
378
|
+
|
|
379
|
+
**Whenever this run is a "direct rewrite", a rollback point is created before work starts** (a full snapshot of the top-level `.md` files in the memory directory, each with a sha256), and after the run you can see it in the settings page and roll back to it in one click.
|
|
380
|
+
|
|
381
|
+
- The rollback point is **bound** to that run: the report carries the runId, the rollback directory has the same name, and the two match up.
|
|
382
|
+
- **A rollback can itself be rolled back**: before acting, it first saves "now" once more as a **protection snapshot** (not part of the normal snapshot rotation).
|
|
383
|
+
- **Scope is selectable**: restore only the files that run touched, or take the whole directory back to that point in time.
|
|
384
|
+
- **No file is deleted at any point**: files that "should not exist" during a rollback are all moved into `archive/`, recoverable by hand; on a name collision it skips and counts, and never overwrites.
|
|
385
|
+
- Every rollback leaves a trace: a rollback report is written into the report directory, and a record is appended to the state directory.
|
|
386
|
+
|
|
387
|
+
### Consolidation Manifest: What Changed and Why
|
|
388
|
+
|
|
389
|
+
Every consolidation leaves a **manifest** behind, in two forms: the human-readable `memory/autodream/<runId>.md` (embedded in the report) and the machine-readable `.sage-mem/autodream/runs/<runId>/manifest.json`.
|
|
390
|
+
|
|
391
|
+
It records item by item: **file / operation (create · rewrite · archive) / reason / byte change**, plus a structural audit comparison before and after the run.
|
|
392
|
+
|
|
393
|
+
**Since 2.4.0 the manifest also lists the facts that were deleted, one by one.** A rewrite or an archive can make a few facts that used to be written down stop existing, and the structural audit (dangling links / formatting / index) **cannot see a lost fact** — every check it runs can be green. So whenever something is removed, the manifest gets a section of its own listing the **exact original lines that no longer exist in the active memories**, with their original line numbers (so you can check them against the snapshot), and a rewrite also states the line-count change. Files taken by the auto-archive policy are listed too. The facts are computed by the host by diffing "the body before the write" against "the body written in" — **not self-reported by the model**: a model can miss things, a diff cannot. One real lesson: on 2026-11-05 a consolidation pass compressed away an entry that was still in effect — 「到期 2027-10-03、不自动续费」— and the run reported "audit 2 problems → 0 problems", all green.
|
|
394
|
+
|
|
395
|
+
- **`reason` should be filled in.** A `write_memory` without `reason` **will not be refused** — the change takes effect all the same, but the manifest marks it as 「未自述」(not self-described) and attaches what the host can trace (which turn / what operation / byte change). The reason: losing an otherwise correct correction over one missing explanation is the greater cost.
|
|
396
|
+
- **`archive_memory`'s `reason` is still required**; without it the action is refused outright, and refused actions get a section of their own, 「未能落地的改动」(changes that could not land), rather than disappearing silently.
|
|
397
|
+
|
|
398
|
+
### Three Safety Nets
|
|
399
|
+
|
|
400
|
+
1. **The safety boundary is in the tool set, not in the prompt** — during autodream the host gives the model only read-only tools, and only rewrite mode adds the rewriting tools; and **there is no "delete" capability at all** — archiving moves a file into `archive/`, recoverable by hand.
|
|
401
|
+
2. **A rollback point is created automatically before rewriting**, under `<parent of the memory directory>/.sage-mem/autodream/snapshots/<runId>/`, keeping the most recent N. The reason: the memory directory is **not under any version control** and deletion has no recycle bin, so "rollback-ability" has to be built by hand.
|
|
402
|
+
3. **An audit runs automatically at the end** — dangling index entries, missing index entries, `type` disagreeing with the file name prefix, CRLF, broken wikilinks, "space + #" swallowed as a YAML comment inside frontmatter, missing frontmatter, temporary files left behind by an interrupted archive, plus "unreadable" as a category of its own, and the result and the before/after comparison are written into the report. It **only reports, it does not change**. The index `MEMORY.md` is a list, not a memory, so it takes part in **neither** the "type vs prefix" check nor the "missing frontmatter" check (since 1.9.1).
|
|
403
|
+
|
|
404
|
+
### Rename Note
|
|
405
|
+
|
|
406
|
+
This feature was originally called "dream" (identifier `dream`) and has been called **autodream** since 0.7.0. Old data is not dropped: the old configuration `.sage-mem/dream.json` is read out and migrated to `.sage-mem/autodream.json` (**the old file is left untouched**), old reports under `memory/dream/` are still listed in the panel and marked 「旧版」(legacy), and old snapshots can still be rolled back to.
|
|
407
|
+
|
|
408
|
+
## Migrating from Claude Code
|
|
409
|
+
|
|
410
|
+
If you have already raised an agent in Claude Code (CC), sage-mem is part of a "CC → DSH" migration path. **Our own memories were migrated exactly this way** — at the time the memories on both sides were already Markdown files, so they could simply be copied over.
|
|
411
|
+
|
|
412
|
+
The two things are moved **differently**, spelled out:
|
|
413
|
+
|
|
414
|
+
| What | From | To | How |
|
|
415
|
+
|---|---|---|---|
|
|
416
|
+
| **Persona** | CC's `CLAUDE.md` | DSH's `AGENTS.md` | Both are plain text loaded natively by DSH; copy the content as-is |
|
|
417
|
+
| **Memories** | cross-session material accumulated in CC | sage-mem's `memory/` directory | One `.md` per memory, with frontmatter, filed under the four types `user` / `feedback` / `project` / `reference` |
|
|
418
|
+
|
|
419
|
+
⚠️ **The premise is that the shape matches.** The four-type division and the frontmatter are sage-mem's own conventions: if your memories in CC were already "one Markdown file per memory + frontmatter" (which is what ours were at the time), just copy them over; **if they were not** (if they were all piled into a single `CLAUDE.md`, say), you need to organize them first — and that job can perfectly well be handed to an agent: have it read the old material and lay it out as files under the four types.
|
|
420
|
+
|
|
421
|
+
## Configuration Reference
|
|
422
|
+
|
|
423
|
+
### Environment Variables
|
|
424
|
+
|
|
425
|
+
| Variable | Default | Description |
|
|
426
|
+
|---|---|---|
|
|
427
|
+
| `SAGE_MEM_DIR` | `~/.sage-mem/memory` | memory directory |
|
|
428
|
+
| `SAGE_MEM_STATE_DIR` | `<parent of the memory directory>/.sage-mem` | state directory (configuration, ledger, locks, conflict queue, rollback points) |
|
|
429
|
+
| `SAGE_MEM_MAX_SESSION_BYTES` | `61440` (60 KiB, 4096–524288) | total injection cap per session |
|
|
430
|
+
| `SAGE_MEM_MAX_RESULTS` | `10` (1–30) | cap on the number of candidate items injected per turn |
|
|
431
|
+
| `SAGE_MEM_MAX_CHARS` | `1500` (200–20000) | character cap for a single memory's injection |
|
|
432
|
+
| `SAGE_MEM_MAX_BASELINE` | `8` (1–50) | number of `baseline` slots |
|
|
433
|
+
| `SAGE_MEM_STALE_DAYS` | `1` (0–365) | threshold for marking a heading 「保存于 N 天前」(saved N days ago); 0 = mark every one |
|
|
434
|
+
| `SAGE_MEM_TASK_ID` | empty | scope of task checkpoints; empty falls back to the session id |
|
|
435
|
+
| `SAGE_MEM_DEBUG` | off | debug logging; when on, the panel's **工具 ▾ → 注入调试** shows the last 8 injection attempts (read-only, never written to disk; it also lists any memory kept out of the body by an expired `invalid_at` or an unresolved contradiction, with the reason and the conflict id) |
|
|
436
|
+
| `SAGE_MEM_VALIDITY` | on | master switch for invalidity filtering and contradiction suppression. **Only `0` / `false` / `off` turn it off** (empty or misspelled values count as on); off restores the 1.9.x injection behaviour |
|
|
437
|
+
|
|
438
|
+
Value semantics: **out-of-range values are clamped to the boundary, illegal values (including pure whitespace) fall back to the default.**
|
|
439
|
+
|
|
440
|
+
**Scope of the state root, and migration (spelled out since 2.1.13).** `SAGE_MEM_STATE_DIR` is the **single** state root: settings, autodream configuration and reports, the lock table, the access ledger, the conflict queue, task checkpoints, rollback snapshots and its index all live under it, and every module (including the autodream engine and the lock table) resolves it from the same value instead of re-deriving `<parent of the memory directory>/.sage-mem`. **Changing the state root does not migrate old data**: the new root wins, the old `.sage-mem` is left untouched but no longer read, and startup prints one line naming what is still in the old root. Move it by hand if you want to keep it.
|
|
441
|
+
|
|
442
|
+
### What the Settings Page Can Change
|
|
443
|
+
|
|
444
|
+
The panel can change the five caps above (written to `<state directory>/settings.json`, precedence **settings page > environment variable > built-in default**), as well as reserved names (file names that are protected and that the settings page may not write or delete, written to `<state directory>/reserved.json`). **Some items take effect only after restarting DSH**, and the panel writes out both the value in effect now and the value after restart.
|
|
445
|
+
|
|
446
|
+
### Files in the State Directory
|
|
447
|
+
|
|
448
|
+
| File | Purpose |
|
|
449
|
+
|---|---|
|
|
450
|
+
| `access.json` | access ledger (`injected` / `read` / `use`) |
|
|
451
|
+
| `conflicts/<id>.json` | pending conflicts: a failed write, or a pair of contradictions awaiting resolution |
|
|
452
|
+
| `tasks/<task id>/WORKING.md` | task checkpoints |
|
|
453
|
+
| `settings.json` / `reserved.json` / `locked.json` | settings, reserved names, locks |
|
|
454
|
+
| `autodream.json`, `autodream/` | autodream configuration, snapshots, run records |
|
|
455
|
+
| `dream.json` | the legacy name, read once only during migration |
|
|
456
|
+
|
|
457
|
+
Apart from `access.json`, `conflicts/` and `tasks/`, the rest is internal implementation state, and its **format may change in minor versions**; do not depend on it.
|
|
458
|
+
|
|
459
|
+
## Stability and Versioning Policy
|
|
460
|
+
|
|
461
|
+
**1.0.0 is this project's stability line.** From this version on, "what you can rely on" is no longer a piece of self-assessment but **a list that can be refuted by a machine**:
|
|
462
|
+
|
|
463
|
+
- **[Frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)** — the public surface is characterized item by item, with only four wordings: **guaranteed** (backed by an assertion; regressions go red) / **best-effort** (designed that way with empirical basis, but with no regression assertion that would go red) / **explicitly cannot do** (a capability boundary, a tradeoff rather than a defect) / **unverified** (we do not know).
|
|
464
|
+
- **Every "guaranteed" item can point to one concrete assertion** (one entry in some test suite), and `test/contract-binding-test.mjs` checks on every `npm test` that "this assertion really ran". A commitment that cannot point to an assertion can only be marked "best-effort" or "unverified".
|
|
465
|
+
- The **[capability boundaries table](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-boundaries.md)** is **cross-referenced line by line** with the contract, and `test/doc-consistency-test.mjs` checks that both sides characterize things the same way.
|
|
466
|
+
|
|
467
|
+
**How version numbers go up** (semantic versioning):
|
|
468
|
+
|
|
469
|
+
| Change | Version |
|
|
470
|
+
|---|---|
|
|
471
|
+
| Changing any item marked "guaranteed" in the contract, changing a tool name or a required parameter, dropping read compatibility with the old ledger format, changing an injection section name | **major** |
|
|
472
|
+
| Changing wording, changing ordering details, changing internal state file formats, changing the panel, adding a new environment variable, changing performance | minor / patch |
|
|
473
|
+
|
|
474
|
+
**Upgrading from 0.9.x to 1.0.0**: **there are no breaking changes** — not one of the tool names, parameters, environment variable names and defaults, injection section names, or ledger format changed. The only **behaviour change** is: an integer environment variable filled with **pure whitespace** now falls back to the default (previously it was treated as 0 and clamped to the lower bound). The item-by-item list is in the 1.0.0 section of the [CHANGELOG](https://github.com/gezi-wen/sage-mem/blob/main/CHANGELOG.md).
|
|
475
|
+
|
|
476
|
+
**Review material** (for those who want to dig in):
|
|
477
|
+
|
|
478
|
+
| File | Content |
|
|
479
|
+
|---|---|
|
|
480
|
+
| [1.0.0-contract.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md) | public commitments characterized item by item + the assertion each guarantee points to |
|
|
481
|
+
| [1.0.0-boundaries.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-boundaries.md) | 38 capabilities characterized item by item (with basis and means of verification) |
|
|
482
|
+
| [1.0.0-eval-report.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-eval-report.md) | quantified results for retrieval quality and budget, and what these numbers **cannot** measure |
|
|
483
|
+
| [review-findings.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/review-findings.md) | the permanent ledger of what external review found: each finding, its disposition, and **which assertion watches over it** |
|
|
484
|
+
| [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) | release discipline (the four sanitization checks, evaluation exit codes, probes, the four anchors) |
|
|
485
|
+
|
|
486
|
+
## Permissions and Privacy
|
|
487
|
+
|
|
488
|
+
sage-mem does three things only, all of them on your own machine:
|
|
489
|
+
|
|
490
|
+
| What it does | Concrete scope |
|
|
491
|
+
|---|---|
|
|
492
|
+
| **Reading memories** | reads only the top-level `*.md` under `SAGE_MEM_DIR` (default `~/.sage-mem/memory`), 512 KB per file |
|
|
493
|
+
| **Writing memories** | writes to the same directory through the model's file tools or the settings page; reserved names such as `MEMORY.md` and `session-log.md` are protected and the settings page cannot touch them |
|
|
494
|
+
| **Injection** | hands the selected memories to the model as context (at most 10 relevant + 8 `baseline`, 60 KB cumulative per session — ⚠️ this cap is **soft**, see [Retrieval and Injection](#retrieval-and-injection)) |
|
|
495
|
+
|
|
496
|
+
**What it does not do**: it opens no port, starts no long-running process, and needs no API key; it **does not capture every event** (whether to store is judged by the model according to rules; it is not "everything you say gets recorded").
|
|
497
|
+
|
|
498
|
+
**Two places where it goes out to the network / reads beyond its bounds, spelled out**:
|
|
499
|
+
|
|
500
|
+
1. **Version check**: opening the 「记忆管理」(Memory management) page silently makes one request to
|
|
501
|
+
`https://api.github.com/repos/gezi-wen/sage-mem/releases/latest` (10-second timeout; any failure is reported nowhere).
|
|
502
|
+
The request carries only the plugin's own `User-Agent`; **it contains no memory content, file path or usage statistics**. When `GITHUB_TOKEN` / `GH_TOKEN` is present in the environment it is attached (raising GitHub's unauthenticated 60/hour to 5000). This is the **only** network request.
|
|
503
|
+
2. **Session records**: when "autodream"'s input source is set to "memory + session records", it additionally **reads** the session records under `$DSH_HOME/sessions` for targeted search — that decompresses your raw conversation text (including private content) and sends it into the model context. **Off by default**.
|
|
504
|
+
|
|
505
|
+
**The only feature that calls a model is "autodream", and it is off by default.** Its model follows the current session's default model by default, and can also be specified separately in the settings page — once specified, only that one is accepted, with no silent fallback.
|
|
506
|
+
|
|
507
|
+
Your memories are the files in that directory, openable at any time for confirmation — which is also why file-based was chosen.
|
|
508
|
+
|
|
509
|
+
**Trust boundary for the memory directory.** Anyone who can write the memory directory can change what gets injected into the model — that is **inside** the trust model, not a vulnerability in the plugin. The read path rejects symlinks, reserved-name aliases and files over 512 KB at check time, and re-verifies file identity when reading (a file swapped in after the check is refused instead of injected); it cannot defend against a writer who is already allowed to modify your memories. Keep the directory's permissions to your own account.
|
|
510
|
+
|
|
511
|
+
## Compatibility
|
|
512
|
+
|
|
513
|
+
sage-mem is a pure DSH plugin and declares its host requirement through **`engines.dsh`** in `package.json`: `>=0.1.2-rc.1` — **a one-sided open interval with no upper bound**, so no future DSH version will be blocked by this declaration. `engines.node` requires `>= 18`.
|
|
514
|
+
|
|
515
|
+
**Verified on DSH Go (the Android client).** After installing from the in-app plugin market, the settings section and sidebar panel render normally and the `sidebar.panellist` island opens; the memory directory keeps sage-mem's default `~/.sage-mem/memory`, no extra adaptation needed. 2.1.1 fixes the invisible primary-button label on the phone host (foreground token fallback).
|
|
516
|
+
|
|
517
|
+
Version-by-version compatibility record:
|
|
518
|
+
|
|
519
|
+
| DSH version | Status |
|
|
520
|
+
| --- | --- |
|
|
521
|
+
| 0.1.2-rc.1 | compatible |
|
|
522
|
+
| 0.1.3-alpha.1 | compatible |
|
|
523
|
+
| 0.1.3-alpha.2 | compatible |
|
|
524
|
+
| 0.1.5-rc.1 | compatible |
|
|
525
|
+
| 0.1.6-alpha.1 | compatible |
|
|
526
|
+
| 0.1.6-alpha.2 | compatible |
|
|
527
|
+
| 0.1.7-rc.1 | compatible |
|
|
528
|
+
| 0.1.7-rc.2 | compatible |
|
|
529
|
+
| 0.2.0-rc.1 | compatible |
|
|
530
|
+
| 0.2.0-rc.2 | compatible |
|
|
531
|
+
| 0.2.1-rc.1 | compatible |
|
|
532
|
+
| 0.2.1-rc.3 | compatible |
|
|
533
|
+
| 0.2.1-rc.4 | compatible |
|
|
534
|
+
| 0.2.1-rc.5 | compatible |
|
|
535
|
+
|
|
536
|
+
**How to read `compatible`.** It means this plugin's codec and manifest contract has been checked against that host generation (code-level comparison plus the unit/contract suite), **not** that an end-to-end probe was run on that exact host build. The table is a declaration; how each claim was checked is summarized here. Real-host probes so far: `headless` (2026-10-15), `web` (2026-10-16), `web` on **0.2.1-rc.3** for the 2.0.0 release (2026-10-27: client activation, conflict panel arbitration, injection suppression, rollback to 1.9.2 — 23/23 checks), and the **DSH Go mobile engine on 0.2.1-rc.4** (2026-10-31: installed from the in-app plugin market, sidebar panel and settings section rendered by the app, `sidebar.panellist` island opened); `0.2.1-rc.5` was checked on a local web instance the same day (composed config, boot, panel walk-through). The `desktop` profile has **not** been probed yet (see [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b). CI runs on Ubuntu against **Node 18.x / 20.x / 24.x** with the `0.2.0-rc.1` peer generation; macOS and Windows are not CI-covered, and there is no desktop-host probe yet.
|
|
537
|
+
|
|
538
|
+
Two points worth spelling out:
|
|
539
|
+
|
|
540
|
+
- **A single build covers both the `0.1.x` and the `0.2.x` line.** Since DSH `0.1.6-alpha.2` the TypertCodec contract changed from "read `schema`" to "read the `create()` factory", and a plugin that still writes only `schema:` throws directly during registration and drags down the whole plugin tree. Every codec in this plugin carries both `schema` and `create: () => schema`, so DSH from both eras can load it. The ranges of the peer dependencies `@deepseek-ai/dsh-typert-protocol` and `@deepseek-ai/dsh-client-locale` drop their upper bound, so that some future major version is not mistakenly blocked by this declaration (between `0.1.7-rc.2` and `0.2.0-rc.1` these two packages added and removed zero exported symbols; compared byte by byte).
|
|
541
|
+
- **One layer of defensive tracing is included.** The event names and fields the injection chain depends on have been very stable across everyday upgrades; if DSH ever changes them to asynchronous or renames them, the plugin will **raise an alarm in the log**, rather than silently stopping injecting memories.
|
|
542
|
+
|
|
543
|
+
## Architecture
|
|
544
|
+
|
|
545
|
+
```
|
|
546
|
+
DSH (Cordis plugin host)
|
|
547
|
+
└─ sage-mem plugin (retrieval by question + injection) ← this repository
|
|
548
|
+
│ direct Node fs read / write
|
|
549
|
+
▼
|
|
550
|
+
memory/ directory (markdown files, 4 types)
|
|
551
|
+
├── user_*.md
|
|
552
|
+
├── feedback_*.md
|
|
553
|
+
├── project_*.md
|
|
554
|
+
├── reference_*.md
|
|
555
|
+
└── MEMORY.md ← index
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
No worker, no SQLite, no HTTP port, no long-running process.
|
|
559
|
+
|
|
560
|
+
Module boundaries (since 1.2.0):
|
|
561
|
+
|
|
562
|
+
- `lib/index.js` — the **composition root** plus the three gateways. The typert manifest pins `sourceLocation` to this file, so the gateway classes stay here (moving them would be a contract drift)
|
|
563
|
+
- `lib/recall/` — the injection chain: selection (`select`) / rendering and budget (`compose`) / text (`text`) / session state (`state`)
|
|
564
|
+
- `lib/assembly/` — the four mounts: autodream / retrieval tools / observation / write gate
|
|
565
|
+
- `lib/memory/` — pure memory-layer functions and file operations; `lib/autodream/` — the autodream engine
|
|
566
|
+
- `lib/shared/` — zero-dependency cross-layer rules: file-name rule / local timestamps / the two size limits (**exactly one copy each**; `test/duplication-test.mjs` turns red otherwise)
|
|
567
|
+
- UI side: `client/*.js` is split per screen (`05-shared.js` holds the cross-screen primitives and type tables); `npm run build:client` concatenates them into `lib/client.js`
|
|
568
|
+
- Dependency direction: `memory` never imports `autodream`, `recall` depends only on `memory`, `assembly` never imports the composition root (`test/structure-test.mjs` turns red if that changes)
|
|
569
|
+
|
|
570
|
+
## Contributing
|
|
571
|
+
|
|
572
|
+
```bash
|
|
573
|
+
git clone https://github.com/gezi-wen/sage-mem
|
|
574
|
+
cd sage-mem
|
|
575
|
+
npm install
|
|
576
|
+
npm test # all assertions (including the three guards)
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
- The assertion scripts depend only on `node:` built-in modules and **introduce no test framework**; each script hand-writes `ok()` and ends with a summary line.
|
|
580
|
+
- This repository uses **npm** for install / test / CI. No `pnpm-lock.yaml` is kept: a stale one could not be frozen-installed from a fresh clone (its workspace file was untracked), so it was removed rather than left to mislead.
|
|
581
|
+
- **Three guards** watch over "whether the tests themselves hold up": `suite-registry-test.mjs` (is there a suite that was written but never runs), `contract-binding-test.mjs` (is there a commitment that was made but never verified), `doc-consistency-test.mjs` (do the two documents characterize things identically line by line).
|
|
582
|
+
- CI runs twice on `ubuntu-latest` (the second time with `TZ=UTC`) — a case-sensitive volume catches defects that are invisible on Windows.
|
|
583
|
+
- When you change **user-visible behaviour**, please also update the corresponding line in the [contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md) and the CHANGELOG; a commitment you cannot write an assertion for can only be marked "best-effort".
|
|
584
|
+
|
|
585
|
+
**Questions and suggestions**: open an [Issue](https://github.com/gezi-wen/sage-mem/issues). Small, well-defined fixes are welcome as direct PRs; for changes with a larger surface, open an Issue first to align on direction, so that the work is not wasted.
|
|
586
|
+
|
|
587
|
+
## FAQ
|
|
588
|
+
|
|
589
|
+
**What changes in injection behaviour after upgrading to 2.0.0?**
|
|
590
|
+
2.0.0 is a **behaviour-changing release** (hence MAJOR): ① a memory whose `invalid_at` has passed is **no longer injected as body text**; ② a memory with an unresolved contradiction keeps **both sides out of the body** until you resolve it on the 「记忆管理 → 冲突」(Memory management → Conflicts) tab; ③ **unlabelled old memories behave exactly as before** — no migration is needed, and there is no migration script. **Downgrading to 1.9.x is safe**: no data is damaged, but the old version ignores the invalid markers (invalid memories are injected again) and the conflicts tab disappears — that is **functionality disappearing, not data corruption**. If you only want to turn this filter off while keeping the rest of this release, use `SAGE_MEM_VALIDITY=0` (only `0` / `false` / `off` count as off).
|
|
591
|
+
|
|
592
|
+
**What do I do if my memories are not being injected?**
|
|
593
|
+
First confirm the memory directory is right (`SAGE_MEM_DIR`, default `~/.sage-mem/memory`), then check whether the files have frontmatter (broken frontmatter is **handled leniently** — the file is still read, with fields falling back to defaults). The 「体检」(audit) page in the panel reports structural problems such as dangling index entries, missing index entries and broken links.
|
|
594
|
+
|
|
595
|
+
**Why can it not find something when I phrase it differently?**
|
|
596
|
+
Retrieval is **lexical overlap**, not semantic. Add a line `aliases: [another wording]` to that memory. This is a deliberate tradeoff, not a defect waiting to be fixed.
|
|
597
|
+
|
|
598
|
+
**Will it record every word I say?**
|
|
599
|
+
No. Whether to store is judged by the model according to rules: nothing without substance is stored, nothing derivable by reading the code / git is stored.
|
|
600
|
+
|
|
601
|
+
**I want to see what it actually stored.**
|
|
602
|
+
Open the memory directory; it is all Markdown. The 「记忆管理」(Memory management) page in the GUI can also browse, edit and delete.
|
|
603
|
+
|
|
604
|
+
**Is it safe for several DSH instances to share one memory directory?**
|
|
605
|
+
No. Serialized writes guarantee only **in-process** ordering; there is no file lock across processes — using two instances to write to the same directory at the same time can still lose one write. This is a known boundary.
|
|
606
|
+
|
|
607
|
+
**Can I do without autodream?**
|
|
608
|
+
Yes, it is off by default. It and archiving are two different things: archiving is a deterministic rule (by idle days), calls no model and costs nothing.
|
|
609
|
+
|
|
610
|
+
**The plugin market says "the registry's latest is older than the installed version, updating would downgrade"?**
|
|
611
|
+
That is a **stale `/latest` cache on the download-region mirror**, not a mis-published package: the mirror's full metadata (the document pnpm reads) already has the new version, while the `/<pkg>/latest` shortcut still serves an old one. Retry after a minute or two; if it persists, temporarily switch the market's download region to "Global" (the origin registry, registry.npmjs.org), update, then switch back. To check what is actually published: `npm view sage-mem dist-tags`.
|
|
612
|
+
|
|
613
|
+
**Why is the downloads badge an endpoint badge instead of `npm/dm`?**
|
|
614
|
+
npm's own `downloads/point/last-month` API has returned 0 for every package since July 2026, which made `npm/dm` render a red `0/month`. The badge is now computed from the still-working `downloads/range` API by a daily GitHub Actions job and published to the `badges` branch as a [shields.io endpoint badge](https://shields.io/badges/endpoint-badge). If the number has not moved for more than ~2 days, it is usually a delayed scheduled run (or an npm API outage, in which case the last good value is kept on purpose).
|
|
615
|
+
|
|
616
|
+
## Uninstall
|
|
617
|
+
|
|
618
|
+
```bash
|
|
619
|
+
dsh plugin --profile web remove sage-mem
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Or manually delete the `sage-mem` dependency from the profile's `package.json` and remove `"sage-mem"` from `dsh.profile.bundles`; then `pnpm install` and restart DSH.
|
|
623
|
+
|
|
624
|
+
**The memory Markdown files stay in their original directory, unaffected.**
|
|
625
|
+
|
|
626
|
+
## Maintainer
|
|
627
|
+
|
|
628
|
+
[@gezi-wen](https://github.com/gezi-wen)
|
|
629
|
+
|
|
630
|
+
## Credits
|
|
631
|
+
|
|
632
|
+
- [@bjvgukv25842-cmyk](https://github.com/bjvgukv25842-cmyk) (LITMUS) — the earliest Web memory management UI; the memory management page started here.
|
|
633
|
+
- [@miztizm](https://github.com/miztizm) (mjztjzm) — i18n for the memory panel: the dictionary shard and the `ctx.locale` wiring, so three screens follow the harness language (1.9.0).
|
|
634
|
+
- [@DDDMUC](https://github.com/DDDMUC) — three things: the open-memory-folder entry in the file list; the short-circuit gate in the recall chain (2.4.3, the same question is no longer re-scored on every step); and `interpolate: false` on the recall context (2.4.2, a memory containing double braces no longer kills the whole turn).
|
|
635
|
+
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — the plugin host and the Cordis plugin system.
|
|
636
|
+
|
|
637
|
+
This plugin is alive today because of more than one person; the list above follows the order their code entered the repository. If I missed someone, open an issue and tell me off — I will add them.
|
|
638
|
+
|
|
639
|
+
## Related Projects
|
|
640
|
+
|
|
641
|
+
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — DSH itself, everything is a plugin
|
|
642
|
+
- [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) — a catalog of the DSH plugin ecosystem
|
|
643
|
+
|
|
644
|
+
## Legacy
|
|
645
|
+
|
|
646
|
+
This repository was originally a SQLite + worker architecture, now archived on the [`sqlite-worker`](https://github.com/gezi-wen/sage-mem/tree/sqlite-worker) branch. The file-based version is the successor implementation.
|
|
647
|
+
|
|
648
|
+
## License
|
|
649
|
+
|
|
650
|
+
Apache-2.0 · see [LICENSE](https://github.com/gezi-wen/sage-mem/blob/main/LICENSE)
|