sage-mem 1.2.1 → 1.3.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,7 @@ 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)
501
502
  - 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
503
 
503
504
  ## Contributing
package/README.zh.md CHANGED
@@ -502,6 +502,7 @@ 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` 会红)
505
506
  - 依赖方向:`memory` 不认识 `autodream`,`recall` 只依赖 `memory`,`assembly` 不反向依赖组合根(`test/structure-test.mjs` 会红)
506
507
 
507
508
  ## 参与开发
@@ -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/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.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {