[
 {
  "id": "T01",
  "target": "ai_session_search_archive",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "不能直接用那份几周前的 txt。两个原因，都会让你漏结果：\n\n**1. 文件列表同时是结果白名单。** semantic-search 的 `--file-list` 不只是\"要索引哪些文件\"，它也是查询时的结果允许集合。列表里没有的文件，即使它的向量已经在缓存里，也永远不会被返回。几周前的列表意味着这几周新导出的会话全部被排除在外。\n\n**2. 相对路径会静默失配。** CLI 会把列表里的每一项规范化成绝对路径再去查缓存。相对路径是相对于 `--source-root`（没给的话就是 CLI 当前工作目录）解析的，**不是**相对于你当初生成列表的那个目录。如果列表是在别处生成的、而你现在从另一个目录跑 CLI，解析出来的路径跟缓存里的身份对不上，查询会**静默返回空结果，不报错**。这是最容易误判成\"没有匹配\"的坑。\n\n正确做法是查询时现生成列表，用绝对路径：\n\n```bash\nFILELIST=\"$(mktemp)\"\ntrap 'rm -f \"$FILELIST\"' EXIT\nrg --files contexts/ai_sessions/{opencode,claude_code,codex,antigravity,second_mind}/ \\\n  -g '*.md' > \"$FILELIST\"\n\nsemantic-search query \\\n  --file-list \"$FILELIST\" \\\n  --cache-dir .knowledge_cache \\\n  --query '你记得的大概内容' \\\n  --top-k 10 \\\n  --no-refresh\n```\n\n几点补充：\n\n- **范围**：记得来源就只搜那个目录；不记得就全搜。你手头那份 txt 如果只覆盖了部分来源，也会造成同样的漏检。\n- **`--no-refresh`**：只读查询不要顺手重建大缓存，刷新是单独的维护动作。缓存里已有的向量不会因为 `--no-refresh` 而失效——失效的是\"列表里没列出的文件\"。\n- **先做词法搜索**：如果你还记得人名、项目名、标题、日期或 session id，先用 `rg -i -n --glob '*.md' '变体1|变体2'` 搜一遍，比语义搜索更准。注意 glob 匹配的是文件名不是内容，结果被截断不代表没有。\n- **怀疑缓存本身有问题**时，跑 `semantic-search doctor --cache-dir .knowledge_cache` 和 `stats` 看健康度；如果缓存里存在相对路径或重复身份，用 `canonicalize-paths --source-root <当初生成列表的目录>` 先 dry-run 看 JSON 报告，确认后再 `--apply`（它只改 SQLite 元数据，不重算向量）。\n- **时效性**：如果目标会话比最近一次成功导出还新，归档里根本没有它，得先做一次临时导出，查完删掉。\n\n结果按 source + session_id 归并，一个会话只出现一次，附标题、日期、来源、项目短名和一段原文摘录供你核对。",
  "error": null,
  "opened": [
   "ai_session_search_archive",
   "skill_semantic_search"
  ],
  "requested": [
   "ai_session_search_archive",
   "skill_semantic_search"
  ],
  "opened_target": true,
  "prompt_tokens": 36818,
  "completion_tokens": 1321,
  "sec": 18.556732177734375,
  "cost": 0.0163153,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T04",
  "target": "mac_universal_clipboard",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "下面这条命令就是完整方案，直接整段粘进终端（zsh）执行即可：\n\n```bash\ndefaults delete ~/Library/Preferences/com.apple.coreservices.useractivityd.plist ClipboardSharingEnabled 2>/dev/null; \\\ndefaults write ~/Library/Preferences/com.apple.coreservices.useractivityd.plist ClipboardSharingEnabled 1; \\\nkillall useractivityd 2>/dev/null; \\\nkillall sharingd 2>/dev/null; \\\nkillall pboard 2>/dev/null; \\\nsleep 2; \\\npgrep -fl 'useractivityd|sharingd|pboard'\n```\n\n**要修改的具体偏好项位置**\n\n- 文件：`~/Library/Preferences/com.apple.coreservices.useractivityd.plist`\n- 键：`ClipboardSharingEnabled`\n- 操作：先 `defaults delete` 清掉可能已损坏/过期的旧值，再 `defaults write ... 1` 写回开启状态。\n\n**需要重启的所有相关后台进程（三个）**\n\n1. `useractivityd` —— 管 Handoff / Continuity activity 状态，包括 Universal Clipboard 偏好和共享剪贴板 blob（这是关键的那个）。\n2. `sharingd` —— 管附近设备发现和 sharing transport。\n3. `pboard` —— 管本地 pasteboard 状态。\n\n**预期结果**\n\n`killall` 之后这三个进程会被 launchd 自动拉起，最后那条 `pgrep -fl` 应该能把它们重新列出来。看到三个进程都在，就去测两个方向：iPhone/iPad → Mac，以及 Mac → iPhone/iPad。\n\n**如果这条命令没用**\n\n按顺序升级，不要一上来就退 iCloud：\n\n1. Mac 上 System Settings → General → AirDrop & Handoff，以及 iOS/iPadOS 上 Settings → General → AirPlay & Handoff，把 Handoff 关掉再打开。\n2. 让 Mac 进一次 Safe Mode，再正常重启（会清一批系统缓存，对 macOS 更新后的 Continuity 异常历史上有效）。\n3. 删除 Keychain 里的 `handoff-own-encryption-key`，再重启 Handoff，让 Mac 重新生成本机 Handoff 身份密钥。\n4. 退出 iCloud 再登录——最后手段，会触发 iCloud Drive、Photos、Keychain 重新同步，打扰很大。\n\n另外：如果你的 Mac 跑的是 macOS beta，快速修复失败后优先按系统回归 bug 判断，别反复叠加更重的本地改动。",
  "error": null,
  "opened": [
   "mac_universal_clipboard"
  ],
  "requested": [
   "mac_universal_clipboard"
  ],
  "opened_target": true,
  "prompt_tokens": 15500,
  "completion_tokens": 551,
  "sec": 12.747015953063965,
  "cost": 0.0026555999999999997,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T05",
  "target": "openreview",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "403 和 401 不是一回事，先别急着删缓存——按下面顺序做，通常第 1、2 步就能恢复。\n\n## 先判断：403 大概率不是凭据过期\n\n这个 skill 覆盖的是**凭据过期**（表现为 401）。你描述的是\"连续执行几次查询后全部 403\"，账号没变，这更像**被限流/风控**，而不是 token 失效。所以：\n\n**不要删本地缓存。** 这个 skill 的凭据链里根本没有\"缓存\"这个东西——token 存在 `.env` 目标文件里，浏览器登录态存在持久 profile 目录里（如 `~/.local/share/session-keeper/<site>/`）。删掉 profile 只会让你重新登录一次，对 403 毫无帮助，反而丢掉登录态。\n\n## 立刻恢复的做法\n\n1. **先停手，等一会儿。** 403 是服务端在拒绝你，继续重试只会延长封禁窗口。停 5–15 分钟，然后只发**一个**请求试探。\n\n2. **确认是不是限流。** 看响应体/响应头里有没有 `Retry-After`、`X-RateLimit-*`、或\"too many requests\"之类的字样。有的话就是限流，等窗口过去即可。\n\n3. **确认凭据本身还有效**（这一步是本地、零网络）：\n   ```\n   session-keeper status --env-target <你的 .env 路径> --field EXAMPLE_AUTHORIZATION\n   ```\n   它会解码 JWT 的 `exp` 并打印绝对到期时间。如果还没到期，就**不要**跑 `capture --apply`——问题不在凭据。\n\n4. **如果 status 显示已过期**，才走刷新链：\n   ```\n   session-keeper capture --site <name> --headers Authorization,Session,Account \\\n       --verify-url <只读端点> --env-target <你的 .env 路径> --apply\n   ```\n   注意：`capture` 会先拿候选值去打只读端点验证，**只有 200 才允许写入**。如果服务端还在 403 你，验证会失败并返回 `verification_failed`，此时它不会写文件——这正是你想要的保护，别绕过它。\n\n5. **如果 `capture` 报 `no_captured_headers`**，那才是登录态的问题：在它管理的那个 Chrome 窗口里手动打开/刷新一次目标站点，然后重试一次（一次 reload 通常就够，等更久没用）。\n\n## 关于\"删哪个缓存\"\n\n没有该删的缓存。真要清理，只有两个东西，且都不是为了修 403：\n\n- **`.env` 备份**：每次写入前会自动生成 `<target>.bak-<YYYYMMDD_HHMMSS>`。写坏了就从这个备份回滚，不要手改。\n- **浏览器 profile 目录**：只有在登录态确实坏了（反复 `no_captured_headers`）时才需要，做法是重新 `session-keeper login --site <name>` 手动登录一次，而不是删目录。\n\n## 一个容易踩的坑\n\n这个 skill 明确记录过：**CLI 退出码 0 可能掩盖 HTTP 401/403**。如果你的调度脚本只看进程退出码，它会以为一切正常。所以排查时看 JSON 里的 `verify_status` 和 `verified_ok` 字段，别只看 exit code。\n\n**总结**：403 先当限流处理——停手等待、单次试探、用 `status` 确认凭据未过期；不要删任何缓存，不要连续重试。只有 `status` 显示过期时才跑 `capture --apply`，且必须通过只读端点验证才会写入。",
  "error": null,
  "opened": [
   "skill_session_keeper"
  ],
  "requested": [
   "skill_session_keeper"
  ],
  "opened_target": false,
  "prompt_tokens": 59230,
  "completion_tokens": 1619,
  "sec": 27.805459022521973,
  "cost": 0.0198559,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T09",
  "target": "codex_cli",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "写这一行：\n\n```toml\nweb_search = \"disabled\"\n```\n\n要点：\n\n1. **值必须是字符串 `\"disabled\"`，不能用布尔值。** 在 0.156.1 上只有 `\"disabled\"` 能解析通过；`false`、`on`、`off`、`enabled` 都会被拒绝，报 `expected string only in 'web_search'`。所以 `web_search = false` 是错的。\n\n2. **层级：顶层裸键（bare top-level key），不是 `[section]` 里的键。** 放在基础配置 `$CODEX_HOME/config.toml` 里，或者放在这次运行实际加载的 profile 文件里（profile 文件会覆盖基础配置）。\n\n3. **位置很关键：必须写在所有 `[section]` 表头之前。** TOML 会把裸键绑定到它前面最近的那个 table，所以如果你把它写在 `[profiles.xxx]` 之类的表头下面，它就会变成那个 section 的键，而不是顶层键，等于没生效。\n\n一个可用的最小形态：\n\n```toml\nweb_search = \"disabled\"\n\n[profiles.someprofile]\nmodel = \"...\"\n```\n\n另外两点顺带提醒：\n\n- 改完配置后，用 `codex exec --help` 加一次便宜的拒绝探针重新验证一下，这个 CLI 的 flag/配置在 minor 版本之间会变。\n- 如果你只是想临时绕开，也可以在命令行用 `-c` 覆盖：`codex exec -c web_search=\"disabled\" ...`，但注意 `-c` 的值解析规则和 TOML 一致，仍然要写字符串 `\"disabled\"`。",
  "error": null,
  "opened": [
   "codex_cli"
  ],
  "requested": [
   "codex_cli"
  ],
  "opened_target": true,
  "prompt_tokens": 41178,
  "completion_tokens": 741,
  "sec": 17.697328090667725,
  "cost": 0.016621300000000002,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T11",
  "target": "apple_photos",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "不能一次性发起 120 对的删除，而且这个 skill 目前根本没有可用的批量删除入口——原因和做法如下。\n\n**1. 现在没有 apply/trash 命令可调用**\n\n这个 skill 的边界写得很明确：`apply` 步骤在用户确认 review 产物之前属于 out of scope，CLI 只提供 `read-tags`、`survey`、`build-review` 三个子命令。所以\"一次性发起 120 对删除\"这件事在当前阶段无法执行，不是分批多少的问题，而是删除动作本身还没被实现/授权。\n\n**2. 授权不能复用，也不能对不确定的批次重试**\n\n授权是绑定在**具体那一份 review CSV** 上的：流程要求你打开 HTML 逐簇检查、编辑 `music_library_dedup_review.csv`（或配对 JSON）、把不要的重复项设成 `user_action=trash`、不确定的留 `user_action=pending`，然后**明确告诉 agent 这份 CSV 已批准**。只有在这之后，未来的 apply 才允许 trash 那些 `user_action=trash` 的非 canonical 行。\n\n因此：\n- 之前拿到的授权只对当时那份被确认的 CSV 有效，不能拿来覆盖新一批候选。\n- 对结果不确定的批次\"再试一次\"也不行——不确定的行必须保持 `pending`，不能靠重试或复用旧授权把它变成可删。\n- 任何 canonical 变更和 trash 决定在用户确认前都归用户，agent 不能代劳。\n\n**3. 关于\"每批最多多少对\"**\n\nskill 里没有规定批量上限，因为它压根没设计批量删除路径。真正需要分批的是**人工审阅**：120 对候选要逐簇看 artwork、格式、库位置和 canonical 建议，这个量级建议按簇分批过 review CSV，而不是按\"批\"去发删除请求。\n\n**4. 另外两个会卡住你的点**\n\n- 如果候选里混了 `needs_review` 簇（标题/艺术家相同但带 `Live`、`Cover`、`伴奏`、`串烧` 等版本标记），这些簇即使完全匹配也不会进自动 trash 流程，必须人工判定。\n- 同名不同艺术家的组只写在 `music_library_near_duplicates.csv`，仅供人工查看，**不能**从 duplicate review CSV 里删。\n\n**建议的照做步骤**\n\n1. 先跑 `read-tags` → `survey` → `build-review`，生成 inventory、survey、review CSV/JSON 和 HTML。\n2. 打开 `source_identification/music_library_dedup_review.html`，按簇分批审阅（120 对建议分几轮，每轮几十簇）。\n3. 在 `music_library_dedup_review.csv` 里把确定不要的设 `user_action=trash`，不确定的留 `pending`，canonical 选错的直接改。\n4. 审完一批就明确告知 agent 该 CSV 已批准，让 apply 只处理这一批里 `trash` 的行；下一批重新走一遍审阅+确认，不要复用上一批的授权。\n5. `pending` 和 `needs_review` 的行永远不进删除。",
  "error": null,
  "opened": [
   "music_library_dedup"
  ],
  "requested": [
   "music_library_dedup"
  ],
  "opened_target": false,
  "prompt_tokens": 29266,
  "completion_tokens": 1423,
  "sec": 17.65491795539856,
  "cost": 0.0152437,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T13",
  "target": "ct_education",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "不能填绝对路径。`serve` 的 `--video-file` / `--rendered-video-file` 只接受**已完成 workspace 内部的相对路径**，指向一个已存在的普通 `.mp4` 文件。绝对路径、`..` 穿越、符号链接、文件不存在、非 `.mp4` 后缀都会在启动时直接失败并报 `E_VIDEO_FILE`。\n\n所以正确做法是：\n\n1. 先把 MP4 复制进那个已完成的 workspace 目录里（例如 `/path/to/external/workspace/demo.mp4`）。workspace 必须是已经构建完成、含 `manifest.json` 的那个目录。\n2. 用相对 workspace 的路径传参：\n\n```bash\nct-edu serve --workspace /path/to/external/workspace --video-file demo.mp4\n```\n\n两个槽位可以单独用，也可以一起用：\n\n```bash\nct-edu serve --workspace /path/to/external/workspace --video-file demo.mp4 --rendered-video-file film.mp4\n```\n\n几点补充：\n\n- 视频默认关闭，不传这两个 flag 就没有播放/下载入口。\n- 两个槽位分别对应 demo（默认播放）和 rendered film；只配一个时，客户端会回退到可用的那个。\n- HTTP 请求方无法指定别的文件，服务端只认启动时选定的这两个。\n- 下载名固定为 `ct-education-tour.mp4` 和 `ct-education-film.mp4`。\n- 如果这个 MP4 是 `render-cinematic` 的产物，它的 run 目录与 viewer workspace 是互斥的，把最终成片拷进 workspace 需要显式批准；只拷最终 MP4，不要暴露中间产物或 run 目录。\n- `serve` 只允许服务 viewer workspace 内被显式选中的 demo/film MP4，不是把整个输出目录静态托管出去。\n\n如果不想动 workspace，另一条路是用 `render-video` 直接在 workspace 内生成 MP4（`--output` 必须是 workspace 内的新 `.mp4`，相对路径按 workspace 解析），生成后同样要用 `serve --video-file` 显式选中才能播放。",
  "error": null,
  "opened": [
   "ct_education"
  ],
  "requested": [
   "ct_education"
  ],
  "opened_target": true,
  "prompt_tokens": 42071,
  "completion_tokens": 958,
  "sec": 16.734293937683105,
  "cost": 0.01688545,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T17",
  "target": "hybrid_ai_video",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "不能直接重发。超时只说明你没收到响应，不代表服务端没建成任务；盲目重发很可能产生重复任务（重复扣费、重复渲染、后续回调/轮询对不上号）。\n\n按下面顺序做：\n\n1. **先查，不要先发。** 用创建接口返回体里通常会带的 `task_id` / `request_id`（如果超时前拿到了部分响应），或者用你请求里带的幂等键/客户端生成的 request id，去调查询接口（GET task / list tasks）确认任务是否存在。很多视频生成 API 支持按 `client_request_id` 或 `idempotency_key` 反查。\n\n2. **如果查不到、且接口支持幂等键**：用**完全相同的幂等键**重发同一个请求。服务端会识别为同一请求，要么返回已有任务，要么真正新建，不会重复建两个。\n\n3. **如果接口不支持幂等键**：不要立刻重发。先按时间窗口列出最近的 task 列表（按创建时间倒序），人工比对 prompt/参数/时间戳，确认没有已建成的任务，再重发。重发时把参数（prompt、时长、分辨率、seed 等）原样复制，避免生成出两个不同版本。\n\n4. **重发后仍超时**：不要连续重试。改成\"先查后发\"的循环——每次重试前先查一次任务列表，确认无对应任务再发；并给重试加退避（如 5s、15s、45s），最多 2–3 次，之后转人工/异步轮询。\n\n5. **长期做法**：给创建请求固定加一个客户端生成的 `idempotency_key`（比如 `uuid4`，和业务参数一起持久化到本地），这样任何超时都能安全重发。同时把\"创建\"和\"查询\"解耦：创建只负责拿到 task_id，状态一律靠轮询/回调，不靠创建响应。\n\n一句话：**超时后先查任务是否存在，能带幂等键就用同一个键重发，不能带就先比对任务列表再决定。**",
  "error": null,
  "opened": [],
  "requested": [],
  "opened_target": false,
  "prompt_tokens": 7272,
  "completion_tokens": 461,
  "sec": 9.974161863327026,
  "cost": 0.0013674,
  "web_tools": [],
  "n_calls": 1
 },
 {
  "id": "T20",
  "target": "narwal",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "`NOT_READY` 不是故障，是机器人在拒绝这条命令——它明确告诉你\"现在这个状态下我不接受开始清扫\"。exit code 是 20（`EXIT_COMMAND`），意思是命令已经送达机器人，是机器人自己 decline 的。\n\n## 最可能的原因\n\n`start` / `clean` 走的是 `clean/start_clean` 这个 topic，而它**只在机器人处于 docked（在基站上）状态时才生效**。如果你刚才是在客厅里让它干活、它还在外面跑着或者刚被暂停在半路，那它就不在基站上，于是回 `NOT_READY`。\n\n其他可能触发 `NOT_READY` / `CONFLICT` 的情况：正在执行别的任务、正在回充途中、或者处于某种不允许起新任务的中间态。\n\n## 怎么做它才会接受\n\n1. **先确认它到底在什么状态**（不要猜）：\n   ```\n   narwal-local snapshot\n   ```\n   看 `base_query.mode` 和 `task`：\n   - `standby` 且 task 字段全 null → 空闲\n   - 某个 cleaning mode 且 `area_m2` / `elapsed_seconds` 有值 → 正在清扫\n   - 注意 `is_docked` 和 `charging_state` **永远是 null**，别拿它们判断在不在基站上。要判断是否在充电/在基站，用 `watch` 看电池趋势（上升说明在充）。\n\n2. **如果它不在基站上，先让它回基站**：\n   ```\n   narwal-local dock --yes\n   ```\n   （想先看会发什么 payload 就加 `--dry-run`，不会真的发控制指令。）\n   注意：docking 会自己结束当前任务，**不要再叠一个 `stop`**。\n\n3. **等它确实回到基站后，再发起清扫**：\n   ```\n   narwal-local start --yes\n   ```\n   全屋清扫。想指定房间用：\n   ```\n   narwal-local clean --rooms 3,5 --yes\n   ```\n   房间 id 和 `map_id` 要从当前的 `get_map` 拿（先跑 `narwal-local map`），这个工具不会把房间 id 映射成家里的房间名。\n\n4. **建议先 dry-run 看 payload**：\n   ```\n   narwal-local start --dry-run\n   ```\n   只构造 payload、不发控制 topic，确认 topic 和参数对得上再真发。\n\n## 两个重要提醒\n\n- **绝对不要循环重试被拒绝的命令。** `NOT_READY` 是机器人的答案，不是 bug。反复重发只会一直拿到同样的拒绝。先改变状态（回基站），再重发。\n- **一次只能有一个连接（每个源 IP）。** 不要并行跑命令，否则会互相干扰。\n\n另外，如果你想知道\"它之前为什么停了\"，这个工具**没有历史记录**，单次 snapshot 也答不了。要么用 `watch --duration 20` 看它现在动不动，要么去 App 里看任务历史。",
  "error": null,
  "opened": [
   "narwal"
  ],
  "requested": [
   "narwal"
  ],
  "opened_target": true,
  "prompt_tokens": 40918,
  "completion_tokens": 1323,
  "sec": 18.02987289428711,
  "cost": 0.016931500000000002,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T22",
  "target": "skill_opencode_data",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "会，默认会一起算进去。\n\n按 skill 里的 Selector Semantics：选择器作用在 session 上，message/part 行只通过被选中的 session ID 带进来；而**默认情况下 CLI 选择器会沿 `parent_id` 链展开后代**，也就是把根会话和它的 subagent 子会话打包在一起。\n\n所以 `plan --selector title --prefix batch-` 默认会把匹配前缀的会话**及其所有子会话**都纳入。\n\n如果只想动被选中的那些会话本身，加 `--no-expand`：\n\n```bash\n.venv/bin/python -m opencode_skill plan --selector title --prefix batch- --no-expand\n```\n\n确认无误后再 apply：\n\n```bash\n.venv/bin/python -m opencode_skill apply --selector title --prefix batch- --no-expand \\\n  --dest ~/.local/share/opencode/opencode_archive.db --confirm\n```\n\n两点提醒：\n\n1. 先跑 `plan` 看解析出的 session/message/part 数量和选择器描述，再决定是否 `apply --confirm`；不要凭模糊请求直接 apply。\n2. 想先只复制不删除，加 `--no-delete` 做一次 copy-and-verify。",
  "error": null,
  "opened": [
   "skill_opencode_data"
  ],
  "requested": [
   "skill_opencode_data"
  ],
  "opened_target": true,
  "prompt_tokens": 15901,
  "completion_tokens": 276,
  "sec": 12.801347970962524,
  "cost": 0.00255075,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T23",
  "target": "video-scoring-audio",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "你的测法确实有问题，问题就出在 `-ac 1` 这一步。\n\n## 为什么 `-ac 1` 会造出假削波\n\n`ffmpeg -ac 1` 不是\"取平均\"，而是**把两个声道相加**。对相关立体声（也就是把同一个单声道混音同时写进左右两个声道——旁白/配乐混音最常见的情况），相加等于给信号加了约 **+3 dB**。\n\nskill 里记录过一个实测案例：一个真实峰值 **-1.5 dBFS** 的混音，用 `-ac 1` 之后测出 **1.19**，于是被报告成削波。原始混音根本没削波，是测量步骤自己把电平抬上去的。\n\n所以：**测峰值必须在降混之前、逐声道地测**，绝不能先 downmix 再测。\n\n## 正确的测法\n\n### 1. 峰值 / 削波：逐声道测，不降混\n\n用 ffprobe 读出声道数，把音频解成 float32 后 reshape 回 `(-1, ch)`，然后对**所有声道**取 `max(abs())`：\n\n```python\nxc, sr = load(\"out.wav\")          # 保留声道维度，不要 mean\nprint(float(np.max(np.abs(xc))))  # 峰值必须 < 1.0\n```\n\n只有做 RMS 包络和频谱质心时，才 `xc.mean(axis=1)` 降成单声道——那是为了看形状，不是为了判削波。\n\n### 2. 独立交叉验证：ebur128\n\n用 ffmpeg 自带的 EBU R128 实现做一次独立复核，它同时给出 integrated loudness 和 true peak：\n\n```bash\nffmpeg -i out.wav -af ebur128=peak=true -f null -\n```\n\n看输出里的 `I:`（LUFS）和 `Peak:`（dBFS）。这是和上面 numpy 路径完全独立的第二条证据链，两条都对得上才敢下结论。\n\n### 3. 包络：验证结构而不是验证\"好听\"\n\n半秒窗口算 RMS，转 dB，逐窗打印。这是验证配乐是否跟着分镜走的工具（比如 6.0s 的 reveal 处应该有一个 swell）。\n\n一个坑：**单个击弦音符的 attack 窗口本身就是响的**，哪怕在稀疏段落里。所以验证\"这里应该安静\"要在**音符之间的空隙**里测，不要测在 onset 上，否则你会去追一个根本不存在的失败。\n\n### 4. 频谱质心：音色指纹\n\n4096 窗 / 2048 hop 的 FFT，算加权平均频率。它的真正用途是下一节。\n\n## 交叉验证清单\n\n按这个顺序做，任何一步不过就停下来：\n\n1. **逐声道峰值**（numpy）→ 必须 < 1.0\n2. **ebur128 true peak**（ffmpeg）→ 和上一步的结论必须一致；不一致就说明你的解码/声道处理有问题\n3. **时长**：`len(x)/sr` 必须等于你请求的 cue 长度。fluidsynth 会一直写到混响和 release 衰减完，10.00s 的 MIDI 实测渲染出 12.83s（大音色库 13.25s），所以渲染时 `-t 10` 是必须的，不是整理\n4. **包络 vs 分镜**：每个 storyboard beat 的时间点上，包络形状要对得上\n5. **变体指纹**：如果你在给用户提供多个选项，先 `fingerprint` 一遍，断言它们真的不同\n\n## 顺带提醒两件事\n\n**换音色库必须重测增益。** 增益是 per-bank 的，不通用。macOS 内置 GM（`gs_instruments.dls`）用 `-g 4.5`，MuseScore General 用 `-g 6`。换库不重测，结果要么几乎无声要么削波。记住的增益数字只能当假设。\n\n**`loudnorm` 的目标值是输出，不是你能控制的输入。** `I=-16` 第一次尝试就削到峰值 1.092。`I=-19:TP=-3:LRA=11` 是个合理的起点，但峰值永远要测完才知道。\n\n如果你要的是\"给用户一个可交付的结论\"，措辞应该是这样的：\n\n> 10.00s，逐声道峰值 0.94（无削波），ebur128 true peak -1.5 dBFS，包络显示 6.0s 处有 swell——你听一下音色对不对。\n\n而不是\"听起来不错\"——你听不到，这个判断只能交给用户。",
  "error": null,
  "opened": [
   "video-scoring-audio"
  ],
  "requested": [
   "video-scoring-audio"
  ],
  "opened_target": true,
  "prompt_tokens": 40474,
  "completion_tokens": 2069,
  "sec": 20.84781527519226,
  "cost": 0.017312499999999998,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T24",
  "target": "speaker_notes",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "有硬性上限，而且分两档：\n\n**硬性门槛（hard gate）**\n- 任何普通口播句子**不得超过 22 词**。这是不可越过的红线。\n\n**常规目标区间**\n- 普通句子控制在 **12–20 词**之间。\n- 判断标准不只是数字，而是\"一口气能说完\"——每个句子必须能一次呼吸读完。\n\n**还有一条同样会卡住你的规则：单载荷（single payload）**\n- 一个句子最多承载**一个主信息 + 一个附属从句**。\n- 如果一句话里同时塞了条件、例外、后果，即使词数没超 22，也必须拆开。这就是\"breathless nesting\"陷阱：词数合格，但嵌套了 condition + exception + consequence，读起来照样喘不过气。拆的时候在逻辑转折处切，让条件、动作、后果各自成为一个节拍。\n\n**实操建议**\n1. 先按 12–20 词写，写完做机械检查，把所有 >22 词的句子挑出来。\n2. 对超长句，不要靠删连接词来压词数——那会破坏口播语气。优先整点删除（重复内容、旁枝轶事、已经放在幻灯片上的细节）。\n3. 压缩之后必须重读一遍，把被顺手删掉的连接词、反问句和讲者指定的措辞补回来。\n\n另外提醒一句：词数只是下限保障，别为了凑短句把口播稿写成碎片。讲台语气偏好完整句子加口语连接词（\"And why does that matter? Because…\"、\"Now, here's the catch.\"），而不是电报体。",
  "error": null,
  "opened": [
   "speaker_notes"
  ],
  "requested": [
   "speaker_notes"
  ],
  "opened_target": true,
  "prompt_tokens": 34357,
  "completion_tokens": 714,
  "sec": 16.67754888534546,
  "cost": 0.01558195,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T25",
  "target": "skill_tavily",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "不行，`search` 没有 `--stdout`。\n\n具体行为：\n\n- `--stdout` 只在 `usage` 子命令上存在（`python -m tavily_skill usage --stdout`）。\n- 给 `search`（或 `extract`）传 `--stdout` 会被当成无法识别的参数，属于 usage error，**退出码 2**，并且**不会发起任何网络请求**（不消耗 Tavily credits）。\n- 失败时 stdout 可能是空的，所以脚本里必须先看 `proc.returncode`，再决定要不要 `json.loads(proc.stdout)`。\n\n`search` 的设计就是「完整结果落盘、stdout 只回轻量状态」：\n\n- 完整 payload 写到自动命名的文件，默认在 `tmp/tavily/`，或 `TAVILY_CLI_OUTPUT_DIR` 指定的目录；也可以用 `--output <path>` 指定文件。\n- stdout 只返回状态对象：`command`、`status`、`output_mode`、`output_path`、`payload_bytes`、`summary`、`payload_schema`。全文永远不会内联到 stdout。\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\"]:\n    print(item.get(\"title\"), item.get(\"url\"))\n    print((item.get(\"raw_content\") or item.get(\"content\") or \"\")[:500])\n    print(\"---\")\nPY\n```\n\n几点提醒：\n\n- 用返回的**确切** `output_path` 打开文件，不要 glob 或按 mtime 猜最新文件。\n- 不要真的把整个 payload 无脑 print 出来——skill 明确警告：落盘文件不会自动保护 LLM 上下文窗口，要在代码里挑有界字段。\n- 退出码约定：单次 search 成功 0；API/写盘失败 1；参数用法错误（比如 `--stdout`）2。批量模式下部分失败仍返回 0（stderr 有警告），全部失败返回 1，用法错误 2。\n- 如果你只是想要「少一次落盘、直接看结果」，可以考虑 `--raw-content off` 减小体积，但那也仍然不会把全文打到 stdout。",
  "error": null,
  "opened": [
   "skill_tavily"
  ],
  "requested": [
   "skill_tavily"
  ],
  "opened_target": true,
  "prompt_tokens": 40423,
  "completion_tokens": 1309,
  "sec": 11.110382080078125,
  "cost": 0.01684885,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T26",
  "target": "zun_music",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "直接回答你的两个问题。\n\n## 1. 上限是多少\n\n**每四小节最多一处**，也就是 8 小节里**最多 2 处**强拍改动。\n\n规则原文：在 4/4 的每个强拍（第 1 拍和第 3 拍）上，编曲的音级（pitch class）必须等于**未改动的原始旋律**（`SOURCE`）在同一位置的音级。任何有意的偏离都必须逐条登记在 `skeleton_allow` 里，而 `skeleton_allow` 被硬性限制为**每四小节一条**。\n\n而且\"上限\"不等于\"配额\"——正常情况下一首 8 小节里**只有最后那个终止式**会用到这个额度。也就是说，默认预期是 1 处，2 处是天花板。\n\n## 2. 怎么才算合规\n\n合规 = 同时满足下面三条：\n\n1. **强拍骨架不动**：第 1、3 拍上，编曲音级 == `SOURCE` 音级。这是 `check()` 里的 skeleton check，自动跑。\n2. **每一处偏离都显式登记**：不能\"偷偷改\"，必须写进 `skeleton_allow`，且数量 ≤ 每四小节 1 条。\n3. **偏离的典型形态是终止式**：最标准的做法是最后落音改到小调主音（minor tonic），配合终止处的和声小调 V7。这就是参考实现里那个典型的 `skeleton_allow` 条目。\n\n## 3. 怎么验证\n\n```bash\npython scripts/render_example.py <tune> --midi-only   # 跑 check()，覆盖标准 1–2，失败非零退出\npython -m pytest tests/ -v                            # 对每个 example 跑标准 1–3\n```\n\n`check()` 会直接拒绝非 4/4 的拍号，也会在骨架检查不过时失败。\n\n## 4. 为什么不能多改\n\n这是踩过的坑，写在 Known pitfalls 里：把旋律改成平行小调、加密集装饰音、乐句中途转调，结果被听者评价为\"和原曲完全没关系\"。骨架检查就是为了强制这件事——**强拍不动，ZUN 的味道全部塞在骨架音之间**：\n\n- 3-3-2 切分（附点八分 + 附点八分 + 八分）替换原来的平铺四分音符\n- 长音提前半拍进入\n- 长音收尾加 16 分音符回旋（如 C–D–C–B–C）\n- 乐句之间用上行 16 分音符跑动连接，而不是留白\n- 终止处加小调色彩：和声小调 V7，旋律第 4 级音在该 V7 上形成 ♭9 张力\n\n所以你的操作空间是：**强拍上最多 2 处（每四小节 1 处），且最好只留 1 处给最后终止式；其余所有变化都放在弱拍和骨架音之间。**\n\n另外提醒一句：自动化检查只保证骨架合规，最终\"像不像 ZUN\"只有人听了才算数，不能只凭 `check()` 通过就宣布完成。",
  "error": null,
  "opened": [
   "zun_music"
  ],
  "requested": [
   "zun_music"
  ],
  "opened_target": true,
  "prompt_tokens": 31206,
  "completion_tokens": 1421,
  "sec": 11.462291955947876,
  "cost": 0.015533499999999999,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T28",
  "target": "skill_process_launcher",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "你遇到的是 Process Launcher 的**精确时间冲突保护**（exact-time collision guard）：当一次带 `delay_seconds` 或 `run_at` 的 `POST /run` 解析出的 `run_at` 与某个已存在的 pending/running 任务**完全相等**时，服务返回 HTTP 409，`detail.error = \"schedule_conflict\"`，并在 `detail.conflicts` 里列出每个冲突任务（id、label、command、status、run_at）。**409 时不会创建任何东西**。\n\n## 情况一：两个都要保留\n\n用同一个请求重发一次，加上 `\"confirm_conflict\": true`：\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_b.py\"], \"label\": \"job_b\", \"run_at\": \"2026-06-10T09:00:00-07:00\", \"confirm_conflict\": true}'\n```\n\n`confirm_conflict` 默认是 `false`，只有显式设为 `true` 才允许与同一 `run_at` 上已 pending/running 的任务并存。这样两条任务都会保留。\n\n## 情况二：用新的替换旧的\n\n先取消冲突的那条，再提交新任务：\n\n```bash\n# 1. 从 409 响应的 detail.conflicts 里拿到旧 job 的 id\ncurl -sf -X POST http://127.0.0.1:7997/scheduled/{old_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\", \"job_new.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` 过滤，拿到要取消的 `job_id`。\n- **改时间也会触发同样的保护**：`PATCH /scheduled/{job_id}` 把 `run_at` 挪到冲突时刻时同样返回 409，逃生口也是 `confirm_conflict: true`。\n- **只匹配精确相等的时间**：两条相隔几秒提交、延迟相近的任务不会冲突。所以如果你其实不介意先后顺序，把 `run_at` 错开一秒即可，不必动 `confirm_conflict`。\n- **只能改 pending 的任务**：running / completed / failed / cancelled / missed 的任务不能 PATCH，要替换只能对 pending 的旧任务走 cancel 路径。\n- **注意 `pid: 0` 是正常的**：延迟任务创建成功时响应里 `pid` 可能是 0、`output_file` 为 null，因为子进程还没启动。后续用响应里的 `scheduled_job_id` 做取消和审计。",
  "error": null,
  "opened": [
   "skill_process_launcher"
  ],
  "requested": [
   "skill_process_launcher"
  ],
  "opened_target": true,
  "prompt_tokens": 40129,
  "completion_tokens": 1438,
  "sec": 9.782456159591675,
  "cost": 0.01688215,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T29",
  "target": "skill_resend_email",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "不行，`--after`/`--before` 不能传 ISO 时间。\n\n在 `received list` 和 `received export-all-md` 上，`--after`/`--before` 是 **Resend 的分页游标（pagination cursor）**，不是时间过滤器。API 期望的是 UUID 游标值，传 ISO 时间戳会直接返回 validation error。\n\n## 正确的按日期筛选流程\n\n**1. 先自检配置**\n\n```bash\nop run --env-file=.env -- .venv/bin/python -m resend_email_skill.cli doctor config --format json\n```\n\n确认 API key、收件地址、数据目录都正常。\n\n**2. 导出一个足够大的近期页面到 Markdown**\n\n```bash\nop run --env-file=.env -- .venv/bin/python -m resend_email_skill.cli received export-all-md \\\n  --limit 100 \\\n  --output-dir data/received/markdown \\\n  --format json\n```\n\n`--limit` 要开得足够大，保证\"最近两天\"的邮件都落在这一页里（如果两天内邮件量可能超过 100，就调大）。\n\n**3. 在本地按 frontmatter 的 `created_at` 过滤**\n\n导出的每个 Markdown 文件头部都有 `created_at` frontmatter。用本地工具（`rg`、`find`、脚本等）按这个字段筛出最近两天的文件即可，例如：\n\n```bash\nrg -l 'created_at: 2025-0X-0Y' data/received/markdown\n```\n\n（把日期换成你要的两天区间，或写个小脚本按时间戳比较。）\n\n## 要点\n\n- 时间窗口筛选**只能在本地做**，不要在 CLI 参数上做。\n- 导出的 Markdown 和附件属于私有邮箱数据：不要提交 `data/received/`、附件、原始 MIME 或任何暴露个人邮件内容的衍生报告。\n- 如果只是要分析代表性邮件，别只读最新的——最新优先的样本会过度集中在重复通知和营销邮件上。",
  "error": null,
  "opened": [
   "skill_resend_email"
  ],
  "requested": [
   "skill_resend_email"
  ],
  "opened_target": true,
  "prompt_tokens": 35215,
  "completion_tokens": 911,
  "sec": 14.965076208114624,
  "cost": 0.01582885,
  "web_tools": [],
  "n_calls": 2
 },
 {
  "id": "T30",
  "target": "skill_typefully_twitter",
  "model": "deepseek-v4.1-flash",
  "condition": "index_fixed",
  "answer": "**默认用 long post（长帖）模式。**\n\n理由：文章分发帖（article distribution post）默认走 Typefully long post。\"一条帖子\"指的是一个 Typefully post，不是 280 字符的普通 X 推文。所以不要主动套 280 字限制。\n\n具体做法：\n\n1. **先确认账号能力。** 如果用户的 Typefully/X 账号能发长帖，在本地 `.env` 里设：\n   ```\n   TYPEFULLY_TWITTER_DEFAULT_LONG_POST=true\n   ```\n   设了之后，`post count`、`post draft`、`post create` 默认就按 long-post 校验，不用每次加参数。\n\n2. **如果环境默认没开**（未设或 false），就在命令上显式加 `--long-post`：\n   ```bash\n   typefully-twitter post count --thread-file thread.md --long-post\n   typefully-twitter post draft --thread-file thread.md --long-post\n   ```\n\n**用户明确要短推时，加 `--no-long-post` 切回普通 X 长度限制：**\n\n```bash\ntypefully-twitter post count --text \"...\" --no-long-post\ntypefully-twitter post draft --text \"...\" --no-long-post\n```\n\n触发切短推的信号是用户说了\"普通推文\"\"280 字内\"\"short tweet\"这类话，或者直接用了 `--no-long-post`。只有这时才施加 280 字符限制。\n\n另外两个提醒：\n\n- 普通长度校验用的是 X 的加权长度规则，不是可见字符数；URL 按 X 转换后的长度算。\n- 如果 `post count` 在分析类内容上返回 `TOO_LONG`，别急着删内容去凑长度——这通常说明内容本来就该走 long-post 模式，除非用户明确要单条推文摘要。",
  "error": null,
  "opened": [
   "skill_typefully_twitter"
  ],
  "requested": [
   "skill_typefully_twitter"
  ],
  "opened_target": true,
  "prompt_tokens": 39161,
  "completion_tokens": 804,
  "sec": 7.794888019561768,
  "cost": 0.01635655,
  "web_tools": [],
  "n_calls": 2
 }
]