sage-mem 0.5.2 → 0.5.4

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 CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  记忆存在**本地 markdown 文件**里(frontmatter + 正文),不是数据库。透明、可检查、防膨胀。
6
6
 
7
+ > **DSH 版本兼容性(0.5.3 核对于 `0.1.7-rc.1`)**
8
+ > `session.deriveMessages()`(仍是同步)、`system-prompt/assemble` 事件、以及 `context.agent.session` 这条链,在 0.1.6 → 0.1.7 之间**均未改变**,本插件在 0.1.7 上无需改动即可工作。
9
+ > 0.5.3 另加了一层防御:`deriveMessages()` 若哪天被改成异步,或 `Session` 上的历史读取接口被改名(0.1.7 起 `snapshotEvents` / `eventAt` / `ownEvents` 已标记弃用),插件会在**日志里报警**,而不是像以前那样静默地不再注入记忆。0.1.6 上的行为与 0.5.2 完全一致。
10
+
7
11
  ## 从 Claude Code 无损迁移
8
12
 
9
13
  你已经在 Claude Code(CC)里养了一个 agent,舍不得它的记忆和人格?sage-mem 是「CC → DSH」迁移方案的一部分,**记忆与人格都能无损搬过来**:
package/lib/client.js CHANGED
@@ -1478,6 +1478,176 @@ const StarmapRuntime = (function (require, _react) {
1478
1478
  };
1479
1479
  const TYPE_ORDER = ["user", "feedback", "project", "reference"];
1480
1480
 
1481
+ // ── frontmatter 纯逻辑(顶层自包含普通函数,不碰 React state)─────
1482
+ //
1483
+ // 2026-09-25 P0 数据事故的根因全在这几行里,三条硬规矩:
1484
+ // 1) 绝不「重建」frontmatter —— 就地合并,除 description / type 外每一行
1485
+ // 逐字保留。旧版 buildFrontmatter() 只写 name/description/type 三个字段,
1486
+ // 保存一次就把 baseline / node_type / originSessionId / updated 全抹掉
1487
+ // (baseline 一丢,「每次会话必读」的记忆从此不再注入)。
1488
+ // 2) 类型正则必须锚行首:` node_type: memory` 里的 `type:` 否则会被先命中,
1489
+ // 捕获到 memory → 不在 TYPE_ORDER 里 → 兜底成 reference(全库 45 条中招)。
1490
+ // 3) 换行必须容忍 CRLF(Windows 编辑器碰一次就是 \r\n);回写沿用原文件的
1491
+ // 换行风格,从而 LF / CRLF 都能逐字节 round-trip。
1492
+
1493
+ /** 去掉值两侧成对的引号(与 host 半 parseFrontmatter 的剥法一致)。 */
1494
+ function stripQuotes(v) {
1495
+ const s = typeof v === "string" ? v.trim() : "";
1496
+ return s.replace(/^["']|["']$/g, "").trim();
1497
+ }
1498
+
1499
+ /** 渲染成双引号 YAML 标量:转义反斜杠与双引号(旧版把 " 换成 ' 会改字)。 */
1500
+ function yamlQuote(v) {
1501
+ return String(v == null ? "" : v).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1502
+ }
1503
+
1504
+ /** 传整篇 content 或只传 fm 文本都能用;以 --- 开头的当整篇拆。 */
1505
+ function fmInner(text) {
1506
+ const t = typeof text === "string" ? text : "";
1507
+ return /^---[ \t]*\r?\n/.test(t) ? splitFrontmatter(t).fm : t;
1508
+ }
1509
+
1510
+ /**
1511
+ * 拆 frontmatter。返回 { hasFm, fm, body }:
1512
+ * fm = 两条 --- 之间的原始文本(不含定界行)
1513
+ * body = 定界之后的内容(只吃掉紧跟 --- 的那一个换行,其余原样)
1514
+ * 没有 frontmatter 时 hasFm=false、fm=""、body=整个 content。
1515
+ */
1516
+ function splitFrontmatter(content) {
1517
+ const text = typeof content === "string" ? content : "";
1518
+ const m = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n)?/);
1519
+ if (!m) return { hasFm: false, fm: "", body: text };
1520
+ return { hasFm: true, fm: m[1], body: text.slice(m[0].length) };
1521
+ }
1522
+
1523
+ /**
1524
+ * 识别记忆类型。锚行首(^ + m),**不看 node_type**:
1525
+ * 1) 顶层 `type: X`
1526
+ * 2) metadata 块内的缩进写法 ` type: X`
1527
+ * 优先级与 host 半 parseFrontmatter 一致(顶层优先)。找不到返回 ""。
1528
+ */
1529
+ function detectType(text) {
1530
+ const fm = fmInner(text);
1531
+ const top = fm.match(/^type[ \t]*:[ \t]*([^\r\n]*)/m);
1532
+ if (top) return stripQuotes(top[1]);
1533
+ const nested = fm.match(/^[ \t]+type[ \t]*:[ \t]*([^\r\n]*)/m);
1534
+ if (nested) return stripQuotes(nested[1]);
1535
+ return "";
1536
+ }
1537
+
1538
+ /**
1539
+ * 兜底:读 node_type。本机有两份老文件没有 type 行,node_type 直接就是类型
1540
+ * (project_ui-lab.md → project、user_courses-2026fall.md → user)。
1541
+ * 只由调用方在 detectType 返回空时使用 —— detectType 本身绝不认 node_type。
1542
+ */
1543
+ function detectNodeType(text) {
1544
+ const fm = fmInner(text);
1545
+ const m = fm.match(/^[ \t]*node_type[ \t]*:[ \t]*([^\r\n]*)/m);
1546
+ return m ? stripQuotes(m[1]) : "";
1547
+ }
1548
+
1549
+ /** 取顶层 description 的值(剥掉成对引号)。 */
1550
+ function extractDescription(text) {
1551
+ const fm = fmInner(text);
1552
+ const m = fm.match(/^description[ \t]*:[ \t]*([^\r\n]*)/m);
1553
+ return m ? stripQuotes(m[1]) : "";
1554
+ }
1555
+
1556
+ /**
1557
+ * 就地合并 frontmatter,返回新的完整文件内容。
1558
+ * patch = { type, description, addMissingType }
1559
+ * - description:与原文相同时整行逐字保留;不同时改写该行;原本没有该行才补一行。
1560
+ * - type:只改写已有的 type 行(顶层优先,其次 metadata 内缩进那一行)并保留原缩进;
1561
+ * 原本没有 type 行时默认一行都不加(不动 node_type-only 的老文件),
1562
+ * 只有 patch.addMissingType === true 才插入(插进 metadata 块或顶层)。
1563
+ * - 其余所有行(name / baseline / node_type / originSessionId / updated / 任意键)
1564
+ * 逐字保留,顺序不变。
1565
+ * 没有 frontmatter 的文件原样返回(新增文件走 buildFrontmatter)。
1566
+ */
1567
+ function mergeFrontmatter(content, patch) {
1568
+ const src = typeof content === "string" ? content : "";
1569
+ const p = patch || {};
1570
+ const parts = splitFrontmatter(src);
1571
+ if (!parts.hasFm) return src;
1572
+
1573
+ const oldDesc = extractDescription(parts.fm);
1574
+ const oldType = detectType(parts.fm);
1575
+ const srcLines = parts.fm.split(/\r?\n/);
1576
+
1577
+ // 目标 type 行的下标:与 detectType 同一优先级(顶层 → 缩进)
1578
+ let typeIdx = -1;
1579
+ for (let i = 0; i < srcLines.length; i++) {
1580
+ if (/^type[ \t]*:/.test(srcLines[i])) { typeIdx = i; break; }
1581
+ }
1582
+ if (typeIdx < 0) {
1583
+ for (let i = 0; i < srcLines.length; i++) {
1584
+ if (/^[ \t]+type[ \t]*:/.test(srcLines[i])) { typeIdx = i; break; }
1585
+ }
1586
+ }
1587
+
1588
+ const wantDesc = typeof p.description === "string" && p.description !== oldDesc;
1589
+ const wantType = typeof p.type === "string" && p.type !== "" && p.type !== oldType;
1590
+ const out = [];
1591
+ let descDone = !wantDesc;
1592
+ let typeDone = !wantType || typeIdx < 0;
1593
+ let metadataAt = -1;
1594
+ for (let i = 0; i < srcLines.length; i++) {
1595
+ const line = srcLines[i];
1596
+ if (/^metadata[ \t]*:/.test(line)) metadataAt = out.length;
1597
+ if (!descDone && /^description[ \t]*:/.test(line)) {
1598
+ out.push('description: "' + yamlQuote(p.description) + '"');
1599
+ descDone = true;
1600
+ continue;
1601
+ }
1602
+ if (!typeDone && i === typeIdx) {
1603
+ out.push((line.match(/^[ \t]*/) || [""])[0] + "type: " + p.type);
1604
+ typeDone = true;
1605
+ continue;
1606
+ }
1607
+ out.push(line);
1608
+ }
1609
+ // 原本没有 type 行 → 仅在调用方明确要求时插入
1610
+ if (typeIdx < 0 && p.addMissingType === true) {
1611
+ if (metadataAt >= 0) {
1612
+ let at = metadataAt + 1;
1613
+ let indent = " ";
1614
+ for (let i = metadataAt + 1; i < out.length; i++) {
1615
+ const mm = out[i].match(/^([ \t]+)\S/);
1616
+ if (!mm) break;
1617
+ indent = mm[1];
1618
+ at = i + 1;
1619
+ }
1620
+ out.splice(at, 0, indent + "type: " + p.type);
1621
+ } else {
1622
+ out.push("type: " + p.type);
1623
+ }
1624
+ }
1625
+ // 原本没有 description 行 → 补一行(description 本就在可改范围内)
1626
+ if (!descDone) {
1627
+ const rendered = 'description: "' + yamlQuote(p.description) + '"';
1628
+ let at = -1;
1629
+ for (let i = 0; i < out.length; i++) {
1630
+ if (/^name[ \t]*:/.test(out[i])) { at = i + 1; break; }
1631
+ }
1632
+ if (at >= 0) out.splice(at, 0, rendered); else out.unshift(rendered);
1633
+ }
1634
+
1635
+ // 原文件是 CRLF 就照样回写 CRLF(LF 文件回写 \n)——保证逐字节 round-trip。
1636
+ // 组件把原文重建成 "---\n" + fm + "\n---\n" + body,所以还要从 fm / body 里看 CRLF。
1637
+ const eol = /^---[ \t]*\r\n/.test(src) || parts.fm.indexOf("\r\n") >= 0 || parts.body.indexOf("\r\n") >= 0 ? "\r\n" : "\n";
1638
+ return "---" + eol + out.join(eol) + eol + "---" + eol + parts.body;
1639
+ }
1640
+
1641
+ /**
1642
+ * 新建记忆的 frontmatter(只用于「添加记忆」或原本没有 frontmatter 的文件;
1643
+ * 编辑已有文件一律走 mergeFrontmatter,绝不重建)。
1644
+ */
1645
+ function buildFrontmatter(type, desc, fileName) {
1646
+ const slug = String(fileName == null ? "" : fileName).replace(/\.md$/i, "");
1647
+ return "---\nname: \"" + yamlQuote(slug) + "\"\ndescription: \"" + yamlQuote(desc) +
1648
+ "\"\nmetadata:\n node_type: memory\n type: " + type + "\n---\n\n";
1649
+ }
1650
+
1481
1651
  function fmtSize(n) {
1482
1652
  if (n < 1024) return n + " B";
1483
1653
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
@@ -1559,6 +1729,12 @@ const StarmapRuntime = (function (require, _react) {
1559
1729
  const formDesc = sFormDesc[0], setFormDesc = sFormDesc[1];
1560
1730
  const sFormBody = react.useState("");
1561
1731
  const formBody = sFormBody[0], setFormBody = sFormBody[1];
1732
+ // 打开编辑时的原 frontmatter 原文(null = 该文件没有 frontmatter)
1733
+ const sFormFm = react.useState(null);
1734
+ const formFm = sFormFm[0], setFormFm = sFormFm[1];
1735
+ // 打开时展示给用户的类型(判断他是否真的改过类型)
1736
+ const sFormBaseType = react.useState("");
1737
+ const formBaseType = sFormBaseType[0], setFormBaseType = sFormBaseType[1];
1562
1738
  const sBusy = react.useState(false);
1563
1739
  const busy = sBusy[0], setBusy = sBusy[1];
1564
1740
  const sOk = react.useState(false);
@@ -1588,6 +1764,7 @@ const StarmapRuntime = (function (require, _react) {
1588
1764
 
1589
1765
  function closeForm() {
1590
1766
  setFormOpen(false); setEditingFile(null); setFormName(""); setFormDesc(""); setFormBody(""); setFormType("feedback");
1767
+ setFormFm(null); setFormBaseType("");
1591
1768
  }
1592
1769
 
1593
1770
  function openEdit(file) {
@@ -1597,22 +1774,20 @@ const StarmapRuntime = (function (require, _react) {
1597
1774
  .then(function (res) {
1598
1775
  const data = unwrap(res);
1599
1776
  const content = (data && data.content) || "";
1600
- const fm = content.match(/^---\n([\s\S]*?)\n---\n?/);
1601
- let type = "reference";
1602
- let desc = "";
1603
- let body = content;
1604
- if (fm) {
1605
- body = content.slice(fm[0].length);
1606
- const t = fm[1].match(/type:\s*(\S+)/);
1607
- if (t) type = t[1];
1608
- const d = fm[1].match(/description:\s*"?(.+?)"?\n/);
1609
- if (d) desc = d[1].replace(/["']/g, "");
1610
- }
1777
+ // 解析走顶层纯函数:拆 frontmatter / 认类型 / 取描述
1778
+ const parts = splitFrontmatter(content);
1779
+ // detectType 只认 type 行(锚行首,不碰 node_type);
1780
+ // 老文件里 node_type 直接就是类型的,才退回 detectNodeType。
1781
+ const found = detectType(parts.fm) || detectNodeType(parts.fm);
1782
+ const shown = TYPE_ORDER.indexOf(found) >= 0 ? found : "reference";
1611
1783
  setEditingFile(file);
1612
1784
  setFormName(file);
1613
- setFormType(TYPE_ORDER.indexOf(type) >= 0 ? type : "reference");
1614
- setFormDesc(desc);
1615
- setFormBody(body);
1785
+ setFormType(shown);
1786
+ setFormBaseType(shown);
1787
+ setFormDesc(extractDescription(parts.fm));
1788
+ setFormBody(parts.body);
1789
+ // 关键:原 frontmatter 原文留着,保存时「就地合并」而不是重建
1790
+ setFormFm(parts.hasFm ? parts.fm : null);
1616
1791
  setFormOpen(true);
1617
1792
  })
1618
1793
  .catch(function (e) { setError(String((e && e.message) || e)); })
@@ -1624,22 +1799,34 @@ const StarmapRuntime = (function (require, _react) {
1624
1799
  setEditingFile(null);
1625
1800
  setFormName("");
1626
1801
  setFormType("feedback");
1802
+ setFormBaseType("feedback");
1627
1803
  setFormDesc("");
1628
1804
  setFormBody("");
1805
+ setFormFm(null);
1629
1806
  setFormOpen(true);
1630
1807
  }
1631
1808
 
1632
- function buildFrontmatter(type, desc) {
1633
- return "---\nname: \"\"\ndescription: \"" + desc.replace(/"/g, "'") + "\"\nmetadata:\n type: " + type + "\n---\n\n";
1634
- }
1635
-
1636
1809
  function submit() {
1637
1810
  if (!formDesc.trim()) { setError("描述不能为空"); return; }
1638
1811
  setBusy(true); setError(null); setNotice("");
1639
1812
  const type = formType;
1640
1813
  const name = (formName.trim() || (type + "_note.md"));
1641
1814
  const safeName = name.endsWith(".md") ? name : name + ".md";
1642
- const content = buildFrontmatter(type, formDesc.trim()) + formBody;
1815
+ const desc = formDesc.trim();
1816
+ let content;
1817
+ if (formFm === null) {
1818
+ // 没有 frontmatter(新增记忆 / 索引文件)→ 生成一份
1819
+ content = buildFrontmatter(type, desc, safeName) + formBody;
1820
+ } else {
1821
+ // 有 frontmatter → 就地合并:除 description / type 外一字不动
1822
+ const original = "---\n" + formFm + "\n---\n" + formBody;
1823
+ content = mergeFrontmatter(original, {
1824
+ type: type,
1825
+ description: desc,
1826
+ // 原文没有 type 行时,只有用户确实改过类型才补写一行
1827
+ addMissingType: type !== formBaseType,
1828
+ });
1829
+ }
1643
1830
  remote.writeFile(safeName, content)
1644
1831
  .then(function (r) {
1645
1832
  // host 侧对保留名 / 非字符串内容 / 超限一律返回 { ok: false, error },不再抛异常。
package/lib/index.js CHANGED
@@ -36,17 +36,34 @@ const MAX_BASELINE = 5
36
36
  * frontmatter 里的 originSessionId / birthday 等字段一并抹掉。
37
37
  *
38
38
  * 判定依据是「丢了能不能从别处重建」:memory 目录**不在任何版本控制下**
39
- * (在 E:\shengdian 下 `git rev-parse` 直接报 not a git repository),
39
+ * (该目录里没有 .git,`git rev-parse` 直接报 not a git repository),
40
40
  * deleteFile 是 unlink、没有回收站,所以宁严勿松。
41
41
  * 这几个文件要改,用文件工具直接编辑。
42
+ *
43
+ * ⚠️ 全部小写存放,比较一律走 nameKey()(NTFS 不区分大小写,集合里放 'MEMORY.md'
44
+ * 而拿 'memory.md' 去 has() 是查不到的 —— 曾经就是这个漏洞:表单里填 memory.md
45
+ * 就能整份覆盖 19 KB 的索引,deleteFile 还能不可逆删掉它)。
42
46
  */
43
47
  const RESERVED_FILES = new Set([
44
- 'MEMORY.md',
48
+ 'memory.md',
45
49
  'session-log.md',
46
50
  'project_heartscape.md',
47
51
  'project_self-cognition.md',
48
52
  ])
49
53
 
54
+ /**
55
+ * 非记忆条目:不参与检索 / 不进星图 / 不出现在设置页列表里,读写也一律不通。
56
+ *
57
+ * 是 RESERVED_FILES 的子集,区别在「列不列出来」:MEMORY.md 是索引、
58
+ * session-log.md 是追加型流水(已 390+ KB),两者都不是一条记忆;
59
+ * 而 project_heartscape.md / project_self-cognition.md 是正常记忆条目
60
+ * (要出现在列表与星图里、要能读),只是不许从表单写/删。
61
+ */
62
+ const NON_ENTRY_FILES = new Set([
63
+ 'memory.md',
64
+ 'session-log.md',
65
+ ])
66
+
50
67
  /**
51
68
  * 单文件体积上限(字节)。定 512 KB 的理由:
52
69
  * - 当前 memory 目录最大的一条正文 19 KB(project_guikit.md),索引与流水都排除在外,
@@ -104,7 +121,7 @@ async function scanMemoryFiles(ctx, sessionId) {
104
121
  try {
105
122
  const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
106
123
  const names = entries
107
- .filter(e => e.isFile() && e.name.endsWith('.md') && e.name !== 'MEMORY.md' && e.name !== 'session-log.md')
124
+ .filter(e => e.isFile() && isMemoryEntry(e.name))
108
125
  .map(e => e.name)
109
126
  const loaded = await Promise.all(names.map(async (name) => {
110
127
  try {
@@ -203,11 +220,52 @@ async function writeFileAtomic(target, content) {
203
220
  }
204
221
  }
205
222
 
206
- /** 防路径穿越:只取 basename,且必须在 memory 目录内(.md,排除索引)。 */
223
+ /**
224
+ * 文件名归一:只取单段文件名再小写。
225
+ *
226
+ * 这是**唯一**的名字比较基准(保留名、非条目名、扫描过滤全走它)。
227
+ * 不能直接用原始字符串比:NTFS 不区分大小写,`memory.md` 与 `MEMORY.md`
228
+ * 是同一个文件;`basename` 还顺手挡掉 `..\..\MEMORY.md` 这类带目录成分的输入。
229
+ */
230
+ function nameKey(raw) {
231
+ return basename(String(raw ?? '')).toLowerCase()
232
+ }
233
+
234
+ /** 是否是保留名(写/删一律拒绝)。大小写不敏感。 */
235
+ function isReserved(raw) {
236
+ return RESERVED_FILES.has(nameKey(raw))
237
+ }
238
+
239
+ /** 是否是非记忆条目(索引与流水):列出/检索/星图/读写全部排除。大小写不敏感。 */
240
+ function isNonEntry(raw) {
241
+ return NON_ENTRY_FILES.has(nameKey(raw))
242
+ }
243
+
244
+ /** 目录扫描过滤:这个名字算不算一条记忆条目。 */
245
+ function isMemoryEntry(name) {
246
+ return /\.md$/i.test(String(name ?? '')) && !isNonEntry(name)
247
+ }
248
+
249
+ /**
250
+ * 唯一的路径校验函数(防穿越 + 扩展名 + 排除非记忆条目)。
251
+ *
252
+ * MemoryGateway.readFile / StarmapGateway.readFile 都走这里,不再各写一套
253
+ * —— 旧版 starmap 那套内联校验只排除了 'MEMORY.md'(大小写敏感)且**完全不排除
254
+ * session-log.md**,于是 `starmap.readFile('session-log.md')` 能把 390+ KB 的流水
255
+ * 整个拉进浏览器。
256
+ *
257
+ * 不复用 nameKey 的原因:这里要保留磁盘上的原始大小写(返回值直接 join 进路径)。
258
+ * @param raw — 调用方传来的文件名(任意值)
259
+ * @returns 通过校验的原始文件名;非法返回 null
260
+ */
207
261
  function safeName(raw) {
208
- const name = basename(String(raw || ''))
209
- if (!name || !name.endsWith('.md') || name === 'MEMORY.md') return null
210
- return name
262
+ if (typeof raw !== 'string' || raw === '') return null
263
+ // basename 与原文不一致=含目录成分。旧版是「悄悄取 basename 继续」,
264
+ // 与 starmap 那套(要求 safe === raw)行为不一致;现在统一为拒绝。
265
+ if (basename(raw) !== raw) return null
266
+ if (!/\.md$/i.test(raw)) return null
267
+ if (isNonEntry(raw)) return null
268
+ return raw
211
269
  }
212
270
 
213
271
  /**
@@ -236,6 +294,16 @@ export class MemoryGateway extends TypertRemoteService {
236
294
  // 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
237
295
  const recallCache = new Map()
238
296
 
297
+ // 「本会话是否已注入过 baseline」——会话级状态,按 session id 记。
298
+ //
299
+ // 为什么不是消息条数:见下面 baseline 那段注释(恢复会话的历史消息早就在
300
+ // session 里,条数永不为 0)。会话 id 在整场会话里稳定,与 assemble 时
301
+ // 当前用户消息有没有落盘完全无关,所以新会话、恢复会话两种时序都成立。
302
+ // 容量 64 与 recallCache 同理,只防长驻进程无限增长:要挤掉一个 id 得先有
303
+ // 63 场别的会话开起来,那场老会话再被唤醒时最多是把同样的 baseline 再注入一次。
304
+ const BASELINE_CACHE_MAX = 64
305
+ const baselineInjected = new Set()
306
+
239
307
  // 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
240
308
  // 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
241
309
  // 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
@@ -246,8 +314,17 @@ export class MemoryGateway extends TypertRemoteService {
246
314
  try {
247
315
  const agent = context?.agent
248
316
  const session = agent?.session
249
- if (!session || typeof session.deriveMessages !== 'function') return assembled
250
- const messages = [...session.deriveMessages()]
317
+ if (!session) return assembled
318
+ // DSH 0.1.7 起 Session 的历史读取接口在陆续调整(snapshotEvents / eventAt /
319
+ // ownEvents 已弃用)。deriveMessages 在 0.1.7-rc.1 实测仍在且仍同步,但这三行
320
+ // 不是白写的:方法一旦被改名或改成异步,老写法要么静默不注入、要么把 Promise
321
+ // 当数组展开成空。宁可日志里响一声,也不要"记忆不注入但日志一字没有"。
322
+ if (typeof session.deriveMessages !== 'function') {
323
+ warnLog(ctx, 'session.deriveMessages() 不可用 —— 记忆召回已跳过(DSH session API 变了?)', sessionId)
324
+ return assembled
325
+ }
326
+ const derived = session.deriveMessages()
327
+ const messages = [...(derived && typeof derived.then === 'function' ? await derived : derived)]
251
328
 
252
329
  // 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
253
330
  // (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
@@ -265,25 +342,54 @@ export class MemoryGateway extends TypertRemoteService {
265
342
  picked.push(f)
266
343
  }
267
344
 
268
- // 新会话首步:注入 frontmatter 标了 `baseline: true` 的记忆。当前这条
269
- // 用户消息在 assemble 时还没写进 session,所以首步这里恰好是 0 条。
270
- // 这一层补「开场白失忆」——「继续吧」这类短话在 description 里找不到
271
- // 匹配,纯检索会返回 0 条,而那恰恰是最需要上下文的时刻。
272
- if (userMessages.length === 0) {
345
+ // baseline:每个会话注入一次,**不看消息条数**。
346
+ //
347
+ // 旧写法 `if (userMessages.length === 0)` 依赖一个真实存在的时序:assemble
348
+ // 触发时当前这条用户消息还没写进 session。已在 DSH 检出里核实为真 ——
349
+ // dsh-agent-loop/lib/index.js:906 const claimed = this.inbox.claim(...) // 只从 pending 队列取走
350
+ // dsh-agent-loop/lib/index.js:907 await this.loopCtx.systemPrompt.assemble(...) // ← 本监听器在这里跑
351
+ // dsh-agent-loop/lib/index.js:1046 this.session.append("user/message", ...) // 落 session 在 preStep 之后
352
+ // 所以全新会话首步 pick 到 0 条 user message,分支命中。
353
+ //
354
+ // 但**恢复 / 续聊会话**的历史消息早就在 session 里,条数永远 ≥ 1 → 分支永不
355
+ // 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
356
+ // 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
357
+ let baselineTaken = 0
358
+ const baselineKey = session.id ? session.id : session
359
+ if (!baselineInjected.has(baselineKey)) {
273
360
  files
274
361
  .filter(f => f.baseline)
275
362
  .sort((a, b) => a.file.localeCompare(b.file))
276
363
  .slice(0, MAX_BASELINE)
277
- .forEach(take)
364
+ .forEach(f => {
365
+ const before = picked.length
366
+ take(f)
367
+ if (picked.length > before) baselineTaken++
368
+ })
369
+ // 只有真取到才落标记:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
370
+ // 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
371
+ if (baselineTaken > 0) {
372
+ baselineInjected.add(baselineKey)
373
+ if (baselineInjected.size > BASELINE_CACHE_MAX) {
374
+ baselineInjected.delete(baselineInjected.values().next().value)
375
+ }
376
+ }
278
377
  }
279
378
 
280
- if (text && text.length >= 2) selectRelevant(text, files).forEach(take)
281
- if (picked.length === 0) return assembled
379
+ // 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
380
+ // 旧版两者共用 signature,首步签名一旦与后续某步相同,baseline 会连坐被吞;
381
+ // 反过来,旧版把 baseline 文件名也算进签名,会让同一步的检索结果在下一步
382
+ // 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
383
+ // 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
384
+ const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
385
+ const signature = text + '\u0000' + relevant.map(f => f.file).join(',')
386
+ if (recallCache.get(session.id) !== signature) {
387
+ recallCache.set(session.id, signature)
388
+ if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
389
+ relevant.forEach(take)
390
+ }
282
391
 
283
- const signature = text + '\u0000' + picked.map(f => f.file).join(',')
284
- if (recallCache.get(session.id) === signature) return assembled
285
- recallCache.set(session.id, signature)
286
- if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
392
+ if (picked.length === 0) return assembled
287
393
 
288
394
  const parts = picked.map(f => {
289
395
  const body = f.content.length > MAX_CHARS_PER_FILE
@@ -339,11 +445,14 @@ export class MemoryGateway extends TypertRemoteService {
339
445
  * 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
340
446
  */
341
447
  async writeFile(name, content) {
448
+ // 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
449
+ // 'Session-Log.md' 在 NTFS 上都是同一个文件,必须一律走 { ok: false } 这条路
450
+ // (设置页按 ok 判定,见 client.js 的 submit())。
451
+ if (isReserved(name)) {
452
+ return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
453
+ }
342
454
  const safe = safeName(name)
343
455
  if (!safe) throw new Error('sage-mem: invalid file name')
344
- if (RESERVED_FILES.has(safe)) {
345
- return { ok: false, file: safe, error: `sage-mem: reserved file, edit it with the file tools: ${safe}` }
346
- }
347
456
  // 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
348
457
  // 原文件就此被一行垃圾顶掉,且没有任何错误信号。
349
458
  if (typeof content !== 'string') {
@@ -357,13 +466,13 @@ export class MemoryGateway extends TypertRemoteService {
357
466
  return { ok: true, file: safe }
358
467
  }
359
468
 
360
- /** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。 */
469
+ /** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
361
470
  async deleteFile(name) {
471
+ if (isReserved(name)) {
472
+ return { ok: false, error: `sage-mem: reserved file, refusing to delete: ${nameKey(name)}` }
473
+ }
362
474
  const safe = safeName(name)
363
475
  if (!safe) throw new Error('sage-mem: invalid file name')
364
- if (RESERVED_FILES.has(safe)) {
365
- return { ok: false, error: `sage-mem: reserved file, refusing to delete: ${safe}` }
366
- }
367
476
  await unlink(join(MEMORY_DIR, safe))
368
477
  return { ok: true }
369
478
  }
@@ -392,7 +501,7 @@ async function scanStars(ctx) {
392
501
  return []
393
502
  }
394
503
  const names = entries
395
- .filter(e => e.isFile() && e.name.endsWith('.md') && e.name !== 'MEMORY.md' && e.name !== 'session-log.md')
504
+ .filter(e => e.isFile() && isMemoryEntry(e.name))
396
505
  .map(e => e.name)
397
506
  const loaded = await Promise.all(names.map(async (name) => {
398
507
  try {
@@ -434,11 +543,10 @@ export class StarmapGateway extends TypertRemoteService {
434
543
  }
435
544
 
436
545
  async readFile(name) {
437
- const raw = String(name || '')
438
- const safe = basename(raw)
439
- if (!safe || safe !== raw || !safe.endsWith('.md') || safe === 'MEMORY.md') {
440
- throw new Error('sage-starmap: invalid file name')
441
- }
546
+ // 与 MemoryGateway.readFile 收敛到同一个 safeName:它现在同时排除
547
+ // MEMORY.md 与 session-log.md,且大小写不敏感。
548
+ const safe = safeName(name)
549
+ if (!safe) throw new Error('sage-starmap: invalid file name')
442
550
  const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
443
551
  return { name: safe, content }
444
552
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in transparent markdown files, with a memory star-map view",
4
- "version": "0.5.2",
4
+ "version": "0.5.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -39,7 +39,9 @@
39
39
  "0.1.3-alpha.2": "compatible",
40
40
  "0.1.5-rc.1": "compatible",
41
41
  "0.1.6-alpha.1": "compatible",
42
- "0.1.6-alpha.2": "compatible"
42
+ "0.1.6-alpha.2": "compatible",
43
+ "0.1.7-rc.1": "compatible",
44
+ "0.1.7-rc.2": "compatible"
43
45
  }
44
46
  }
45
47
  },