sage-mem 1.0.2 → 1.2.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
@@ -151,7 +151,7 @@ Every image below is **rendered from the UI code in this repository** (the same
151
151
 
152
152
  ![File list: one Markdown file per memory, greyed rows are archived](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/files-list.png)
153
153
 
154
- *File list — the header always shows 「体检 N 个问题」(audit: N issues) and 「待重启生效」(restart to apply), so you know whether you should deal with it without opening anything; greyed rows are archived and can be restored at any time.*
154
+ *File list — the header always shows 「体检 N 个问题」(audit: N issues) and 「待重启生效」(restart to apply), so you know whether you should deal with it without opening anything; greyed rows are archived and can be restored at any time, or deleted for good with 「彻底删除」(irreversible, asks once more).*
155
155
 
156
156
  ![Tools drawer: archive candidates / reserved-name editing, zero footprint when closed](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/tools-drawer.png)
157
157
 
@@ -171,7 +171,7 @@ Every image below is **rendered from the UI code in this repository** (the same
171
171
 
172
172
  ![Archive candidates: each states "why archiving is suggested", executed only after confirmation](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/archive-candidates.png)
173
173
 
174
- *Archive candidates — each states "why archiving is suggested" (idle days vs the threshold for that type); files are touched only after you confirm one by one or select all. Archiving moves a file into `archive/`, it **does not delete**.*
174
+ *Archive candidates — each states "why archiving is suggested" (idle days vs the threshold for that type); files are touched only after you confirm one by one or select all. Archiving moves a file into `archive/`, it **does not delete**; to actually delete an archived entry, click 「彻底删除」(delete permanently) in the archive view and confirm once more.*
175
175
 
176
176
  ![Memory star map: dark stars are archived memories](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/starmap-archive.png)
177
177
 
@@ -492,6 +492,14 @@ memory/ directory (markdown files, 4 types)
492
492
 
493
493
  No worker, no SQLite, no HTTP port, no long-running process.
494
494
 
495
+ Module boundaries (since 1.2.0):
496
+
497
+ - `lib/index.js` — the **composition root** plus the three gateways. The typert manifest pins `sourceLocation` to this file, so the gateway classes stay here (moving them would be a contract drift)
498
+ - `lib/recall/` — the injection chain: selection (`select`) / rendering and budget (`compose`) / text (`text`) / session state (`state`)
499
+ - `lib/assembly/` — the four mounts: autodream / retrieval tools / observation / write gate
500
+ - `lib/memory/` — pure memory-layer functions and file operations; `lib/autodream/` — the autodream engine
501
+ - 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
+
495
503
  ## Contributing
496
504
 
497
505
  ```bash
package/README.zh.md CHANGED
@@ -153,7 +153,7 @@ SAGE_MEM_DIR=/path/to/your/memory
153
153
 
154
154
  ![文件列表:每条记忆一个 Markdown 文件,灰底的是已归档](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/files-list.png)
155
155
 
156
- *文件列表 —— 头部常显「体检 N 个问题」与「待重启生效」,不用点开就知道该不该处理;灰底条目是已归档,随时可以恢复。*
156
+ *文件列表 —— 头部常显「体检 N 个问题」与「待重启生效」,不用点开就知道该不该处理;灰底条目是已归档,随时可以恢复,也可以点「彻底删除」真的删掉(不可恢复,要再确认一次)。*
157
157
 
158
158
  ![工具抽屉:归档候选 / 保留名编辑,关着时零占位](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/tools-drawer.png)
159
159
 
@@ -173,7 +173,7 @@ SAGE_MEM_DIR=/path/to/your/memory
173
173
 
174
174
  ![归档候选:每条写清「为什么建议归档」,确认后才执行](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/archive-candidates.png)
175
175
 
176
- *归档候选 —— 每条写清「为什么建议归档」(闲置天数 vs 该类型阈值),逐条或全选后确认才动文件。归档是把文件移进 `archive/`,**不删除**。*
176
+ *归档候选 —— 每条写清「为什么建议归档」(闲置天数 vs 该类型阈值),逐条或全选后确认才动文件。归档是把文件移进 `archive/`,**不删除**;真要删掉一条归档件,得在档案馆里单独点「彻底删除」并再确认一次。*
177
177
 
178
178
  ![记忆星图:暗星是已归档的记忆](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/starmap-archive.png)
179
179
 
@@ -496,6 +496,14 @@ memory/ 目录(markdown 文件,4 类)
496
496
 
497
497
  没有 worker、没有 SQLite、没有 HTTP 端口、没有常驻进程。
498
498
 
499
+ 模块边界(1.2.0 起):
500
+
501
+ - `lib/index.js` —— **组合根** + 三个 Gateway。typert manifest 的 `sourceLocation` 钉在这个文件上,所以 Gateway 类不搬走(搬 = 契约漂移)
502
+ - `lib/recall/` —— 注入链:选材(`select`)/ 渲染与预算(`compose`)/ 文本(`text`)/ 会话状态(`state`)
503
+ - `lib/assembly/` —— 四块挂载:autodream / 检索工具 / 观测 / 写入门
504
+ - `lib/memory/` —— 记忆层的纯函数与文件操作;`lib/autodream/` —— 自动做梦引擎
505
+ - 依赖方向:`memory` 不认识 `autodream`,`recall` 只依赖 `memory`,`assembly` 不反向依赖组合根(`test/structure-test.mjs` 会红)
506
+
499
507
  ## 参与开发
500
508
 
501
509
  ```bash
@@ -0,0 +1,24 @@
1
+ /**
2
+ * autodream 的挂载(1.2.0 从 `MemoryGateway` 构造函数原样搬出)。
3
+ *
4
+ * 整个包在 try 里:autodream 起不来(比如 timer 服务缺失、状态目录没权限)
5
+ * 不该把 sage-mem 最核心的那件事——「记忆按问题注入」——一起拖死。
6
+ * 这是本插件唯一一处「降级而不是失败」的设计,理由就在这里。
7
+ */
8
+ import { warnLog } from '../memory/util.js'
9
+
10
+ /** @param {object} ctx @param {{AutodreamEngine:Function, AutodreamGateway:Function, memoryDir:string, sessionsRoot:string, reservedFiles:string[]}} deps */
11
+ export function mountAutodream(ctx, { AutodreamEngine, AutodreamGateway, memoryDir, sessionsRoot, reservedFiles }) {
12
+ try {
13
+ const engine = new AutodreamEngine(ctx, { memoryDir, sessionsRoot, reservedFiles })
14
+ new AutodreamGateway(ctx, engine)
15
+ engine.start()
16
+ // 卸载时把定时器与锁收干净。effect 拿不到就交给 unref 兜底(不阻止进程退出)。
17
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => engine.dispose())
18
+ } catch (err) {
19
+ warnLog(ctx, `autodream 初始化失败(记忆本体不受影响):${err?.message ?? err}`)
20
+ // 异常路径额外打一行到 stderr:warnLog 走 ctx.logger,某些启动阶段它还没接上,
21
+ // 而「autodream 静默不工作」正是最难查的那种故障(曾为此排查很久)。
22
+ console.error('[sage-mem] autodream init failed:', err && err.stack ? err.stack : err)
23
+ }
24
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 组合根的四块挂载(1.2.0 的模块边界)。
3
+ *
4
+ * 每块一个文件、一个 `mountXxx(ctx, …)`:组合根的构造函数只负责按顺序调用它们。
5
+ * 顺序是语义的一部分(autodream → 工具 → 观测 → 写门),所以这里按同一顺序再导出。
6
+ *
7
+ * ⚠️ `mountWriteGate` 必须**同步**调用(注册窗口在 fs 就绪之前就关),
8
+ * 这条约束写在 `write-gate-mount.js` 头注里,并由 `write-gate-wiring-test.mjs` 第 7 组看着。
9
+ */
10
+ export { mountAutodream } from './autodream-mount.js'
11
+ export { mountMemoryTools } from './tools-mount.js'
12
+ export { mountObserve } from './observe-mount.js'
13
+ export { mountWriteGate } from './write-gate-mount.js'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 「模型自己翻了记忆目录」的观测挂载(1.2.0 从构造函数原样搬出)。
3
+ *
4
+ * 宿主的 `tools/result` 把**每一次**工具调用的 `exec`(含 name / arguments)交给我们,
5
+ * 于是「模型用内置 read/grep 读了哪份记忆」第一次变得可观测 —— 此前台账只记得下
6
+ * 「插件注入了什么」,记不下「模型主动翻了什么」。
7
+ *
8
+ * 三条纪律:
9
+ * 1. **只记能可靠判定的**(`observe.js` 认 read/grep/glob 的路径参数;`pwsh` 一律不认,
10
+ * 那是**已知观测盲区** —— 不许把「看不到」说成「没读」)。
11
+ * 2. **按 session × 文件去重**:翻页(read 的 offset 不同)不刷计数,那是同一个动作。
12
+ * 3. **失败绝不影响任何东西**:观测是副产品,它的问题不许冒泡到工具调用链上。
13
+ */
14
+ import { accessEventKey, classifyToolExecution, createDeduper } from '../memory/observe.js'
15
+ import { recordRead } from '../memory/access.js'
16
+
17
+ export function mountObserve(ctx, memoryDir) {
18
+ const observedReads = createDeduper()
19
+ try {
20
+ ctx.on('tools/result', (exec) => {
21
+ try {
22
+ const cls = classifyToolExecution(exec?.name, exec?.arguments, memoryDir)
23
+ if (cls.kind !== 'active_read' || !cls.file) return
24
+ const key = accessEventKey({
25
+ sessionId: exec?.agent?.id ?? '',
26
+ file: cls.file,
27
+ // revision 要读盘才算得出,这里拿不到 —— 所以去重粒度是「session × 文件」。
28
+ // 写明这条口径,免得以后有人以为它按内容去重。
29
+ revision: '',
30
+ kind: cls.kind,
31
+ })
32
+ if (!key || !observedReads.shouldCount(key)) return
33
+ recordRead(cls.file, '')
34
+ } catch { /* 观测失败静默:它不该有任何存在感 */ }
35
+ })
36
+ } catch { /* 老宿主没有这个事件:不影响注入 */ }
37
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 检索工具(`search_memory` / `read_memory`)的挂载(1.2.0 从构造函数原样搬出)。
3
+ *
4
+ * 工具是否注册成功。拿不到宿主的 `tools` 服务时为 false —— 状态卡据此换文案:
5
+ * 有工具就教它「用 search_memory 搜」,没有就退回「直接读记忆目录」。
6
+ *
7
+ * 为什么是个可变量:注册是**异步就绪**的(`ctx.inject` 要等依赖服务上线),而注入链
8
+ * 每步都跑 —— 前几步可能还没就绪、后几步才就绪,这是正常的。
9
+ * 因此返回 `{ ready: () => boolean }` 而不是一个布尔值:值拷贝会让注入链永远读到 false。
10
+ *
11
+ * ⚠️ **用 `ctx.inject` 挂子 fiber,绝不给本类加 `static inject = ['tools']`。**
12
+ * 理由(`docs/decisions.md` D5,有官方依据):`inject` 是**硬依赖** —— 声明了它的插件
13
+ * 要等依赖就绪才会被加载。宿主没有 `tools` 服务时,整个 sage-mem 会一直待机,
14
+ * **记忆注入跟着一起消失**。而工具只是锦上添花,不值得拿核心功能去换。
15
+ * `ctx.inject` 只让这个子回调等依赖就绪,主插件照常工作;依赖永远不来就只有它不生效。
16
+ *
17
+ * 另一条实测事实(`docs/host-facts.md`):不声明 inject 时 `ctx.get('tools')` 只给
18
+ * `undefined`(那一刻服务还没被提供,而插件不会重跑),所以「先探测再注册」在这里
19
+ * 是行不通的 —— 必须走 `ctx.inject`。
20
+ */
21
+ import { createMemoryTools } from '../tools/memory-tools.js'
22
+ import { warnLog } from '../memory/util.js'
23
+
24
+ export function mountMemoryTools(ctx) {
25
+ const state = { ready: false }
26
+ try {
27
+ if (typeof ctx.inject === 'function') {
28
+ ctx.inject(['tools'], (inner) => {
29
+ try {
30
+ const { searchMemory, readMemory } = createMemoryTools(ctx)
31
+ inner.tools.register(searchMemory)
32
+ inner.tools.register(readMemory)
33
+ state.ready = true
34
+ } catch (err) {
35
+ // 注册失败 ≠ 注入失败。出声一次,然后什么也不做。
36
+ warnLog(ctx, `检索工具注册失败(记忆注入不受影响):${err?.message ?? err}`)
37
+ }
38
+ })
39
+ }
40
+ } catch (err) {
41
+ warnLog(ctx, `检索工具挂载失败(记忆注入不受影响):${err?.message ?? err}`)
42
+ }
43
+ return { ready: () => state.ready }
44
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 写入门(版本守卫)的挂载(1.2.0 从构造函数原样搬出)。
3
+ *
4
+ * 实测(`scripts/events-probe.mjs`,本机 headless,2026-10-15):这两个事件**只对
5
+ * 「工具发起」的写入**触发 —— 模型调内置 `write` / `edit` 打到记忆文件时会走这里;
6
+ * 而**插件自己**调 `ctx.fs.writeText` 时一次都不发(实测 0 次),那条路由调用方自己传
7
+ * `expected`(见 `lib/autodream/tools.js` 的 `writeMemoryText`)。
8
+ * 监听器**不需要声明 inject** 也能收到,所以这里不挂子 fiber。
9
+ *
10
+ * 它买到的东西:**模型的 `write` 没法在一个记忆文件被改过之后静默覆盖它** ——
11
+ * 守卫过不了,宿主会以 `FS_STALE_VERSION` 明确拒绝(实测确实会被拒)。
12
+ * 这属于「把静默的坏结果变成显式的失败」,不是「阻止写入」:这个事件**没有拒绝动作**,
13
+ * 我们也不发明一个(见 write-gate.js 的头注释)。
14
+ *
15
+ * 单槽纪律:返回 `undefined` 才是「不管」。目录外**必须**返回 undefined,否则会占掉槽位、
16
+ * 影响整块盘的写入(write-gate.js 的 `outside_memory` 判在 `caller_wins` 之前正是为此)。
17
+ *
18
+ * ⚠️⚠️ **本函数必须在 `apply()` 里被直接调用,绝不能挪进 `ctx.inject(['fs'], …)`,也不能给
19
+ * 插件加 `static inject = ['fs']`。** 这不是风格偏好,是实测出来的硬约束:
20
+ * 这两个事件有一个**注册窗口**,窗口在 fs 服务就绪**之前**就关了。
21
+ * 不 inject、apply 里直接挂 → 收到 1 次 ✅
22
+ * 顶层 `inject:['fs']` → **0 次**
23
+ * 顶层 `inject:['tokenMeter']` → **0 次**(与 fs 无关,是时机问题)
24
+ * `ctx.inject(['fs'], sub)` 子 fiber → **0 次**
25
+ * 不 inject、但 apply 之后 2.5 秒再挂 → **0 次**(晚挂收不到)
26
+ * 证据:`scripts/host-facts-probe.mjs` 的七组对照 + `docs/host-facts.md` 3.3 节。
27
+ * 之所以特别标注:**按 D5 的规范直觉(可选服务要用 ctx.inject 挂子 fiber)改这里,
28
+ * 会让写门静默失效** —— 编译得过、测试可能也过,只是永远不触发。
29
+ * 而我们**不需要** inject:实测不声明 inject 时,处理器**内部** `ctx.get('fs')` 照样拿得到服务
30
+ * (`{hasFs:true, hasStat:true}`),取版本就靠它。
31
+ */
32
+ import { memoryPathOf } from '../memory/paths.js'
33
+ import { decideEditGuard, decideWriteIntent } from '../memory/write-gate.js'
34
+
35
+ /**
36
+ * 探目标的当前版本。**区分「不存在」与「不知道」**。
37
+ *
38
+ * 这个区分不是洁癖:`!!info` 会把「拿不到 fs 服务」也写成 `exists:false`,判定表据此给
39
+ * `createIfAbsent` —— 而目标**其实是存在的**记忆文件时,那是一次**误拦**(写入必然失败,
40
+ * 模型收到一句看不懂的错误)。拿不准就不干涉,比猜一个安全。
41
+ */
42
+ function makeProbeTarget(ctx) {
43
+ return async (target) => {
44
+ try {
45
+ const fs = typeof ctx.get === 'function' ? ctx.get('fs') : undefined
46
+ if (!fs || typeof fs.stat !== 'function' || !target) return { known: false }
47
+ const info = await fs.stat(target)
48
+ return { known: true, exists: !!info, version: info?.version ?? null }
49
+ } catch {
50
+ return { known: false }
51
+ }
52
+ }
53
+ }
54
+
55
+ export function mountWriteGate(ctx, memoryDir) {
56
+ const probeTarget = makeProbeTarget(ctx)
57
+ try {
58
+ if (typeof ctx.on === 'function') {
59
+ // 公共骨架:**先判位置、再决定要不要碰链子**。
60
+ //
61
+ // ⚠️ 三条纪律(都是踩出来的):
62
+ // 1. **目录外连 `next()` 都不调**。宿主对**任何**工具写入都发这个事件(实测:写一个
63
+ // 临时文件也发),无条件调 next() + stat 等于给整个系统的每次写文件都加一次系统调用,
64
+ // 而我们本来就什么都不该做。直接 `return next()` 把决策权原样交出去。
65
+ // 2. **一旦调了 `next()`,就必须把它给的东西交回去**。`next()` 返回的是下游(或调用方
66
+ // 自己)已经做出的决定;此时再 `return undefined` 会把这个决定**丢掉** ——
67
+ // 我们本想「不干涉」,实际却比干涉还糟。
68
+ // 3. **探不到版本就不碰**(见 probeTarget):把「不知道」当「不存在」会误拦真实存在的文件。
69
+ const gateEvent = (decide) => async (target, actor, next) => {
70
+ try {
71
+ const memoryPath = memoryPathOf(target?.displayPath, memoryDir)
72
+ if (!memoryPath.ok) return typeof next === 'function' ? next() : undefined
73
+ const caller = typeof next === 'function' ? await next() : undefined
74
+ const probed = await probeTarget(target)
75
+ if (!probed.known) return caller
76
+ const chosen = decide({
77
+ memoryPath,
78
+ exists: probed.exists,
79
+ version: probed.version,
80
+ caller,
81
+ })
82
+ // 我们给出决定就用我们的;决定不了(undefined)就把 `caller` 原样交回去,不吞。
83
+ return chosen === undefined ? caller : chosen
84
+ } catch {
85
+ return undefined /* 写门在宿主的写入链路上:抛出去会把一次正常写入炸掉 */
86
+ }
87
+ }
88
+ ctx.on('fs/write-intent', gateEvent((facts) => decideWriteIntent(facts).intent))
89
+ ctx.on('fs/edit-intent', gateEvent((facts) => decideEditGuard(facts).guard))
90
+ }
91
+ } catch { /* 老宿主没有这两个事件:写入按宿主默认语义走 */ }
92
+ }
@@ -23,8 +23,8 @@
23
23
 
24
24
  import { readdir, readFile } from 'node:fs/promises'
25
25
  import { join } from 'node:path'
26
- import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from './memory/scan.js'
27
- import { warnLog } from './memory/util.js'
26
+ import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from '../memory/scan.js'
27
+ import { warnLog } from '../memory/util.js'
28
28
 
29
29
  /** 索引文件名。它本身不是一条记忆,是索引。 */
30
30
  const INDEX_FILE = 'MEMORY.md'
@@ -33,8 +33,8 @@
33
33
  * 这里保留**同名同签名**的方法,以 `.call(this, …)` 转发 —— 纯搬移、零行为变更。
34
34
  */
35
35
 
36
- import { auditMemoryDir } from '../autodream-audit.js'
37
- import { autodreamToolSchemas, createToolRunner } from '../autodream-tools.js'
36
+ import { auditMemoryDir } from './audit.js'
37
+ import { autodreamToolSchemas, createToolRunner } from './tools.js'
38
38
  import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_TOKEN_BUDGET, FAILURE_BACKOFF_MS, FEATURE_DIR, LEGACY_SNAPSHOT_DIR, LEGACY_STATE_FILE, SCHEDULE_INTERVAL_MS, STARTUP_CHECK_DELAY_MS, STATE_DIR } from './config.js'
39
39
  import { countSessionsSince, newRunId, stampHuman } from './util.js'
40
40
  import { flushAccess, ledgerFileFor, pickArchiveCandidates, readAccessLedger } from '../memory/access.js'
@@ -8,7 +8,7 @@
8
8
  * 之所以不用真正的子代理:见 engine.js 顶部第 2 条设计决定。
9
9
  */
10
10
 
11
- import { buildAutodreamSystemPrompt, buildAutodreamUserPrompt } from '../autodream-prompt.js'
11
+ import { buildAutodreamSystemPrompt, buildAutodreamUserPrompt } from './prompt.js'
12
12
  import { randomUUID } from 'node:crypto'
13
13
 
14
14
  export class BlockAssembler {
@@ -6,7 +6,7 @@
6
6
  * 与拆分前完全一致,没有引入任何模块级可变状态。
7
7
  */
8
8
 
9
- import { formatAudit } from '../autodream-audit.js'
9
+ import { formatAudit } from './audit.js'
10
10
  import { LEGACY_REPORT_DIR, REPORT_DIR } from './config.js'
11
11
  import { OP_LABEL, formatDelta, readJson, safeName, stampHuman } from './util.js'
12
12
  import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
@@ -27,14 +27,14 @@
27
27
  import { readFile, readdir, stat } from 'node:fs/promises'
28
28
  import { join, basename } from 'node:path'
29
29
  import { createHash } from 'node:crypto'
30
- import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
31
- import { archiveMemory } from './memory/archive.js'
32
- import { MAX_FILE_BYTES, STATE_ROOT } from './memory/config.js'
33
- import { writeFileAtomic, warnLog } from './memory/util.js'
34
- import { buildConflict, writeConflict } from './memory/conflicts.js'
35
- import { memoryPathOf } from './memory/paths.js'
36
- import { decideWriteIntent } from './memory/write-gate.js'
37
- import { inspectMemoryEntry, reservedIdentitySet } from './memory/scan.js'
30
+ import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './audit.js'
31
+ import { archiveMemory } from '../memory/archive.js'
32
+ import { MAX_FILE_BYTES, STATE_ROOT } from '../memory/config.js'
33
+ import { writeFileAtomic, warnLog } from '../memory/util.js'
34
+ import { buildConflict, writeConflict } from '../memory/conflicts.js'
35
+ import { memoryPathOf } from '../memory/paths.js'
36
+ import { decideWriteIntent } from '../memory/write-gate.js'
37
+ import { inspectMemoryEntry, reservedIdentitySet } from '../memory/scan.js'
38
38
 
39
39
  /** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
40
40
  const READ_LIMIT = 20000
package/lib/client.js CHANGED
@@ -1605,6 +1605,9 @@ const StarmapRuntime = (function (require, _react) {
1605
1605
  // 归档文件的读 / 写(B1b):形状与活跃记忆那套的 readSchema / writeSchema 对称。
1606
1606
  const archivedReadSchema = parseSchema({ "name": isStr, "content": isStr });
1607
1607
  const archivedWriteSchema = parseSchema({ "ok": isBool, "file": isStr });
1608
+ // 「彻底删除」的入参:归档代次(`archivedAt`)。它不是装饰 —— 宿主拿它核对
1609
+ // 「要删的是不是用户确认过的那一版」。老归档件没有留痕时传空串,所以只校类型。
1610
+ const archiveStampSchema = strSchema;
1608
1611
 
1609
1612
  // ── autodream 的 codec ──
1610
1613
  // 刻意写得宽:这几个方法返回的是配置 / 状态对象,字段会随版本增删。
@@ -1734,6 +1737,21 @@ const StarmapRuntime = (function (require, _react) {
1734
1737
  result: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedWriteResult", schema: archivedWriteSchema, create: () => archivedWriteSchema },
1735
1738
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1736
1739
  },
1740
+ {
1741
+ // 彻底删除一条归档件(不可恢复)。第二个参数是归档代次:宿主拿它核对
1742
+ // 「要删的是不是用户确认过的那一版」,传错(比如列表过期)会被拒绝。
1743
+ id: "sage-mem#memory/deleteArchived",
1744
+ service: "memory",
1745
+ namespace: "memory",
1746
+ method: "deleteArchived",
1747
+ invocation: { kind: "direct" },
1748
+ parameters: [
1749
+ { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedFileName", schema: strSchema, create: () => strSchema } },
1750
+ { name: "archivedAt", wire: "archivedAt", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchiveStamp", schema: archiveStampSchema, create: () => archiveStampSchema } },
1751
+ ],
1752
+ result: { mode: "strict", typeSymbol: "sage-mem/types#ArchiveResult", schema: archiveSchema, create: () => archiveSchema },
1753
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1754
+ },
1737
1755
  // ── 面板 API(D1):体检 / 归档候选 / 设置 / 保留名原样读写 ──
1738
1756
  // 结果形状都在演进(审计清单、候选表、设置项),顶层用 objCodec 接住、
1739
1757
  // 内部宽松 —— 与 autodream 那几个 codec 同一套理由:host 加字段不该让旧界面整体报错。
@@ -2555,7 +2573,7 @@ const StarmapRuntime = (function (require, _react) {
2555
2573
 
2556
2574
  /**
2557
2575
  * 归档区的一条。与活动卡片共用骨架(类型章 / 标题 / 标签 / 描述),刻意的差异:
2558
- * 虚线边框 + 整体压灰(退役感)、多一行归档留痕、动作是「编辑 / 恢复」。
2576
+ * 虚线边框 + 整体压灰(退役感)、多一行归档留痕、动作是「编辑 / 恢复 / 彻底删除」。
2559
2577
  *
2560
2578
  * ⚠️ 灰的只有**内容**(`.smem-card--archived` 那几条只点标题/类型章/标签/正文的字色)。
2561
2579
  * 按钮一律保持原样 —— 按钮一灰,用户就以为不能按。
@@ -2563,7 +2581,10 @@ const StarmapRuntime = (function (require, _react) {
2563
2581
  * ⚠️ 编辑走**原样文本**:readArchived 拿到的全文直接进 textarea,保存时原样交回
2564
2582
  * writeArchived。绝不走活跃记忆那套 buildFrontmatter 重建 —— 重建会吞字段,
2565
2583
  * 归档文件里还有 archived_at / archived_reason 这种留痕,更动不得。
2566
- * 也不提供删除:归档 = 不删,host 侧也没有那条 remote。
2584
+ *
2585
+ * 「彻底删除」是 v1.1.0 加的**唯一**一个不可逆入口,所以它走两段式:
2586
+ * 点一下只展开一句「真的删、不能恢复」的确认条,再点「确认彻底删除」才动手。
2587
+ * 归档的承诺(只移不删)没有被它破坏 —— 归档动作仍然一个字节都不删。
2567
2588
  */
2568
2589
  function ArchivedCard(props) {
2569
2590
  const item = props.item;
@@ -2603,19 +2624,39 @@ const StarmapRuntime = (function (require, _react) {
2603
2624
  h("button", { type: "button", className: "smem-btn", key: "cancel", disabled: props.busy, onClick: props.onCancel }, "取消"),
2604
2625
  ]),
2605
2626
  ])
2606
- : h("div", { className: "smem-card-foot", key: "foot" },
2607
- h("div", { className: "smem-actions", key: "acts" }, [
2608
- h("button", {
2609
- type: "button", className: "smem-btn", key: "edit",
2610
- disabled: props.busy,
2611
- onClick: function () { props.onEdit(item.file); },
2612
- }, "编辑"),
2613
- h("button", {
2614
- type: "button", className: "smem-btn smem-btn-primary", key: "restore",
2615
- disabled: props.busy,
2616
- onClick: function () { props.onRestore(item.file); },
2617
- }, props.restoring ? "恢复中…" : "恢复"),
2618
- ])),
2627
+ : props.deleting
2628
+ ? h("div", { className: "smem-ar-confirm", key: "del" }, [
2629
+ h("div", { key: "what" },
2630
+ "彻底删除「" + item.file + "」:它会从 archive/ 里被真的删掉,不能恢复。" +
2631
+ "要保留它就先点「恢复」,把它放回活动记忆。"),
2632
+ h("div", { className: "smem-ar-hint", key: "hint" },
2633
+ "这一步之后没有任何界面能把内容找回来 —— 只剩你自己在磁盘上留过备份。"),
2634
+ h("div", { className: "smem-row", key: "btns" }, [
2635
+ h("button", { type: "button", className: "smem-btn smem-btn-danger", key: "yes", disabled: props.busy, onClick: function () { props.onConfirmDelete(item.file); } }, "确认彻底删除"),
2636
+ h("button", { type: "button", className: "smem-btn", key: "no", disabled: props.busy, onClick: props.onCancelDelete }, "取消"),
2637
+ ]),
2638
+ ])
2639
+ : h("div", { className: "smem-card-foot", key: "foot" },
2640
+ h("div", { className: "smem-actions", key: "acts" }, [
2641
+ h("button", {
2642
+ type: "button", className: "smem-btn", key: "edit",
2643
+ disabled: props.busy,
2644
+ onClick: function () { props.onEdit(item.file); },
2645
+ }, "编辑"),
2646
+ h("button", {
2647
+ type: "button", className: "smem-btn smem-btn-primary", key: "restore",
2648
+ disabled: props.busy,
2649
+ onClick: function () { props.onRestore(item.file); },
2650
+ }, props.restoring ? "恢复中…" : "恢复"),
2651
+ // 放在最后、且用 danger-ghost:它是这张卡上唯一不可逆的动作,
2652
+ // 不该跟「恢复」长得一样。
2653
+ h("button", {
2654
+ type: "button", className: "smem-btn smem-btn-danger-ghost", key: "purge",
2655
+ disabled: props.busy,
2656
+ title: "从 archive/ 里彻底删掉,不可恢复(要留就先恢复)",
2657
+ onClick: function () { props.onAskDelete(item.file); },
2658
+ }, "彻底删除"),
2659
+ ])),
2619
2660
  ]);
2620
2661
  }
