WorkBuddy 工作空间清理
用途
本技能通过直接操作 workspaces 表来清理工作空间下拉列表(~/.workbuddy/workbuddy.db),并在每次写入前自动备份,可随时回滚。
本技能包含两层能力:
| 层 | 能力 | 计费 |
|---|---|---|
| 本地层(默认,始终可用) | 扫描 / 清理 / 回滚 workspaces 表与会话分组 |
免费,纯本地,不联网 |
| 付费资源层(可选) | 工作空间「深度体检报告」:跨表交叉核对 + 逐条归因 + 可视化体检结论 | 支付宝 AI 按量付费(A2M),按次收费 |
> 本地层不依赖任何网络与账号,付费资源层不可用时本地层功能完全不受影响。
适用场景:
- "把工作空间下拉里那些日期目录全删了"
- "不想再看到 C 盘的工作空间"
- "列了一大堆点进去打不开的工作空间,清一下"
- "侧边栏那些点不进去的日期分组也一起清了"
> ⚠️ 先看下面「核心认知」一节。如果你的目标是「让侧边栏的『空间』数量变多」,本技能做不到,也不该做 —— 那和 workspaces 表无关。
> 关于界面入口:早期版本结论是「界面没有删除入口」,已过时。当前版本应用内已有官方流程
> delete.workspace.dialog /「移除工作空间」(挂在侧边栏工作空间分组的操作菜单上,批量删除时文案是
> 「同时 {count} 个工作空间会被一并移除。本地文件保留」)。只有在该分组根本没显示时(见下节)才无法触达,
> 这时本技能才是唯一手段。
付费资源层:工作空间「深度体检报告」(支付宝 A2M 按量付费)💰
> 本节是可选增强能力。 本地清理(--status / --clean / --restore)无需付费、无需联网,先看后面「用法」一节。
它是什么
「深度体检报告」在本地只读扫描的基础上,额外给出:workspaces 表 × sessions 表交叉核对、
每条会话的侧边栏归属逐条归因、失效路径与僵尸分组的量化统计、以及一份可直接执行的处置建议。
这份报告由远端计费服务生成,属于按次付费资源。
单价:由服务端配置(默认 0.01 CNY/次),以服务端下发的 402 账单为准。
结算:支付宝 AI 按量付费(A2M) —— 由支付宝侧完成支付,本技能与服务端都不接触用户的支付凭证或资金。
前置条件
需要一份支付宝 AI 收银台所需的运行环境(即 alipay-pay-for-402-service 技能)。它负责在收到 402 时唤起支付并把支付凭证回传。若当前环境未安装,按官方方式安装:
npx -y @alipay/alipay-aipay@latest install
服务端地址(可配置)
计费服务端地址按以下优先级解析,默认指向本地联调地址:
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | 环境变量 WB_CLEANUP_A2M_URL |
显式覆盖,最高优先 |
| 2 | 技能目录下 server/.a2m_url 文件内容 |
部署时写入,便于固定 |
| 3 | http://127.0.0.1:8787 |
本地默认(配合 --mock 联调) |
受保护资源端点固定为 /resource(GET)。
请求流程(核心,必须严格遵守)
1. 需要「深度体检报告」时,向服务端发起:
GET {A2M_URL}/resource
【不要】自己计算金额、自己构造账单、自己校验支付 —— 金额与订单只在服务端产生。
2. 若返回 200 且 body.code == "10000"
→ 交付成功,直接使用 body.resource 的内容,流程结束。
3. 若返回 402(Payment Required)
→ 【关键】不要尝试自行解析、自行支付、自行重试。
把 402 的【整个原始响应报文】原样转交给 `alipay-pay-for-402-service` 技能:
· 完整响应 Header(尤其 `Payment-Needed`)
· 完整响应 Body
由该技能负责唤起支付宝收银台完成支付,并带回 `Payment-Proof`。
4. 拿到 `Payment-Proof` 后,携带该 Header 重新请求:
GET {A2M_URL}/resource
Payment-Proof: <上一步拿到的值>
→ 返回 200 即为支付成功且资源已交付。
5. 若再次返回 402
→ 说明支付未通过(余额/超时/凭证失效/该凭证已使用),把新的 402 报文
再次转交 `alipay-pay-for-402-service`。**最多重试 2 次**,仍失败则
向用户说明原因并回退到本地层(免费)继续。
铁律(违反会导致资金或数据风险)
- 绝不本地算钱。 金额、订单号一律以服务端 402 账单为准。
- 绝不本地加签。
seller_signature由服务端持有私钥签发;客户端只做验签(可选)。 - 402 报文要原样转交。 不要裁剪、不要只取
out_trade_no、不要重新序列化 ——
收银台技能需要完整的protocol+method两层结构。 - 同一
Payment-Proof只能用一次。 支付凭证一次性;重复使用会被服务端拒绝(防重放)。 - 交付失败不算成功。 只有
HTTP 200+code == "10000"才算资源到手;
任何其他状态都不得向用户声称"已支付成功"。 - 付费层失败不阻塞本地层。 用户即便不付费,本技能的本地清理功能仍应正常提供 ——
不要因为付费失败而拒绝服务。
让用户知情
在引导付费前,必须明确告知用户这是收费能力(单价、按次、由支付宝结算),
并说明「免费本地清理」的替代路径。不得在用户不知情的情况下发起付费请求。
本地层:核心认知 —— 侧边栏「空间 (N)」不是工作空间数量 ⭐
这是最容易误判的一点。下拉列表(工作空间)和侧边栏「空间 (N)」是两个完全不同的数据源:
| 数据源 | 计数含义 | |
|---|---|---|
| 下拉「工作空间」列表 | workspaces 表 |
表里的行数 |
| 侧边栏「空间 (N)」 | sessions 表逐条归类后落入 space 桶的会话(任务)数 |
不是工作空间数,是任务数 |
应用源码里的权威注释(渲染层 buildSpaceFolder,见 packages/agent-ui/src/components/conversation-list/):
> space.totalCount = subfolders.reduce((s, sf) => s + sf.totalCount, 0) ·
> 即空间下所有子文件夹会话数的总和(而非 subfolders.length)。
也就是说:空间 (N) 里的 N 永远是「落在工作空间里的任务数」,workspaces 表怎么改都不会影响它。
删 / 增 workspaces 行 ⇒ 侧边栏「空间」计数纹丝不动。
一条会话要落进「空间」桶,必须同时满足
由 classify$1() 的 10 层归属矩阵决定(命中即停)。这里只列与本地任务相关的几层:
Pre-check lifecycle === 'archived' → 直接 skip(不进侧边栏)
4. 带 projectId → 落「空间」桶(kind: project)
5. kind/space.type === 'assistant' → skip(claw 等由 UI 单独渲染)
6. transport === 'local' && autoGenerated === true → 落「任务」桶(folders.local)
7. transport === 'local' && autoGenerated !== true && cwd 非空 → 落「空间」桶(kind: workdir)
9. transport === 'local'(无 cwd,非 playground) → 落「任务」桶(边界兜底)
而 autoGenerated 的来源只有一个 —— 渲染层 sidebarConvToGroupingInput() 里的字面量:
autoGenerated: conv.isPlayground === true
↓ 即
sessions.is_playground (DB 字段,schema 为 NOT NULL DEFAULT 0)
⚠️ 三个曾经踩过的误判(旧版本文档写错过,已修正):
is_playground = 1的会话一律进「任务」。快速问答模式(mode === 'quick')会被强制置
isPlayground: true;「不使用工作空间」创建的临时任务同理。这是唯一的 playground 判据。- 目录名叫什么完全不影响归属。 曾有一版误以为「目录名形如
2026-09-28-16-47-14就会被判为
自动生成」—— 这是错的。app 里那个按目录名判定的isAutoGeneratedWorkspaceCwd()
是用在别的场景(工作空间 picker 的可选性),不参与侧边栏归类。
只要is_playground = 0,哪怕目录名是时间戳,照样进「空间」。 - 归档看
lifecycle,不能只看status。 core 的lifecycle独立于运行态status;
status会被mapConversationStateToStatus把 idle 的归档态映射成completed,
从而丢失归档信息。只判status == 'archived'会漏掉一部分归档会话。
所以「空间 (0)」的典型病因
| 症状 | 病因 | 处理 |
|---|---|---|
| 空间 (0),但下拉里有若干工作空间 | 正常现象。你的任务全部 is_playground = 1,没有一条落进 space 桶 |
想要空间里有数:在该工作空间里新建任务,且别用快速问答模式(这样才会写入 is_playground = 0),或对已有任务点顶栏的「保存到工作空间」 |
| 想知道具体是哪条任务落哪里 | 不用猜 | 跑 --spaces-diag,逐条列出落点与原因 |
改了 workspaces 表但空间计数不变 |
预期行为,两者无关联 | 见上表 |
排查命令(只读,不改任何东西):
python scripts/ws_cleanup.py --spaces-diag
会按应用的归类规则,逐条列出会话最终会落到「任务」「空间」还是「不进侧边栏(跳过)」,
并给出原因。输出格式:空间 N 条 / 任务 M 条 / 跳过 K 条。
关键事实(先读,避免踩坑)
- 列表数据源:
~/.workbuddy/workbuddy.db的workspaces(path TEXT PRIMARY KEY, last_opened_at INTEGER)表,按last_opened_at倒序展示。 - 该表无任何外键(
pragma foreign_keys= 0,schema 里也没有 FK)。删它不会级联影响sessions会话历史。 - 不要用改文件夹名的方式改名/删除:界面读的是数据库记录,改文件名只会让记录变成失效路径。
- 必须重启 WorkBuddy 才生效:列表在启动时读表,运行中改数据库界面不会刷新。
- 重启后 App 会把"当前所在目录"自动 upsert 回
workspaces,所以列表不会长期保持空——这是正常行为,不是清理失败。 - 清空
workspaces不会让侧边栏那些日期分组消失,也不会改变「空间 (N)」的数字。
侧边栏是按sessions逐条归类后按 cwd 聚合的,要动得处理会话(见「核心认知」与下节)。
用法(本地层 · 免费)
脚本:scripts/ws_cleanup.py(纯标准库,Python 3.10+)
# 0) 先只读看一眼现状(不带参数时的默认行为,不写任何东西)
python scripts/ws_cleanup.py --status
# 1) 清掉指向"已不存在文件夹"的失效记录(最常用、最安全)
python scripts/ws_cleanup.py --clean dead --yes
# 2) 只清 C 盘的工作空间
python scripts/ws_cleanup.py --clean c-drive --yes
# 3) 按关键字匹配删除(大小写不敏感,匹配路径子串)
python scripts/ws_cleanup.py --clean "match:2026-09-26" --yes
# 4) 清空整个列表
python scripts/ws_cleanup.py --clean all --yes
# 5) 侧边栏僵尸分组:只读查看 / 软删除 cwd 已失效的会话
python scripts/ws_cleanup.py --sessions
python scripts/ws_cleanup.py --sessions-clean dead --yes
# 6) 诊断「空间 (N)」为什么是这个数(只读,见「核心认知」一节)
python scripts/ws_cleanup.py --spaces-diag
# 7) 回滚
python scripts/ws_cleanup.py --list-backups
python scripts/ws_cleanup.py --restore --latest --yes
--yes 可省略,省略时会交互式要求输入 yes 确认。--db <路径> 可指向别的数据库(默认 ~/.workbuddy/workbuddy.db)。
用法(付费资源层 · 按次计费)
脚本:scripts/fetch_report.py(纯标准库,用于请求远端付费资源)
# 按服务端地址优先级解析并请求「深度体检报告」
python scripts/fetch_report.py
# 指定服务端地址(等价于设 WB_CLEANUP_A2M_URL)
python scripts/fetch_report.py --url http://127.0.0.1:8787
# 只探测当前是「可访问(200)」还是「需付费(402)」,不做任何支付动作
python scripts/fetch_report.py --probe
行为约定(与「请求流程」一节一致):
- 无
--proof时直接请求 → 若得200则打印报告;若得402则原样输出整份 402 报文
(含Payment-NeededHeader 与 Body),交给上层转交alipay-pay-for-402-service。 - 拿到支付凭证后再次请求:
python scripts/fetch_report.py --proof "<Payment-Proof 值>"。 - 脚本只发起请求与打印结果,不含任何支付、算钱、加签逻辑。
- 退出码:
0= 已交付;2= 需支付(402);1= 其他状态;3= 服务端不可达。
执行流程(推荐做法)
- 先用
--status只读扫描,把结果整理成表格给用户看(路径 + 目录是否存在 + 最后打开时间)。 - 判断清理范围时不要替用户拍板:失效记录和有效记录要分开列,让用户在「只清失效 / 清 C 盘 / 全部清空」之间选。全部清空会连当前会话的工作空间一起删,需明确告知。
- 用户确认后再执行带
--clean的命令,脚本会先自动备份。 - 收尾必须提醒用户两件事:重启 WorkBuddy 才生效;重启后当前目录会被写回列表。
- 如果用户还嫌侧边栏有残留,再说明那属于会话历史,需用
--sessions-clean(软删除,可恢复)。
安全性与回滚
- 所有写操作前自动备份到
~/.workbuddy/ws-cleanup-backups/<时间戳>-<tag>/,包含主库 +-wal+-shm+settings.json。 --restore在覆盖前会再存一份当前状态(tag 为prerestore),保证回滚本身也可逆。--sessions-clean只写deleted_at(软删除),不删~/.workbuddy/projects/<cwd-slug>/*.jsonl流水文件,数据可恢复。- 会话「活跃」有两个判据:本地行
deleted_at IS NULL,云端行deleted_at = -1
(CLOUD_ACTIVE_DELETED_AT哨兵,含义是"活跃但非 NULL")。脚本已用
(deleted_at is null or deleted_at = -1)统一处理 —— 早期版本只判is null,会把
transport='cloud'的活跃任务整片漏掉(看不见也清不掉)。改动前请勿回退这一条。 - WorkBuddy 运行中可以改库(WAL 模式允许第二个连接写入),但界面要重启才刷新。回滚时建议先退出 WorkBuddy。
- 备份是按文件逐个复制(主库 +
-wal+-shm),不是 SQLite 在线备份 API,理论上存在
复制瞬间不一致的窗口。要求绝对严谨时,先退出 WorkBuddy 再操作,或操作前手动
pragma wal_checkpoint(TRUNCATE)。日常使用下风险很低。
常见问题
Q: 清理后列表还是有一两条?
A: 那是重启后 App 自动写回的当前目录,正常现象。
Q: 界面完全没变化?
A: 没重启。列表只在启动时读表。
Q: 想恢复被删的记录?
A: --restore --latest --yes,然后重启 WorkBuddy。
Q: 提示 unable to open database file?
A: --db 传的路径不对,或该路径下没有数据库文件。默认路径是 ~/.workbuddy/workbuddy.db。
Q: --sessions-clean 之后侧边栏还在?
A: 需要重启 WorkBuddy;被软删的会话要 deleted_at 过滤生效后才会从界面消失。
Q: 清理后侧边栏「空间 (N)」还是 0,是不是没生效?
A: 这是必然的。「空间 (N)」数的是落在工作空间里的任务数,与 workspaces 表无关。
跑 --spaces-diag 看逐条归因。别反复删 workspaces 行,那是无效操作。
Q: 「任务 (N)」和「空间 (N)」到底怎么分的?
A: 由应用的 classify$1() 归属矩阵决定。本地会话:is_playground = 1 →「任务」;
否则有 cwd 就进「空间」;归档(lifecycle === 'archived')的会话两边都不进,直接不显示。
目录名是时间戳与否,跟归属完全无关。
Q: 怎么让任务真的进「空间」?
A: 关键是让会话的 is_playground = 0:① 在已选工作空间的状态下新建任务
(选了工作空间就不能用快速问答,正是这个互斥保证了 is_playground = 0);
② 或者对已有的 playground 任务,点会话顶栏那个按钮(playground 任务显示「保存到工作空间」)。
之后侧边栏就会出现「空间」分组,这时也能用应用自带的「移除工作空间」了。
Q: 「深度体检报告」要收费吗?
A: 要。它是支付宝 AI 按量付费资源,按次收费(默认 0.01 CNY/次,以服务端 402 账单为准)。
不付费时本地清理功能照常可用,两者互不影响。
Q: 请求付费资源时返回 402 怎么办?
A: 这是正常流程,不是错误。把 402 的整份原始报文转交 alipay-pay-for-402-service 技能完成支付,
拿到 Payment-Proof 后带着该 Header 重新请求即可。不要自己解析账单、不要自己算钱。
Q: 已经付过钱但重新请求又 402?
A: 多半是重复使用了同一份 Payment-Proof(凭证一次性、服务端有防重放)。
重新发起一次请求会拿到新的 402 账单,再用新的凭证支付。若余额已扣,凭 trade_no 联系商户核对。
Q: 服务端地址怎么改?
A: 设环境变量 WB_CLEANUP_A2M_URL,或把地址写进技能目录的 server/.a2m_url 文件。
都未设置时默认 http://127.0.0.1:8787(本地联调)。
环境
- Python 3.10+。Windows 上若
python不在 PATH,用 WorkBuddy 自带解释器:
C:\Users\<用户>\.workbuddy\binaries\python\versions\<版本>\python.exe - 本地层:仅操作
~/.workbuddy/下的本地文件,不联网,不需要任何账号。 - 付费资源层:需要网络可达计费服务端,以及
alipay-pay-for-402-service(收银台技能)。
服务端侧需持有支付宝商户资质与密钥(A2M_SELLER_ID/A2M_APP_ID/A2M_SERVICE_ID/
A2M_PRIVATE_KEY/A2M_ALIPAY_PUBLIC_KEY),密钥只存在服务端,客户端不含任何密钥。
服务端部署(商户侧,可选)
只有在你要自己收款时才需要部署。源码在 server/,零第三方 Web 框架依赖。
# 联调(假支付,不产生真实资金流;禁止用于生产)
python server/a2m_server.py --port 8787 --mock
# 生产(需环境变量注入真实密钥)
set A2M_PRIVATE_KEY=... # Windows
python server/a2m_server.py --port 8787 --price 0.01
关键文件:
| 文件 | 职责 |
|---|---|
server/a2m.py |
A2M 协议核心:账单构造、RSA2 加签/验签、Payment-Proof 解析/编码、四项校验、沙箱密钥生成 |
server/store.py |
订单/计量/履约三表(SQLite),含原子防重放 |
server/alipay_client.py |
调用 alipay.aipay.agent.payment.verify / fulfillment.confirm(含 Mock 实现) |
server/a2m_server.py |
HTTP 端点:GET /resource(402/200)、/health、/bills、POST /mock-pay(仅 mock) |
server/test_a2m.py |
端到端自测(40 项,覆盖签名、凭证、四项校验、防重放、幂等) |
自测:
python server/test_a2m.py # 期望:结果:PASS 40 / FAIL 0
本地联调(不需要真实资质)
# 1) 启动沙箱服务端(自动生成临时 RSA 密钥用于签名验证)
python server/a2m_server.py --port 8787 --mock
# 2) 观察 402
python scripts/fetch_report.py
# 3) 模拟完成支付(仅 mock 模式提供),拿到 Payment-Proof
curl -X POST http://127.0.0.1:8787/mock-pay \
-H "Content-Type: application/json" \
-d "{\"out_trade_no\":\"<上一步 Body 里的 out_trade_no>\"}"
# 4) 带凭证再次请求 → 200 + 资源
python scripts/fetch_report.py --proof "<上一步返回的 payment_proof_header>"
预期行为:第 3 步得到 payment_proof_header;第 4 步返回 code: 10000 与体检报告;
同一凭证再请求一次会被拒绝(该支付已被履约过,拒绝重复消费)—— 这是防重放生效,符合协议要求。