sage-mem 0.8.0 → 0.9.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/lib/index.js CHANGED
@@ -15,15 +15,120 @@
15
15
  */
16
16
 
17
17
  import { AutodreamEngine } from './autodream.js'
18
- 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 } from './memory/config.js'
18
+ import { auditMemoryDir } from './autodream-audit.js'
19
+ import { DEFAULT_CONFIG } from './autodream/config.js'
20
+ import { readJson } from './autodream/util.js'
21
+ 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'
19
22
  import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
20
23
  import { isReserved, nameKey, safeName } from './memory/naming.js'
21
24
  import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
22
- import { extractTitle, scanMemoryFiles, scanStars } from './memory/scan.js'
25
+ import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars } from './memory/scan.js'
26
+ import { archiveMemory, restoreMemory } from './memory/archive.js'
27
+ import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
23
28
  import { warnLog, writeFileAtomic } from './memory/util.js'
24
29
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
25
- import { readFile, unlink, writeFile } from 'node:fs/promises'
26
- import { join } from 'node:path'
30
+ import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
31
+ import { basename, join } from 'node:path'
32
+
33
+ /**
34
+ * `SAGE_MEM_DEBUG=1` 才出声的注入链调试出口。
35
+ *
36
+ * 一行结构化 JSON:候选数 / 命中数 / 逐条得分 / 入选与真注入的文件名 / 注入字符数 /
37
+ * 被会话预算挡掉几条。它回答的是「这条记忆为什么没被召回」——一个光看上下文永远查不出的问题。
38
+ *
39
+ * ⚠️ **不开时零输出、零额外开销**:常量的读取只在模块加载时发生一次;调用点整个包在
40
+ * `if (DEBUG_RECALL)` 里,连 payload 对象都不会被构造出来。
41
+ */
42
+ const DEBUG_RECALL = process.env.SAGE_MEM_DEBUG === '1'
43
+
44
+ function debugRecall(payload) {
45
+ if (!DEBUG_RECALL) return
46
+ console.log(`[sage-mem] recall ${JSON.stringify(payload)}`)
47
+ }
48
+
49
+ /**
50
+ * 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
51
+ * 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
52
+ */
53
+ const LIMIT_SPECS = {
54
+ maxResults: { env: 'SAGE_MEM_MAX_RESULTS', dflt: 10, lo: 1, hi: 30 },
55
+ maxChars: { env: 'SAGE_MEM_MAX_CHARS', dflt: 1500, lo: 200, hi: 20000 },
56
+ maxBaseline: { env: 'SAGE_MEM_MAX_BASELINE', dflt: 8, lo: 1, hi: 50 },
57
+ maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
58
+ staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
59
+ }
60
+
61
+ /** 面板设置的落点:状态根下(与 autodream 的配置、台账同级)。 */
62
+ const SETTINGS_FILE = join(STATE_ROOT, 'settings.json')
63
+ const RESERVED_FILE = join(STATE_ROOT, 'reserved.json')
64
+
65
+ /**
66
+ * `writeRaw` 的体积上限(4 MB)。
67
+ *
68
+ * ⚠️ **故意不复用 `MAX_FILE_BYTES`(512 KB)**:保留名里就有 `session-log.md` 这种追加型
69
+ * 流水,它天然长得比单条记忆大得多(实测已 > 512 KB)。沿用小上限 = 用户永远存不回去。
70
+ */
71
+ const MAX_RAW_BYTES = 4 * 1024 * 1024
72
+
73
+ /** 整数收敛(与 config.js 的 envInt 同一套语义):非有限数回落默认,越界夹到边界。 */
74
+ const clampInt = (v, spec) => (Number.isFinite(v) ? Math.max(spec.lo, Math.min(spec.hi, Math.round(v))) : spec.dflt)
75
+
76
+ /** 读 `<状态根>/settings.json`;缺失、坏 JSON、不是对象都当「没设置」。 */
77
+ async function readSettingsFile() {
78
+ try {
79
+ const parsed = JSON.parse(await readFile(SETTINGS_FILE, 'utf8'))
80
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ /** 待生效的 5 项上限:`settings.json` > 环境变量 > 内置默认。 */
87
+ function pendingLimits(fileObj) {
88
+ const out = {}
89
+ const saved = fileObj?.limits && typeof fileObj.limits === 'object' ? fileObj.limits : {}
90
+ for (const [key, spec] of Object.entries(LIMIT_SPECS)) {
91
+ const fromFile = Number(saved[key])
92
+ if (Number.isFinite(fromFile)) {
93
+ out[key] = clampInt(fromFile, spec)
94
+ continue
95
+ }
96
+ const raw = process.env[spec.env]
97
+ out[key] = clampInt(raw === undefined || raw === '' ? NaN : Number(raw), spec)
98
+ }
99
+ return out
100
+ }
101
+
102
+ /** 读 `<状态根>/reserved.json`(用户声明的保护名单)。**保留原始大小写**,好让 get 读回一致。 */
103
+ async function readReservedFile() {
104
+ try {
105
+ const arr = JSON.parse(await readFile(RESERVED_FILE, 'utf8'))
106
+ if (!Array.isArray(arr)) return []
107
+ return arr
108
+ .map((s) => String(s).trim())
109
+ .filter((s) => s.endsWith('.md') && !s.includes('/') && !s.includes('\\') && !s.startsWith('..'))
110
+ } catch {
111
+ return []
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 原样读写用的名字校验:只要求「单段、`.md`」。
117
+ *
118
+ * ⚠️ **故意不走 `safeName`**:那个函数把 `MEMORY.md` / `session-log.md` 当非记忆条目排除,
119
+ * 而它们恰恰是 `readRaw` / `writeRaw` 的目标。这里只防路径成分与扩展名。
120
+ */
121
+ function rawName(raw) {
122
+ const s = typeof raw === 'string' ? raw : ''
123
+ if (!s || basename(s) !== s) return null
124
+ return /\.md$/i.test(s) ? s : null
125
+ }
126
+
127
+ /** autodream 的实际配置(缺文件/坏文件回落默认值)—— 面板要显示真实策略,不能显示想象。 */
128
+ async function autodreamConfigSnapshot() {
129
+ const parsed = await readJson(join(STATE_ROOT, 'autodream.json'))
130
+ return { ...DEFAULT_CONFIG, ...(parsed?.config ?? {}) }
131
+ }
27
132
 
28
133
  /**
29
134
  * 手动 @Remote marker 注册(Node 24 不解析装饰器语法,手写 decorator context)。
@@ -177,7 +282,10 @@ export class MemoryGateway extends TypertRemoteService {
177
282
  relevant.forEach(take)
178
283
  }
179
284
 
180
- if (picked.length === 0) return assembled
285
+ if (picked.length === 0) {
286
+ if (DEBUG_RECALL) debugRecall({ scanned: files.length, hits: relevant.length, scores: [], picked: [], injected: [], chars: 0, skippedByBudget: 0 })
287
+ return assembled
288
+ }
181
289
 
182
290
  // ── 单会话累计预算 ──
183
291
  // 账记在 session id 上;消息条数变小 = 压缩/清空发生过 → 账本归零(见上面
@@ -189,6 +297,7 @@ export class MemoryGateway extends TypertRemoteService {
189
297
  const nowMs = Date.now()
190
298
 
191
299
  const parts = []
300
+ const injectedFiles = []
192
301
  let skippedForBudget = 0
193
302
  for (const f of picked) {
194
303
  const body = f.content.length > MAX_CHARS_PER_FILE
@@ -201,11 +310,26 @@ export class MemoryGateway extends TypertRemoteService {
201
310
  if (usedBytes + size > MAX_SESSION_BYTES) { skippedForBudget++; continue }
202
311
  usedBytes += size
203
312
  parts.push(part)
313
+ injectedFiles.push(f.file)
204
314
  }
205
315
 
316
+ // 进上下文的这些才算「被用过」—— 台账是自动归档阈值的唯一数据源,记错一步阈值就偏。
317
+ // recordAccess 同步、绝不抛:台账的问题不许升级成注入失败。
318
+ recordAccess(injectedFiles, nowMs)
319
+
206
320
  // 被预算挡掉了全部条目 —— 一个字都不注。本会话已经注进去的那批还在上下文里,
207
321
  // 每步再塞一条「预算已满」只是白花 token;真压缩过之后账本会自己归零。
208
- if (parts.length === 0) return assembled
322
+ // 这条早退也要出声:一条都没注进去,正是最需要解释的那种情况。
323
+ if (parts.length === 0) {
324
+ if (DEBUG_RECALL) {
325
+ debugRecall({
326
+ scanned: files.length, hits: relevant.length,
327
+ scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
328
+ picked: picked.map(f => f.file), injected: [], chars: usedBytes, skippedByBudget: skippedForBudget,
329
+ })
330
+ }
331
+ return assembled
332
+ }
209
333
 
210
334
  injectLedger.set(ledgerKey, { bytes: usedBytes, messageCount: messages.length })
211
335
  if (injectLedger.size > INJECT_LEDGER_MAX) {
@@ -219,6 +343,18 @@ export class MemoryGateway extends TypertRemoteService {
219
343
  (skippedForBudget > 0 ? `;因预算省略 ${skippedForBudget} 条,需要时直接用文件工具读 ${MEMORY_DIR}` : '') +
220
344
  ')'
221
345
 
346
+ if (DEBUG_RECALL) {
347
+ debugRecall({
348
+ scanned: files.length,
349
+ hits: relevant.length,
350
+ scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
351
+ picked: picked.map(f => f.file),
352
+ injected: injectedFiles,
353
+ chars: usedBytes,
354
+ skippedByBudget: skippedForBudget,
355
+ })
356
+ }
357
+
222
358
  return {
223
359
  ...assembled,
224
360
  contexts: [...(assembled.contexts ?? []), {
@@ -299,35 +435,324 @@ export class MemoryGateway extends TypertRemoteService {
299
435
  await unlink(join(MEMORY_DIR, safe))
300
436
  return { ok: true }
301
437
  }
438
+
439
+ /**
440
+ * 归档一条记忆:把文件移进 `memory/archive/`(**不删**),并在 frontmatter 里留下
441
+ * `archived_at` / `archived_reason`。
442
+ *
443
+ * 语义是「静默失效且可回退」:归档区不参与检索(见 memory/scan.js 的 scanMemoryFiles),
444
+ * 但列表与星图都看得见它,也能一键恢复 —— 所以这里只是移动 + 打标,绝不 unlink。
445
+ *
446
+ * 真正的实现在 `memory/archive.js`:autodream 的 `archive_memory` 工具与自动归档策略
447
+ * 走的是同一份 —— 三条入口只许有一种行为。
448
+ *
449
+ * 拒绝一律返回 `{ ok: false, error }`(不是抛):面板按 ok 判定,抛出去只会变成
450
+ * 一句没有上下文的报错。
451
+ */
452
+ async archive(name, reason) {
453
+ const res = await archiveMemory(MEMORY_DIR, name, reason)
454
+ return res.ok ? { ok: true, file: res.file } : { ok: false, error: res.error }
455
+ }
456
+
457
+ /**
458
+ * 从归档区恢复一条记忆:移回根目录,并去掉 `archived_at` / `archived_reason` 两行。
459
+ * 同样委托给 `memory/archive.js`(判断与拒绝矩阵只有一份)。
460
+ */
461
+ async restore(name) {
462
+ const res = await restoreMemory(MEMORY_DIR, name)
463
+ return res.ok ? { ok: true, file: res.file } : { ok: false, error: res.error }
464
+ }
465
+
466
+ /**
467
+ * 列出归档区的全部记忆(面板的「已归档」区)。
468
+ *
469
+ * 与 `listFiles()` 分开、形状也不同:`listFiles` 只列活动记忆、多一个字段都会破坏
470
+ * 界面与断言依赖的形状;归档条目额外带 `archivedAt` / `archivedReason`。
471
+ * 旧版 autodream 工具直接移文件、不写留痕,所以这两个字段可能是空串。
472
+ */
473
+ async listArchived() {
474
+ const files = await scanArchived(this.ctx)
475
+ const out = files.map(f => {
476
+ const meta = parseFrontmatter(f.content)
477
+ const archived = readArchivedMeta(f.content)
478
+ return {
479
+ file: f.name,
480
+ type: meta.type || 'reference',
481
+ description: meta.description || '',
482
+ size: f.content.length,
483
+ tags: meta.tags,
484
+ archivedAt: archived.archivedAt,
485
+ archivedReason: archived.archivedReason,
486
+ }
487
+ })
488
+ return { count: out.length, files: out }
489
+ }
490
+
491
+ /**
492
+ * 读一份**已归档**记忆的全文。
493
+ *
494
+ * 归档不是删除,正文就该和活跃记忆一样能看能改(使用者的要求)。名字仍然只走
495
+ * `safeName`:调用方永远不许自己拼路径,归档区也只认 `memory/archive/` 这一层。
496
+ */
497
+ async readArchived(name) {
498
+ const safe = safeName(name)
499
+ if (!safe) throw new Error('sage-mem: invalid file name')
500
+ const full = join(MEMORY_ARCHIVE_DIR, safe)
501
+ const info = await stat(full).catch(() => null)
502
+ // 找不到就抛:与 readFile 一致。静默返回空串会让面板显示一个「空的记忆」,
503
+ // 而用户改完一存 —— 就把一条真实内容顶掉了。
504
+ if (!info?.isFile()) throw new Error(`sage-mem: archived file not found: ${safe}`)
505
+ const content = await readFile(full, 'utf8')
506
+ return { name: safe, content }
507
+ }
508
+
509
+ /**
510
+ * 写一份**已归档**记忆的正文(覆盖整份文件,形状与 `writeFile` 一致)。
511
+ *
512
+ * 三道闸照抄 writeFile:名字先过 safeName、内容必须是字符串、体积上限,最后原子写。
513
+ * 额外一条:**盘上那份的归档留痕(`archived_at` / `archived_reason`)不许因为改正文而丢**。
514
+ * 调用方若交回一份连留痕都没有的内容(「重建 frontmatter」式编辑器就是这样),
515
+ * 这里按盘上原来的值补回去 —— 归档凭据不该依赖调用方是否体贴。
516
+ */
517
+ async writeArchived(name, content) {
518
+ if (isReserved(name)) {
519
+ return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
520
+ }
521
+ const safe = safeName(name)
522
+ if (!safe) throw new Error('sage-mem: invalid file name')
523
+ const full = join(MEMORY_ARCHIVE_DIR, safe)
524
+ const info = await stat(full).catch(() => null)
525
+ // 只改归档区里**已经存在**的文件:这条 remote 不是「把任意文件写进归档区」的后门。
526
+ if (!info?.isFile()) return { ok: false, file: safe, error: `sage-mem: archived file not found: ${safe}` }
527
+ if (typeof content !== 'string') {
528
+ return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
529
+ }
530
+ const bytes = Buffer.byteLength(content, 'utf8')
531
+ if (bytes > MAX_FILE_BYTES) {
532
+ return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
533
+ }
534
+ const given = readArchivedMeta(content)
535
+ let toWrite = content
536
+ if (!given.archivedAt && !given.archivedReason) {
537
+ const onDisk = readArchivedMeta(await readFile(full, 'utf8'))
538
+ if (onDisk.archivedAt || onDisk.archivedReason) {
539
+ toWrite = addArchiveMeta(content, onDisk.archivedReason, onDisk.archivedAt)
540
+ }
541
+ }
542
+ await writeFileAtomic(full, toWrite)
543
+ return { ok: true, file: safe }
544
+ }
545
+
546
+ /**
547
+ * 记忆目录体检:结构化审计结果。
548
+ *
549
+ * **直接透出 `auditMemoryDir` 的返回**,不另写一套检查 —— 面板上的「问题」与 autodream
550
+ * 报告里的必须是同一份判断,否则用户与模型看到的健康状况会互相打架。只读。
551
+ */
552
+ async audit() {
553
+ return auditMemoryDir(MEMORY_DIR)
554
+ }
555
+
556
+ /**
557
+ * 自动归档候选 + 当前策略与阈值(面板「执行归档」前先看清单)。
558
+ *
559
+ * 复用 `memory/access.js` 的台账与候选计算,与 autodream 引擎跑的是同一份逻辑。
560
+ * 先强制 flush 台账:面板看到的候选不能比引擎少(内存里还压着的那批注入必须算进去)。
561
+ */
562
+ async archiveCandidates() {
563
+ await flushAccess(MEMORY_DIR)
564
+ const [ledger, files] = await Promise.all([readAccessLedger(), scanMemoryFiles(this.ctx, '')])
565
+ const cfg = await autodreamConfigSnapshot()
566
+ const thresholds = {
567
+ project: cfg.archiveAfterDaysProject,
568
+ reference: cfg.archiveAfterDaysReference,
569
+ user: cfg.archiveAfterDaysUser,
570
+ }
571
+ const candidates = pickArchiveCandidates(files, ledger, { thresholds })
572
+ return { count: candidates.length, candidates, autoArchive: cfg.autoArchive, thresholds }
573
+ }
574
+
575
+ /**
576
+ * 面板上的注入设置(5 项上限 + 保留名保护名单)。
577
+ *
578
+ * ⚠️ **这 5 个常量是模块加载时读的**:面板改完要**重启 DSH** 才生效。所以这里同时给出
579
+ * 「当前生效值」`limits` 与「待生效值」`pendingLimits`,外加 `restartRequired` ——
580
+ * 界面才能诚实地说「改好了,重启后生效」,而不是让用户以为立刻生效又看不到变化。
581
+ */
582
+ async getSettings() {
583
+ const file = await readSettingsFile()
584
+ const limits = {
585
+ maxResults: MAX_RESULTS,
586
+ maxChars: MAX_CHARS_PER_FILE,
587
+ maxBaseline: MAX_BASELINE,
588
+ maxSessionBytes: MAX_SESSION_BYTES,
589
+ staleDays: STALE_DAYS,
590
+ }
591
+ const pending = pendingLimits(file)
592
+ const reservedExtra = await readReservedFile()
593
+ // 模块加载时那份「用户声明的保护名」(RESERVED_FILES 里抠掉两个内置名)
594
+ const activeExtra = [...RESERVED_FILES].filter((n) => n !== 'memory.md' && n !== 'session-log.md')
595
+ const norm = (list) => JSON.stringify([...list].map((s) => String(s).toLowerCase()).sort())
596
+ const restartRequired =
597
+ JSON.stringify(limits) !== JSON.stringify(pending) || norm(activeExtra) !== norm(reservedExtra)
598
+ return {
599
+ limits,
600
+ pendingLimits: pending,
601
+ restartRequired,
602
+ // 生效的保护名单(含两个内置名)+ 用户声明的那部分原文(面板编辑的是后者)
603
+ reserved: [...new Set(['memory.md', 'session-log.md', ...reservedExtra.map((s) => s.toLowerCase())])],
604
+ reservedExtra,
605
+ }
606
+ }
607
+
608
+ /**
609
+ * 改设置:5 项上限落 `<状态根>/settings.json`,保护名单落 `<状态根>/reserved.json`。
610
+ *
611
+ * 校验**先全部过一遍再写盘**:任何一个值非法就整体 `{ ok:false }`,绝不写半份。
612
+ * 越界不是「夹到边界」(那是环境变量的宽容语义)—— 面板上手输的数字越界是笔误,
613
+ * 静默改成别的值比报错更让人困惑。
614
+ */
615
+ async setSettings(patch) {
616
+ const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}
617
+ const limitsPatch = p.limits && typeof p.limits === 'object' && !Array.isArray(p.limits) ? p.limits : null
618
+ const nextLimits = {}
619
+ if (limitsPatch) {
620
+ for (const [key, value] of Object.entries(limitsPatch)) {
621
+ const spec = LIMIT_SPECS[key]
622
+ if (!spec) return { ok: false, error: `sage-mem: unknown setting: ${key}` }
623
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
624
+ return { ok: false, error: `sage-mem: ${key} must be a number, got ${typeof value}` }
625
+ }
626
+ const rounded = Math.round(value)
627
+ if (rounded < spec.lo || rounded > spec.hi) {
628
+ return { ok: false, error: `sage-mem: ${key} must be an integer in [${spec.lo}, ${spec.hi}], got ${value}` }
629
+ }
630
+ nextLimits[key] = rounded
631
+ }
632
+ }
633
+ let nextReserved = null
634
+ if (p.reserved !== undefined) {
635
+ if (!Array.isArray(p.reserved)) return { ok: false, error: 'sage-mem: reserved must be an array of file names' }
636
+ const cleaned = []
637
+ for (const item of p.reserved) {
638
+ const s = String(item ?? '').trim()
639
+ if (!s || !s.endsWith('.md') || s.includes('/') || s.includes('\\') || s.startsWith('..')) {
640
+ return { ok: false, error: `sage-mem: invalid reserved file name: ${String(item)}` }
641
+ }
642
+ if (!cleaned.includes(s)) cleaned.push(s)
643
+ }
644
+ nextReserved = cleaned
645
+ }
646
+ if (!limitsPatch && nextReserved === null) return { ok: false, error: 'sage-mem: empty patch' }
647
+
648
+ await mkdir(STATE_ROOT, { recursive: true })
649
+ if (limitsPatch) {
650
+ const current = await readSettingsFile()
651
+ const merged = { ...(current?.limits ?? {}), ...nextLimits }
652
+ await writeFileAtomic(SETTINGS_FILE, JSON.stringify({ limits: merged, updatedAt: Date.now() }, null, 2))
653
+ }
654
+ if (nextReserved !== null) {
655
+ await writeFileAtomic(RESERVED_FILE, `${JSON.stringify(nextReserved, null, 2)}\n`)
656
+ }
657
+ // 写盘即提示重启:这些值在模块加载时就固化了,不重启它一定还没生效。
658
+ return { ok: true, settings: await this.getSettings(), restartRequired: true }
659
+ }
660
+
661
+ /**
662
+ * 原样读保留名文件(`MEMORY.md` / `session-log.md` / 用户声明的保护名)。
663
+ *
664
+ * **不解析、不重建**:索引与流水是纯文本,面板拿到什么就显示什么。
665
+ */
666
+ async readRaw(name) {
667
+ if (!isReserved(name)) {
668
+ return { ok: false, error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or readFile` }
669
+ }
670
+ const safe = rawName(name)
671
+ if (!safe) return { ok: false, error: `sage-mem: invalid file name: ${String(name)}` }
672
+ // 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
673
+ // 用户一存就把真索引顶掉。
674
+ const content = await readFile(join(MEMORY_DIR, safe), 'utf8')
675
+ return { name: safe, content }
676
+ }
677
+
678
+ /**
679
+ * 原样写保留名文件:**逐字节照写,绝不重建 frontmatter**。
680
+ *
681
+ * 这就是 2026-09-25 那次 P0 数据丢失事故(表单重建 frontmatter 把 baseline /
682
+ * node_type / originSessionId 全抹掉)的解药:索引与流水只许原样进出。
683
+ */
684
+ async writeRaw(name, content) {
685
+ if (!isReserved(name)) {
686
+ return { ok: false, error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or writeFile` }
687
+ }
688
+ const safe = rawName(name)
689
+ if (!safe) return { ok: false, error: `sage-mem: invalid file name: ${String(name)}` }
690
+ if (typeof content !== 'string') {
691
+ return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
692
+ }
693
+ const bytes = Buffer.byteLength(content, 'utf8')
694
+ // ⚠️ 与 writeFile 的 512 KB 上限**不是同一个数**:流水天然更大(见 MAX_RAW_BYTES 的注释)。
695
+ if (bytes > MAX_RAW_BYTES) {
696
+ return {
697
+ ok: false,
698
+ file: safe,
699
+ error: `sage-mem: content too large (${bytes} bytes > ${MAX_RAW_BYTES / (1024 * 1024)} MB raw limit)`,
700
+ }
701
+ }
702
+ await writeFileAtomic(join(MEMORY_DIR, safe), content)
703
+ return { ok: true, file: safe }
704
+ }
302
705
  }
303
706
 
304
707
  markRemote(MemoryGateway, 'listFiles', 'listFiles')
305
708
  markRemote(MemoryGateway, 'readFile', 'readFile')
306
709
  markRemote(MemoryGateway, 'writeFile', 'writeFile')
307
710
  markRemote(MemoryGateway, 'deleteFile', 'deleteFile')
711
+ markRemote(MemoryGateway, 'archive', 'archive')
712
+ markRemote(MemoryGateway, 'restore', 'restore')
713
+ markRemote(MemoryGateway, 'listArchived', 'listArchived')
714
+ markRemote(MemoryGateway, 'readArchived', 'readArchived')
715
+ markRemote(MemoryGateway, 'writeArchived', 'writeArchived')
716
+ markRemote(MemoryGateway, 'audit', 'audit')
717
+ markRemote(MemoryGateway, 'archiveCandidates', 'archiveCandidates')
718
+ markRemote(MemoryGateway, 'getSettings', 'getSettings')
719
+ markRemote(MemoryGateway, 'setSettings', 'setSettings')
720
+ markRemote(MemoryGateway, 'readRaw', 'readRaw')
721
+ markRemote(MemoryGateway, 'writeRaw', 'writeRaw')
308
722
 
309
723
  /**
310
724
  * 记忆星图(宿主侧)—— 只读扫描记忆目录,把每条记忆解析成一颗「星」。
311
725
  * 通过 TypertRemoteService 暴露两个 remote 方法给界面侧:
312
- * - starmap.listStars() 全部星(元数据,不含正文)
313
- * - starmap.readFile(n) 单条记忆全文(文件名白名单校验)
726
+ * - starmap.listStars(includeArchived) 全部星(元数据,不含正文)
727
+ * - starmap.readFile(n) 单条记忆全文(文件名白名单校验)
314
728
  */
315
729
  export class StarmapGateway extends TypertRemoteService {
316
730
  constructor(ctx) { super(ctx, 'starmap') }
317
731
 
318
- async listStars() {
319
- const files = await scanStars(this.ctx)
732
+ /**
733
+ * @param {boolean} [includeArchived] — 连归档区一起给(默认 false:与从前一样只看活着的星)
734
+ */
735
+ async listStars(includeArchived) {
736
+ const files = await scanStars(this.ctx, includeArchived === true)
320
737
  const stars = files.map(f => {
321
738
  const meta = parseFrontmatter(f.content)
322
739
  const title = extractTitle(f.content)
323
- return {
740
+ const star = {
324
741
  file: f.name,
325
742
  kind: meta.type || 'special',
326
743
  title: title || meta.name || (meta.description ? meta.description.slice(0, 24) : f.name.replace(/\.md$/, '')),
327
744
  desc: meta.description || '',
328
745
  bytes: f.bytes,
329
746
  mtimeMs: f.mtimeMs,
747
+ archived: f.archived === true,
748
+ }
749
+ // 归档留痕只对归档星有意义;活动星连键都不出现(保持与旧形状逐字段相同)。
750
+ if (star.archived) {
751
+ const a = readArchivedMeta(f.content)
752
+ star.archivedAt = a.archivedAt
753
+ star.archivedReason = a.archivedReason
330
754
  }
755
+ return star
331
756
  })
332
757
  return { count: stars.length, stars }
333
758
  }