From 216c590fd9dee4eb356fa02859feb756e503b222 Mon Sep 17 00:00:00 2001 From: iroha3 <275074778+iroha3@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:54:20 +0800 Subject: [PATCH] feat(antigravity): index Antigravity IDE transcripts Antigravity IDE is a separate tool from the Antigravity CLI (and the desktop app): it writes full plaintext transcripts under ~/.gemini/antigravity-ide/brain//.system_generated/logs/transcript.jsonl, while the CLI keeps only an encrypted body plus a summaries DB. Read the IDE transcripts from a dedicated adapter, mirroring the Cursor CLI/IDE split: two adapters, one shared AgentId::Antigravity, two roster instances. The existing CLI adapter is left untouched. Parses user requests, thinking, tool calls and outputs, and checkpoints. User-uploaded images live under the session's .user_uploaded/ and are re-attached to the nearest user turn by the media filename timestamp (28/36 within 60s on real data). --- PRODUCT.md | 4 +- README.md | 5 +- .../wake-core/src/adapters/antigravity_ide.rs | 737 ++++++++++++++++++ crates/wake-core/src/adapters/mod.rs | 8 +- crates/wake-core/src/adapters/remote.rs | 2 +- crates/wake-core/src/bin/wake_cli.rs | 2 +- crates/wake-core/src/bin/wake_mcp.rs | 2 +- crates/wake-core/src/models.rs | 15 +- crates/wake-core/src/remote.rs | 12 +- crates/wake-core/src/services/terminal/mod.rs | 5 + crates/wake-core/tests/adapter_contracts.rs | 82 +- .../.system_generated/logs/transcript.jsonl | 8 + .../.user_uploaded/media_1788055868000.png | Bin 0 -> 68 bytes crates/wake/src/theme.rs | 2 + crates/wake/src/workbench.rs | 2 +- 15 files changed, 866 insertions(+), 20 deletions(-) create mode 100644 crates/wake-core/src/adapters/antigravity_ide.rs create mode 100644 crates/wake-core/tests/fixtures/antigravity/brain/ag-ide-0001/.system_generated/logs/transcript.jsonl create mode 100644 crates/wake-core/tests/fixtures/antigravity/brain/ag-ide-0001/.user_uploaded/media_1788055868000.png diff --git a/PRODUCT.md b/PRODUCT.md index 1fbe282..d182038 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -28,9 +28,9 @@ Corey 本人(独立开发者,主力工具 Claude Code 与 Codex,中文为主)。 ## Capabilities and Constraints -已实现:二十一家 adapter、FTS5 trigram 搜索(<3 码点 LIKE 降级)、**搜索跳转定位**(2026-08-18:⌘K 命中直达详情页对应消息并高亮,seq 契约保证)、详情页逐消息渲染(气泡/工具折叠簇/thinking/tree-sitter 高亮;2026-08-17 由整篇 markdown 方案升级)、恢复/收藏/置顶/导出/删除(废纸篓+墓碑)、文件监听增量、Insights 统计页(0.3.0:活跃热力图与 streak、时段/星期/月份分布、Agents·Projects·Models 三榜单按 sessions/tokens/prompts 切换;2026-09-03 加 Last 7 days 周对比行与 Over time 近 53 周按 agent 堆叠的每周 prompts 趋势图;口径=主线用户消息,规格见 DESIGN.md)、Updates 页与 macOS Wake 菜单手动检查 GitHub 最新正式版并打开 Release 页更新(不后台联网、不自动替换应用包)、测试套件(adapter 契约、DB 往返、scanner 回归 + CI 三平台 + pre-commit,合成 fixture)、三端桌面(macOS 主平台;Linux/Windows experimental,终端恢复/废纸篓·回收站/剪贴板按平台原生实现,发版 CI 自动出六产物)、窗口记忆(0.3.6:记住所在屏幕/屏内位置/最大化/全屏,重启与 Dock 重开都回到原处,Settings 开在主窗所在屏)、完整 macOS 菜单栏(0.3.6:标准 Edit/Window 菜单、⌘W、Window → Main Window,主窗关闭后 About/Settings/Updates 仍可用)、导出 Markdown 与保存图片走系统「另存为」并记住上次目录(0.3.6,issue #25)、远程 host(0.4.0,issue #21:Settings → Remote hosts 配 SSH 目标,rsync 白名单镜像会话数据到本地缓存后与本地会话同列同搜,`@host` 徽章标识;阶段 1 只读——不动远端文件、远程会话禁删,resume 以 Copy SSH command 形态提供;分期计划见 CLAUDE.md「远程 host」节)、**MCP 接入**(0.5.0:随应用打包的只读 `wake-mcp` stdio server,四个工具 wake_search / wake_list_sessions / wake_get_session / wake_list_projects,Claude Code / Codex / Cursor 等任何 MCP 客户端可搜历史、按项目列会话、分页读转录;Settings → Connect 展示路径与可复制配置片段,只展示不代写别家配置;零 LLM、零网络、不写库;完整参考 docs/mcp.md)、**命令行与 Skill**(0.6.0:并排打包的只读 `wake-cli`,四个子命令 search / sessions / show / projects 加 setup,给"能跑 shell 但不说 MCP"的 agent 和真人用——它不新写查询逻辑,argv 翻成 JSON 喂同一批工具,输出与 MCP 客户端看到的**逐字节相同**(仅 CLI 末尾补一个换行)并由测试卡住,所以文档只需描述一份格式;`skills/wake/SKILL.md` 是可一行安装的 skill(`npx skills add iAmCorey/Wake`),用触发条件而非名词解释写 description,让 agent 自己想得起来去翻会话历史;完整参考 docs/cli.md;0.6.3 加 `wake-cli index`——装了 Wake 却从没启动过时,agent 自己跑一条就能把索引建起来继续干活,而不是停下来问人。它**只建不存在的索引**,已有的库原样交给 GUI:扫描落在旁边的临时库、扫完才原子占位,所以被中途杀掉可重试、撞上 GUI 首扫就退让;2026-09-22 加 `wake-cli refresh`,issue #43:app 不开时增量刷新已有索引,给靠 MCP / CLI 用 Wake、只在浏览时开 app 的人用 launch agent / systemd timer 定时跑,Wake 开着窗或还有扫描在写就退让——GUI 与两个写命令共用一把索引写锁,锁跟 Workbench 与写线程走、关窗即放,GUI 启动会等正在跑的 refresh 收尾)、Connect 页四区块(0.6.1:MCP server / MCP clients / Command line / Skill,每个面的第一块挂自己的文档链接)、**记忆可见层**(2026-09-17 起,只读:把各家 agent 自己写下的记忆——Claude Code 的按项目 auto-memory、Codex 的 memories 与逐会话摘要、ZCode 的按项目记忆——按项目聚合;GUI 的 Memory 页是侧栏底部的整页目的地,侧栏在这一页换成按记忆计数的 All Memory / Agents / Projects 导航;MCP 加第五个工具 `wake_list_memories`,`wake_get_session` 认 `wake://memory/…` 引用现场读文件,`wake_search` 末尾附记忆命中;CLI 加 `memories`;记忆的项目归属按它挂的会话在读时解析,永远跟着索引走。二期(2026-09-21)把用户写给 agent 的指令文件也收进来:各家 home 里的 CLAUDE.md / AGENTS.md(与 `rules/*.rules`)/ GEMINI.md,以及已索引项目根下的 CLAUDE.md / AGENTS.md / GEMINI.md / `.cursor/rules` / `.cursorrules` / `.kiro/steering` / `.github/copilot-instructions.md`,与记忆同列同搜、不另作区分;Settings 加 Memory locations 页,与 Session locations 同形制:逐来源开关、添加自定义目录或文件、Restore defaults。不编辑、不同步、不删除——写别家数据破铁律,要改一个 Reveal 就到;也不做"把会话自动写成记忆")。后续边界(2026-09-07 定):摘要层(需 LLM、opt-in、明示会把会话正文发给用户配置的端点)只在 MCP 接入有真实使用之后再做;Timeline / Profile 一类视图建立在摘要之上;不做 ChatGPT / Claude.ai 导出导入主线、不做实体本体与知识图谱、不做 Chrome 扩展注入。 +已实现:二十二家 adapter、FTS5 trigram 搜索(<3 码点 LIKE 降级)、**搜索跳转定位**(2026-08-18:⌘K 命中直达详情页对应消息并高亮,seq 契约保证)、详情页逐消息渲染(气泡/工具折叠簇/thinking/tree-sitter 高亮;2026-08-17 由整篇 markdown 方案升级)、恢复/收藏/置顶/导出/删除(废纸篓+墓碑)、文件监听增量、Insights 统计页(0.3.0:活跃热力图与 streak、时段/星期/月份分布、Agents·Projects·Models 三榜单按 sessions/tokens/prompts 切换;2026-09-03 加 Last 7 days 周对比行与 Over time 近 53 周按 agent 堆叠的每周 prompts 趋势图;口径=主线用户消息,规格见 DESIGN.md)、Updates 页与 macOS Wake 菜单手动检查 GitHub 最新正式版并打开 Release 页更新(不后台联网、不自动替换应用包)、测试套件(adapter 契约、DB 往返、scanner 回归 + CI 三平台 + pre-commit,合成 fixture)、三端桌面(macOS 主平台;Linux/Windows experimental,终端恢复/废纸篓·回收站/剪贴板按平台原生实现,发版 CI 自动出六产物)、窗口记忆(0.3.6:记住所在屏幕/屏内位置/最大化/全屏,重启与 Dock 重开都回到原处,Settings 开在主窗所在屏)、完整 macOS 菜单栏(0.3.6:标准 Edit/Window 菜单、⌘W、Window → Main Window,主窗关闭后 About/Settings/Updates 仍可用)、导出 Markdown 与保存图片走系统「另存为」并记住上次目录(0.3.6,issue #25)、远程 host(0.4.0,issue #21:Settings → Remote hosts 配 SSH 目标,rsync 白名单镜像会话数据到本地缓存后与本地会话同列同搜,`@host` 徽章标识;阶段 1 只读——不动远端文件、远程会话禁删,resume 以 Copy SSH command 形态提供;分期计划见 CLAUDE.md「远程 host」节)、**MCP 接入**(0.5.0:随应用打包的只读 `wake-mcp` stdio server,四个工具 wake_search / wake_list_sessions / wake_get_session / wake_list_projects,Claude Code / Codex / Cursor 等任何 MCP 客户端可搜历史、按项目列会话、分页读转录;Settings → Connect 展示路径与可复制配置片段,只展示不代写别家配置;零 LLM、零网络、不写库;完整参考 docs/mcp.md)、**命令行与 Skill**(0.6.0:并排打包的只读 `wake-cli`,四个子命令 search / sessions / show / projects 加 setup,给"能跑 shell 但不说 MCP"的 agent 和真人用——它不新写查询逻辑,argv 翻成 JSON 喂同一批工具,输出与 MCP 客户端看到的**逐字节相同**(仅 CLI 末尾补一个换行)并由测试卡住,所以文档只需描述一份格式;`skills/wake/SKILL.md` 是可一行安装的 skill(`npx skills add iAmCorey/Wake`),用触发条件而非名词解释写 description,让 agent 自己想得起来去翻会话历史;完整参考 docs/cli.md;0.6.3 加 `wake-cli index`——装了 Wake 却从没启动过时,agent 自己跑一条就能把索引建起来继续干活,而不是停下来问人。它**只建不存在的索引**,已有的库原样交给 GUI:扫描落在旁边的临时库、扫完才原子占位,所以被中途杀掉可重试、撞上 GUI 首扫就退让;2026-09-22 加 `wake-cli refresh`,issue #43:app 不开时增量刷新已有索引,给靠 MCP / CLI 用 Wake、只在浏览时开 app 的人用 launch agent / systemd timer 定时跑,Wake 开着窗或还有扫描在写就退让——GUI 与两个写命令共用一把索引写锁,锁跟 Workbench 与写线程走、关窗即放,GUI 启动会等正在跑的 refresh 收尾)、Connect 页四区块(0.6.1:MCP server / MCP clients / Command line / Skill,每个面的第一块挂自己的文档链接)、**记忆可见层**(2026-09-17 起,只读:把各家 agent 自己写下的记忆——Claude Code 的按项目 auto-memory、Codex 的 memories 与逐会话摘要、ZCode 的按项目记忆——按项目聚合;GUI 的 Memory 页是侧栏底部的整页目的地,侧栏在这一页换成按记忆计数的 All Memory / Agents / Projects 导航;MCP 加第五个工具 `wake_list_memories`,`wake_get_session` 认 `wake://memory/…` 引用现场读文件,`wake_search` 末尾附记忆命中;CLI 加 `memories`;记忆的项目归属按它挂的会话在读时解析,永远跟着索引走。二期(2026-09-21)把用户写给 agent 的指令文件也收进来:各家 home 里的 CLAUDE.md / AGENTS.md(与 `rules/*.rules`)/ GEMINI.md,以及已索引项目根下的 CLAUDE.md / AGENTS.md / GEMINI.md / `.cursor/rules` / `.cursorrules` / `.kiro/steering` / `.github/copilot-instructions.md`,与记忆同列同搜、不另作区分;Settings 加 Memory locations 页,与 Session locations 同形制:逐来源开关、添加自定义目录或文件、Restore defaults。不编辑、不同步、不删除——写别家数据破铁律,要改一个 Reveal 就到;也不做"把会话自动写成记忆")。后续边界(2026-09-07 定):摘要层(需 LLM、opt-in、明示会把会话正文发给用户配置的端点)只在 MCP 接入有真实使用之后再做;Timeline / Profile 一类视图建立在摘要之上;不做 ChatGPT / Claude.ai 导出导入主线、不做实体本体与知识图谱、不做 Chrome 扩展注入。 约束:对 agent 数据目录只读;绝不写 Codex 的 SQLite;不读凭证;GPUI 无 SF Symbols(图标用 lucide SVG 自备)。 -已支持二十一家 agent:Claude Code、Codex(0.6.6 起排除可识别的内部线程:guardian auto-review、`/review`、compaction、memory consolidation 与用户会话同树,adapter 文件边界按首行 session_meta 的 source / thread_source 认出即不枚举;老格式或读不出的首行保守放行,`codex exec --thread-source` 打了标签的自动化线程照常收录,issue #30。`spawn_agent` 子代理是其中唯一的例外,issue #42:那是用户自己派出去的活,收录后挂在派它的会话下面、以任务名为标题,fork 进来的父线程历史折成一条不入库的占位,父会话的对话不会被索引两遍)、Qoder CLI(`~/.qoder/projects` JSONL,active-leaf 分支恢复、tool result 回挂、`QODER_CONFIG_DIR`)、Copilot CLI、Cursor(CLI 转录 + 0.6.7 起 IDE Chat/Composer 历史,PR #32:`globalStorage/state.vscdb` 明文 KV 库,composerData 给顺序、bubbleId 行给正文;转录带正文的会话归转录源,库只补转录缺失或只剩 turn_ended 空壳的会话;库里多数老会话没存工作区路径,落 Unknown project)、OpenCode(含 OpenCode 2 next,stable 的 `opencode.db` 与 next 的 `opencode-next.db` 同时扫描;逐会话兼容 `message+part`、真实 `session+session_message` 及早期 `session_v2` schema;preview 会话标 opencode2 徽章)、Kiro、Gemini CLI(2026-08-17 P1 五家落地)+ Pi、Oh My Pi、Grok Build、Kimi Code、Antigravity CLI(2026-08-19 对齐 kooky 内置 roster;Antigravity 正文加密仅元数据卡片)+ DeepSeek Harness(dsh,2026-08-20,zstd 事件日志透明解压;格式由源码推断,当天用户跑出真实会话完成首验)+ Hermes Agent(2026-09-03,多档案 state.db,无 cwd 故 project 留空)、OpenClaw(2026-09-03,两代存储同扫;本机零会话,格式由源码推断未经真机验证)、CodeBuddy(2026-09-14,issue #27:`~/.codebuddy/projects` 的 Responses 形 JSONL,custom / ai / topic 三级标题,`CODEBUDDY_CONFIG_DIR`;本机零会话,格式由官方 CLI 文档与三家第三方实现交叉推断、未经真机验证)、WorkBuddy(同日,CodeBuddy 的孪生实例:同一解析核心,`~/.workbuddy/projects`、`WORKBUDDY_CONFIG_DIR`;桌面 app 无 CLI,不提供 resume;同构只有 cc-switch 一家佐证)、ZCode(2026-09-21:Z.ai 的 GLM-5.3 官方 harness,桌面 app;运行时是 OpenCode 衍生物——`~/.zcode/cli/db/db.sqlite` 的 session/message/part 与 OpenCode v1 同形、part 解码复用,但 session 表没有 model/tokens 列故另起一家;`v2/tasks-index.sqlite` 只借 deleted / migration_source 两个过滤位;按 `task_type` 白名单只列 interactive / fork / selection_side_chat(subagent_child 等运行时会话不列);`semantics.transcriptVisibility == hidden` 与用户消息 `origin != real_user` 归 Meta、compact_summary 折成一条、字段缺席放行;`ZCODE_STORAGE_DIR`;桌面 app 无 CLI、`zcode://` 没有按会话打开的深链,不提供 resume;本机 3.14.0 真会话首验通过,同日开源(Apache-2.0)后语义全部按源码核对)、Craft Agents(2026-09-24,issue #44:Craft Docs 的开源桌面 app,自己不带引擎——Anthropic 连接跑 Claude Agent SDK、其余连接(ChatGPT / Codex 订阅、Copilot、Google)跑 Pi SDK,另记一份给用户看的 `~/.craft-agent/workspaces/<工作区>/sessions//session.jsonl`,Wake 读这一份;子任务与分支挂回原会话,分支从父会话复制来的那段折成一条;Claude 连接在 `~/.claude/projects` 留下的引擎副本由通用的"认领"机制藏掉,原件还在才藏;工作区可建在任意位置,但登记表带 server token 不读,别处的工作区走 Session locations;桌面 app 无 CLI,不提供 resume;本机 0.13.5 真会话首验通过,ChatGPT 与 Claude 连接各一条)、Devin(2026-09-24,issue #46:`~/.local/share/devin/cli/sessions.db` 单库两表,message_nodes 森林按 `main_chain_id` 叶子沿 `parent_node_id` 取主链,hidden 会话与零正文不列,逐消息 `generation_model` 与 `metrics` 按主链累计;`XDG_DATA_HOME`;`devin --resume ` 按 cwd 分桶 resume;格式按本机真库验证)。做不了的:Windsurf/Trae 加密,Amp/Factory(Droid)/Warp 云端无本地数据,Reasonix 本机零会话格式未实测。 +已支持二十二家 agent:Claude Code、Codex(0.6.6 起排除可识别的内部线程:guardian auto-review、`/review`、compaction、memory consolidation 与用户会话同树,adapter 文件边界按首行 session_meta 的 source / thread_source 认出即不枚举;老格式或读不出的首行保守放行,`codex exec --thread-source` 打了标签的自动化线程照常收录,issue #30。`spawn_agent` 子代理是其中唯一的例外,issue #42:那是用户自己派出去的活,收录后挂在派它的会话下面、以任务名为标题,fork 进来的父线程历史折成一条不入库的占位,父会话的对话不会被索引两遍)、Qoder CLI(`~/.qoder/projects` JSONL,active-leaf 分支恢复、tool result 回挂、`QODER_CONFIG_DIR`)、Copilot CLI、Cursor(CLI 转录 + 0.6.7 起 IDE Chat/Composer 历史,PR #32:`globalStorage/state.vscdb` 明文 KV 库,composerData 给顺序、bubbleId 行给正文;转录带正文的会话归转录源,库只补转录缺失或只剩 turn_ended 空壳的会话;库里多数老会话没存工作区路径,落 Unknown project)、OpenCode(含 OpenCode 2 next,stable 的 `opencode.db` 与 next 的 `opencode-next.db` 同时扫描;逐会话兼容 `message+part`、真实 `session+session_message` 及早期 `session_v2` schema;preview 会话标 opencode2 徽章)、Kiro、Gemini CLI(2026-08-17 P1 五家落地)+ Pi、Oh My Pi、Grok Build、Kimi Code、Antigravity CLI(2026-08-19 对齐 kooky 内置 roster,正文加密仅元数据卡片)、Antigravity IDE(2026-09-05 起读 `~/.gemini/antigravity-ide/brain` 明文转录全解析,含用户贴图;2026-09-25 与 CLI 拆成两个 agent——共用一个笼统的 Antigravity 名会让人以为桌面端 agy 本体也支持了)+ DeepSeek Harness(dsh,2026-08-20,zstd 事件日志透明解压;格式由源码推断,当天用户跑出真实会话完成首验)+ Hermes Agent(2026-09-03,多档案 state.db,无 cwd 故 project 留空)、OpenClaw(2026-09-03,两代存储同扫;本机零会话,格式由源码推断未经真机验证)、CodeBuddy(2026-09-14,issue #27:`~/.codebuddy/projects` 的 Responses 形 JSONL,custom / ai / topic 三级标题,`CODEBUDDY_CONFIG_DIR`;本机零会话,格式由官方 CLI 文档与三家第三方实现交叉推断、未经真机验证)、WorkBuddy(同日,CodeBuddy 的孪生实例:同一解析核心,`~/.workbuddy/projects`、`WORKBUDDY_CONFIG_DIR`;桌面 app 无 CLI,不提供 resume;同构只有 cc-switch 一家佐证)、ZCode(2026-09-21:Z.ai 的 GLM-5.3 官方 harness,桌面 app;运行时是 OpenCode 衍生物——`~/.zcode/cli/db/db.sqlite` 的 session/message/part 与 OpenCode v1 同形、part 解码复用,但 session 表没有 model/tokens 列故另起一家;`v2/tasks-index.sqlite` 只借 deleted / migration_source 两个过滤位;按 `task_type` 白名单只列 interactive / fork / selection_side_chat(subagent_child 等运行时会话不列);`semantics.transcriptVisibility == hidden` 与用户消息 `origin != real_user` 归 Meta、compact_summary 折成一条、字段缺席放行;`ZCODE_STORAGE_DIR`;桌面 app 无 CLI、`zcode://` 没有按会话打开的深链,不提供 resume;本机 3.14.0 真会话首验通过,同日开源(Apache-2.0)后语义全部按源码核对)、Craft Agents(2026-09-24,issue #44:Craft Docs 的开源桌面 app,自己不带引擎——Anthropic 连接跑 Claude Agent SDK、其余连接(ChatGPT / Codex 订阅、Copilot、Google)跑 Pi SDK,另记一份给用户看的 `~/.craft-agent/workspaces/<工作区>/sessions//session.jsonl`,Wake 读这一份;子任务与分支挂回原会话,分支从父会话复制来的那段折成一条;Claude 连接在 `~/.claude/projects` 留下的引擎副本由通用的"认领"机制藏掉,原件还在才藏;工作区可建在任意位置,但登记表带 server token 不读,别处的工作区走 Session locations;桌面 app 无 CLI,不提供 resume;本机 0.13.5 真会话首验通过,ChatGPT 与 Claude 连接各一条)、Devin(2026-09-24,issue #46:`~/.local/share/devin/cli/sessions.db` 单库两表,message_nodes 森林按 `main_chain_id` 叶子沿 `parent_node_id` 取主链,hidden 会话与零正文不列,逐消息 `generation_model` 与 `metrics` 按主链累计;`XDG_DATA_HOME`;`devin --resume ` 按 cwd 分桶 resume;格式按本机真库验证)。做不了的:Windsurf/Trae 加密,Amp/Factory(Droid)/Warp 云端无本地数据,Reasonix 本机零会话格式未实测。 ## Brand Commitments diff --git a/README.md b/README.md index 3ae345d..fd4c8dd 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ Your agent history is scattered across `~/.claude`, `~/.codex`, and a dozen othe | Oh My Pi | `~/.omp/agent/sessions/**/*.jsonl` | ✅ | — | | Grok Build | `~/.grok/sessions/**/updates.jsonl` | ✅ | — | | Kimi Code | `~/.kimi-code/sessions/**/wire.jsonl` | — | — | -| Antigravity CLI | `~/.gemini/antigravity-cli/conversation_summaries.db` (metadata only — transcripts are encrypted) | — | — | +| Antigravity CLI | `~/.gemini/antigravity-cli/conversation_summaries.db` (metadata only — the transcript bodies are encrypted) | ✅ | — | +| Antigravity IDE | `~/.gemini/antigravity-ide/brain/**/transcript.jsonl` (full transcripts, plus pasted images under the session's `.user_uploaded/`) | ✅ | — | | DeepSeek Harness (`dsh`) | `~/.dsh/sessions/**/session[.vN].jsonl[.zstd]` (the newest log format generation is read; zstd-compressed logs are decoded transparently) | ✅ | — | | Hermes Agent | `~/.hermes/state.db` + `profiles/*/state.db` (`HERMES_HOME` is respected) | ✅ | ✅ | | OpenClaw | `~/.openclaw/agents/*/agent/openclaw-agent.sqlite` + legacy `agents/*/sessions/*.jsonl` (`OPENCLAW_STATE_DIR` is respected) | ✅ | ✅ | @@ -220,7 +221,7 @@ CI runs `cargo test -p wake-core` plus a full app build on every push to main an crates/ ├── wake-core # pure data layer, no UI dependencies │ ├── adapters/ # claude / codex / qoder / copilot / cursor / opencode / kiro / hermes / openclaw -│ │ # gemini / pi / omp / grok / kimi / antigravity / dsh +│ │ # gemini / pi / omp / grok / kimi / antigravity-cli / antigravity-ide / dsh │ │ # (AgentAdapter trait — add an adapter, get the whole UI for free; │ │ # remote.rs wraps any adapter over a synced cache for remote hosts) │ ├── remote.rs # remote hosts: ssh probe + rsync whitelist mirror into /remotes// diff --git a/crates/wake-core/src/adapters/antigravity_ide.rs b/crates/wake-core/src/adapters/antigravity_ide.rs new file mode 100644 index 0000000..e198587 --- /dev/null +++ b/crates/wake-core/src/adapters/antigravity_ide.rs @@ -0,0 +1,737 @@ +use super::parse_utils::*; +use super::AgentAdapter; +use crate::models::*; +use anyhow::Result; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +/// Antigravity IDE(Google 编辑器,与 CLI、桌面端是三件工具): +/// 扫描 `~/.gemini/antigravity-ide/brain//.system_generated/logs/transcript.jsonl`, +/// 解析完整的用户请求、模型思考过程、工具调用与输出、以及检查点摘要。 +/// 用户的贴图另存在同会话的 `.user_uploaded/media_.` 目录里, +/// 正文中不含图片数据。 +/// +/// 与 `antigravity`(读 `~/.gemini/antigravity-cli` 的 summaries,只有元数据卡片) +/// 是两个独立 adapter、各用各的 `AgentId`(CLI = `Antigravity`,IDE = `AntigravityIde`)。 +/// 刻意不共用一个笼统的 "Antigravity" 名:那会让人以为桌面端(agy 本体)也支持了。 +/// `~/.gemini/antigravity/` 是桌面端的数据,不归本 adapter。 +pub struct AntigravityIdeAdapter { + brain_root: Option, + projects_json: PathBuf, + projects_cache: std::sync::Mutex)>>, +} + +impl AntigravityIdeAdapter { + pub fn new() -> Self { + let gemini = super::home_dir().unwrap_or_default().join(".gemini"); + Self { + // 只认 IDE 自己的数据根;`~/.gemini/antigravity/` 是桌面端的数据, + // 两者不是一回事,不要互相回退 + brain_root: Some(gemini.join("antigravity-ide").join("brain")), + projects_json: gemini.join("projects.json"), + projects_cache: std::sync::Mutex::new(None), + } + } + + fn projects_map(&self) -> HashMap { + let mtime = std::fs::metadata(&self.projects_json) + .map(|m| mtime_ms(&m)) + .unwrap_or(0); + { + let cache = self.projects_cache.lock().unwrap(); + if let Some((t, map)) = cache.as_ref() { + if *t == mtime { + return map.clone(); + } + } + } + let mut out = HashMap::new(); + if let Ok(raw) = std::fs::read_to_string(&self.projects_json) { + if let Ok(v) = serde_json::from_str::(&raw) { + if let Some(serde_json::Value::Object(map)) = v.get("projects") { + for (path, name) in map { + if let Some(n) = name.as_str() { + out.insert(path.clone(), n.to_string()); + } + } + } + } + } + *self.projects_cache.lock().unwrap() = Some((mtime, out.clone())); + out + } + + fn lookup_project_path(&self, candidate: &str) -> Option { + if candidate.is_empty() { + return None; + } + let map = self.projects_map(); + let cand_norm = candidate.to_lowercase().replace('/', "\\"); + let mut best: Option<(&String, usize)> = None; + for (proj_path, _) in &map { + let p_norm = proj_path.to_lowercase().replace('/', "\\"); + if cand_norm.starts_with(&p_norm) + && best.as_ref().map_or(true, |(_, len)| p_norm.len() > *len) + { + best = Some((proj_path, p_norm.len())); + } + } + best.map(|(p, _)| p.clone()) + } + + fn build_meta(&self, r: &SessionFileRef, parsed: &AntigravityIdeParse) -> SessionMeta { + let title = parsed + .title + .as_deref() + .filter(|t| !t.is_empty()) + .map(clean_title_candidate) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| UNTITLED.to_string()); + + let mut project_path = parsed.project_path.clone().unwrap_or_default(); + if !project_path.is_empty() { + let p = Path::new(&project_path); + if p.is_file() || p.extension().is_some() { + if let Some(parent) = p.parent() { + project_path = parent.to_string_lossy().to_string(); + } + } + } + if let Some(mapped) = self.lookup_project_path(&project_path) { + project_path = mapped; + } + + let created_at = if parsed.created_at > 0 { + parsed.created_at + } else { + r.mtime_ms + }; + let updated_at = if parsed.updated_at > 0 { + parsed.updated_at + } else { + r.mtime_ms + }; + + SessionMeta { + key: format!("antigravity:{}", r.native_id), + host: String::new(), + id: r.native_id.clone(), + agent: AgentId::AntigravityIde, + title, + project_name: project_name_of(&project_path), + project_path, + file_path: r.file_path.clone(), + created_at, + updated_at, + message_count: parsed.messages.len() as i64, + size_bytes: r.size, + git_branch: None, + model: parsed.model.clone(), + tokens_used: None, + archived: false, + source: None, + favorite: false, + pinned: false, + } + } + + fn parse( + &self, + r: &SessionFileRef, + decode_images: bool, + ) -> Result<(SessionMeta, Vec, u32)> { + let parsed = parse_antigravity_jsonl(Path::new(&r.file_path), decode_images)?; + let meta = self.build_meta(r, &parsed); + Ok((meta, parsed.messages, parsed.unknown_lines)) + } +} + +struct AntigravityIdeParse { + messages: Vec, + title: Option, + project_path: Option, + model: Option, + created_at: i64, + updated_at: i64, + unknown_lines: u32, +} + +#[derive(Default)] +struct PendingAssistant { + content: String, + thinking: Option, + tool_calls: Vec, + timestamp: Option, + model: Option, +} + +fn flush_pending_assistant(pending: &mut PendingAssistant, messages: &mut Vec) { + if pending.content.is_empty() && pending.thinking.is_none() && pending.tool_calls.is_empty() { + return; + } + let (clipped, truncated) = clip(&pending.content, MAX_MSG_TEXT); + let thinking = pending.thinking.take().map(|t| clip(&t, MAX_TOOL_IO).0); + messages.push(TranscriptMessage { + seq: 0, + role: Role::Assistant, + kind: MessageKind::Text, + text: clipped, + truncated, + tool_calls: std::mem::take(&mut pending.tool_calls), + thinking, + timestamp: pending.timestamp, + model: pending.model.take(), + images: Vec::new(), + }); + pending.content.clear(); + pending.timestamp = None; +} + +fn extract_user_request(raw: &str) -> (String, Option, Option) { + let user_req = if let Some(start) = raw.find("") { + let after = &raw[start + "".len()..]; + if let Some(end) = after.find("") { + after[..end].trim().to_string() + } else { + after.trim().to_string() + } + } else { + raw.trim().to_string() + }; + + let mut doc_path = None; + if let Some(pos) = raw.find("Active Document:") { + let after = &raw[pos + "Active Document:".len()..]; + let line = after.lines().next().unwrap_or("").trim(); + let path_part = if let Some((p, _)) = line.split_once(" (") { + p.trim() + } else { + line + }; + if !path_part.is_empty() { + doc_path = Some(path_part.to_string()); + } + } + + let mut model = None; + if let Some(pos) = raw.find("Model Selection` from") { + let after = &raw[pos..]; + if let Some(to_pos) = after.find(" to ") { + let after_to = &after[to_pos + 4..]; + let end_idx = after_to + .find(". No need") + .or_else(|| after_to.find(".\r\n")) + .or_else(|| after_to.find(".\n")) + .or_else(|| after_to.find('\n')) + .unwrap_or(after_to.len()); + let m = after_to[..end_idx].trim().trim_end_matches('.'); + if !m.is_empty() { + model = Some(m.to_string()); + } + } + } + + (user_req, doc_path, model) +} + +fn parse_tool_args( + _tool_name: &str, + args_val: Option<&serde_json::Value>, +) -> (String, Option, Option) { + let Some(args_val) = args_val else { + return (String::new(), None, None); + }; + + let obj: Option = match args_val { + serde_json::Value::Object(_) => Some(args_val.clone()), + serde_json::Value::String(s) => serde_json::from_str(s).ok(), + _ => None, + }; + + let mut preview = String::new(); + let mut cwd = None; + + if let Some(serde_json::Value::Object(map)) = obj.as_ref() { + for key in &["Cwd", "DirectoryPath", "SearchPath"] { + if let Some(val) = map.get(*key).and_then(|v| v.as_str()) { + let v = val.trim_matches(|c| c == '"' || c == '\'').trim(); + if !v.is_empty() { + cwd = Some(v.to_string()); + break; + } + } + } + + for key in &[ + "toolSummary", + "toolAction", + "CommandLine", + "TargetFile", + "AbsolutePath", + "Query", + "DirectoryPath", + ] { + if let Some(val) = map.get(*key).and_then(|v| v.as_str()) { + let v = val.trim_matches(|c| c == '"' || c == '\'').trim(); + if !v.is_empty() { + preview = v.to_string(); + break; + } + } + } + } + + if preview.is_empty() { + preview = match args_val { + serde_json::Value::String(s) => s.trim().to_string(), + _ => args_val.to_string(), + }; + } + let preview = clip(&preview, 120).0; + + let input_str = match args_val { + serde_json::Value::String(s) => Some(s.clone()), + _ => Some(args_val.to_string()), + }; + + (preview, input_str, cwd) +} + +/// 单张用户贴图的上限,防某个异常文件把内存吃穿(真机实测都在 300KB 内) +const MAX_UPLOAD_IMAGE_BYTES: u64 = 16 * 1024 * 1024; + +struct UploadedImage { + ms: i64, + path: PathBuf, +} + +/// `.user_uploaded/media_.`:文件名里的毫秒戳是贴图上传时刻,和它所属 +/// 那条用户消息的 `created_at` 只差几秒(实测 36 张:28 张 ≤60s、35 张 ≤5min) +fn uploaded_images(session_dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(session_dir.join(".user_uploaded")) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let Some(rest) = name.strip_prefix("media_") else { + continue; + }; + let Some((stem, _)) = rest.split_once('.') else { + continue; + }; + let Ok(ms) = stem.parse::() else { + continue; + }; + out.push(UploadedImage { + ms, + path: entry.path(), + }); + } + out.sort_by_key(|u| u.ms); + out +} + +fn media_type_for(path: &Path) -> Option<&'static str> { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "bmp" => Some("image/bmp"), + _ => None, + } +} + +/// 按时间戳把贴图回挂到最近的用户消息(尾部插入,`text_offset` = 正文长度)。 +/// 超过半小时对不上的宁可不挂,也不硬塞到别的轮次。 +fn attach_uploaded_images(messages: &mut [TranscriptMessage], session_dir: &Path) { + const MAX_AGE_MS: i64 = 30 * 60 * 1000; + let uploads = uploaded_images(session_dir); + if uploads.is_empty() { + return; + } + let users: Vec<(usize, i64)> = messages + .iter() + .enumerate() + .filter(|(_, m)| m.role == Role::User) + .filter_map(|(i, m)| m.timestamp.map(|t| (i, t))) + .collect(); + if users.is_empty() { + return; + } + for upload in uploads { + let Some((idx, ts)) = users + .iter() + .min_by_key(|(_, t)| (t - upload.ms).abs()) + .copied() + else { + break; + }; + if (ts - upload.ms).abs() > MAX_AGE_MS { + continue; + } + let Some(media_type) = media_type_for(&upload.path) else { + continue; + }; + let Ok(meta) = std::fs::metadata(&upload.path) else { + continue; + }; + if meta.len() == 0 || meta.len() > MAX_UPLOAD_IMAGE_BYTES { + continue; + } + let Ok(bytes) = std::fs::read(&upload.path) else { + continue; + }; + let text_offset = messages[idx].text.len(); + messages[idx].images.push(ImageAttachment { + media_type: media_type.to_string(), + bytes, + text_offset, + }); + } +} + +fn parse_antigravity_jsonl(path: &Path, decode_images: bool) -> Result { + let file = File::open(path)?; + let reader = BufReader::with_capacity(1 << 20, file); + + let mut messages: Vec = Vec::new(); + let mut unknown_lines = 0u32; + let mut first_ts = 0i64; + let mut last_ts = 0i64; + let mut title_candidate = None; + let mut candidate_doc_path = None; + let mut candidate_tool_cwd = None; + let mut current_model = None; + + let mut pending_assistant = PendingAssistant::default(); + + for line in reader.lines() { + let Ok(line) = line else { + unknown_lines += 1; + continue; + }; + if line.trim().is_empty() { + continue; + } + let Ok(row) = serde_json::from_str::(&line) else { + unknown_lines += 1; + continue; + }; + + let step_type = row.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let status = row.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let ts_str = row.get("created_at").and_then(|v| v.as_str()).unwrap_or(""); + let ts = iso_ms(ts_str); + if ts > 0 { + if first_ts == 0 { + first_ts = ts; + } + last_ts = ts; + } + + match step_type { + "USER_INPUT" => { + flush_pending_assistant(&mut pending_assistant, &mut messages); + let content = row.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let (user_req, doc_path, model_opt) = extract_user_request(content); + if title_candidate.is_none() && !user_req.is_empty() { + let cleaned = clean_title_candidate(&user_req); + if !cleaned.is_empty() { + title_candidate = Some(cleaned); + } + } + if candidate_doc_path.is_none() && doc_path.is_some() { + candidate_doc_path = doc_path; + } + if let Some(m) = model_opt { + current_model = Some(m); + } + + let (clipped, truncated) = clip(&user_req, MAX_MSG_TEXT); + messages.push(TranscriptMessage { + seq: 0, + role: Role::User, + kind: user_kind(&user_req), + text: clipped, + truncated, + tool_calls: Vec::new(), + thinking: None, + timestamp: if ts > 0 { Some(ts) } else { None }, + model: current_model.clone(), + images: Vec::new(), + }); + } + "PLANNER_RESPONSE" => { + if pending_assistant.timestamp.is_none() && ts > 0 { + pending_assistant.timestamp = Some(ts); + } + if current_model.is_some() && pending_assistant.model.is_none() { + pending_assistant.model = current_model.clone(); + } + if let Some(th) = row.get("thinking").and_then(|v| v.as_str()) { + if !th.trim().is_empty() { + if let Some(ref mut existing) = pending_assistant.thinking { + existing.push_str("\n\n"); + existing.push_str(th.trim()); + } else { + pending_assistant.thinking = Some(th.trim().to_string()); + } + } + } + if let Some(c) = row.get("content").and_then(|v| v.as_str()) { + if !c.trim().is_empty() { + if !pending_assistant.content.is_empty() { + pending_assistant.content.push_str("\n\n"); + } + pending_assistant.content.push_str(c.trim()); + } + } + if let Some(calls) = row.get("tool_calls").and_then(|v| v.as_array()) { + let step_idx = row.get("step_index").and_then(|v| v.as_i64()).unwrap_or(0); + for (ci, tc) in calls.iter().enumerate() { + let name = tc.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args = tc.get("args"); + let (preview, input_str, cwd_cand) = parse_tool_args(name, args); + if candidate_tool_cwd.is_none() && cwd_cand.is_some() { + candidate_tool_cwd = cwd_cand; + } + pending_assistant.tool_calls.push(ToolCallView { + id: format!("tc-{}-{}", step_idx, ci), + name: name.to_string(), + input_preview: preview, + input: input_str, + output: None, + is_error: false, + sidechain_ref: None, + }); + } + } + } + "VIEW_FILE" | "RUN_COMMAND" | "GREP_SEARCH" | "LIST_DIRECTORY" | "CODE_ACTION" + | "BROWSER_SUBAGENT" | "SEARCH_WEB" | "READ_URL_CONTENT" | "ASK_QUESTION" + | "GENERIC" | "ERROR_MESSAGE" => { + let content = row.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let is_err = status == "ERROR" || step_type == "ERROR_MESSAGE"; + let mut matched = false; + for tc in pending_assistant.tool_calls.iter_mut().rev() { + if tc.output.is_none() { + tc.output = Some(clip(content.trim(), MAX_TOOL_IO).0); + tc.is_error = is_err; + matched = true; + break; + } + } + if !matched && is_err && !content.trim().is_empty() { + let (clipped, truncated) = clip(content.trim(), MAX_MSG_TEXT); + messages.push(TranscriptMessage { + seq: 0, + role: Role::System, + kind: MessageKind::Text, + text: clipped, + truncated, + tool_calls: Vec::new(), + thinking: None, + timestamp: if ts > 0 { Some(ts) } else { None }, + model: None, + images: Vec::new(), + }); + } + } + "CHECKPOINT" => { + flush_pending_assistant(&mut pending_assistant, &mut messages); + let content = row.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if !content.trim().is_empty() { + let (clipped, truncated) = clip(content.trim(), MAX_MSG_TEXT); + messages.push(TranscriptMessage { + seq: 0, + role: Role::System, + kind: MessageKind::CompactSummary, + text: clipped, + truncated, + tool_calls: Vec::new(), + thinking: None, + timestamp: if ts > 0 { Some(ts) } else { None }, + model: None, + images: Vec::new(), + }); + } + } + "CONVERSATION_HISTORY" | "KNOWLEDGE_ARTIFACTS" | "SYSTEM_MESSAGE" => {} + _ => { + unknown_lines += 1; + } + } + } + + flush_pending_assistant(&mut pending_assistant, &mut messages); + + if decode_images { + if let Some(session_dir) = path + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + { + attach_uploaded_images(&mut messages, session_dir); + } + } + + assign_seq(&mut messages); + + Ok(AntigravityIdeParse { + messages, + title: title_candidate, + project_path: candidate_tool_cwd.or(candidate_doc_path), + model: current_model, + created_at: first_ts, + updated_at: last_ts, + unknown_lines, + }) +} + +impl AgentAdapter for AntigravityIdeAdapter { + fn agent(&self) -> AgentId { + AgentId::AntigravityIde + } + + fn list_session_files(&self) -> Result> { + let mut refs = Vec::new(); + if let Some(ref brain) = self.brain_root { + if let Ok(entries) = std::fs::read_dir(brain) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let id = entry.file_name().to_string_lossy().to_string(); + if id.starts_with('.') || id == "tempmediaStorage" { + continue; + } + let transcript = path + .join(".system_generated") + .join("logs") + .join("transcript.jsonl"); + if let Ok(meta) = std::fs::metadata(&transcript) { + if meta.is_file() && meta.len() > 0 { + refs.push(SessionFileRef { + agent: AgentId::AntigravityIde, + native_id: id, + file_path: transcript.to_string_lossy().to_string(), + mtime_ms: mtime_ms(&meta), + size: meta.len() as i64, + }); + } + } + } + } + } + Ok(refs) + } + + fn file_ref(&self, path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + if name != "transcript.jsonl" { + return None; + } + let logs = path.parent()?; + if logs.file_name()?.to_str()? != "logs" { + return None; + } + let sys = logs.parent()?; + if sys.file_name()?.to_str()? != ".system_generated" { + return None; + } + let session_dir = sys.parent()?; + let native_id = session_dir.file_name()?.to_str()?.to_string(); + if native_id.starts_with('.') || native_id == "tempmediaStorage" { + return None; + } + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() == 0 { + return None; + } + Some(SessionFileRef { + agent: AgentId::AntigravityIde, + native_id, + file_path: path.to_string_lossy().to_string(), + mtime_ms: mtime_ms(&meta), + size: meta.len() as i64, + }) + } + + fn parse_session(&self, r: &SessionFileRef) -> Result { + let (meta, messages, unknown_line_count) = self.parse(r, false)?; + Ok(ParsedSession::derive(meta, &messages, unknown_line_count)) + } + + fn parse_transcript(&self, r: &SessionFileRef) -> Result { + let (meta, messages, unknown_line_count) = self.parse(r, true)?; + Ok(ParsedTranscript { + meta, + mainline: messages, + sidechains: Vec::new(), + unknown_line_count, + }) + } + + fn session_paths(&self, meta: &SessionMeta) -> Vec { + let p = Path::new(&meta.file_path); + if meta.file_path.ends_with("transcript.jsonl") { + if let Some(session_dir) = p.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) + { + if session_dir.is_dir() { + return vec![session_dir.to_string_lossy().to_string()]; + } + } + } + vec![meta.file_path.clone()] + } + + fn with_custom_root(&self, dir: PathBuf) -> Box { + let brain_root = if dir.file_name().and_then(|s| s.to_str()) == Some("brain") { + Some(dir.clone()) + } else if dir.join("brain").is_dir() { + Some(dir.join("brain")) + } else if dir.join("antigravity-ide").join("brain").is_dir() { + Some(dir.join("antigravity-ide").join("brain")) + } else { + Some(dir.clone()) + }; + let projects_json = { + let p = dir.join("projects.json"); + if p.is_file() { + p + } else if dir + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + == Some("antigravity-ide") + { + // 根是 `…/antigravity-ide/brain` 形态(远程缓存挂载点): + // projects.json 在再上一层的 `.gemini` 顶层。缓存里此刻可能 + // 还没同步,不做 is_file 判据 + dir.parent() + .and_then(|p| p.parent()) + .map(|g| g.join("projects.json")) + .unwrap_or_else(|| dir.join("projects.json")) + } else { + super::home_dir() + .unwrap_or_default() + .join(".gemini") + .join("projects.json") + } + }; + Box::new(Self { + brain_root, + projects_json, + projects_cache: std::sync::Mutex::new(None), + }) + } + + fn data_roots(&self) -> Vec { + self.brain_root.iter().cloned().collect() + } +} diff --git a/crates/wake-core/src/adapters/mod.rs b/crates/wake-core/src/adapters/mod.rs index 22e7b5d..3b65879 100644 --- a/crates/wake-core/src/adapters/mod.rs +++ b/crates/wake-core/src/adapters/mod.rs @@ -1,4 +1,5 @@ pub mod antigravity; +pub mod antigravity_ide; pub mod claude; pub mod codebuddy; pub mod codex; @@ -169,7 +170,7 @@ pub trait AgentAdapter: Send + Sync { /// 拥有本家 session 文件的位置",凭据/配置/索引这类不产生会话的文件不列。 /// 新增 adapter 必须实现:没有默认值,漏了编译就过不去 fn data_roots(&self) -> Vec; - /// 文件监听根目录。默认 = data_roots 中现存的目录,二十一家实测全部吻合: + /// 文件监听根目录。默认 = data_roots 中现存的目录,二十二家实测全部吻合: /// 目录型给出自己的 root,SQLite 型的根是库文件、天然筛空(watcher 只认 /// .jsonl,库变更靠启动/手动刷新),codex 的 sessions + archived 一并覆盖。 /// 只有当监听范围确实不同于数据根时才覆写——否则一次根路径搬迁 @@ -270,7 +271,7 @@ pub(crate) fn env_dir(key: &str) -> Option { /// 各家数据根共用的 HOME。**全部 adapter 必须走这里**,不要直接 /// `dirs::home_dir()`——`WAKE_HOME` 是整组 adapter 的统一改道开关: -/// 契约测试靠它把二十一家指向 fixture 目录,而 `dirs::home_dir()` 只在 +/// 契约测试靠它把二十二家指向 fixture 目录,而 `dirs::home_dir()` 只在 /// POSIX 上看 `$HOME`,Windows 上走 SHGetKnownFolderPath、无论如何都指向 /// 真实用户目录(于是 Windows 上的契约测试全部落空,2026-08-25 review)。 /// 对用户它顺带是便携安装/多档案切换的手动开关。 @@ -278,7 +279,7 @@ pub(crate) fn home_dir() -> Option { env_dir("WAKE_HOME").or_else(dirs::home_dir) } -/// 全量二十一家 roster,**不按 detect 过滤**。这是全应用唯一的构造点: +/// 全量二十二家 roster,**不按 detect 过滤**。这是全应用唯一的构造点: /// scanner/watcher/resume/Session locations 面板共享 Workbench 启动时的 /// 同一份实例。缺根的家由各自 list_session_files 降级为 Ok(空)(scanner /// 对 Err 会 `?` 截断整轮,新 adapter 必须维持这条降级约定,contract 测试 @@ -306,6 +307,7 @@ pub fn create_adapters() -> Vec> { Box::new(grok::GrokAdapter::new()), Box::new(kimi::KimiAdapter::new()), Box::new(antigravity::AntigravityAdapter::new()), + Box::new(antigravity_ide::AntigravityIdeAdapter::new()), Box::new(dsh::DshAdapter::new()), Box::new(hermes::HermesAdapter::new()), Box::new(openclaw::OpenclawAdapter::new()), diff --git a/crates/wake-core/src/adapters/remote.rs b/crates/wake-core/src/adapters/remote.rs index 25c93d1..205a2dd 100644 --- a/crates/wake-core/src/adapters/remote.rs +++ b/crates/wake-core/src/adapters/remote.rs @@ -50,7 +50,7 @@ impl RemoteAdapter { } } -/// 对一个 host 构造整组远程实例:二十一家模板 × `with_custom_root`(缓存内 +/// 对一个 host 构造整组远程实例:二十二家模板 × `with_custom_root`(缓存内 /// 挂载点,见 remote::REMOTE_LAYOUTS)× 装饰器。追加进 roster 的 active 尾部 /// (不进 Session locations 面板)。`templates` 由 roster 唯一构造点传入 /// (不变量 8:本模块不得自行二次 create_adapters());产物的数据根 100% diff --git a/crates/wake-core/src/bin/wake_cli.rs b/crates/wake-core/src/bin/wake_cli.rs index 69f3ba7..5c5ce99 100644 --- a/crates/wake-core/src/bin/wake_cli.rs +++ b/crates/wake-core/src/bin/wake_cli.rs @@ -186,7 +186,7 @@ fn setup(db: &Option) -> String { // sibling_named 取的就是 current_exe 同级目录,从 wake-cli 调恰好对 let mcp_bin = mcp::sibling_file("wake-mcp"); let path = db_path(db); - // 只探库,不必为一行提示把二十一家 roster 建起来 + // 只探库,不必为一行提示把二十二家 roster 建起来 let db_error = Store::open_read_only(&path).err().map(|e| format!("{e:#}")); cli::setup_text(&cli::SetupFacts { cli_bin: &cli_bin, diff --git a/crates/wake-core/src/bin/wake_mcp.rs b/crates/wake-core/src/bin/wake_mcp.rs index a5f2668..2739f53 100644 --- a/crates/wake-core/src/bin/wake_mcp.rs +++ b/crates/wake-core/src/bin/wake_mcp.rs @@ -102,7 +102,7 @@ fn main() -> ExitCode { for s in mcp::setup_snippets(&bin) { let _ = writeln!(out, "## {} — {}\n\n{}\n", s.client, s.hint, s.text); } - // 只探库,不必为一行提示把二十一家 roster 建起来 + // 只探库,不必为一行提示把二十二家 roster 建起来 if let Err(e) = Store::open_read_only(&db_path(&args.db)) { let _ = writeln!(out, "Note: {e:#}"); } diff --git a/crates/wake-core/src/models.rs b/crates/wake-core/src/models.rs index b8e9ea0..76a363e 100644 --- a/crates/wake-core/src/models.rs +++ b/crates/wake-core/src/models.rs @@ -18,6 +18,7 @@ pub enum AgentId { Gemini, Copilot, Antigravity, + AntigravityIde, Qoder, Hermes, Openclaw, @@ -29,10 +30,10 @@ pub enum AgentId { } impl AgentId { - /// 全部二十一家,**枚举声明序**(= Ord = 用户钉的侧栏展示序;面板成组、 + /// 全部二十二家,**枚举声明序**(= Ord = 用户钉的侧栏展示序;面板成组、 /// 表单下拉共用同一顺序)。曾误抄 create_adapters 的构造序,下拉与侧栏 /// 排序当场对不上——契约测试现在卡它与 Ord 一致 - pub const ALL: [AgentId; 21] = [ + pub const ALL: [AgentId; 22] = [ AgentId::ClaudeCode, AgentId::Codex, AgentId::Grok, @@ -46,6 +47,7 @@ impl AgentId { AgentId::Gemini, AgentId::Copilot, AgentId::Antigravity, + AgentId::AntigravityIde, AgentId::Qoder, AgentId::Hermes, AgentId::Openclaw, @@ -71,6 +73,7 @@ impl AgentId { AgentId::Grok => "grok", AgentId::Kimi => "kimi", AgentId::Antigravity => "antigravity", + AgentId::AntigravityIde => "antigravity-ide", AgentId::Dsh => "dsh", AgentId::Hermes => "hermes", AgentId::Openclaw => "openclaw", @@ -97,6 +100,7 @@ impl AgentId { "grok" => Some(AgentId::Grok), "kimi" => Some(AgentId::Kimi), "antigravity" => Some(AgentId::Antigravity), + "antigravity-ide" => Some(AgentId::AntigravityIde), "dsh" => Some(AgentId::Dsh), "hermes" => Some(AgentId::Hermes), "openclaw" => Some(AgentId::Openclaw), @@ -124,6 +128,7 @@ impl AgentId { AgentId::Grok => "Grok Build", AgentId::Kimi => "Kimi Code", AgentId::Antigravity => "Antigravity CLI", + AgentId::AntigravityIde => "Antigravity IDE", AgentId::Dsh => "DeepSeek Harness", AgentId::Hermes => "Hermes Agent", AgentId::Openclaw => "OpenClaw", @@ -142,7 +147,7 @@ impl AgentId { /// Qoder 是白/黑字形配绿色品牌色,同样按模式切图;Hermes(Nous 的少女字形) /// 浅色用墨色透明底字形,**深色不能用白色实心字形**——细节密、16px 下糊成一团白 /// (用户 2026-09-03 反馈),深色改用官方形态「白色圆角底 + 墨色画」的贴片; - /// 其余彩色品牌(Claude/Codex/Gemini/Kiro/Omp/Antigravity/OpenClaw 的红龙虾) + /// 其余彩色品牌(Claude/Codex/Gemini/Kiro/Omp/Antigravity 的 CLI/IDE 同图/OpenClaw 的红龙虾) /// 保持原色,两模式通用。 pub fn brand_icon(&self, dark: bool) -> &'static str { match self { @@ -200,7 +205,9 @@ impl AgentId { "brands/kimi-light.png" } } + // IDE 与 CLI 是同一家的两个面,共用一张品牌图(用户 2026-09-25) AgentId::Antigravity => "brands/antigravity.png", + AgentId::AntigravityIde => "brands/antigravity.png", AgentId::Dsh => "brands/deepseek.png", AgentId::Hermes => { if dark { @@ -970,7 +977,7 @@ pub const UNTITLED: &str = "Untitled"; /// 会话 key 的唯一构造点:本地 `{agent}:{native_id}`,远程 /// `{agent}:{host}:{native_id}`。scanner 的墓碑查询、watcher 的幸存者反查、 /// 远程装饰器的 key 改写都走这里——"远程 key 长什么样"只此一处知识。 -/// (二十一家 adapter 的本地两段构造保留各自 `format!`,它们从不涉及 host。) +/// (二十二家 adapter 的本地两段构造保留各自 `format!`,它们从不涉及 host。) pub fn session_key(agent: AgentId, host: &str, native_id: &str) -> String { if host.is_empty() { format!("{}:{native_id}", agent.as_str()) diff --git a/crates/wake-core/src/remote.rs b/crates/wake-core/src/remote.rs index a347c39..5185dba 100644 --- a/crates/wake-core/src/remote.rs +++ b/crates/wake-core/src/remote.rs @@ -16,7 +16,7 @@ //! ①openrsync(macOS 15+ 的 /usr/bin/rsync)做发送端时,某个源连父目录 //! 都不存在会让它中止整份文件列表,排在后面的源全部静默不传,退出码却 //! 只是 23;②rsync 家族只要发送端遇到任何 I/O 错误(缺源即算)就整体跳过 -//! 删除阶段——没有哪台机器二十一家全装,`--delete` 就永远不会生效。 +//! 删除阶段——没有哪台机器二十二家全装,`--delete` 就永远不会生效。 //! //! 同步跑在**独立于扫描的线程**(Workbench::spawn_remote_sync):本地扫描 //! 不等网络,不可达 host 只拖慢自己;缓存落盘由 watcher 增量收编,同步 @@ -49,7 +49,7 @@ pub struct RemoteAgentLayout { pub exclude: &'static [&'static str], } -/// 二十一家的远程布局。远程主机按 Linux/macOS 默认路径假设(两平台一致, +/// 二十二家的远程布局。远程主机按 Linux/macOS 默认路径假设(两平台一致, /// 均为 home 相对;OpenCode 的 XDG 变体、CODEX_HOME 这类 env 覆盖在远端 /// 探测不到,阶段 1 不支持非默认远程布局)。 pub const REMOTE_LAYOUTS: &[RemoteAgentLayout] = &[ @@ -164,6 +164,14 @@ pub const REMOTE_LAYOUTS: &[RemoteAgentLayout] = &[ ], exclude: &[], }, + RemoteAgentLayout { + agent: AgentId::AntigravityIde, + // brain 就是数据根本身;拿它当 mount,with_custom_root 靠目录名识别 + // (未同步时目录还不存在,is_dir 判据会失真)。projects.json 与 gemini 同源 + mount: ".gemini/antigravity-ide/brain", + sync_paths: &[".gemini/antigravity-ide/brain", ".gemini/projects.json"], + exclude: &[], + }, RemoteAgentLayout { agent: AgentId::Dsh, mount: ".dsh/sessions", diff --git a/crates/wake-core/src/services/terminal/mod.rs b/crates/wake-core/src/services/terminal/mod.rs index 23e776a..6986f1d 100644 --- a/crates/wake-core/src/services/terminal/mod.rs +++ b/crates/wake-core/src/services/terminal/mod.rs @@ -134,6 +134,9 @@ pub fn agent_bin(agent: AgentId) -> Option<&'static str> { AgentId::Grok => Some("grok"), AgentId::Kimi => Some("kimi"), AgentId::Antigravity => Some("agy"), + // IDE 与 CLI 同一家的两个面、共用 `agy` 入口与同一套会话 id 空间 + // (`agy` 的 /resume 能打开非 CLI 面的会话,故同一个 --conversation= 形制通用) + AgentId::AntigravityIde => Some("agy"), // dsh 官方唯一分发形态是 npx(README 只有 `npx @deepseek-ai/dsh web`, // 不发全局命令);包名由 resume_args 作首参带上 AgentId::Dsh => Some("npx"), @@ -169,6 +172,8 @@ fn resume_args(meta: &SessionMeta) -> Option<(Vec, bool)> { AgentId::Grok => Some((vec!["--resume".into(), id.into()], false)), AgentId::Kimi => Some((vec!["--session".into(), id.into()], false)), AgentId::Antigravity => Some((vec![format!("--conversation={id}")], false)), + // 与 CLI 同形制:`agy` 按会话 id 打开,跨面(CLI/IDE/桌面)同一空间 + AgentId::AntigravityIde => Some((vec![format!("--conversation={id}")], false)), // dsh 官方 tui bundle 未发布(rc.8 shipped profile 只有 web/headless, // help 里的 --profile tui --resume 当下无消费端),web 是唯一交互 // surface 且无 per-session 深链——resume 退而求其次:cd 到会话 cwd diff --git a/crates/wake-core/tests/adapter_contracts.rs b/crates/wake-core/tests/adapter_contracts.rs index 36762c6..ba2b846 100644 --- a/crates/wake-core/tests/adapter_contracts.rs +++ b/crates/wake-core/tests/adapter_contracts.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; use wake_core::adapters::antigravity::AntigravityAdapter; +use wake_core::adapters::antigravity_ide::AntigravityIdeAdapter; use wake_core::adapters::claude::ClaudeAdapter; use wake_core::adapters::codebuddy::CodebuddyAdapter; use wake_core::adapters::codex::CodexAdapter; @@ -122,9 +123,11 @@ fn fs_ref(agent: AgentId, path: &Path, native_id: &str) -> SessionFileRef { } /// 默认 roster 的实例数。**不等于 `AgentId::ALL.len()`**:Cursor 一家有两个 -/// 数据源(CLI 的 agent-transcripts 与 IDE 的 state.vscdb),各占一个实例。 -/// 新增 agent 或给某家再加数据源时,这个数跟着加一——契约要卡的是"漏了实例 -/// 就爆",而不是"每家恰好一个" +/// 数据源(CLI 的 agent-transcripts 与 IDE 的 state.vscdb),各占一个实例、 +/// 共用 `AgentId::Cursor`。Antigravity 同样两个数据源,但 CLI 与 IDE 是两个 +/// `AgentId`(`Antigravity` / `AntigravityIde`),各自只算一份,不产生额外实例。 +/// 新增 agent、或给某家再加一个**共用 AgentId**的数据源时,这个数跟着加一—— +/// 契约要卡的是"漏了实例就爆",而不是"每家恰好一个" const DEFAULT_INSTANCES: usize = AgentId::ALL.len() + 1; /// SQLite 型 agent 的虚拟路径引用(`#`,与 sqlite_ro::virtual_path 同构) @@ -1977,6 +1980,79 @@ fn antigravity_parse_contract() { assert!(s.units[0].text.contains("QR overlay polish")); } +#[test] +fn antigravity_ide_parse_contract() { + let _env = setup(); + let fixture_dir = fixture("antigravity/brain"); + let adapter = AntigravityIdeAdapter::new().with_custom_root(fixture_dir.clone()); + assert_eq!(adapter.agent(), AgentId::AntigravityIde); + + let refs = adapter.list_session_files().expect("antigravity ide list"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].native_id, "ag-ide-0001"); + assert!(refs[0].file_path.ends_with("transcript.jsonl")); + + let s = adapter.parse_session(&refs[0]).expect("parse_session"); + let t = adapter + .parse_transcript(&refs[0]) + .expect("parse_transcript"); + + assert_eq!(s.meta.title, "Fix the button layout on the dashboard"); + assert_eq!(s.meta.project_path, "/Users/tester/Github/wakefx"); + assert_eq!(s.meta.project_name, "wakefx"); + assert_eq!(s.meta.model.as_deref(), Some("Gemini 3.7 Flash (High)")); + assert_eq!(s.meta.message_count, 5); // 2 user + 2 assistant + 1 checkpoint + + assert_eq!(t.mainline.len(), 5); + assert_eq!(t.mainline[0].role, Role::User); + assert_eq!(t.mainline[0].text, "Fix the button layout on the dashboard"); + + // 用户贴图存在同会话 .user_uploaded/,按文件名毫秒戳回挂到最近的用户消息 + assert_eq!(t.mainline[0].images.len(), 1); + assert_eq!(t.mainline[0].images[0].media_type, "image/png"); + assert!(t.mainline[0].images[0].bytes.starts_with(b"\x89PNG")); + assert_eq!( + t.mainline[0].images[0].text_offset, + t.mainline[0].text.len() + ); + assert!(t.mainline[1..].iter().all(|m| m.images.is_empty())); + + assert_eq!(t.mainline[1].role, Role::Assistant); + assert!(t.mainline[1] + .thinking + .as_ref() + .unwrap() + .contains("check the layout structure")); + assert_eq!(t.mainline[1].tool_calls.len(), 2); + assert_eq!(t.mainline[1].tool_calls[0].name, "view_file"); + assert_eq!( + t.mainline[1].tool_calls[0].output.as_deref(), + Some("pub fn render_buttons() {}") + ); + assert_eq!(t.mainline[1].tool_calls[1].name, "run_command"); + assert_eq!( + t.mainline[1].tool_calls[1].output.as_deref(), + Some("test result: ok. 5 passed") + ); + assert!(t.mainline[1].text.contains("I have examined the layout")); + + assert_eq!(t.mainline[2].role, Role::System); + assert_eq!(t.mainline[2].kind, MessageKind::CompactSummary); + + assert_eq!(t.mainline[3].role, Role::User); + assert_eq!(t.mainline[3].text, "Looks good, please commit the changes"); + + assert_eq!(t.mainline[4].role, Role::Assistant); + assert_eq!(t.mainline[4].text, "Committed successfully."); + + assert_seq_contract(adapter.as_ref(), &refs[0]); + + let fr = adapter + .file_ref(Path::new(&refs[0].file_path)) + .expect("file_ref"); + assert_eq!(fr.native_id, "ag-ide-0001"); +} + // ---------------------------------------------------------------- seq 契约 /// 跨文件不变量 1:FTS 单元的 seq 必须能在详情页 mainline 中找到同号消息, diff --git a/crates/wake-core/tests/fixtures/antigravity/brain/ag-ide-0001/.system_generated/logs/transcript.jsonl b/crates/wake-core/tests/fixtures/antigravity/brain/ag-ide-0001/.system_generated/logs/transcript.jsonl new file mode 100644 index 0000000..21e4e40 --- /dev/null +++ b/crates/wake-core/tests/fixtures/antigravity/brain/ag-ide-0001/.system_generated/logs/transcript.jsonl @@ -0,0 +1,8 @@ +{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-08-30T02:11:10Z","content":"\nFix the button layout on the dashboard\n\n\nThe current local time is: 2026-08-30T10:11:10+08:00.\nActive Document: /Users/tester/Github/wakefx/src/ui.rs (LANGUAGE_RUST)\n\n\nThe user changed setting `Model Selection` from None to Gemini 3.7 Flash (High).\n"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-08-30T02:11:12Z","thinking":"Need to view ui.rs to check the layout structure.","tool_calls":[{"name":"view_file","args":{"AbsolutePath":"/Users/tester/Github/wakefx/src/ui.rs","toolSummary":"View ui.rs"}}]} +{"step_index":2,"source":"MODEL","type":"VIEW_FILE","status":"DONE","created_at":"2026-08-30T02:11:14Z","content":"pub fn render_buttons() {}"} +{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-08-30T02:11:16Z","content":"I have examined the layout and updated the button alignment.","tool_calls":[{"name":"run_command","args":{"CommandLine":"cargo test","Cwd":"/Users/tester/Github/wakefx","toolSummary":"Run cargo test"}}]} +{"step_index":4,"source":"MODEL","type":"RUN_COMMAND","status":"DONE","created_at":"2026-08-30T02:11:18Z","content":"test result: ok. 5 passed"} +{"step_index":5,"source":"SYSTEM","type":"CHECKPOINT","status":"DONE","created_at":"2026-08-30T02:11:20Z","content":"# Resuming from a compaction"} +{"step_index":6,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-08-30T02:12:00Z","content":"\nLooks good, please commit the changes\n"} +{"step_index":7,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-08-30T02:12:05Z","content":"Committed successfully."} diff --git a/crates/wake-core/tests/fixtures/antigravity/brain/ag-ide-0001/.user_uploaded/media_1788055868000.png b/crates/wake-core/tests/fixtures/antigravity/brain/ag-ide-0001/.user_uploaded/media_1788055868000.png new file mode 100644 index 0000000000000000000000000000000000000000..26a8c68efad2a094e8fe6d850426b651d353c568 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$DL?ob7+Dz^)g>OM Q0fiYnUHx3vIVCg!0A@T4ng9R* literal 0 HcmV?d00001 diff --git a/crates/wake/src/theme.rs b/crates/wake/src/theme.rs index d8e8c08..3aea4a8 100644 --- a/crates/wake/src/theme.rs +++ b/crates/wake/src/theme.rs @@ -101,6 +101,8 @@ pub fn agent_series_color(agent: wake_core::models::AgentId) -> u32 { Gemini => 0x3B8BD9, Copilot => 0x6E9C3F, Antigravity => 0x648AB5, + // 与 Antigravity(CLI)同品牌色,同一家的两个面 + AntigravityIde => 0x648AB5, Qoder => 0x2BB454, Hermes => 0xE0B040, Openclaw => 0xE04A4A, diff --git a/crates/wake/src/workbench.rs b/crates/wake/src/workbench.rs index d9ff524..512b0b3 100644 --- a/crates/wake/src/workbench.rs +++ b/crates/wake/src/workbench.rs @@ -2344,7 +2344,7 @@ impl UsageBoard { } } - /// Agents 全量列出(总共二十一家);项目/模型长尾长,取前 6 + /// Agents 全量列出(总共二十二家);项目/模型长尾长,取前 6 fn limit(self) -> usize { match self { Self::Agents => usize::MAX,