Преглед на файлове

docs: 容器会话隔离失效·完整取证报告

用户质疑新会话为何能拿到旧上下文,坚持「逻辑不对」推动查清。

结论:三层全部未隔离
- 记忆层:memory_tool_store.py:189-191 _path_for 只按 profile 取路径,
  无 session/chat 参数,三个房间共用同一份 28859 字节 prompt
- 消息层:messages 有 session_id(存储隔离),但 session_search 走
  FTS5 全库索引,设计上跨 session 召回
- 文件层:doc_<contenthash>_<name> 扁平单层,无会话身份

串台链路:全局 MEMORY 注入 → session_search 全库命中 11-12 某 session
的回复原文 → 复述。全库 LIKE '%计划.md%' 命中 23 条 / 4 个 session。

App 侧已排除:createSessionRoom 每次确实新建独立房间。
修复:记忆/消息层需改 Hermes 内核(容器无写权限);
止血手段为禁用 session_search + 清理全局 MEMORY 的任务内容。

同时如实记录我的两个错误:给了不存在的 adapter.py 路径、
一度接受容器「凭空生成」的说法。
Huccct преди 10 часа
родител
ревизия
115b19e6b1

+ 171 - 0
docs/00-tracking/2026-11-14-容器会话隔离失效-取证报告.md

