diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a786fe..de5e97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - New: Craft Agents sessions are indexed from its workspaces — titles, models, tool calls and token counts, with archived sessions, sub-tasks and branches, whether the session ran on a Claude connection or on ChatGPT, Copilot or Google. The copy that a Claude connection also leaves in Claude Code's history is hidden while the Craft session exists, so each conversation is listed once. Workspaces kept outside `~/.craft-agent/workspaces` can be added in Settings → Session locations. Read-only, and no Open In (#44, thanks @qiajue) +- New: Devin sessions are indexed from its local database (`~/.local/share/devin/cli/sessions.db`, read-only) — titles, models, thinking, tool calls and token counts included; the visible transcript follows each session's main chain, so retries edited out of the conversation and hidden helper sessions stay out. Open In resumes a session with `devin --resume` in its project directory (#46, thanks @BUKOWSKIREAL for the report and the implementation in #48) ## [0.8.1] — 2026-09-23 diff --git a/PRODUCT.md b/PRODUCT.md index b6381ec..1fbe282 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 连接各一条)。做不了的: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 正文加密仅元数据卡片)+ 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 61cd39d..37f05e6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Your agent history is scattered across `~/.claude`, `~/.codex`, and a dozen othe | WorkBuddy | `~/.workbuddy/projects/*/*.jsonl` (`WORKBUDDY_CONFIG_DIR` is respected; desktop app, so no resume) | ✅ | — | | ZCode | `~/.zcode/cli/db/db.sqlite` + `v2/tasks-index.sqlite` (read-only; `ZCODE_STORAGE_DIR` is respected; desktop app, so no resume) | ✅ | — | | Craft Agents | `~/.craft-agent/workspaces/*/sessions/*/session.jsonl` (workspaces kept elsewhere can be added under Session locations; desktop app, so no resume) | ✅ | ✅ | +| Devin | `~/.local/share/devin/cli/sessions.db` (read-only; `XDG_DATA_HOME` is respected) | ✅ | — | **Model** = whether Wake shows which LLM a session used (the model the session last used). **Via** = whether Wake shows where the session was started from (CLI, IDE extension, desktop app) — Codex records this in its local data; Hermes and OpenClaw record the channel a session came in through (Telegram, Discord, …); Craft Agents marks sessions its automations started. A "—" means the agent's local data simply doesn't record that field, not a missing feature. diff --git a/crates/wake-core/src/adapters/devin.rs b/crates/wake-core/src/adapters/devin.rs new file mode 100644 index 0000000..a27392f --- /dev/null +++ b/crates/wake-core/src/adapters/devin.rs @@ -0,0 +1,542 @@ +//! Devin(Cognition 的 agent,CLI 与 Devin Desktop 共用本地存储): +//! `<数据根>/cli/sessions.db` 全明文 SQLite。数据根服从 XDG—— +//! `$XDG_DATA_HOME/devin`,缺省 `~/.local/share/devin`(macOS 同样走 XDG +//! 形态,实测);`~/Library/Application Support/devin` 是桌面 Electron 壳的 +//! profile 目录,作为历史候选一并探。候选按序取第一个真有库文件的,都没有 +//! 时回落 XDG 语义下的默认位置(env 候选里真有库才采信,存在但空的目录 +//! 不能遮掉默认根——与其他 adapter 同一约定)。 +//! +//! `sessions(id,title,working_directory,model,agent_mode,created_at, +//! last_activity_at,hidden,main_chain_id,…)` + `message_nodes(row_id, +//! session_id,node_id,parent_node_id,chat_message,created_at)`——每个会话 +//! 一座森林:重试/编辑从中间节点开出侧枝,可见转录是从 +//! `sessions.main_chain_id` 这片叶子沿 `parent_node_id` 走回根的那一条链; +//! 叶子缺列(老库)、悬空或走不出根(成环)时退回 `created_at,row_id` 全列。 +//! 时间戳全是 unix **秒**(INTEGER)。`hidden = 1` 是 Devin 自己不列的会话 +//! (compaction 辅助会话、已删会话),不列。空会话(零正文)不列。 +//! +//! `chat_message` 是 JSON:`role` ∈ user/assistant/system/tool,`content` +//! 恒为字符串;assistant 的 `tool_calls` 是 `[{id,name,arguments(对象)}]`, +//! `role="tool"` 行按 `tool_call_id` 回填 output;`thinking` 是对象 +//! `{thinking,signature,signature_type}`——signature 是 sealed 二进制签名, +//! 只取 thinking 文本;`metadata` 携带 `is_user_input`(真人输入= true)、 +//! `telemetry.source`(`cache_keepalive` 等内部来源)、`generation_model` +//! (逐请求的实发模型,比 sessions.model 权威)与 `metrics` +//! (`input_tokens`/`output_tokens`/`cache_read_tokens`/ +//! `cache_creation_tokens`,按主链累计进 tokens_used)。 +//! +//! 内部消息归 Meta:`system` 角色(注入上下文)、`telemetry.source` 非 +//! `user` 的用户消息(cache_keepalive 心跳)、`is_user_input == false`、 +//! 以及 compaction 请求正文("Conversation to summarize:" / +//! "Now summarize the conversation above");assistant 的 `` 应答 +//! 折成 CompactSummary。字段缺席按真人放行(老写端未必有 is_user_input)。 +//! +//! resume:`devin --resume `(会话按 cwd 分桶,`devin list` 只列当前 +//! 目录,故 resume 在项目目录里跑)。无每会话文件,SessionFileRef 用虚拟 +//! 路径 `#`。 +use super::parse_utils::*; +use super::sqlite_ro::{ + db_cache_stamp, open_sqlite_ro, strip_virtual_path, table_columns, virtual_path, SqliteRo, +}; +use super::AgentAdapter; +use crate::models::*; +use anyhow::{anyhow, Result}; +use rusqlite::Connection; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// 会话与正文的唯一来源,相对数据根 +const DB_REL: &str = "cli/sessions.db"; + +/// 数据根下的库文件,按段拼:`join(DB_REL)` 在 Windows 上会留下正斜杠,虚拟路径 +/// 与别处按段拼出的同一个库字符串对不上 +fn db_in(root: &Path) -> PathBuf { + root.join(DB_REL.split('/').collect::()) +} + +pub struct DevinAdapter { + db: PathBuf, + /// 枚举查询带全表相关子查询,按库 mtime 缓存一轮扫描内的重复调用 + rows_cache: MtimeCache>, +} + +#[derive(Clone)] +struct DevinRow { + id: String, + title: String, + cwd: String, + model: String, + created_ms: i64, + updated_ms: i64, + /// 主链叶子;老库没有 main_chain_id 列(见模块注释的兜底) + main_chain_id: Option, + /// chat_message 总长——空会话过滤与 SessionFileRef.size 的脏判据(单会话取行时不算) + content_len: i64, +} + +/// ALTER 追加的列各自在不在(初始 schema 的列不用探) +#[derive(Clone, Copy)] +struct DevinSchema { + hidden: bool, + main_chain_id: bool, +} + +impl DevinSchema { + fn probe(conn: &Connection) -> Self { + let sessions = table_columns(conn, "sessions"); + Self { + hidden: sessions.contains("hidden"), + main_chain_id: sessions.contains("main_chain_id"), + } + } +} + +struct DevinNode { + node_id: i64, + parent_node_id: Option, + chat_message: String, + created_ms: i64, +} + +impl DevinAdapter { + pub fn new() -> Self { + let home = super::home_dir().unwrap_or_default(); + let local = home.join(".local").join("share").join("devin"); + let xdg = super::env_dir("XDG_DATA_HOME").map(|d| d.join("devin")); + let app_support = home + .join("Library") + .join("Application Support") + .join("devin"); + let hit = xdg + .iter() + .chain([&local, &app_support]) + .find(|dir| db_in(dir).is_file()) + .cloned(); + let root = hit.unwrap_or_else(|| xdg.unwrap_or(local)); + Self::with_db(db_in(&root)) + } + + fn with_db(db: PathBuf) -> Self { + Self { + db, + rows_cache: MtimeCache::new(), + } + } + + fn open(&self) -> Option { + open_sqlite_ro(&self.db, "devin") + } + + /// 行清单,按库(含 -wal)mtime 缓存;库这一刻读不出交回**上一次读到的** + /// (空清单会让 scanner 把整家会话当"磁盘已删"清掉),从没读成功过才是 None + fn rows(&self) -> Option> { + let stamp = db_cache_stamp(&self.db); + self.rows_cache.get_or_stale(stamp, || { + let ro = self.open()?; + query_rows(&ro.conn, DevinSchema::probe(&ro.conn), None).ok() + }) + } + + fn parse(&self, r: &SessionFileRef) -> Result<(SessionMeta, Decoded)> { + if Path::new(strip_virtual_path(&r.file_path)) != self.db.as_path() { + return Err(anyhow!("devin database is outside adapter roots")); + } + let ro = self.open().ok_or_else(|| anyhow!("cannot open devin db"))?; + let schema = DevinSchema::probe(&ro.conn); + let row = query_rows(&ro.conn, schema, Some(&r.native_id))? + .into_iter() + .next() + .ok_or_else(|| anyhow!("devin session {} not in db", r.native_id))?; + let nodes = query_nodes(&ro.conn, &r.native_id)?; + let chain = main_chain(nodes, row.main_chain_id); + let decoded = decode_nodes(chain, &row.model); + let meta = build_meta(r, &row, &decoded.messages, decoded.tokens); + Ok((meta, decoded)) + } +} + +fn build_meta( + r: &SessionFileRef, + row: &DevinRow, + messages: &[TranscriptMessage], + tokens: i64, +) -> SessionMeta { + let title = Some(row.title.as_str()) + .map(clean_title_candidate) + .filter(|t| !t.is_empty()) + .or_else(|| title_from_messages(messages)) + .unwrap_or_else(|| UNTITLED.to_string()); + SessionMeta { + key: format!("devin:{}", row.id), + host: String::new(), + id: row.id.clone(), + agent: AgentId::Devin, + title, + project_path: row.cwd.clone(), + project_name: project_name_of(&row.cwd), + file_path: r.file_path.clone(), + created_at: if row.created_ms > 0 { + row.created_ms + } else { + r.mtime_ms + }, + updated_at: if row.updated_ms > 0 { + row.updated_ms + } else { + r.mtime_ms + }, + message_count: messages + .iter() + .filter(|m| m.kind == MessageKind::Text) + .count() as i64, + size_bytes: r.size, + git_branch: None, + // 同一会话会换模型,取最后一条 assistant 的实发模型;没有消息级记录用会话行 + model: messages + .iter() + .rev() + .find_map(|m| m.model.clone()) + .or_else(|| Some(row.model.clone()).filter(|m| !m.is_empty())), + tokens_used: (tokens > 0).then_some(tokens), + archived: false, + source: None, + favorite: false, + pinned: false, + } +} + +/// unix 秒 → epoch ms;非正数与溢出都给 0(调用方按"没有时间"处理) +fn secs_to_ms(secs: i64) -> i64 { + secs.checked_mul(1000).unwrap_or(0).max(0) +} + +/// 枚举 / 单会话取行。缺列给默认值:老库不得整家消失。单会话取行时不算 +/// chat_message 总长——它只给枚举做空会话过滤与脏判据 +fn query_rows( + conn: &Connection, + schema: DevinSchema, + id: Option<&str>, +) -> rusqlite::Result> { + let visible = if schema.hidden { + "COALESCE(s.hidden, 0) <> 1" + } else { + "1" + }; + let leaf = if schema.main_chain_id { + "s.main_chain_id" + } else { + "NULL" + }; + let content_len = if id.is_some() { + "0" + } else { + "(SELECT COALESCE(SUM(LENGTH(m.chat_message)), 0) FROM message_nodes m + WHERE m.session_id = s.id)" + }; + let sql = format!( + "SELECT s.id, COALESCE(s.title, ''), COALESCE(s.working_directory, ''), + COALESCE(s.model, ''), COALESCE(s.created_at, 0), + COALESCE(s.last_activity_at, s.created_at, 0), {leaf}, {content_len} + FROM sessions s WHERE {visible} AND (?1 IS NULL OR s.id = ?1)" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([id], |r| { + Ok(DevinRow { + id: r.get(0)?, + title: r.get(1)?, + cwd: r.get(2)?, + model: r.get(3)?, + created_ms: secs_to_ms(r.get::<_, i64>(4)?), + updated_ms: secs_to_ms(r.get::<_, i64>(5)?), + main_chain_id: r.get(6)?, + content_len: r.get::<_, Option>(7)?.unwrap_or(0), + }) + })?; + rows.collect() +} + +fn query_nodes(conn: &Connection, session_id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT node_id, parent_node_id, chat_message, created_at + FROM message_nodes WHERE session_id = ?1 ORDER BY created_at, row_id", + )?; + let rows = stmt.query_map([session_id], |r| { + Ok(DevinNode { + node_id: r.get(0)?, + parent_node_id: r.get(1)?, + chat_message: r.get(2)?, + created_ms: secs_to_ms(r.get::<_, i64>(3)?), + }) + })?; + rows.collect() +} + +/// 可见链:从 main_chain_id 叶子沿 parent_node_id 走回根再反转。走不完 +/// (叶子悬空、父节点缺失、成环)一律退回原始顺序全列——宁可多列侧枝 +/// 也不让会话正文消失 +fn main_chain(rows: Vec, leaf: Option) -> Vec { + let Some(leaf) = leaf else { return rows }; + let by_id: HashMap = rows + .iter() + .enumerate() + .map(|(i, n)| (n.node_id, i)) + .collect(); + let Some(&start) = by_id.get(&leaf) else { + return rows; + }; + // 先按下标走完整条链,确认走得到根再搬节点 + let mut chain = vec![start]; + let mut cur = start; + while let Some(parent) = rows[cur].parent_node_id { + match by_id.get(&parent) { + // 链不可能比全部节点还长,再长就是成环 + Some(&ix) if chain.len() < rows.len() => { + chain.push(ix); + cur = ix; + } + _ => return rows, + } + } + let mut slots: Vec> = rows.into_iter().map(Some).collect(); + chain + .iter() + .rev() + .filter_map(|&ix| slots[ix].take()) + .collect() +} + +/// compaction 请求的正文特征(Devin 把摘要写成隐藏辅助会话,也内联进主链) +fn is_compact_request(text: &str) -> bool { + let t = text.trim_start(); + t.starts_with("Conversation to summarize:") || t.starts_with("Now summarize the conversation") +} + +/// 一条 chat_message 是否内部来源:system 角色之外,`telemetry.source` 非 +/// `user`(cache_keepalive 心跳)、`is_user_input` 显式 false、compaction +/// 请求正文都归 Meta;字段缺席按真人放行 +fn is_internal_user(v: &Value, text: &str) -> bool { + let meta = v.get("metadata"); + let source = meta + .and_then(|m| m.pointer("/telemetry/source")) + .and_then(Value::as_str); + if source.is_some_and(|s| s != "user") { + return true; + } + if meta + .and_then(|m| m.get("is_user_input")) + .and_then(Value::as_bool) + == Some(false) + { + return true; + } + is_compact_request(text) +} + +/// thinking 字段两种形态:对象 `{thinking,signature,signature_type}`(签名是 +/// sealed 二进制,不进索引)或老写端的纯字符串 +fn thinking_text(v: &Value) -> Option { + let t = v.get("thinking")?; + t.get("thinking") + .and_then(Value::as_str) + .or_else(|| t.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) +} + +fn metric_i64(metrics: &Value, key: &str) -> i64 { + metrics.get(key).and_then(Value::as_i64).unwrap_or(0) +} + +struct Decoded { + messages: Vec, + /// 在消息取舍之外单独累计:content 为空但消耗了 token 的 assistant 收尾节点照样入账 + tokens: i64, + /// 解不开的 chat_message 与词汇表外的 role:格式漂移的金丝雀 + unknown: u32, +} + +/// 主链逐节点解码 +fn decode_nodes(chain: Vec, session_model: &str) -> Decoded { + let mut messages: Vec = Vec::new(); + let mut tokens = 0i64; + let mut unknown = 0u32; + // tool_call_id → (消息下标, tool_calls 下标) + let mut by_id: HashMap = HashMap::new(); + for node in chain { + let Ok(v) = serde_json::from_str::(&node.chat_message) else { + unknown += 1; + continue; + }; + if let Some(metrics) = v.pointer("/metadata/metrics") { + tokens += metric_i64(metrics, "input_tokens") + + metric_i64(metrics, "output_tokens") + + metric_i64(metrics, "cache_read_tokens") + + metric_i64(metrics, "cache_creation_tokens"); + } + match v.get("role").and_then(Value::as_str) { + Some("user") => { + let text = v.get("content").and_then(Value::as_str).unwrap_or_default(); + if text.trim().is_empty() { + continue; + } + let mut msg = text_msg(Role::User, text, node.created_ms); + if is_internal_user(&v, text) { + msg.kind = MessageKind::Meta; + } + messages.push(msg); + } + Some("assistant") => { + let text = v.get("content").and_then(Value::as_str).unwrap_or_default(); + let thinking = thinking_text(&v); + let calls: Vec = v + .get("tool_calls") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|item| { + let id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let name = + item.get("name").and_then(Value::as_str).unwrap_or_default(); + let args = item.get("arguments").cloned().unwrap_or(Value::Null); + tool_call_view(id, name, &args, None, false) + }) + .collect() + }) + .unwrap_or_default(); + if text.trim().is_empty() && calls.is_empty() && thinking.is_none() { + continue; + } + let mut msg = text_msg(Role::Assistant, text, node.created_ms); + if text.trim_start().starts_with("") { + msg.kind = MessageKind::CompactSummary; + } + msg.thinking = thinking.map(|t| clip(&t, MAX_TOOL_IO).0); + msg.model = v + .pointer("/metadata/generation_model") + .and_then(Value::as_str) + .map(String::from) + .or_else(|| Some(session_model.to_string()).filter(|m| !m.is_empty())); + let base = messages.len(); + for tc in calls { + by_id.insert(tc.id.clone(), (base, msg.tool_calls.len())); + msg.tool_calls.push(tc); + } + messages.push(msg); + } + Some("tool") => { + let output = v.get("content").and_then(Value::as_str).unwrap_or_default(); + let slot = v + .get("tool_call_id") + .and_then(Value::as_str) + .and_then(|id| by_id.get(id).copied()); + if let Some((mi, ti)) = slot { + messages[mi].tool_calls[ti].output = Some(clip(output, MAX_TOOL_IO).0); + } + } + Some("system") => { + let text = v.get("content").and_then(Value::as_str).unwrap_or_default(); + if text.trim().is_empty() { + continue; + } + let mut msg = text_msg(Role::System, text, node.created_ms); + msg.kind = MessageKind::Meta; + messages.push(msg); + } + _ => unknown += 1, + } + } + assign_seq(&mut messages); + Decoded { + messages, + tokens, + unknown, + } +} + +impl AgentAdapter for DevinAdapter { + fn agent(&self) -> AgentId { + AgentId::Devin + } + + fn list_session_files(&self) -> Result> { + // 库不在 = 没有会话(Ok 空);库在但从没读成功过 = 不知道,报 Err 让这轮 + // 扫描停下——折成空会让 seen_paths 清理把整家会话当"磁盘已删"删掉 + let rows = match self.rows() { + Some(rows) => rows, + None if self.db.is_file() => { + anyhow::bail!("{} exists but could not be read", self.db.display()) + } + None => Vec::new(), + }; + Ok(rows + .into_iter() + .filter(|row| row.content_len > 0) + .map(|row| SessionFileRef { + agent: AgentId::Devin, + file_path: virtual_path(&self.db, &row.id), + native_id: row.id, + mtime_ms: row.updated_ms, + size: row.content_len, + }) + .collect()) + } + + fn parse_session(&self, r: &SessionFileRef) -> Result { + let (meta, decoded) = self.parse(r)?; + Ok(ParsedSession::derive( + meta, + &decoded.messages, + decoded.unknown, + )) + } + + fn parse_transcript(&self, r: &SessionFileRef) -> Result { + let (meta, decoded) = self.parse(r)?; + Ok(ParsedTranscript { + meta, + mainline: decoded.messages, + sidechains: Vec::new(), + unknown_line_count: decoded.unknown, + }) + } + + fn data_roots(&self) -> Vec { + vec![self.db.clone()] + } + + fn with_custom_root(&self, dir: PathBuf) -> Box { + // 只按路径形状整形、不看存不存在:远程缓存首次同步前目录还没落盘, + // 判据随目录出现而改判会让先构造的实例指错树。认两种:数据根 + // (/cli/sessions.db),或直接给到 sessions.db 的孤立库拷贝; + // 中间层 cli/ 由 normalize_custom_root 在入库前上提到数据根 + let db = if dir.file_name().is_some_and(|n| n == "sessions.db") { + dir + } else { + db_in(&dir) + }; + Box::new(Self::with_db(db)) + } +} + +/// 入库前的归一化(mod.rs 静态分派):用户在目录选择器里选中 `cli/sessions.db` +/// 或 `cli` 层时上提到数据根。判据是纯路径形状——选中的路径以这几层收尾就按 +/// 层数上提——不摸文件系统;孤立的库拷贝(父链不长这个样)原样保留 +pub fn normalize_custom_root(dir: PathBuf) -> PathBuf { + for rel in [DB_REL, "cli"] { + if dir.ends_with(rel) { + let depth = Path::new(rel).components().count(); + return dir + .ancestors() + .nth(depth) + .map_or_else(|| dir.clone(), Path::to_path_buf); + } + } + dir +} diff --git a/crates/wake-core/src/adapters/mod.rs b/crates/wake-core/src/adapters/mod.rs index a3cac4f..e6e5135 100644 --- a/crates/wake-core/src/adapters/mod.rs +++ b/crates/wake-core/src/adapters/mod.rs @@ -6,6 +6,7 @@ pub mod copilot; pub mod craft; pub mod cursor; pub mod cursor_ide; +pub mod devin; pub mod dsh; pub mod gemini; pub mod grok; @@ -167,7 +168,7 @@ pub trait AgentAdapter: Send + Sync { /// 拥有本家 session 文件的位置",凭据/配置/索引这类不产生会话的文件不列。 /// 新增 adapter 必须实现:没有默认值,漏了编译就过不去 fn data_roots(&self) -> Vec; - /// 文件监听根目录。默认 = data_roots 中现存的目录,二十家实测全部吻合: + /// 文件监听根目录。默认 = data_roots 中现存的目录,二十一家实测全部吻合: /// 目录型给出自己的 root,SQLite 型的根是库文件、天然筛空(watcher 只认 /// .jsonl,库变更靠启动/手动刷新),codex 的 sessions + archived 一并覆盖。 /// 只有当监听范围确实不同于数据根时才覆写——否则一次根路径搬迁 @@ -247,6 +248,7 @@ pub fn normalize_custom_root(agent: AgentId, dir: std::path::PathBuf) -> std::pa AgentId::Codex => codex::normalize_custom_root(dir), AgentId::Zcode => zcode::normalize_custom_root(dir), AgentId::CraftAgents => craft::normalize_custom_root(dir), + AgentId::Devin => devin::normalize_custom_root(dir), _ => dir, } } @@ -266,7 +268,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)。 /// 对用户它顺带是便携安装/多档案切换的手动开关。 @@ -274,7 +276,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 测试 @@ -309,6 +311,7 @@ pub fn create_adapters() -> Vec> { Box::new(codebuddy::CodebuddyAdapter::workbuddy()), Box::new(zcode::ZcodeAdapter::new()), Box::new(craft::CraftAdapter::new()), + Box::new(devin::DevinAdapter::new()), ] } diff --git a/crates/wake-core/src/adapters/remote.rs b/crates/wake-core/src/adapters/remote.rs index 2786b23..51cb7f1 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 e6fe69c..69f3ba7 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 cc57e01..a5f2668 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 368ea0f..be3d79c 100644 --- a/crates/wake-core/src/models.rs +++ b/crates/wake-core/src/models.rs @@ -25,13 +25,14 @@ pub enum AgentId { Workbuddy, Zcode, CraftAgents, + Devin, } impl AgentId { - /// 全部二十家,**枚举声明序**(= Ord = 用户钉的侧栏展示序;面板成组、 + /// 全部二十一家,**枚举声明序**(= Ord = 用户钉的侧栏展示序;面板成组、 /// 表单下拉共用同一顺序)。曾误抄 create_adapters 的构造序,下拉与侧栏 /// 排序当场对不上——契约测试现在卡它与 Ord 一致 - pub const ALL: [AgentId; 20] = [ + pub const ALL: [AgentId; 21] = [ AgentId::ClaudeCode, AgentId::Codex, AgentId::Grok, @@ -52,6 +53,7 @@ impl AgentId { AgentId::Workbuddy, AgentId::Zcode, AgentId::CraftAgents, + AgentId::Devin, ]; pub fn as_str(&self) -> &'static str { @@ -76,6 +78,7 @@ impl AgentId { AgentId::Workbuddy => "workbuddy", AgentId::Zcode => "zcode", AgentId::CraftAgents => "craft-agents", + AgentId::Devin => "devin", } } @@ -101,6 +104,7 @@ impl AgentId { "workbuddy" => Some(AgentId::Workbuddy), "zcode" => Some(AgentId::Zcode), "craft-agents" => Some(AgentId::CraftAgents), + "devin" => Some(AgentId::Devin), _ => None, } } @@ -127,6 +131,7 @@ impl AgentId { AgentId::Workbuddy => "WorkBuddy", AgentId::Zcode => "ZCode", AgentId::CraftAgents => "Craft Agents", + AgentId::Devin => "Devin", } } @@ -210,6 +215,16 @@ impl AgentId { // 官方 app 图标(黑底白 Z),两模式通用 AgentId::Zcode => "brands/zcode.png", AgentId::CraftAgents => "brands/craft-agents.png", + // 官网现用的单色标志(三个六边形、中间一个圆形缺口,devin.ai 的 SVG 渲染), + // 与 Cursor / Grok 一样按模式取白版或深墨版。lobe-icons 那版蓝青绿六段链不是 + // 官网现用的;Devin Desktop 的 app 图标是整块圆角方块加蓝图网格,侧栏尺寸下只剩噪点 + AgentId::Devin => { + if dark { + "brands/devin.png" + } else { + "brands/devin-light.png" + } + } } } } @@ -947,7 +962,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 630bf6f..a347c39 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] = &[ @@ -243,6 +243,18 @@ pub const REMOTE_LAYOUTS: &[RemoteAgentLayout] = &[ "session.jsonl.tmp", ], }, + RemoteAgentLayout { + agent: AgentId::Devin, + // `cli/sessions.db` 是会话与正文的唯一来源(转录也在库里,transcripts/ + // 是导出副本不拉);凭证在 ~/.config/devin 下、不在数据根里, + // session_locks/_versions/logs 不进白名单 + mount: ".local/share/devin", + sync_paths: &[ + ".local/share/devin/cli/sessions.db", + ".local/share/devin/cli/sessions.db-wal", + ], + exclude: &[], + }, ]; /// host 名合法性:进 session key 作中段(不能有 ':')、直接作为 ssh/rsync diff --git a/crates/wake-core/src/services/terminal/mod.rs b/crates/wake-core/src/services/terminal/mod.rs index 94f6dd5..23e776a 100644 --- a/crates/wake-core/src/services/terminal/mod.rs +++ b/crates/wake-core/src/services/terminal/mod.rs @@ -148,6 +148,7 @@ pub fn agent_bin(agent: AgentId) -> Option<&'static str> { // Craft Agents 是桌面 app;它的 craft-cli 只会连一台在跑的 Craft server // 发消息,不是能在终端里接着聊的界面 AgentId::CraftAgents => None, + AgentId::Devin => Some("devin"), } } @@ -200,6 +201,9 @@ fn resume_args(meta: &SessionMeta) -> Option<(Vec, bool)> { // Craft Agents:`craftagents://` 能定位会话,但要带全局 config.json 里的 // 工作区 id(那个文件存着 server token,不读);也不是终端命令——不画 Open In AgentId::CraftAgents => None, + // Devin 会话按 cwd 分桶(`devin list` 只列当前目录),`--resume ` + // 在原项目目录启动 + AgentId::Devin => Some((vec!["--resume".into(), id.into()], true)), // Kiro / Gemini CLI 没有按会话 id 续会话的形制 AgentId::Kiro | AgentId::Gemini => None, } diff --git a/crates/wake-core/tests/adapter_contracts.rs b/crates/wake-core/tests/adapter_contracts.rs index bc92aaa..3a88e43 100644 --- a/crates/wake-core/tests/adapter_contracts.rs +++ b/crates/wake-core/tests/adapter_contracts.rs @@ -21,6 +21,7 @@ use wake_core::adapters::copilot::CopilotAdapter; use wake_core::adapters::craft::CraftAdapter; use wake_core::adapters::cursor::CursorAdapter; use wake_core::adapters::cursor_ide::CursorIdeAdapter; +use wake_core::adapters::devin::DevinAdapter; use wake_core::adapters::dsh::DshAdapter; use wake_core::adapters::gemini::GeminiAdapter; use wake_core::adapters::grok::GrokAdapter; @@ -50,6 +51,7 @@ struct TestEnv { openclaw_db: PathBuf, cursor_ide_db: PathBuf, zcode_db: PathBuf, + devin_db: PathBuf, /// 假 HOME 目录本体,持有 TempDir 保证整个测试进程期间不被清理 _home: tempfile::TempDir, } @@ -93,6 +95,7 @@ fn setup() -> &'static TestEnv { openclaw_db: sc.openclaw_db, cursor_ide_db: sc.cursor_ide_db, zcode_db: sc.zcode_db, + devin_db: sc.devin_db, _home: home, } }) @@ -2263,6 +2266,14 @@ fn seq_contract_holds_for_all_agents() { CraftAdapter::new().with_custom_root(fixture("craft-agents")), craft_ref("260803-bold-pine"), ), + ( + Box::new(DevinAdapter::new()), + db_ref(AgentId::Devin, &env.devin_db, "dv-0001"), + ), + ( + Box::new(DevinAdapter::new()), + db_ref(AgentId::Devin, &env.devin_db, "dv-0002"), + ), ]; for (adapter, r) in &checks { assert_seq_contract(adapter.as_ref(), r); @@ -2797,6 +2808,204 @@ fn zcode_custom_root_lifts_to_home_before_storing() { assert_eq!(adapter.data_roots(), vec![lone]); } +/// Devin:`cli/sessions.db` 单库两表——sessions + message_nodes 森林。hidden=1 +/// 与零正文会话不列;可见转录是从 main_chain_id 叶子沿 parent_node_id 走回 +/// 根的链,重试侧枝不进转录、其 metrics 不进 token 累计;消息级 +/// generation_model 比 sessions.model 权威;system 注入、心跳与 compaction +/// 请求归 Meta, 应答折 CompactSummary +#[test] +fn devin_parse_contract() { + let env = setup(); + let adapter = DevinAdapter::new(); + let mut ids: Vec = adapter + .list_session_files() + .expect("devin list") + .into_iter() + .map(|r| r.native_id) + .collect(); + ids.sort(); + // dv-0003 是 hidden 会话、dv-0004 零正文,都不列 + assert_eq!(ids, vec!["dv-0001", "dv-0002"]); + // 虚拟路径 # + assert_eq!( + adapter.list_session_files().unwrap()[0].file_path, + format!("{}#dv-0001", env.devin_db.display()) + ); + + let r = db_ref(AgentId::Devin, &env.devin_db, "dv-0001"); + let s = adapter.parse_session(&r).expect("devin parse_session"); + let t = adapter + .parse_transcript(&r) + .expect("devin parse_transcript"); + assert_eq!(s.meta.key, "devin:dv-0001"); + assert_eq!(s.meta.title, "Devin QR fix"); + assert_eq!(s.meta.project_path, "/Users/tester/Github/wakefx"); + assert_eq!(s.meta.project_name, "wakefx"); + // 逐消息 generation_model,取最后一条 assistant 的实发模型 + assert_eq!(s.meta.model.as_deref(), Some("swe-2-max")); + // 主链 (200+20+800) + (300+40);重试侧枝 n5 的 550 不进账 + assert_eq!(s.meta.tokens_used, Some(1360)); + assert_eq!(s.meta.created_at, 1789000000000); + assert_eq!(s.meta.updated_at, 1789000060000); + assert_eq!(s.meta.message_count, 3); + assert_eq!( + roles_kinds(&t.mainline), + vec![ + (Role::User, MessageKind::Text), + (Role::Assistant, MessageKind::Text), + (Role::Assistant, MessageKind::Text), + ] + ); + assert!(!t.mainline.iter().any(|m| m.text.contains("重试侧枝"))); + let a = &t.mainline[1]; + assert_eq!(a.thinking.as_deref(), Some("先读一下组件源码")); + assert_eq!(a.model.as_deref(), Some("swe-2-high")); + assert_eq!(a.timestamp, Some(1789000008000)); + assert_eq!(a.tool_calls.len(), 1); + assert_eq!(a.tool_calls[0].name, "exec"); + assert!(a.tool_calls[0].input_preview.contains("rg useEffect")); + assert_eq!( + a.tool_calls[0].output.as_deref(), + Some("src/QrScanner.tsx:12: useEffect(() => watch())") + ); + // seq 契约:FTS 单元的 seq 等于详情页序号(Meta / CompactSummary 不进 FTS) + assert_eq!( + s.units.iter().map(|u| u.seq).collect::>(), + t.mainline + .iter() + .filter(|m| m.kind == MessageKind::Text) + .map(|m| m.seq) + .collect::>() + ); + + // 空标题回退首条真人消息;system 注入、心跳、compaction 请求归 Meta, + // 应答折 CompactSummary + let r2 = db_ref(AgentId::Devin, &env.devin_db, "dv-0002"); + let s2 = adapter.parse_session(&r2).unwrap(); + let t2 = adapter.parse_transcript(&r2).unwrap(); + assert_eq!(s2.meta.title, "空标题会话取这句"); + assert_eq!(s2.meta.message_count, 2); + assert_eq!( + roles_kinds(&t2.mainline), + vec![ + (Role::System, MessageKind::Meta), + (Role::User, MessageKind::Meta), + (Role::User, MessageKind::Meta), + (Role::Assistant, MessageKind::CompactSummary), + (Role::User, MessageKind::Text), + (Role::Assistant, MessageKind::Text), + ] + ); +} + +/// 老库没有 hidden / main_chain_id 两列:照常列(无 hidden 不过滤),链退回 +/// 全部节点(无 main_chain_id 就按 created_at,row_id 全列) +#[test] +fn devin_degrades_on_old_schema() { + setup(); + let home = tempfile::tempdir().unwrap(); + let db_dir = home.path().join("cli"); + fs::create_dir_all(&db_dir).unwrap(); + let conn = rusqlite::Connection::open(db_dir.join("sessions.db")).unwrap(); + conn.execute_batch( + r#" + CREATE TABLE sessions (id TEXT PRIMARY KEY, working_directory TEXT NOT NULL, + backend_type TEXT NOT NULL, model TEXT NOT NULL, + agent_mode TEXT NOT NULL, created_at INTEGER NOT NULL, + last_activity_at INTEGER NOT NULL, title TEXT); + CREATE TABLE message_nodes (row_id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, + node_id INTEGER NOT NULL, parent_node_id INTEGER, + chat_message TEXT NOT NULL, created_at INTEGER NOT NULL); + INSERT INTO sessions VALUES ('old-1','/work/old','windsurf','swe-1','auto',1789000000,1789000001,'old schema'); + INSERT INTO message_nodes (session_id, node_id, parent_node_id, chat_message, created_at) VALUES + ('old-1',1,NULL,'{"role":"user","content":"老库也要能读"}',1789000000), + ('old-1',2,1,'{"role":"assistant","content":"在。","metadata":{"generation_model":"swe-1","metrics":{"input_tokens":10,"output_tokens":5}}}',1789000001); + "#, + ) + .unwrap(); + drop(conn); + + let adapter = DevinAdapter::new().with_custom_root(home.path().to_path_buf()); + let refs = adapter.list_session_files().unwrap(); + assert_eq!(refs.len(), 1, "老 schema 不得整家消失"); + let s = adapter.parse_session(&refs[0]).unwrap(); + assert_eq!(s.meta.title, "old schema"); + assert_eq!(s.meta.project_path, "/work/old"); + assert_eq!(s.meta.message_count, 2); + assert_eq!(s.meta.tokens_used, Some(15)); +} + +/// 主链走不到根(叶子的父节点不在库里)就退回全部节点,不给半截链;解不开的 +/// chat_message 与词汇表外的 role 计进 unknown(格式漂移的金丝雀) +#[test] +fn devin_broken_chain_falls_back_and_counts_unknown_nodes() { + setup(); + let home = tempfile::tempdir().unwrap(); + let db_dir = home.path().join("cli"); + fs::create_dir_all(&db_dir).unwrap(); + let conn = rusqlite::Connection::open(db_dir.join("sessions.db")).unwrap(); + conn.execute_batch( + r#" + CREATE TABLE sessions (id TEXT PRIMARY KEY, working_directory TEXT NOT NULL, + model TEXT NOT NULL, created_at INTEGER NOT NULL, + last_activity_at INTEGER NOT NULL, title TEXT, + main_chain_id INTEGER, hidden INTEGER NOT NULL DEFAULT 0); + CREATE TABLE message_nodes (row_id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, + node_id INTEGER NOT NULL, parent_node_id INTEGER, + chat_message TEXT NOT NULL, created_at INTEGER NOT NULL); + INSERT INTO sessions VALUES ('gap-1','/work/gap','swe-2',1789000000,1789000005,'gap',5,0); + INSERT INTO message_nodes (session_id, node_id, parent_node_id, chat_message, created_at) VALUES + ('gap-1',1,NULL,'{"role":"user","content":"第一句"}',1789000001), + ('gap-1',2,1,'{"role":"assistant","content":"第一句的回答"}',1789000002), + ('gap-1',3,2,'{"role":"developer","content":"没见过的角色"}',1789000003), + ('gap-1',4,3,'{ torn',1789000004), + ('gap-1',5,99,'{"role":"assistant","content":"父节点 99 不在库里"}',1789000005); + "#, + ) + .unwrap(); + drop(conn); + + let adapter = DevinAdapter::new().with_custom_root(home.path().to_path_buf()); + let refs = adapter.list_session_files().unwrap(); + let t = adapter.parse_transcript(&refs[0]).unwrap(); + assert_eq!( + t.mainline + .iter() + .map(|m| m.text.as_str()) + .collect::>(), + ["第一句", "第一句的回答", "父节点 99 不在库里"], + "只剩叶子那一句就把前面的对话丢了" + ); + assert_eq!(t.unknown_line_count, 2, "没见过的 role + 写坏的 JSON"); + assert_eq!( + adapter.parse_session(&refs[0]).unwrap().unknown_line_count, + 2 + ); +} + +/// 自定义 location:`cli/sessions.db` 与 `cli` 层在入库前上提到数据根 +/// (normalize_custom_root),孤立库拷贝原样进构造器 +#[test] +fn devin_custom_root_normalization() { + setup(); + let root = Path::new("/nowhere/devin-data"); + for (picked, stored) in [ + (root.join("cli/sessions.db"), root.to_path_buf()), + (root.join("cli"), root.to_path_buf()), + (root.to_path_buf(), root.to_path_buf()), + ] { + assert_eq!( + wake_core::adapters::normalize_custom_root(AgentId::Devin, picked), + stored + ); + } + let adapter = DevinAdapter::new().with_custom_root(root.to_path_buf()); + assert_eq!(adapter.data_roots(), vec![root.join("cli/sessions.db")]); + let lone = PathBuf::from("/backup/sessions.db"); + let adapter = DevinAdapter::new().with_custom_root(lone.clone()); + assert_eq!(adapter.data_roots(), vec![lone]); +} + #[test] fn hermes_parse_contract() { let env = setup(); diff --git a/crates/wake-core/tests/common/mod.rs b/crates/wake-core/tests/common/mod.rs index d5064ad..dc48937 100644 --- a/crates/wake-core/tests/common/mod.rs +++ b/crates/wake-core/tests/common/mod.rs @@ -240,6 +240,7 @@ pub struct Sidecars { pub openclaw_db: PathBuf, pub cursor_ide_db: PathBuf, pub zcode_db: PathBuf, + pub devin_db: PathBuf, } /// 侧档与 SQLite 型 fixture 库:copilot/opencode(两代)/antigravity 现建库, @@ -343,6 +344,15 @@ pub fn stage_sidecars(home: &Path) -> Sidecars { fs::create_dir_all(zcode_tasks_db.parent().unwrap()).expect("mkdir .zcode/v2"); build_zcode_tasks_db(&zcode_tasks_db); + let devin_db = home + .join(".local") + .join("share") + .join("devin") + .join("cli") + .join("sessions.db"); + fs::create_dir_all(devin_db.parent().unwrap()).expect("mkdir devin cli dir"); + build_devin_db(&devin_db); + Sidecars { copilot_db, opencode_db, @@ -353,6 +363,7 @@ pub fn stage_sidecars(home: &Path) -> Sidecars { openclaw_db, cursor_ide_db, zcode_db, + devin_db, } } @@ -901,6 +912,53 @@ pub fn build_zcode_tasks_db(path: &Path) { .expect("populate zcode tasks fixture db"); } +/// Devin `/cli/sessions.db` 最小同构库(列集按真机写端): +/// sessions + message_nodes(node_id/parent_node_id 森林,chat_message JSON, +/// unix 秒时间戳)。dv-0001 是正常会话:main_chain_id 指向 n4,n5 是挂在 +/// n1 上的重试侧枝——既不该进转录,它的 metrics 也不该进 token 累计; +/// dv-0002 标题为空(回退首条真人消息),带 system 注入、cache_keepalive +/// 心跳、compaction 请求与 应答;dv-0003 是 hidden 会话不列; +/// dv-0004 零正文不列 +pub fn build_devin_db(path: &Path) { + let conn = rusqlite::Connection::open(path).expect("create devin fixture db"); + conn.execute_batch( + r#" + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, working_directory TEXT NOT NULL, backend_type TEXT NOT NULL, + model TEXT NOT NULL, agent_mode TEXT NOT NULL, created_at INTEGER NOT NULL, + last_activity_at INTEGER NOT NULL, title TEXT, main_chain_id INTEGER, + hidden INTEGER NOT NULL DEFAULT 0, metadata TEXT + ); + CREATE TABLE message_nodes ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, + node_id INTEGER NOT NULL, parent_node_id INTEGER, + chat_message TEXT NOT NULL, created_at INTEGER NOT NULL, metadata TEXT, + UNIQUE(session_id, node_id) + ); + INSERT INTO sessions (id, working_directory, backend_type, model, agent_mode, + created_at, last_activity_at, title, main_chain_id, hidden) VALUES + ('dv-0001','/Users/tester/Github/wakefx','windsurf','swe-2-max','auto',1789000000,1789000060,'Devin QR fix',4,0), + ('dv-0002','/Users/tester/Github/wakefx','windsurf','swe-2-max','auto',1789000100,1789000110,'',6,0), + ('dv-0003','/Users/tester/Github/wakefx','windsurf','swe-2-max','auto',1789000200,1789000210,'hidden helper',1,1), + ('dv-0004','/Users/tester/Github/wakefx','windsurf','swe-2-max','auto',1789000300,1789000300,'empty',NULL,0); + INSERT INTO message_nodes (session_id, node_id, parent_node_id, chat_message, created_at) VALUES + ('dv-0001',1,NULL,'{"message_id":"u1","role":"user","content":"Devin 看看二维码扫描为何闪退,是不是 useEffect() 的问题","metadata":{"is_user_input":true,"telemetry":{"source":"user"}}}',1789000005), + ('dv-0001',2,1,'{"message_id":"a1","role":"assistant","content":"","thinking":{"thinking":"先读一下组件源码","signature":"sealed.v1.xyz","signature_type":"sealed"},"tool_calls":[{"id":"exec_0","name":"exec","arguments":{"command":"rg useEffect src/QrScanner.tsx"},"index":0,"kind":"function"}],"metadata":{"generation_model":"swe-2-high","metrics":{"input_tokens":200,"output_tokens":20,"cache_read_tokens":800,"cache_creation_tokens":null}}}',1789000008), + ('dv-0001',3,2,'{"message_id":"t1","role":"tool","content":"src/QrScanner.tsx:12: useEffect(() => watch())","tool_call_id":"exec_0","metadata":{}}',1789000009), + ('dv-0001',4,3,'{"message_id":"a2","role":"assistant","content":"是依赖数组问题,我给出了修复补丁。","metadata":{"generation_model":"swe-2-max","metrics":{"input_tokens":300,"output_tokens":40}}}',1789000012), + ('dv-0001',5,1,'{"message_id":"a3-retry","role":"assistant","content":"重试侧枝的另一种回答,不该进主链","metadata":{"generation_model":"swe-2-max","metrics":{"input_tokens":500,"output_tokens":50}}}',1789000020), + ('dv-0002',1,NULL,'{"message_id":"s1","role":"system","content":"\nworkspace context\n","metadata":{"telemetry":{"source":"system"}}}',1789000101), + ('dv-0002',2,1,'{"message_id":"u0","role":"user","content":"continue","metadata":{"telemetry":{"source":"cache_keepalive"}}}',1789000102), + ('dv-0002',3,2,'{"message_id":"u1","role":"user","content":"Conversation to summarize:\n=== MESSAGE 0 - User ===\n二维码","metadata":{"is_user_input":null,"telemetry":{"source":"user"}}}',1789000103), + ('dv-0002',4,3,'{"message_id":"a1","role":"assistant","content":"\n## Overview\n之前聊过二维码扫描的修复。\n","metadata":{"telemetry":{"source":"assistant"}}}',1789000104), + ('dv-0002',5,4,'{"message_id":"u2","role":"user","content":"空标题会话取这句","metadata":{"is_user_input":true,"telemetry":{"source":"user"}}}',1789000105), + ('dv-0002',6,5,'{"message_id":"a2","role":"assistant","content":"好的。","metadata":{"generation_model":"swe-2-max","metrics":{"input_tokens":50,"output_tokens":10}}}',1789000110), + ('dv-0003',1,NULL,'{"message_id":"u1","role":"user","content":"Output a summary from the following messages","metadata":{"is_user_input":true,"telemetry":{"source":"user"}}}',1789000205); + "#, + ) + .expect("populate devin fixture db"); +} + /// 以某种身份持住索引锁,拿不到就 panic 点名——各测试文件里"扮演 GUI / 另一个 /// wake-cli"的同一句 pub fn lock_as(db: &Path, kind: &str) -> IndexLock { diff --git a/crates/wake/assets/brands/devin-light.png b/crates/wake/assets/brands/devin-light.png new file mode 100644 index 0000000..c8a09dc Binary files /dev/null and b/crates/wake/assets/brands/devin-light.png differ diff --git a/crates/wake/assets/brands/devin.png b/crates/wake/assets/brands/devin.png new file mode 100644 index 0000000..e93f54d Binary files /dev/null and b/crates/wake/assets/brands/devin.png differ diff --git a/crates/wake/src/assets.rs b/crates/wake/src/assets.rs index 2ba7578..159fb6e 100644 --- a/crates/wake/src/assets.rs +++ b/crates/wake/src/assets.rs @@ -63,6 +63,8 @@ brands!( "workbuddy", "zcode", "craft-agents", + "devin", + "devin-light", ); fn lookup_product(path: &str) -> Option<&'static [u8]> { diff --git a/crates/wake/src/theme.rs b/crates/wake/src/theme.rs index f3680a0..d8e8c08 100644 --- a/crates/wake/src/theme.rs +++ b/crates/wake/src/theme.rs @@ -110,6 +110,9 @@ pub fn agent_series_color(agent: wake_core::models::AgentId) -> u32 { Zcode => 0x8B95A5, // app 图标像素 "C" 的紫(取自内嵌 PNG),比 Kiro 的电光紫灰、暗 CraftAgents => 0x9570BE, + // 标志是单色的,给一档天蓝(Devin Desktop 的主色系),蓝色家族里离 Gemini、 + // Cursor 都最远的一档 + Devin => 0x3BB3F1, } } diff --git a/crates/wake/src/workbench.rs b/crates/wake/src/workbench.rs index ab1de04..d9ff524 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, diff --git a/docs/cli.md b/docs/cli.md index 2e62cf2..476cd2f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -303,7 +303,7 @@ Numeric options outside their range are clamped, not rejected: `--limit 999` on ## Agent ids -`claude-code`, `codex`, `grok`, `dsh`, `cursor`, `opencode`, `pi`, `omp`, `kiro`, `kimi`, `gemini`, `copilot`, `antigravity`, `qoder`, `hermes`, `openclaw`, `codebuddy`, `workbuddy`, `zcode`, `craft-agents`. Display names (`"Claude Code"`) and a few aliases (`claude`, `deepseek`, `craft`) work too. +`claude-code`, `codex`, `grok`, `dsh`, `cursor`, `opencode`, `pi`, `omp`, `kiro`, `kimi`, `gemini`, `copilot`, `antigravity`, `qoder`, `hermes`, `openclaw`, `codebuddy`, `workbuddy`, `zcode`, `craft-agents`, `devin`. Display names (`"Claude Code"`) and a few aliases (`claude`, `deepseek`, `craft`) work too. ## Session keys and references diff --git a/docs/mcp.md b/docs/mcp.md index 739a2ed..2cb0f00 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -132,7 +132,7 @@ All five tools are read-only and return Markdown text (`content[0].text`). Param | Parameter | Type | Meaning | |---|---|---| | `project` | string | Scope to one project. Pass an absolute path — the agent's working directory is ideal — or a project name. Path matching is three-tier: an exact match of an indexed project path; otherwise the longest indexed project that contains the path (you are in a subdirectory); otherwise every indexed project below the path (a monorepo root or a parent folder). A bare name matches the project name case-insensitively. When nothing matches, the reply lists the known projects instead of erroring. | -| `agents` | string[] | Only these agents. Ids: `claude-code`, `codex`, `grok`, `dsh`, `cursor`, `opencode`, `pi`, `omp`, `kiro`, `kimi`, `gemini`, `copilot`, `antigravity`, `qoder`, `hermes`, `openclaw`, `codebuddy`, `workbuddy`, `zcode`, `craft-agents`. Display names (`"Claude Code"`, `"Gemini CLI"`) and a few aliases (`claude`, `deepseek`, `opencode2`, `craft`) are accepted too. | +| `agents` | string[] | Only these agents. Ids: `claude-code`, `codex`, `grok`, `dsh`, `cursor`, `opencode`, `pi`, `omp`, `kiro`, `kimi`, `gemini`, `copilot`, `antigravity`, `qoder`, `hermes`, `openclaw`, `codebuddy`, `workbuddy`, `zcode`, `craft-agents`, `devin`. Display names (`"Claude Code"`, `"Gemini CLI"`) and a few aliases (`claude`, `deepseek`, `opencode2`, `craft`) are accepted too. | | `since` | string | Only sessions updated at or after this time. Relative: `30m`, `24h`, `7d`, `2w`. Absolute: `2026-09-01`, `2026-09-01 09:30`, `2026-09-01T09:30:00Z`. Naive date-times are read in local time. | | `limit` | integer | Maximum items to return; values outside the allowed range are clamped. |