sage-mem 2.1.13 → 2.1.15
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/memory/checkpoint.js +50 -32
- package/lib/recall/injector.js +7 -6
- package/lib/recall/state.js +40 -12
- 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.1.
|
|
15
|
+
**Current release: `2.1.15`** — 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.
|
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.1.
|
|
17
|
+
**当前发布版本:`2.1.15`** —— 顶部 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 是最直接的反馈。
|
package/lib/memory/checkpoint.js
CHANGED
|
@@ -497,26 +497,22 @@ export async function writeCheckpoint(taskId, text, opts) {
|
|
|
497
497
|
/** 注入用的短句柄硬上限(字节)。它要跟正文抢上下文,不能自己长起来。 */
|
|
498
498
|
const HANDLE_MAX_BYTES = 160
|
|
499
499
|
|
|
500
|
-
/**
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const b = Buffer.byteLength(ch, 'utf8')
|
|
507
|
-
if (used + b > maxBytes) break
|
|
508
|
-
out += ch
|
|
509
|
-
used += b
|
|
510
|
-
}
|
|
511
|
-
return out
|
|
512
|
-
}
|
|
500
|
+
/**
|
|
501
|
+
* 句柄里相对路径的**基准标记**:相对路径只说「相对谁」,不说清基准就是歧义的
|
|
502
|
+
* (去记忆目录里找?去状态根里找?)。生成与解析**共用这一个常量** ——
|
|
503
|
+
* 两边各写一份字符串,改了一边就会静默给出读不到的路径(Q12 的旧句柄正是这么坏的)。
|
|
504
|
+
*/
|
|
505
|
+
const HANDLE_BASE_MARK = '状态根下'
|
|
513
506
|
|
|
514
507
|
/**
|
|
515
|
-
* 注入用的短句柄(一行,≤ 160 字节):说清「有这个检查点、revision
|
|
508
|
+
* 注入用的短句柄(一行,≤ 160 字节):说清「有这个检查点、revision 几、去哪读」。
|
|
516
509
|
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
510
|
+
* 预算是硬的,按优先级退化:**可解析路径与 revision 不可丢**,项目名是可选线索
|
|
511
|
+
* (放不下就整个丢掉),「续做前先读」是纯提示(再放不下就丢它)。taskId 非法返回 ''。
|
|
512
|
+
*
|
|
513
|
+
* ⚠️ **id 只出现一次**:完整相对路径 `tasks/<id>/WORKING.md` 已经含它了。
|
|
514
|
+
* 旧版在头部又重复了一遍 id,预算被耗光后退化成一条**不存在的路径**
|
|
515
|
+
* `tasks/WORKING.md`(2026-10-31 外部审查 Q12)—— 短了,但不再是准确的读取指令。
|
|
520
516
|
*
|
|
521
517
|
* revision 缺省按 0(与 renderCheckpoint 的缺省一致)。
|
|
522
518
|
*/
|
|
@@ -526,21 +522,43 @@ export function checkpointHandleText({ taskId, revision, project } = {}) {
|
|
|
526
522
|
const rev = Number.isInteger(revision) && revision >= 0 ? revision : 0
|
|
527
523
|
const proj = String(project ?? '').replace(/\s+/g, ' ').trim()
|
|
528
524
|
|
|
529
|
-
|
|
530
|
-
const
|
|
525
|
+
// 身份只表达一次:id 在路径里,revision 在头部。
|
|
526
|
+
const head = `检查点 revision ${rev}:`
|
|
527
|
+
const rel = `${TASKS_DIR_NAME}/${id}/${CHECKPOINT_FILE_NAME}`
|
|
531
528
|
const tail = ',续做前先读'
|
|
532
|
-
const build = (p) =>
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) {
|
|
542
|
-
// 到这儿只剩「id 太长」一种可能:完整相对路径(id 出现两次)放不下,退成不重复的写法。
|
|
543
|
-
line = `${head}读 ${TASKS_DIR_NAME}/${CHECKPOINT_FILE_NAME}`
|
|
544
|
-
}
|
|
529
|
+
const build = (p, withTail) =>
|
|
530
|
+
`${head}${HANDLE_BASE_MARK} ${rel}${withTail ? tail : ''}${p === '' ? '' : `(项目 ${p})`}`
|
|
531
|
+
|
|
532
|
+
let line = build(proj, true)
|
|
533
|
+
if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) line = build('', true)
|
|
534
|
+
if (Buffer.byteLength(line, 'utf8') > HANDLE_MAX_BYTES) line = build('', false)
|
|
535
|
+
// 到这儿只剩「revision 是天文数字」一种可能,合法整数不会触发。宁可给一条超长句柄,
|
|
536
|
+
// 也不截断路径:截出来的路径定位不到真实文件,比多占几个字节更糟。
|
|
545
537
|
return line
|
|
546
538
|
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 从短句柄里解析回检查点身份(生成规则见 `checkpointHandleText`)。
|
|
542
|
+
*
|
|
543
|
+
* 为什么需要它:句柄是**给模型执行的读取指令**,必须能被机器核对「它说的是不是真实文件」。
|
|
544
|
+
* 旧版退化成 `读 tasks/WORKING.md`,字节数达标、也含 id —— 只断言「≤160 字节 + 包含 id」
|
|
545
|
+
* 的测试正是这么放它过去的。断言要看的是**用同一解析规则能不能定位到真实文件**。
|
|
546
|
+
*
|
|
547
|
+
* 解析要求基准标记 + `tasks/<id>/WORKING.md`:没有基准的裸相对路径是歧义的,按失败处理。
|
|
548
|
+
*
|
|
549
|
+
* @param {string} text
|
|
550
|
+
* @returns {{taskId: string, revision: number} | null}
|
|
551
|
+
*/
|
|
552
|
+
export function parseCheckpointHandle(text) {
|
|
553
|
+
if (typeof text !== 'string') return null
|
|
554
|
+
const file = CHECKPOINT_FILE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
555
|
+
const re = new RegExp(
|
|
556
|
+
`检查点\\s+revision\\s+(\\d+)[::]\\s*${HANDLE_BASE_MARK}\\s+${TASKS_DIR_NAME}/([A-Za-z0-9._-]{1,64})/${file}`,
|
|
557
|
+
)
|
|
558
|
+
const m = text.match(re)
|
|
559
|
+
if (!m) return null
|
|
560
|
+
const id = normalizeTaskId(m[2])
|
|
561
|
+
if (id === null) return null
|
|
562
|
+
const revision = Number(m[1])
|
|
563
|
+
return Number.isSafeInteger(revision) ? { taskId: id, revision } : null
|
|
564
|
+
}
|
package/lib/recall/injector.js
CHANGED
|
@@ -110,9 +110,9 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
|
|
|
110
110
|
const files = await scanMemoryFiles(ctx, sessionId)
|
|
111
111
|
|
|
112
112
|
// ── 代次:**先处理状态转换,再算正文与控制提示** ──
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
113
|
+
// `begin()` 会把「这一次观测到的消息条数」立刻落进状态(Q08)——去重 / 零命中 /
|
|
114
|
+
// 预算拒绝的轮次不会走到 commit,若不在这里落账,下一次 begin 就还拿旧基数比,
|
|
115
|
+
// 压缩就永远判不出来。条数下降时它同时把签名、两本账、卡签名、baseline 标记归零。
|
|
116
116
|
const ledgerKey = session.id || 'no-session-id'
|
|
117
117
|
const st = state.begin(ledgerKey, messages.length)
|
|
118
118
|
|
|
@@ -249,9 +249,10 @@ export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
|
|
|
249
249
|
return assembled
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
252
|
+
// 投递账一次写回:签名、两本账、卡签名、缺口卡、baseline 标记,以及
|
|
253
|
+
// **delivered 条数**(`messageCount`)。放在这里(而不是更早)是刻意的 ——
|
|
254
|
+
// 上面每一步都可能没真注进去东西,那就不该让投递账前进;一次失败把一条记忆永久
|
|
255
|
+
// 挡在门外是最难查的一种。压缩判据用的**观测账**在 `begin()` 里已经落过了(Q08)。
|
|
255
256
|
state.commit(ledgerKey, {
|
|
256
257
|
signature,
|
|
257
258
|
contentBytes,
|
package/lib/recall/state.js
CHANGED
|
@@ -6,9 +6,20 @@
|
|
|
6
6
|
* 否则一次失败会把这条记忆永久挡在门外。唯一的例外是代次变化:那是**已经发生的事实**
|
|
7
7
|
* (上下文真的被压缩了),必须在算任何东西之前生效,否则这一步会拿着上一代的账做判断。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* ── 2.1.14(外部审查 Q08):两本账拆开 ─────────────────────────────────
|
|
10
|
+
* 旧实现只有一个 `messageCount`,记的是「上一次**见效**时的条数」,只有真正提交才前进。
|
|
11
|
+
* 于是被去重 / 零命中 / 预算拒绝的那些轮次从不更新它 —— 2 条时注入过、涨到 20 条被去重、
|
|
12
|
+
* 压缩到 10 条再问时,压缩判定拿 10 比的是 2(投递账),不是 20(刚观测到的实况),
|
|
13
|
+
* 判不出压缩,签名没清,继续去重、不注入。
|
|
14
|
+
*
|
|
15
|
+
* 现在两本账:
|
|
16
|
+
* - `lastObservedMessageCount`:**每次 assemble 都推进**(观测是既成事实,与是否投递无关);
|
|
17
|
+
* - `messageCount`(delivered 账):只有成功投递才前进。
|
|
18
|
+
* 压缩判定只看观测账;`begin()` 会在返回前把观测写回状态,所以「不提交的轮次」也不会丢。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ 诚实边界(作者已声明,本版不越界):压缩信号是「消息条数下降」这条启发式,它
|
|
21
|
+
* **覆盖不了条数不降的压缩**(宿主原地改写历史,或截断后又在同一步补足条数)。那种情况下
|
|
22
|
+
* 这里看不出代次变化,记忆仍会被去重挡住。要真正解决得等宿主给出可比的上下文代次标识。
|
|
12
23
|
*/
|
|
13
24
|
export function createSessionInjectState(maxSessions = 64) {
|
|
14
25
|
const sessions = new Map()
|
|
@@ -18,27 +29,44 @@ export function createSessionInjectState(maxSessions = 64) {
|
|
|
18
29
|
cardBytes: 0, // 本代状态卡累计字节(与正文分账,见 config.js)
|
|
19
30
|
cardSig: '', // 本代已发过的那张卡(含检查点句柄的合成文本)
|
|
20
31
|
gapCard: '', // 本代渲染过的缺口卡正文(签名没变时沿用,避免换个数字重发)
|
|
21
|
-
|
|
32
|
+
lastObservedMessageCount: -1, // 上一次 assemble **观测到**的消息条数(压缩判据的唯一基点)
|
|
33
|
+
messageCount: -1, // 上一次**成功投递**时的消息条数(delivered 账,只有 commit 推进)
|
|
22
34
|
baselineDone: false, // 本代是否已经给过 baseline
|
|
23
35
|
})
|
|
24
36
|
return {
|
|
25
37
|
/**
|
|
26
38
|
* 处理代次变化,取回本会话这一代的起点。**必须在算正文与控制提示之前调用。**
|
|
27
39
|
* 返回值是一份快照 —— 调用方只读,写回统一走 `commit()`。
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ 与旧实现不同:这里会**立刻**把观测到的条数写回状态。观测是已经发生的事实,
|
|
42
|
+
* 而「去重 / 零命中 / 预算拒绝」的轮次不会调用 `commit()` —— 不在这里落账,
|
|
43
|
+
* 下一次 begin 就还拿着旧基数比(Q08)。
|
|
28
44
|
*/
|
|
29
45
|
begin(key, messageCount) {
|
|
30
46
|
const prev = sessions.get(key)
|
|
31
|
-
const
|
|
32
|
-
//
|
|
47
|
+
const observed = Number.isFinite(messageCount) ? messageCount : -1
|
|
48
|
+
// 压缩判据对着**观测账**比,不是对着上一次投递比。
|
|
49
|
+
const generationChanged = !!prev && observed >= 0 && prev.lastObservedMessageCount >= 0 &&
|
|
50
|
+
observed < prev.lastObservedMessageCount
|
|
51
|
+
// 条数下降 → 除了观测基点,四样东西一起归零。少清任何一样都会留下一个「上一代的真相」:
|
|
33
52
|
// 字节账残留 → 新内容注不进来;baseline 残留 → 它再也不出现;
|
|
34
|
-
// 卡签名残留 →
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
// 卡签名残留 → 恢复入口永久失效;投递账残留 → 调试/诊断把旧代当新代。
|
|
54
|
+
const base = generationChanged ? fresh() : { ...(prev ?? fresh()) }
|
|
55
|
+
base.lastObservedMessageCount = observed
|
|
56
|
+
sessions.set(key, base)
|
|
57
|
+
// 只 begin 不提交的会话(全程被去重)也会进 Map —— 长驻宿主里必须同样按 FIFO 收敛。
|
|
58
|
+
if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
|
|
59
|
+
return { ...base, generationChanged }
|
|
38
60
|
},
|
|
39
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* 一次写回。只防长驻进程无限增长:被挤掉的老会话下次醒来至多把同一批记忆再注一次。
|
|
63
|
+
*
|
|
64
|
+
* 合并(而不是整体替换)是刻意的:`begin()` 刚写下的观测账必须留在状态里 ——
|
|
65
|
+
* 调用方只回传投递账(签名 / 字节 / 卡 / baseline),不该关心、也不该覆盖观测账。
|
|
66
|
+
*/
|
|
40
67
|
commit(key, next) {
|
|
41
|
-
sessions.
|
|
68
|
+
const prev = sessions.get(key) ?? fresh()
|
|
69
|
+
sessions.set(key, { ...prev, ...(next && typeof next === 'object' ? next : {}) })
|
|
42
70
|
// FIFO 而不是 LRU:Map 重写已存在的键不会挪动位置,与旧行为一致。
|
|
43
71
|
if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
|
|
44
72
|
},
|
package/package.json
CHANGED