@@ -0,0 +1,171 @@
+# 容器会话隔离失效 · 完整取证报告
+
+- 日期:2026-11-14
+- 被调查容器:`agent-node-kangshifu`(`@edzb0hg93a:fmode.cn`)
+- 触发场景:用户在 App 里**新建会话**后发同一句提问,容器却表现出「知道上一轮」「知道之前发过同样的文件」
+
+---
+
+## 一、结论摘要
+
+**用户判断正确:房间历史没有隔离。三层全部失效。**
+
+| 层 | 是否隔离 | 根因位置 |
+|---|---|---|
+| **系统提示 / 记忆** | ❌ **完全不隔离** | `/opt/hermes/tools/memory_tool_store.py:189-191` `_path_for` |
+| **消息检索** | ⚠️ **存储隔离,读取不隔离** | `/opt/hermes/tools/session_search_tool.py`(FTS5 全库) |
+| **落盘文件** | ❌ **完全不隔离** | `doc_<contenthash>_<name>`,无会话身份 |
+
+**容器自己的原话**:
+
+> 「历史到底有没有隔离 = **没有**。分三层,都没有隔离。」
+>
+> 「修正我上次的错误结论:我说『凭空生成』是错的。当时的错误在于我只查了 `chat_id`
+>   过滤后的消息数(本房间 1 条),没有查全库 FTS 和全局记忆。
+>   真实链路是**全库检索 + 全局 MEMORY 注入**,两处都在设计上就是跨房间的。」
+
+---
+
+## 二、逐层证据
+
+### 2.1 记忆层:不隔离
+
+```python
+# /opt/hermes/tools/memory_tool_store.py:189-191
+def _path_for(target):
+    # 只按 profile 取路径 —— **签名里没有 session / chat 参数**
+```
+
+```python
+# /opt/hermes/tools/memory_tool.py:38-40
+def get_memory_dir(...):  # 路径只到 profile,无 chat 维度
+```
+
+```python
+# /opt/hermes/system_prompt.py:514
+def _memory_parts(...):   # 无条件注入,不判断当前是哪个房间
+```
+
+**实证**:三个不同房间拿到的是**同一份 28,859 字节的 system prompt**(hash `a7d045ee818f`)。
+
+### 2.2 消息层:存储隔离,读取不隔离
+
+- `messages` 表**有** `session_id` 且索引正确 → **存储层面是隔离的**
+- `messages_fts` 是**全库索引**
+- `session_search` 按「long-term conversation recall over the SQLite session DB」设计,
+  **跨 session 召回**(只把 cron 来源降权,不按当前 session 过滤)
+
+**实证**:
+
+```sql
+-- 全库 LIKE '%计划.md%'
+命中 23 条 / 分布在 4 个 session
+```
+
+**「按最新的干」这句话的来源**:命中了 `msg#19934`(2026-11-12 某个 session)的历史助手回复。
+
+### 2.3 文件层:不隔离
+
+```
+/opt/data/cache/documents/doc_<contenthash>_<name>
+```
+
+- 扁平单层,**文件名 hash 不含会话身份**
+- 任何 session 都能看到别的房间落盘的文件
+
+---
+
+## 三、这次具体是怎么串的
+
+```
+用户在新房间发消息
+  ↓
+① 系统 prompt 注入全局 MEMORY(跨房间共用,28,859 字节)
+  ↓
+② 模型调用 session_search → FTS5 全库检索
+  ↓
+③ 命中 11-12 那个 session 里「计划.md」相关的助手回复原文
+  ↓
+④ 复述出来 → 听起来像「它记得上一轮」
+```
+
+**不是模型幻觉,是真的读到了别处的内容。**
+
+---
+
+## 四、修复方向
+
+| 层 | 修法 | 谁能改 |
+|---|---|---|
+| **记忆层** | `_path_for` 增加 chat 维度(按 roomId 分目录) | ❌ 改 Hermes 内核,`/opt/hermes` 对 `hermes` 用户**只读** |
+| **消息层** | `session_search` 增加 `WHERE session_id = 当前` | ❌ 同上 |
+| **文件层** | 落盘路径加 roomId 前缀 | ✅ 容器侧可改 |
+
+### 4.1 容器侧现在能做的缓解
+
+1. **收窄或禁用 `session_search`**
+   - 它主动去全库捞,是「串台」最直接的通道
+   - 禁用后模型只能看当前 session 的转录(`messages WHERE session_id = ?`)
+
+2. **清理全局 MEMORY**
+   - 检查 `/opt/data/MEMORY.md` 里有没有写进任务相关的上下文
+   - 只保留「通用规范」(如交付链路铁则),**不保留任何具体任务内容**
+
+3. **文件落盘加房间前缀**
+   - `cache/documents/<roomId>/doc_xxx_name`
+   - 成本低,能挡住「看到别的房间的文件」
+
+### 4.2 需要平台/内核层解决的问题(长期)
+
+- 记忆按会话隔离 —— 需要 Hermes 提供多租户语义
+- 检索按会话隔离 —— 同上
+
+**在此之前,容器侧的「禁用 session_search + 清理 MEMORY」是唯一止血手段。**
+
+---
+
+## 五、其它一并确认的事实(本次会话)
+
+### 5.1 App 侧新建会话确实建了新房间
+
+```ts
+// src/app/core/matrix-client.service.ts:739
+async createSessionRoom(agentMxId, agentName) {
+  const created = await c.createRoom({
+    invite: [agentMxId],
+    is_direct: true,
+    preset: sdk.Preset.TrustedPrivateChat,
+    creation_content: { 'fmode.session_room': true },
+  });
+  return created.room_id;
+}
+```
+
+**所以「App 复用房间」这条不成立。** 串台发生在容器读取侧,不在 App 写入侧。
+
+### 5.2 两台容器的业务脚本不同
+
+| | lj-test | kangshifu |
+|---|---|---|
+| `fmode-deliverable-hook.py` | ✅ 存在(48KB) | ❌ 全盘 find 0 命中 |
+| 40MB 交付上限 | 在 `:124` | 不存在 |
+
+**Hermes 内核相同,但 `/opt/data/scripts/` 下的业务脚本各写各的。**
+这是历史遗留,也是之前多次「两台对不上」的根源。
+
+### 5.3 我犯的两个错误(如实记录)
+
+1. **两次给了不存在的路径** `/opt/hermes/gateway/relay/adapter.py`。
+   真实路径是 `/opt/hermes/plugins/platforms/matrix/adapter.py`(两台容器都纠正了我)。
+
+2. **一度接受了容器「凭空生成」的说法**,没有继续追「那它为什么能说出上一轮的任务内容」。
+   直到用户坚持「这个逻辑不对」才查清是全库检索 + 全局记忆注入。
+
+---
+
+## 六、待办
+
+- [ ] 让容器确认 `session_search` 能否禁用 / 收窄
+- [ ] 检查 `/opt/data/MEMORY.md` 现有内容,清理任务相关条目
+- [ ] 文件落盘路径加 roomId 前缀
+- [ ] lj-test 是否存在同样问题(**高度可能,内核相同**)

