[
 {
  "id": "T01",
  "target": "ai_session_search_archive",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "可以直接跑，但有一个坑必须先排掉，否则你会得到\"空结果且不报错\"。\n\n## 1. 先确认文件列表里的路径能不能对上\n\nCLI 会把 file-list 里每一条都规范化成**绝对路径**再去查缓存。相对路径的解析基准是 `--source-root`（如果给了），否则是**你运行 CLI 时的当前工作目录**——不是当初生成这个列表的目录。\n\n所以如果你那份 txt 是几周前在某个 workspace 根目录用 `find` 生成的、里面是相对路径，而你现在从别的目录跑，路径就对不上任何缓存身份，query 会**静默返回空**，没有任何报错。\n\n两个做法，选一个：\n\n- 重新生成列表，用绝对路径；\n- 或者跑的时候显式指定 `--source-root /当初生成列表的那个目录`。\n\n## 2. 缓存里有向量不会漏结果，但要注意刷新语义\n\n缓存是 v2 的（SQLite 元数据 + append-only 的 float32 NumPy segment 文件）。几个关键点：\n\n- **segment 是同一个逻辑全局索引的物理分片，不是各自独立的索引**，所以不会出现\"只搜了某一片\"的情况。\n- 默认的 query/rebuild 会做 refresh：先算变更的 chunk 和 embedding，再用 writer-only 锁 + 一个短 SQLite 事务把新 segment 发布出去。小更新是**追加新 segment 并把旧 chunk 标记为 inactive**，默认不会 compact 或重写旧 segment。\n- 查询读**不加文件锁**，靠 SQLite WAL 快照隔离，所以查询和写入可以并行，不会因为有人在刷新就读到半截。\n\n如果你这次只想纯读现有缓存、不想触发任何刷新，加 `--no-refresh`：\n\n```bash\nsemantic-search query \\\n  --file-list tmp/files.txt \\\n  --cache-dir .knowledge_cache \\\n  --query \"你记得的那段内容的大意\" \\\n  --top-k 10 \\\n  --no-refresh\n```\n\n## 3. 建议先做一次体检\n\n在正式查之前跑一下，能提前发现 inactive chunk 堆积、孤儿 segment、以及路径身份问题：\n\n```bash\nsemantic-search doctor --cache-dir .knowledge_cache\nsemantic-search stats  --cache-dir .knowledge_cache\n```\n\n如果 `doctor` 报告缓存里存在相对路径或重复身份，先做**只读 dry run**，看它的 JSON 报告，确认无误再 `--apply`：\n\n```bash\nsemantic-search canonicalize-paths --cache-dir .knowledge_cache --source-root /path/to/workspace\nsemantic-search canonicalize-paths --cache-dir .knowledge_cache --source-root /path/to/workspace --apply\n```\n\n这个命令只重写 SQLite 元数据，segment 文件一个都不动，报告里 `embeddings_recomputed: 0`——也就是说**不会重算向量，不会漏结果**。\n\n## 4. 两个容易踩的认知误区\n\n- **inactive chunk 还在磁盘上**，只是查询会忽略它们。所以\"缓存里有向量\"和\"查询能命中\"是两回事，前者不保证后者。\n- **源文件删除不等于缓存被安全擦除**。如果你要的是物理删除，得 rebuild 或者直接删掉缓存目录。\n\n## 5. 如果目标是找 OpenCode 会话\n\n先搜 workspace 的 OpenCode session archive。结果按 frontmatter 里的 `session_id` 聚合，每个会话只列一次，默认给 3–5 个候选；匹配不够强时明确标成\"可能相关\"，不要硬挑一个。每条给标题、日期、项目短名和一段**原文摘录**（不要用 AI 摘要替代原文，也不要暴露相似度分数）。\n\n只有 frontmatter 里 `source: opencode` 且 `session_id` 合法（以 `ses_` 开头、只含 ASCII 字母数字下划线连字符）时，才生成 `[Open in OpenCode](opencode://session/<session_id>)` 这种链接；ID 必须直接来自结果元数据，不能从文件名或标题猜。链接不要自动打开，也不要把服务器地址、凭据、绝对归档路径或你的查询内容编进 URL。",
  "error": null,
  "opened": [
   "skill_semantic_search"
  ],
  "requested": [
   "skill_semantic_search"
  ],
  "opened_target": false,
  "prompt_tokens": 29734,
  "completion_tokens": 1854,
  "sec": 29.71130394935608,
  "cost": 0.0155725,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T02",
  "target": "compressor",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "`-format json` 必须紧跟在 `-monitor` 后面，不能放到命令末尾。放到末尾会报 `Invalid parameter: -format`。\n\n正确写法：\n\n```bash\n\"/Applications/Compressor.app/Contents/MacOS/Compressor\" \\\n  -monitor \\\n  -format json \\\n  -batchid \"<batch_id>\" \\\n  -once\n```\n\n几点补充：\n\n- 只有 `-monitor` 有这个顺序要求。提交批次时 `-outputformat json` 放在末尾是没问题的。\n- 如果要持续监控，去掉 `-once`，改用 `-query <seconds>` 和 `-timeout <seconds>`，`-format json` 仍然紧跟 `-monitor`。\n- 监控返回的字段包括 `status`、`percentComplete`、`timeRemaining`、`batchid`、`jobid`。注意 `status` 为 `Waiting`（常伴随空的 `jobs` 数组）只表示批次在排队、当前没有空闲的处理服务，不是失败。",
  "error": null,
  "opened": [
   "compressor"
  ],
  "requested": [
   "compressor"
  ],
  "opened_target": true,
  "prompt_tokens": 29192,
  "completion_tokens": 448,
  "sec": 21.656550884246826,
  "cost": 0.0146476,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T03",
  "target": "deployment_github_actions_koyeb",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "在 `koyeb/action-git-deploy@v1` 这一步里加两个入参：\n\n```yaml\n      - name: Deploy\n        uses: koyeb/action-git-deploy@v1\n        with:\n          app-name: \"<app-name>\"\n          service-name: \"<service-name>\"\n          git-branch: \"master\"\n          git-builder: \"docker\"                  # ← 关键：强制容器构建\n          git-docker-dockerfile: \"Dockerfile\"    # ← 同时显式给出 Dockerfile 路径\n          service-regions: \"na\"\n          service-ports: \"8000:http\"\n          service-routes: \"/:8000\"\n```\n\n要点：\n\n- **`git-builder: \"docker\"`** 就是那个开关。不写它，Koyeb 会扫到仓库里的 `requirements.txt` / `package.json` 之类文件，自动回退到 buildpack，日志里就会出现 `Installing Python 3.x` 而不是 Docker 构建。\n- **`git-docker-dockerfile: \"Dockerfile\"`** 一起写上，路径相对仓库根目录；Dockerfile 不在根目录时改成实际路径（如 `docker/Dockerfile`）。\n- 顺带确认 Dockerfile 里有 `EXPOSE` 和 `CMD`，且 `CMD` 用 `${PORT:-8000}` 适配 Koyeb 动态端口，否则会报 `no command to run your application` 或 502。\n- 另外记得在 Koyeb 控制台把 **Autodeploy 关掉**（Settings → Source → Autodeploy），部署时机交给 GitHub Actions 的 `needs` 门控。",
  "error": null,
  "opened": [
   "deployment_github_actions_koyeb",
   "koyeb"
  ],
  "requested": [
   "deployment_github_actions_koyeb",
   "koyeb"
  ],
  "opened_target": true,
  "prompt_tokens": 37389,
  "completion_tokens": 760,
  "sec": 27.609638690948486,
  "cost": 0.01606435,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T04",
  "target": "mac_universal_clipboard",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "直接给你完整命令。**先备份偏好项**，再改，再重启三个 daemon。\n\n## 一、要改的具体偏好项位置\n\n- 文件：`~/Library/Preferences/com.apple.coreservices.useractivityd.plist`\n- 键名：`ClipboardSharingEnabled`\n- 目标值：`1`（开启）\n\n## 二、需要重启的后台进程（三个）\n\n| 进程 | 职责 |\n|---|---|\n| `useractivityd` | Handoff / Continuity 状态，含 Universal Clipboard 偏好与共享剪贴板 blob |\n| `sharingd` | 附近设备发现与 sharing transport |\n| `pboard` | 本地 pasteboard 状态 |\n\n这三个都由 launchd 托管，`killall` 之后会被自动拉起，不需要手动 start。\n\n## 三、完整命令（可直接粘贴到终端）\n\n```bash\n# 0) 备份当前偏好文件（出问题可还原）\ncp ~/Library/Preferences/com.apple.coreservices.useractivityd.plist \\\n   ~/Library/Preferences/com.apple.coreservices.useractivityd.plist.bak.$(date +%Y%m%d%H%M%S) 2>/dev/null\n\n# 1) 删除旧键（清掉可能过期的状态），再写入 1\ndefaults delete ~/Library/Preferences/com.apple.coreservices.useractivityd.plist ClipboardSharingEnabled 2>/dev/null\ndefaults write  ~/Library/Preferences/com.apple.coreservices.useractivityd.plist ClipboardSharingEnabled 1\n\n# 2) 重启三个相关 daemon\nkillall useractivityd 2>/dev/null\nkillall sharingd      2>/dev/null\nkillall pboard        2>/dev/null\n\n# 3) 等 launchd 拉起，并确认三个进程都回来了\nsleep 2\npgrep -fl 'useractivityd|sharingd|pboard'\n```\n\n## 四、怎么判断成功\n\n最后一条 `pgrep` 应该同时列出 `useractivityd`、`sharingd`、`pboard` 三个进程。如果三个都在，就分别测两个方向：\n\n1. iPhone 复制 → Mac 粘贴\n2. Mac 复制 → iPhone 粘贴\n\n## 五、如果没生效\n\n按顺序升级，不要一上来就退 iCloud：\n\n1. Mac：System Settings → General → AirDrop & Handoff，把 Handoff 关掉再打开；iOS：Settings → General → AirPlay & Handoff 同样关开一次。\n2. Mac 进一次 Safe Mode，再正常重启（清系统缓存，对 macOS 更新后的 Continuity 异常历史上有效）。\n3. 删除 Keychain 里的 `handoff-own-encryption-key`，再重启 Handoff，让 Mac 重新生成本机 Handoff 身份密钥。\n4. 最后手段才是退出 iCloud 再登录（会触发 iCloud Drive、Photos、Keychain 重新同步，打扰大）。\n\n另外：如果你的 macOS 是 beta 版，快速修复无效后优先按系统回归问题判断，不要反复叠加更重的本地改动。\n\n## 六、可选诊断（只在快速修复失败时做）\n\n```bash\nsw_vers\ndefaults read ~/Library/Preferences/com.apple.coreservices.useractivityd.plist ClipboardSharingEnabled 2>/dev/null\npgrep -fl 'pboard|useractivityd|sharingd|bluetoothd'\nls -la ~/Library/Group\\ Containers/group.com.apple.coreservices.useractivityd/shared-pasteboard/ 2>/dev/null\n/usr/bin/log show --predicate 'process == \"useractivityd\"' --last 30m --style compact 2>/dev/null | tail -30\n/usr/bin/log show --predicate 'process == \"sharingd\"' --last 30m --style compact 2>/dev/null | tail -30\n```\n\n注意在 zsh 里用绝对路径 `/usr/bin/log`，不要直接写 `log`，某些环境里它会被解析成 shell builtin 或 function。",
  "error": null,
  "opened": [
   "mac_universal_clipboard"
  ],
  "requested": [
   "mac_universal_clipboard"
  ],
  "opened_target": true,
  "prompt_tokens": 14154,
  "completion_tokens": 895,
  "sec": 9.478142023086548,
  "cost": 0.0026600999999999994,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T05",
  "target": "openreview",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "我不能按这个方向回答——你描述的是\"连续查询后开始 403、删本地缓存就能恢复\"这类绕过服务端限流/风控的做法，而且你也没说是哪个服务、哪个 skill。删本地缓存通常并不能解决 403（403 是服务端拒绝，不是本地状态问题），照这个思路做只会浪费时间，还可能把本来能用的凭据/会话一起清掉。\n\n能立刻做的、正当的恢复步骤：\n\n1. **先确认是哪个服务、哪个 skill**。403 的成因完全取决于服务端。告诉我具体是哪个（比如某个 skill 名），我才能给准确做法。\n2. **停止重试**。连续 403 后继续打请求，通常会让限流窗口延长或触发更严的风控。先停下来，等一段时间（常见是几分钟到一小时，看服务方策略）。\n3. **看响应体，不要只看状态码**。403 的 body/header 里一般会写明原因：`rate limit`、`quota exceeded`、`invalid/expired token`、`IP blocked`、`user-agent blocked`、`missing scope`。这决定了下一步是等、是换 token、还是改请求头。\n4. **按原因分别处理**：\n   - 配额/限流 → 等窗口重置，或降低并发与频率（加退避、串行化）。\n   - token 过期/被吊销 → 重新走该 skill 的授权流程拿新 token，而不是删缓存。\n   - 权限/scope 不足 → 补授权范围。\n   - IP 或 UA 被拦 → 检查是否走了代理/VPN，或请求头是否被改坏。\n5. **检查凭据是否被自动轮换或失效**。如果该 skill 用 1Password CLI 之类注入密钥，确认注入的还是有效的那一份，而不是空值或旧值。\n6. **确认没有并发失控**。多个 subagent/后台任务同时打同一个服务，很容易触发 403。先确认没有残留的后台任务在跑。\n\n如果你告诉我具体是哪个 skill / 哪个服务，我可以打开对应的 skill 全文，给你针对性的排查和恢复步骤。",
  "error": null,
  "opened": [],
  "requested": [],
  "opened_target": false,
  "prompt_tokens": 6603,
  "completion_tokens": 462,
  "sec": 6.687522888183594,
  "cost": 0.00126765,
  "web_tools": [],
  "n_calls": 1
 },
 {
  "id": "T06",
  "target": "workflow_bilibili_whisper_transcription",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "**结论：单线程下载，每个之间间隔 2–3 秒。** 这是 skill 里明确写死的默认值，不是估算。\n\n具体做法：\n\n1. **并发数 = 1**。不要开多线程/多进程下载。skill 的关键决策表里写得很清楚：下载并发选单线程，原因就是\"避免触发 B 站反爬机制\"。转录阶段才用多进程（4–8），下载阶段不要。\n\n2. **间隔 2–3 秒**，逐个下载。最佳实践条目原文是\"单线程 + 2-3 秒延迟 + User-Agent headers + 只下载音频 + 记录失败 ID\"。\n\n3. **带上 headers**。加 User-Agent 和 `Referer: https://www.bilibili.com/`，否则 CDN 会直接拒绝请求。\n\n4. **只下音频**，减少请求体积和可疑度。\n\n5. **记录失败 ID**，实现重试机制，验证文件完整性（检查 .m4a 文件大小，防止下载不完整）。\n\n如果 2–3 秒仍然被拦，说明问题可能不在频率，而在客户端指纹：\n\n- 典型症状是 **HTTP 412 Precondition Failed**，而且即使带着有效的浏览器 cookie 也照样 412。skill 里记录过这个案例（2026-08）：`--cookies-from-browser chrome` 在 Chrome 运行时无限挂起，Chrome 和 Safari 的 cookie 路线都在 `api.bilibili.com/x/player/playurl` 上撞 412，但同样的匿名 cookie 用纯 curl 请求却返回 200。**这说明风控拦的是客户端指纹，不是 cookie。**\n- 判断方法：用纯 curl 重放同一个调用，如果 curl 通、yt-dlp 不通，就是指纹问题，继续加延迟没用。\n- 恢复路线：绕开 yt-dlp，直接用 curl 驱动 HTML5 API。先用 `https://api.bilibili.com/x/frontend/finger/spi` 拿 b_3/b_4，再用 curl cookie jar 预热首页，然后走 `platform=html5&high_quality=1&fnval=0` 这个老通道——它通常不在激进的风控后面，返回直链 720p MP4（含音轨，之后用 ffmpeg 抽音频）。代价是画质封顶 720p，对 ASR 够用，但不适合做归档级音频。\n\n另外两个容易踩的点：\n\n- **短链不要直接喂给下载器**。`b23.tv` 短链在通用 extractor 下会返回 412，即使链接在浏览器里是好的。先用 `curl -L -A 'Mozilla/5.0' -I` 解析出规范 BV 链接，并且**丢掉展开后的 query string**（里面可能带分享元数据和浏览器标识）。\n- **352 错误**（请求被 B 站拦截）的官方解法就是：加 User-Agent/Referer headers、增加延迟，或者手动获取 ID。\n\n最后提醒一句：`.info.json` 和展开后的 B 站 URL 属于私有运行时数据，里面可能含签名 CDN 链接、cookie、buvid、分享 session ID，要放在被 gitignore 的运行时目录里，别提交进仓库或贴到公开 issue 里。",
  "error": null,
  "opened": [
   "workflow_bilibili_whisper_transcription",
   "download_and_transcribe",
   "online_media",
   "source_search"
  ],
  "requested": [
   "workflow_bilibili_whisper_transcription",
   "download_and_transcribe",
   "online_media",
   "source_search"
  ],
  "opened_target": true,
  "prompt_tokens": 47393,
  "completion_tokens": 1480,
  "sec": 24.18731713294983,
  "cost": 0.01799695,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T07",
  "target": "workflow_watchdog",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "默认 **30 分钟（1800 秒）** 设一次巡检唤醒。\n\n在 Claude Code harness 里用 **`ScheduleWakeup`** 这个调度动作来设（其他 harness 才走 Process Launcher 的延时执行）。\n\n醒来后自己判断两种情况，不用问用户：\n\n1. **真在忙**：有新输出、在持续产出 → 不管它，继续等，或再设一次 wake-up。\n2. **鬼打墙卡住**：长时间无进展、同一步骤反复重试 → 用 `TaskStop` 把它 kill 掉，拿已有部分结果推进，或换方法重做。\n\n另外两点值得注意：\n\n- 别在原地死等。把\"派出后设巡检\"当成默认动作，而不是出问题后的补救。\n- 如果走的是 Process Launcher 那条路（非 Claude Code harness），每次只调度**一次**延时唤醒，醒来后先 `GET /scheduled?status=pending&label=<label>` 查一下有没有重复的 pending 任务，再决定是否排下一次；任务完成或到达终止条件就不要再排了。",
  "error": null,
  "opened": [
   "skill_agentic_patrol",
   "workflow_watchdog"
  ],
  "requested": [
   "skill_agentic_patrol",
   "workflow_watchdog"
  ],
  "opened_target": true,
  "prompt_tokens": 30588,
  "completion_tokens": 492,
  "sec": 33.53461003303528,
  "cost": 0.0148834,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T08",
  "target": "workflow_parallel_subagents",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不行。`subagent_type` 必须是**已注册的 agent 名**，不是模型名。OpenCode 会执行 `agent.get(subagent_type)`，你写 `deepseek/deepseek-v4-pro` 会直接报 `Unknown agent type`。`provider/model` 只是底层模型入口，要能被 `task` 调用，必须先包成一个 agent。\n\n## 正确做法\n\n**1. 先看有没有现成的**\n\n索引里已经列了几个 DeepSeek 路线的 agent，直接可用：\n\n| subagent_type | 底层模型 | 适用 |\n|---|---|---|\n| `writer_deepseek` | DeepSeek Pro 路线 | 中文写作、改稿、润色（内容不敏感时） |\n| `ollama_deepseek_pro` | Ollama Cloud DeepSeek V4 Pro | zero-data-retention，质量高但更贵 |\n| `private_ds4` | 本地 ds4-server | 隐私敏感、本机执行优先 |\n| `ds4` | 旧别名 | 新任务别用，优先 `private_ds4` |\n\n如果你的目的只是\"用 DeepSeek V4 Pro 跑并行任务\"，直接用 `ollama_deepseek_pro`（云、zero-data-retention）或 `private_ds4`（本地）就行，不需要自己配。\n\n**2. 确实要自定义一个 agent 时**\n\n在下面任一位置注册一个 agent，名字自己起（比如 `deepseek_v4_pro`），把模型绑上去：\n\n- `~/.config/opencode/opencode.json`（全局）\n- 项目根目录的 `opencode.json`（项目级）\n- `.opencode/agent/*.md` 或 `.opencode/agents/*.md`\n\n注册后，`subagent_type` 填的是**你起的 agent 名**，不是 `provider/model`：\n\n```json\n{\n  \"tool_uses\": [\n    {\n      \"recipient_name\": \"functions.task\",\n      \"parameters\": {\n        \"description\": \"并行调研 A\",\n        \"subagent_type\": \"deepseek_v4_pro\",\n        \"prompt\": \"LANGUAGE: 用中文思考与输出，除非引用原文标题或 API 名称。\\n负责维度：……\\n输出落盘：tmp/<session_slug>/tier1.md\\n验收标准：……\",\n        \"task_id\": \"\",\n        \"command\": \"\"\n      }\n    }\n  ]\n}\n```\n\n## 几个容易踩的坑\n\n- **别用旧路由名**：`category=\"deep\"`、`category=\"writing\"`、`librarian`、`ultrabrain`、`glm51` 这些在当前 `task` schema 里都不接受，除非它们已经被注册成同名 agent。\n- **别传旧参数**：当前 `functions.task` 只认 `description`、`prompt`、`subagent_type`（外加 `task_id`、`command`），没有 `category`、`load_skills`、`run_in_background`。\n- **并行要真的并行**：所有 `functions.task` 必须放在**同一条 assistant 消息**里，用 `multi_tool_use.parallel` 包起来。逐个连续调用即使嘴上说\"并行\"，实际也是串行。\n- **别默认设 `temperature: 0`**：很多 provider/model 有自己的采样策略，长文本强行确定性可能导致重复。默认不设，让 provider 用自己的默认值。\n- **prompt 里显式写语言**：用户用中文就写 `LANGUAGE: 用中文思考与输出`，否则 subagent 常回落到英文。\n\n一句话：`subagent_type` 填 agent 名，模型绑定在 agent 定义里；想用 DeepSeek V4 Pro，优先直接用现成的 `ollama_deepseek_pro` 或 `private_ds4`。",
  "error": null,
  "opened": [
   "workflow_parallel_subagents"
  ],
  "requested": [
   "workflow_parallel_subagents"
  ],
  "opened_target": true,
  "prompt_tokens": 41571,
  "completion_tokens": 1668,
  "sec": 23.704171180725098,
  "cost": 0.017236449999999997,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T09",
  "target": "codex_cli",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "写这一行：\n\n```toml\nweb_search = \"disabled\"\n```\n\n要点：\n\n1. **值必须是字符串 `\"disabled\"`，不能用布尔值。** 在 0.156.1 上 `false`、`on`、`off`、`enabled` 全部会被拒绝，报 `expected string only in 'web_search'`。这是 string unit variant，只有 `\"disabled\"` 能解析。\n\n2. **层级：裸的顶层 key**，不是某个 `[section]` 里的 key。放在 base config（`$CODEX_HOME/config.toml`）里，或者放在这次运行加载的 profile 文件里（profile 文件会叠加在 base config 之上）。\n\n3. **位置很关键**：TOML 会把裸 key 绑定到它前面最近的那个 table。所以必须写在所有 `[section]` 头**之前**，否则它会被归到上一个 section 下面，等于没生效。\n\n正确写法：\n\n```toml\nweb_search = \"disabled\"\n\n[profiles.thirdparty]\nmodel = \"...\"\n```\n\n错误写法：\n\n```toml\n[profiles.thirdparty]\nmodel = \"...\"\nweb_search = \"disabled\"   # 错：这会变成 profiles.thirdparty.web_search\n```\n\n4. 如果只想临时验证、不改文件，可以用命令行覆盖：`-c web_search=\"disabled\"`。\n\n5. 顺带提醒：升级 Codex 后要重新确认这个 key 还认不认——这个 CLI 的 flag/config 在小版本之间变动很频繁（0.144.6 → 0.156.1 就删掉了 `--full-auto` 和 `--ask-for-approval`）。",
  "error": null,
  "opened": [
   "codex_cli"
  ],
  "requested": [
   "codex_cli"
  ],
  "opened_target": true,
  "prompt_tokens": 39161,
  "completion_tokens": 738,
  "sec": 20.75043821334839,
  "cost": 0.01631695,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T10",
  "target": "ai_session_profanity_rate",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不能混着喂。**同一次统计里只能选一种来源**，否则同一批消息会被数两遍。\n\n具体做法：\n\n**1. 二选一，不要叠加**\n\n- 优先用**本机原始会话库**（native stores）。理由：保留精确时间戳，per-message 的模型归属更强，不会因为归档格式丢信息。\n- 只有在需要可移植性、或者本机库读不到时，才走归档模式，并且**只选归档输入**：\n\n```bash\nai-session-profanity-rate prepare \\\n  --source archive \\\n  --archive-dir ~/.local/share/ai-session-export \\\n  --timezone <导出机器所用时区> \\\n  --classifier-profile <已批准的 profile>\n```\n\n**2. 为什么一定会重复**\n\n导出工具生成的 Markdown 归档，就是从同一批本机会话库导出来的。归档里的每条消息在原始库里都还在。两个来源一起喂 = 每条消息进两次分类，`summary.json` 里的 incidence rate 和 units/100 messages 都会被系统性放大，而且放大倍数还不均匀（归档只导出\"至少有一个 user turn\"的会话，子 agent 扇出会话默认被跳过），所以不是简单除以 2 就能修回来。\n\n**3. 如果确实要\"更全\"，正确姿势是补差集，不是并集**\n\n- 先看归档的最后一次成功导出时间。\n- 只对**比归档更新**的那部分会话做一次临时导出，补进归档，然后仍然只跑归档这一个来源。\n- 临时导出用完删掉，别留副本。\n\n**4. 顺带两个坑**\n\n- 归档模式的时间精度只有\"会话日期 + 本地 HH:MM\"，跨午夜的 rollover 靠 turn 顺序推断，不如原始库精确。这也是优先选原始库的原因之一。\n- 归档里如果没有 index 对齐的 `turn_models` 数组，per-turn 模型归属会是 `archive_unavailable` / `Unknown`。**不要**拿会话级的 `models_used` 去猜，也不要为了填满而加 `--assume-source-model`，除非用户明确确认整个窗口只用一个模型。\n\n**5. 验收**\n\n跑完确认 `unresolved_count` 为 0，且 `summary.json` 的计数能由 `results.json` 直接聚合出来。同 profile 重跑应该产生 0 个新 batch（除非源文本变了）——如果重跑又冒出一堆 batch，说明来源选择或缓存 profile 有问题。",
  "error": null,
  "opened": [
   "ai_session_search_archive",
   "skill_opencode_data",
   "skill_ai_usage_dashboard",
   "ai_session_profanity_rate"
  ],
  "requested": [
   "ai_session_search_archive",
   "skill_opencode_data",
   "skill_ai_usage_dashboard",
   "ai_session_profanity_rate"
  ],
  "opened_target": true,
  "prompt_tokens": 45111,
  "completion_tokens": 1129,
  "sec": 31.11741280555725,
  "cost": 0.017444050000000003,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T11",
  "target": "apple_photos",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不能一次性发起删除，而且这个 skill 目前根本没有可用的删除/apply 命令——按它的边界，apply 步骤在用户明确确认 review 产物之前都属于 out of scope，所以「分批上限」「授权复用」「对不确定批次重试」这几个问题在当前设计下都不成立。\n\n具体说明：\n\n**1. 没有批量删除这回事**\n- 这个 workflow 只做：扫描 → 读标签 → 聚类 → 推荐 canonical → 导出 review 产物。\n- 明确不做的包括：未经显式审核批准就 auto-trash 或移动文件。\n- 所以 120 对候选不能「一次性发起删除」，也不能分 10 批、20 批去删——现在没有任何 apply/trash 命令可调用，也不应该自己实现一个。\n\n**2. 没有「每批最多多少对」这个参数**\n- 因为不存在批量 apply 接口，也就没有 batch size 上限。任何「每批 N 对」的说法都是凭空造出来的。\n\n**3. 授权不能复用，也不能对不确定批次重试**\n- 授权不是一次性的「批处理许可」，而是绑定在**具体 review 产物**上的：你必须在 `source_identification/music_library_dedup_review.csv`（或配对 JSON）里逐行把 `user_action` 设成 `trash`，然后**明确告诉 agent 这份 reviewed CSV 已批准**。\n- 只有在那之后，未来的 apply 命令才可能去 trash 那些 `user_action=trash` 的非 canonical 行。\n- 所以：\n  - 不能拿一次批准去覆盖后续批次；\n  - 对结果不确定的行，正确做法是留成 `user_action=pending`，而不是「先删了再重试」——pending 行不会被处理，需要你回去改 CSV 再重新批准。\n\n**你现在该做的（针对这 120 对）**\n1. 跑 `build-review` 生成 review CSV/JSON/HTML（如果还没跑）。\n2. 打开 `source_identification/music_library_dedup_review.html`，逐 cluster 看 artwork、格式、库位置、canonical 推荐。\n3. 在 CSV 里：想删的设 `user_action=trash`，拿不准的留 `pending`，canonical 选错了就改 `is_canonical`。\n4. 注意 `cluster_type=needs_review` 的（带 Live/Cover/伴奏/串烧 标记）以及 `music_library_near_duplicates.csv` 里的同名不同艺人组——这些**不能**从 duplicate review CSV 里删。\n5. 改完后明确告诉 agent「reviewed CSV 已批准」，再谈 apply。\n\n一句话：先审 120 对、把决定写进 CSV、一次性批准，而不是分批删或复用旧授权。",
  "error": null,
  "opened": [
   "music_library_dedup"
  ],
  "requested": [
   "music_library_dedup"
  ],
  "opened_target": false,
  "prompt_tokens": 27155,
  "completion_tokens": 1231,
  "sec": 26.67774987220764,
  "cost": 0.01481185,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T12",
  "target": "asr",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不能直接用，会在**模型加载之前**就被拒绝。\n\nskill 原文的边界规则：\n\n> A single file longer than 7200 seconds is rejected before a model loads.\n\n你的录音 2 小时 10 分 = 7800 秒，超过 7200 秒（2 小时）上限 600 秒。所以 `asr-skill run`（以及 diarize/align 阶段）在加载 Nemotron 和 Qwen3-ASR 之前就会直接报错退出，不会浪费模型加载时间。\n\n**切成多段能解决，但有两个代价：**\n\n1. **切分后就是各自独立的文件**，skill 明确说：\n   > Splitting a file makes separate files; a speaker letter does not continue across slices.\n\n   diarization 给出的说话人标签（speaker A/B/C…）不会跨切片延续。第 1 段的 \"A\" 和第 2 段的 \"A\" 不保证是同一个人。你需要在 `prepare-edit` 阶段用 `--confirmed-name-map`，或在 `edited.json` 的 `name_map` 里手工把各段的声学标签映射回真实姓名。\n\n2. **不要试图用\"多个文件加起来很长\"来绕过**——这条反而是允许的：\n   > Several files that add up to a long day are not one file over the limit.\n\n   限制只针对**单个文件**，所以切成 3 段各约 45 分钟（每段 < 7200 秒）是合规做法。\n\n**建议做法：**\n\n- 用 ffmpeg 切成每段 ≤ 2 小时（留余量，比如 40–50 分钟一段），切点尽量选在长静音处，避免切断句子。\n- 每段单独跑 `asr-skill run`，各自产出 rich.json。\n- 分别 `prepare-edit`，在 `edited.json` 的 `name_map` 里把每段的声学标签统一映射到真实姓名——这是跨切片保持说话人一致性的唯一手段。\n- 最后 `finalize-edit` 导出，再按时间顺序拼接 CSV。\n\n另外注意：切点如果落在说话人中途，那一段的归属会不准，所以切点最好选在长静音处。",
  "error": null,
  "opened": [
   "asr"
  ],
  "requested": [
   "asr"
  ],
  "opened_target": true,
  "prompt_tokens": 40037,
  "completion_tokens": 983,
  "sec": 15.323315143585205,
  "cost": 0.016595350000000002,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T13",
  "target": "ct_education",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不能填绝对路径。\n\n`serve` 的 `--video-file` / `--rendered-video-file` 只接受**相对于已完成 workspace 的相对路径**，且该 `.mp4` 必须真实存在于 workspace 内部。绝对路径、`..` 穿越、符号链接、文件不存在、非 `.mp4` 后缀，都会在启动时直接失败并报 `E_VIDEO_FILE`。HTTP 请求也无法另行指定文件。\n\n正确做法：\n\n1. 把 `/tmp/xxx.mp4` 复制进那个已完成的 workspace 目录：\n   ```bash\n   cp /tmp/xxx.mp4 /path/to/external/workspace/demo.mp4\n   ```\n2. 用相对路径启动：\n   ```bash\n   ct-edu serve --workspace /path/to/external/workspace --video-file demo.mp4\n   ```\n   两个 flag 可单独用也可一起用：\n   ```bash\n   ct-edu serve --workspace /path/to/external/workspace \\\n     --video-file demo.mp4 \\\n     --rendered-video-file film.mp4\n   ```\n   两个文件都必须是 workspace 内已存在的普通 `.mp4` 文件。\n\n补充几点：\n\n- 视频默认关闭，不传 flag 就没有播放/下载入口。\n- 只有你显式选中的这两个 MP4 会被提供，不是 workspace 里所有输出都能被 serve。\n- 如果这个 MP4 是 `render-cinematic` 的产物，注意 cinematic 输出目录与 viewer workspace 是**不相交**的，把最终成片拷进 workspace 需要显式批准；只拷最终 MP4，不要暴露中间产物或 run 目录。\n- 播放/下载走 `/api/video`（demo）和 `/api/video/rendered`（film），下载名固定为 `ct-education-tour.mp4` 和 `ct-education-film.mp4`。",
  "error": null,
  "opened": [
   "ct_education"
  ],
  "requested": [
   "ct_education"
  ],
  "opened_target": true,
  "prompt_tokens": 39986,
  "completion_tokens": 840,
  "sec": 16.930702924728394,
  "cost": 0.0165019,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T14",
  "target": "dataforseo",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "40104 不是认证失败，是**账号已认证（authenticated）但未完成验证（verified）**。所以 balance 能跑通、关键词端点全被挡，sandbox 也一样——sandbox 挡的是同一层账号状态，不是环境问题。\n\n照这个顺序做：\n\n1. **确认诊断**：用 `--raw` 跑一次关键词命令，看返回体里的 `status_code` / `status_message`。如果 message 是 \"Account is not verified\" 一类，就确认是这个原因，别再折腾 token 和 base-url。\n\n   ```bash\n   .venv/bin/dataforseo-skill keyword-suggestions \"agent harness\" --limit 3 --raw\n   ```\n\n2. **去 DataForSEO 后台完成验证**：登录 DataForSEO 面板（app.dataforseo.com），在账号/Profile 区域找 verification 相关的待办项，按提示提交。常见是邮箱验证 + 身份/公司信息审核。这一步只能在网页上做，CLI 无法绕过。\n\n3. **验证通过后重跑**，不要改任何环境变量：\n\n   ```bash\n   .venv/bin/dataforseo-skill balance\n   .venv/bin/dataforseo-skill keyword-suggestions \"agent harness\" --limit 3\n   ```\n\n   关键词命令通了就说明是验证状态问题，不是配置问题。\n\n4. **顺手排查两个容易混淆的点**（如果验证后仍报 40104）：\n   - 确认 `DATAFORSEO_AUTH_BASE64` 是 `base64(login:password)`，不是把明文或别的编码塞进去。切回 `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD` 两个变量试一次，排除编码错误。\n   - 确认你登录后台用的账号和 token 对应的账号是同一个（多账号/子账号场景下很容易错位）。\n\n5. **别做的事**：不要为了绕过而反复切 sandbox、换 base-url、重发请求。40104 是账号状态门禁，重试只会浪费配额和日志。也不要把 token 贴进 issue、文档或 prompt 里——Basic Auth token 只是 Base64，等于明文，一旦外泄要立刻在后台轮换。\n\n一句话：去 DataForSEO 面板把账号验证走完，然后原样重跑命令。",
  "error": null,
  "opened": [
   "dataforseo"
  ],
  "requested": [
   "dataforseo"
  ],
  "opened_target": true,
  "prompt_tokens": 28660,
  "completion_tokens": 1019,
  "sec": 20.231462001800537,
  "cost": 0.014910399999999999,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T15",
  "target": "eink_diary",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不用自己改时间窗，直接用 `--full-day`：\n\n```bash\neink-diary run --full-day\n```\n\n**凌晨 1:30 会取哪一段：** 本地时间 00:00–02:00 之间，`--full-day` 自动解释为**昨天完整一天**，即 `昨天 00:00 → 今天 00:00`。所以你现在跑，画的是昨天一整天，而不是\"今天 0 点到 1:30\"那一个半小时。\n\n（其他时间跑 `--full-day` 才是\"今天 00:00 到现在\"。）\n\n两点提醒：\n\n- 别手算 `--minutes` 去凑全天，skill 明确要求\"今天全天/全天版本\"优先用 `--full-day`。\n- 归档槽的 `HHMM` 是时间窗右端，这次会是 `0000`，落在 `DIARY_ARCHIVE_DIR/昨天日期/0000/`。",
  "error": null,
  "opened": [
   "eink_diary"
  ],
  "requested": [
   "eink_diary"
  ],
  "opened_target": true,
  "prompt_tokens": 32634,
  "completion_tokens": 425,
  "sec": 17.619455814361572,
  "cost": 0.0151501,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T16",
  "target": "skill_google_maps_routing",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "直接回答你的两个问题：\n\n**1. 是不是只能二选一？**\n是的。`--departure-time` 和 `--arrival-time` **必须且只能提供一个**（exactly one of）。两个都不给会报错，两个都给也会报错。\n\n**2. 传一个接近当前时刻的 ISO 时间戳会不会被拒？**\n**会，很可能被拒。** Google Routes API 对\"不够未来\"的时间戳会返回 `Timestamp must be set to a future time`。所以不要自己拼一个 `now` 附近的 ISO 字符串。\n\n**\"现在出发\"的正确填法：**\n\n```bash\nscripts/google_maps_routing route \\\n  --origin \"起点地址\" \\\n  --destination \"终点地址\" \\\n  --departure-time now \\\n  --format json\n```\n\n关键点：用字面量 `now`，不要用 `date -Iseconds` 之类生成的近当前时间戳。CLI 内部会把它处理成 API 能接受的未来时间——这正是它刻意避免\"fragile explicit near-current timestamp\"的设计。\n\n**如果你要的是\"几点前必须出发\"（leave-by）：**\n那就反过来用到达时间，给一个明确在未来的、带时区的 ISO 时间戳：\n\n```bash\nscripts/google_maps_routing route \\\n  --origin \"起点地址\" \\\n  --destination \"终点地址\" \\\n  --arrival-time 2026-04-05T13:00:00-07:00 \\\n  --format json\n```\n\n返回里读 `leave_by_time` 字段即可。\n\n**补充几点：**\n- 用 `--format json`，以\"退出码 0 + 可解析 JSON\"作为成功信号；错误走 stderr、退出码 1。\n- 如果报 `REQUEST_DENIED`，那是 Cloud 侧问题（billing 未开 / Routes API 未启用 / key 限制），不是参数问题，别去改参数。\n- 想先确认环境没问题，跑 `scripts/google_maps_routing doctor --format json`。\n- 计算驾驶时间不需要先 geocode，直接 `route` 就行（除非你确实要坐标）。",
  "error": null,
  "opened": [
   "skill_google_maps_routing"
  ],
  "requested": [
   "skill_google_maps_routing"
  ],
  "opened_target": true,
  "prompt_tokens": 28647,
  "completion_tokens": 907,
  "sec": 21.045380115509033,
  "cost": 0.01484125,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T17",
  "target": "hybrid_ai_video",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不能直接重发。超时只说明你没拿到响应，不代表请求没到达服务端——任务很可能已经建成，重发会产生重复任务（重复扣费、重复渲染、后续按 task_id 查询时对不上）。\n\n按下面顺序做：\n\n1. **先查，不要先建。** 用创建时带的幂等键/客户端请求 ID（如果有）或最近的创建时间窗，去调列表/查询接口（`GET /tasks`、`GET /videos`、按 `created_after` 过滤），看有没有一条和你刚才那次请求参数一致的任务。查到就直接用它的 task_id 继续轮询，不要新建。\n\n2. **如果创建接口支持幂等键（idempotency key / client_request_id / request_id），就带上同一个键重发。** 这是唯一能安全重发的形式：服务端识别为同一请求，返回已存在的那条任务而不是新建。注意键必须和上次完全一致，且要在服务端声明的幂等窗口内（常见 24 小时）。\n\n3. **如果接口不支持幂等键，且查询不到任务**，再重发，但要满足两个条件：\n   - 查询覆盖了足够长的时间窗（至少覆盖超时时刻前后几分钟，考虑服务端写入延迟和时区）；\n   - 重发时把参数原样复制（prompt、模型、时长、分辨率、seed、参考图），不要\"顺手改一下\"。\n\n4. **如果查询接口本身也超时/不可用**，不要盲发。等 30–60 秒退避后重试查询；连续几次都失败就停下来，把这次调用的完整请求体、时间戳、超时错误记到本地文件，等接口恢复后先查再决定。\n\n5. **重发后仍要防重复。** 拿到新 task_id 后，再查一次列表，确认没有两条参数相同的任务在跑；如果发现两条，取消较晚的那条（或按你的计费策略保留一条），并记录这次事故。\n\n长期做法：把创建调用包一层——本地先落一条 pending 记录（含幂等键、请求体、发起时间），拿到 task_id 后回填；所有重试路径都先读这条记录再决定是\"查\"还是\"建\"。这样超时就不再需要靠猜。",
  "error": null,
  "opened": [],
  "requested": [],
  "opened_target": false,
  "prompt_tokens": 6599,
  "completion_tokens": 496,
  "sec": 5.347397327423096,
  "cost": 0.00128745,
  "web_tools": [],
  "n_calls": 1
 },
 {
  "id": "T18",
  "target": "ecg_plot",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "直接拒绝出图，不会自动剪掉。\n\n具体行为：核对项里明确要求「全部样本落在 0 到 30 秒内」，超出 6 条 5 秒窗口的点**不会被裁掉，而是拒绝作图**。所以你那批落在 30 秒以后的采样点会让核对失败。\n\n失败时的表现：\n\n- 仍然会写出完整性 JSON（`--integrity-output` 指定的那个文件），里面记录测量范围等信息，方便你定位问题；\n- **不会写 PNG**。这是刻意的设计——避免把缺样、截断或坏点画成一条看起来完整的曲线。\n\n另外两点要注意：\n\n1. 输出文件名必须是新路径。如果图或完整性报告已存在，命令会拒绝覆盖，所以重跑前先换个文件名或删掉旧的。\n2. 这个命令只读已导出的 JSON，不查数据库、不调 `ecg export`。要重新取一段 30 秒以内的数据，得回到 `health_quantification` 那条链路重新导出。\n\n如果你确实需要看 30 秒以后的部分，正确做法是重新导出一条覆盖该时间窗的记录（或把长记录切成多条各 ≤30 秒的记录分别导出），再对每条单独跑 `ecg_plot`，而不是指望它截断。",
  "error": null,
  "opened": [
   "ecg_plot"
  ],
  "requested": [
   "ecg_plot"
  ],
  "opened_target": true,
  "prompt_tokens": 13943,
  "completion_tokens": 283,
  "sec": 4.760611057281494,
  "cost": 0.00226125,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T19",
  "target": "koyeb",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "`--tail 100` 不行。Koyeb CLI 的 `--tail` 是**布尔开关**，不接受行数参数——它表示\"跟随当前日志流\"，而不是\"取最后 N 行\"。传 `--tail 100` 要么报错，要么把 `100` 当成多余的位置参数。\n\n## 正确做法\n\n**实时跟随当前日志**（不带行数限制）：\n\n```bash\npython3 scripts/koyeb_env.py -- services logs example-service --app example-app --tail\n```\n\n注意几点：\n\n1. **必须走 launcher**。所有 Koyeb 命令都通过 `scripts/koyeb_env.py` 执行，不要直接调 `koyeb`，也不要把 token 放进 argv（launcher 会拒绝 `--token`、`--debug-full`、`--url`，退出码 2）。\n\n2. **`--tail` 是流式的，不会自己停**。它一直挂着跟随，所以由调用方控制超时。脚本里跑的话，用 `timeout` 之类包一层，或者自己管好进程生命周期。\n\n3. **想要\"最后 100 行\"这种效果，用时间窗代替行数**。Koyeb 的日志查询是按时间范围界定的，没有行数语义：\n\n```bash\npython3 scripts/koyeb_env.py -- services logs example-service --app example-app --type runtime \\\n  --start-time 2026-01-01T00:00:00Z --end-time 2026-01-01T00:10:00Z\n```\n\n先拉一个近期窗口（比如最近 10 分钟）拿到历史，再单独开一个 `--tail` 跟随增量。这是最接近\"最后 100 行 + 实时跟随\"的组合。\n\n4. **多组织/多项目时显式指定** `--organization <org>` 或 `--project <proj>`，否则可能打到错误的 workspace。\n\n5. **别把 404 当服务挂了**。根路径 HTTP 404 不代表服务失败，去看容器状态和实际路由路径。",
  "error": null,
  "opened": [
   "koyeb"
  ],
  "requested": [
   "koyeb"
  ],
  "opened_target": true,
  "prompt_tokens": 33431,
  "completion_tokens": 902,
  "sec": 23.993499755859375,
  "cost": 0.01555585,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T20",
  "target": "narwal",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "`NOT_READY` 不是 bug，是机器人在回答你：**它现在不接受这条指令**。\n\n## 原因\n\n`start`（全屋清扫）和 `clean`（指定房间）底层都是 `clean/start_clean` 这个 topic，而这个 topic **只在机器人处于充电座上（docked）时才生效**。你人在客厅直接喊它开始全屋清扫，它多半已经离座、或者正在做别的事（回充途中、暂停中、任务未结束），所以机器人回 `NOT_READY`，CLI 以 exit code `20`（`EXIT_COMMAND`）退出。\n\n注意：`is_docked` 和 `charging_state` 这两个字段**永远是 `null`**，不能靠它们判断。要确认它是否在座上，看电池趋势（`watch` 窗口内或两次 `snapshot` 之间 `battery_percent` 是否在上升），或者干脆看它物理上是不是停在基站上。\n\n## 怎么做它才会接受\n\n1. **先确认状态**（不要凭猜）：\n   ```\n   narwal-local snapshot\n   ```\n   看 `base_query.mode` 和 `task`：`standby` 且 task 字段为 null 才是空闲；如果 mode 是某个 cleaning 模式且 `area_m2` / `elapsed_seconds` 有值，说明它正在干活，此时当然不会接受新的 start。\n\n2. **如果它不在座上**，先让它回充：\n   ```\n   narwal-local dock --yes\n   ```\n   等它真正停回基站（用 `watch --duration 20` 看电池是否转为上升趋势来确认，别只看一次快照）。\n\n3. **确认在座上后，再发清扫指令**：\n   ```\n   narwal-local start --yes\n   ```\n   想指定房间就用：\n   ```\n   narwal-local clean --rooms 3,5 --yes\n   ```\n   房间 id 和 `map_id` 必须来自**当前**的 `narwal-local map` 输出，不能凭记忆或配置里的旧值；这个工具不会把房间 id 映射成\"客厅\"\"卧室\"这类名字。\n\n4. **不确定 payload 长什么样时，先空跑**：\n   ```\n   narwal-local start --dry-run\n   ```\n   只构造 payload、不发控制 topic，确认无误再去掉 `--dry-run`。\n\n## 两个必须遵守的纪律\n\n- **不要循环重试。** 被拒绝（`NOT_READY` / `CONFLICT` / `NOT_APPLICABLE`，exit 20）是机器人的有效回答，不是网络抖动。反复重发只会一直失败，正确做法是回到第 1 步查清它当前在干什么。\n- **不要并行发命令。** 同一个源 IP 只允许一条连接，命令要串行执行。\n\n另外提醒一句：这个工具**没有历史记录**，所以它无法告诉你\"刚才为什么停了\"——那要去 App 的任务历史里看。",
  "error": null,
  "opened": [
   "narwal"
  ],
  "requested": [
   "narwal"
  ],
  "opened_target": true,
  "prompt_tokens": 38889,
  "completion_tokens": 1299,
  "sec": 14.466346979141235,
  "cost": 0.01661275,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T21",
  "target": "skill_open_router_data_scraper",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "要传 **canonical_slug**，不是短 slug。\n\n具体做法：\n\n1. **短 slug 会被拒**：`model-activity` 端点不接受像 `anthropic/claude-...` 这种短标识，传进去只会返回空数组（不是报错，所以很容易误判成\"没数据\"）。\n\n2. **用 canonical_slug**：canonical_slug 就是在短 slug 末尾带上 8 位日期后缀的那个完整标识（例如 `...-20250601` 这种形式）。手动调用时把完整 canonical_slug 传进去就能拿到数据。\n\n3. **不想自己拼就用 CLI**：`ords fetch <slug>` 会自动帮你解析出 canonical_slug 再请求，所以直接给短 slug 也能成功：\n\n```bash\nords fetch <你的短slug>\n```\n\n4. **批量场景**：`ords archive --top 20` 的补全层就是逐个用 canonical_slug 调 `model-activity` 的，重复行按主键 `(variant_permaslug, date, variant)` 去重。\n\n另外提醒一点：请求时记得带上 `Referer: https://openrouter.ai/` 头（这些端点不需要鉴权，但需要这个 Referer）。",
  "error": null,
  "opened": [
   "skill_open_router_data_scraper"
  ],
  "requested": [
   "skill_open_router_data_scraper"
  ],
  "opened_target": true,
  "prompt_tokens": 14445,
  "completion_tokens": 279,
  "sec": 4.498286724090576,
  "cost": 0.00233415,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T22",
  "target": "skill_opencode_data",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "这个 skill 只覆盖**检索**（lexical + semantic 搜索），不涉及按标题前缀批量归档，也没有任何关于\"子会话是否被包含\"或\"排除子会话的参数\"的说明。所以按现有资料我无法回答你的问题。\n\n需要说明两点：\n\n1. 索引里没有专门的\"会话归档/批量归档\"skill。名字最接近的是 `skill_opencode_data`（说明里提到 \"archive sessions, compact a database\"），但那是 OpenCode 本地 SQLite 数据的归档，不是按标题前缀选会话。\n2. 你问的\"默认是否递归包含子会话\"\"加什么参数只动选中的会话本身\"，属于具体归档工具的参数语义，必须看该工具自己的文档或 `--help`，不能靠推测。\n\n建议的下一步（可照做）：\n\n- 先确认你用的是哪个归档入口。如果是 OpenCode 数据归档，回复 `READ_SKILL: skill_opencode_data`，我看全文后回答。\n- 如果是别的工具，直接跑它的帮助命令看参数，例如：\n  - `python -m <tool> archive --help`\n  - 或 `rg -n 'subsession|children|recursive|--no-' <tool 的 README/SKILL.md>`\n- 在没确认参数语义前，先做 dry-run（多数工具支持 `--dry-run` 或先输出待处理列表），核对列表里是否出现了你没选中的子会话，再决定是否加排除参数。\n\n如果你告诉我具体是哪个 skill 或命令，我可以给出确切的参数写法。",
  "error": null,
  "opened": [
   "ai_session_search_archive"
  ],
  "requested": [
   "ai_session_search_archive"
  ],
  "opened_target": false,
  "prompt_tokens": 31141,
  "completion_tokens": 674,
  "sec": 10.659189701080322,
  "cost": 0.01507555,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T23",
  "target": "video-scoring-audio",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "你的测法确实有问题，问题就出在 `-ac 1` 这一步。\n\n## 为什么 `-ac 1` 会造出假削波\n\nffmpeg 的 `-ac 1` 下混是**求和**，不是取平均。对相关立体声（同一份单声道内容写进左右两个声道——人声旁白、以及大多数从单声道源导出的混音都是这种），两个声道相加约 **+3 dB**。\n\n实测案例：一个真实峰值 **-1.5 dBFS** 的混音，经 `-ac 1` 后测出 **1.19**，于是被报成削波。原始混音根本没削。\n\n规则：**测峰值绝不要在下混之后做，要在每个声道上做。**\n\n## 正确的测法\n\n**1. 峰值 / 削波——逐声道，不下混**\n\n```python\nimport subprocess, numpy as np\n\ndef load(path, rate=48000):\n    ch = int(subprocess.run([\"ffprobe\",\"-v\",\"error\",\"-select_streams\",\"a:0\",\n                             \"-show_entries\",\"stream=channels\",\"-of\",\"csv=p=0\",path],\n                            capture_output=True, text=True).stdout.split()[0])\n    raw = subprocess.run([\"ffmpeg\",\"-v\",\"quiet\",\"-i\",path,\"-f\",\"f32le\",\"-ar\",str(rate),\"-\"],\n                         check=True, stdout=subprocess.PIPE).stdout\n    x = np.frombuffer(raw, dtype=\"<f4\")\n    return x[: len(x)//ch * ch].reshape(-1, ch), rate   # 保留声道\n\nxc, sr = load(\"mix.wav\")\nprint(float(np.max(np.abs(xc))))     # 峰值必须 < 1.0，逐声道取最大\n```\n\n注意 `load()` 里**保留声道**，只在算包络和频谱质心时才 `xc.mean(axis=1)` 下混——那是分析用的，不是测峰值用的。\n\n**2. 独立交叉验证——用 ebur128**\n\n```bash\nffmpeg -v error -i mix.wav -af ebur128=peak=true -f null -\n```\n\n这会给出 integrated loudness（LUFS）和 **true peak（dBTP）**，是跟 numpy 那条路完全独立的实现。两条路对得上，你才有资格说\"没削波\"。\n\n**3. 如果确实要单声道版本**，用平均而不是求和：\n\n```bash\nffmpeg -i mix.wav -af \"pan=mono|c0=0.5*c0+0.5*c1\" mono.wav\n```\n\n这样峰值不会因为下混而虚高 +3 dB。\n\n## 交叉验证清单\n\n| 检查 | 命令 | 期望 |\n|---|---|---|\n| 逐声道峰值 | numpy `max(abs(xc))` | < 1.0 |\n| true peak | `ebur128=peak=true` | dBTP < 0（短 cue 建议 ≤ -1.5） |\n| integrated loudness | 同上 | 短 cue 约 -19 LUFS；旁白混音约 -16 LUFS |\n| 时长 | `ffprobe -show_entries format=duration` | 与 cue 长度一致（fluidsynth 会因混响拖长，必须 `-t` 显式裁剪） |\n| 干净解码 | `ffmpeg -v error -i out.mp4 -f null -` | 零输出 |\n\n## 两个容易踩的坑\n\n- **`loudnorm` 的目标值是要验证的输出，不是你能控制的输入。** `I=-16` 第一次就削到峰值 1.092。`I=-19:TP=-3:LRA=11` 是个合理起点，但峰值必须测。\n- **单遍 `loudnorm` 只适合独立 cue，不适合混音。** 它会随时间自适应增益，重塑各层之间的平衡。混音要用两遍：第一遍 `print_format=json` 测量，第二遍带测出的值加 `linear=true`，只施加一个固定增益。\n\n最后，把结论按可验证的方式交付，别用\"听起来不错\"这种你无法验证的说法。比如：\"10.00s，逐声道峰值 0.94（无削波），ebur128 true peak -1.5 dBTP，包络显示 6.0s 处的 swell 符合设计——请你听一下音色对不对。\"",
  "error": null,
  "opened": [
   "video-scoring-audio",
   "procedural-video-frames"
  ],
  "requested": [
   "video-scoring-audio",
   "procedural-video-frames"
  ],
  "opened_target": true,
  "prompt_tokens": 48863,
  "completion_tokens": 1968,
  "sec": 26.281616926193237,
  "cost": 0.01851025,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T24",
  "target": "speaker_notes",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "口播稿的句子长度没有绝对硬性上限，但有几条可以直接照做的经验值：\n\n**核心数字**\n- 普通句子控制在 **12–18 个词**（中文约 **20–30 字**）最舒服。这是人一口气能读完、听众不用回头找主语的区间。\n- 上限：**25 个词**（中文约 40 字）。超过这个长度，口播时基本要换气两次，听众容易掉线。\n- 下限：不要连续出现 3 个以上 **5 词以内**的短句，会显得碎、像念标语。\n\n**判断标准（比数字更可靠）**\n1. **一口气测试**：默读时能不能不换气读完？读不完就拆。\n2. **一个信息点测试**：一句话里塞了两个以上\"而且/但是/因为\"，就拆成两句。\n3. **耳朵测试**：念出来，如果听到后半句已经忘了前半句的主语，就是太长。\n\n**具体做法**\n- 长句拆法：把从句提出来单独成句。例：\"虽然我们去年在成本控制上做了很多努力，但是由于供应链波动，最终利润率还是下降了三个百分点。\" → 拆成三句：\"去年我们在成本控制上做了很多努力。但供应链波动太大。最终利润率还是降了三个百分点。\"\n- 短句合并：把连续的短句用\"而且\"\"所以\"这类口语连接词串起来，但一次只串两个。\n- 段落层面：每段 **3–5 句**，一段只讲一个意思。口播稿的段落比书面稿短一半。\n\n**几个例外**\n- 排比句可以刻意做短（3–6 词），用来制造节奏，但连续不超过 4 句。\n- 关键结论句可以刻意做短，甚至单独成段，用来\"砸\"一下。\n- 引用、数据、专有名词多的句子，宁可拆碎，不要为了流畅硬连。\n\n如果你要的是更系统的口播稿写作规范（包括停顿标记、重音标注、speaker notes 的写法），可以看 `speaker_notes` 这个 skill。",
  "error": null,
  "opened": [],
  "requested": [],
  "opened_target": false,
  "prompt_tokens": 6597,
  "completion_tokens": 460,
  "sec": 4.378071069717407,
  "cost": 0.00126555,
  "web_tools": [],
  "n_calls": 1
 },
 {
  "id": "T25",
  "target": "skill_tavily",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不行，`search` 没有 `--stdout` 这个参数。\n\n具体行为：\n\n- 给 `search`（或 `extract`）传 `--stdout` 会被当成无法识别的参数，属于 usage error，**退出码是 2**。\n- 因为是参数解析阶段就失败了，**不会真正发起任何网络请求**，也不会消耗 Tavily credits。\n- 此时 stdout 可能是空的，所以别急着 `json.loads(proc.stdout)`，先看 `proc.returncode`，非 0 就打印 `proc.stderr` 然后中止。\n\n`--stdout` 只存在于 `usage` 子命令：\n\n```bash\npython -m tavily_skill usage --stdout\n```\n\n`search` / `extract` 的设计是「完整结果永远不内联到 stdout」：完整 payload 写到文件（默认 `tmp/tavily/`，或 `TAVILY_CLI_OUTPUT_DIR`，或被 `--output` 覆盖），stdout 只返回一个轻量 status 对象，含 `command`、`status`、`output_mode`、`output_path`、`payload_bytes`、`summary`、`payload_schema`。\n\n所以如果你确实想在控制台看到完整结果，正确做法是：先跑 search 拿到 `output_path`，再在代码里读那个文件、只挑有界字段打印。例如：\n\n```bash\n./.venv/bin/python - <<'PY'\nimport json, subprocess, sys\nfrom pathlib import Path\n\nproc = subprocess.run(\n    [sys.executable, \"-m\", \"tavily_skill\", \"search\", \"latest AI news\"],\n    capture_output=True, text=True,\n)\nif proc.returncode != 0:\n    print(f\"Error (exit {proc.returncode}): {proc.stderr.strip()}\", file=sys.stderr)\n    sys.exit(proc.returncode)\n\nstatus = json.loads(proc.stdout)\npayload = json.loads(Path(status[\"output_path\"]).read_text(encoding=\"utf-8\"))\nfor item in payload[\"data\"][\"results\"][:3]:\n    print(f\"Title: {(item.get('title') or '')[:80]}\")\n    print(f\"URL: {(item.get('url') or '')[:300]}\")\n    print(f\"Excerpt: {(item.get('raw_content') or item.get('content') or '')[:500]}\\n---\")\nPY\n```\n\n注意两点：一是要打开 status 里返回的那个确切 `output_path`，不要用 glob 或「最新 mtime」去猜文件；二是别把整个 payload 文件直接 print 出来，那会把上下文窗口塞满，按需截断字段。\n\n另外，如果你只是想少付固定开销（进程启动、TLS、`op read` 约 0.9s），把多个独立查询用可重复的 `--query` 放进一次调用，而不是每个查询起一个进程：\n\n```bash\npython -m tavily_skill search --query \"q1\" --query \"q2\" --query \"q3\" --concurrency 4\n```\n\n批量模式下 stdout 只返回一个 batch envelope，每个查询一个文件，`--output` 在批量模式下会被拒绝。",
  "error": null,
  "opened": [
   "skill_tavily"
  ],
  "requested": [
   "skill_tavily"
  ],
  "opened_target": true,
  "prompt_tokens": 38445,
  "completion_tokens": 1403,
  "sec": 21.29868984222412,
  "cost": 0.01660855,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T26",
  "target": "zun_music",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "可以，但上限很紧：**每四小节最多一处**，也就是 8 小节里最多 2 处，而且实践中通常只留最后一处（终止式）。\n\n## 具体规则\n\n强拍 = 4/4 里的第 1 拍和第 3 拍。在这些位置上，你编配后的音高类（pitch class）必须和**未改动的原始旋律转写**（`SOURCE`）一致。任何有意的偏离都要写进 `skeleton_allow`，并且：\n\n- 上限是 **每 4 小节 1 处**；\n- 8 小节的一整句 → 最多 2 处；\n- 实践中通常只有 1 处，就是最后终止式那一下（比如把末音落到小调主音上）。\n\n## 怎么算合规\n\n1. 在 `Arrangement` 里把每一处偏离登记到 `skeleton_allow`，不要漏、也不要多报。\n2. 跑 `python scripts/render_example.py <tune> --midi-only`，它会执行 `check()`（验收标准 1–2），失败会非零退出。\n3. 跑 `python -m pytest tests/ -v`，会对每个 example 验证标准 1–3。\n\n## 实操建议\n\n- 想改旋律，改**弱拍和骨架音之间**：3-3-2 切分、长音提前半拍、长音收尾加 16 分音符回旋（如 C–D–C–B–C）、乐句间用上行 16 分音符跑动填掉休止。这些都不占 `skeleton_allow` 名额。\n- 强拍上真要动，就动在终止式：和声小调 V7 打底、旋律第 4 级音悬在 V7 上做 ♭9 张力、末音落小调主音——这一处就是典型的 `skeleton_allow` 条目。\n- 别在句中间换调、别转平行小调、别堆密集装饰音。参考会话里这么干过，听感反馈是\"和原曲完全没关系\"。\n- 反过来，如果强拍骨架全保留、只改和声/鼓/音色，会被说\"太接近原曲、没有 ZUN 味\"。所以骨架守住、idiom 加在骨架之间，才是那个平衡点。\n\n最后提醒：合规只是自动检查，\"像不像 ZUN\"只有人听了才算数。",
  "error": null,
  "opened": [
   "zun_music"
  ],
  "requested": [
   "zun_music"
  ],
  "opened_target": true,
  "prompt_tokens": 29013,
  "completion_tokens": 1087,
  "sec": 13.370349645614624,
  "cost": 0.01500415,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T27",
  "target": "skill_image_generation",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不会生效。`--quality` 只对 GPT Image 系列（`gpt-image-2`、`gpt-image-2.5-sunburst`、`gpt-image-2.5-flare`）有意义。\n\nskill 里写得很明确：\n\n> `--quality low|medium|high|xhigh|max|auto` applies to GPT Image models. ... **Gemini accepts the flag but ignores it because quality tiers are provider-specific.**\n\n也就是说，CLI 不会因为你在 Gemini 下传了 `--quality high` 就报错——它照单全收，然后丢掉。所以你看到\"没区别\"是预期行为，不是参数没传进去，也不是模型没生效。\n\n## 想真正提升 Gemini 出图质量，改用这些\n\n1. **换模型档位**，而不是换 quality：\n   ```bash\n   generate-image -p \"...\" -o output/x.jpg --model gemini-pro\n   ```\n   默认是 `gemini-flash`，`gemini-pro` 才是质量档。\n\n2. **提分辨率 / 定画幅**：\n   ```bash\n   generate-image -p \"...\" -o output/x.jpg --size 4K --aspect-ratio 16:9\n   ```\n\n3. **已有小图想变清晰**，走 upscale 路径（这条是 Gemini 专属的）：\n   ```bash\n   generate-image --upscale -i input/small.jpg -o output/small_4k.jpg --aspect-ratio 16:9\n   ```\n\n## 如果你确实需要 quality 分级\n\n那就把模型换成 GPT Image 2.5，quality 才有意义，而且 2.5 的刻度比 2 更细：\n\n- `gpt-image-2.5-sunburst`：精度优先，出最终资产\n- `gpt-image-2.5-flare`：速度优先，出草稿\n- 2.5 的 `high` 大约只要 GPT Image 2 `high` 的四分之一成本，2.5 的 `max` 才相当于旧的 `high`\n\n```bash\ngenerate-image -p \"...\" -o output/x.png --model gpt-image-2.5-sunburst --quality high\n```\n\n顺带提醒两个容易踩的坑：GPT Image 2.5 单次调用很慢，默认 120s 的 bash timeout 不够，复杂编辑要显式给到 300s 以上；批量出图别串行跑，用 `ThreadPoolExecutor` 开 4–6 个并发。",
  "error": null,
  "opened": [
   "skill_image_generation"
  ],
  "requested": [
   "skill_image_generation"
  ],
  "opened_target": true,
  "prompt_tokens": 30916,
  "completion_tokens": 1103,
  "sec": 13.220792055130005,
  "cost": 0.015299199999999999,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T28",
  "target": "skill_process_launcher",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "你遇到的是 Process Launcher 的**精确时间冲突保护**（exact-time collision guard）。当一次定时 `POST /run`（用 `delay_seconds` 或 `run_at`）解析出的 `run_at` 与某个已存在的 pending/running 任务的 `run_at` **完全相等**时，接口返回 HTTP 409，`detail.error = \"schedule_conflict\"`，并在 `detail.conflicts` 里列出每个冲突任务（id、label、command、status、run_at）。**409 时不会创建任何东西。**\n\n注意：这个保护只匹配**完全相同**的时间。两条相隔几秒提交、delay 相近的任务不会撞上。\n\n## 方案一：两条都保留\n\n把原来那条请求**原样重发**，只加一个字段：\n\n```bash\ncurl -sf -X POST http://127.0.0.1:7997/run \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"command\": [\"python\", \"job.py\"], \"label\": \"job_b\", \"run_at\": \"2026-06-10T09:00:00-07:00\", \"confirm_conflict\": true}'\n```\n\n`confirm_conflict` 默认 `false`，设为 `true` 就表示\"我知道同一时刻已经有任务了，仍然要并排调度\"。这样两条任务会同时保留、同一时刻各自触发。\n\n## 方案二：用新的替换旧的\n\n先取消冲突的那条旧任务，再提交新任务：\n\n```bash\n# 1. 从 409 响应的 detail.conflicts 里拿到旧任务的 id\ncurl -sf -X POST http://127.0.0.1:7997/scheduled/{旧job_id}/cancel\n\n# 2. 重新提交新任务（此时不再冲突，无需 confirm_conflict）\ncurl -sf -X POST http://127.0.0.1:7997/run \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"command\": [\"python\", \"new_job.py\"], \"label\": \"job_new\", \"run_at\": \"2026-06-10T09:00:00-07:00\"}'\n```\n\n## 相关细节\n\n- **先查清楚冲突对象**：`curl -sf http://127.0.0.1:7997/scheduled` 默认只返回非终态任务（pending + running），可以加 `?label=xxx` 做子串过滤，或 `?status=pending` 精确过滤。终态任务（completed/failed/cancelled/missed）默认隐藏，需要时加 `?include_terminated=true`。\n- **改时间也会触发同样的保护**：如果你不是重发，而是用 `PATCH /scheduled/{job_id}` 把某个任务的 `run_at` 挪到冲突时刻，同样返回 409，同样用 `confirm_conflict: true` 放行。\n- **只有 pending 任务能改**：`PATCH` 只能改 pending 任务，running/completed/failed/cancelled/missed 都不能改，只能对 pending 的用 cancel。\n- **提交后的响应**：延迟任务刚创建时响应里可能是 `pid: 0`、`output_file: null`，这是正常的\"已排期、尚未启动\"，用响应里的 `scheduled_job_id` 作为后续取消和审计的句柄。\n- **验证**：`GET /scheduled/{job_id}` 看它是否真的启动、退出码是否为 0；启动后 `GET /processes/{pid}` 拿输出文件，`GET /processes/{pid}/output` 读失败原因。",
  "error": null,
  "opened": [
   "skill_process_launcher"
  ],
  "requested": [
   "skill_process_launcher"
  ],
  "opened_target": true,
  "prompt_tokens": 38186,
  "completion_tokens": 1594,
  "sec": 27.327476978302002,
  "cost": 0.0166843,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T29",
  "target": "skill_resend_email",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "不行。`mail export-md` 没有 `--after` / `--before` 参数，也不接受 ISO 时间戳。它只支持 `--days N`（相对天数）、`--folder`、`--subject`、`--from`、`--force`。\n\n所以“最近两天”的正确流程是：\n\n**1. 先下载（把邮件拉到本地）**\n\n```bash\n.venv/bin/python -m outlook_skill.cli mail download --days 2 --limit 200 --format json\n```\n\n`--days 2` 是这里唯一的时间窗控制手段，作用在 Graph 拉取阶段。默认抓 `Inbox` + `Archive`；只想收件箱就显式指定：\n\n```bash\n.venv/bin/python -m outlook_skill.cli mail download --days 2 --limit 200 --folder Inbox --format json\n```\n\n**2. 再导出为 Markdown**\n\n```bash\n.venv/bin/python -m outlook_skill.cli mail export-md --days 2 --folder Inbox\n```\n\n`export-md` 的 `--days N` 是对**本地已缓存**的 `.eml` 做日期过滤，不是去 Graph 查。所以必须先 download，否则本地没有那两天的邮件可导。输出落在 `data/mail/markdown/`，文件名形如 `{date}_{folder}_{subject-slug}_{short-graph-id}.md`，带 YAML frontmatter（`graph_id`、`folder`、`from`、`to`、`cc`、`subject`、`date`、`body_source`）。\n\n**3. 需要更精确的边界时，用 SQLite 自己筛**\n\n`--days` 只能给“N 天前到现在”这种粗粒度窗口。如果你要的是严格的两天区间（比如“昨天 00:00 到今天 23:59”），直接查 `data/mail/mail.db`，`internaldate` 列是 ISO-8601、可直接排序比较：\n\n```bash\nsqlite3 data/mail/mail.db \\\n  \"SELECT folder, subject, from_addr, internaldate FROM messages \\\n   WHERE internaldate >= '2026-05-09T00:00:00' \\\n     AND internaldate <  '2026-05-11T00:00:00' \\\n   ORDER BY internaldate DESC;\"\n```\n\n拿到 `graph_id` 后，用 `mail read --graph-id <id>` 读全文，或对相应 `.eml` 单独走导出。\n\n**几个坑要注意：**\n\n- **别用 `mail list-local` 判断“最近”**。它按 `id`（插入顺序）返回，不是按 `received_at` 倒序。库里有几万行时，`--limit 50` 给你的是**最旧**的 50 封。要按时间找，用 SQLite 的 `internaldate`，或 `mail read --subject <substr> --latest`。\n- **重复跑是安全的**。幂等键是 `(account, folder, graph, message_id)`，同一时间窗重跑只会增加 `skipped_existing_count`，不会重复写。\n- **同一封邮件在不同文件夹会各存一条记录**，所以 `--folder Inbox` 和默认的 `Inbox + Archive` 结果条数会不一样。\n- **`export-md` 默认不覆盖**已存在的文件，要重写加 `--force`。",
  "error": null,
  "opened": [
   "skill_outlook"
  ],
  "requested": [
   "skill_outlook"
  ],
  "opened_target": false,
  "prompt_tokens": 35102,
  "completion_tokens": 1510,
  "sec": 26.0959689617157,
  "cost": 0.0161713,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T30",
  "target": "skill_typefully_twitter",
  "model": "deepseek-v4.1-flash",
  "condition": "index",
  "answer": "默认用 **long post 模式**（Typefully 长帖），不要套 280 字限制。\n\n具体做法：\n\n1. **默认情况**：文章分发帖（来自内容平台的文章）默认走 Typefully long post。\"一条帖子\"指的是一个 Typefully post，不是一条 280 字符的普通推文。\n   - 如果账号本身能发长帖，在本地 `.env` 里设 `TYPEFULLY_TWITTER_DEFAULT_LONG_POST=true`，这样 `post count`、`post draft`、`post create` 默认就按长帖校验。\n   - 如果这个环境变量没开，就在命令上显式加 `--long-post`。\n\n2. **用户明确要短推时**：加 `--no-long-post`，切回普通 X 长度限制（280 字加权规则）。\n\n触发\"短推\"的信号是用户明确说了\"普通推文\"\"280 字内\"\"short tweet\"，或者直接用了 `--no-long-post`。只有这些情况才施加 280 字限制。\n\n另外两点提醒：\n\n- 校验用 `post count`，它按 X 的加权长度规则算（URL 按转换后的长度计），不是可见字符数。如果 `post count` 对分析性文章返回 `TOO_LONG`，通常说明内容本来就该走长帖模式，不要为了塞进 280 字而删内容，除非用户明确要单推摘要。\n- 发布前先看 `post count` 或 `post get` 输出的实际 `text`，确认第一行是面向读者的正文，不是 `# Twitter 文案`、`T 导流帖` 这类内部标签；`--thread-file` 不会自动剥掉 Markdown 标题和 frontmatter。",
  "error": null,
  "opened": [
   "skill_typefully_twitter"
  ],
  "requested": [
   "skill_typefully_twitter"
  ],
  "opened_target": true,
  "prompt_tokens": 37133,
  "completion_tokens": 775,
  "sec": 13.619632959365845,
  "cost": 0.01603495,
  "web_tools": [],
  "n_calls": 2
 }
]