2621
2662
 
@@ -3804,6 +3845,10 @@ const StarmapRuntime = (function (require, _react) {
3804
3845
  const archiveReason = sArchiveReason[0], setArchiveReason = sArchiveReason[1];
3805
3846
  const sRestoring = react.useState(null);
3806
3847
  const restoring = sRestoring[0], setRestoring = sRestoring[1];
3848
+ // 「彻底删除」的二次确认:正在问哪一条(null = 没在问)。
3849
+ // 与 editing 互斥 —— 同一张卡上不能既在改又在删。
3850
+ const sArchDelete = react.useState(null);
3851
+ const archDelete = sArchDelete[0], setArchDelete = sArchDelete[1];
3807
3852
  // 归档文件的「原样文本」编辑器:正在编辑谁、编辑框里是什么、读/存进行中
3808
3853
  const sArchEdit = react.useState(null);
3809
3854
  const archEdit = sArchEdit[0], setArchEdit = sArchEdit[1];
@@ -4073,6 +4118,7 @@ const StarmapRuntime = (function (require, _react) {
4073
4118
  }
4074
4119
  setBusy(true); setError(null); setNotice("");
4075
4120
  setRestoring(file);
4121
+ setArchDelete(null);
4076
4122
  remote.restore(file)
4077
4123
  .then(function (r) {
4078
4124
  if (r && r.ok === false) { setError(r.error || "恢复被拒绝"); return; }
@@ -4084,6 +4130,31 @@ const StarmapRuntime = (function (require, _react) {
4084
4130
  .then(function () { setBusy(false); setRestoring(null); });
4085
4131
  }
4086
4132
 
4133
+ /**
4134
+ * **彻底删除**一条归档件(不可恢复)。
4135
+ *
4136
+ * 第二个参数是归档代次(列表上看到的 `archivedAt`):宿主拿它核对「要删的是不是
4137
+ * 我确认过的那一版」。不传它会变成「按名字删」—— 名字底下的内容刚才可能已经换人了。
4138
+ */
4139
+ function deleteArchivedFile(file) {
4140
+ if (typeof remote.deleteArchived !== "function") {
4141
+ setError("宿主侧还没有 deleteArchived 接口(插件版本较旧)");
4142
+ return;
4143
+ }
4144
+ const item = archiveItems.filter(function (x) { return x.file === file; })[0];
4145
+ const stamp = (item && item.archivedAt) || "";
4146
+ setBusy(true); setError(null); setNotice("");
4147
+ remote.deleteArchived(file, stamp)
4148
+ .then(function (r) {
4149
+ if (r && r.ok === false) { setError(r.error || "删除被拒绝"); return; }
4150
+ setArchDelete(null);
4151
+ setNotice("已彻底删除 " + file + " —— 归档区里也没有它了,找不回来");
4152
+ load();
4153
+ })
4154
+ .catch(function (e) { setError(String((e && e.message) || e)); })
4155
+ .then(function () { setBusy(false); });
4156
+ }
4157
+
4087
4158
  /**
4088
4159
  * 打开归档文件的**原样文本**编辑器。
4089
4160
  *
@@ -4098,6 +4169,7 @@ const StarmapRuntime = (function (require, _react) {
4098
4169
  return;
4099
4170
  }
4100
4171
  setError(null); setNotice("");
4172
+ setArchDelete(null);
4101
4173
  setArchEdit(file);
4102
4174
  setArchText("");
4103
4175
  setArchLoad(true);
@@ -4368,6 +4440,7 @@ const StarmapRuntime = (function (require, _react) {
4368
4440
  item: it,
4369
4441
  busy: busy,
4370
4442
  restoring: restoring === it.file,
4443
+ deleting: archDelete === it.file,
4371
4444
  editing: archEdit === it.file,
4372
4445
  loading: archLoad && archEdit === it.file,
4373
4446
  saving: archSaving && archEdit === it.file,
@@ -4377,6 +4450,10 @@ const StarmapRuntime = (function (require, _react) {
4377
4450
  onSave: saveArchEdit,
4378
4451
  onCancel: cancelArchEdit,
4379
4452
  onRestore: restoreFile,
4453
+ // 两个「危险动作的确认」互斥:问到一半又去改文本,界面就会同时摆两个确认条。
4454
+ onAskDelete: function (file) { setArchDelete(file); setArchEdit(null); },
4455
+ onCancelDelete: function () { setArchDelete(null); },
4456
+ onConfirmDelete: deleteArchivedFile,
4380
4457
  });
4381
4458
  }
4382
4459
  return h(Card, {
@@ -4407,7 +4484,7 @@ const StarmapRuntime = (function (require, _react) {
4407
4484
  ? [
4408
4485
  h("div", { className: "smem-ar-banner", key: "banner" },
4409
4486
  "档案馆里的记忆不参与检索与星图,原文仍在记忆目录的 archive/ 下 —— 恢复后它就回到活动列表;" +
4410
- "「编辑」是原样文本编辑,改完存回同一份文件。"),
4487
+ "「编辑」是原样文本编辑,改完存回同一份文件;「彻底删除」会真的从 archive/ 里删掉它(不可恢复,删除前还要再确认一次)。"),
4411
4488
  list,
4412
4489
  ]
4413
4490
  : list;