dsh-memu-memory 1.1.0 → 1.1.1

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/AGENTS.md CHANGED
@@ -45,7 +45,8 @@
45
45
  待复核的记忆被用上 → T3.9 改了没反应 / 没有 memu_* → T3.10
46
46
  待发区冒出整理提示 → T3.11 新对话读不到文件夹记忆 → T3.12
47
47
  注入整段没了 → T3.13 日期语义错 / 看不见日期 → T3.14
48
- 保存成功但没变 / 字段改不回来 → T3.15
48
+ 保存成功但没变 / 字段改不回来 → T3.15 记忆凭空消失 / 侧边栏空 → T3.17
49
+ 弹窗选了却两侧都勾上 → T3.16 聊很久一条记忆都没写 → T3.18
49
50
  ```
50
51
 
51
52
  ---
package/README.md CHANGED
@@ -30,7 +30,7 @@ It is **local-first by default**. The default `agent` backend stores full text i
30
30
  > [!WARNING]
31
31
  > **Early-stage software.** This plugin is still early and may contain unforeseen errors, bugs or breaking changes. Back up your data, and please [report an issue](https://github.com/Tkingxiao/dsh-memu-memory/issues) or send a pull request — feedback helps the project improve.
32
32
 
33
- > **Status:** 1.1.0. The first public release was 1.0.0. The manual now lives in [`docs/`](docs/), with [`AGENTS.md`](AGENTS.md) as a short index. Back up `~/.dsh/dsh-memu-memory` before upgrades, and read [Compatibility & limits](#compatibility--limits) before storing data you cannot re-create.
33
+ > **Status:** 1.1.1. The first public release was 1.0.0. The manual now lives in [`docs/`](docs/), with [`AGENTS.md`](AGENTS.md) as a short index. Back up `~/.dsh/dsh-memu-memory` before upgrades, and read [Compatibility & limits](#compatibility--limits) before storing data you cannot re-create.
34
34
 
35
35
  ## Screenshots
36
36
 
@@ -62,7 +62,7 @@ All screenshots are the real WebUI on isolated example data, not anyone's memori
62
62
  - **Memory fades unevenly.** Recency, access count, arousal, mood congruence, peak and end effects, primacy, habituation and flashbulb accuracy all feed one strength score. A vivid memory can be more confident than it is accurate, and the UI says so.
63
63
  - **Provenance is a fact, not a style.** Every entry carries `source` (`user_said` / `inferred` / `imagined` / `told`), `confidence`, `accuracy`, and disclosure rules. A vague recollection cannot impersonate a quote.
64
64
  - **Contradictions wait.** A write that rewrites or contradicts an existing entry lands as `pending_review`: it is not injected, not recalled, and appears in a review queue until someone accepts or rejects it.
65
- - **Secrets stay excluded.** `session`-scoped memories, `secret` visibility, group-chat channels, characters, worlds, branches and `knownBy` all constrain reads and edits. Automatic recall never injects a `secret` memory.
65
+ - **Secrets stay excluded.** `secret` visibility, group-chat channels, characters, worlds, branches and `knownBy` all constrain reads and edits. Automatic recall never injects a `secret` memory, and a `secret` is still readable by an explicit recall from the same folder — it hides a memory from the prompt, it is not a lock. What actually separates two conversations in one folder is the `context` scope (`channelId` / `characterId` / `worldId` / `branchId`); a blank scope on both sides means one shared scope, which is the point of a folder. The old `visibility: 'session'` level is gone — reading turns it into `shared` with its `context` untouched, and `node scripts/migrate-visibility.mjs` rewrites libraries that still store it (`--apply`, backs up first).
66
66
  - **People and stories are structured.** Group chat uses stable `speakerId` / `channelId`, not display names. RP uses `characterId` / `worldId` / `branchId`. Relationships are edges with a note and confidence, entities can be renamed without rewriting memory bodies, and the current relationship state (familiarity, trust, warmth, boundaries, promises) is a snapshot that keeps its history and evidence.
67
67
  - **Images are pointers and captions, not pretend vision.** Attachments are deduplicated by SHA-256, quota-limited and tiered; an image without a description is remembered as "received an image", never as a scene the model did not see.
68
68
  - **Housekeeping happens in the background.** Consolidation runs in a seeded fork sub-session, never as an editable turn in your chat. Failed runs back off instead of reporting success.
@@ -182,7 +182,7 @@ To use the `api` backend, point it at an OpenAI-compatible **embedding** endpoin
182
182
 
183
183
  1. Install, then restart DSH.
184
184
  2. Open **MemU WebUI** from the sidebar (or Settings → MemU).
185
- 3. New conversations ask once whether this chat should use memory: private only, global only, both, or none. **Remember my choice** skips the question later.
185
+ 3. New conversations ask once whether this chat should use memory: private only, global only, both, or none. **Remember my choice** skips the question later. The card you pick **sets both switches at once**, so the sidebar shows the card you chose; the advanced "existing / new library" pick only changes which library the private layer reads, never the two switches.
186
186
  4. If you want a folder-wide ceiling, set it under **文件夹** — a folder can forbid private or global memory, and the chat switches will respect that ceiling.
187
187
  5. Talk. The agent writes private memories itself. The **MemU 记忆** wall is where you review, edit, archive, link and export them.
188
188
 
@@ -299,6 +299,7 @@ npm test # full suite; the isolated real-engine
299
299
  npm run check # syntax + i18n key alignment + package.json
300
300
  npm run build:graph # rebuild shipped offline assets after graph source changes
301
301
  node scripts/acceptance-local.mjs # 50 automated acceptance checks
302
+ node scripts/migrate-visibility.mjs # report (add --apply to rewrite) libraries that still store the removed session level
302
303
  node --import ./tests/loader-register.mjs scripts/preview.mjs --port 7789
303
304
  ```
304
305
 
package/README.zh.md CHANGED
@@ -62,7 +62,7 @@
62
62
  - **记忆会不均匀地淡。** 新近、访问次数、唤醒度、情绪同调、峰终、首因、习惯化、闪光灯记忆的准确度共同参与强度;一条生动但可能记错的记忆会显示「细节未必准」,而不是假装确定。
63
63
  - **来源是事实,不是文风。** 每条记忆带 `source`(`user_said` / `inferred` / `imagined` / `told`)、`confidence`、`accuracy` 与说出口径。模糊的推断不能冒充原话。
64
64
  - **矛盾先挂起。** 与已有记忆同题改写或说法相反的新条进入 `pending_review`:不注入、不召回,只出现在复核队列,等人或整理作业裁决。
65
- - **秘密不进自动召回。** `session` 可见性、`secret`、群聊频道、角色、世界线、剧情分支与 `knownBy` 共同约束读写;`secret` 永远不会被自动注入。
65
+ - **秘密不进自动召回。** `secret`、群聊频道、角色、世界线、剧情分支与 `knownBy` 共同约束读写;`secret` 永远不会被自动注入,但同文件夹里**主动检索仍然读得到**——它挡的是「自己冒出来」,不是保险箱。真正把同一文件夹里的两段对话隔开的是 `context` 作用域(`channelId` / `characterId` / `worldId` / `branchId`);两边都没填就是同一个作用域,这正是「文件夹」的含义。旧的 `visibility: 'session'` 级别已删除:读到就按 `shared` 处理、`context` 原样保留,仍存着该级别的库用 `node scripts/migrate-visibility.mjs` 重写(`--apply` 才写,先备份)。
66
66
  - **人与故事是有结构的。** 群聊用稳定的 `speakerId` / `channelId`,不拿显示名当身份;RP 用 `characterId` / `worldId` / `branchId` 隔离知识。关系边有理由和置信度,实体改名不动记忆正文,关系当前状态(熟悉、信任、温度、边界、承诺)是带证据和历史快照。