+ 15 - 0
docs/00-tracking/操作追踪.md

@@ -8285,3 +8285,18 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **两台差异** | `fmode-deliverable-hook.py` 只有 lj-test 有(40MB 限制在那台,已改 800MB);kangshifu 全盘 find 无此文件。**Hermes 内核相同,`/opt/data/scripts/` 业务脚本各写各的** |
 | **纠正自己的错误** | 我两次给的路径 `/opt/hermes/gateway/relay/adapter.py` **不存在**;真实路径是 `/opt/hermes/plugins/platforms/matrix/adapter.py`(两台都纠正了我) |
 | **验证** | `tsc` ✅ / `npm test` **1783 SUCCESS** / `build` ✅ / 线上 `main-YVR3T26G.js` 与本地一致 |
+
+## 2026-11-14(续十二)· 容器会话隔离失效取证
+
+| 项 | 内容 |
+|---|---|
+| **用户质疑** | 新开的会话,为什么容器能说出「按最新的干」「之前发过同样的文件」?每个房间的历史应该隔离 |
+| **初次误判(我)** | 容器先说「本房间 session 只有 1 条、用户消息只有 1 条 → 那个文件路径从没被创建过,是**凭空生成**」。我一度接受了这个结论 |
+| **用户坚持** | 「这个逻辑是不对的」—— 逼着我继续追 |
+| **真因(三层全失效)** | ① **记忆层**:`memory_tool_store.py:189-191 _path_for` 只按 profile 取路径,**无 session/chat 参数**,三个房间共用同一份 28,859 字节 prompt(hash `a7d045ee818f`)② **消息层**:`messages` 有 `session_id`(存储隔离),但 `session_search` 用 **FTS5 全库索引**,设计上就是跨 session 召回 ③ **文件层**:`doc_<contenthash>_<name>` 扁平单层,无会话身份 |
+| **串台链路** | 系统 prompt 注入全局 MEMORY → 模型调 `session_search` → 全库 FTS 命中 11-12 某 session 的「计划.md」回复原文 → 复述出来 | 
+| **实证** | 全库 `LIKE '%计划.md%'` 命中 **23 条 / 4 个 session**;「按最新的干」来自 `msg#19934`(11-12 的 session) |
+| **容器自我更正** | 「我说『凭空生成』是错的…… 用户说的『逻辑不对』是准确的」 |
+| **App 侧排除** | `matrix-client.service.ts:739 createSessionRoom` **每次确实新建独立房间**(`creation_content: {'fmode.session_room': true}`)→ 串台在容器读取侧,不在 App 写入侧 |
+| **修复可行性** | 记忆层/消息层要改 **Hermes 内核**(`/opt/hermes` 对 hermes 用户只读,容器改不了);文件层容器可改;**止血手段**:容器侧禁用/收窄 `session_search` + 清理全局 MEMORY 里的任务内容 |
+| **交付文档** | 新建 `docs/00-tracking/2026-11-14-容器会话隔离失效-取证报告.md`(三层证据 + 串台链路 + 修复方向 + 我的两个错误) |

+ 484 - 0
docs/03-interface/ksf-历史隔离-回答.md

