agent-archive
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
agent-archive
跨 Agent 的项目级会话档案馆。
给定一个项目路径,把所有 Agent 在这个项目里发生过的会话,按时间合并成一条线。
许可证:MIT(见 LICENSE)。
research/reference-src/下是第三方项目的源码副本(MIT), 版权与许可声明见 NOTICE.md。
为什么只做这一件事
跨 Agent 的记忆、蒸馏、交接摘要都建立在"你首先得知道发生过什么"之上。 而各家 Agent 的会话存储格式互不兼容、路径各不相同,这件事没有标准答案, 只能逐家适配。
本工具只做三件事:扫描 → 归一 → 时间线。不做蒸馏,不做向量检索, 不往 Agent 里注入任何东西。它先把事实记录下来,其余都是可选视图。
在此之上还有一层环境层(env):识别实测每个 Agent 配了哪些 MCP 服务、
装了哪些技能、有哪些记忆。会话层回答"聊过什么",环境层回答"会什么"——
两层都只提供事实与出处,不替任何 Agent 做决定。
四种看法,同一份数据:终端里的 projects/timeline/cat,
浏览器里的 serve,导出成 Markdown 的 export,
以及给其他 Agent 用的 /api/* 与 MCP server。
定位:一个 AI 能读得懂的档案馆,而不是一个会读东西的 AI。 事实层(扫描、归一、时间线)是确定性的、可复现的、零成本的; 总结、归纳、蒸馏交给用户自己的模型——通过 MCP,不通过我们的 API Key。
Related MCP server: kairan
它长什么样
一个项目、四场会话、三家 Agent。下面是真实输出——用合成数据跑出来的, 项目名和对话是编的,格式、排序和换手判断都是真的。
$ node src/cli.js timeline C:/example/my-app
C:/example/my-app
4 个会话 · claude-code / codex / qoder · 2026-03-02 17:12 → 2026-03-05 16:52 · 累计 2.3h
2026-03-02
codex 17:12 36min 我的应用里,登录之后刷新页面就掉登录态。帮我看下。
2026-03-03
qoder 18:00 31min 登录问题昨天在 Codex 那边修了,但测试环境还是复现。
2026-03-04
codex 22:03 38min 购物车在 Safari 上数字不更新,Chrome 正常。
2026-03-05
claude-code 16:20 32min 把这三件事的修复写进 CHANGELOG,并补两个回归用例。handoff 把同一批会话压成接力链——谁在什么时候接手、中间隔了多久:
$ node src/cli.js handoff C:/example/my-app
4 场会话压成 4 段 · 3 家 Agent · 2026-03-02 17:12 → 2026-03-05 16:52
换手概况
3 次接续
接续间隔中位数 1.0d
最常走的接力线:codex→qoder(1) qoder→codex(1) codex→claude-code(1)
codex → qoder +1.0d 接续
离开: 我的应用里,登录之后刷新页面就掉登录态。帮我看下。
接手: 登录问题昨天在 Codex 那边修了,但测试环境还是复现。
qoder → codex +1.1d 接续
离开: 登录问题昨天在 Codex 那边修了,但测试环境还是复现。
接手: 购物车在 Safari 上数字不更新,Chrome 正常。
codex → claude-code +17.6h 接续
离开: 购物车在 Safari 上数字不更新,Chrome 正常。
接手: 把这三件事的修复写进 CHANGELOG,并补两个回归用例。间隔是重点。 隔了一天再换手,新 Agent 不会知道前面修过什么——除非你 手动把上下文粘过去。「接续间隔中位数 1.0d」这个数字,比任何摘要都更能说明 问题出在哪:你换了工具,但记忆没跟着换。
handoff 也会如实区分"并行"(上一段还没结束下一段就开始了,不是真正的交接)
和"长期中断"(隔了一周以上),不会把两种完全不同的情况混成一个数字。
当前支持
Agent | 存储位置 | 备注 |
Claude Code |
| 斜杠命令需过滤;分叉/复制会话会标记,复制品不重复计数 |
Codex |
| zstd;续接会话需合并 |
ZCode |
|
|
OpenCode |
| 与 ZCode 同源结构 |
Qoder |
| 新格式带时间戳和 cwd;旧格式只能靠 mtime |
DeepSeek Harness |
| 多帧 zstd;子代理跳过 |
Hermes |
| 多数无 cwd; |
Cursor |
| VSCode 键值库 |
Antigravity |
| 部分会话是加密 .pb |
Kiro |
| 正文在执行包里 |
Copilot Chat |
| 大量空会话 |
WorkBuddy |
| 注入上下文包着真话;编辑重发只留最后一版 |
格式差异就是适配成本。 每家的坑都不一样,都在各自扫描器里写明了:
OpenCode 与 ZCode 同源(session/message/part 三表),但没有
semantics.origin,只能靠 part 类型过滤。108 场里 57 场是子代理、 3 场归档,真实顶层会话 48 场。Cursor 消息顺序不在 KV 表里,在
composerData.fullConversationHeadersOnly——按 key 排序会得到乱序对话。 项目路径要从workspaceProjectDir(c:\...\.cursor\projects\e-my-app) 反解成e:/my-app。Antigravity 有三套并存的数据。索引库(FTS5)覆盖 21 场,但只有 3 场有明文
transcript_full.jsonl,另 18 场是.pb(熵 8.0, gzip/deflate/brotli 全解不开)。元数据能搜到,正文读不到—— doctor 会如实报 1/3 而不是假装能读。Kiro 的会话文件里 assistant 正文全是占位符
"On it.",真回复在 执行包里(actions[].output.message),靠executionId关联。Copilot 的 16 个会话文件里 14 个
requests是空数组,文件存在 不代表有对话。Qoder 有两套存储。
cache/projects/.../conversation-history/是旧格式, 没有时间戳也没有 cwd,只能靠文件 mtime 和目录名近似;projects/<工作区>/<uuid>.jsonl是现在的主存储,每行带timestamp/cwd/sessionId,工具调用和结果都在文件里。 实测新格式 291 场、旧格式只有 19 场——只读旧格式会漏掉九成以上。 新格式里attachment(技能清单)、active-leaf(对话树指针)、file-history-snapshot(文件备份)是运行时事件,不是对话内容,读正文时按类型过滤。DeepSeek Harness 的会话文件默认是
.zstd,而且是多帧追加—— 每批事件单独一帧,不是一个大帧。Node 的zstdDecompressSync只解第一帧, 直接读会得到一行 header 就结束;扫描器按帧魔数切开逐帧解。 Windows 桌面版把DSH_HOME指到~/DeepSeekHarness,不是~/.dsh。 子代理也是独立 session 文件(header 带parentSession),和 Codex 子代理一样跳过。 正文里user/message/assistant/message/tool/call/tool/result是对话,*-chunks是流式增量、request/*是请求头,读进来只会重复。Hermes 的主存储是
state.db(SQLite),不是sessions/*.jsonl。 这是本项目最大的一次数据修正:早期只扫 jsonl,实测读到 34 场, 而库里实际有 219 场(211 场有消息,去掉子代理和空会话后 165 场)。 Hermes 官方文档也写明旧版*.jsonl不再写入或读取。 有 cwd 的只有 46/219——多数是接飞书渠道的聊天,不绑定目录, 但那 46 场必须归到项目,硬编码project: null会让它们永远进不了时间线。
sameProject() 会把"只记项目名"和"记完整路径"的条目并成同一个项目,
否则 my-app 和 e:/my-app 会在列表里并排两行。
装
需要 Node 22.16 或更高。理由不是口味:读 .zst 压缩会话要用 zlib 的 zstd
支持(22.15 起),读各家 SQLite 会话库要用 node:sqlite(22.5 起),
全文搜索要用 node:sqlite 里的 FTS5 扩展(22.16 起;23.x 整条线没有)。
版本不够时入口会直接说清楚缺什么、要升到哪个版本,而不是在某个 scanner 里
抛一段看不懂的 TypeError——第一个跑起来的用户很可能就在老 Node 上。
还没发布到 npm registry,所以直接从 Git 装:
# 装成全局命令,之后用 agent-archive
npm install -g github:AmonCon/agent-archive
agent-archive projects
# 不装,直接跑一次
npx github:AmonCon/agent-archive projects
# 或者克隆下来跑源码(改代码时用这个)
git clone https://github.com/AmonCon/agent-archive && cd agent-archive
node src/cli.js projects第一次运行会在 stderr 打一行 ExperimentalWarning: SQLite is an experimental feature——那是 Node 对 node:sqlite 的提示,不是错误。为什么用这个还在实验期的
API:它是避免引入第三方依赖的唯一办法,而且入口自检已确认它在你的 Node 上可用。
嫌吵可以加 NODE_NO_WARNINGS=1,但这条警告本身是有信息量的,我们没替你静音。
零 npm 依赖,所以装上不会因为原生模块编译失败,也没有 node_modules 要下。
用法
下面用 node src/cli.js 演示(跑源码的写法)。装成全局命令的话,
把 node src/cli.js 换成 agent-archive 即可。
# 实测有哪些项目(自动过滤掉家目录、桌面、临时目录)
node src/cli.js projects
# 某个项目的时间线(默认最近 100 场,末尾会说明总数)
node src/cli.js timeline E:\my-app
node src/cli.js timeline E:\my-app --all # 完整历史
# 跨 Agent 接力:谁在什么时候接手、中间隔了多久
# 默认按间隔从大到小列前 12 次——间隔越大,接手的 Agent 越不知道前面发生了什么
node src/cli.js handoff E:\my-app
node src/cli.js handoff E:\my-app --all # 全部换手
node src/cli.js handoff E:\my-app --min-gap 1440 # 只看隔了一天以上的换手
# 不分天,平铺(同样受 100 场窗口约束,--all 拿全部)
node src/cli.js timeline . --flat
# 这个项目在各 Agent 里的分布
node src/cli.js agents E:\my-app
# 扫描统计与缓存位置
node src/cli.js stats
# 采集体检:覆盖率、缺口、正文可读性
node src/cli.js doctor
# 模型调用成本(按 Agent / 模型 / 会话)
node src/cli.js calls
node src/cli.js calls --by session
# 各家 Agent 自己记的 token(不需要网关)
node src/cli.js usage
node src/cli.js usage --by model
node src/cli.js usage --by project
# 价目表(可选;只有 sync 会联网)
node src/cli.js pricing
node src/cli.js pricing sync
# 扫描归档里的明文凭证(只报告,掩码输出)
node src/cli.js secrets
# 配置模型(用户自己的 API Key)
node src/cli.js llm --key sk-xxx --model deepseek-chat --base-url https://api.deepseek.com
# 生成项目摘要(默认只估算成本,加 --run 才真的调模型)
node src/cli.js digest E:\my-app
node src/cli.js digest E:\my-app --run --out digest.md
# 审计/刷新已过期的摘要(默认只审计,加 --run 才重算)
node src/cli.js refresh
node src/cli.js refresh --run
# 建全文索引(第一次约一分钟,之后只处理变过的会话)
node src/cli.js index
node src/cli.js index --force # 全部重建
# 全文搜索
node src/cli.js search 迁移
node src/cli.js search 缓存 --agent zcode --limit 5
# 导出 Markdown
node src/cli.js export E:\my-app # 项目时间线
node src/cli.js export E:\my-app --bundle # 该项目全部会话正文
node src/cli.js export 01a11a93 --session # 单场会话
node src/cli.js export . --out archive.md # 写文件而不是 stdout
# 读一场会话的正文
node src/cli.js cat --project . # 该项目最近一场
node src/cli.js cat 1d551b54 # 按 id 前缀
node src/cli.js cat 019a30ad --tools # 展开工具调用与输出
# 起本地网页查看器(浏览器打开 http://127.0.0.1:7788)
node src/cli.js serve
node src/cli.js serve --port 8000通用选项:--json 输出机器可读结果,--no-cache 强制全量重扫。
深入:按主题看
上面的用法够日常用了。以下是每个设计决定为什么这么定——都是踩过之后写下的, 按主题分组,可以按需跳读:
扫描与合并:搜索与中文分词 · 续接会话合并 · 子代理会话 · 时间戳 · 符号链接 · 解析失败 · 缓存原子写
接口:跨 Agent 接力 · 导出 · MCP server · 本地网页查看器 ·
cat成本与安全:调用层
calls· 用量层usage· 凭证扫描secrets环境层:MCP / 技能 / 记忆 · 采集体检
doctor跨平台:三个系统的路径差异 · 为何只有 10 家
扩展:给别人家的 Agent 写扫描器 · 反向发现没适配的存储 · 设计要点 · 下一步
搜索:为什么需要索引,以及中文怎么切
全量读一遍 863 场会话要 33 秒、55 M 字符。每次搜索实时扫不可行,
所以正文先在本地落一份 SQLite FTS5 索引(~/.agent-archive/index/)。
中文分词是这里唯一有技术含量的地方。 FTS5 自带的 unicode61
不切中文——整段中文是一个 token,搜「缓存」永远搜不到;trigram
要求查询至少 3 个字符,「迁移」「缓存」这类两字词同样落空。
所以自己切:连续汉字段切成长度 2 的滑窗(数据库迁移 →
数据 据库 库迁 迁移),英文和标点原样交给 unicode61。
第一次的写法是「原文 + 全量二元组」,实测索引文本膨胀 4 倍 (120 场就 34 M 字符),建索引慢到跑不完。改成只存 token 序列后 降到 1.95 倍,863 场 56 秒建完,索引 179 MB。
摘要不用 FTS5 的 snippet():它从索引文本里取,会把二元组一起抓出来,
摘录变成「约 约束 束:」这样的碎片。改为回原始正文里定位关键词取上下文。
只索引人说的话和工具调用摘要,不索引完整工具输出——那些多是日志和 源码,放进去既占空间又把真正的对话信号淹没。
合并:一场对话可能散在多个文件里
Codex 的续接会话会为同一 session id 产出多个 rollout 文件
(rollout-<时间>-<父uuid>.jsonl.zst 和
rollout-<时间>-<父uuid>_<子uuid>.jsonl),时间上首尾相接。
registry.js 按 agent|id 合并它们:时间取包络,正文读取时按
顺序拼接。
不合并的后果很直观——合并前实测 2225 个「会话」里只有 904 个唯一 id, 同一场对话在时间线里重复出现几十次。合并后是 863 场。
另外 Codex 的子代理会话(source: {subagent: {...}})以前整条跳过。
现在照常采集,标记成 kind: 'subagent' 并记下父会话——理由见下面「子代理会话」一节。
子代理会话:为什么不丢掉
这是本版最大的一次数据修正。
早期所有扫描器都把子代理会话整条丢掉,理由是"它们 session_id 指向父会话,
按它归并会让几十个子代理挤成一条,时间线重复出现几十次"。这个理由只对了一半:
该修的是标识,不是把它们扔了。
主会话只记"我让子代理去查一下 X",子代理会话里才是"具体查了什么、看到了什么"。 扔掉等于档案馆只存了一半事实。实测:
Agent | 主会话 | 子代理会话 |
Codex | 675 | 1322 |
ZCode | 198 | 98 |
OpenCode | 48 | 57 |
Hermes | 165 | 46 |
DSH | 20 | 20 |
合计 | 1452 | 1543 |
也就是说,按这场对话被执行的步骤算,档案馆以前漏掉的比存下来的还多。
模型层加了两个字段
makeSession() 新增 kind(main / subagent)和 parent(父会话 id)。
缺省是 main,这样老缓存不会把历史数据误判成子代理。
下游的默认行为跟着改:时间线、项目列表、项目会话数只算主会话,
子代理会话单列一个数字。这样列表不会因为多存了 1543 条而翻倍,
但数据一条没丢——read_session 能按 id 直接读,搜索也能命中。
父会话要沿链上溯,不能只看一层
Codex 支持多级委派:子代理还能再派子代理。实测 1543 场子代理里有 181 场的 直接父会话本身也是子代理。只判断"父是不是 main",这 181 场会被误报成孤儿, 而且挂不到任何时间线节点下(父节点根本不显示)。
所以 indexChildren() 沿 parent 链上溯到最近的 main 祖先,并保留 depth
说明层级;doctor 的归属判定也用同一套逻辑。上溯带 64 跳上限防环。
Codex 取父 id 有三代写法
写法 | 父 id 在哪 | 实测占比 |
旧版 |
| 少量 |
新版 |
| 绝大多数 |
guardian | 根本没有 | 27 场 |
第三种是 source.subagent.other = "guardian" 这类内部看护线程,rollout 里
压根没写父 id。这是数据源限制,不是解析错误——doctor 把它单列成「无父id」
如实报数,不用推测去填一个看起来合理的父 id。
当前归属率:
1543 场 · 归属 1513 · 孤儿 3 · 无父id 27 · 归属率 99.8%
! codex 1322 场 无父id 27
✓ zcode 98 场
✓ opencode 57 场
! hermes 46 场 孤儿 3
✓ dsh 20 场孤儿是"有父 id 但链断了"(父会话被删或跨项目),与"没取到父 id"分开报—— 两者处置完全不同:前者可能是正常的历史清理,后者说明某家的字段取错了。
搜索里也要标出来
索引表加了 kind 列(老库打开时自动 alter table 补上,幂等)。
搜索结果默认包含子代理会话——它们记着主会话没记的细节,排除会让结果变浅;
但每条都带 [子代理] 标记,用户读到一段细节时得知道那是子代理查到的,
不是主对话说过的。只想要主会话时传 includeSubagents: false。
剥离嵌入的父会话副本
子代理被采集之后,索引体积一度从 143 MB 涨到 386 MB。查下来不是"子代理本身 就该这么大",而是有一类子代理在复制父会话:
Codex 的 guardian 子代理不做活,专门审阅父会话的每个动作。它收到的 prompt 长这样:
The following is the Codex agent history whose request action you are assessing...
>>> TRANSCRIPT START
[1] user: ... ← 父会话的完整片段,含工具输出、测试日志、文件内容
>>> TRANSCRIPT END
The Codex agent has requested the following action:
>>> APPROVAL REQUEST START
Planned action JSON: {...}
>>> APPROVAL REQUEST END而且一场子代理里有几十轮——每审一个动作就嵌一份。实测 1543 场子代理里 650 场是这类,合计 74.7 M 字符,占子代理索引量的 86%。同一份对话被索引两遍, 搜「支付回调」时命中结果被复制品挤占。顺带:这些嵌入片段里常有明文 API key。
所以索引层加了 stripEmbeddedTranscript(),剥掉标记包住的部分:
块 | 处理 |
| 剥(父会话副本) |
| 剥(增量副本,且会嵌套在上一块里) |
| 剥(待审动作的完整参数) |
只剥标记内,标记外全部保留——那里才是这场子代理的产出:它对每个动作的
判断({"outcome":"allow","rationale":"..."})。那是父会话没有的信息。
两个踩过的坑,都写进注释了:
只认
TRANSCRIPT START会漏掉 DELTA 变体。第一版剥完仍有 400K+, 因为后面几十轮用的是TRANSCRIPT DELTA START。必须先剥 DELTA 再剥普通块。DELTA 块有时整块落在普通块内部,反过来的话 非贪婪匹配先吃掉外层,把内层 DELTA 的 END 留成孤立标记(实测残留 96 场)。
效果:索引 386 MB → 205 MB,子代理正文 82.7 M → 19.9 M 字符。
主会话 1433 场一场都没被碰(规则只匹配带 >>> 前缀的标记,
正常对话里提到 "transcript" 这个词不会命中)。
验证方式:拿 31 场原始 rollout 直接跑规则,残留 0;再对全库查标记,只剩 1 场 ——那是我们正在讨论这个话题的会话本身。
时间戳:三个静默缺陷
这一节记录本版修掉的三处"看起来正常、实际算错"的时间问题。它们的共同点是 不报错:都不抛异常、都返回一个看起来合理的值,只是那个值是错的。
一、只读文件头会让结束时间早算
Codex 的 rollout 是 .jsonl[.zst],为避免为列个标题解压几百 MB,扫描器只读
前 256 KB。写这段时的想法是"截断的 zstd 帧解压会失败,失败了就退回整文件"。
这个兜底从来没触发过。 zlib.zstdDecompressSync() 遇到截断帧不抛错,
而是安静地返回已解码的部分。实测在一份 87 MB 的 rollout 上它"成功"返回了
0.3% 的内容,于是结束时间取到的是开头的几条事件。
指标 | 修复前 |
结束时间差 >1 小时 | 268 场 |
结束时间差 >1 天 | 91 场 |
最大偏差 | 26 天 |
整文件解压能修(2032 个文件、1.7 GB,实测 110 秒),但不值得——Codex 自己
维护了一份更准的记录。~/.codex/state_5.sqlite 的 threads 表里每个线程有
updated_at / title / tokens_used。拿它和完整解压的真实末条对照:
判据 | 平均偏差 | 最大偏差 |
| 1 秒 | 1 秒 |
文件 mtime | 507 秒 | 2420 秒 |
所以用那张表修,代价是一次 48 毫秒的查询。这也正是 cc-switch 的做法
(它把 state_5 的用户标题和 mtime 兜底结合起来)。明文 .jsonl 另走一条
更便宜的路:直接读尾部 64 KB 取真实末条。
二、导入会话的时间戳全是导入时刻
37 场 external-import-turn 会话,文件里每个事件的顶层 timestamp 都是
导入那一刻(集中在一秒内),真实时间在 payload.started_at / completed_at
(Unix 秒)里。
不修的话这些会话在时间线上全部堆到导入那一天,start 和 end 都是。
注意 payload.completed_at_ms 名字有误导性——它装的也是导入时刻,不是完成时间。
三、旁路数据改了,索引不跟着改
索引按源文件的 (mtime, size) 判复用。但上面两处修的是会话元数据,
源文件一个字没动——于是索引里一直显示旧的占位标题和错的起止时间。
修法是把 title / start / end 也纳入复用比较。代价是比三个值,
比重新读正文便宜得多。
修完的效果:
指标 | 修复前 | 修复后 |
时间差 >1 小时 | 268 场 | 0 |
最大偏差 | 26 天 | 727 秒 |
平均偏差 | — | 1.8 秒 |
标题覆盖率(doctor) | 94.1% | 98.9% |
无真人输入 | 5.9% | 1.1% |
换手视图的时间随之重算(my-app 的并行从 87 次变 117 次,中位间隔更准)。
实现上的一处取舍:enrich 不进磁盘缓存
旁路数据的修正放在 scanner.enrich() 里,在 registry 合并之后跑,不写进
磁盘缓存。因为会话文件的解析结果按 (mtime, size, 头哈希) 缓存,如果把
state_5.sqlite 也算进每个 rollout 的指纹,Codex 每写一次状态库就会让 2037 个
缓存条目全部作废——一次几十秒,而正确性没有任何额外收益。
enrich 自己的读取按 (size, mtime) 在进程内记忆,所以重算很便宜。
测试
npm test # node --no-warnings --test14 个用例,1.3 秒。零依赖——用 Node 内置的 node:test。
测试不依赖这台机器
这是最关键的一条设计。每个用例自己在临时目录里造一份假机器:
os.homedir() 在运行时跟随 USERPROFILE(Windows)/ HOME(POSIX),所以
把家目录指到临时目录之后,所有扫描器自然只看得到用例造的数据——不需要给
每一家单独做环境变量覆盖,也不可能碰到跑测试那台机器上的真实会话。
这件事对开源产品是必须的:真实数据有 2400 多个文件、几个几百 MB 的库, 全扫一遍要一分多钟;换成空家目录是毫秒级。更重要的是,别人的机器上路径、 版本、格式都和我们不一样,测试如果依赖开发者的家目录,那它只证明了 "我这台机器能跑"。
用例锁的是踩过的坑,不是当前实现
用例 | 锁住的事实 |
旧版子代理 |
|
新版父 id 在 | 只认旧写法会漏 41 场 |
guardian 没有父 id | 如实留 |
导入会话 | 用 |
明文长文件 | 尾部读取能拿到真实结束时间 |
旁路表 | 用 |
真实用户第一句 | 不被应用标题覆盖 |
解析失败 | 会话保留、计数正确、下一轮重试、修好后恢复 |
缓存 | 原子写、不留临时文件、损坏不崩 |
空结果 | 不算解析失败(否则实测凭空报 15 个假警报) |
上游改了格式,这些用例会先红——比用户机器上静默少数据好。
还缺的:十二家现在全部有契约测试(.claims-skip 已清空)。
下一个会过期的不是"有没有测试",而是上游格式漂移:哪家改了存储结构,
先在这里加一条"还缺的"说明,再按 test/codex.test.js 的形状补用例——
门禁会逼着这条说明和现实保持一致。
补测试这件事本身就是有产出的:ZCode 的用例写下去当天就抓到一个真 bug——
默认标题的回退查询里 json_extract(data,…) 没写表前缀,JOIN 之下抛
"ambiguous column name"被吞掉,所有默认标题的会话一直静默显示
"(untitled)"。修复后本机 310 场 ZCode 会话全部有了真标题。
其中 Antigravity 与 Kiro 锁的也是实测踩过的静默缺陷: 「元数据能搜到、正文只有 3/21 能读」和「assistant 正文是占位符、真回复在执行包里」。
那个登记机制还在:哪天又有扫描器没配测试,把 slug 写进 .claims-skip、
在这里点名,npm run claims 会逼着两边保持一致。
跨平台路径也是被测的
这个工具只在 Windows 上真机跑过,macOS / Linux 的路径假设全部来自官方文档和 源码核对。造不出一台 Mac,但可以让路径解析接受平台注入:
userDataCandidates('Cursor', { platform: 'darwin', home: '/Users/demo', env: {} })
// → ['/Users/demo/Library/Application Support/Cursor', ...]于是能在实测断言"在 macOS 上会得到什么候选列表"。test/platform.test.js 锁住了
README 里写的那些反直觉事实,比如 OpenCode 在 macOS 上也用 ~/.local/share
(它源码没有 darwin 分支)。哪天有人"顺手"给它加个 Application Support 分支,
测试会红。
这不替代真机验证:它验证的是"我们怎么想",不是"系统怎么表现"。
真机验证仍然待做——research/probes/mac-check.mjs 就是为此准备的。
打包与发布
npm pack 出来 226 KB / 43 个文件,只含 src/ 和三个文档:
"files": ["src/", "README.md", "LICENSE", "NOTICE.md"]research/(含第三方源码副本与调研脚本)和 test/ 不进包——安装的人不需要
它们,而 research/reference-src/ 里有别人的代码,随包分发只会带来许可上的混乱。
bin 两个入口都带 shebang,npx agent-archive 和全局安装都能直接用。
许可:MIT。research/reference-src/ 下是 MIT 项目的源码副本,版权与许可
声明在 NOTICE.md——这也是它不随 npm 包分发的原因之一。
会话正文缓存:为什么读第二次要快
读一场大会话很贵:实测 Codex 的 89 MB .zst 读一次 1.1 秒
(整文件解压 + 逐行 JSON 解析)。而原来读第二次仍然要 1 秒——没有任何缓存。
这不是"偶尔慢一下":网页每刷新一次读一次、cat 一次读一次、导出读一遍。
用户在会话页上按 F5 就要等一秒。
所以加了一层进程内缓存(src/transcript-cache.js),做法与 cc-switch 的
TranscriptCache 同一个设计——按源文件指纹失效,LRU 同时按条数和字符数
双限(8 场 / 96 M 字符):
场景 | 加缓存前 | 加缓存后 |
同一场大会话第二次读 | 1082 ms | 0.4 ms |
网页会话页第二次请求 | 1.3 s(只有扫描缓存) | 0.33 s |
三条边界:
只缓存解析好的消息数组,不缓存原始字节——瓶颈是解析,不是 IO。
共享引用,不深拷贝:几万个 part 克隆一次就把缓存的意义抵消了。 所以调用方不得修改返回的数组;现有调用方(export / digest / server / mcp / search)都只读。
指纹算不出来就不缓存:文件刚被删、stat 失败时宁可重读,也不能把过期 内容当成新的。多段
a|b、path#id、db:id四种 source 形态都能取指纹。
索引:为什么不做字节游标增量
原本的计划是给索引加"字节游标"——只读文件新增的部分。动手前先量了一下 成本分布,结论是不值得做:
环节 | 40 场抽样耗时 |
读文件 + 解析 | 11.9 s |
拆成索引文本 | 7 ms |
中文二元组分词 | 36 ms |
成本几乎全在读取上,而稳态下的索引本来就不慢:
第一次(全量) 90 s
第二次(无变化) 2.8 s 复用 3102 / 重建 0倒排本身也改成了增量。 以前只要有一场会话变化,就执行 FTS5 的
rebuild 把 226 MB 倒排整体重建(改一场约 6.5 秒)。现在 doc 表的
增删改由三条触发器同步到 doc_fts,日常更新只动变化的那几行;只有首次
建库、--force 和 reindex 才走 rebuild。实测全量重建 7.3 秒,
增量改一场 2–3 秒(其中约 2 秒是冷扫描,与索引无关)。
正文压缩。 doc.body 存的是整段可读正文(摘录和凭证扫描都要用),
占索引体积的大头。现在正文以 zlib.deflateRaw 压进 body_z,老库首次
打开时自动迁移并 VACUUM。实测真实索引 240 MB → 169 MB(-26%),
迁移 + 回收一次性约 26 秒;搜索摘录和 secrets 结果与迁移前逐条一致。
也就是说 90 秒只在首次建索引或 --force 时出现。字节游标能把"大量会话各追加
几行"那次从 22 秒压到更低,但它救不了首次全量,而首次全量恰恰是最痛的一次。
真正能同时改善首次索引、网页、cat、导出的是上面那层正文缓存——一次改动,
四个消费者都受益。所以索引层保持"按 (mtime, size) 复用解析结果"不变。
如果以后要再做:该做的是按解析策略分三类(追加型日志走游标、整份重写的
走整读、SQLite 走每会话指纹)——magpie 就是这么分的,见
research/upstream/LESSONS.md。
配置:路径在别人机器上不一样
agent-archive config init # 生成一份配置模板
agent-archive config # 看当前配置生效成什么样
agent-archive config path # 配置文件在哪~/.agent-archive/config.json:
{
"roots": {
"codex": ["D:/archive/.codex"],
"claude-code": ["/Volumes/work/.claude"]
},
"disabled": ["kiro"]
}roots.<agent> 里每一项的含义 = 该 Agent 的家目录(~/.codex、~/.claude
那一层)。往下怎么拼(Codex 是 sessions/、Hermes 是 state.db)由扫描器自己
知道,用户不需要懂。disabled 里的 Agent 不会被扫描。
为什么不能只靠环境变量
大部分 Agent 都支持环境变量覆盖(CODEX_HOME、CLAUDE_CONFIG_DIR、
HERMES_HOME…),终端里很好用。但两种真实场景下很别扭:
GUI 拉起的 MCP server:Claude Desktop / Cursor 启动子进程时不一定继承 你 shell 里设的变量。用户改了半天"没生效",也不知道为什么。
一台机器上有多份数据:
CODEX_HOME只能指一个。用户想同时看本机盘和 外置盘上的两份,环境变量表达不了。
配置文件两个问题都能解决,而且与环境变量不冲突:配置里的根排在前面 (用户显式写的优先),环境变量与平台默认继续作为后备候选。
三条边界
没有配置文件是正常状态,不是错误。
配置写坏了只让这一层失效:当作空配置继续跑,原因在
agent-archive config里显示。配置是让事情变方便,不该成为新的单点故障。改完立即生效:按
(mtime, size)缓存,长驻的 serve / MCP 也能感知。
扩展:给别人家的 Agent 写一个扫描器
具体步骤(读格式、写契约、跑验证、注意事项)见 CONTRIBUTING.md。 这一节只说为什么这么设计。
内置的十一家是"我们碰巧知道"的。世界上的 Agent 远不止这些,而且新的一家随时 会出现。支持某家不该需要改我们的代码、提 PR、等发版。
所以扫描器是开放的:
agent-archive scanner init myagent # 生成一份能跑通的骨架
agent-archive scanner path # 看目录(默认 ~/.agent-archive/scanners)
agent-archive scanner check <文件> # 校验契约
agent-archive scanner list # 看哪些生效了、哪些加载失败把文件放进那个目录,下次运行就生效。
契约
export const name = 'xxx'; // 必需,唯一标识
export function discover() {} // 必需,返回文件路径数组
export function parse(file) {} // 二选一:一个文件一场会话,返回会话或 null
export function parseAll(file) {} // 二选一:一个文件多场会话,返回数组
export function readMessages(session) {} // 可选,读正文(不写就只进清单)
export function extraFiles(file) {} // 可选,参与缓存指纹的附属文件(如 -wal)
export function enrich(sessions) {} // 可选,用旁路数据修正元数据
export function closeStore() {} // 可选,用完关句柄会话对象用 model.js 的 makeSession() 造,字段是
{agent, id, project, title, start, end, source, resume, kind, parent}。
子代理会话标 kind: 'subagent' 并填 parent,时间线会自动把它们挂在父会话下。
三条边界
一、一个坏文件不能拖垮整次扫描。 加载失败、契约不合格、discover() 抛错,
都只影响它自己:其余扫描器和内置的照常工作,doctor 与 scanner list 会把
失败原因列出来。开放扩展必须付这个代价——别人的代码跑在我们的进程里,
至少要保证隔离在文件级别。
二、不能与内置重名。 重名会让人分不清是哪份在跑,直接拒绝加载。
三、它是代码,不是配置。 用户扫描器在本进程里执行,能读写任何东西。 README 不假装它是沙箱——只该放自己信任的文件。
扫描器和配置的分工
你的情况 | 用哪个 |
同一个 Agent,数据在别的位置 |
|
一家我们不认识的 Agent |
|
某家不用 / 太慢 / 坏了 |
|
和「反向发现」是同一个闭环
doctor 会列出"看起来像会话存储、但我们读不懂"的目录。看到这些之后有两条路:
提到 issue 等我们支持,或者自己花二十分钟写一个扫描器。后者不需要等任何人
——这也是这个项目愿意开放扫描器的原因。
反向发现:这台机器上还有什么我读不懂
这是"给所有人用"最直接的一条。
用户装的 Agent 里一定有几个我们没听说过。原来的行为是沉默——列表里没有、 doctor 也不提,用户会以为"这工具不支持我的 Agent",然后卸载。我们自己也不知道 该补哪一家。
doctor 现在会扫一遍常见位置,把"看起来像会话存储、但不在已接入范围"的目录列出来:
未适配的会话存储(启发式猜测)
这些目录不在已接入范围内,但内容看起来像会话记录。
只是"看到什么",不下结论——是不是、要不要支持,需要人判断。
? ~/AppData/Roaming/SomeAgent 57.2MB 1 个 sessions 类子目录 · 6 个会话形态文件
? ~/.some-agent-cli 21.5MB 2 个 sessions 类子目录 · 4 个会话形态文件
? ~/AppData/Roaming/AnotherApp 1.9MB 1 个 sessions 类子目录 · 9 个会话形态文件
? ~/.yet-another 11.4MB 1 个 sessions 类子目录 · 37 个会话形态文件
如果要支持其中某个,把这一行(路径 + 里面有什么)提到 issue 即可。(路径与数字是示意——真实输出会列出你这台机器上装了什么。)
同样的信息也经 MCP 的 session_stats 的 unknownStores 字段给到其他 Agent。
三条设计上的克制
一、只报"看到什么",不下结论。 不写"这是一个 Agent 会话库",只写
"这里有 1 个 sessions 类子目录、6 个会话形态文件"。因为这是启发式,会误报
(pgAdmin 也有 sessions/ 目录)也会漏报(加密的、改扩展名的扫不到)。
给一个确定性口吻的结论比不给更糟。
二、入选要求"有 sessions 类子目录"或"目录名像会话存储",光有 .jsonl 不算。
第一版只看扩展名,结果实测把 pnpm-cache(251 个 .jsonl)、pgAdmin、
~/.config/magpie 全报了出来。一个充满误报的列表,用户第二次就不会看了。
三、已知范围要从"已发现的会话"反推,不能只看我们写的适配清单。
适配清单列的是数据文件(state.vscdb),而这里扫的是应用根目录
(AppData/Roaming/Cursor)。拿前者排除后者会漏掉一大片——实测会把
Cursor、QoderCN、DeepSeekHarness 全报成"未适配",而它们本来就读得好好的。
性能:8.2 秒 → 0.55 秒
第一版递归扫三层,实测 8.2 秒——~/AppData/Roaming 下上百个应用,每个都往下走,
代价全花在浏览器缓存、GPU 缓存这类明显无关的目录上。
改成两阶段:先只看顶层,没有 sessions 类子目录、也没有会话形态文件、名字也不像, 就直接判否。真正需要深看的只有很少几个,耗时降了一个量级。
遍历有硬上限(深度 3、单目录 300 个条目),只 readdir + stat,不打开任何文件。
它会在别人的机器上跑,不能因为某个目录特别大就卡住。
跟踪上游参照
我们借鉴的三个项目(MAGPIE、cc-switch、AutoSwarm)都在持续演进,MAGPIE 几乎 每天有提交。靠"重新通读一遍"来跟会失控,而且每次重读都会重新推导同样的结论。
所以把它们当成长期订阅源:
node research/upstream/refresh.mjs # 只看有没有新提交
node research/upstream/refresh.mjs --diff # 拉新版本,只列我们关注范围内的改动
node research/upstream/refresh.mjs --update # 读完确认,把 pin 推进到最新research/upstream/sources.json记住"我们读到哪一版"(commit)research/upstream/LESSONS.md是判断台账:每条写清已采用 / pending / 不适用 / 已否决,以及落到了哪个文件
于是"继续研究"变成一次几分钟的 diff 复核。上次跑的时候它立刻发现 AutoSwarm 在我们读完后又有了新提交——这正是这种机制要解决的问题。
本地镜像放在 ~/.agent-archive/upstream-cache/<id>,不进仓库:仓库里只保留
"读到哪一版"和"学到了什么",不复制别人的源码。
符号链接与 junction:一整家会话可能被静默漏掉
fs.readdirSync(dir, { withFileTypes: true }) 返回的 Dirent,对符号链接和
Windows junction 一律报 isDirectory() === false——它回答的是"这个目录项本身
是不是目录",不是"它指向的东西是不是目录"。实测:
link dirent.isDirectory()=false | lstat.isSymbolicLink()=true | stat.isDirectory()=true
target dirent.isDirectory()=true | lstat.isSymbolicLink()=false | stat.isDirectory()=true后果是静默丢一整家会话:用户把 ~/.codex/sessions/2026 搬到别的盘、在原处
留一个 junction(Windows 上最常见的腾空间做法),扫描器一个文件都读不到,
而 doctor 报的是"这个 Agent 没装"——不是报错,是安静地少一家。
这个坑在环境层已经踩过一次(技能扫描因为同样的原因漏掉 Qoder 的 3 个和 Kiro 的
28 个技能,症状也是"0 个技能"而不是报错)。当时只修了那一处,会话扫描器里一直留着。
现在统一成 src/fsentry.js 的 isDirEntry() / isFileEntry(),8 个扫描器的
17 处 Dirent 判定全部替换。
实测验证(子目录级 junction,这才是会失效的场景):
<config>/projects/E--fake --> <另一个盘>/E--fake
dirent.isDirectory() = false ← 旧逻辑在这里跳过整段
新逻辑发现: projects\E--fake\1111....jsonl保留例外:kiro 的 listJson() 刻意跳过文件级符号链接,注释写明是
"避免被指到根目录之外"的安全考虑——那处不动。
解析失败不再让会话消失
扫描器解析失败时,原来的处理是 continue:这场会话从本轮结果里消失,而且
缓存条目被丢掉,下次也补不回来。更糟的是"解析成功但结果为空"——那会被当成
"这场对话没有内容"直接缓存下来,一次暂时性失败就变成了永久错误。
实测:把一场 DSH 会话的 jsonl 写坏,下一次扫描它就从时间线上没了,
failures 还是 0。
现在的处理:
情况 | 判据 | 处置 |
解析抛异常 |
| 计 |
曾经有会话、这次解析为空 |
| 计 |
本来就不是会话 | 扫描器返回 null | 正常,不计失败 |
第三行是关键。 如果把"返回空"一律当失败,实测会凭空报出 15 个"解析失败"
(Copilot 的 16 个会话文件里 14 个 requests 是空数组、Codex 有 2 个 rollout
没有时间戳)——这正是这个项目一直在避免的那种假警报。
保留旧结果时沿用旧指纹,这样下一轮仍会判定"变了"并重试;若写新指纹, 下一轮会命中缓存,会话就永远停在旧内容上。
实测(故意写坏一场 DSH 会话):
好数据: sessions 41 failures 0 emptied 0
损坏后: sessions 41 failures 0 emptied 1 keptStale 1 ← 会话保住了
修复后: sessions 41 failures 0 emptied 0 ← 自动恢复doctor 会把 emptied / keptStale 报出来。不报的话,"会话数没变"可能掩盖
一场正在被写坏的会话——那正是这个项目一贯要避免的那种沉默。
缓存的原子写
saveCache() 从 writeFileSync 改成同目录临时文件 → fsync → rename。
原因是这份缓存有 1 MB 量级,写一半崩了会留下截断的 JSON;下次 loadCache
解析失败返回 {},看起来是"优雅降级",实际是整机全量重扫一次(实测 50 秒),
而用户不知道发生了什么。
CLI、serve、MCP 可能同时写这份缓存,原子替换也让并发写不会互相截断——
最后写完的赢,而不是两败俱伤。这个做法取自 MAGPIE 的 steady.WriteFile
和 cc-switch 的 stage_write。
跨 Agent 接力:handoff
时间线按会话列,一个项目几百场就没人看得完。handoff 换一个角度:
这个项目被哪些 Agent 轮流接手过,每次换手之间隔了多久。
把连续同一 Agent 的会话压成一段"run",实测 my-app 的 891 场会话 变成 223 段——人只需要看换手点。
node src/cli.js handoff E:\my-appE:/my-studio
38 场会话压成 12 段 · 4 家 Agent · 2026-05-26 15:57 → 2026-10-23 20:54
换手概况
7 次接续 · 4 次并行 · 2 次长期中断
接续间隔中位数 1.3d
最常走的接力线:qoder→codex(3) codex→qoder(2) ...
qoder → codex 重叠5.9d 并行
离开: 这个项目是生图的吗
接手: [@gmail](plugin://...) Summarize inbox, draft replies...
codex → qoder +14min 接续
离开: 你先了解一下我们这个项目
接手: 我还有一个想法,我希望你能帮我做一个...为什么这件事只能在这里做
用户真实的用法是换工具:这个项目先用 Codex,界面问题换 Antigravity,后端又回 Codex。每换一次,新 Agent 都不知道前面发生了什么——它只看得到自己的会话。 档案馆是唯一能看见整条接力的地方。
所以这个视图最实用的读法是:换手间隔长的地方,就是上下文最容易丢的地方, 那几场会话值得优先读。
一个必须如实处理的细节:会话会重叠
实测 my-app 的 224 次换手里有 87 次间隔为负——上一段还没结束,
下一段已经开始了。原因很实际:会话的 end 是最后一条消息的时间,一个开了
好几天、偶尔回一句的会话会"横跨"别人的工作。
三种情况分开报,不合并成一个数:
类型 | 含义 | 为什么不合并 |
| 正常先后接续(间隔 ≥ 0) | — |
| 时间重叠(间隔 < 0) | 算成 0 会让"平均换手间隔"偏小;取绝对值则虚构了一段等待时间 |
| 有一边没有时间戳 | 算不出来就是算不出来 |
平均间隔只在 sequential 上算,重叠数单独报。把并行当成"接续"会让
"换手间隔"这个指标彻底失真。
间隔超过一周的标成 stale(长期中断)——这类换手基本等于重新开始。
三处入口
位置 | 内容 |
| 终端,带每个换手点前后的会话标题 |
| MCP 工具,让别的 Agent 自己判断"该先读哪场" |
| 网页,表格形式;项目页顶部也有一行入口 |
网页只在项目页给一行摘要(N 次换手 · N 次并行),详情在独立页面——
项目页已经很长,再塞一张换手表会把它压垮。
导出:三种粒度
命令 | 内容 | 用途 |
| 会话清单 + 按天时间线 | 看这个项目经历了什么 |
| 单场对话完整正文 | 贴进 issue、给别的模型读 |
| 该项目所有会话正文合成一份 | 完整交接材料 |
三种都默认把正文包成 > 引用块——这样原文里偶然出现的 # 结论
不会把导出文档的大纲撕开。工具调用压成一行摘要,加 --tools
展开入参与输出。
网页上对应三个按钮,走同一个 /export 接口,所以浏览器里看到的
和导出的永远是同一份内容(两者都调用 src/export.js)。
噪声剥离是导出质量的前提
导出时发现 Codex 会话有 78% 的正文是注入内容:<app-context>(桌面端
能力说明)、<recommended_plugins>(插件清单)之类。它们以
role: "developer" 的消息出现——这个角色名容易被忽略,因为直觉上
只想着 user/assistant。
修法是:developer 消息整条归为系统内容,并且和 user 消息一样过
stripBlocks;剥空了就丢弃。同一场会话导出从 118 KB 降到 19.5 KB。
stripBlocks 也从"逐个列标签名"改成按形状剥(成对标签、自闭合标签、
无闭合时剥到末尾)。实测 904 场里有 273 场标题是这类标签,
硬编码名单永远追不上 Codex 加功能的速度。
接给别的 Agent:MCP server
这是这个产品真正的用法——不用自己接模型。档案馆包成一个 MCP server,用户已经在用的 Agent(Claude Code、Codex、ZCode、Cursor、 OpenCode、Kiro……)自己就能来读。
node src/cli.js mcp # 打印可粘贴的配置
node src/cli.js mcp --json # 只要配置 JSON粘进对应 Agent 的 MCP 配置即可(各家的位置不同,命令会列出来)。
零依赖实现。 MCP 的 stdio 传输就是「stdin/stdout 上一行一条 JSON-RPC」,手写足够,不需要引入任何 npm 包。
十二个工具
工具 | 作用 |
| 有哪些项目、多少会话、哪些 Agent |
| 某项目的会话清单(只有元数据),每条带派生的子代理数 |
| 跨 Agent 接力:谁在什么时候接手、间隔多久 |
| 全文搜索,返回命中处摘录(含子代理会话,带标记) |
| 读已生成的项目摘要;没有就提示怎么生成,不现编 |
| 读某场会话已有的摘要,用来判断值不值得深挖 |
| 读正文,分段,默认 8000 字 |
| 采集质量、索引规模、已有摘要数量 |
| 导出 Markdown |
| 环境层:各 Agent 的 MCP / CLI / 技能 / 使用率 / 记忆( |
| 调用层:经过网关的请求——token、缓存命中、费用 |
| 用量层:各家 Agent 自己账本里的 token(不需要网关) |
read_digest 和 read_session_summary 是视图层的入口。它们只读已有的
摘要,不会替你调模型——生成要花钱,必须由用户在终端显式执行。
推荐给 Agent 的调用顺序也写在 initialize 的 instructions 里:
list_projects → read_digest(有现成交接材料吗)→
没有就 project_handoffs 看接力(哪次换手间隔最长)→
search_sessions / project_timeline 定位 → read_session 读正文翻 984 场会话的成本,因此可以降一个数量级。
为什么要默认截断
一场会话常有十几万字(约 5 万 token)。如果 read_session 直接返回
全文,Agent 手滑点一次就能烧掉大量上下文额度,而且它往往只需要其中
一段。
所以每个返回正文的工具都有 maxChars 上限(默认 8000,硬上限 40000),
超出时明确报告 totalChars / hasMore / nextOffset,让调用方自己
决定要不要继续读。列表类工具只给元数据,从不夹带正文。
所有工具都标记 readOnlyHint: true——它们不会修改任何 Agent 的原始数据。
调用层:calls——为一场对话付了多少钱
node src/cli.js calls # 按 Agent 汇总
node src/cli.js calls --by model # 按模型
node src/cli.js calls --by session # 按会话,并报出与会话层的匹配率会话层记录"说了什么",调用层记录"为它花了多少"。同一场对话换个模型, 成本能差十倍,而正文完全一样——只看会话看不出这件事。
数据来自本地模型网关的调用日志(实测 ~/.config/magpie/usage.jsonl
15 天 2.5 万条)。网关记得比会话文件细:
字段 | 含义 |
| 本次请求的未缓存输入、输出 token |
| 提示词缓存命中与写入 |
| 首 token 延迟、整次耗时 |
| 供应商自报的额度消耗,单位由各家自定,不是货币金额 |
| 结果、重试次数 |
| 接会话层的钩子 |
实测:输入里 96.8% 是缓存读。没有这个口径,任何成本视图都是错的。
边界要一起报。 网关只看得见经过它的调用:实测 dsh 和 hermes 的
session 字段全空(不走网关),qoder 只有 76 场被网关看到而归档有 310 场。
所以 calls 同时给两个数——86 场会话可对上 · 17704/25868 次调用(68%)。
只报前者会让人以为覆盖率是 100%。这和技能使用率只统计显式调用是同一种诚实。
没装网关就优雅降级:返回 ready: false 和一句怎么启用的说明,不报错。
调用层是可选视图,不是依赖。
三类数字不能混在一起
这一层最容易误读的地方不是缺数据,而是把不同性质的行算进同一个分母。 实测对着 3.2 万条真实日志核过,三处必须分开:
情况 | 实测 | 为什么不能混 |
网关本地拒绝( | 44 条 | 它没往上打、credits 是 0、ms 只有 1 毫秒。混进去会让"这个模型很爱失败""平均耗时很低"两个结论同时失真。现在单独计 |
| 21850 条有值,17112 条为正 | 它是供应商自报的额度消耗,单位由各家自定(次数/积分/美元都有)。写"费用"会让人拿它做预算决策 |
| 30410 条有 served,其中 75% 字面不同 | 绝大多数不是"模型被替换",而是路由组别名: |
会话匹配也必须带 agent:ZCode 和 OpenCode 都用 session_xxx 这类 id,
只按 id 会跨家错挂。改成 agent|id 之后,实测能对上的调用从 17704 次
增加到 20653 次——之前有些调用挂到了别家的同 id 会话上。
上游有、我们没有的两个字段:MAGPIE 的日志里有 handed_on(失败后转交
下一跳)和 source=reported(客户端自报的直连调用),实测 3.2 万条里
一条都没有,所以不做相应处理。查过、不适用,写在这里免得以后重复调研。
用量层:usage——不装网关也能看见 token
node src/cli.js usage # 按 Agent
node src/cli.js usage --by model # 按模型
node src/cli.js usage --by project # 按项目(要先扫归档,把会话挂上去)calls 读的是网关日志,没装网关就是空的;usage 直接读各家 Agent 自己的账本,
装没装网关都有数。两者不是替代关系:网关记的是"每个请求",账本记的是
"这家 Agent 自己认账的用量",粒度不同,不能相加。
实测(一批真实记录):
来源 | 粒度 | 记录 | 未缓存输入 | 缓存读 | 命中 |
ZCode | 每个请求 | 10,299 | 7,217 万 | 26.68 亿 | 97% |
Hermes | 会话 × 模型 | 390 | 7,531 万 | 8.41 亿 | 92% |
OpenCode | 每场会话 | 91 | 2,589 万 | 1.97 亿 | 88% |
Claude Code | 每条消息 | 44 | 82 万 | 332 万 | 80% |
Codex | 每场会话(只有总量) | 1,779 | 200.37 亿 | — | 口径未知 |
同一列 input_tokens,四家有三种含义
这是这一层唯一真正难的地方。字段名一样,含义不一样:
来源 | 实测证据 | 语义 |
ZCode |
| input 含缓存 |
OpenCode | 某场 | input 不含缓存 |
Hermes |
| input 不含缓存 |
Codex | 只有 | 看不出来 |
所以每条记录都带 semantic,统一换算成 fresh(不含缓存的输入)之后再比。
按字面把四家相加,ZCode 那 26 亿缓存读会被按全价算进去,结论差一个数量级。
Codex 这类"只有总量、口径未知"的来源单独列表,也不进缓存命中率——它和按请求 记的数放在同一张表里排名,会让人以为它真的比别人贵三百倍。命中率的分母同样 只收语义已知的行:把两种口径塞进同一个分母,算出来的百分比没有意义。
成本有两本账:自报的,和按价目表估的
$ 是 Agent 自己报的钱——Hermes 有 estimated_cost_usd / actual_cost_usd,
OpenCode 有 cost。实测 12,679 条里只有 12 条,这个比例本身就是要展示的信息。
只报了一部分的行会在金额后加 +。
~$ 是按 models.dev 价目表推出来的估算,覆盖剩下那些没自报的记录(实测 10,657 条)。
两本账永远分两个字段、两个前缀,不会加在一起:一个是"供应商认过的钱",
一个是"按今天的公开价应该花多少"。合成一个数之后,就再也说不清哪部分可信。
价目表单独一层:
node src/cli.js pricing # 看有没有、什么时候抓的、收录多少模型
node src/cli.js pricing sync # 拉一次 models.dev(5.9 MB,只下载不上传)同一个模型在不同供应商下价格不同(官方目录 vs 中转站加价),建索引时 优先厂商自己的目录,中转站只作兜底——否则一个转售商的加价会变成你全部历史 成本的估算基准。缓存价缺失时退回输入价,并标记为近似:宁可高估,不要当免费。
~$ 不是账单。价目表过期、中转站议价、阶梯价、峰谷价都不在估算里,
所以这个数字只适合回答"哪一层最烧钱",不适合拿去对账。
读不到不是错误
各家库的结构会变。缺表、缺列都按"这一家没有用量"处理并给出原因,不影响其他家。
Hermes 老版本只有 sessions 表、缺 billing_provider 列也能读——缺的列留空,
不编一个值。
凭证扫描:secrets
node src/cli.js secrets # 扫全部归档
node src/cli.js secrets high # 只看高危以上
node src/cli.js secrets --agent zcode会话归档里常有真实凭证——AK 粘进对话、工具输出回显、部署脚本硬编码。 这些埋在几万条消息里,人自己看不见,但档案馆有全文,扫一遍就能找出来。
内置特征:阿里云 LTAI*、AWS AKIA*、GitHub gh[pousr]_* /
github_pat_*、sk-*(OpenAI 兼容)、sk-ant-*、私钥文件头、
ALIBABA_CLOUD_ACCESS_KEY_SECRET 赋值、通用的 api_key= / secret=。
输出一律掩码,而且掩码比命中更激进:命中只认已知前缀, 掩码会把上下文里任何 24 位以上的连续字母数字都打掉—— 否则这个工具自己就成了泄漏渠道(第一版就踩过这个坑)。
它只报告,不处置。 真正的修法是去对应平台停用/轮换凭证; 删聊天记录没用,备份和索引里还在。
视图层:摘要由模型做,事实不由模型做
这是整个产品的分界线:
事实层(确定性) 视图层(模型生成)
扫描 → 归一 → 时间线 ──────→ 摘要 / 交接 / 洞察
可复现、可审计、零成本 可重算、有版本、有成本模型只在「这些事实意味着什么」上工作,绝不参与「发生了什么」。 原因很实际:如果摘要烘进事实层,换模型就没法重算,档案馆会被锁死在 生成它的那一代模型上。原始正文存着,随时能用任何模型重新分析。
配置
node src/cli.js llm --key sk-xxx --model deepseek-chat \
--base-url https://api.deepseek.com
node src/cli.js llm # 看当前配置(密钥掩码)密钥写在 ~/.agent-archive/llm.json(权限 600),不进项目目录——
这个目录会进 git,密钥一旦进了历史就洗不掉。
两种请求形状覆盖绝大多数场景:openai(/v1/chat/completions,
DeepSeek / Kimi / Qwen / GLM / Ollama / LM Studio / 各种中转站都是这个)
和 anthropic(/v1/messages)。
环境变量优先于配置文件,方便临时切换:AGENT_ARCHIVE_LLM_API_KEY /
_MODEL / _BASE_URL / _PROVIDER。
生成摘要
node src/cli.js digest E:\my-app # 只估算,不花钱
node src/cli.js digest E:\my-app --run # 真的跑
node src/cli.js digest E:\my-app --limit 20 --run --out digest.md默认只估算。 模型调用要花钱,用户得先看到量级再决定:
预计成本
会话 5 场(项目共 732 场)
已缓存 0 场(这部分不花钱)
输入 约 98.5k token四元组缓存
每份衍生数据的键是:
(kind, subject, source_hash, prompt_version, model)改任何一个都是未命中:会话又聊了几轮(hash 变)、提示词改好了 (version 变)、换了个模型(model 变)。这是视图层能被安全重算的 全部前提——没有它,三个月后你无法回答"哪些摘要是旧的""换模型后 该重算哪些"。
旧结果不会被删,所以能拿新旧模型做对比。
分层
一场会话十几万字、一个项目几百场会话,一次性塞进模型既超上下文 也烧钱。所以逐层收敛:
会话正文 ─┬─ 超长就切块 → 每块摘要 → 合并
└─ 不超长就直接摘要
↓ 会话摘要(缓存)
↓ 项目摘要(缓存)关于云端
不做。理由不是隐私,而是它加不了核心能力:知道每家 Agent 把会话 藏在哪里、怎么解开,100% 是本地的事,云端帮不上。云端能加的是多机 同步和团队共享,那是另一个客户群。
真要做的话顺序是:先同步衍生摘要,不同步原始会话——原始对话里有
API key、客户信息、内部 URL,上传即不可撤销。(这不是假设:
secrets 命令在实测 960 场会话里扫出了 14 条真实凭证。)
增量刷新:refresh
node src/cli.js refresh # 审计:哪些过期了、为什么、要花多少
node src/cli.js refresh --run # 真的重算
node src/cli.js refresh --project E:\my-app四元组缓存本来就让"变了才重算"成立——换模型、改提示词、会话追加内容,
键自然 miss。所以 refresh 不负责重算逻辑,它解决的是另一个问题:
花钱之前看得见哪些过期了。
之前只能对整个项目盲跑 digest --run,跑完才知道花了多少。项目一多就
变成"不知道要不要跑、跑了也不知道值不值"。
过期原因分五类,处置方式完全不同:
原因 | 含义 | 该不该重算 |
| 四元组全对得上 | 不用 |
| 会话有新内容 | 应该 |
| 换了模型 | 想用新模型口径才重算 |
| 提示词改过 | 确认改进过才重算 |
| 本项目里有会话摘要过期 | 跟着重算 |
| 源会话已不在 | 可以清掉 |
一个刻意的边界:只刷新已经存在的摘要,不给没有摘要的项目新建。
否则一条 refresh 命令可能悄悄花掉一大笔,而用户以为只是"更新一下"。
审计本身一次模型都不调用——它只读本地缓存、重算哈希、比对四元组。
网页上的新鲜度提示
项目页和会话页的摘要标题旁会出现「可能已过期」的徽标,命中三种情况之一时:
生成它的模型不是当前配置的模型
提示词版本变过
生成之后这个项目新增了会话
但网页只做不花钱的检查。 有一项它查不了:会话内容变了但数量没变 (同一场又聊了几轮)。这一项要重读正文才能判断,全量 33 秒,不能放在 每次请求里。
所以提示里会明说「这一项查不了,需要准确结果请运行 refresh」。宁可承认
能力边界,也不给一个看起来确定、实际会漏的结论——那比没有提示更糟。
本地网页查看器
serve 起一个只读的本地服务,同一个进程同时提供页面和 JSON:
地址 | 内容 |
| 所有项目 |
| 某项目的时间线(默认最近 300 场, |
| 某场会话的正文 |
| 全文搜索,关键词高亮;默认 20 场, |
| 跨 Agent 接力的项目清单 |
| 某项目的换手点、间隔与并行情况 |
| 能力面:各 Agent 的 MCP / 技能 / 记忆( |
| 用量:token 与成本, |
| 项目清单 |
| 时间线 |
| 各 Agent 分布 |
| 搜索结果 |
| 项目摘要(如果有) |
| 换手数据 |
| 会话正文,默认前 200 条, |
| 会话正文的 HTML 片段,供「加载更多」按块追加 |
| 用量数据 |
| 能力面数据, |
/env 用七个 tab 而不是一个长页:技能清单有几百行、记忆有几十处,
全堆一页会让人找不到重点。默认落在总览——那才是第一眼该看的。
tab 参数只认白名单里的值,别的一律回落到总览,不把用户输入拼进页面。
页面上的环境变量只有键名:值在 env/mcp.js 的 normalize() 里
就被丢掉了,这一层根本拿不到。
/usage 的合计不含口径未知的来源(目前是 Codex 的会话总量):
它们单独一张表,页头只算能按请求拆开的那部分,否则一个 200 亿的累计值
会把"未缓存输入"整体拉歪。用量快照缓存 5 分钟——库里还在写,
缓存太久会让人以为统计不更新。项目页顶部也有一张同口径的用量卡。
项目页会展示已生成的摘要(可展开的卡片),会话页顶部展示会话摘要。 摘要是视图层,和下面的时间线(事实层)在视觉上分开摆—— 点开摘要旁边永远有一句「模型生成的视图,核对细节请点开下面的会话」。
页面外壳是左栏 + 内容:左栏常驻"最近 10 个项目"和项目 / 搜索 / 用量 / 接力 / 能力面 / 体检入口,换项目是一次点击,不用先回列表再找 (几十个项目全铺进左栏等于把首页表格抄一遍,所以只列最近 10 个)。 项目页第一块是接手卡,回答换 Agent 之前最要紧的三件事: 最后停在谁手上(并区分"接续"和"两段并行")、隔了一周以上才接手的断点有几处、 这个项目有没有人把结论写成记忆。前两项从会话现算,第三项来自记忆文件; 模型写的摘要排在它下面——事实在前,视图在后,视觉上也分开。
摘要用极简的 Markdown 渲染(标题/列表/引用/链接/加粗/行内代码/围栏代码块), 先整体转义再套标签,所以摘要里即使混进 HTML 也不会执行。助手回复也走同一套 渲染;用户消息只在明显是 Markdown(粘贴的报告、列表、代码块)时才这么排, 普通说话保持原样。为了这点需求引入一个 markdown 解析器不值得。
三个硬约束:
只监听 127.0.0.1,并校验 Host 头——否则恶意网页可以用 DNS rebinding 把域名指过来,跨域读走实测会话存档。
只处理 GET/HEAD,没有写操作。
会话正文一律 HTML 转义。这些内容是别的 Agent 写的,不能当可信 HTML。 页面同时带 CSP(
default-src 'none',脚本/样式只允许同源), 即使某处漏了转义,也没有内联脚本可以执行。
页面里的折叠块(工具调用、推理)默认只传摘要,展开时才去
/api/session?...&message=N 取那一条。一场会话的推理加工具输出常有
几十万字,全量内联会把页面撑到几百 KB;按需加载后同一页面通常只有一百多 KB。
会话页顶部还有一份对话目录:按用户发问切轮,每行给出时间、提问摘要 (80 字)、工具步数和"有没有最终回复",点一行跳到对应消息。超过 30 轮默认 收起——目录本身不该又变成一屏。这一点学自 cc-switch 的 TurnIndex:目录由 后端算,前端只渲染。
正文也不是一次性全塞进 HTML:首屏只服务端渲染最近 200 条,其余由
「加载更多」按块取 /api/session/html 追加;目录点到的消息还没加载时,
前端会先补块再滚动。一场 1914 条消息的会话,首屏从 1.6 MB 降到 203 KB。
禁用 JS 时用 /s?...&all=1 仍能拿到完整页面。
为什么切换页面不再卡。 以前每个页面第一次打开都要现算它需要的数据: 最近项目要 stat 2585 个文件、能力面要扫 295 个技能目录(约 2.4 秒)、 用量要读 5 个库。cc-switch 和 magpie 不卡,是因为它们把这类重活放在 常驻进程里,并且结果会持久化,前端先出壳再填数据。我们做了对应的四件事:
措施 | 效果 |
最近项目、能力面清单落盘(TTL 5 分钟) | 重启后首访不再重扫 |
| 页面可用时数据已经就绪 |
| 该 tab 从 2.95s 降到毫秒级 |
所有 HTML / JSON / 静态资源 gzip |
|
实测首次点击:/env 12 ms、/usage 33 ms、项目页 153 ms、
/env?tab=usage 7 ms(改造前分别是 2.4 s / 1.7 s / 1.8 s / 2.95 s)。
代价是 serve 启动时多花一两秒读缓存/算一次能力面——这发生在用户开始点
页面之前,比点开转圈更划算。
前端不引任何第三方脚本或样式,也不发任何第三方请求。页面自己的
CSS 和 JS 由本地进程从 src/web/ 同源提供(带 ETag,改完重启即可)。
界面默认深色(TraeCode 的 dark-first),手动切到浅色会记住;Ctrl/Cmd+K 打开命令面板,
可以直接搜会话或跳到某个页面。
cat:怎么把一场对话读得下去
一场两小时的对话可能有几千行工具输出。全部倒出来,人是不会读的。所以:
内容 | 默认 | 展开 |
用户与助手说的话 | 显示 | — |
工具调用 | 压成一行摘要 |
|
推理摘要 | 隐藏 |
|
注入内容(环境、指令、提醒) | 折成一行计数 |
|
工具调用摘要认得各家入参形状:Claude Code 的 {command, description}、
Codex 的 {command:["powershell.exe","-NoProfile",...]}、ZCode 的
{command, description}。取的是真正有信息量的那一段,不是整个 JSON。
--json 给其他 Agent 或脚本消费,结构是 {session, messages},
消息里是 {role, parts, ts, meta},parts 保持原顺序——
说了什么和干了什么是交织的,打乱会读错。
环境层:env——这台机器上各 Agent 会什么
会话层回答"聊过什么",环境层回答"具备什么能力",调用层回答"花了多少"。
node src/cli.js env # 每个 Agent 一行
node src/cli.js env mcp # MCP 服务清单,跨 Agent 归并
node src/cli.js env cli # CLI 产品、安装位置与各 Agent 的调用
node src/cli.js env skills # 技能清单,按内容去重
node src/cli.js env usage # 技能使用率:哪些真用过
node src/cli.js env memory # 记忆与指令文件
node src/cli.js env audit # 体检对应的 MCP 工具是 inventory(type 取 overview / mcp / cli / skills / usage / memory / audit)。
这是给新 Agent 用的入口:它自己调一次 inventory,就知道这台机器上别的
Agent 配了什么、装了什么、记得什么,再自己决定要不要跟进——本工具不替它搬。
技能使用率:实测 290 个技能里用过 25 个
这是环境层唯一"两边数据都要"的功能,也是别的工具做不了的:要同时知道 装了哪些技能(技能目录)和用过哪些(会话里的工具调用)。
实测:290 个技能,用过 25 个,使用率 8.6%。调用最多的是
control-browser(32 次),其余大多一两次。265 个从没被调用过。
但"从没用过"是个上限,不是定论。 实测各家的做法不一样:
Agent | 怎么用技能 | 能否统计 |
ZCode / Claude Code | 调 | ✅ |
Codex | 没有 Skill 工具,直接 Read 那个 SKILL.md | ❌ |
Hermes | 走自己的机制,形态未统一 | ❌ |
所以只统计显式调用 Skill 工具的记录。Codex 那 269 场提到 "Skill" 的 会话里,绝大多数只是在正文里聊到这个词(讨论技能、贴 GitHub URL)。
这里有一个刻意的取舍:不把"读了 SKILL.md 就算用过"算进来。 那会显著抬高使用率,但也会让"从没用过"这个结论失去意义——而后者 才是这个功能的价值。宁可报一个偏保守的下限,并在输出里明说覆盖范围。
同名的技能可能是不同实现
env usage 里会看到三个 xlsx,那不是重复行。实测确实有三个内容哈希
不同的 xlsx 技能(ZCode 两个版本 + Hermes 一份)。它们各自匹配到同一批
使用记录,所以调用次数一样——输出里会标 [同名 3 份实现] 区分。
为什么单独一层,而不是并进会话表
会话是按项目组织的,换 Agent 就断了。MCP / 技能 / 记忆是可迁移的能力与偏好, 恰恰是新 Agent 最想要的东西,而且现在没有任何标准接口。两层共用同一个做法: 没有协议可依附,只能一家一个适配器,集中成注册表。
MCP:按启动指纹认,不按名字认
这是和参照项目最大的分歧。cc-switch 的 McpServer.id 就是原生配置里的键名,
所以同一个服务在不同 Agent 里名字不同时,它会被算成两个。
真实数据(env mcp 实测):
● supabase 4 条注册 / 4 家
别名 adbsupabase / supabase
启动 local / npx一个 supabase 服务,4 条注册,2 个名字,3 种启动形态。如果按名字认,
"我到底配了几份、哪份是活的"就永远答不了。所以身份从 command + args 推:
剥掉
cmd /c、sh -c这类包装层(Windows 上极常见)找
npx拉起的包名 →pkg:@scope/name#npx找
node_modules/<包名>/→pkg:@scope/name#local其余取
dist/之后的脚本路径,或可执行文件名
服务身份 = 去掉安装方式后缀的包名,所以 npx 拉的和本地装的能归到一起;
而 #npx / #local 这个后缀单独保留,正好回答"两边装的到底是不是一个东西"。
supabase 就因此报出「安装形态不一致」——一边钉版本、一边每次拉最新。
各家格式差异(都在 src/env/mcp.js 里写明):
Agent | 位置 | 形状 |
Claude Code |
| 顶层 |
Codex |
|
|
ZCode |
| 多一层: |
Qoder |
|
|
Cursor |
|
|
OpenCode |
|
|
Hermes |
|
|
Gemini |
|
|
TOML 和 YAML 都是手写的最小解析器,只取 mcp_servers 那一段——
为一个字段引依赖不值得,而 Codex 的多行数组用单行正则会被吃掉半行
(第一版就是这么失败的)。
CLI:装在哪,谁能看见
MCP 和技能之外还有一层常被忽略的能力:命令行产品。同一台机器上,
aliyun 可能装在系统 PATH 里,所有 Agent 都能解析到;uv 可能只在
Hermes 的 ~/.hermes/bin 里,换一家就"看不见了"。
所以这一层分两半,事实和推断分开报:
安装位置是事实:扫 PATH 和已知的 Agent 私有 bin 目录,找到哪个可执行文件。
可见范围是推断:系统/共享目录默认对全部 Agent 可见,Agent 私有目录只对那一家可见。
调用记录来自索引:从 shell 工具调用摘要里统计,用来判断"哪家真的跑过",不当作精确调用数。
实测(env cli):
阿里云 CLI 云服务 全部(12) 39 zcode:23, codex:14, qoder:2
~/AppData/Local/Programs/aliyun-cli/aliyun.exe
uv 运行时 hermes 0 —
~/.hermes/bin/uv.exe两行合起来才是完整答案:阿里云 CLI 装在系统目录、12 家都能看见,
但高频调用的是 ZCode(23 次),Codex/Qoder 只是偶尔用;
uv 只装在 Hermes 私有目录,别家看不到。
env audit 会单独提示"只装在单个 Agent 私有目录"的 CLI。
凭证:只给键名,值永不出这个模块
实测多份 Agent 配置文件里存在明文云厂商 AccessKey。
MCP 工具如果原样回传 env,等于把这些凭证直接送进某个模型的上下文。
所以规则是硬的:normalize() 只保留 Object.keys(env),值在那一行就被丢掉,
下游任何一层都拿不到。env audit 会提醒"这个服务有明文凭证",
但也只说键名,并明确写「值未读取、未传输」。
要看值请自己打开配置文件。这个工具自己不能成为泄漏渠道——
和 secrets 命令「掩码比命中更激进」是同一条教训。
技能:按内容哈希认,而且必须跟随符号链接
实测 375 处安装、290 个独立技能。同一个技能在五个目录里装着,按名字认会撑爆列表。
身份用 SKILL.md 的 SHA-256:名字会改(design-taste-frontend 和
-v1 是两代),同名不同内容也真的存在(同一插件的新旧版本就是)。
踩过的坑:第一版用 Dirent.isDirectory() 遍历,Qoder 的 3 个和 Kiro 的
28 个技能全部漏掉,而且静默漏——显示"0 个技能"而不是报错。原因是它们
全是指向 ~/.agents/skills/<name> 的符号链接,isDirectory() 对链接返回 false。
而这恰恰是最典型的跨 Agent 场景:用链接共享一份技能。修法是 lstat + stat,
真目录才参与环路保护(链接不记录已访问路径,否则会误判成环)。
现在 Kiro 显示 28 个、Qoder 32 个,lark-* 系列正确报成三家共享。
每条产出还带 symlinked 计数:全是链接说明只维护一份(好),
有真有假说明有人手工复制过(会漂)。
插件缓存里的历史版本不能算"多个技能"
ZCode 的插件缓存按 <插件>/<版本>/skills/<技能> 组织,升级后旧版本不删。
实测 122 个技能里面有 60 个是历史版本残留——docx 8 份(0.1.0 一路到
0.2.1)、pdf 8 份、computer-use 6 份。
那不是"装了 8 个不同技能",是同一个技能留了 8 个版本。所以这个根按
(插件, 技能名) 只保留版本号最高的一份。ZCode 从 122 落到 62,
全机从 300 落到 290——这才是真实数字。
版本判断只处理数字段(0.2.1 > 0.1.5);读不到版本号的目录原样保留,
宁可多报一个也不猜谁新谁旧。
性能:从 3.2 秒到 0.55 秒
第一版全量扫一次 3.2 秒,而 MCP 是常驻进程、每次 inventory 都重扫一遍。
三段优化,每一段都是先量再改:
改法 | 省下 | 为什么快 |
不陪每个真目录做 | 1.2 s | 环路只可能由符号链接造成,真目录没有回边。Windows 上每次 realpath 要 1-2ms,ZCode 缓存有 700 多个目录 |
一次读文件,正文和哈希共用同一个 buffer | ~0.15 s | 原先是 |
以 | ~1.0 s | 与会话层 |
汇总层还有 5 分钟 TTL 的缓存,因为一个会话里 inventory 可能被连调几次
(先看 overview,再按类型细看)。缓存返回 structuredClone,不是同一引用——
MCP 工具会拿列表去 filter/map,一旦改到缓存对象,后面所有调用都读到污染数据,
症状是"技能数莫名多一个",极难查。
记忆:只报出处,绝不合并
这是最容易做错的一层。用户的 Hermes USER.md 和 Codex MEMORY.md
完全可能写着两条相反的偏好。合并成"你的记忆"是在说谎,而且新 Agent
读到一句被平滑过的结论,比读到两条带出处的矛盾更危险。
所以产出只有三样:在哪、什么格式、有多大。正文不在这层返回。
指令和记忆分开计数,因为语义不同:
instruction — 每轮注入的行为约定(
AGENTS.md/SOUL.md/GEMINI.md)memory — 跨会话积累的事实(
MEMORY.md/ 分片目录 / SQLite 库)
实测 21 处、322 条,覆盖 3 种格式:
Agent | 形态 |
Codex |
|
Hermes |
|
Qoder |
|
ZCode | 项目级: |
ZCode 的项目级记忆是这一层最有价值的部分——它按项目分开,正好能对上
会话层的项目维度。但目录名会混进临时工作区(1792567885219-...、default-...、
draft-...),这些如果当成项目名,会让"这个项目有哪些记忆"里全是噪音。
所以先判内部前缀、再剥 16 位哈希、最后过一道形状检查;认不出项目的
归到 unattributed,不塞进项目列表冒充项目。
体检 env audit
和会话层的 doctor 同一个思路:报数字,不报感觉。检查的都是
"看起来正常、实际会让人做错判断"的东西:
检查 | 为什么值得报 |
安装形态不一致 | npx 每次拉最新、本地安装钉版本,两边行为可能不同 |
同名不同别名 | 不是错误,但会让人以为配了两份 |
明文凭证 | 会被每个读到该配置的 Agent 拿到 |
记忆格式分散 | 换 Agent 后旧记忆不会自动跟过去 |
有数据但无配置能力 | 可能没配过,也可能这家配置放在没适配的位置 |
技能只在共享目录 | 共享目录本身不生效,要有 Agent 目录里的链接或副本才读得到 |
采集体检:doctor
stats 报的是产量(扫了多少文件、识别出多少会话),doctor 报的是质量:
node src/cli.js doctor它检查三件事,任何一项坏掉都会让下游静默失真:
支柱 | 坏了会怎样 |
时间 | 时间线排序错乱,用户看不出问题但结论会错 |
项目 | 会话进不了任何项目,等于对用户隐身 |
标题 | 列表看起来全是垃圾 |
外加:正文抽样可读性、重复 id、以及实测有哪些 Agent 目录存在但还没接入。
指标设计上刻意区分了「没有」和「丢了」:
无项目:该 Agent 多数不记录工作目录(Hermes 165 场里只有 37 场归到了项目),不算缺陷
临时路径:路径存在但属于 Agent 的临时落脚点 (
.zcode/workspace/<uuid>、Documents/Codex/<日期>),被isProjectPath主动过滤,是设计如此占位标题:桌面端自动触发的会话没有真人输入,标题退化为来源名
(Codex Desktop),这是合理降级
所以"待办"里只列真的该修的东西,不会长期挂着已经查清的正常项。
跨平台
各 Agent 的存储位置在不同系统上遵循不同约定。src/platform.js 负责应用数据类路径,
但大多数 Agent 的家目录点目录三平台一致,真正需要单独处理的是少数几个。
类型 | Windows | macOS | Linux |
VS Code 系应用数据 |
|
|
|
XDG 数据目录 |
| 通常是 |
|
家目录点目录 |
| 同左 | 同左 |
受影响的是 Cursor / Kiro / Copilot Chat / OpenCode 四家。
macOS 上最容易搞错的三件事
这三条都是查了官方文档和源码才确认的,和直觉不一样:
一、OpenCode 在 macOS 上不用 Application Support。
它自己的 global-roots.ts 里写死 XDG_DATA_HOME || ~/.local/share,
没有 process.platform === 'darwin' 分支。所以 macOS 上也是
~/.local/share/opencode/opencode.db。看到 ~/.local/share 别以为是 Linux 专用。
(有意思的是:OpenCode 的管理员级配置确实在
/Library/Application Support/opencode/——有 ~ 和没 ~ 是两个完全不同的位置。)
二、~/Library/Application Support/Claude 是桌面应用,不是 Claude Code。
Claude Code 的 CLI 三平台都用 ~/.claude/,官方文档从没给过 Application Support 路径。
两者是不同产品、不同会话格式。
三、Hermes 在 Windows 上官方位置是 %LOCALAPPDATA%/hermes,mac/Linux 是 ~/.hermes。
这是少数真的按平台分叉的一家(源码里 sys.platform == "win32" 判断)。
环境变量是比平台推断更可靠的一层
这是跨平台最值得投入的部分:每个 OS 的默认路径都可能不同,但环境变量是
各家自己定义的、跨平台语义一致的。用户把 CLAUDE_CONFIG_DIR 指到外置盘,
在哪个系统上都该找到。
已支持的覆盖变量(逗号分隔多值,支持 ~ 展开):
Agent | 变量 |
Claude Code |
|
Codex |
|
OpenCode |
|
Qoder |
|
Hermes |
|
Cursor |
|
Kiro |
|
Antigravity |
|
Gemini |
|
Gemini CLI 有一个 macOS 专属的改道:在 sandbox-exec 下写 ~/.gemini
会被 Seatbelt 拦,运行时目录(含会话)改到 ~/.cache/.gemini。
源码注释原文如此。所以两个位置都要探测。
实现上不用 process.platform 硬分支选路径,而是给出候选列表、
按顺序挑第一个真实存在的。这样换机、跨平台拷贝备份、或者某个应用
在某平台上没按约定写盘时,都能命中而不是静默失败。
isProjectPath 的临时目录名单同时涵盖 Windows(Desktop/Documents/
AppData)和 macOS(Library/Applications/Movies…)的系统目录;
assertUnder 的大小写比较跟随文件系统——Windows/macOS 忽略大小写,
Linux 严格区分(在 Linux 上忽略大小写是个真实的绕过口子)。
多根场景用 assertUnderAny:路径落在任意一个候选根下就合法
(CLAUDE_CONFIG_DIR 指到外置盘、~/.config/claude 的历史遗留都要能读)。
macOS 上还要注意的几处细节
/var是/private/var的符号链接,/tmp、/var/folders/...同理。 路径相等比较必须realpath两侧,否则同一条路径会判不相等。APFS 默认大小写不敏感,但可以挂载 case-sensitive 卷。 所以去重前先
realpath,不要依赖"大小写不敏感"当去重手段。 (claude-code-log就把/private/var/folders/硬编码进临时路径过滤表—— 这是路径比较必须 realpath 的典型证据。)沙盒化的 app 会把
$HOME重定向到~/Library/Containers/<bundle-id>/Data/。Desktop 类应用值得两个位置都探。在后台服务(launchd)里跑时
HOME可能不是用户家目录。 Hermes 源码专门用pwd.getpwuid(os.getuid()).pw_dir兜底。
验证状态:
平台
状态
证据
Windows 11
✅ 真机
10 家 Agent、1131 场会话、解析失败 0、重复 id 0
macOS 26.7.1 (Darwin 25.6, x64)
✅ 真机
97 个用例全过(2.0 s);
doctor在干净机器上跑通Linux
⏳ 仅 CI
.github/workflows/test.yml的 ubuntu jobmacOS 那次真机验证抓出两个只有干净机器才会暴露的问题: 零会话时 doctor 首屏全是 ✗(覆盖率分母为 0 被当成失败);反向发现的 "已知路径"排除清单因为读错字段(
pathsvshits)一直是空的, 导致同一屏里既写"已接入:Qoder"又把它列成"未适配"。两个都修了。CI 每次推送在 ubuntu + windows × Node 22.16 / 24 上跑同一套测试(22.16 是
engines声明的最低版本,等于顺带验证那句话是不是真的)。macOS 排在手动触发里: 私有仓库拿不到 macOS runner,每次都在排队 15 分钟后被取消——常年红的门等于没有门, 所以它跟发布一起跑,不跟每次推送跑。想在另一台机器上验,一条命令:
node research/probes/remote-verify.mjs --host user@192.168.1.10 \ --node-bin /tmp/node/bin # 远端没装 Node 时,指向临时解压出来的 bin它把当前 HEAD 打包传过去、跑测试、把结果带回来,跑完删远端临时目录。 远端不需要装依赖(项目零依赖),只要求能免密 ssh 登录。全程不碰远端 任何 Agent 的真实数据——测试都在临时目录里自造数据。
如果只是想看路径层面(不跑测试、不动数据):
node research/probes/mac-check.mjs。已用上游源码做过一次交叉核对(magpie 是跨平台的,它的路径函数按 GOOS 参数化并有 darwin/windows 测试):Hermes、Claude Code、Qoder、DSH、ZCode 五家的 macOS 规则与我们一致,其中 ZCode 的结论值得单独说——它故意不跟随
$HOME覆盖,会话库始终在USERPROFILE\.zcode\cli\db(上游注释里有 ZCode 3.14.3 的实测记录)。详见research/upstream/LESSONS.md。 核对还发现一处覆盖缺口:~/.gemini/antigravity-cli我们没扫。在 Mac / Linux 上首次使用,跑这一个就够:
node research/probes/mac-check.mjs它是单文件、零依赖的,不需要 clone 整个仓库——把文件复制过去
node mac-check.mjs就能跑。会输出一份报告到/tmp/agent-archive-mac-check.txt,直接贴回来即可。它做路径层面的自检:11 个 Agent 的候选路径哪些真实存在、 每个位置有多少会话文件、哪些环境变量被读进来。全程只读。
想跑更完整的验证(真的解析会话、验证正文可读性):
node research/probes/audit-paths.mjs # 秒级:路径假设对不对 node research/probes/verify-mac.mjs # 分钟级:实际读到多少会话 node research/probes/run-all.mjs # 全部七个回归套件
Cursor 的路径反解是个特例
Cursor 的项目目录名是「绝对路径把所有分隔符换成 -」——
/Users/alice/my-repo → Users-alice-my-repo。这个编码有损:
Users-a-my-app 可能是 /Users/a/my-app,也可能是 /Users/a/my/app。
所以取项目路径有明确的优先级:
workspaceStorage/<hash>/workspace.json里的真实路径(权威来源)目录名反解,且只在能确定形状时(Windows 盘符 /
Users-/home-前缀)其余情况返回
null
第 3 条是关键:宁可留空,也不给一个看起来合理、实际错误的路径。 错的路径会静默污染项目归集,比缺数据难发现得多。
为何只有 10 家
实测装了 13 个 Agent,其中三个确认读不了,doctor 会把它们单独列成
「已调研、确认读不了」而不是待办:
Agent | 结论 | 证据 |
Trae | 会话库加密 |
|
Windsurf | 会话文件加密 |
|
Cline | 实测无数据 | 所有库 0 行, |
不去破解加密。 那既不可靠,也会产出错误内容。doctor 把「还没做」
和「做不了」分开报,待办里只留前者——否则清单永远挂着几个不可能完成项。
Antigravity 是中间态:21 场里 3 场有明文 transcript_full.jsonl,
另 18 场是加密 .pb。所以它的元数据能搜、正文只能读 3 场,
readMessages 会直接抛错说明原因,doctor 的正文抽样如实报 1/3。
设计要点
统一模型(src/model.js):所有 scanner 产出
{agent, id, project, title, start, end, source, resume}。
下游只认这个形状,不关心来源格式。
项目路径归一化:Windows 上同一个项目会出现 e:\my-app、
E:\my-app、E:/my-app 三种写法,来自不同 Agent。
比较用小写规范键,展示保留原样。
路径解释在查询层:CLI 只把用户敲的文本原样传下去,
src/query.js 的 absProject() 负责归一成绝对路径。
项目身份属于查询层,不属于展示层。
缓存(src/registry.js):以文件路径为键,(mtime, ctime, size) 都没变就复用
上次的解析结果。这与 CC Switch 的 FileParseCache 是同一策略——绝大多数
会话在两次扫描之间没有变化,而重新解析一个 1.8 MB 的 JSONL 要几十毫秒。
实测 2042 个文件第二次扫描命中 2041 个。
解析代码本身也进指纹。 缓存里带一个 __fingerprint,由内置扫描器
(src/scanners/*.js)和用户扫描器(~/.agent-archive/scanners/**)的内容哈希
算出。改了扫描器而源会话没变时,缓存会自动作废并重扫一次——以前只能手工
把 CACHE_VERSION 加一,忘了就会一直读旧解析结果。
只信 (mtime, size) 会漏数。 恢复备份、云盘回写、git checkout 都会
保留 mtime;某些文件系统的 mtime 精度只有 1-2 秒,同一 tick 内两次写入看不出
差别;等长原地改写 size 完全不变。这三种情况都会让"没变化"这个判断成立,
而内容其实变了。
原来的做法是每个命中文件再算一次文件头 256 字节的哈希(MAGPIE 的判据), 代价是 2500 个文件约 0.7 秒。现在改成比 ctime:它是 inode 状态变更时间, 内容被改写、哪怕 mtime 被回填成原值,ctime 也会变(Windows / Linux / macOS 都实测过),而 stat 本身就能拿到,零额外 IO。头部哈希只在 ctime 不可用的 文件系统上兜底。实测热扫因此从 2.17 秒降到 1.02 秒。
已知盲区:只改文件中间、头不变,且连 ctime 都能被伪造成原值的改写,
这一层检测不到。堵它要对全文做采样哈希,实测 1.6 秒(2400 文件、129 MB),
比整个扫描还慢。真需要准确结果时用 --no-cache。
SQLite 源必须把 -wal 一起算进缓存指纹。 Hermes、ZCode、OpenCode 都是
WAL 模式,新消息先写 -wal,主库文件的 mtime/size 可以长时间不动。只按
主库判断就会漏数,而且不报错:实测 Hermes 的 wal 比主库新 24 秒、
积了 3.4 MB 新消息,缓存却仍然命中。
扫描器通过 extraFiles(file) 声明自己的附属文件,registry 统一合并成指纹
(CACHE_VERSION 因此升到 3,旧缓存自动作废一次)。这条思路来自 MAGPIE
对 Hermes 的做法——它把 (size, mtime) 拼成 revision 并覆盖 checkpoint 后
wal 被截断的情况;这里的差别是"带哪些文件"由 scanner 自己声明。
附属文件只看 size,不看 mtime。 这条是从实测里学到的:SQLite 的 wal 在 没有新数据时,mtime 也会被读标记、checkpoint 元数据反复推动。实测连续三次 采样,两个库的 wal size 一模一样、mtime 却一直在走。按 mtime 判,等于每次 扫描都认定"变了",Hermes 和 ZCode 会被无条件整库重解析。WAL 是追加式的, size 不变就意味着主库里没有新的未合并页,所以只比 size 既能抓住真实变化, 又不会被读写痕迹误伤。
索引侧的 source 解析也得认这几种形态。 source 有四种写法:单文件、
多段 |(Codex 续接会话合并而成)、path#id(SQLite 源)、db:<id>
(Hermes 只记会话 id)。曾经只做了 split('#')[0],另外两种直接
statSync 失败、mtime 记成 null;而 null === null 成立,索引就
永远不会重建——实测 28 场 Codex + 165 场 Hermes 全部中招,表现是
"搜不到刚聊过的内容"。现在多段取最大 mtime、求和 size,db: 回退到
Hermes 库路径。修完 mtime is null 的记录从 193 条降到 0。
长驻进程用 scanCached(),别直接调 scanAll()。 serve 和 MCP 都是常驻的,
一个页面会打出好几个请求(项目列表、时间线、摘要徽标),以前每个请求都完整
重扫一遍。加了 5 分钟 TTL 后实测:首请求 2.35 秒,后续 12 毫秒。返回的是
structuredClone,不是缓存对象本身——下游会拿 sessions 去 filter/sort,
一处误改会污染后面所有调用(inventory 层踩过:技能数莫名多一个)。
CLI、doctor、refresh 这些一次性进程仍用 scanAll():它们只扫一次,TTL 没有收益。
噪音过滤:各 Agent 会把斜杠命令、环境上下文、AGENTS.md 指令块、 IDE 状态当成用户消息写进会话。这些会污染标题,必须在抽取"第一条真实 用户输入"时剥掉。没有用户输入的会话(自动化、子代理触发)退到助手 第一句话,再退到触发来源。
只读:所有 scanner 只读,绝不修改 Agent 的原始会话文件。ZCode 的 SQLite 以 readOnly 打开,用完立即关闭句柄(Windows 上不关会锁文件)。
但 readOnly: true 并不真的不落盘。 实测(把 Hermes 的 state.db 复制到
临时目录后打开,前后各列一次目录):打开之后目录里多出 -shm 和 -wal
两个文件。一个声称只读的工具,会在 Agent 自己的目录里新建文件——不只是脏,
-wal 的存在还会让 Agent 下次启动走不同的 checkpoint 路径,并且这两个文件的
时间戳会污染其他工具(包括本项目)的"有没有变化"判断。
所以统一走 src/sqlite-read.js,分两种情形:
情形 | 做法 | 理由 |
没有 |
| 零文件创建,代价是会忽略 WAL——但没有 WAL 要忽略 |
有 | 复制 db + wal 到临时目录再读 |
|
临时副本按 (size, mtime) 缓存,源没变就复用,不必每次复制 250 MB 的库。
这是 MAGPIE 在 Cursor 上踩过同一个坑后的处理方式。
Hermes 再套一层按会话的增量。 registry 的缓存是按文件判的,state.db 一动
整库 165 场全部重解析——而 Hermes 是追加式写入,聊完一场,别的会话一个字都没变。
所以 hermes.js 里用一次 SQL 聚合给每场会话算指纹(count(*) + max(timestamp)
sum(rowid)),只有指纹变了的才去查首条用户消息。
指纹刻意不用 total(length(content)):实测它要解压每行正文,203 ms,
比原来读全表求首条消息(117 ms)还慢,把省下来的力气全吃回去。三列合起来
只要 6 ms。效果:整库解析从 379 ms 降到 3-4 ms(稳态)。
越界校验**:正文读取的路径来自缓存里记的 source 字段。如果只信它,
一份被改过的缓存就能让本工具读任意文件。所以每次读之前都要求路径落在
该 Agent 自己的根目录之下(messages.assertUnder),与 CC Switch 的
resolve_under_roots 同一个思路。
新增一个 Agent
写一个 src/scanners/<name>.js,导出:
export const name = 'xxx';
export function discover() { /* 返回文件路径数组 */ }
export function parse(file) { /* 一个文件一个会话 */ }
// 或者
export function parseAll(file) { /* 一个文件多个会话,返回数组 */ }
// 想看正文再加一个(cat 用)
export function readMessages(session) { /* 返回统一消息数组 */ }
// SQLite 源再加一个:参与缓存判据的附属文件(WAL 等)
export function extraFiles(file) { return [`${file}-wal`]; }然后在 src/registry.js 的 scanners 数组里加一行。
这不是设计上的偷懒,而是这类工具的唯一组织方式:没有 Agent 提供 "读取我的会话"的标准接口,只能一家一个适配器,把它集中成注册表 才可维护。
下一步(还没做)
漂移样本抽成共享 fixture(
test/fixtures/),供扫描器与 Web/MCP 共用。会话正文做成
Header → Messages → Done流式下发(现在是分块轮询, 首屏已经降到 200 KB 级)。在 Mac / Linux 上跑一次
research/probes/verify-mac.mjs,按输出修正平台差异批量刷新多项目:现在
refresh一次处理全部,但缺一个"只刷最近活跃项目"的过滤
只有等这个档案馆在你手上真正用起来之后,才值得往上加蒸馏和交接摘要。
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.
Read-only MCP server exposing a user ORANO library to their own AI agent.
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Hosted MCP server for live public-data APIs and Skills for AI agents.
Related MCP Servers
- AlicenseAqualityBmaintenanceA read-only MCP server that exposes local coding-agent session logs as three tools for introspection of recent work, debugging tool failures, and tracking token usage and estimated cost without parsing log files.3MIT
- AlicenseNot gradedqualityBmaintenanceA local MCP server that lets AI agents publish markdown/HTML to a browser viewer with session management, revision diffs, and live updates via a single tool call.2MIT
- FlicenseBqualityDmaintenanceRead-only MCP server for local Warp/Oz agent data, exposing memories, notebooks, conversations, queries, project rules, and cross-agent handoffs.13-
- FlicenseBqualityDmaintenanceProvides a minimal MCP server with safe tools for AI assistants to search project files, read files, search local documentation, and run pre-approved checks within a single project directory.4-