sage-mem 2.1.18 → 2.1.20
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/client.js +61 -11
- package/lib/memory/util.js +42 -2
- 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.20`** — 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.20`** —— 顶部 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/client.js
CHANGED
|
@@ -978,6 +978,22 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
978
978
|
return makeCanvas2DRenderer(canvasEl, layout, visBox, hoverBox);
|
|
979
979
|
}
|
|
980
980
|
|
|
981
|
+
/**
|
|
982
|
+
* 星图动画的统一时钟(Q15)。
|
|
983
|
+
*
|
|
984
|
+
* rAF 回调的时间戳与 `performance.now()` 是**同一个原点**(单调、从页面加载起算),
|
|
985
|
+
* 而 `Date.now()` 是 epoch 毫秒 —— 两套混用会让相位变成大负数。旧版入场动画就是这个
|
|
986
|
+
* bug:`t0 = Date.now()` 去减 rAF 的 `ts`,`ts - t0` 恒为大负数 → 永远「入场没结束」,
|
|
987
|
+
* 但每帧画完又不接着排帧,于是入场只画了一帧、相位还是负的。
|
|
988
|
+
*
|
|
989
|
+
* 星图里所有「过了多少毫秒」都走这里(freshness 那种墙钟判断除外,那本来就该用 Date.now)。
|
|
990
|
+
*/
|
|
991
|
+
function starmapNow() {
|
|
992
|
+
return (typeof performance !== "undefined" && performance && typeof performance.now === "function")
|
|
993
|
+
? performance.now()
|
|
994
|
+
: Date.now();
|
|
995
|
+
}
|
|
996
|
+
|
|
981
997
|
// ── 星图主体(canvas + 图例 + 详情面板)──
|
|
982
998
|
function StarMap(props) {
|
|
983
999
|
const ctx = props.ctx || appCtx;
|
|
@@ -1108,16 +1124,18 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1108
1124
|
if (!data || !layout || !canvasEl) return undefined;
|
|
1109
1125
|
const renderer = createRenderer(canvasEl, layout, visBox, hoverBox);
|
|
1110
1126
|
drawBox.renderer = renderer;
|
|
1111
|
-
|
|
1127
|
+
// t0 与 rAF 的 ts 必须同一时钟(见 starmapNow)。旧版用 Date.now 当 t0,
|
|
1128
|
+
// 于是相位是 epoch 级的大负数,入场窗口永远关不上。
|
|
1129
|
+
drawBox.t0 = starmapNow();
|
|
1112
1130
|
renderer.render(0);
|
|
1113
1131
|
|
|
1114
1132
|
// ── 按需重绘(重做过一版)──
|
|
1115
1133
|
// 旧版是一个常驻 rAF 循环:只要星图页开着就永远满帧重画,后台标签页也一样,
|
|
1116
1134
|
// 而画面其实早就静止了。新的规矩只有三种情况会画:
|
|
1117
1135
|
// 1. 入场展示动画期间(约 1.3s,星点按诞生时间依次点亮)
|
|
1118
|
-
// 2. 有人调 markDirty()(hover / 数据或筛选变化 /
|
|
1136
|
+
// 2. 有人调 markDirty()(hover / 数据或筛选变化 / 尺寸变化 / 回放)
|
|
1119
1137
|
// 3. 诞生回放进行中
|
|
1120
|
-
// 其余时间一帧都不画。另外帧率封顶 30fps
|
|
1138
|
+
// 其余时间一帧都不画。另外帧率封顶 30fps,页面不可见时暂停(回来再补一帧)。
|
|
1121
1139
|
const FRAME_MS = 1000 / 30;
|
|
1122
1140
|
const ENTRANCE_MS = 1300;
|
|
1123
1141
|
let raf = 0;
|
|
@@ -1126,26 +1144,51 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1126
1144
|
function schedule() {
|
|
1127
1145
|
if (!raf) raf = window.requestAnimationFrame(tick);
|
|
1128
1146
|
}
|
|
1147
|
+
/** 还需要连续帧的两种状态:入场动画没走完 / 诞生回放激活。 */
|
|
1148
|
+
function animating(ts) {
|
|
1149
|
+
return (ts - drawBox.t0 < ENTRANCE_MS) || replayBox.active === true;
|
|
1150
|
+
}
|
|
1129
1151
|
function tick(ts) {
|
|
1130
1152
|
raf = 0;
|
|
1131
1153
|
if (!drawBox.renderer) return;
|
|
1132
|
-
if (ts
|
|
1154
|
+
if (animating(ts)) dirty = true;
|
|
1155
|
+
// 页面不可见:这一帧不画,也**不**再排下一帧(旧版直接 return,结果从后台
|
|
1156
|
+
// 切回来就再也不画了)。visibilitychange 回到可见时会补一帧。
|
|
1157
|
+
if (typeof document !== "undefined" && document && document.hidden) {
|
|
1158
|
+
dirty = true;
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1133
1161
|
if (!dirty) return;
|
|
1134
|
-
if (typeof document !== "undefined" && document.hidden) return;
|
|
1135
1162
|
if (ts - lastDraw < FRAME_MS) { schedule(); return; }
|
|
1136
1163
|
dirty = false;
|
|
1137
1164
|
lastDraw = ts;
|
|
1138
1165
|
drawBox.renderer.render((ts - drawBox.t0) / 1000);
|
|
1166
|
+
// 入场 / 回放没结束就接着排下一帧;静止之后一帧都不排(不允许常驻空转)。
|
|
1167
|
+
if (animating(ts)) schedule();
|
|
1139
1168
|
}
|
|
1140
1169
|
drawBox.markDirty = function () { dirty = true; schedule(); };
|
|
1170
|
+
// 从后台标签页回来:补一帧(隐藏期间不排帧,靠这个事件唤醒)。
|
|
1171
|
+
const onVisibility = function () {
|
|
1172
|
+
if (typeof document === "undefined" || !document) return;
|
|
1173
|
+
if (document.hidden) { dirty = true; return; }
|
|
1174
|
+
dirty = true;
|
|
1175
|
+
schedule();
|
|
1176
|
+
};
|
|
1177
|
+
if (typeof document !== "undefined" && document && typeof document.addEventListener === "function") {
|
|
1178
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
1179
|
+
}
|
|
1141
1180
|
schedule();
|
|
1142
1181
|
|
|
1143
1182
|
return () => {
|
|
1144
1183
|
window.cancelAnimationFrame(raf);
|
|
1184
|
+
if (typeof document !== "undefined" && document && typeof document.removeEventListener === "function") {
|
|
1185
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
1186
|
+
}
|
|
1145
1187
|
drawBox.renderer = null;
|
|
1146
1188
|
drawBox.markDirty = null;
|
|
1147
1189
|
replayBox.active = false;
|
|
1148
1190
|
window.cancelAnimationFrame(replayBox.raf);
|
|
1191
|
+
replayBox.raf = 0;
|
|
1149
1192
|
if (renderer.destroy) renderer.destroy();
|
|
1150
1193
|
};
|
|
1151
1194
|
}, [data, layout, canvasEl, themeKey]);
|
|
@@ -1163,7 +1206,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1163
1206
|
if (drawBox.renderer.setGeometry) drawBox.renderer.setGeometry();
|
|
1164
1207
|
// 交给按需重绘那一套:下一帧画(<33ms),不再这里直接 render
|
|
1165
1208
|
if (drawBox.markDirty) drawBox.markDirty();
|
|
1166
|
-
else drawBox.renderer.render((
|
|
1209
|
+
else drawBox.renderer.render((starmapNow() - drawBox.t0) / 1000);
|
|
1167
1210
|
}
|
|
1168
1211
|
}, [data, layout, kindsOn, query, showArchive]);
|
|
1169
1212
|
|
|
@@ -1298,7 +1341,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1298
1341
|
function stopReplay() {
|
|
1299
1342
|
replayBox.active = false;
|
|
1300
1343
|
window.cancelAnimationFrame(replayBox.raf);
|
|
1344
|
+
replayBox.raf = 0;
|
|
1301
1345
|
if (drawBox.renderer && drawBox.renderer.setReveal) drawBox.renderer.setReveal(null);
|
|
1346
|
+
// 停回放要回到「全亮」:markDirty 让绘制循环补这一帧。
|
|
1302
1347
|
if (drawBox.markDirty) drawBox.markDirty();
|
|
1303
1348
|
setReplaying(false);
|
|
1304
1349
|
setReplayDate(null);
|
|
@@ -1316,17 +1361,22 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1316
1361
|
window.cancelAnimationFrame(replayBox.raf);
|
|
1317
1362
|
const span = Math.max(mx - mn, 60000) + 2400;
|
|
1318
1363
|
const dur = 18000;
|
|
1319
|
-
|
|
1364
|
+
let t0 = null;
|
|
1320
1365
|
replayBox.active = true;
|
|
1321
1366
|
setReplaying(true);
|
|
1367
|
+
// 关键:回放期间由「按需绘制」那套逐帧重画。setReveal 只改渲染器的变量,
|
|
1368
|
+
// 不标脏 / 不唤醒绘制循环的话屏幕上什么都不动(旧版就是这样,回放是空的)。
|
|
1369
|
+
if (drawBox.markDirty) drawBox.markDirty();
|
|
1322
1370
|
let lastLabel = 0;
|
|
1323
|
-
const tick = () => {
|
|
1371
|
+
const tick = (ts) => {
|
|
1324
1372
|
if (!replayBox.active) return;
|
|
1325
|
-
|
|
1373
|
+
// 进度也用 rAF 的单调时间戳,和绘制循环同一个时钟(旧版用 Date.now 又混了一次)。
|
|
1374
|
+
if (t0 === null) t0 = ts;
|
|
1375
|
+
const p = Math.min(1, (ts - t0) / dur);
|
|
1326
1376
|
const cur = mn - 1200 + span * p;
|
|
1327
1377
|
if (drawBox.renderer && drawBox.renderer.setReveal) drawBox.renderer.setReveal(cur);
|
|
1328
|
-
if (
|
|
1329
|
-
lastLabel =
|
|
1378
|
+
if (ts - lastLabel > 250) {
|
|
1379
|
+
lastLabel = ts;
|
|
1330
1380
|
setReplayDate(Math.min(mx, cur));
|
|
1331
1381
|
}
|
|
1332
1382
|
if (p >= 1) { stopReplay(); return; }
|
package/lib/memory/util.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { constants } from 'node:fs'
|
|
6
6
|
import { randomUUID } from 'node:crypto'
|
|
7
|
-
import { copyFile, link, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { chmod, copyFile, link, lstat, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
|
|
@@ -46,6 +46,31 @@ async function renameWithRetry(from, to, attempts = 8) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* 新建文件(写进本原语的那些记忆与状态)的默认 mode:**只对本人可读写**。
|
|
51
|
+
*
|
|
52
|
+
* 为什么是明确的 0600 而不是「umask 默认」(2026-10-31 外部审查 Q18):记忆正文是隐私
|
|
53
|
+
* 内容,权限不该由开发机 / 服务器的 umask 环境决定(umask=022 时默认新建是 0644,
|
|
54
|
+
* 同机其它账号可读)。写进 `writeFileAtomic` 的既有文件一律继承原 mode,
|
|
55
|
+
* 所以这一条只影响**新建**。
|
|
56
|
+
*/
|
|
57
|
+
const NEW_FILE_MODE = 0o600
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 这次替换该用的 mode:**已有普通文件**继承它现在的权限位;新建文件用保守默认。
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ 用 `lstat` 而不是 `stat`(2026-10-31 外部审查 Q18):目标是符号链接时不该跟随 ——
|
|
63
|
+
* `stat` 会拿到链接指向的**外部**文件的权限,等于把记忆文件的权限交给别处的文件决定;
|
|
64
|
+
* 而 rename 之后被替换的是链接本身(不是穿透),本来也不该继承外部的 mode。
|
|
65
|
+
*
|
|
66
|
+
* @returns {Promise<number>} mode 位(含 setuid/setgid/sticky:`& 0o7777`)
|
|
67
|
+
*/
|
|
68
|
+
async function modeForTarget(target) {
|
|
69
|
+
const info = await lstat(target).catch(() => null)
|
|
70
|
+
if (!info || !info.isFile() || typeof info.mode !== 'number') return NEW_FILE_MODE
|
|
71
|
+
return info.mode & 0o7777
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
/**
|
|
50
75
|
* 原子写:先写同目录临时文件,再 rename 覆盖目标。
|
|
51
76
|
*
|
|
@@ -53,13 +78,28 @@ async function renameWithRetry(from, to, attempts = 8) {
|
|
|
53
78
|
* 缺半边,这条记忆从此解析不出来、检索不到,而文件看上去还在。
|
|
54
79
|
* 同卷 rename 是原子的:读方要么看到旧全文、要么看到新全文,不存在中间态。
|
|
55
80
|
* 任何一步失败都不碰目标文件,**自己那份**临时文件收尾清掉。
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ **权限继承**(2026-10-31 外部审查 Q18):rename 替换的是**目录项**,新 inode
|
|
83
|
+
* 不会继承原文件的 mode。旧写法新建临时文件走默认 mode,于是一个被用户设成 0600 的
|
|
84
|
+
* 记忆,面板保存一次就变成 0644(umask=022),隐私被悄悄放宽,而且没有任何提示。
|
|
85
|
+
* 现在在 rename **之前**把 mode 贴到临时文件上:
|
|
86
|
+
* - 已有普通文件 → 继承它现在的权限(用 lstat,不跟随符号链接);
|
|
87
|
+
* - 新建文件 → 明确的保守模式 `NEW_FILE_MODE`(0600,不随 umask 漂)。
|
|
88
|
+
* 先 `writeFile(tmp, …, { mode })` 让临时文件**一开始就不会比目标宽**,再 `chmod` 成精确值
|
|
89
|
+
* (writeFile 的 mode 会被 umask 掩掉一部分,比如 0660 & ~022 = 0640)。
|
|
90
|
+
* 任何一步失败都发生在 rename 之前 → 目标文件一个字节、一个权限位都不会动。
|
|
91
|
+
* Windows 上 chmod 是 no-op,这里不依赖它能生效(行为断言在 POSIX 上跑)。
|
|
56
92
|
*/
|
|
57
93
|
export async function writeFileAtomic(target, content) {
|
|
58
94
|
tmpSeq += 1
|
|
59
95
|
const uniq = `${process.pid}-${Date.now().toString(36)}-${tmpSeq.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
60
96
|
const tmp = `${target}.${uniq}.tmp`
|
|
97
|
+
const mode = await modeForTarget(target)
|
|
61
98
|
try {
|
|
62
|
-
await writeFile(tmp, content, 'utf8')
|
|
99
|
+
await writeFile(tmp, content, { encoding: 'utf8', mode })
|
|
100
|
+
// 精确落位:writeFile 传的 mode 受 umask 掩码,chmod 不受 —— 两者都做才既「不会更宽」
|
|
101
|
+
// 又「不会被收紧」。放在 rename 之前,失败时目标仍是原样。
|
|
102
|
+
await chmod(tmp, mode)
|
|
63
103
|
await renameWithRetry(tmp, target)
|
|
64
104
|
} finally {
|
|
65
105
|
// rename 成功后 tmp 已不存在(ENOENT),失败时把它清掉不留垃圾。
|
package/package.json
CHANGED