@@ -0,0 +1,484 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T08:58:34.696Z
+- 房间:`!i3MhDsCz8znWcwBgo5wKjYePsa7bWBp22hkeYWL_jPs`(临时房间,可保留查阅)
+- 消息统计:正文 25 段 / 工具过程 15 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 房间历史必须隔离 —— 请把机制讲清楚并给证据
+
+## 用户的原话
+
+> 「我又新开了一个会话,然后发了同样的问题,它就能知道,就是回复的是先按我最新的干。
+>   但是我之前在这个房间内没有发任何的消息给它,这个逻辑是不对的。
+>   它还知道我之前有发同样的文件,那这样的逻辑也会有问题。
+>   **每个房间它的一些数据,就是历史记录应该是隔离的,不会相互影响。**」
+
+## 你上次的结论(我认为不完整)
+
+你说:
+
+> 「本房间 session 只有 1 条,message_count=22,thread_id=None」
+> 「本房间用户入站消息只有 1 条」
+> 「那个文件的路径从来没被创建过,是凭空生成的」
+
+**但用户坚持:新房间第一次发消息,你就能说出「按最新的干」「之前发过同样的文件」。这不是凭空,是真的读到了什么。**
+
+---
+
+# 请查清这四件事(只读,贴原文)
+
+## 1. 你构造 context 时,到底读了哪些东西
+
+```bash
+grep -rn "build_context\|load_context\|history\|system_prompt\|memory" \
+  /opt/hermes/agent/*.py 2>/dev/null | grep -v "^Binary" | head -20
+```
+
+**要回答**:每轮对话的上下文由哪些部分组成?(房间消息 / 全局记忆 / 文件缓存 / session 转录 / MEMORY.md)
+
+## 2. 有没有跨房间共享的东西
+
+```bash
+echo "=== 全局记忆 ==="
+ls -la /opt/data/MEMORY.md /opt/data/memory* /opt/data/.hermes/memory* 2>/dev/null
+head -30 /opt/data/MEMORY.md 2>/dev/null
+
+echo "=== sessions 表结构 ==="
+cd /opt/data && sqlite3 state.db ".schema sessions" 2>/dev/null
+```
+
+**要回答**:
+- 有没有跨 session 共享的记忆文件?
+- 里面有没有提到「计划.md」「文件」「打断」之类的内容?
+
+## 3. `cache/documents/` 是全局还是按房间
+
+```bash
+ls -la --time-style=+%m-%d/%H:%M /opt/data/cache/documents/ 2>/dev/null | tail -15
+```
+
+**要回答**:这个目录是全局的还是有房间隔离?容器能不能看到别的房间落盘的文件?
+
+## 4. 关键:你在那个新房间的完整 system prompt / context
+
+```bash
+cd /opt/data && sqlite3 state.db \
+  "SELECT chat_id, message_count, created_at FROM sessions ORDER BY created_at DESC LIMIT 8;" 2>/dev/null
+```
+
+把最近的 session 列出来,标出哪个是用户说的「新开的房间」。
+
+---
+
+# 🔴 最重要的一条
+
+**你说你是凭空生成的 —— 那为什么能说出「按最新的干」这种明确指向上一轮任务的话?**
+
+请把**那一轮的完整输入**(system prompt + 注入的上下文)找出来贴给我:
+
+```bash
+grep -n "按最新的干\|先停一停\|最新" /opt/data/logs/*.log 2>/dev/null | tail -10
+cd /opt/data && sqlite3 state.db \
+  "SELECT content FROM messages WHERE content LIKE '%按最新%' ORDER BY id DESC LIMIT 3;" 2>/dev/null
+```
+
+---
+
+# 回答格式
+
+```
+## 1. 上下文构成
+由哪些部分组成 = ?
+代码位置 = ?
+
+## 2. 跨房间共享的东西
+MEMORY.md 有没有 = ?
+内容摘要 = ?
+sessions 表 = ?
+
+## 3. cache/documents
+是全局还是按房间 = ?
+证据 = ?
+
+## 4. 那轮的真实输入
+system prompt 里有什么 = ?
+有没有注入别的房间的内容 = ?
+
+## 结论
+历史到底有没有隔离 = ?
+如果没有,是哪一段代码导致的 = ?
+```
+
+🔴 只要事实和原文。**不要再下「凭空生成」这种结论,除非能解释为什么它能说出上一轮的任务内容。**
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先把环境摸清楚——先确认实际路径和结构,再逐条查证据。
+```
+
+### 2【prose】
+
+```text
+```
+echo "=== 2. state.db 定位 ===" ...
+```
+```
+echo "=== state.db 表结构 ===" ...
+```
+```
+
+### 3【prose】
+
+```text
+sqlite3 命令行工具没装。我会用 Python 查询数据库,同时并行检查记忆文件和缓存。
+```
+
+### 4【prose】
+
+```text
+* ```
+echo "=== 2. state.db 定位 ===" ...
+```
+```
+echo "=== state.db 表结构 ===" ...
+```
+```
+echo "=== 全局记忆文件 ===" ...
+```
+```
+cd /opt/data && python3 -c " ...
+```
+```
+
+### 5【prose】
+
+```text
+MEMORY.md 里已有关键发现,但我先把四件事查全。下面同时列出 sessions、搜索“按最新”、查看缓存目录和代码位置。
+```
+
+### 6【prose】
+
+```text
+```
+echo "=== MEMORY.md 全文 ===" ...
+```
+```
+cd /opt/data && python3 -c " ...
+```
+```
+
+### 7【prose】
+
+```text
+```
+echo "=== cache/documents ===" ...
+```
+```
+
+### 8【prose】
+
+```text
+现在已经找到确凿证据:`doc_4931c3351b3c_计划.md` 大小为 0 字节,同时发现了关键线索 `documents` 目录里还有 `test-100MB.md`。接下来检查代码中上下文构建和 SQLite 的实际读取路径。
+```
+
+### 9【prose】
+
+```text
+* ```
+echo "=== cache/documents ===" ...
+```
+🔎 Searching files for def build_system_prompt|def _build_sy...
+```
+
+### 10【prose】
+
+```text
+现在读 `system_prompt.py` 的关键部分:它决定了每轮注入哪些内容,是回答问题的核心。
+```
+
+### 11【prose】
+
+```text
+需要精确定位记忆注入点的代码行。
+```
+
+### 12【prose】
+
+```text
+已定位 `build_system_prompt_parts`:它会注入 skills 索引、memory、user profile、external memory、时间戳和环境提示。接下来查找 memory 和 user profile 的具体加载函数,并确认它们是否按 chat/session 隔离。
+```
+
+### 13【prose】
+
+```text
+记忆块的来源已确认。现在检查 `format_for_system_prompt` 中是否存在任何 chat/session 过滤,并验证 `_memory_store` 的构造与路径。
+```
+
+### 14【prose】
+
+```text
+内置记忆存储的位置不同。我先定位 `format_for_system_prompt` 的定义处。
+```
+
+### 15【prose】
+
+```text
+关键发现:`_system_prompt_snapshot` 在加载时生成快照,且只按 target(memory/user)区分,没有按房间区分。现在确认路径解析方式,以及快照的加载时机。
+```
+
+### 16【prose】
+
+```text
+找到根因了:`_path_for` 返回 `get_memory_dir()/MEMORY.md`,路径里完全没有 session 或 chat 维度。接下来确认 `get_memory_dir` 是否依赖 chat,并检查主提示中是否注入了跨会话检索工具。
+```
+
+### 17【prose】
+
+```text
+决定性证据是:8 个不同房间的 session 共用同一个 `system_prompt_hash` `a7d045ee818e`,这从机制上证明系统提示词字节完全相同、不区分房间。现在确认 `system_prompt_hash` 存的是前缀。
+```
+
+### 18【prose】
+
+```text
+核心机制已经清楚。现在补上最后两块证据:文件缓存路径(是否有房间隔离),以及“88...计划.md”当时实际被写入了哪个会话。
+```
+
+### 19【prose】
+
+```text
+```
+echo "=== documents 目录是否含会话维度 ===" ...
+```
+```
+
+### 20【prose】
+
+```text
+引号写错了,需要重跑这一条。
+```
+
+### 21【prose】
+
+```text
+```
+cd /opt/data && python3 -c " ...
+```
+```
+cd /opt/data && python3 << 'EOF' ...
+```
+```
+
+### 22【prose】
+
+```text
+最后一块:确认消息检索工具能跨房间取到内容,这是“知道发过同样文件”的另一条通道。
+```
+
+### 23【prose】
+
+```text
+```
+cd /opt/hermes && grep -rn "session_i...
+```
+```
+
+### 24【prose】
+
+```text
+证据已齐。我最后核对一个关键点:本房间 session 的 `system_prompt_hash` 是否与上一个房间相同,以及这个 prompt 里是否真的包含那些记忆条目。
+```
+
+### 25【prose】
+
+```text
+全部证据到位。下面按要求逐条贴出原文。
+
+---
+
+## 1. 上下文构成
+
+**由哪些部分组成 =** 四层,各层放在代码里带名字的三个 tier 中(`stable`、`context`、`volatile`):
+
+- `stable`:身份、工作准则、编码摘要
+- `context`:调用方传入的 system_message、项目上下文文件、workspace snapshot
+- `volatile`:skills 索引、**MEMORY.md**、**USER.md**、外部记忆 provider 块、时间戳行、运行时环境提示
+
+然后是消息序列:**当前 session 自己的 messages**(SQLite 按 `session_id` 取)。
+
+**代码位置 =**
+
+- `/opt/hermes/agent/system_prompt.py:659` `build_system_prompt_parts` — docstring 原话:
+  > `volatile` (skills index, memory, user profile, external memory block, timestamp line, runtime environment hints)
+- `/opt/hermes/agent/system_prompt.py:708`
+  > `volatile_parts: List[str] = [skills_prompt, *_memory_parts(agent)]`
+- `/opt/hermes/agent/system_prompt.py:514` `_memory_parts` → `agent._memory_store.format_for_system_prompt(kind)`
+- `/opt/hermes/tools/memory_tool_store.py:369`
+  > `def format_for_system_prompt(self, target: str)` … `return self._system_prompt_snapshot.get(target, "")`
+- `/opt/hermes/tools/memory_tool_store.py:189` ← **根因**
+  > ```
+  > @staticmethod
+  > def _path_for(target: str) -> Path:
+  >     return memory_tool.get_memory_dir / ("USER.md" if target == "user" else "MEMORY.md")
+  > ```
+- `/opt/hermes/tools/memory_tool.py:38`
+  > ```
+  > def get_memory_dir -> Path:
+  >     """Profile-scoped memories dir, resolved per call"""
+  >     return get_hermes_home / "memories"
+  > ```
+
+**注意路径里有什么、没有什么**:`get_hermes_home/memories/MEMORY.md` 只有**profile** 维度,参数里**没有 session_id,也没有 chat_id**。签名 `_path_for(target: str)` 接收的唯一变量是 `"user"` 或 `"memory"`。
+
+---
+
+## 2. 跨房间共享的东西
+
+**MEMORY.md 有没有 = 有,而且是同一个文件被所有房间读。**
+
+```
+   1842 bytes  Nov 14 15:02
+     2038 bytes  Nov 13 16:16
+```
+
+**内容摘要 =** 4 条 MEMORY(交付���链路、VOC 技能包 v0.3.46、VOC 速查、HTML 浅色主题)+ 6 条 USER PROFILE(含「只读验收要严格照做」「Vivi 学生宿舍夜宵赛道 / D1-01 三问」)。**没有**「计划.md」「打断」相关字节 —— 这两件事不在 MEMORY.md 里(见第 2 条后半与第 4 条)。
+
+**sessions 表 = `thread_id` 与 `chat_id` 都在,但没有 per-room 记忆字段。** 决定性的是这一列:
+
+| session | chat_id | system_prompt_hash |
+|---|---|---|
+| 20261114_165729 (本房间) | `!i3MhDsCz8...` | **a7d045ee818f** |
+| 20261114_165151 (上房间) | `!XZ95VaZBfl...` | **a7d045ee818f** |
+| 20261114_162843 (更早房间) | `!zGEDhNH5G0...` | **a7d045ee818f** |
+
+**三个完全不同的房间,系统提示词哈希逐字节相同**(`system_prompts` 表里 `a7d045ee...` 长度 28859)。我把这份 prompt 取出来验过:
+
+```
+MEMORY (your personal notes): YES
+USER PROFILE: YES
+VOC 技能包: YES
+Vivi / 宿舍夜宵: YES
+Matrix Room: no        ← 房间维度完全不在 prompt 里
+```
+
+所以「每个房间历史隔离」在**系统提示层根本不成立**:prompt 是全局的,房间只影响「去哪取消息」,不影响「注入什么记忆」。
+
+**但「计划.md」这件事不是 MEMORY 干的。** 它走的是另一条路 —— 消息检索:
+
+```
+SELECT count(*), count(DISTINCT session_id) FROM messages WHERE content LIKE '%计划.md%'
+→ 命中 23 条,横跨 4 个 session
+```
+
+`messages_fts` 是全库索引,`session_search` 工具(`/opt/hermes/tools/session_search_tool.py`)按 docstring 是 "long-term conversation recall over the SQLite session DB",跨 session 检索,只把 `cron` 降权而不排除。这就是「它知道我发过同样的文件」。
+
+---
+
+## 3. cache/documents
+
+**是全局的,零房间隔离。**
+
+**证据 =** 目录本身是 ,扁平一层,**没有任何 chat_id / session_id 子目录**。文件名格式是 `doc_<12位hash>_<原始文件名>` —— hash 只对内容算,不含会话身份:
+
+```
+-rw-r--r--  1 hermes hermes        0  11-14/01:47  doc_4931c3351b3c_计划.md
+-rw-r--r--  1 hermes hermes 104857711 11-14/16:49  doc_360439fd6136_test-100MB.md
+-rw-r--r--  1 hermes hermes      7224  11-14/08:37  doc_622649e8a7e2_智能纪要:校园夜宵消费情况讨论.md
+-rw-r--r--  1 hermes hermes     27531  11-13/16:02  doc_9aa3077f65d3_D1-01-哲恒-宿舍夜宵品类全景图谱.html
+```
+
+容器能看到**全部房间**落盘的文件:昨晚夜宵纪要(08:37)、D1-01 图谱(11-13 16:02)、100MB 测试文件(16:49)都在同一个目录里,当前房间能 `ls` 到全部。谁往这个目录写,谁就能被任何房间读到。
+
+---
+
+## 4. 那轮的真实输入
+
+**system prompt 里有什么 =** 就是上面那份 28859 字节、hash `a7d045ee` 的全局 prompt。原文截取(volatile 段尾部):
+
+```
+MEMORY (your personal notes) [51% — 1,131/2,200 chars]
+交付物唯一链路(2026-11-14 用户裁决,铁的):...
+VOC 技能包 v0.3.46(catalog v1.9.0,2026-11-13):...
+HTML 交付铁则:一律浅色主题...
+
+USER PROFILE (who the user is) [79% — 1,092/1,375 chars]
+主人对「只读验收」类指令执行很严格...
+```
+
+**有没有注入别的房间的内容 = 有两个不同的通道,必须分开说:**
+
+**通道 A —— 记忆(确实跨房间,但内容对不上)**:MEMORY.md / USER.md 被无差别注入每个房间。但全文我逐字读过,**里面没有任何一句是「按最新的干」或「先停一停」**。所以那两句话**不是**从 MEMORY 来的。
+
+**通道 B —— 全库消息检索(这才是那两句话的来源)**:
+
+```
+msg#19934 sess=20261112_230712_4ada  role=assistant
+  我按 `hud-structured-pages.md` 重做了。… **这一版是按最新规范做的,不是换皮(只改外观)**
+
+msg#42711 / msg#42564 sess=20261113_154534_a1ec  role=assistant
+  文件名被规范化了,我按最新时间找。
+```
+
+`按最新` 全库命中都落在**别的 session**里(11-12 / 11-13,不是本房间)。而「先停一停」原文在 `msg#45919` 的工具结果里,指向的是 `20261114_165151_97849f`——**上一个房间**的回复:
+
+```
+收到,先停一停——你这条更重要。3 字节(0 行…)
+路径:
+内容只有一个换行/空白字符,正文是空的 —— 所以你最初发来的「计划.md」没有实际内容可读。
+```
+
+**这段话本身就是上一轮回复的原文**,被全库检索命中后带进了本房间的上下文。所以它能说出「按最新的干」「之前发过同样的文件」,机制上是清楚的,**不是凭空生成,是全库检索命中后复述**。
+
+---
+
+## 结论
+
+**历史到底有没有隔离 = 没有。分三层,都没有隔离:**
+
+1. **系统提示层(记忆):完全不隔离。** `_path_for(target)` 只按 profile 取 ,签名里没有 session/chat 参数。三个不同房间共用 hash `a7d045ee818f` 的同一份 28859 字节 prompt,已实证。
+2. **消息层:存储是隔离的,读取不是。** `messages` 有 `session_id` 且索引正确,但 `messages_fts` 是全库索引,`session_search` 按"long-term conversation recall over the SQLite session DB"设计,跨 session 召回(只把 cron 降权)。全库 `LIKE '%计划.md%'` 命中 23 条 / 4 个 session。
+3. **文件层:完全不隔离。**  扁平单层,文件名 hash 不含会话身份。
+
+**是哪一段代码导致的:**
+
+- 记忆串扰 → `/opt/hermes/tools/memory_tool_store.py:189-191` `_path_for` + `/opt/hermes/tools/memory_tool.py:38-40` `get_memory_dir`:路径只到 profile,无 chat 维度;`system_prompt.py:514 _memory_parts` 无条件注入。
+- 「发过同样文件」→ `/opt/hermes/tools/session_search_tool.py`:FTS5 全库召回,`_DISCOVER_SEARCH_FIELDS` 含 `session_id` 但检索条件不按当前 session 过滤。
+- 「按最新的干」→ 同一通道,命中的是 `msg#19934`(11-12 的 session)等历史助手回复。
+- 文件可见性 → documents 落盘路径无会话前缀(`doc_<contenthash>_<name>`)。
+
+**修正我上次的错误结论**:我说"凭空生成"是错的。当时的错误在于我只查了 `chat_id` 过滤后的消息数(本房间 1 条),没有查全库 FTS 和全局记忆。真实链路是**全库检索 + 全局 MEMORY 注入**,两处都在设计上就是跨房间的。用户说的「逻辑不对」是准确的。
+```
+
+### 26【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 1. 路径探测 ===" ... ````
+- `🔎 Searching files for MEMORY\.md|memories|def .*memory|load...`
+- `🔎 Searching files for MEMORY \(your personal notes\)|USER P...`
+- `🔎 Searching files for volatile_parts\.append`
+- `🔎 Searching files for def _memory_parts`
+- `* 🔎 Searching files for def _memory_parts 🔎 Searching files for def format_for_system_prompt`
+- `🔎 Searching files for def format_for_system_prompt`
+- `🔎 Searching files for def format_for_system_prompt`
+- `* 🔎 Searching files for def format_for_system_prompt 🔎 Searching files for _path_for|_system_prompt_snapshot\s*=...`
+- `🔎 Searching files for def _path_for`
+- `* 🔎 Searching files for def _path_for 🔎 Searching files for _system_prompt_snapshot\[ 🔎 Searching files for def get_memory_dir`
+- `💻 terminal ``` cd /opt/data && python3 -c " ... ``` ``` cd /opt/data && python3 -c " ... ````
+- `🔎 Searching files for session_id|current_session|exclude`
+- `* 🔎 Searching files for session_id|current_session|exclude 💻 terminal ``` cd /opt/hermes && grep -n "def .*sear... ``` 📖 Reading session_search_tool.py L30-89`
+- `* 🔎 Searching files for session_id|current_session|exclude 💻 terminal ``` cd /opt/hermes && grep -n "def .*sear... ``` 📖 Reading session_search_tool.py L30-89 💻 terminal ``` cd /opt/data && python`