sage-mem 1.2.1 → 1.4.0

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
@@ -498,6 +498,8 @@ Module boundaries (since 1.2.0):
498
498
  - `lib/recall/` — the injection chain: selection (`select`) / rendering and budget (`compose`) / text (`text`) / session state (`state`)
499
499
  - `lib/assembly/` — the four mounts: autodream / retrieval tools / observation / write gate
500
500
  - `lib/memory/` — pure memory-layer functions and file operations; `lib/autodream/` — the autodream engine
501
+ - `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)
502
+ - 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`
501
503
  - 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)
502
504
 
503
505
  ## Contributing
package/README.zh.md CHANGED
@@ -502,6 +502,8 @@ memory/ 目录(markdown 文件,4 类)
502
502
  - `lib/recall/` —— 注入链:选材(`select`)/ 渲染与预算(`compose`)/ 文本(`text`)/ 会话状态(`state`)
503
503
  - `lib/assembly/` —— 四块挂载:autodream / 检索工具 / 观测 / 写入门
504
504
  - `lib/memory/` —— 记忆层的纯函数与文件操作;`lib/autodream/` —— 自动做梦引擎
505
+ - `lib/shared/` —— 跨层零依赖的小判据:文件名规则 / 本地时间戳 / 两个体积上限(**只许一份**,`test/duplication-test.mjs` 会红)
506
+ - 界面侧:`client/*.js` 按屏分片(`05-shared.js` 放跨屏小基元与类型词表),`npm run build:client` 拼成 `lib/client.js`
505
507
  - 依赖方向:`memory` 不认识 `autodream`,`recall` 只依赖 `memory`,`assembly` 不反向依赖组合根(`test/structure-test.mjs` 会红)
506
508
 
507
509
  ## 参与开发
@@ -23,6 +23,8 @@
23
23
 
24
24
  import { readdir, readFile } from 'node:fs/promises'
25
25
  import { join } from 'node:path'
26
+ import { hasFrontmatter, parseFrontmatter } from '../memory/frontmatter.js'
27
+ import { SESSION_LOG_KEY } from '../shared/md-name.js'
26
28
  import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from '../memory/scan.js'
27
29
  import { warnLog } from '../memory/util.js'
28
30
 
@@ -51,7 +53,7 @@ const STRAY_RE = /\.archiving-[0-9a-f]+$/i
51
53
  * **索引 `MEMORY.md`**:它**要**被读(要当链接来源、要查行尾与链接),只是
52
54
  * **不是一条记忆** —— 见下面 `f !== INDEX_FILE` 那两处。
53
55
  */
54
- const AUDIT_SKIP_FILES = new Set(['session-log.md'])
56
+ const AUDIT_SKIP_FILES = new Set([SESSION_LOG_KEY])
55
57
 
56
58
  /** 归档区目录名(与 autodream-tools.js 的 ARCHIVE_DIR、宿主侧 memory/scan.js 同名)。 */
57
59
  const ARCHIVE_DIR = 'archive'
@@ -60,26 +62,19 @@ const ARCHIVE_DIR = 'archive'
60
62
  const YAML_LEAF_RE = /^[A-Za-z0-9_.-]+:\s*(.*)$/
61
63
 
62
64
  /**
63
- * 极简 frontmatter 解析,读法与 宿主侧一致:
64
- * 顶层 `type:` 优先,没有才下钻 `metadata.type:`。
65
- * @param {string} content — 文件全文
66
- * @returns {{name: string, description: string, type: string, hasFrontmatter: boolean}}
65
+ * 审计用的 frontmatter 视图:**不再自己写一份正则**,改成正典解析的薄包装。
66
+ *
67
+ * 原先这里是一份独立的 `grab` 实现,漏了 `updated` / `tags` —— 两份解析器对同一份
68
+ * 损坏文件的"type 是空还是不是空"可能给出不同答案,而审计报告与注入排序都依赖它。
69
+ * 保留本函数只为**导出面**不变(审计与工具都在用);行为差异由 shared-parity 对拍守着。
67
70
  */