67
67
  - **图片是指针加描述,不是假装能看图。** 附件按 SHA-256 去重、有配额、分三级留存;没有描述时只记「收到一张图片」,绝不编造画面。
68
68
  - **整理在后台。** 整理跑在种子 fork 子会话里,不会在你的待发区多出一轮;失败退避重试,不伪记成功。
@@ -182,7 +182,7 @@ npx --package dsh-memu-memory dsh-memu-memory-setup
182
182
 
183
183
  1. 安装后重启 DSH。
184
184
  2. 从右侧栏打开 **MemU WebUI**(或设置 → MemU)。
185
- 3. 新会话会问一次:这个对话要不要记忆——只用私有 / 只用全局 / 私有 + 全局 / 不用。「记住我的选择」之后不再问。
185
+ 3. 新会话会问一次:这个对话要不要记忆——只用私有 / 只用全局 / 私有 + 全局 / 不用。「记住我的选择」之后不再问。选哪张卡就**同时定下两个开关**,侧边栏显示的就是你点的那张卡;高级区块里「选已有记忆库 / 新建记忆库」只改记忆落在哪个库,不改这两个开关。
186
186
  4. 如果想按文件夹设上限,去 **文件夹**:文件夹可以禁止私有或全局记忆,对话里的开关会受这个上限约束。
187
187
  5. 正常对话。私有记忆由 agent 自己写;**MemU 记忆** 墙用来审阅、编辑、归档、连边和导出。
188
188
 
@@ -291,6 +291,7 @@ npm test # 全量测试;真实引擎用例在
291
291
  npm run check # 语法 + i18n 键对齐 + package.json
292
292
  npm run build:graph # 修改图谱源码后重建随包分发的离线资源
293
293
  node scripts/acceptance-local.mjs # 50 条自动验收
294
+ node scripts/migrate-visibility.mjs # 报告(--apply 才重写)仍存着旧 session 级别的库
294
295
  node --import ./tests/loader-register.mjs scripts/preview.mjs --port 7789
295
296
  ```
296
297
 
@@ -48,4 +48,48 @@
48
48
  - RP 与 coding 是**内容**差异:coding 记事实(决定、约束、踩过的坑、工程偏好),召回当「可能过期的事实」,与代码冲突时以代码为准;rp 记经验(具体瞬间、半句原话、身体感觉、没说出口的话),允许模糊记错,不确定就标低 `confidence`。
49
49
  - **KV cache**:section 文本随 domain 变,但同一会话内恒定,缓存不受影响;中途改 domain 会让该会话失效一次缓存。别在文档里写「文本恒定」。
50
50
 
51
+ ### 8.6 可见范围只剩两档,它决定「自动浮现」而不是「谁能读」
52
+
53
+ - `shared`(默认):库级,整个文件夹共用。`secret`:只是「不自动推给你」的开关,**不是保险箱**。
54
+ - 按通道分开看:
55
+
56
+ | 通道 | `shared` | `secret` |
57
+ |---|---|---|
58
+ | 每回合锚点 / pin / 纪念日 | ✅ | ❌ |
59
+ | 自动召回注入 | ✅ | ❌ |
60
+ | 随机回响 cue / 短期到期提示 | ✅ | ❌ |
61
+ | 模型主动 `memu_private_recall` | ✅ | ✅ |
62
+ | `memu_private_list` / 侧边栏 / 记忆墙 | ✅ | ✅ |
63
+ | 附件像素 | ✅ | ❌(`hidden: true`;`never_say` 的 caption 也不回显) |
64
+
65
+ - 所以 `secret` 的实际边界是「**不被自动注入、不当回响源、附件不出像素**」;同一文件夹里另一个会话**主动检索时照样能拿到**。它挡的是「自己冒出来」,不是「别人查到」。要「跨会话绝不外泄」,靠 §8.7 的上下文边界,不是 `secret`。
66
+ - 「只有我知道」= `disclosure: never_say`(不许说出口)+ `secret`(不许自动浮现)两个字段的组合,不是加密。侧边栏和记忆墙始终能看到正文——这是**事后监督**的设计,不是疏漏。
67
+ - 全局 curator 读的是会话日志与全局库,不读私有库条目(`compactGlobalIndex`),所以 `secret` 不会被复制进「每会话都注入」的全局记忆;跨文件夹(不同库)之间也读不到。
68
+
69
+ ### 8.7 上下文作用域:`channelId / characterId / worldId / branchId` + `knownBy`
70
+
71
+ - 边界是这四个字段(`lib/context.js` 的 `BOUNDARIES`);`speakerId` 只是身份标签,**不参与可见性**;`knownBy` 是「谁知道」的白名单,按当前 `characterId` 判。
72
+ - 会话侧是 `binding.memoryContext`(+ `sceneTime`),入口 RPC `setMemoryContext`(侧边栏「记忆上下文」表单,`lib/client.js` 的 `contextScope`)。**它不由宿主自动提供**:没填就是 `{}`,等于整个文件夹共享——两个会话都没填,它们在记忆层就是同一个上下文。
73
+ - 写端只能收紧:`{...binding.memoryContext, ...传入 context}`;改边界或放宽一律 `memory context cannot widen or change the bound conversation`(传空字符串也算放宽)。
74
+ - 读端严格相等:条目有某边界字段 → 必须等于当前会话同名字段;条目没有 → 不限制;`knownBy` 有值且不含当前角色 → 不可见。**宽会话看不到窄条目**(条目 `characterId=hero`、会话没设角色 → 不等 → 看不到)。
75
+ - **存储不按上下文分库**:一个文件夹 = 一个库 = `index/<libraryId>.json` 一个文件,所有上下文条目同库共存,靠每条 entry 的 `context` 字段区分、读取时过滤。于是「同群组不同角色」是同一个文件里 `characterId` 不同的两条,「不同群组同角色」是 `channelId` 不同的两条,互相都看不到。
76
+ - 去重与矛盾检测也按上下文分组(`sameFactContext` = 五个 IDENTITY 全等):只有 context 完全一致才算「同一件事」;不同 `speakerId` 的同标题不算矛盾,不同世界/分支的两种说法可以并存。
77
+ - 记忆墙 `browseLibrary` 不带 session,显示整库——这是唯一能编辑「别处上下文」条目的通路,也是修可见范围的兜底入口。
78
+
79
+ ### 8.8 `session` 级别已删除,老数据升级为 `shared`
80
+ - 曾经有第三档 `visibility: 'session'`:条目绑定写下它的那段对话,只有那段对话能召回。**它在三种状态下都错**:①会话还在——同文件夹的其它对话读不到,而「同文件夹共享」正是这个插件存在的理由;②会话被删——条目变成谁也读不到的孤儿(侧边栏空白、记忆凭空消失就是这么来的);③想让人读到——模型只能改成 `shared`,于是「作用域」变成了一次性开关,而不是约束。真正需要「只给某个角色/某条世界线看」的场景由 §8.7 的 `context` 表达,那一维是可读、可审计、可放宽的。
81
+ - 升级:读到 `visibility: 'session'` 一律当 `shared`(`lib/visibility.js` 的 `migrateEntryVisibility`),**`context` 原封不动**——原本限定在某个角色/分支的条目,升级后仍然只有那个上下文看得到,丢掉的只是「哪段对话写的」这一维(以及 `sessionId` 字段)。标记 `migratedFromVisibility: 'session'` + `visibilityMigratedAt`,便于事后审计。
82
+ - 落在哪一层:`lib/state.js` 的 `loadPrivateIndex` 在读取时升级视图(所有读写都经过这里,消费者不必知道该级别存在),基线仍是磁盘原文,所以下一次普通写入就会把升级落盘;`lib/migrate.js` 的周期巡检与侧边栏轮询(60 秒节流)把没人再写的库也重写掉。
83
+ - 手工/一次性:`node scripts/migrate-visibility.mjs`(默认只报告,`--apply` 先 `backupDatabases()` 再写,`--json` 机器可读)。退出码 3 = 有老数据待转换,1 = 转换失败。
84
+ - 导入导出:老版本导出的 `session` 条目按 `shared` 导入(同 `migratedFromVisibility` 标记),报告里记在 `downgraded` 并写明「session -> shared,要收窄请补 context」——丢一条记忆比放宽一条更糟,但「读者从一段对话变成整个文件夹」必须让用户看得见。
85
+ - 工具面:`visibility` 只接受 `shared` / `secret`,传 `session` 会得到解释性报错(而不是静默降级);面板下拉与中英文标签同步删掉了这一档。
86
+
87
+ ### 8.9 空闲整理:什么时候跑,以及为什么可以什么都不写
88
+ - 触发:`settings.sleepConsolidation` 开着时,每 5 分钟扫一遍(`lib/cycle.js` 的 `runSleepPass`),挑「`lastTurnAt` 早于 `lastSleepAt` 且已经安静 ≥ `sleepAfterIdleMinutes`」的会话,交给 `requestPrivateConsolidation` 起一个 fork 去读转录、写记忆。**默认 3 分钟**(`lib/state.js` 的 `DEFAULT_SETTINGS`);旧值 8/20 的安装保留原选择,只是新装默认更短。
89
+ - 应用启动后 8 秒补跑一次(`STARTUP_SWEEP_MS`),因为定时器只在应用开着时存在:一段对话如果是在应用关着的时候结束的,它永远等不到那个「空闲窗口」——用户一回来发消息,`lastTurnAt` 就被顶新了。补偿每次最多处理 **2 个**会话(`runSleepPass` 的 `limit`),按最近活动排序。
90
+ - **有新回合 ≠ 该写记忆。** 进 fork 之前先做一次本地评估(`lib/private.js` 的 `assessConsolidationValue`,只看 `lastSleepSeq` 之后的转录):①没有任何人类消息(只有插件通知、工具结果、心跳)→ 跳过;②只有一条**纯应答**(「谢谢」「好的」「ok」「收到」,且没有任何工具调用)→ 跳过。**这里刻意不按字数判断**:字数少的偏好句(「我不吃香菜」)比一千字的闲聊更该留,长度是坏代理。其余一律交给 fork——**跳过只是省一次 fork,「该不该留」仍由整理提示词判断**,它完全可以读完写 0 条。宁可多跑一次,也不要在本地猜「这条不重要」。
91
+ - 评估通过但整理写了 0 条 = 正常成功(`lastSleepAt` 前移),不会每小时重来。
92
+ - 会话被删:host 对 `readSession` 报 `session "…" not found`,这是**不变量**而不是瞬时故障。`runSleepPass` 一旦看到就把 binding 标 `sessionGone: true` 并清掉重试计数,此后直接跳过(以前会按 2 的幂退避、封顶 1 小时,永远重试);同名会话后来真的收到新消息时(`lib/inbound.js`)会清掉这个标记。
93
+ - 手动「整理这场对话」(RPC `generatePrivate`)带 `force: true`,跳过上面那道评估:用户已经决定了。
94
+
51
95
  ---
@@ -29,6 +29,8 @@
29
29
  待发区冒出整理提示 → §3.11 新对话读不到文件夹记忆 → §3.12
30
30
  注入整段没了 → §3.13 日期语义错 / 看不见日期 → §3.14
31
31
  保存成功但没变 / 字段改不回来 → §3.15
32
+ 同文件夹里某条记忆凭空消失 / 新会话侧边栏空 → §3.17
33
+ 聊很久一条记忆都没写 → §3.18
32
34
  ```
