sage-mem 2.0.0 → 2.0.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.md +4 -4
- package/README.zh.md +4 -4
- package/lib/client.js +26 -2
- package/lib/recall/injector.js +52 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.0.
|
|
15
|
+
**Current release: `2.0.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
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
|
@@ -70,7 +70,7 @@ Most memory schemes take the "SQLite + a long-running worker" route: once memori
|
|
|
70
70
|
| Feature | Description |
|
|
71
71
|
|---|---|
|
|
72
72
|
| **File-based storage** | One `.md` per memory: frontmatter holds metadata, the body holds content. No database, no hidden format |
|
|
73
|
-
| **Automatic injection** | Every
|
|
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
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
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
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`) |
|
|
@@ -116,7 +116,7 @@ Then **open a new session** and try three steps:
|
|
|
116
116
|
|
|
117
117
|
1. Tell the model "remember: my cat is called Zhima, and it likes sunbathing" — it writes this into the memory directory by its rules
|
|
118
118
|
2. Open another **new session** and ask "what is my cat called"
|
|
119
|
-
3.
|
|
119
|
+
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
|
|
120
120
|
|
|
121
121
|
<details>
|
|
122
122
|
<summary>Manual install (if you do not want the CLI, or want to install a local development build)</summary>
|
|
@@ -415,7 +415,7 @@ The two things are moved **differently**, spelled out:
|
|
|
415
415
|
| `SAGE_MEM_MAX_BASELINE` | `8` (1–50) | number of `baseline` slots |
|
|
416
416
|
| `SAGE_MEM_STALE_DAYS` | `1` (0–365) | threshold for marking a heading 「保存于 N 天前」(saved N days ago); 0 = mark every one |
|
|
417
417
|
| `SAGE_MEM_TASK_ID` | empty | scope of task checkpoints; empty falls back to the session id |
|
|
418
|
-
| `SAGE_MEM_DEBUG` | off | debug logging; when on, the panel's **工具 ▾ → 注入调试** shows the last 8 injection attempts (read-only, never written to disk) |
|
|
418
|
+
| `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) |
|
|
419
419
|
| `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 |
|
|
420
420
|
|
|
421
421
|
Value semantics: **out-of-range values are clamped to the boundary, illegal values (including pure whitespace) fall back to the default.**
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.0.
|
|
17
|
+
**当前发布版本:`2.0.1`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
|
@@ -72,7 +72,7 @@ sage-mem 补上这一层,做法是:**把该记住的事写成一张张卡片
|
|
|
72
72
|
| 特性 | 说明 |
|
|
73
73
|
|---|---|
|
|
74
74
|
| **文件式存储** | 每条记忆一个 `.md`:frontmatter 存元数据,正文存内容。无数据库、无隐藏格式 |
|
|
75
|
-
| **自动注入** |
|
|
75
|
+
| **自动注入** | 每条新问题都会重新扫一遍记忆目录,按这条问题挑出最相关的几条,在模型开口之前拼进上下文 —— 不用模型自己去翻文件 |
|
|
76
76
|
| **模型也能自己查** | 另给模型两个检索工具(`search_memory` / `read_memory`),它可以主动翻记忆、按行读原文 |
|
|
77
77
|
| **写入不被覆盖** | 你正在编辑、模型同时在改的时候,后写的不会静默盖掉先写的;冲突留成待处理记录,由你决定保留哪边 |
|
|
78
78
|
| **零额外服务** | 无 worker、无 SQLite、无端口、无常驻进程,不需要额外的 API key 或云服务(运行时唯一的依赖是普通 npm 库 `zod`) |
|
|
@@ -116,7 +116,7 @@ dsh plugin --profile web list
|
|
|
116
116
|
|
|
117
117
|
1. 跟模型说「记住:我的猫叫芝麻,它喜欢晒太阳」—— 它按规则把这条写进记忆目录
|
|
118
118
|
2. 再开一个**新会话**,问「我的猫叫什么」
|
|
119
|
-
3.
|
|
119
|
+
3. 它的第一个回答就该答出「芝麻」—— 记忆由插件自动检索并注入,模型没有主动去翻文件
|
|
120
120
|
|
|
121
121
|
<details>
|
|
122
122
|
<summary>手动安装(不想用 CLI,或要装本地开发版本)</summary>
|
|
@@ -415,7 +415,7 @@ sage-mem 给记忆目录的写入加了**版本守卫**:写入前记下当前
|
|
|
415
415
|
| `SAGE_MEM_MAX_BASELINE` | `8`(1–50) | `baseline` 名额 |
|
|
416
416
|
| `SAGE_MEM_STALE_DAYS` | `1`(0–365) | 标题标「保存于 N 天前」的门槛;0 = 每条都标 |
|
|
417
417
|
| `SAGE_MEM_TASK_ID` | 空 | 任务检查点的作用域;空则回落会话 id |
|
|
418
|
-
| `SAGE_MEM_DEBUG` | 关 | 调试日志;打开后面板「工具 ▾ → 注入调试」显示最近 8
|
|
418
|
+
| `SAGE_MEM_DEBUG` | 关 | 调试日志;打开后面板「工具 ▾ → 注入调试」显示最近 8 次注入的账(只读、不落盘;同时列出被已失效标注 / 未裁决矛盾挡在正文外的记忆与理由,矛盾还带冲突 id) |
|
|
419
419
|
| `SAGE_MEM_VALIDITY` | 开 | 失效过滤与矛盾抑制的总开关。**只认 `0` / `false` / `off` 为关闭**(空值、拼错都按开启);关掉后回到 1.9.x 的注入行为 |
|
|
420
420
|
|
|
421
421
|
取值语义:**越界夹到边界,非法值(含纯空格)回落默认。**
|
package/lib/client.js
CHANGED
|
@@ -2923,14 +2923,18 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
2923
2923
|
: null);
|
|
2924
2924
|
|
|
2925
2925
|
/**
|
|
2926
|
-
* 「注入调试」面板(1.7.0
|
|
2926
|
+
* 「注入调试」面板(1.7.0;2.0.1 起显示有效性抑制明细)。
|
|
2927
2927
|
*
|
|
2928
2928
|
* 只读:没有「清空 / 重跑」按钮 —— 账是注入链在 `SAGE_MEM_DEBUG=1` 时自动记的,
|
|
2929
2929
|
* 人能做的是看,以及点「刷新」再取一次。入口只在开关打开时出现(宿主告诉我们)。
|
|
2930
2930
|
*
|
|
2931
|
-
* 每一条记录的口径写在 `lib/recall/
|
|
2931
|
+
* 每一条记录的口径写在 `lib/recall/injector.js` 的 makeDebug 注释里;这里只负责把
|
|
2932
2932
|
* 「为什么这一步没注入」翻译成人话 —— 那是这个面板存在的唯一理由。**尤其是
|
|
2933
2933
|
* `empty + deduped`**:每轮多步里它占大多数,不解释就会被当成「插件坏了」。
|
|
2934
|
+
*
|
|
2935
|
+
* 2.0.0 让失效 / 未裁决矛盾的记忆不再进正文。2.0.1 把这两类的**文件名、理由与
|
|
2936
|
+
* 冲突 id** 单列一行:否则「这条记忆不见了」在面板上只能看到它不在 injected 里,
|
|
2937
|
+
* 分不清是没检索到还是被新规则挡住 —— 而那正是 2.0.0 之后最需要解释的一种消失。
|
|
2934
2938
|
*/
|
|
2935
2939
|
function RecallDebugPanel(props) {
|
|
2936
2940
|
const data = props.data || {};
|
|
@@ -2965,6 +2969,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
2965
2969
|
}));
|
|
2966
2970
|
}
|
|
2967
2971
|
const injected = latest && Array.isArray(latest.injected) ? latest.injected : [];
|
|
2972
|
+
// 2.0.1:被有效性挡掉的条目。旧记录没有这个字段 → 空数组(面板显示「无」),
|
|
2973
|
+
// 不因为多一个可选字段就把老账读崩。
|
|
2974
|
+
const validitySuppressed = latest && Array.isArray(latest.validitySuppressed) ? latest.validitySuppressed : [];
|
|
2968
2975
|
const histogram = recs.slice(1).map(function (r, i) {
|
|
2969
2976
|
return h("div", { className: "smem-dbg-hist-row", key: "h" + i }, [
|
|
2970
2977
|
h("span", { className: "smem-dbg-time", key: "t" }, clock(r.at)),
|
|
@@ -3008,6 +3015,23 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
3008
3015
|
"正文字节 " + String(latest.chars || 0) + "(本代累计 " + String(latest.sessionChars || 0) + ")" +
|
|
3009
3016
|
" · 状态卡 " + (latest.cardChars ? String(latest.cardChars) + " 字节" : "未发") +
|
|
3010
3017
|
" · 预算挡掉 " + String(latest.skippedByBudget || 0) + " 条"),
|
|
3018
|
+
h("div", { className: "smem-dbg-row", key: "vs" }, [
|
|
3019
|
+
h("span", { className: "smem-label", key: "l" }, "有效性抑制"),
|
|
3020
|
+
validitySuppressed.length
|
|
3021
|
+
? h("span", { className: "smem-dbg-scores", key: "v" }, validitySuppressed.map(function (x, i) {
|
|
3022
|
+
const reason = String((x && x.label) || (x && x.reason) || "未知理由");
|
|
3023
|
+
const cid = x && x.conflictId ? " · " + String(x.conflictId) : "";
|
|
3024
|
+
return h("span", { className: "smem-dbg-score", key: String((x && x.file) || i) + "-" + i }, [
|
|
3025
|
+
h("span", { className: "smem-dbg-file", key: "f" }, String((x && x.file) || "?")),
|
|
3026
|
+
h("span", { className: "smem-dbg-num", key: "r" }, reason + cid),
|
|
3027
|
+
]);
|
|
3028
|
+
}))
|
|
3029
|
+
: h("span", { className: "smem-muted", key: "v" }, "无"),
|
|
3030
|
+
]),
|
|
3031
|
+
validitySuppressed.length
|
|
3032
|
+
? h("div", { className: "smem-ar-hint", key: "vshint" },
|
|
3033
|
+
"被标失效或待裁决矛盾挡掉的记忆不会进正文 —— 这不是检索没命中,是 2.0.0 的有效性规则生效;去「冲突」页签可裁决。")
|
|
3034
|
+
: null,
|
|
3011
3035
|
h("div", { className: "smem-ar-hint", key: "hint" },
|
|
3012
3036
|
"注入每走一步都会尝试一次;同题同命中集不会重复注入 —— 所以「空」多半是去重生效,不是坏了。"),
|
|
3013
3037
|
histogram.length
|
package/lib/recall/injector.js
CHANGED
|
@@ -26,6 +26,43 @@ import { buildCandidates, composeDelivery, composeStatus } from './compose.js'
|
|
|
26
26
|
import { composeRecallText } from './text.js'
|
|
27
27
|
import { debugEnabled, debugRecall, recordInjection } from './debug.js'
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 2.0.1:调试账里「被有效性挡掉」的两类理由(2.0.0 新引入的抑制)。
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ **只收这两个**:`below_floor` / `budget_omitted` 等老理由在 2.0.0 之前就有,
|
|
33
|
+
* 它们的解释已经在面板的「预算挡掉 N 条」与得分列表里;而失效与未裁决矛盾是
|
|
34
|
+
* 2.0.0 让记忆**从正文里消失**的唯一原因 —— 不单列出来,用户与维护侧都只能靠
|
|
35
|
+
* 猜「这条到底是没检索到,还是被新规则挡住了」。
|
|
36
|
+
*/
|
|
37
|
+
const VALIDITY_SUPPRESS_REASONS = new Set(['expired_source', 'conflict_pending'])
|
|
38
|
+
|
|
39
|
+
/** reason → 面板上的一句人话(英文由 client 侧字典决定,这里只给稳定键)。 */
|
|
40
|
+
const VALIDITY_REASON_LABEL = Object.freeze({
|
|
41
|
+
expired_source: '已失效',
|
|
42
|
+
conflict_pending: '待裁决矛盾',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 从 `decideDelivery` 的逐条处置里挑出被有效性挡掉的,附上文件名、理由与(可选)冲突 id。
|
|
47
|
+
*
|
|
48
|
+
* 纯函数,给调试账用;**不影响注入结果** —— items 是决策的产物,这里只是把其中
|
|
49
|
+
* 两类 reason 摘出来。`conflictIds` 是「文件名 → 冲突记录 id」的映射:同一份文件被
|
|
50
|
+
* 多条记录牵扯时取第一条(按 `listConflictIndex` 的时间倒序),够定位就行。
|
|
51
|
+
*/
|
|
52
|
+
function validitySuppressedItems(items, conflictIds) {
|
|
53
|
+
const out = []
|
|
54
|
+
for (const it of Array.isArray(items) ? items : []) {
|
|
55
|
+
if (!it || !VALIDITY_SUPPRESS_REASONS.has(it.reason)) continue
|
|
56
|
+
const row = { file: String(it.file ?? ''), reason: it.reason, label: VALIDITY_REASON_LABEL[it.reason] }
|
|
57
|
+
if (it.reason === 'conflict_pending' && conflictIds instanceof Map) {
|
|
58
|
+
const id = conflictIds.get(row.file)
|
|
59
|
+
if (id) row.conflictId = id
|
|
60
|
+
}
|
|
61
|
+
out.push(row)
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
/**
|
|
30
67
|
* @param {object} deps
|
|
31
68
|
* @param {object} deps.ctx — 宿主 ctx(`scanMemoryFiles` / `warnLog` 用)
|
|
@@ -95,6 +132,9 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
|
|
|
95
132
|
// ⚠️ 只对 `contradiction` 进集合:写失败型记录里盘上那份仍是现行内容(§6.3)。
|
|
96
133
|
let conflictsForDelivery = new Set()
|
|
97
134
|
let pendingConflicts = 0
|
|
135
|
+
// 2.0.1:调试账用的「文件名 → 冲突记录 id」。只在开关打开时才读得到,但先按需
|
|
136
|
+
// 建一份空 Map(代价可忽略,比在 makeDebug 里为了 id 再扫一次队列便宜)。
|
|
137
|
+
const conflictIdByFile = new Map()
|
|
98
138
|
const validity = validityEnabled()
|
|
99
139
|
const expired = validity ? buildExpirySet(files, nowMs) : new Set()
|
|
100
140
|
if (validity) {
|
|
@@ -104,6 +144,13 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
|
|
|
104
144
|
const listed = await listConflictIndex({ stateRoot: STATE_ROOT })
|
|
105
145
|
if (listed.ok) {
|
|
106
146
|
conflictsForDelivery = buildContradictionFileSet(listed.items)
|
|
147
|
+
for (const c of listed.items) {
|
|
148
|
+
if (!c || c.status !== 'open' || c.kind !== 'contradiction') continue
|
|
149
|
+
for (const key of ['file', 'otherFile']) {
|
|
150
|
+
const name = typeof c[key] === 'string' ? c[key].trim() : ''
|
|
151
|
+
if (name !== '' && !conflictIdByFile.has(name)) conflictIdByFile.set(name, c.id)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
107
154
|
pendingConflicts = listed.items.filter((c) => c.status === 'open' && c.kind === 'contradiction').length
|
|
108
155
|
} else {
|
|
109
156
|
warnLog(ctx, `读冲突队列失败,本轮不做矛盾抑制:${listed.error ?? listed.code}`, sessionId)
|
|
@@ -162,7 +209,10 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
|
|
|
162
209
|
// - `sessionChars` = 本代累计正文字节(预算账);
|
|
163
210
|
// - `cardChars` = 这一步发出去的状态卡字节(没发就是 0);
|
|
164
211
|
// - `deduped` = 同题同命中集刚注过,这一步没重复追加 —— 面板上要解释成
|
|
165
|
-
//
|
|
212
|
+
// 「不是坏了,是去重生效」,否则每轮多步里大部分账看着都是空的;
|
|
213
|
+
// - `validitySuppressed`(2.0.1)= 被 2.0.0 的有效性规则挡在正文外的条目,
|
|
214
|
+
// 带 `reason`(expired_source / conflict_pending)与 `label`;矛盾还带
|
|
215
|
+
// `conflictId`,便于去「冲突」页签定位。**这不是检索失败**,是规则生效。
|
|
166
216
|
const debugOn = debugEnabled()
|
|
167
217
|
const makeDebug = debugOn
|
|
168
218
|
? (outcome) => ({
|
|
@@ -182,6 +232,7 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
|
|
|
182
232
|
sessionChars: contentBytes,
|
|
183
233
|
cardChars: cardPart ? Buffer.byteLength(cardPart, 'utf8') : 0,
|
|
184
234
|
skippedByBudget: skippedForBudget,
|
|
235
|
+
validitySuppressed: validitySuppressedItems(delivered.delivery.items, conflictIdByFile),
|
|
185
236
|
card: cardPart ? 'sent' : (status.cardText ? 'deduped-or-over-budget' : 'none'),
|
|
186
237
|
})
|
|
187
238
|
: () => null
|
package/package.json
CHANGED