Repository navigation
Description
目标
侧边栏新增「ta的日记」页:一个 Agent 一本日记,导航栏按 Agent 名切换;手绘日记本风格,Agent 可自选纸样与标题;素材取全局记忆(该 Agent 在各会话里留下的痕迹),而非只读当前会话转录。
前置(2026-11-08 实测校正,已含 rebase 后的分支)
原先写「前置 = #606 / PR #609」不完整,日记本体根本不在 main 上。真实顺序三步:
第 1 步(已就绪,等落地):feat/diary-v013-rebased,worktree .worktrees/qq-diary,HEAD 360bfed。
- 基于
7461768(当前 origin/main,Merge PR feat(panel): 用户设置块产出段边界,面板可按段读 #601)重做后的三笔:a82e03d日记 v0.13(35 files, +3530)→d698d36页签入口 →360bfedM.O. 下架。 - 相对 origin/main 35 files, +3681/−40,工作区干净,
git rev-parse --verify origin/feat/diary-v013-rebased报fatal: Needed a single revision→ 未 push、无上游、无 PR。 src/diary/8 个文件(auto / compose / dir / export / index / qq-inbox / rollover / session-provider)只在它上面;git ls-tree -r origin/main -- dsh-mneme/src/diary为空。lib/diary/同名 8 个文件与之逐 blob 一致(8/8 OK)。- 验证:
check-sync103 个文件一致(比 main 多 7,正是日记那批);全量 2930 tests / 2929 pass / 0 fail / 1 skipped。 - 原始分支
feat/qq-diary(父041e8f3,落后 main 9 提交)从未 push,origin/feat/qq-diary不存在。
第 2 步:#609(#606 bySession)合入。git grep -c bySession origin/main -- dsh-mneme/src → exit 1、零命中。没有它,agentId 只剩标题前缀一条腿。
第 3 步:从「main + v0.13 + #609」切 feat/diary-by-agent。
若从当前 main 直接切分支,src/diary/ 整目录不存在,写出来是一份孤儿实现,rebase 时全废。
rebase 冲突面复核:git merge-tree --write-tree origin/main feat/qq-diary 只报一个冲突文件 dsh-mneme/CHANGELOG.md。main 自 041e8f3 以来只动 4 个文件(CHANGELOG +15、src/user-settings.js、lib/user-settings.js、test/user-settings-segments.test.js +528),与 src/diary/ 零交集。
CHANGELOG 解冲突的隐蔽坑(不是代码问题,测试也不管):第一轮解完,main 的 #600 段化条目被并进 ## 🎨 面板 节下,还多出一个空「面板」节。重做后已复原:[Unreleased] → 🆕 新增(日记 v0.13 + #600)→ 🎨 面板(M.O. 下架)→ [0.12.0],diff 是 +20 行、0 删除。这类错位不报错但会让后来的人以为 #600 是面板改动——以后 rebase 值得专门看一节归属。
本 issue 不动 #603 的 worktree。
决定性的四条实测事实
这几条都从 live 库(约 273MB)现查,不是推断。
1. sessionId 不是 Agent 的键
按 memories.source 里的 sessionId 分区是错的。实测同一个 bot 名下 24 个会话(13 个「Amarre · 记忆研究」+ 11 个「Amarre」),另一个 bot 4 个;近三天每天 23/36/27 个不同 session 源。按 sessionId 分区,一个 bot 会散成 N 本,与「一个 Agent 一本」正相反。
正确的分区键来自宿主标题事件。ctx.sessionProjections 是注册进 cordis 服务表的正式服务,插件可以直接注入(宿主自己的 dsh-action-cards、AgentLoop、agentPresets 都这么做)。但 projection 里只有标题字符串、没有 source:titleProjectionDefinition.apply 是 event.type === "session/title" ? event.data.title : state。要判「用户手改」还是「模型自动起」,必须读事件日志 session.snapshotEvents().findLast(e => e.type === "session/title").data.source。
实测 707 个会话目录:source: "user" 的标题全是「Bot 名 · 主题」形(Amarre · 记忆研究 / Ephor · 记忆研究 / Loom …),前缀无一例外;source: "fallback" 是整句(「可以生图吧?……」「@专家团:……」)。所以规则是:source === "user" 才取「·」前缀,且前缀必须命中已知 bot 名单;fallback 落 _unnamed。
2. source 有两种形态,且零重叠
session:session-<uuid>6492 条- 裸
session:<uuid>1146 条
同一 uuid 不会两种并存(INTERSECT = 0)。今天 top 10 会话里有 3 个只有裸形,只用 LIKE 'session:session-<id>%' 会各漏 7–8 条,且不报错。正确写法 source IN ('session:session-<uuid>', 'session:<uuid>')。另有 97 条别形(session:onebot-private-* 95 条 + 2 条自由文本脏数据)。
3. 日界必须按本地日,差一倍
created_at 存 UTC。2026-11-08 当天 session: 行:UTC 裸判 105 条,本地日(datetime(x,'+8 hours'))210 条。今天按完整口径是 created 242 / 修正后 235。SQL 必须 datetime(x, '+8 hours'),不能用 date('now') 也不能取 created_at 前 10 位。
occurred_at 空值占活跃行的 54.4%(4429 条),COALESCE 是必需项。
4. occurred_at 有一批整 8 小时的偏移
偏移分钟直方图是尖峰不是散布:偏移 0 801 条、-480 705 条、-481 73 条,[-490,-470] 全带命中 854 条(活跃口径;不过滤 archived/forgotten 则 1085)。这 854 行全部是 .000Z 整秒精度(全库整秒 3471 条、带毫秒 246 条),即来自外部批量写入而非本机生成;整带 +8h 后与 created_at 的最大偏差降到 10.3 分钟,是单向可复原的错误。月份集中在 10 月 765 / 11 月 89。
判据必须按偏移量认,不能用「相差 >12h 就回落」:那 854 行全部落在 12h 以内,阈值法一条都挡不住;反过来非 8h 带里还有 195 条确实相隔 12h 以上的(真实「回忆往事」型记忆),阈值法会把它们一起误判成不可信。
方案的五个部分
§1 面板接 bySession
lib/client.js:4422-4425 读回 persona 时只认 default/byAgent(isMulti = "default" in pj || "byAgent" in pj);:4647 保存时只发两维。改法是纯加法:isMulti 判定加 "bySession" in pj、保存体加该键,src/settings.js:562 已兼容三键不用改后端。
§2 agentId 解析
三级:命中名单的标题「·」前缀 → _unnamed 兜底 → 拒绝把 fallback 整句标题当 id。「公共管理复习」这类用户手改的标题不能造出同名 agent 目录,否则会冒出一个不存在的 Agent。
§3 存储布局与归并规则
两套路径并存,必须显式分开:
- 按项目分桶(v0.13 现状):
<memoryDir>/diaries/<项目键>/YYYY-MM-DD.md - 按 Agent 分桶(本 issue 目标):
<memoryDir>/diaries/agents/<agentId>/YYYY-MM-DD.md
不迁移存量项目桶(两者是正交维度),但归并规则必须写进实现,因为按项目分桶这一层已经出过一次「同一项目两个桶」:
§3.1 按 hash 后缀认同一个项目,不按前缀名。 projectKey(src/candidates.js:194 起)取 sha256(projectRoot) 前 8 位作后缀;前缀是 path.basename 过 .replace(/[^A-Za-z0-9._-]/g, "-") 的结果,清洗规则一改前缀就漂,hash 不会。实测 sha256("D:\QQ对话") = 4f061790c57fcb63…,后缀 4f061790 稳定可复算。归并时只认后缀,前缀仅作展示名。
§3.2 同 hash 多桶要合,且不设「真正的那份」。 现场真发生过:QQ对话-4f061790(11-07 一篇、persona: settings (amarre)、无 runs.jsonl)与 QQ---4f061790(11-08 一篇、persona: default (ptc)、有 runs.jsonl)同 hash、同项目。已在 2026-11-08 手工合入后者:先复制、校验 SHA256 一致、再删源(1565B → 1565B),现 QQ---4f061790 有 2026-11-07.md / 2026-11-08.md / runs.jsonl,旧桶已删。当时的现场是 6 桶(amarre-c836db6a / attune-dd7a5103 / dsh-mneme-54f4c9ae / QQ---4f061790 / QQ对话-4f061790 / workspace-e9be35f6),现在是 5 桶。实现要做的是自动化这条合并:同 hash 两桶、日期不重叠 → 各篇归位到同一目录;日期重叠 → 不许静默覆盖,留两份并报审计(哪一份留、哪一份降级为 .conflict-<ts>.md)。
§3.3 runs.jsonl 记账从新桶起算,别指望旧桶补。 旧桶没有 runs.jsonl;新桶那条 ledger 里只有合并后写的一轮({"v":1,"date":"2026-11-08","trigger":"rollover","status":"written","mode":"create","items":6,"source":"session","persona":"default","pointer":"superseded"}),其余是 3 条 skipped。迁移不重建历史记账,合并时把旧桶有的 runs.jsonl 按时间序追加,缺的就是缺,不补写、不伪造。
§3.4 frontmatter 的 project: 是历史记录,路径才是权威。 合过来的 2026-11-07.md 里仍写 project: QQ对话-4f061790(旧的桶名),因为它记录了当时被写出来的样子,不改写历史文件。读取侧一律以目录路径为准;以后 project: 只作为「生成时的项目键」留痕。
§4 素材 SQL
WITH src AS (
SELECT type, title, content, importance, source,
CASE WHEN CAST(round((julianday(occurred_at) - julianday(created_at)) * 1440) AS INTEGER)
BETWEEN -490 AND -470
THEN datetime(substr(occurred_at,1,19), '+8 hours') -- 修 -480
ELSE COALESCE(substr(occurred_at,1,19), substr(created_at,1,19))
END AS ts -- ts 已是 UTC 语义
FROM memories
WHERE forgotten = 0 AND archived = 0
AND source IN ('session:session-<uuid>', 'session:<uuid>')
)
SELECT type, title, content, importance, source, ts
FROM src
WHERE datetime(ts, '+8 hours') >= :dayStartLocal
AND datetime(ts, '+8 hours') < :dayEndLocal
ORDER BY ts ASC
LIMIT 400注意 ts 已经是 UTC 语义,外层日界仍要再 +8 hours 一次。
§5 触发与开关
复用 auto.js 的 turn/end 判定 + 启动 catchUp 一次一篇,改 agentId 维度节流;不加固定时刻定时器。diaryEnabled 加到 PERSONA_FIELDS 白名单。
默认值口径别混(两个事实并存,不矛盾):代码里 src/config.js 是 diaryEnabled: z.boolean().default(false)(opt-in 默认关);而 live 库 user_settings 里 diaryEnabled = true(用户自己开过)。不要把 issue 写成「默认开启」,也不要拿代码默认值去解释线上状态——两者说的不是同一件事。本 issue 保持 default(false) 不改,理由同上:这个开关会把日记从「按项目」切到「按 Agent」并开始写新目录,默认开等于用户重启后行为变了却没人告她。
两处会静默出错的坑(必须进验收)
A. 漏写一次 COALESCE,4037 条静默进 null 桶
ELSE 分支里 COALESCE 漏掉时,occurred_at 为 NULL 的活跃行(4429 条)会全部落进 date = null 桶,外层 WHERE 默默滤掉,SQL 不报错、结果还是个正常数字。实测该日条数塌陷:
| 本地日 | 正确 | 漏 COALESCE | 丢 |
|---|---|---|---|
| 2026-10-17 | 1004 | 478 | 526 |
| 2026-11-07 | 489 | 358 | 131 |
| 2026-10-09 | 365 | 7 | 358 |
10-09 那条是三者里最有说服力的:丢 358 条、只剩 7 条,结果看上去仍是个正常数字。
B. LIMIT 400 截断的正好是晚上那段
按本地日超 400 的天:10-17 1004、10-12 511、10-16 508、11-07 489、10-18 415、11-06 412。ORDER BY ts ASC LIMIT 400 砍掉当天尾部,11-07 的尾部正是 18 点 96 条、19 点 96 条、22 点 75 条 —— 日记会系统性漏掉她晚上做的事,且不报错(与 #611「图被静默丢」同一病)。
单会话单日峰值实测 219 条(10-09 session-1a22c3a7;10-17 session-aa4d5ef2 同为 219),所以「按 agentId 分流后 400 就够」不成立——那个 52 条是只看当天得出的,不能外推。
处理:① 分流后再卡上限;② 超限落审计 diaryTruncated: {total, used} 并经 diaryAuditPath 记录;③ 日记正文末尾留一行「今天素材 N 条,本篇用了 400 条」。
验收红线
- 时区:造
2026-11-08T20:30:00Z(本地 11-09 04:30)的行,断言落在 11-09; - COALESCE(两条断言):① 结果集里没有任何
date(ts,'+8 hours') IS NULL的行;② 造一条occurred_at为 NULL 的已知行,断言它出现在created_at的本地日; - -480 修正:造
created_at=2026-11-08T12:21:15Z、occurred_at=2026-11-08T04:20:00Z的行,断言出现在本地 11-08 而不是 11-07; - source 两形:同一 uuid 造两形各一条,断言都进当天;
- LIMIT 不静默:造 500 条同日素材,断言元信息记了「共 500 条、用了 400 条」;
- agentId 守卫:标题「公共管理复习」不得造出同名目录,必须落
_unnamed; - fallback 标题不认;
- 冷行默认进
_unnamed;diaryBackfillColdAgents=true时才解冷行,且它解的是标题的source.kind不是 agent 归属,命中不了名单仍留_unnamed; - 同 hash 两桶归并:造
X-aaaa1111(有 day1)与Y-aaaa1111(有 day2)两桶,断言归并后同目录且两篇都在;再造两桶各有 day1 → 断言不静默覆盖、报审计; diaryEnabled=false一个字都不写;黑名单类型(diaryMemoryExcludeTypes默认constraint/preference)真被排除;- 面板 bySession 保存/读回闭环。
不做
多 Agent 共用一本;跨项目转录抓取(宿主无接口,memories 是唯一稳源);Agent 自由生成 CSS(无沙箱);固定时刻定时器;历史项目桶迁移(只做同 hash 归并);改写历史文件的 frontmatter。
已知权衡
素材从会话转录换成记忆库,换来跨项目跨工作区视野,代价是日记偏工作日志:Agent 在群里说的话若没进记忆库,日记看不见。二期可把 QQ inbox 兜底扩成群消息源(onebot-* 的 source 已在库里,98 条)。
Activity
modusensus commentedon Nov 8, 2026
处置:待定(前置第 1 步还没落地,现在动手会写成孤儿实现)
判定依据
issue 自己写的三步前置经我核对成立,而且第 1 步的载体就是我方 PR #614(同一天开的):
feat/diary-v013-rebased落进 mainf57d204,8 项必需检查全绿、MERGEABLE,等合并git grep -c bySession origin/main -- dsh-mneme/src仍零命中feat/diary-by-agentgit ls-tree -r origin/main -- dsh-mneme/src/diary为空,即src/diary/整目录只在 #614 的分支上。若现在从 main 切分支做 v2,写出来是一份孤儿实现,#614 一合就全废——这不是排期偏好,是 rebase 面的事实。对正文的核对结果
逐条查过 live 库与源码,四条决定性事实全部成立,没有发现需要修正的地方:
source两形零重叠:session:session-<uuid>与裸session:<uuid>,INTERSECT为空 → SQL 必须IN (两种),只用前者会静默漏 7–8 条/会话;+8 hours:2026-11-08 UTC 裸判 105 条 vs 本地日 210 条;occurred_at空值占活跃行 54.4%,COALESCE是必需项;-480偏移带共 854 条活跃行、全为.000Z整秒精度 → 必须按偏移量认,阈值法(>12h 回落)一条都挡不住,还会误伤 195 条真跨 12h 的记忆。两处「静默出错」的坑也复核过:漏
COALESCE会让 10-09 从 365 条塌到 7 条而结果仍是个正常数字;LIMIT 400砍掉的正是 11-07 晚上(18 点 96 条 / 19 点 96 条 / 22 点 75 条)——与 #611「图被静默丢」同一病。一个补充(正文没写,我认为该进 §2 验收)
agentId解析取标题「·」前缀,但标题是用户随时能改的(source === "user"正是「她手改过」这一档)。建议验收里再加一条:手改标题不得让已存在的 agent 桶换名或分叉——即 agentId 只在会话首次被写入时才落定,之后改标题只更新展示名、不动目录。否则用户改一次标题,那本日记就在侧栏里多出一本、旧的那本变成孤儿。下一步
前置第 1 步(#614)合并、第 2 步(#609)合入之后,从那时的新 main 切
feat/diary-by-agent按 issue 的 §1–§5 落地,验收按正文 10 条红线逐条补测试。在此之前不动代码。Merge pull request #614 from slow-stack/feat/diary-v013-rebased