68
71
  export function parseFrontmatterLite(content) {
69
- const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
70
- if (!m) return { name: '', description: '', type: '', hasFrontmatter: false }
71
- const body = m[1]
72
- const grab = (key) => {
73
- const hit = body.match(new RegExp(`^${key}:[ \\t]*(.+?)[ \\t\\r]*$`, 'm'))
74
- return hit ? hit[1].replace(/^["']|["']$/g, '').trim() : ''
75
- }
76
- const nested = body.match(/^metadata:\s*\r?\n(?:\s+[^\r\n]+\r?\n)*?\s+type:\s*(\S+)/m)
77
- const type = grab('type') || (nested ? nested[1] : '')
72
+ const fm = parseFrontmatter(content)
78
73
  return {
79
- name: grab('name'),
80
- description: grab('description'),
81
- type: String(type).trim(),
82
- hasFrontmatter: true,
74
+ name: fm.name,
75
+ description: fm.description,
76
+ type: String(fm.type).trim(),
77
+ hasFrontmatter: hasFrontmatter(content),
83
78
  }
84
79
  }
85
80
 
@@ -13,12 +13,7 @@
13
13
  * 那段文字会发给每一个使用者的模型;把作者的记忆主题编进去就是泄漏
14
14
  */
15
15
 
16
- /** 让 prompt 里的日期对人可读:模型算相对日期时不会写错。 */
17
- function today(ms) {
18
- const d = new Date(ms)
19
- const p = (n) => String(n).padStart(2, '0')
20
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
21
- }
16
+ import { stampDate } from '../shared/stamp.js'
22
17
 
23
18
  /**
24
19
  * 系统提示:身份 + 记忆系统的约定 + 这一趟的权限边界。
@@ -92,7 +87,7 @@ export function buildAutodreamUserPrompt(opts) {
92
87
 
93
88
  return `# Autodream:一次记忆整理
94
89
 
95
- 今天:${today(now)}
90
+ 今天:${stampDate(now)}
96
91
  记忆目录:\`${memoryDir}\`
97
92
  距上次整理:${hoursSince === 0 ? '(没有记录,当成第一次)' : hoursSince.toFixed(1) + ' 小时'}
98
93
  期间有更新的会话数:${sessionCount}
@@ -30,6 +30,8 @@ import { createHash } from 'node:crypto'
30
30
  import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './audit.js'
31
31
  import { archiveMemory } from '../memory/archive.js'
32
32
  import { MAX_FILE_BYTES, STATE_ROOT } from '../memory/config.js'
33
+ import { INDEX_FILE_KEY, SESSION_LOG_KEY } from '../shared/md-name.js'
34
+ import { stampHuman as localStamp } from '../shared/stamp.js'
33
35
  import { writeFileAtomic, warnLog } from '../memory/util.js'
34
36
  import { buildConflict, writeConflict } from '../memory/conflicts.js'
35
37
  import { memoryPathOf } from '../memory/paths.js'
@@ -42,17 +44,6 @@ const READ_LIMIT = 20000
42
44
  const LIST_DESC_CHARS = 70
43
45
  /** search_sessions 单次返回上限(字符)。 */
44
46
  const SEARCH_LIMIT = 8000
45
- /** 索引文件名:它在列表里要单独标注,免得模型当成一条普通记忆去改。 */
46
- const INDEX_FILE = 'memory.md'
47
- /**
48
- * 非记忆条目:不进 list_memory。
49
- *
50
- * session-log.md 是追加型流水,不是一条记忆 —— 列出来既
51
- * 占掉模型一大块注意力,又诱导它去「整理」一份流水账。冒烟测试
52
- * 逮到的就是这条:它出现在了 list_memory 的输出里。
53
- */
54
- const NON_ENTRY_FILES = new Set(['session-log.md'])
55
-
56
47
  /**
57
48
  * 把一个模型给的文件名收敛成 memory 目录顶层的一个真实路径。
58
49
  *
@@ -92,11 +83,7 @@ async function resolveRealName(memoryDir, base) {
92
83
  return hit ? hit.name : base
93
84
  }
94
85
 
95
- /** ISO 时间 → 本地 `YYYY-MM-DD HH:mm`(报告与列表里给人看)。 */function localStamp(ms) {
96
- const d = new Date(ms)
97
- const p = (n) => String(n).padStart(2, '0')
98
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
99
- }
86
+ /** ISO 时间 → 本地 `YYYY-MM-DD HH:mm`(唯一实现在 shared/stamp.js)。 */
100
87
 
101
88
  /** 距今多少天,给模型一个人话尺度。 */
102
89
  function ageDays(ms) {
@@ -118,7 +105,7 @@ async function scanEntries(memoryDir, ctx) {
118
105
  }
119
106
  const reservedIds = await reservedIdentitySet(memoryDir)
120
107
  const names = entries
121
- .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !NON_ENTRY_FILES.has(e.name.toLowerCase()))
108
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && e.name.toLowerCase() !== SESSION_LOG_KEY)
122
109
  .map((e) => e.name)
123
110
  const out = []
124
111
  for (const name of names) {
@@ -141,7 +128,7 @@ async function scanEntries(memoryDir, ctx) {
141
128
  out.push({
142
129
  file: name,
143
130
  // 索引不是一条记忆:标出来,否则模型会看到「MEMORY.md · reference」而困惑。
144
- type: name.toLowerCase() === INDEX_FILE ? '索引' : meta.type || 'reference',
131
+ type: name.toLowerCase() === INDEX_FILE_KEY ? '索引' : meta.type || 'reference',
145
132
  bytes: gate.size,
146
133
  mtimeMs: gate.mtimeMs,
147
134
  title: (h1 ? h1[1] : meta.name || '').trim(),
@@ -9,19 +9,8 @@ import { join } from 'node:path'
9
9
 
10
10
  export const clampNum = (v, lo, hi, dflt) => (Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt)
11
11
 
12
- /** 本地时间戳,给文件名用。 */
13
- export function stampCompact(ms) {
14
- const d = new Date(ms)
15
- const p = (n) => String(n).padStart(2, '0')
16
- return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
17
- }
18
-
19
- /** 本地时间戳,给人看。 */
20
- export function stampHuman(ms) {
21
- const d = new Date(ms)
22
- const p = (n) => String(n).padStart(2, '0')
23
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
24
- }
12
+ import { stampCompact, stampHuman } from '../shared/stamp.js'
13
+ export { stampCompact, stampHuman }
25
14
 
26
15
  /**
27
16
  * 运行 id:`YYYYMMDD-HHmmss-<4 位>`。
package/lib/client.js CHANGED
@@ -22,13 +22,68 @@ window.__ModuleLoader__.load({
22
22
  let react = require("react");
23
23
  const h = react.createElement;
24
24
 
25
+ // ── 共享基元(1.4.0):跨屏复用的小东西只有这一份 ─────────────
26
+ // 这些名字原本在 10 / 20 / 50 三个分片各写一遍(isStr/isNum/strSchema/parseSchema/
27
+ // unwrap/fmtSize/hexA,以及类型词表 3 份)。分片共享同一个 factory 作用域,
28
+ // 所以收敛到本片之后,后面的分片直接用、不再各写一份。
29
+ const isStr = (v) => { if (typeof v !== "string") throw new Error("expected string"); };
30
+ const isBool = (v) => { if (typeof v !== "boolean") throw new Error("expected boolean"); };
31
+ const isNum = (v) => { if (typeof v !== "number") throw new Error("expected number"); };
32
+ function parseSchema(shape) {
33
+ return {
34
+ parse(value) {
35
+ if (value === null || typeof value !== "object") throw new Error("sage-mem: invalid payload");
36
+ for (const [key, check] of Object.entries(shape)) {
37
+ if (!(key in value)) throw new Error("sage-mem: missing field " + key);
38
+ check(value[key]);
39
+ }
40
+ return value;
41
+ },
42
+ };
43
+ }
44
+ const strSchema = { parse(v) { isStr(v); return v; } };
45
+
46
+ /** 拆 typert remote 的 {ok, value} 信封。 */
47
+ function unwrap(result) {
48
+ return result && result.ok ? result.value : result;
49
+ }
50
+
51
+ /** 字节数 → 人话。三个档位对记忆文件足够(单条上限 512 KB)。 */
52
+ function fmtSize(n) {
53
+ if (n < 1024) return n + " B";
54
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
55
+ return (n / 1024 / 1024).toFixed(1) + " MB";
56
+ }
57
+
58
+ /** #rrggbb + alpha → rgba()。星图的辉光/底盘用。 */
59
+ function hexA(hex, a) {
60
+ const n = parseInt(hex.slice(1), 16);
61
+ return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a.toFixed(3) + ")";
62
+ }
63
+
64
+ // ── 类型词表(唯一定义) ──
65
+ // 文件列表的排序 / 筛选 / 徽章、星图的 kind 标签与配色,全部从这里取。
66
+ const TYPE_ORDER = ["user", "feedback", "project", "reference"];
67
+ const TYPE_SORT = ["user", "feedback", "project", "reference", "special"];
68
+ const TYPE_LABEL = { user: "用户", feedback: "反馈", project: "项目", reference: "参考", special: "特殊" };
69
+ const TYPE_META = {
70
+ user: ["👤", TYPE_LABEL.user],
71
+ feedback: ["📌", TYPE_LABEL.feedback],
72
+ project: ["📁", TYPE_LABEL.project],
73
+ reference: ["🔗", TYPE_LABEL.reference],
74
+ };
75
+ // 星图配色:深色档(夜空)与浅色档(暖纸 + 矿物颜料)各一套,色相错开才分得清类型。
76
+ const TYPE_COLOR_DARK = {
77
+ user: "#e0c07a", feedback: "#7ec8da", project: "#ac9edc", reference: "#92ceaa", special: "#bcc3d0",
78
+ };
79
+ const TYPE_COLOR_LIGHT = {
80
+ user: "#a4652c", feedback: "#3a6f8f", project: "#6a5b8f", reference: "#4a7c63", special: "#5b5750",
81
+ };
25
82
  const StarmapRuntime = (function (require, _react) {
26
83
  let react = _react;
27
84
  const h = react.createElement;
28
85
  // ── 手写 strict codec(浏览器无 zod,typert 只要求 parse 方法)──
29
- const isStr = (v) => { if (typeof v !== "string") throw new Error("expected string"); };
30
- const isNum = (v) => { if (typeof v !== "number") throw new Error("expected number"); };
31
- const strSchema = { parse(v) { isStr(v); return v; } };
86
+ // isStr / isNum / strSchema 来自 05-shared.js(这个 IIFE 在 factory 作用域内,看得到)。
32
87
  const starSchema = {
33
88
  parse(v) {
34
89
  if (v === null || typeof v !== "object") throw new Error("invalid star");
@@ -81,11 +136,6 @@ const StarmapRuntime = (function (require, _react) {
81
136
  ],
82
137
  };
83
138
 
84
- /** 拆 typert remote 的 {ok, value} 信封。 */
85
- function unwrap(result) {
86
- return result && result.ok ? result.value : result;
87
- }
88
-
89
139
  /** 错误详情尽量展开,避免 "[object Object]" 式黑洞。 */
90
140
  function detail(e) {
91
141
  if (!e) return "未知错误";
@@ -114,15 +164,10 @@ const StarmapRuntime = (function (require, _react) {
114
164
  // 深空画布上够辨认,又不抢眼。
115
165
  //
116
166
  // 两档取值:`color` = 深色档(夜空画布),`light` = 浅色档(纸本设色)。
117
- // 浅色档不用「深色档降亮度」那种做法 —— 高饱和冷色压在暖纸上会发脏;
118
- // 换成明度接近的矿物颜料色(赭石/石青/紫墨/石绿/墨),色相错开才分得清类型。
119
- const KIND_META = {
120
- user: { label: "用户", color: "#e0c07a", light: "#a4652c" },
121
- feedback: { label: "反馈", color: "#7ec8da", light: "#3a6f8f" },
122
- project: { label: "项目", color: "#ac9edc", light: "#6a5b8f" },
123
- reference: { label: "参考", color: "#92ceaa", light: "#4a7c63" },
124
- special: { label: "特殊", color: "#bcc3d0", light: "#5b5750" },
125
- };
167
+ // 类型标签与配色来自 05-shared.js 的唯一定义;这里只是把它们组装成星图用的 meta。
168
+ const KIND_META = {};
169
+ for (const k of TYPE_SORT) KIND_META[k] = { label: TYPE_LABEL[k], color: TYPE_COLOR_DARK[k], light: TYPE_COLOR_LIGHT[k] };
170
+
126
171
  /**
127
172
  * 宿主主题挂钩 —— 与 ui-theme 同一处:body[data-ds-dark-theme] 存在即深色档。
128
173
  */
@@ -180,16 +225,6 @@ const StarmapRuntime = (function (require, _react) {
180
225
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
181
226
  }
182
227
 
183
- function hexA(hex, a) {
184
- const n = parseInt(hex.slice(1), 16);
185
- return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a.toFixed(3) + ")";
186
- }
187
-
188
- function fmtBytes(n) {
189
- if (n >= 1024) return (n / 1024).toFixed(1) + " KB";
190
- return n + " B";
191
- }
192
-
193
228
  function fmtDate(ms) {
194
229
  if (!ms) return "";
195
230
  const d = new Date(ms);
@@ -391,39 +426,12 @@ const StarmapRuntime = (function (require, _react) {
391
426
  return blocks;
392
427
  }
393
428
 
394
- // ── overlay 开合的模块级 store(footer 按钮与 overlay 共享)──
395
- const uiStore = { open: false, subs: new Set() };
396
429
  /** client context 由 apply 存入;Slot 组件经 props 或此兜底获取。 */
397
430
  let appCtx = null;
398
- function setOpen(v) {
399
- uiStore.open = !!v;
400
- uiStore.subs.forEach((fn) => fn(uiStore.open));
401
- }
402
- function useOpen() {
403
- const s = react.useState(uiStore.open);
404
- react.useEffect(() => {
405
- const fn = (v) => s[1](v);
406
- uiStore.subs.add(fn);
407
- return () => { uiStore.subs.delete(fn); };
408
- }, []);
409
- return s[0];
410
- }
411
-
412
431
  // ── 样式(data-plugin-css 约定,幂等注入)──
413
432
  const CSS = [
414
- ".smap-footer-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1,transparent);background:transparent;color:var(--dsw-alias-label-secondary,inherit);border-radius:8px;padding:4px 10px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap;}",
415
- ".smap-fab{position:fixed;right:0;top:15%;transform:translateY(-50%);z-index:900;width:36px;height:64px;border-radius:12px 0 0 12px;border:1px solid var(--dsw-alias-border-l1);border-right:none;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-brand-primary);font-size:17px;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:.5;pointer-events:auto;transition:opacity .15s,background .15s,width .15s;box-shadow:-4px 0 18px rgba(0,0,0,.35);font-family:inherit;}",
416
- ".smap-fab:hover{opacity:1;width:46px;background:var(--dsw-alias-bg-layer-1);}",
417
- ".smap-footer-btn:hover{color:var(--dsw-alias-label-primary,inherit);border-color:var(--dsw-alias-border-l2,#8886);}",
418
- ".smap-footer-icon{font-size:13px;line-height:1;}",
419
- ".smap-veil{position:fixed;inset:0;z-index:1000;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.45));backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;pointer-events:auto;animation:smap-fade-in .18s ease-out;}",
420
- ".smap-modal{width:min(980px,94vw);height:min(680px,92vh);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.55);padding:16px 18px;display:flex;flex-direction:column;overflow:hidden;pointer-events:auto;animation:smap-pop-in .22s cubic-bezier(.2,.9,.3,1.15);}",
421
- "@keyframes smap-fade-in{from{opacity:0}to{opacity:1}}",
422
- "@keyframes smap-pop-in{from{opacity:0;transform:scale(.96) translateY(10px)}to{opacity:1;transform:none}}",
423
433
  ".smap-card{background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:12px 14px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;flex:1;min-height:0;}",
424
434
  ".smap-head{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}",
425
- ".smap-title{font-size:15px;font-weight:600;letter-spacing:.06em;}",
426
- ".smap-sub{font-size:11px;color:var(--dsw-alias-label-secondary);}",
427
435
  ".smap-spacer{flex:1;}",
428
436
  ".smap-search{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;color:var(--dsw-alias-label-primary);font-size:12px;padding:4px 9px;outline:none;width:160px;font-family:inherit;}",
429
437
  ".smap-search:focus{border-color:var(--dsw-alias-border-l2);}",
@@ -1358,7 +1366,7 @@ const StarmapRuntime = (function (require, _react) {
1358
1366
  if (s.mtimeMs > newest) newest = s.mtimeMs;
1359
1367
  }
1360
1368
  legendKids.push(h("span", { key: "count", className: "smap-count" },
1361
- viewStars.length + " 条 · " + fmtBytes(totalBytes) + (newest ? " · 最近更新 " + fmtDate(newest) : "") +
1369
+ viewStars.length + " 条 · " + fmtSize(totalBytes) + (newest ? " · 最近更新 " + fmtDate(newest) : "") +
1362
1370
  (showArchive && archivedN ? " · 档案馆 " + archivedN + " 颗暗星" : "") +
1363
1371
  " · 单击看" + (showArchive && archivedN ? "详情" : "全文")));
1364
1372
 
@@ -1367,7 +1375,7 @@ const StarmapRuntime = (function (require, _react) {
1367
1375
  const kids = [
1368
1376
  h("div", { key: "t", className: "smap-tip-title" }, hover.star.title),
1369
1377
  h("div", { key: "k", className: "smap-tip-kind" },
1370
- meta.label + " · " + hover.star.file + " · " + fmtBytes(hover.star.bytes)),
1378
+ meta.label + " · " + hover.star.file + " · " + fmtSize(hover.star.bytes)),
1371
1379
  ];
1372
1380
  if (hover.star.archived === true) {
1373
1381
  // 悬浮也要说清它是档案馆里的:暗星外表已经不同,别让人猜。
@@ -1391,7 +1399,7 @@ const StarmapRuntime = (function (require, _react) {
1391
1399
  h("button", { className: "smap-btn", onClick: () => setSelected(null), title: "关闭" }, "×")),
1392
1400
  h("div", { key: "meta", className: "smap-panel-meta" },
1393
1401
  h("span", { className: "smap-meta-dot", style: { background: kindColor(meta) } }),
1394
- meta.label + " · " + selected.star.file + " · " + fmtBytes(selected.star.bytes) +
1402
+ meta.label + " · " + selected.star.file + " · " + fmtSize(selected.star.bytes) +
1395
1403
  // 归档星的 mtime 是「当年写下」的时间,跟「什么时候收起来的」不是一回事 ——
1396
1404
  // 这里只留「已归档」,具体时间在下面的归档块里给。
1397
1405
  (selected.star.archived === true
@@ -1494,42 +1502,11 @@ const StarmapRuntime = (function (require, _react) {
1494
1502
  body);
1495
1503
  }
1496
1504
 
1497
- // ── 全屏 overlay ─────────────────────────────────────────────
1498
- function StarmapOverlay() {
1499
- const open = useOpen();
1500
- react.useEffect(() => {
1501
- if (!open) return undefined;
1502
- const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
1503
- document.addEventListener("keydown", onKey);
1504
- return () => document.removeEventListener("keydown", onKey);
1505
- }, [open]);
1506
- if (!open) return null;
1507
- return h("div", {
1508
- className: "smap-veil",
1509
- onMouseDown: (e) => { if (e.target === e.currentTarget) setOpen(false); },
1510
- },
1511
- h("div", { className: "smap-modal" },
1512
- h(StarMap, { ctx: appCtx, onClose: () => setOpen(false) })));
1513
- }
1514
-
1515
- // ── 窗口右缘浮标(唯一入口,不绑实例/会话/Run 卡片)────────────
1516
- function StarmapEdgeTab() {
1517
- const open = useOpen();
1518
- if (open) return null;
1519
- return h("button", {
1520
- type: "button",
1521
- className: "smap-fab",
1522
- onClick: () => setOpen(true),
1523
- title: "记忆星图 — 把 sage-mem 的记忆画成一片星空",
1524
- "aria-label": "记忆星图",
1525
- }, "✦");
1526
- }
1527
-
1528
1505
  const inject = ["slots", "remote"];
1529
1506
 
1530
1507
  /**
1531
1508
  * Client 插件:$mount strict remote contribution(第三方 remote 不在
1532
- * 宿主的固定 remote 清单里,必须自己挂),再注册 footer 按钮与 overlay。
1509
+ * 宿主的固定 remote 清单里,必须自己挂),再注册星图子槽(B 方案起不再有右缘浮标与全屏 overlay)。
1533
1510
  */
1534
1511
  async function apply(ctx) {
1535
1512
  appCtx = ctx;
@@ -1559,22 +1536,7 @@ const StarmapRuntime = (function (require, _react) {
1559
1536
 
1560
1537
 
1561
1538
  // ── 手写 strict codec(浏览器无 zod,typert 只要求 parse 方法)──
1562
- function parseSchema(shape) {
1563
- return {
1564
- parse(value) {
1565
- if (value === null || typeof value !== "object") throw new Error("sage-mem: invalid payload");
1566
- for (const [key, check] of Object.entries(shape)) {
1567
- if (!(key in value)) throw new Error("sage-mem: missing field " + key);
1568
- check(value[key]);
1569
- }
1570
- return value;
1571
- },
1572
- };
1573
- }
1574
- const isStr = (v) => { if (typeof v !== "string") throw new Error("expected string"); };
1575
- const isBool = (v) => { if (typeof v !== "boolean") throw new Error("expected boolean"); };
1576
- const isNum = (v) => { if (typeof v !== "number") throw new Error("expected number"); };
1577
- const strSchema = { parse(v) { isStr(v); return v; } };
1539
+ // 基元(parseSchema / isStr / isBool / isNum / strSchema)在 05-shared.js。
1578
1540
  const fileSchema = parseSchema({ "file": isStr, "type": isStr, "description": isStr, "size": isNum });
1579
1541
  const listSchema = { parse(v) { if (!Array.isArray(v)) throw new Error("expected array"); v.forEach((f) => fileSchema.parse(f)); return v; } };
1580
1542
  const readSchema = parseSchema({ "name": isStr, "content": isStr });
@@ -2072,10 +2034,6 @@ const StarmapRuntime = (function (require, _react) {
2072
2034
  // ── 工具行 + 工具面板(体检 / 归档候选 / 保留名原样编辑)──
2073
2035
  // 三个新入口收在一行**轻量**文字按钮里:不跟主工具栏抢重量级,也不各占一块版面。
2074
2036
  // 面板一次只开一个(Section 里一个 tool 状态),否则文件列表会被顶到屏幕外。
2075
- ".smem-tools{display:flex;align-items:center;gap:4px;flex-wrap:wrap;}",
2076
- ".smem-tool-btn{border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;font-family:inherit;cursor:pointer;padding:2px 8px;border-radius:6px;}",
2077
- ".smem-tool-btn:hover{color:var(--dsw-alias-label-primary);}",
2078
- '.smem-tool-btn[data-on="1"]{color:var(--dsw-alias-brand-primary,#4d76e6);background:var(--dsw-alias-bg-layer-2);}',
2079
2037
  // 工具抽屉:关着时**零占位**(工具栏里只有一个「工具 ▾」),点开才占版面。
2080
2038
  // ⚠️ 可发现性不靠抽屉本身,靠头部带常显的「体检 N 个问题」徽标 —— 那是整个
2081
2039
  // 抽屉方案能成立的前提(否则三个能力就退回「藏起来」了)。
@@ -2207,18 +2165,6 @@ const StarmapRuntime = (function (require, _react) {
2207
2165
  ".smem-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
2208
2166
  ].join("\n");
2209
2167
 
2210
- const TYPE_META = {
2211
- user: ["👤", "用户"],
2212
- feedback: ["📌", "反馈"],
2213
- project: ["📁", "项目"],
2214
- reference: ["🔗", "参考"],
2215
- };
2216
- const TYPE_ORDER = ["user", "feedback", "project", "reference"];
2217
- // 筛选条上的显示名:emoji 在筛选条里太吵,只用文字;special 是星图的兜底类,
2218
- // 文件列表里出现它说明 frontmatter 写了 type: special。
2219
- const TYPE_LABEL = { user: "用户", feedback: "反馈", project: "项目", reference: "参考", special: "特殊" };
2220
- const TYPE_SORT = ["user", "feedback", "project", "reference", "special"];
2221
-
2222
2168
  /**
2223
2169
  * 把输入框里的一串标签切成数组。
2224
2170
  *
@@ -2467,17 +2413,6 @@ const StarmapRuntime = (function (require, _react) {
2467
2413
  tagLine + "\nmetadata:\n node_type: memory\n type: " + type + "\n---\n\n";
2468
2414
  }
2469
2415
 
2470
- function fmtSize(n) {
2471
- if (n < 1024) return n + " B";
2472
- if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
2473
- return (n / 1024 / 1024).toFixed(1) + " MB";
2474
- }
2475
-
2476
- /** 拆 typert remote 的 {ok, value} 信封。 */
2477
- function unwrap(result) {
2478
- return result && result.ok ? result.value : result;
2479
- }
2480
-
2481
2416
  function Chip(props) {
2482
2417
  return h("button", {
2483
2418
  type: "button",
@@ -2766,7 +2701,7 @@ const StarmapRuntime = (function (require, _react) {
2766
2701
  standalone ? null : toolClose(props),
2767
2702
  ]),
2768
2703
  r.problems
2769
- ? h("div", { className: "smem-audit-secs", key: "secs" }, [
2704
+ ? h("div", { key: "secs" }, [
2770
2705
  section("索引悬空(索引里有、文件没了)", r.dangling, (x) => x.file),
2771
2706
  section("漏索引(文件在、索引里没有)", r.unlisted, (x) => x.file),
2772
2707
  section("type 与前缀对不上", r.typeMismatch, (x) => x.file + " · type=" + x.type + ",文件名前缀=" + x.prefix),
@@ -3252,7 +3187,7 @@ const StarmapRuntime = (function (require, _react) {
3252
3187
  subtab("settings", "设置"),
3253
3188
  subtab("records", "记录", String(snaps.length + runs.length + reports.length)),
3254
3189
  ]),
3255
- segOf === "run" ? h("div", { className: "smem-seg", key: "seg-run" }, [
3190
+ segOf === "run" ? h("div", { key: "seg-run" }, [
3256
3191
  h("div", { className: "smem-autodream-sec", key: "top" }, [
3257
3192
  h("div", { className: "smem-autodream-row", key: "r" }, [
3258
3193
  h("button", {
@@ -3376,7 +3311,7 @@ const StarmapRuntime = (function (require, _react) {
3376
3311
  : null,
3377
3312
  ]),
3378
3313
  ]) : null,
3379
- segOf === "settings" ? h("div", { className: "smem-seg", key: "seg-set" }, [
3314
+ segOf === "settings" ? h("div", { key: "seg-set" }, [
3380
3315
  // 自动归档那一段(三档策略 + 三个阈值)**已挪到「自动归档」页签**(v0.9.9):
3381
3316
  // 归档是确定性规则、不调模型,和这一页的「让模型整理」不是一件事;
3382
3317
  // 同一件事只在一处设定,这里不再重复摆一份。
@@ -3477,7 +3412,7 @@ const StarmapRuntime = (function (require, _react) {
3477
3412
  ]) : null,
3478
3413
  // ── 回滚点(新)──────────────────────────────────────────────
3479
3414
  // 只改这一个文件、只用既有 h() 写法;数据全走 remote.autodream。
3480
- segOf === "records" ? h("div", { className: "smem-seg", key: "seg-rec" }, [
3415
+ segOf === "records" ? h("div", { key: "seg-rec" }, [
3481
3416
  h("div", { className: "smem-autodream-sec", key: "snap" }, [
3482
3417
  h("div", { className: "smem-autodream-row", key: "h" }, [
3483
3418
  h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "回滚点"),
@@ -3706,7 +3641,7 @@ const StarmapRuntime = (function (require, _react) {
3706
3641
  });
3707
3642
  }
3708
3643
 
3709
- return h("div", { className: "smem-seg" }, [
3644
+ return h("div", { }, [
3710
3645
  // ── ① 它做什么(这一屏存在的理由写在最前面)──
3711
3646
  h("div", { className: "smem-autodream-sec", key: "what" }, [
3712
3647
  h("div", { className: "smem-autodream-row", key: "h" }, [
@@ -3898,7 +3833,8 @@ const StarmapRuntime = (function (require, _react) {
3898
3833
  const rawBusy = sRawBusy[0], setRawBusy = sRawBusy[1];
3899
3834
  const sRawLoading = react.useState(false);
3900
3835
  const rawLoading = sRawLoading[0], setRawLoading = sRawLoading[1];
3901
- const renderSlot = props.renderSlot;
3836
+ // renderSlot:shell 会经 props 传进来(见 80-apply 的 children 声明),
3837
+ // 但本插件改用并列 tab 渲染星图,不再调用它 —— 声明保留给 shell,这里不取。
3902
3838
 
3903
3839
  function load() {
3904
3840
  setLoading(true);
package/lib/index.js CHANGED
@@ -17,7 +17,8 @@
17
17
  import { AutodreamEngine } from './autodream.js'
18
18
  import { auditMemoryDir } from './autodream/audit.js'
19
19
  import { DEFAULT_CONFIG } from './autodream/config.js'
20
- import { MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
20
+ import { LIMIT_SPECS, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
21
+ import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from './shared/md-name.js'
21
22
  import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
22
23
  import { ageDays, bigrams, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
23
24
  import { isReserved, nameKey, safeName } from './memory/naming.js'
@@ -30,7 +31,7 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
30
31
  import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
31
32
  import { join } from 'node:path'
32
33
  import { checkUpdate as checkUpdateImpl } from './update.js'
33
- import { autodreamConfigSnapshot, clampInt, LIMIT_SPECS, MAX_RAW_BYTES, pendingLimits, rawName, readReservedFile, readSettingsFile, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
34
+ import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, pendingLimits, rawName, readReservedFile, readSettingsFile, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
34
35
  import { createRecallInjector, createSessionInjectState } from './recall/index.js'
35
36
  import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
36
37
 
@@ -420,7 +421,7 @@ export class MemoryGateway extends TypertRemoteService {
420
421
  const pending = pendingLimits(file)
421
422
  const reservedExtra = await readReservedFile()
422
423
  // 模块加载时那份「用户声明的保护名」(RESERVED_FILES 里抠掉两个内置名)
423
- const activeExtra = [...RESERVED_FILES].filter((n) => n !== 'memory.md' && n !== 'session-log.md')
424
+ const activeExtra = [...RESERVED_FILES].filter((n) => n !== INDEX_FILE_KEY && n !== SESSION_LOG_KEY)
424
425
  const norm = (list) => JSON.stringify([...list].map((s) => String(s).toLowerCase()).sort())
425
426
  const restartRequired =
426
427
  JSON.stringify(limits) !== JSON.stringify(pending) || norm(activeExtra) !== norm(reservedExtra)
@@ -429,7 +430,7 @@ export class MemoryGateway extends TypertRemoteService {
429
430
  pendingLimits: pending,
430
431
  restartRequired,
431
432
  // 生效的保护名单(含两个内置名)+ 用户声明的那部分原文(面板编辑的是后者)
432
- reserved: [...new Set(['memory.md', 'session-log.md', ...reservedExtra.map((s) => s.toLowerCase())])],
433
+ reserved: [...new Set([INDEX_FILE_KEY, SESSION_LOG_KEY, ...reservedExtra.map((s) => s.toLowerCase())])],
433
434
  reservedExtra,
434
435
  }
435
436
  }
@@ -465,7 +466,7 @@ export class MemoryGateway extends TypertRemoteService {
465
466
  const cleaned = []
466
467
  for (const item of p.reserved) {
467
468
  const s = String(item ?? '').trim()
468
- if (!s || !s.endsWith('.md') || s.includes('/') || s.includes('\\') || s.startsWith('..')) {
469
+ if (!isPlainMdName(s) || s.startsWith('..')) {
469
470
  return { ok: false, error: `sage-mem: invalid reserved file name: ${String(item)}` }
470
471
  }
471
472
  if (!cleaned.includes(s)) cleaned.push(s)
@@ -8,6 +8,7 @@
8
8
  import { readFileSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { dirname, join } from 'node:path'
11
+ import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from '../shared/md-name.js'
11
12
 
12
13
  export const MEMORY_DIR = process.env.SAGE_MEM_DIR || join(homedir(), '.sage-mem', 'memory')
13
14
  /**
@@ -29,6 +30,19 @@ export function envInt(name, dflt, lo, hi) {
29
30
  return Math.max(lo, Math.min(hi, Math.round(n)))
30
31
  }
31
32
 
33
+ /**
34
+ * 面板可改的 5 项注入上限 —— **唯一定义**(值 + 边界)。
35
+ * 面板(\u0060lib/settings.js\u0060)、组合根、环境变量三处都从这里取,不再各写一份。
36
+ */
37
+ export const LIMIT_SPECS = {
38
+ maxResults: { env: 'SAGE_MEM_MAX_RESULTS', dflt: 10, lo: 1, hi: 30 },
39
+ maxChars: { env: 'SAGE_MEM_MAX_CHARS', dflt: 1500, lo: 200, hi: 20000 },
40
+ maxBaseline: { env: 'SAGE_MEM_MAX_BASELINE', dflt: 8, lo: 1, hi: 50 },
41
+ maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
42
+ staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
43
+ }
44
+ const fromSpec = (spec) => envInt(spec.env, spec.dflt, spec.lo, spec.hi)
45
+
32
46
  /**
33
47
  * 一次注入最多带几条记忆。
34
48
  *
@@ -37,10 +51,10 @@ export function envInt(name, dflt, lo, hi) {
37
51
  * 字符的上下文。**这个数字该由实测决定**,所以留了 SAGE_MEM_MAX_RESULTS 覆写口
38
52
  * (比较两档的命中率与 token 账单,别拍脑袋)。
39
53
  */
40
- export const MAX_RESULTS = envInt('SAGE_MEM_MAX_RESULTS', 10, 1, 30)
54
+ export const MAX_RESULTS = fromSpec(LIMIT_SPECS.maxResults)
41
55
 
42
56
  /** 单条记忆注入的字符上限。 */
43
- export const MAX_CHARS_PER_FILE = envInt('SAGE_MEM_MAX_CHARS', 1500, 200, 20000)
57
+ export const MAX_CHARS_PER_FILE = fromSpec(LIMIT_SPECS.maxChars)
44
58
 
45
59
  /**
46
60
  * baseline 名额。
@@ -49,7 +63,7 @@ export const MAX_CHARS_PER_FILE = envInt('SAGE_MEM_MAX_CHARS', 1500, 200, 20000)
49
63
  * 而 baseline 恰恰是短问题、恢复会话时唯一的兜底。现在放到 8,
50
64
  * 排序见下面「按显式优先级排,不按文件名」那段。
51
65
  */
52
- export const MAX_BASELINE = envInt('SAGE_MEM_MAX_BASELINE', 8, 1, 50)
66
+ export const MAX_BASELINE = fromSpec(LIMIT_SPECS.maxBaseline)
53
67
 
54
68
  /**
55
69
  * 单会话累计注入字节上限 —— 到顶以后这个会话就不再注入新的记忆。
@@ -63,7 +77,7 @@ export const MAX_BASELINE = envInt('SAGE_MEM_MAX_BASELINE', 8, 1, 50)
63
77
  * 60 KB 是与 CC 对齐的起点、不是实测出来的数字 —— 留了 SAGE_MEM_MAX_SESSION_BYTES
64
78
  * 覆写口,量过再定。
65
79
  */
66
- export const MAX_SESSION_BYTES = envInt('SAGE_MEM_MAX_SESSION_BYTES', 60 * 1024, 4096, 512 * 1024)
80
+ export const MAX_SESSION_BYTES = fromSpec(LIMIT_SPECS.maxSessionBytes)
67
81
 
68
82
  /**
69
83
  * 状态卡预留占总预算的比例,以及它的上下夹逼区间。
@@ -94,7 +108,7 @@ export const CONTENT_BUDGET = MAX_SESSION_BYTES - STATUS_RESERVE
94
108
  * 推理,相对天数才会。为什么需要它:陈旧记忆被当事实引用时,「引用」这个动作
95
109
  * 本身会让它显得更权威、而不是更可疑。
96
110
  */
97
- export const STALE_DAYS = envInt('SAGE_MEM_STALE_DAYS', 1, 0, 365)
111
+ export const STALE_DAYS = fromSpec(LIMIT_SPECS.staleDays)
98
112
 
99
113
  /**
100
114
  * 注入文本开头的引用纪律 —— **必须独立成节,不能压成一条 bullet**。
@@ -144,13 +158,13 @@ export function readExtraReserved() {
144
158
  if (!Array.isArray(arr)) return []
145
159
  return arr
146
160
  .map((s) => String(s).trim().toLowerCase())
147
- .filter((s) => s.endsWith('.md') && !s.includes('/') && !s.includes('\\') && !s.startsWith('..'))
161
+ .filter((s) => isPlainMdName(s) && !s.startsWith('..'))
148
162
  } catch {
149
163
  return []
150
164
  }
151
165
  }
152
166
 
153
- export const RESERVED_FILES = new Set(['memory.md', 'session-log.md', ...readExtraReserved()])
167
+ export const RESERVED_FILES = new Set([INDEX_FILE_KEY, SESSION_LOG_KEY, ...readExtraReserved()])
154
168
 
155
169
  /**
156
170
  * 非记忆条目:不参与检索 / 不进星图 / 不出现在设置页列表里,读写也一律不通。
@@ -161,8 +175,8 @@ export const RESERVED_FILES = new Set(['memory.md', 'session-log.md', ...readExt
161
175
  * (要出现在列表与星图里、要能读),只是不许从表单写/删。
162
176
  */
163
177
  export const NON_ENTRY_FILES = new Set([
164
- 'memory.md',
165
- 'session-log.md',
178
+ INDEX_FILE_KEY,
179
+ SESSION_LOG_KEY,
166
180
  ])
167
181
 
168
182
  /**
@@ -173,4 +187,4 @@ export const NON_ENTRY_FILES = new Set([
173
187
  * 到这个量级基本可以判定是写错了目标(比如把日志、代码贴进来)。
174
188
  * 与 lib/typert.host.js 的 fileContentSchema.max(512 * 1024) 保持一致。
175
189
  */
176
- export const MAX_FILE_BYTES = 512 * 1024
190
+ export { MAX_FILE_BYTES } from '../shared/limits.js'
@@ -29,6 +29,11 @@ export function parseTags(body) {
29
29
  return []
30
30
  }
31
31
 
32
+ /** 这份文件有没有 frontmatter 块(审计用;与 parseFrontmatter 的匹配规则同一份)。 */
33
+ export function hasFrontmatter(content) {
34
+ return typeof content === 'string' && /^---\r?\n[\s\S]*?\r?\n---/.test(content)
35
+ }
36
+
32
37
  /** 解析 frontmatter 的 name/description/type(兼容顶层与 metadata 嵌套两种 type)。 */
33
38
  export function parseFrontmatter(content) {
34
39
  // 换行必须容忍 CRLF:任何用 Windows 编辑器碰过一次的文件都会被换成 \r\n,
@@ -12,9 +12,10 @@ import { readFile, readdir } from 'node:fs/promises'
12
12
  import { basename, join } from 'node:path'
13
13
  import { parseFrontmatter } from './frontmatter.js'
14
14
  import { nameKey } from './naming.js'
15
+ import { INDEX_FILE_KEY } from '../shared/md-name.js'
15
16
  import { createWriteQueue, writeFileAtomic } from './util.js'
16
17
 
17
- const INDEX_KEY = 'memory.md'
18
+ const INDEX_KEY = INDEX_FILE_KEY
18
19
  /** 条目里的描述上限:索引是清单,不是内容堆。 */
19
20
  const DESC_MAX = 120
20
21
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { NON_ENTRY_FILES, RESERVED_FILES } from './config.js'
6
+ import { isPlainMdName } from '../shared/md-name.js'
6
7
  import { basename } from 'node:path'
7
8
 
8
9
  /**
@@ -44,23 +45,9 @@ export function isMemoryEntry(name) {
44
45
  * @returns 通过校验的原始文件名;非法返回 null
45
46
  */
46
47
  export function safeName(raw) {
47
- if (typeof raw !== 'string' || raw === '') return null
48
- // basename 与原文不一致=含目录成分。旧版是「悄悄取 basename 继续」,
49
- // 与 starmap 那套(要求 safe === raw)行为不一致;现在统一为拒绝。
50
- if (basename(raw) !== raw) return null
51
- // `:` 在 NTFS 上是**交替数据流**的分隔符:`MEMORY.md:evil.md` 以 `.md` 结尾、能过下面
52
- // 所有判据,落盘时却会写到索引的隐藏数据流上(`readdir` 看不见,只写流不报错)。
53
- // 实测更糟:`writeFile('MEMORY.md:evil.md')` 会在 `rename` 处抛**未捕获**的 EINVAL ——
54
- // 违背「拒绝一律返回 {ok:false}、不抛」这条契约。正常文件名不允许 `:`,直接拒。
55
- if (raw.includes(':')) return null
56
- // `\` 是 `:` 那条理由的另一半:**它是 Windows 的路径分隔符,在 Linux 上却只是普通字符**。
57
- // 于是 `basename('..\\a.md')` 在 Windows 上得到 `a.md`(被上面那条判据拒掉),
58
- // 在 Linux 上原样返回 `..\a.md`(整条通过)—— 同一份输入在两个平台上得出相反的合法性。
59
- // 而记忆目录是会被同步 / 拷贝 / 备份还原的:**在 Linux 上合法落盘的名字,同步到 Windows
60
- // 就是一个目录穿越**。判定必须与平台无关,所以无条件拒。
61
- // (这条是 Linux CI 抓出来的:本机 Windows 上测试一直是绿的。)
62
- if (raw.includes('\\')) return null
63
- if (!/\.md$/i.test(raw)) return null
48
+ // 底线规则(单段 / .md / 无路径分隔符 / 无冒号)只有一份:shared/md-name.js。
49
+ // 这里额外的是**记忆条目**语义:索引与流水不是一条记忆。
50
+ if (!isPlainMdName(raw)) return null
64
51
  if (isNonEntry(raw)) return null
65
52
  return raw
66
53
  }
@@ -24,12 +24,7 @@ export const ARCHIVE_DIR_NAME = 'archive'
24
24
  /** 归档区绝对路径。文件名不变,只换目录。 */
25
25
  export const MEMORY_ARCHIVE_DIR = join(MEMORY_DIR, ARCHIVE_DIR_NAME)
26
26
 
27
- /** 本地时间 `YYYY-MM-DD HH:mm` —— 归档留痕是给人看的,不用 ISO / UTC。 */
28
- export function localStamp(ms = Date.now()) {
29
- const d = new Date(ms)
30
- const p = (n) => String(n).padStart(2, '0')
31
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
32
- }
27
+ export { stampHuman as localStamp } from '../shared/stamp.js'
33
28
 
34
29
  /**
35
30
  * 把一个值写成 YAML 标量。
package/lib/settings.js CHANGED
@@ -5,35 +5,18 @@
5
5
  * envInt 调用(lo/hi 不许在这里另立一套)。
6
6
  */
7
7
  import { readFile } from 'node:fs/promises'
8
- import { basename, join } from 'node:path'
9
- import { STATE_ROOT } from './memory/config.js'
8
+ import { join } from 'node:path'
9
+ import { LIMIT_SPECS, STATE_ROOT } from './memory/config.js'
10
+ import { MAX_RAW_BYTES } from './shared/limits.js'
11
+ import { isPlainMdName } from './shared/md-name.js'
12
+ export { LIMIT_SPECS, MAX_RAW_BYTES }
10
13
  import { DEFAULT_CONFIG } from './autodream/config.js'
11
14
  import { readJson } from './autodream/util.js'
12
15
 
13
- /**
14
- * 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
15
- * 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
16
- */
17
- export const LIMIT_SPECS = {
18
- maxResults: { env: 'SAGE_MEM_MAX_RESULTS', dflt: 10, lo: 1, hi: 30 },
19
- maxChars: { env: 'SAGE_MEM_MAX_CHARS', dflt: 1500, lo: 200, hi: 20000 },
20
- maxBaseline: { env: 'SAGE_MEM_MAX_BASELINE', dflt: 8, lo: 1, hi: 50 },
21
- maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
22
- staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
23
- }
24
-
25
16
  /** 面板设置的落点:状态根下(与 autodream 的配置、台账同级)。 */
26
17
  export const SETTINGS_FILE = join(STATE_ROOT, 'settings.json')
27
18
  export const RESERVED_FILE = join(STATE_ROOT, 'reserved.json')
28
19
 
29
- /**
30
- * `writeRaw` 的体积上限(4 MB)。
31
- *
32
- * ⚠️ **故意不复用 `MAX_FILE_BYTES`(512 KB)**:保留名里就有 `session-log.md` 这种追加型
33
- * 流水,它天然长得比单条记忆大得多(实测已 > 512 KB)。沿用小上限 = 用户永远存不回去。
34
- */
35
- export const MAX_RAW_BYTES = 4 * 1024 * 1024
36
-
37
20
  /** 整数收敛(与 config.js 的 envInt 同一套语义):非有限数回落默认,越界夹到边界。 */
38
21
  export const clampInt = (v, spec) => (Number.isFinite(v) ? Math.max(spec.lo, Math.min(spec.hi, Math.round(v))) : spec.dflt)
39
22
 
@@ -84,11 +67,8 @@ export async function readReservedFile() {
84
67
  */
85
68
  export function rawName(raw) {
86
69
  const s = typeof raw === 'string' ? raw : ''
87
- if (!s || basename(s) !== s) return null
88
- // `:` 是 NTFS 交替数据流的分隔符:`MEMORY.md:evil.md` 能过扩展名判据,落盘却是写到
89
- // 索引的隐藏流上(`readdir` 看不见)。与 `safeName` 同一条理由,这里也要拒。
90
- if (s.includes(':')) return null
91
- return /\.md$/i.test(s) ? s : null
70
+ if (!isPlainMdName(s)) return null
71
+ return s
92
72
  }
93
73
 
94
74
  /** autodream 的实际配置(缺文件/坏文件回落默认值)—— 面板要显示真实策略,不能显示想象。 */
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 两个体积上限的**唯一**来源(零依赖,host / manifest / 引擎共用)。
3
+ *
4
+ * 为什么单独抽:512 KB 那条同时出现在 `memory/config.js`、`typert.host.js` 的
5
+ * `fileContentSchema` 与注释里;4 MB 那条出现在设置工具与 manifest 的
6
+ * `rawFileContentSchema`。它们**必须相等** —— 传输层比写入层紧,用户就会收到
7
+ * "面板能存、remote 存不进去"的怪现象;比写入层松,则超限内容会穿过闸门。
8
+ */
9
+
10
+ /** 单条记忆的上限(512 KB)。`writeFile` / 读取闸门 / manifest 三处同一把尺子。 */
11
+ export const MAX_FILE_BYTES = 512 * 1024
12
+
13
+ /**
14
+ * 保留名原样读写的上限(4 MB)。**故意不复用 512 KB**:
15
+ * `session-log.md` 是追加型流水,天然比单条记忆大得多,沿用小上限 = 用户永远存不回去。
16
+ */
17
+ export const MAX_RAW_BYTES = 4 * 1024 * 1024
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 「合法 .md 文件名」的**唯一**判据(零依赖,host 与 manifest 共用)。
3
+ *
4
+ * 为什么单独一个文件:这条规则原先有 5 份实现(`memory/naming.js` 的 safeName、
5
+ * 设置页的 rawName、`setSettings` 里手写一遍、`config.js` 的 readExtraReserved 过滤、
6
+ * `typert.host.js` 的 fileNameSchema 正则)。它们**不是复制粘贴这么简单**:
7
+ * 有一份漏了 `i`、有一份少了 `:` 判定,于是同一份"能过的名字"在不同入口得到相反结论。
8
+ * 判据必须只有一份,平台相关的例外必须写成显式规则。
9
+ *
10
+ * 三条平台无关的底线(全部有事故来源):
11
+ * - `/` 与 `\\` 一律拒:Windows 当路径分隔符、Linux 不当,判据不能依赖 path.sep;
12
+ * - `:` 一律拒:NTFS 交替数据流(`MEMORY.md:evil.md` 能过扩展名判据却写到隐藏流);
13
+ * - 扩展名大小写不敏感:文件系统(NTFS/APFS)就是如此,`a.MD` 必须与 `a.md` 同宽。
14
+ */
15
+ export const MD_NAME_RE = /^[^\\/:*?"<>|\u0000-\u001f]+\.md$/i
16
+ export const MD_NAME_PATTERN = MD_NAME_RE.source
17
+
18
+ /** 单段、`.md` 结尾、无路径分隔符 / 冒号 / 控制字符。**不含**保留名判定。 */
19
+ export function isPlainMdName(raw) {
20
+ return typeof raw === 'string' && raw !== '' && MD_NAME_RE.test(raw)
21
+ }
22
+
23
+ /** 索引与流水的规范名(小写)。它们不是"某条记忆",但多处要按名字识别。 */
24
+ export const INDEX_FILE_KEY = 'memory.md'
25
+ export const SESSION_LOG_KEY = 'session-log.md'
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 本地时间戳的**唯一**实现(零依赖)。
3
+ *
4
+ * 原先三份:`memory/scan.js` 的 localStamp、`autodream/util.js` 的 stampHuman、
5
+ * `autodream/tools.js` 里那行与注释挤在一起的 localStamp。三份输出必须逐字相同 ——
6
+ * 它们都会写进**记忆文件本身**(archived_at)或报告,差一个补零规则就是数据不一致。
7
+ *
8
+ * 一律本地时间:这是给人看的("什么时候归档的"),不是机器间传输的 ISO 时间。
9
+ */
10
+
11
+ /** `YYYY-MM-DD` —— 只要日期的场合(提示词里的「今天是」)。 */
12
+ export function stampDate(ms = Date.now()) {
13
+ const d = new Date(ms)
14
+ const p = (n) => String(n).padStart(2, '0')
15
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
16
+ }
17
+
18
+ /** `YYYY-MM-DD HH:mm` —— 给人看(归档留痕、报告列表)。 */
19
+ export function stampHuman(ms = Date.now()) {
20
+ const d = new Date(ms)
21
+ const p = (n) => String(n).padStart(2, '0')
22
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
23
+ }
24
+
25
+ /** `YYYYMMDD-HHmmss` —— 给文件名用(快照目录 / 运行 id 的前半)。 */
26
+ export function stampCompact(ms = Date.now()) {
27
+ const d = new Date(ms)
28
+ const p = (n) => String(n).padStart(2, '0')
29
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
30
+ }
@@ -5,6 +5,8 @@
5
5
  * schema 必须是 zod v4 实例(typert-loader 校验 "_zod" in schema)。
6
6
  */
7
7
  import { z } from 'zod'
8
+ import { MD_NAME_PATTERN } from './shared/md-name.js'
9
+ import { MAX_FILE_BYTES, MAX_RAW_BYTES } from './shared/limits.js'
8
10
 
9
11
  // 文件名:与 host 侧 safeName 同一套底线 —— 非空、无路径分隔符与 Windows 非法字符、
10
12
  // 以 .md 结尾、长度有界。(真正的目录逃逸防护仍在 host 侧 safeName。)
@@ -13,10 +15,10 @@ import { z } from 'zod'
13
15
  // 而文件名闸门原先**没有** `i`,两层判据宽度不一致 —— 宿主侧认可的 `a.MD`
14
16
  // 会被传输入参 schema 挡在门外(读不了自己列表里列出来的文件)。大小写不敏感是
15
17
  // NTFS / APFS 的事实,判据必须与文件系统同宽(与 `nameKey` 归一同一原则)。
16
- const fileNameSchema = z.string().min(1).max(255).regex(/^[^\\/:*?"<>|\u0000-\u001f]+\.md$/i)
18
+ const fileNameSchema = z.string().min(1).max(255).regex(new RegExp(MD_NAME_PATTERN, 'i'))
17
19
  // 正文:512 KB 上限。理由是「记忆要塞进 system prompt」——单条正常量级在几十 KB,
18
20
  // 到半兆量级基本是写错了目标(贴了日志/代码进来)。与 lib/index.js 的 MAX_FILE_BYTES 一致。
19
- const fileContentSchema = z.string().max(512 * 1024)
21
+ const fileContentSchema = z.string().max(MAX_FILE_BYTES)
20
22
 
21
23
  // autodream 报告引用:比 fileNameSchema 宽——旧版报告列表里的条目带 `dream/` 前缀
22
24
  // (新版 `autodream/` 不加前缀只给裸文件名)。这里放行斜杠,真正的目录逃逸防护
@@ -254,7 +256,7 @@ const rawWriteResultSchema = z.object({
254
256
  }).readonly()
255
257
  // 保留名的体积上限比单条记忆大得多(session-log.md 天然 > 512 KB)—— 4 MB,独立于
256
258
  // fileContentSchema,别把两者混用。
257
- const rawFileContentSchema = z.string().max(4 * 1024 * 1024)
259
+ const rawFileContentSchema = z.string().max(MAX_RAW_BYTES)
258
260
 
259
261
  // ── autodream(夜间记忆整理)───────────────────────────────────────────────
260
262
  // 配置的每一项都可写;lastResult 是一个「成功/失败两条分支共用」的结构,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "1.2.1",
4
+ "version": "1.4.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {