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 +2 -1
- package/README.md +4 -3
- package/README.zh.md +3 -2
- package/docs/memory-semantics.md +44 -0
- package/docs/troubleshooting.md +36 -1
- package/lib/client.js +72 -50
- package/lib/cycle.js +53 -7
- package/lib/inbound.js +9 -1
- package/lib/inject.js +2 -2
- package/lib/memory.js +41 -34
- package/lib/migrate.js +119 -0
- package/lib/private.js +97 -6
- package/lib/prompts/private-policy.coding.md +1 -1
- package/lib/prompts/private-policy.md +1 -1
- package/lib/prompts/private-policy.mixed.md +1 -1
- package/lib/prompts/private-policy.rp.md +1 -1
- package/lib/prompts/system-guide.md +1 -1
- package/lib/remote-summary.js +5 -1
- package/lib/rpc.js +13 -14
- package/lib/state.js +26 -9
- package/lib/tools.js +8 -5
- package/lib/visibility.js +63 -0
- package/package.json +1 -1
- package/scripts/migrate-visibility.mjs +94 -0
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.
|
|
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.** `
|
|
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
|
-
- **秘密不进自动召回。** `
|
|
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
|
|
package/docs/memory-semantics.md
CHANGED
|
@@ -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
|
---
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
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;
|
|
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',
|
|
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)
|
|
2964
|
-
|
|
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', '
|
|
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
|
-
//
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
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
|
|
4393
|
-
//
|
|
4394
|
-
const preference =
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
4422
|
-
|
|
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 || (
|
|
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
|
-
|
|
4525
|
-
|
|
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-
|
|
4528
|
-
|
|
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
|
-
|
|
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) ||
|
|
50
|
-
|
|
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
|
-
|
|
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, {
|
|
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) {
|