33
35
 
34
36
  ## 3. 出过的问题:症状 → 落点 → 守卫
@@ -51,7 +53,7 @@ S7 宿主流 pre-step 钩子、compaction 重注入、会话 v4 校验、整
51
53
  S8 展示回写 client.js 面板 + useMemoryBeat 心跳 + rpc.js allowed + tools.js schema
52
54
  ```
53
55
 
54
- 文件清单见 §2.1。总览:记忆不出现 / 只剩锚点 / 注入整段没了 → §3.1、§3.13;会话或设置页打不开、报格式错 → §3.2、§3.7;记忆变少或整库变空 → §3.3、§3.4;面板不刷新、UI 错位 → §3.5、§3.6;引擎、检索质量、复核泄漏 → §3.8、§3.9;改了没反应、新对话读不到记忆 → §3.10、§3.12;输入框上方冒出整理提示 → §3.11;日期语义错、改了保存不上 → §3.14、§3.15。
56
+ 文件清单见 §2.1。总览:记忆不出现 / 只剩锚点 / 注入整段没了 → §3.1、§3.13;会话或设置页打不开、报格式错 → §3.2、§3.7;记忆变少或整库变空 → §3.3、§3.4;面板不刷新、UI 错位 → §3.5、§3.6;引擎、检索质量、复核泄漏 → §3.8、§3.9;改了没反应、新对话读不到记忆 → §3.10、§3.12;输入框上方冒出整理提示 → §3.11;日期语义错、改了保存不上 → §3.14、§3.15;弹窗答了侧边栏却两边都勾上 → §3.16。
55
57
 
56
58
  ### 3.1 记忆不出现,只剩锚点(S4 → S6)
57
59
 
@@ -173,6 +175,36 @@ S8 展示回写 client.js 面板 + useMemoryBeat 心跳 + rpc.js allowed + tool
173
175
 
174
176
  **守卫**:`tests/update-fields.test.mjs`、`tests/browser.test.mjs`;确认顺序:`unknown memory field` → `allowed` 与 `privateUpdate` 签名 → `memu_private_update` schema → `accuracyOf` 是否重算。
175
177
 
178
+ ### 3.16 弹窗选了「只私有」,侧边栏两个开关还是都勾上(S1 绑定 → S8 展示)
179
+
180
+ **症状**:新会话弹窗选了「只用私有记忆」(或「只用全局」),确认后侧边栏「使用私有记忆 / 使用全局记忆」两个 switch 仍然都勾着;勾了「记住我的选择」也一样,下次进新会话还是两个都勾。
181
+
182
+ **落点,可能的原因**:弹窗写了「库」,没写「两个开关」。`initSession` → `state.js` 的 `ensureSession` 在一个还没有 binding 的会话上按**文件夹策略**填两个开关(`policy.useLibrary !== false`),所以任何没被显式写入的开关都会回到文件夹默认值(默认两个都开)。旧 `confirm` 里「只私有」(`auto`)与「选已有库/新建库」三条分支只调了 `initSession`、没调 `setSessionScope`,两个开关就由策略落成 `true`——这就是「选了只私有,侧边栏却两个都勾上」。另一半:高级区块的库单选和卡片共用同一个 `choice`,点一下「选已有记忆库」把整卡答案改写成 `existing`,`confirm` 更没有可写的层。修法:卡片与库选择拆成两个状态(`choice` / `pick`),`LAYERS_OF` 一处定义「卡片 → 两个开关」,`confirm` 的每条分支(含已记住的选择)都以 `setSessionScope(..., ...layers)` 收尾,`createLibrary` 只跟私有层走;`rememberChoice` 的偏好表补 `global`,`rpc.js` 的 `allowed` 与 `state.js` 的 enums 同步接受它——否则「记住」被静默丢弃,`ensureSession` 又会按策略填回两个 `true`。
183
+
184
+ **守卫**:`tests/new-session-gate.test.mjs`——四张卡各点一遍,断言 `setSessionScope` 收到的 `useLibrary/useGlobal` 与卡片一致;四种「记住的选择」各跑一遍;高级区块换库后卡片答案不变、`initSession` 只带 `libraryId`。手工核对:看 `state.json` 里该 binding 的两个布尔值是否等于你点的那张卡。
185
+
186
+ ### 3.17 同文件夹里某条记忆凭空消失 / 新会话侧边栏空白(S2 可见范围)
187
+
188
+ **症状**:同一个文件夹(同一个库)里,老会话看得见的某条记忆,在新会话的侧边栏里没有、注入里也没有,工具也读不到;而文件夹里其它记忆一切正常。`index/<libraryId>.json` 里条目还在。
189
+
190
+ **落点,可能的原因**:这版之前的条目可能被写成了 `visibility: 'session'`(会话级,模型自己填的),而 `sessionId` 指向的会话已被删除——那种条目谁也不是,读不到也改不了,正是「记忆凭空消失」的成因。**该级别已经从代码里删掉**:读到就按 `shared` 处理,`context`(channel/character/world/branch)原样保留,落盘时补 `migratedFromVisibility: 'session'` + `visibilityMigratedAt` 并删掉 `sessionId`。读路径在 `loadPrivateIndex`(`lib/state.js`)+ 迁移规则在 `lib/visibility.js`;没人再写的库由 `lib/migrate.js` 的周期巡检与侧边栏轮询(60 秒节流)重写。
191
+
192
+ **还有一种「消失」,不是 bug**:条目带着 `context`(例如 `characterId=hero`、`branchId=主线`),而当前会话的 `memoryContext` 是空白或另一套值。宽会话看不到窄条目,这是设计。核对方法:看条目 JSON 的 `context` 与侧边栏「记忆上下文」表单是否一致;只有记忆墙(`browseLibrary`,不带会话)能改别处上下文的条目。
193
+
194
+ **守卫**:`tests/visibility-migration.test.mjs`(迁移保留 context、读时归一、落盘可证、二次运行幂等、工具面拒绝 `session`、空白作用域跨会话可读 secret、secret 不进自动通道、`context` 仍隔离)+ `tests/context-scope.test.mjs`;手工核对:`node scripts/migrate-visibility.mjs`(默认只报告,退出码 3 = 有老数据待转换,`--apply` 才写且先备份),条目上出现 `migratedFromVisibility: 'session'` 就是升级过。
195
+
196
+ ### 3.18 聊了一整晚,一条记忆都没写(S7 空闲整理 → S2 写入)
197
+
198
+ **症状**:会话里明明发生了很多事,`memu_status` 的 `inbound` 计数也涨了,但库里一条新条目都没有;`state.json` 里该 binding 的 `lastSleepAt` 停在前一天。
199
+
200
+ **落点,按概率排**:
201
+ - **回合内没有人调用 `memu_private_remember`。** 插件不会每轮自动写记忆——每回合的写入是模型的决定,空闲整理只是兜底。`memu_status` 的 `injectedIds`、`lastInjectedTurn` 正常、`inbound` 计数正常,就说明注入没坏,是「没写」而不是「没读到」。
202
+ - **空闲窗口没落在应用开着的时候。** 巡检定时器只在应用运行时存在;应用关掉时结束的对话等不到那次 `runSleepPass`,而用户一回来发消息,`lastTurnAt` 就被顶新了。现在启动后 8 秒会补跑一次(最多 2 个会话),默认空闲阈值也从 8 分钟降到 3 分钟。
203
+ - **新回合被评估为「不值得整理」。** `assessConsolidationValue` 只拒绝两种:`lastSleepSeq` 之后没有任何人类消息;或只有一条纯应答(「谢谢」「好的」「ok」,无工具调用)。其余都会进 fork,由整理提示词决定写不写——**写 0 条是合法结果**,不是故障。
204
+ - **整理失败一直退避。** 看 binding 的 `lastSleepError` / `sleepRetryAt` / `sleepAttempts`。会话被删会得到 `session "…" not found`,现在会标 `sessionGone: true` 并停止重试(以前会每小时重来)。
205
+
206
+ **守卫**:`tests/cycle.test.mjs`(空闲阈值、启动补偿、`sessionGone` 只标记一次、寒暄不 fork)+ `tests/consolidation-value.test.mjs`(评估边界:插件通知不算人类消息、纯应答跳过、「我不吃香菜」这种五个字的偏好必须留下、`lastSleepSeq` 之后的尾巴才算)。手工核对:`state.json` 里 `lastSleepAt` / `lastSleepSeq` / `sessionGone` / `sleepAttempts` 四个字段。
207
+
176
208
  ---
177
209
 
178
210
 
@@ -198,6 +230,9 @@ S8 展示回写 client.js 面板 + useMemoryBeat 心跳 + rpc.js allowed + tool
198
230
  | §3.13 | 注入整段没了 | 看 binding 的 `libraryId` 是否 `null`(刚解绑/删库时常见);用例见 `tests/injection-chain.test.mjs`。 |
199
231
  | §3.14 | 日期语义错 / 日期看不见 | `tests/injection-chain.test.mjs`、`tests/tools.test.mjs`(列表行必须与召回同标签);看条目 `salientDate` 是 `MM-DD` 还是 `YYYY-MM-DD`、锚点行与列表行里有没有 `date=`/`anniversary=`。 |
200
232
  | §3.15 | 保存成功但没变 / 字段改不回来 | `tests/update-fields.test.mjs`、`tests/browser.test.mjs`;确认顺序:`unknown memory field` → `allowed` 与 `privateUpdate` 签名 → `memu_private_update` schema → `accuracyOf` 是否重算。 |
233
+ | §3.16 | 弹窗选了却两边都勾上 | `tests/new-session-gate.test.mjs`(四张卡 + 四种「记住的选择」都断言 `useLibrary`/`useGlobal`,且换库不改层);看 `state.json` 里该 binding 的两个布尔值是否等于卡片。 |
234
+ | §3.17 | 同文件夹里某条记忆凭空消失 | `tests/visibility-migration.test.mjs`(迁移保留 context / 读时归一 / 落盘可证 / 幂等 / 拒绝 session / 空白作用域互读 / secret 不自动注入 / context 隔离)+ `tests/context-scope.test.mjs`;手工跑 `node scripts/migrate-visibility.mjs`,看条目上有没有 `migratedFromVisibility: 'session'`,以及 `context` 与当前会话是否一致。 |
235
+ | §3.18 | 聊很久一条记忆都没写 | `tests/cycle.test.mjs`(阈值 / 启动补偿 / `sessionGone` 只标一次 / 寒暄不 fork)+ `tests/consolidation-value.test.mjs`(评估边界);手工看 `state.json` 的 `lastSleepAt`/`lastSleepSeq`/`sessionGone`/`sleepAttempts`。 |
201
236
 
202
237
  ## 附录 A · 模块地图(原 §2.1)
203
238
 
package/lib/client.js CHANGED
@@ -343,8 +343,9 @@ window.__ModuleLoader__.load({
343
343
  browse: '浏览',
344
344
  export: '导出 JSON',
345
345
  importJson: '导入 JSON',
346
- importHint: '选择导出的 JSON 或图片 ZIP。按正文去重;已归档与失效条目跳过;session 私有范围保持限制,不自动改为共享。',
346
+ importHint: '选择导出的 JSON 或图片 ZIP。按正文去重;已归档与失效条目跳过;老版本里限定在某段对话的条目会按「共享」导入,并在报告里单独列出(要收窄请补 context 字段)。',
347
347
  importDone: '导入完成(新增 / 跳过)',
348
+ importAdjusted: '已按新语义调整',
348
349
  importEngineWarning: '记忆已写入索引,但引擎提交失败,语义检索暂时看不到它们',
349
350
  newMemory: '新建记忆',
350
351
  memoryTitle: '标题(可留空)',
@@ -385,7 +386,6 @@ window.__ModuleLoader__.load({
385
386
  ownerAboutUser: '关于他',
386
387
  ownerAboutProject: '关于项目',
387
388
  visShared: '共享',
388
- visSession: '本会话',
389
389
  visSecret: '秘密',
390
390
  sourceUserSaid: '对方的话',
391
391
  sourceInferred: '我推断',
@@ -501,6 +501,7 @@ window.__ModuleLoader__.load({
501
501
  memoryPromptBody: '记忆保存在 ~/.dsh/dsh-memu-memory/,删除对话不会删除记忆。',
502
502
  chooseLibrary: '选择已有记忆库',
503
503
  createLibrary: '新建记忆库',
504
+ folderLibrary: '用文件夹自带的记忆库',
504
505
  memOffTitle: '不用记忆',
505
506
  memOffDesc: '这段对话什么都不记。已有记忆仍留在磁盘上,之后可以在侧边栏改。',
506
507
  memPrivateTitle: '只用私有记忆',
@@ -755,8 +756,9 @@ window.__ModuleLoader__.load({
755
756
  browse: 'Browse',
756
757
  export: 'Export JSON',
757
758
  importJson: 'Import JSON',
758
- importHint: 'Import exported JSON or an image ZIP. Duplicate and inactive entries are skipped; session-scoped entries remain restricted and are never widened to shared.',
759
+ importHint: 'Import exported JSON or an image ZIP. Duplicate and inactive entries are skipped; entries an older export scoped to one conversation are imported as shared and listed separately in the report (add context fields to narrow them).',
759
760
  importDone: 'Imported (added / skipped)',
761
+ importAdjusted: 'adjusted to the current semantics',
760
762
  importEngineWarning: 'Memories are in the index, but the engine commit failed, so semantic recall cannot see them yet',
761
763
  newMemory: 'New memory',
762
764
  memoryTitle: 'Title (optional)',
@@ -796,7 +798,6 @@ window.__ModuleLoader__.load({
796
798
  ownerAboutUser: 'about them',
797
799
  ownerAboutProject: 'about project',
798
800
  visShared: 'shared',
799
- visSession: 'session',
800
801
  visSecret: 'secret',
801
802
  sourceUserSaid: 'their words',
802
803
  sourceInferred: 'inferred',
@@ -912,6 +913,7 @@ window.__ModuleLoader__.load({
912
913
  memoryPromptBody: 'Memory is stored in ~/.dsh/dsh-memu-memory/. Deleting the conversation does not delete memory.',
913
914
  chooseLibrary: 'Choose an existing library',
914
915
  createLibrary: 'Create a new library',
916
+ folderLibrary: "Use this folder's library",
915
917
  memOffTitle: 'No memory',
916
918
  memOffDesc: 'This conversation keeps nothing. Existing memories stay on disk and the setting can be changed later in the sidebar.',
917
919
  memPrivateTitle: 'Private memory only',
@@ -1526,7 +1528,7 @@ window.__ModuleLoader__.load({
1526
1528
  const STATUS_LABELS = { active: 'stActive', archived: 'stArchived', suppressed: 'stSuppressed', superseded: 'stSuperseded', pending_review: 'stPendingReview', expired: 'stExpired' }
1527
1529
  const DISCLOSURE_LABELS = { can_tell: 'discCanTell', only_if_asked: 'discOnlyIfAsked', never_say: 'discNeverSay' }
1528
1530
  const OWNER_LABELS = { me: 'ownerMe', us: 'ownerUs', about_user: 'ownerAboutUser', about_project: 'ownerAboutProject' }
1529
- const VISIBILITY_LABELS = { shared: 'visShared', session: 'visSession', secret: 'visSecret' }
1531
+ const VISIBILITY_LABELS = { shared: 'visShared', secret: 'visSecret' }
1530
1532
  // Provenance. `inferred` is the default and the least interesting value, but it is
1531
1533
  // still shown in the browser: the whole failure mode this label exists for is a
1532
1534
  // memory quietly claiming to be a quote.
@@ -2960,8 +2962,11 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
2960
2962
  res = await call('importMemories', payload)
2961
2963
  }
2962
2964
  const c = res.counts || {}
2963
- const skipped = (c.duplicate || 0) + (c.invalid || 0) + (c.dead || 0) + (c.expired || 0) + (c.downgraded || 0) + (c.sessionReset || 0)
2964
- setNotice(`${t('importDone')}: +${res.imported || 0} / −${skipped}`)
2965
+ const skipped = (c.duplicate || 0) + (c.invalid || 0) + (c.dead || 0) + (c.expired || 0)
2966
+ // `downgraded` entries did land in the library — calling them skipped would
2967
+ // undercount what the user just imported, so they get their own suffix.
2968
+ const adjusted = c.downgraded || 0
2969
+ setNotice(`${t('importDone')}: +${res.imported || 0} / −${skipped}${adjusted ? ` · ${t('importAdjusted')}: ${adjusted}` : ''}`)
2965
2970
  if (res.engineWarning) setError(`${t('importEngineWarning')}: ${res.engineWarning}`)
2966
2971
  notifyMemoryWrite('import', browseLibId)
2967
2972
  await openBrowser(browseLibId)
@@ -3607,7 +3612,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
3607
3612
  title: t('visibilityLabel'),
3608
3613
  value: draft.visibility ?? f.visibility ?? 'shared',
3609
3614
  onChange: (e) => set('visibility', e.target.value),
3610
- }, ['shared', 'session', 'secret'].map((v) => h('option', { key: v, value: v }, enumText(t, VISIBILITY_LABELS, v)))),
3615
+ }, ['shared', 'secret'].map((v) => h('option', { key: v, value: v }, enumText(t, VISIBILITY_LABELS, v)))),
3611
3616
  h('select', {
3612
3617
  style: { ...S.input, flex: '1 1 110px', minWidth: 0 },
3613
3618
  title: t('sourceLabel'),
@@ -4292,6 +4297,20 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4292
4297
  h('div', { style: S.tabBody }, pages[tabPage]?.node || null))
4293
4298
  }
4294
4299
 
4300
+ // One card = one pair of switches. The card the user clicks, the binding
4301
+ // written on confirm, and the two checkboxes the sidebar then shows are the
4302
+ // same three ideas, so the mapping lives in one place: any drift shows up as
4303
+ // a switch that says the opposite of the card that was picked.
4304
+ const LAYERS_OF = {
4305
+ none: { useLibrary: false, useGlobal: false },
4306
+ auto: { useLibrary: true, useGlobal: false },
4307
+ global: { useLibrary: false, useGlobal: true },
4308
+ both: { useLibrary: true, useGlobal: true },
4309
+ }
4310
+ // Only the layers survive "remember my choice"; a one-off pick does not.
4311
+ const PREFERENCE_OF = { none: 'off', auto: 'auto', global: 'global', both: 'both' }
4312
+ const CARD_OF = { off: 'none', auto: 'auto', global: 'global', both: 'both' }
4313
+
4295
4314
  function NewSessionGate({ ctx, t }) {
4296
4315
  const current = useCurrentSession(ctx)
4297
4316
  const list = useSessionList(ctx)
@@ -4300,6 +4319,10 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4300
4319
  const checking = React.useRef(false)
4301
4320
  const [prompt, setPrompt] = React.useState(null)
4302
4321
  const [choice, setChoice] = React.useState('auto')
4322
+ // The card picks the layers; this only picks *which* library carries them.
4323
+ // Keeping the two apart is what stops an advanced pick from silently
4324
+ // meaning "and whatever the folder policy says about the other switch".
4325
+ const [pick, setPick] = React.useState('folder')
4303
4326
  const [libraryId, setLibraryId] = React.useState('')
4304
4327
  const [newName, setNewName] = React.useState('')
4305
4328
  const [dontShow, setDontShow] = React.useState(false)
@@ -4347,20 +4370,14 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4347
4370
  return
4348
4371
  }
4349
4372
  if (st.settings?.newSessionPromptDismissed) {
4350
- // "Don't ask again" must actually honor the remembered choice —
4351
- // always creating a library here made the preference a no-op.
4352
- const pref = st.settings?.newSessionDefault || 'both'
4353
- if (pref === 'off') {
4354
- await call('initSession', { sessionId: current, cwd: summaryCwd, createLibrary: false })
4355
- await call('setSessionScope', { sessionId: current, useLibrary: false, useGlobal: false })
4356
- } else if (pref === 'auto') {
4357
- await call('initSession', { sessionId: current, cwd: summaryCwd, createLibrary: true })
4358
- } else {
4359
- // 'both' (and any legacy value): folder library plus both layers;
4360
- // the server still clamps against the folder policy.
4361
- await call('initSession', { sessionId: current, cwd: summaryCwd, createLibrary: true })
4362
- await call('setSessionScope', { sessionId: current, useLibrary: true, useGlobal: true })
4363
- }
4373
+ // "Don't ask again" must actually honor the remembered choice.
4374
+ // Writing only one of the two layers (or, as this branch used to,
4375
+ // only the library) let the folder policy fill the missing switch,
4376
+ // so the remembered answer was not the answer the sidebar showed.
4377
+ const card = CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both'
4378
+ const layers = LAYERS_OF[card]
4379
+ await call('initSession', { sessionId: current, cwd: summaryCwd, createLibrary: layers.useLibrary })
4380
+ await call('setSessionScope', { sessionId: current, ...layers })
4364
4381
  decided.current.add(current)
4365
4382
  return
4366
4383
  }
@@ -4369,9 +4386,10 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4369
4386
  cwd: summaryCwd || '',
4370
4387
  libraries: (st.libraries || []).filter((l) => l.scopeKey !== '__default__'),
4371
4388
  folder: st.folder || null,
4372
- defaultChoice: st.settings?.newSessionDefault || 'both',
4389
+ defaultChoice: CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both',
4373
4390
  })
4374
- setChoice(st.settings?.newSessionDefault || 'both')
4391
+ setChoice(CARD_OF[String(st.settings?.newSessionDefault || 'both')] || 'both')
4392
+ setPick('folder')
4375
4393
  setLibraryId((st.libraries || []).find((l) => l.scopeKey !== '__default__')?.id || '')
4376
4394
  } catch (e) {
4377
4395
  if (!cancelled) setError(String(e?.message || e))
@@ -4389,38 +4407,36 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4389
4407
  setError('')
4390
4408
  try {
4391
4409
  if (dontShow) {
4392
- // Persist only choices the dismissed-branch understands; one-off
4393
- // picks like 'existing'/'new' must not become the default policy.
4394
- const preference = { none: 'off', auto: 'auto', both: 'both' }[choice]
4410
+ // Persist only the layers the dismissed branch understands; a one-off
4411
+ // library pick must not become the default policy.
4412
+ const preference = PREFERENCE_OF[choice]
4395
4413
  await call('setSettings', {
4396
4414
  patch: preference
4397
4415
  ? { newSessionPromptDismissed: true, newSessionDefault: preference }
4398
4416
  : { newSessionPromptDismissed: true },
4399
4417
  })
4400
4418
  }
4401
- if (choice === 'existing' && libraryId) {
4419
+ // The card decides the two layers, and it decides them on *every*
4420
+ // branch. Leaving either one unwritten is what let ensureSession fill
4421
+ // it from the folder policy, so "private memory only" came back as
4422
+ // both switches checked in the sidebar.
4423
+ const layers = LAYERS_OF[choice] || LAYERS_OF.none
4424
+ // The advanced pick only says which library carries the private layer;
4425
+ // with no private layer there is nothing to point at.
4426
+ const target = layers.useLibrary ? pick : 'folder'
4427
+ if (target === 'existing' && libraryId) {
4402
4428
  await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, libraryId })
4403
- } else if (choice === 'new' && newName.trim()) {
4429
+ } else if (target === 'new' && newName.trim()) {
4404
4430
  const lib = await call('createLibrary', { name: newName.trim(), cwd: prompt.cwd })
4405
4431
  await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, libraryId: lib.id })
4406
- } else if (choice === 'none') {
4407
- // Explicit "no memory": do not create a private library for this
4408
- // conversation, and silence both sources for the session.
4409
- await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: false })
4410
- await call('setSessionScope', { sessionId: prompt.sessionId, useLibrary: false, useGlobal: false })
4411
- } else if (choice === 'both') {
4412
- // Private and global together: the folder's own library plus the
4413
- // shared layer. The server still clamps against the folder policy.
4414
- await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: true })
4415
- await call('setSessionScope', { sessionId: prompt.sessionId, useLibrary: true, useGlobal: true })
4416
- } else if (choice === 'global') {
4417
- // Global only: no private library for this conversation at all.
4418
- await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: false })
4419
- await call('setSessionScope', { sessionId: prompt.sessionId, useLibrary: false, useGlobal: true })
4420
4432
  } else {
4421
- // "auto": create and bind a private library for this folder.
4422
- await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: true })
4433
+ // The folder's own library, created only when the private layer is
4434
+ // actually on: "no memory" and "global only" must not mint one.
4435
+ await call('initSession', { sessionId: prompt.sessionId, cwd: prompt.cwd, createLibrary: layers.useLibrary })
4423
4436
  }
4437
+ // Always write both switches, so the sidebar reflects the card and not
4438
+ // a policy default.
4439
+ await call('setSessionScope', { sessionId: prompt.sessionId, ...layers })
4424
4440
  decided.current.add(prompt.sessionId)
4425
4441
  setPrompt(null)
4426
4442
  } catch (e) {
@@ -4478,7 +4494,7 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4478
4494
  onClose: later,
4479
4495
  footer: [
4480
4496
  h(Button, { key: 'later', onClick: later, disabled: busy }, t('later')),
4481
- h(Button, { key: 'ok', onClick: confirm, disabled: busy || (choice === 'existing' && !libraryId) || (choice === 'new' && !newName.trim()), primary: true }, t('confirm')),
4497
+ h(Button, { key: 'ok', onClick: confirm, disabled: busy || (pick === 'existing' && !libraryId) || (pick === 'new' && !newName.trim()), primary: true }, t('confirm')),
4482
4498
  ],
4483
4499
  },
4484
4500
  h('div', { style: { ...S.muted, lineHeight: 1.6 } }, t('memoryPromptBody')),
@@ -4521,11 +4537,17 @@ const SOURCE_LABELS = { user_said: 'sourceUserSaid', inferred: 'sourceInferred',
4521
4537
  h('details', { style: { marginTop: 10 } },
4522
4538
  h('summary', { style: { ...S.muted, cursor: 'pointer' } }, t('advanced')),
4523
4539
  h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 } },
4524
- h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-choice', checked: choice === 'existing', onChange: () => setChoice('existing') }), t('chooseLibrary')),
4525
- choice === 'existing' ? h('select', { style: { ...S.input, marginLeft: 24 }, value: libraryId, onChange: (e) => setLibraryId(e.target.value) },
4540
+ // A separate radio group from the cards above: choosing a library must
4541
+ // not overwrite which layers the card above already asked for. That
4542
+ // overwrite was the bug — clicking "existing library" sent the whole
4543
+ // choice to 'existing', which the confirm branch did not handle, so no
4544
+ // scope was written and the folder policy checked both switches.
4545
+ h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'folder', onChange: () => setPick('folder') }), t('folderLibrary')),
4546
+ h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'existing', onChange: () => setPick('existing') }), t('chooseLibrary')),
4547
+ pick === 'existing' ? h('select', { style: { ...S.input, marginLeft: 24 }, value: libraryId, onChange: (e) => setLibraryId(e.target.value) },
4526
4548
  libraries.map((l) => h('option', { key: l.id, value: l.id }, l.name || l.id))) : null,
4527
- h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-choice', checked: choice === 'new', onChange: () => setChoice('new') }), t('createLibrary')),
4528
- choice === 'new' ? h('input', { style: { ...S.input, marginLeft: 24 }, placeholder: t('name'), value: newName, onChange: (e) => setNewName(e.target.value) }) : null)),
4549
+ h('label', { style: S.row }, h('input', { type: 'radio', name: 'memu-library', checked: pick === 'new', onChange: () => setPick('new') }), t('createLibrary')),
4550
+ pick === 'new' ? h('input', { style: { ...S.input, marginLeft: 24 }, placeholder: t('name'), value: newName, onChange: (e) => setNewName(e.target.value) }) : null)),
4529
4551
  h('label', { style: { ...S.row, marginTop: 12 } }, h('input', { type: 'checkbox', checked: dontShow, onChange: (e) => setDontShow(e.target.checked) }), t('rememberChoice')))
4530
4552
  }
4531
4553
 
package/lib/cycle.js CHANGED
@@ -6,8 +6,13 @@ import {
6
6
  import { expireShortTerm } from './memory.js'
7
7
  import { requestPrivateConsolidation } from './private.js'
8
8
  import { startGlobalCuration } from './curator.js'
9
+ import { runVisibilityMigrationPass } from './migrate.js'
9
10
 
10
11
  const SWEEP_MS = 5 * 60 * 1000
12
+ // Long enough that the host has restored its session list and the agent objects
13
+ // the fork needs, short enough that a user who opens the app and reads for a minute
14
+ // still gets the catch-up before they type their next message.
15
+ const STARTUP_SWEEP_MS = 8000
11
16
 
12
17
  function liveAgent(ctx, sessionId) {
13
18
  try {
@@ -40,16 +45,30 @@ export function runShortTermPass(_ctx, { now = Date.now(), log = () => {} } = {}
40
45
  }
41
46
  }
42
47
 
48
+ // The host's answer when a conversation has been deleted. It is not transient —
49
+ // retrying it is how a dead session used to be woken up once an hour forever.
50
+ export function isGoneSessionError(message) {
51
+ return /session[^"]*".*" not found|no such session|ENOENT|session .*does not exist/i.test(String(message || ''))
52
+ }
53
+
43
54
  // Sleep consolidation: after a conversation has been quiet for a while, the agent
44
55
  // gets one instruction to tidy the library it wrote during it. Failed attempts
45
56
  // back off, but never pretend that an unread transcript was consolidated.
46
- export async function runSleepPass(ctx, { now = Date.now(), log = () => {} } = {}) {
57
+ //
58
+ // Two things this pass must not do. It must not wake a conversation that no longer
59
+ // exists: the host answers "session not found" for it forever, and the retry curve
60
+ // tops out at one hour, so one deleted chat used to be retried for the life of the
61
+ // install. And it must not read "there are new turns" as "there is something worth
62
+ // remembering" — `assessConsolidationValue` inspects the transcript first, and a
63
+ // session that only exchanged greetings is marked asleep instead of being forked.
64
+ export async function runSleepPass(ctx, { now = Date.now(), log = () => {}, limit = 2 } = {}) {
47
65
  const settings = getSettings()
48
66
  if (settings.sleepConsolidation !== true) return { ran: 0, skipped: 'disabled' }
49
- const idleMs = Math.max(1, Number(settings.sleepAfterIdleMinutes) || 20) * 60_000
50
- let ran = 0
67
+ const idleMs = Math.max(1, Number(settings.sleepAfterIdleMinutes) || 3) * 60_000
68
+ const candidates = []
51
69
  for (const binding of listSessionBindings()) {
52
70
  if (!binding.lastTurnAt) continue
71
+ if (binding.sessionGone) continue
53
72
  const activeAt = Math.max(Date.parse(binding.lastTurnAt) || 0, Date.parse(binding.lastInboundAt) || 0, Date.parse(binding.lastAgentIdleAt) || 0)
54
73
  if (now - activeAt < idleMs) continue
55
74
  // Running includes a long-lived asynchronous tool. Waiting is not proof that
@@ -58,6 +77,14 @@ export async function runSleepPass(ctx, { now = Date.now(), log = () => {} } = {
58
77
  if (binding.inbound?.pending?.length) continue
59
78
  if (older(getLastSleepAt(binding.sessionId), binding.lastTurnAt) >= 0) continue
60
79
  if ((Date.parse(binding.sleepRetryAt || '') || 0) > now) continue
80
+ candidates.push({ binding, activeAt })
81
+ }
82
+ // Newest first: a catch-up run after the app was closed should tidy what the user
83
+ // just lived through, not a week-old chat that has already waited this long.
84
+ candidates.sort((a, b) => b.activeAt - a.activeAt)
85
+ const budget = Math.max(1, Math.min(10, Number(limit) || 2))
86
+ let ran = 0
87
+ for (const { binding } of candidates.slice(0, budget)) {
61
88
  if (binding.useLibrary === false) {
62
89
  setLastSleepAt(binding.sessionId)
63
90
  continue
@@ -75,19 +102,26 @@ export async function runSleepPass(ctx, { now = Date.now(), log = () => {} } = {
75
102
  setLastSleepAt(binding.sessionId, binding.lastTurnAt)
76
103
  setSessionBinding(binding.sessionId, { sleepRetryAt: null, sleepAttempts: 0, lastSleepError: null })
77
104
  ran += 1
105
+ } else if (isGoneSessionError(result?.error)) {
106
+ setSessionBinding(binding.sessionId, { sessionGone: true, sessionGoneAt: new Date(now).toISOString(), sleepRetryAt: null, sleepAttempts: 0, lastSleepError: String(result.error) })
107
+ log(`sleep consolidation dropped ${binding.sessionId}: the conversation no longer exists`)
78
108
  } else {
79
109
  const attempts = Number(binding.sleepAttempts || 0) + 1
80
110
  setSessionBinding(binding.sessionId, { sleepAttempts: attempts, lastSleepError: result?.error || 'not settled', sleepRetryAt: new Date(now + Math.min(60, 2 ** Math.min(attempts, 6)) * 60_000).toISOString() })
81
111
  log(`sleep consolidation skipped for ${binding.sessionId}: ${result?.error || 'not queued'}`)
82
112
  }
83
113
  } catch (error) {
114
+ if (isGoneSessionError(error?.message)) {
115
+ setSessionBinding(binding.sessionId, { sessionGone: true, sessionGoneAt: new Date(now).toISOString(), sleepRetryAt: null, sleepAttempts: 0, lastSleepError: String(error.message) })
116
+ continue
117
+ }
84
118
  // A missing service or transient file failure can heal without a new turn.
85
119
  const attempts = Number(binding.sleepAttempts || 0) + 1
86
120
  setSessionBinding(binding.sessionId, { sleepAttempts: attempts, lastSleepError: String(error?.message || error), sleepRetryAt: new Date(now + Math.min(60, 2 ** Math.min(attempts, 6)) * 60_000).toISOString() })
87
121
  log(`sleep consolidation failed for ${binding.sessionId}: ${error?.message || error}`)
88
122
  }
89
123
  }
90
- return { ran }
124
+ return { ran, considered: candidates.length }
91
125
  }
92
126
 
93
127
  // Background global curation: turn the conversations that happened since the last
@@ -178,7 +212,11 @@ export async function runCyclePasses(ctx, options = {}) {
178
212
  const sleep = await runSleepPass(ctx, options)
179
213
  const curate = await runCurationPass(ctx, options)
180
214
  const libraries = runLibraryHousekeeping(ctx, options)
181
- return { shortTerm, sleep, curate, libraries }
215
+ // A library can still be on disk in the removed `session` format when the plugin
216
+ // updated mid-flight. Reads are already normalized, so this only keeps the files in
217
+ // step with the code (see lib/migrate.js); it is cheap and idempotent.
218
+ const migrated = runVisibilityMigrationPass({ log: options.log })
219
+ return { shortTerm, sleep, curate, libraries, migrated }
182
220
  }
183
221
 
184
222
  // DSH has no host scheduler a plugin can register a *global* job with
@@ -201,6 +239,14 @@ export function registerCycle(ctx, { log = () => {} } = {}) {
201
239
  running = false
202
240
  }
203
241
  }
242
+ // One catch-up sweep shortly after load. The sweep timer only exists while the app
243
+ // is open, so a conversation that ended while it was closed used to wait for the
244
+ // next idle window — and the next message always resets that clock, which is how a
245
+ // whole evening could pass without a single consolidation. The delay lets the host
246
+ // finish restoring sessions and agents first; the pass itself is bounded (two
247
+ // sessions) so a long list of stale bindings cannot turn startup into a stampede.
248
+ const startup = setTimeout(() => { void tick() }, STARTUP_SWEEP_MS)
249
+ startup.unref?.()
204
250
  // Property access on an uninjected service throws in cordis, so the probe itself
205
251
  // is guarded; a host without the timer service falls back instead of failing.
206
252
  let scoped = null
@@ -210,9 +256,9 @@ export function registerCycle(ctx, { log = () => {} } = {}) {
210
256
  // clearInterval() on it is a silent no-op and the sweep would keep running
211
257
  // after plugin unload/HMR. Call the disposer.
212
258
  const dispose = scoped.interval(() => { void tick() }, SWEEP_MS)
213
- return () => { try { dispose?.() } catch {} }
259
+ return () => { try { clearTimeout(startup) } catch {} try { dispose?.() } catch {} }
214
260
  }
215
261
  const timer = setInterval(() => { void tick() }, SWEEP_MS)
216
262
  timer.unref?.()
217
- return () => clearInterval(timer)
263
+ return () => { clearTimeout(startup); clearInterval(timer) }
218
264
  }
package/lib/inbound.js CHANGED
@@ -125,6 +125,10 @@ export function ingestInboundBatch({ sessionId, batch, session, ctx, now = Date.
125
125
  setSessionBinding(sessionId, {
126
126
  inbound: { ...before, pending, retryAt: null }, lastInboundAt: at, lastTurnAt: at,
127
127
  lastInboundError: null,
128
+ // A message just came out of this conversation, so it exists. Clears the mark
129
+ // the sleep pass puts on a session the host reported as deleted (a fork/session
130
+ // can be recreated under the same id after a restore).
131
+ sessionGone: false, sessionGoneAt: null,
128
132
  })
129
133
  flushState()
130
134
  if (session) sessions.set(sessionId, session)
@@ -139,7 +143,11 @@ export function noteNativeUserMessages(sessionId, messages, { now = Date.now() }
139
143
  const keys = messages.filter(message => message?.source?.kind === 'user' && message.role === 'user' && typeof message.id === 'string')
140
144
  .map(message => messageKey({ id: message.id, context: binding.memoryContext }))
141
145
  const seen = [...new Set([...before.seen, ...keys])].slice(-MAX_SEEN)
142
- setSessionBinding(sessionId, { inbound: { ...before, seen, pending: before.pending.filter(item => !keys.includes(item.key)) }, lastTurnAt: new Date(now).toISOString() })
146
+ setSessionBinding(sessionId, {
147
+ inbound: { ...before, seen, pending: before.pending.filter(item => !keys.includes(item.key)) },
148
+ lastTurnAt: new Date(now).toISOString(),
149
+ sessionGone: false, sessionGoneAt: null,
150
+ })
143
151
  }
144
152
 
145
153
  export function hasPendingInbound(sessionId) {