dsh-tiddlywiki 0.28.13 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -2623,27 +2623,46 @@ function normalizePromptMode(value) {
2623
2623
  function escapePromptBraces(text) {
2624
2624
  return text.replace(/\{(?=\{)/g, "{​");
2625
2625
  }
2626
- /** Governance block: how to write without clobbering human edits. */
2627
- const WRITE_RULES = `### 写入与并发
2628
- - 覆盖或删除已有笔记前先 \`tiddlywiki_get\` 读一次,把读到的 \`modified\`(或 \`revision\`)作为 \`expectedModified\`(或 \`expectedRevision\`)传回去;若期间有人(在 TW 编辑器里)改过,写入会被拒绝并告诉你当前值——此时重新读一遍再决定,**不要用 \`force\` 硬覆盖**。\`tiddlywiki_attach\` 对同名已有条目默认拒绝写入,理由相同(要覆盖得显式 \`force: true\`)。
2629
- - 纯增量内容(日志、批注、清单)优先用 \`tiddlywiki_append\`,不必读全文。
2630
- - 覆盖既有条目时**不传 \`tags\` 就保留原有标签、自定义字段与内容类型**(只改正文);显式传 \`tags\` 才整体替换标签,要改内容类型用 \`fields.type\`。
2631
- - 内容类型:新笔记默认写成 Markdown(工具自动补 \`text/markdown\`,\`$:/\` 系统条目除外);覆盖或追加既有条目时保留它原有的 \`type\`。要写 TW 原生 wikitext 才显式传 \`fields: {"type":"text/vnd.tiddlywiki"}\`。⚠️ \`fields.type\` 是 TW 的**内容类型**保留字段,业务分类请放 \`tags\`。`;
2626
+ /**
2627
+ * Governance block: how to write without clobbering human edits.
2628
+ *
2629
+ * v0.29.0 — TRIMMED TO THE PART THE SCHEMAS CANNOT SAY.
2630
+ *
2631
+ * This block used to restate `tiddlywiki_put` / `append` / `batch_put` / `attach`
2632
+ * nearly sentence for sentence: tags/fields/type preservation, `tags: []` meaning
2633
+ * "clear", the markdown default for new notes, `fields.type` being TW's content
2634
+ * type. All of that is ALREADY in those tools' descriptions — which the model
2635
+ * receives on every request — and this section's own intro tells it that the
2636
+ * schemas are the contract. Two copies of one rule is not redundancy insurance,
2637
+ * it is a second thing to keep in sync (the exact drift that v0.21.0 removed).
2638
+ *
2639
+ * What is left is the one thing no schema states: after a refused write, read
2640
+ * again instead of forcing. `scripts/verify-prompt.mjs` now asserts the delegated
2641
+ * rules on the SCHEMA side, so deleting them here can never lose them silently.
2642
+ */
2643
+ const WRITE_RULES = `### 写入约定
2644
+ - 覆盖或删除已有笔记前先 \`tiddlywiki_get\` 读一次,并把读到的 \`expectedModified\`(或 \`expectedRevision\`)传回去;被拒绝说明刚有人(在 TW 编辑器里)改过——**重读一遍再决定,不要用 \`force\` 硬覆盖**。`;
2632
2645
  /** Governance block: the three-step git discipline + conflict recovery. */
2633
- const SYNC_RULES = `### 同步纪律(三条)
2634
- 1. 开工先 pull:\`tiddlywiki_git_sync action=pull\`(rebase + autostash;真冲突会自动 abort 并报冲突文件)。
2635
- 2. 收工 commit + push:\`tiddlywiki_git_sync action=sync\`(pull → commit → push)。
2636
- 3. 插件会自动防抖 commit(默认 60s),需要立刻同步时用上面的工具。
2637
- pull 冲突后:先 \`tiddlywiki_git_resolve files=[冲突文件] strategy=keep-local\`(保留本地)或 \`strategy=keep-remote\`(改用远端版本),再重新 pull/sync 整合其余改动。`;
2638
- /** Governance block: knowledge-base conventions (tags, memory, links). */
2646
+ const SYNC_RULES = `### 同步纪律
2647
+ - 开工先 \`tiddlywiki_git_sync action=pull\`,收工 \`action=sync\`(插件另会防抖自动 commit,默认 60s)。
2648
+ - pull 冲突:先 \`tiddlywiki_git_resolve files=[冲突文件] strategy=keep-local\`(保留本地)或 \`keep-remote\`,再重新 pull/sync 整合其余改动。`;
2649
+ /**
2650
+ * Governance block: knowledge-base conventions (tags, memory, links).
2651
+ *
2652
+ * v0.29.0 — the 「工作区标记自动打」 bullet and the 「检索先窄后宽 / 多词 AND」
2653
+ * bullet were REMOVED here because `tiddlywiki_put` and `tiddlywiki_search` already
2654
+ * document them verbatim (see verify-prompt.mjs, which now asserts them on the
2655
+ * schema side). What stays is what only this section can say: what a note IS for
2656
+ * (long-term memory), the todo convention, time-boxing/supersession fields, the
2657
+ * `human-edited` counterpart to the automatic `agent-written`, and the clickable
2658
+ * link format.
2659
+ */
2639
2660
  const NOTE_RULES = `### 笔记约定
2640
2661
  - wiki 是长期记忆:会议纪要、决策记录、调研笔记、随手的想法都存成独立 tiddler(tag 用 inbox/meeting/decision 等便于检索)。
2641
- - **值得做但不在当前范围内的想法**:用 \`tiddlywiki_put\` 写成独立 tiddler、打 \`todo\`,正文简述来源(会话 / 工作区 / 项目背景),由用户决定是否继续。
2642
- - **工作区标记自动打**:新建笔记自动带 \`ws/<项目名>\` 标签与 \`workspace\` 字段(取自会话工作目录),**不要手动再加**;只有内容显然属于**另一个**项目时才显式写 \`workspace\` 字段。
2643
- - **检索先窄后宽**:\`tiddlywiki_search\` 先在工作区内查、没命中才自动扩到全库,回执写明实际范围——**「工作区内 0 条」不等于库里没有**。查询按空白切词、**全部词命中**才算(AND)。
2644
- - **阶段性内容标时效**:会过期的笔记(版本记录 / 部署步骤 / 排期 / 临时方案 / 一次性口令)写时带 \`valid-until: YYYY-MM-DD\`(硬过期)或 \`review-after: YYYY-MM-DD\`(该复查);被取代时写 \`superseded-by: [[新笔记]]\` 并打 \`superseded\` 标签、**保留旧笔记**。**淘汰只由人决定**,不要自行删除。
2662
+ - **值得做但不在当前范围内的想法**:写成独立 tiddler、打 \`todo\`,正文简述来源(会话 / 工作区 / 项目背景),由用户决定是否继续。
2663
+ - **阶段性内容标时效**:会过期的笔记带 \`valid-until: YYYY-MM-DD\`(硬过期)或 \`review-after: YYYY-MM-DD\`(该复查);被取代时写 \`superseded-by: [[新笔记]]\` 并打 \`superseded\` 标签、**保留旧笔记**。**淘汰只由人决定**,不要自行删除。
2645
2664
  - \`agent-written\` 由工具自动补打,别手动加或删;人类编辑过 Agent 笔记后补 \`human-edited\`。
2646
- - **引用笔记用可点击链接**:\`[标题](/dsh-tiddlywiki/tw/#标题)\`(空格等特殊字符做 URL 编码;中文可直写)。点击会打开中央 TW 面板并跳转,优先用它代替纯文本标题。`;
2665
+ - **引用笔记用可点击链接**:\`[标题](/dsh-tiddlywiki/tw/#标题)\`(空格等特殊字符做 URL 编码;中文可直写)。点它会打开中央 TW 面板并跳转,优先用它代替纯文本标题。`;
2647
2666
  /**
2648
2667
  * Publish-metadata rule, appended ONLY when `wechat.enabled` is on (v0.23.0).
2649
2668
  *
@@ -2674,11 +2693,20 @@ function toolSignatureLines(tools, bullet = "-") {
2674
2693
  return `${bullet} \`${tool.name}\`(${params})`;
2675
2694
  });
2676
2695
  }
2677
- /** Heading + a one-line capability pointer (slim intro). */
2696
+ /**
2697
+ * Heading + a one-line capability pointer (slim intro).
2698
+ *
2699
+ * v0.29.0 — the hand-written CATEGORY LIST ("检索/读写/批量/增量追加/重命名…") is
2700
+ * gone. Only the COUNT was derived from the live registry, so adding or renaming a
2701
+ * tool silently made that sentence wrong, and `verify-prompt.mjs` could not see it
2702
+ * (it asserts there is no signature catalogue, not that this list is current). The
2703
+ * tools themselves are described in full by their schemas, which the model always
2704
+ * has; a second, unmaintainable summary of them is exactly what v0.21.0 removed.
2705
+ */
2678
2706
  function slimIntro(count) {
2679
2707
  return `## TiddlyWiki 持久知识库
2680
2708
 
2681
- 本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。插件提供 ${count} 个 \`tiddlywiki_*\` 工具:检索/读写/批量/增量追加/重命名/删除与回收站/反向链接/附件/体检/git 同步与冲突解决。**参数与返回契约以各工具 schema 的 description 为准**,本段只补充 schema 表达不了的约定。`;
2709
+ 本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。插件提供 ${count} 个 \`tiddlywiki_*\` 工具:**参数与返回契约以各工具 schema 的 description 为准**,本段只补充 schema 表达不了的约定。`;
2682
2710
  }
2683
2711
  /** Heading + the generated signature catalogue (full intro). */
2684
2712
  function fullIntro(tools) {
@@ -3563,7 +3591,7 @@ const WECHAT_DOCS_MARKER_TITLE = "$:/plugins/dsh-tiddlywiki/seed-wechat-docs";
3563
3591
  * registry's `content` declaration re-exports the same constant, so the
3564
3592
  * v0.22.0 content-hash bookkeeping guards exactly these bytes).
3565
3593
  */
3566
- const WECHAT_DOCS_TEXT = "# 微信公众号发布:换机器还原指南\n\n> **这是可选功能,默认关闭,需要额外安装。** 插件本体**不含**它:真正干活的是仓库\n> `tools/wechat/` 下的 opencli adapter + Browser Bridge 浏览器扩展。**不安装/不开启,\n> 插件的其他功能完全不受影响**(不注入发布相关提示词、不往 wiki 写发布规范文档)。\n>\n> 目的:在**另一台机器**上无缝还原「TiddlyWiki 笔记 → 微信公众号」的发布能力。\n> 本机(开发机)已验证全程可跑;本文按顺序照做即可复现。\n>\n> 关键结论先讲:**整套发布走浏览器自动化,不依赖公众号服务端 API**——因为\n> 2025-07 起官方已回收个人主体账号的「发布能力」接口权限(详见 §5)。\n\n---\n\n## 0. 它是可选功能:需要什么、不影响什么\n\n| | 说明 |\n|---|---|\n| **默认状态** | **关闭**(`wechat.enabled` 默认 `false`) |\n| **开启方式** | DSH 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」→ 勾选 → 保存配置 |\n| **开启后有什么变化** | ① 注入提示词多一行「发布前先读发布元数据规范」;② 启动时把该规范文档写进 wiki(同名不覆盖)。**仅此两项**——不会安装任何东西、不会启用任何后台服务 |\n| **不开启会怎样** | 提示词里没有发布相关文字;wiki 里不会出现「发布元数据规范」;其他功能一切照旧 |\n| **插件会替你做安装吗** | **不会**。opencli 与浏览器扩展必须你手动装(见 §1–§2)——这属于插件外部的工具链 |\n| **要额外装什么** | ① opencli(npm 包)② Browser Bridge 浏览器扩展 ③ 浏览器登录公众号 |\n| **凭据如何保存** | 不保存任何凭据:复用浏览器已有登录态(cookie)。脚本不接触 AppSecret |\n\n> 一句话:**开关只控制「插件要不要配合这个流程」,不负责装工具链。**\n\n---\n\n## 1. 一分钟总览\n\n```\nTiddlyWiki 笔记(tiddler)\n │ 标题\n ▼\nDSH 的 POST /dsh-tiddlywiki/render ← TW 自己渲染成语义 HTML(含代码高亮)\n │ 纯语义 HTML(无内联样式)\n ▼\nwechat-html.js decorate() ← 补内联样式(微信唯一认的形式)\n │ 带内联样式的 HTML\n ▼\nopencli + Browser Bridge 扩展 ← 驱动你已登录的浏览器\n │ 填标题/作者/摘要 + insertHTML 写正文 + 传图 + 设封面 + 存草稿\n ▼\n公众号草稿箱(mp.weixin.qq.com)\n │ (可选 --publish)\n ▼\n点「发表」→ ⚠️ 管理员扫码确认 → 发布成功\n```\n\n**一条命令**(在第二台机器上,装好后):\n\n```bash\nopencli weixin publish-note \"笔记标题\" --trace retain-on-failure -f json\n```\n\n---\n\n## 1. 需要安装/准备的东西\n\n按顺序做;**最后一步才是打开插件里的开关**(开关只影响提示词与文档,装不上工具链)。\n\n| # | 项目 | 怎么装 | 验证 |\n|---|---|---|---|\n| 1 | **Node.js ≥ 22** | 官网安装包 | `node -v` |\n| 2 | **DSH** + **dsh-tiddlywiki 插件** | 本仓库的插件(含 wiki) | `curl http://127.0.0.1:3080/dsh-tiddlywiki/status` |\n| 3 | **opencli** | `npm install -g @jackwener/opencli` | `opencli --version` |\n| 4 | **Browser Bridge 扩展** | 见 §2(二选一) | `opencli doctor` 显示 `Extension: connected` |\n| 5 | **Chrome/Edge + 公众号登录** | 浏览器登录 mp.weixin.qq.com | 能进后台首页 |\n| 6 | **本仓库的 adapter** | `node tools/wechat/install-wechat-adapters.mjs` | 脚本自检输出 ✔ |\n| 7 | **打开插件开关** | 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」勾选 → 保存配置 | 提示词预览里出现「发布元数据规范」一行 |\n\n> **opencli 版本建议 ≥ 1.8.7**(本方案在该版本实测通过;1.7.x 的 `browser`\n> 子命令语法不同,且内置 weixin adapter 不完整)。\n>\n> **新版 npm 的 allowScripts 机制会拦 opencli 的 postinstall 脚本**(安装时打\n> warning):**无害,可忽略**——postinstall 只装 bash/zsh/fish 补全(Windows 用不上),\n> adapters 随 npm 包自带,功能不受影响。\n>\n> 第 7 步之前,插件不会注入任何发布相关提示词、也不会往 wiki 写「发布元数据规范」。\n> 也就是说:**没装好工具链时开关可以一直关着,不影响任何其他功能。**\n\n### 自动化的终点:草稿箱(2026-09-17 与用户确认的策略)\n\n**adapter 负责到「草稿落盘」为止**:填标题/作者/摘要、写正文、传图、设封面、存草稿。\n**发表由人工在后台完成**——点「发表」时平台会弹确认对话框(原创声明、作者、留言\n设置等)以及最后的管理员扫码;原创声明涉及账号权益,人工点最稳妥。`--publish`\n选项保留(best-effort:点「发表」后轮询等扫码),但**不处理中途弹窗**,遇到弹窗\n会一直等到超时。\n\n---\n\n## 2. 安装 Browser Bridge 扩展(关键前置)\n\n扩展**不在 npm 包里**,必须单独装。二选一:\n\n**方式 A — Chrome Web Store(推荐)**\n1. 打开 https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n2. 点「添加至 Chrome」\n\n**方式 B — GitHub Releases(Web Store 打不开时)**\n1. 到 https://github.com/jackwener/opencli/releases 下载 `opencli-extension-v*.zip`\n2. 解压到某个固定目录\n3. 浏览器打开 `chrome://extensions` → 开启右上角「开发者模式」\n4. 点「加载已解压的扩展程序」→ 选择解压出的目录\n\n> ⚠️ GitHub 的 ext release **经常落后于 Web Store**(2026-09-17 实测只到\n> `ext-v1.0.21`,Web Store 已是 1.0.24)。**扩展版本以 Chrome Web Store 为准**;\n> GitHub 方式只作 Web Store 打不开时的离线兜底。\n\n**验证**:\n\n```bash\nopencli doctor\n# 期望: [OK] Daemon: running ...\n# [OK] Extension: connected (vX.Y.Z)\n# [OK] Connectivity: connected\n# Everything looks good!\n```\n\n若报 `Extension: not connected`:确认浏览器在运行、扩展已启用;再 `opencli daemon stop` 后重跑 `opencli doctor`(daemon 会自动重启)。\n\n---\n\n## 3. 装 adapter 并试跑\n\n```bash\ncd <本仓库>\nnode tools/wechat/install-wechat-adapters.mjs\n```\n\n脚本会把 `tools/wechat/*.js` 复制到 `~/.opencli/clis/weixin/`(Windows:`C:\\Users\\<你>\\.opencli\\clis\\weixin\\`)。\n这些是**私有 adapter**,放在该目录即可被 opencli 自动发现,**无需任何构建**。\n\n然后:\n\n```bash\n# ① 先干跑:只做 TW 渲染 + 排版装饰,导出预览,不碰微信\nopencli weixin publish-note \"某篇笔记标题\" --preview ./out --trace retain-on-failure\n\n# → 用浏览器打开 ./out.html 肉眼确认排版\n# (注意:--preview 仍会继续执行发布流程;只想预览时看文件即可,或 Ctrl-C)\n\n# ② 真发到草稿箱\nopencli weixin publish-note \"某篇笔记标题\" --trace retain-on-failure -f json\n\n# ③ 带封面(公众号封面必填,否则草稿显示\"内容不完整\")\nopencli weixin publish-note \"某篇笔记标题\" --cover ./cover.png --trace retain-on-failure -f json\n\n# ④ 直接发表(⚠️ 会真发文章,需管理员扫码)\nopencli weixin publish-note \"某篇笔记标题\" --publish --trace retain-on-failure -f json\n\n# ⑤ 正文多图(v0.23.2+):TW 笔记内嵌 [img[...]] 经 /render 变成 data URI,\n# publish-note 传不了(微信存草稿时过滤非 mmbiz 图)——用 publish-note-imgs:\nopencli weixin publish-note-imgs \"某篇笔记标题\" --images <图片目录> --trace retain-on-failure -f json\nopencli weixin publish-note-imgs \"某篇笔记标题\" --images \"01.png|02.png|03.png\" -f json\n# · --images 传目录时按文件名排序 = 正文图片出现顺序(封面图排第一)\n# · 也可用 | 分隔的路径列表精确指定顺序\n# · 流程:先逐张上传 CDN → 按 DOM 顺序重写正文 src → insertHTML → 选封面 → 存草稿\n# · 发表前同样读 pub-state / no-publish(只告警不阻断),--publish 可直接发表\n```\n\n### 3.6 在 TiddlyWiki 里点按钮发布(v0.23.3,日常推荐)\n\n不想敲命令时,用**笔记工具栏的「发布到公众号」按钮**(嵌入式 TW 面板、右侧栏 tab、原生编辑弹窗里都在):\n\n1. 设置页勾选「可选功能:微信公众号发布」并保存 → 重启 dsh web 后,启动 seed 会把按钮插件\n `$:/plugins/dsh/wechat-publish` 写进 wiki(老 wiki 也可在设置页「初始化」区单独写它)。\n2. 打开任意笔记 → 工具栏点「发布到公众号」→ 按钮**先预检**(opencli 跑不跑得起来、adapter 缺哪些文件),\n 再弹确认框(自动列出 `no-publish` / `pub-state` 警告)→ 点「开始存草稿」。\n3. 宿主进程起一个**后台任务**,覆盖层每 2 秒显示状态与日志尾部。**只到草稿箱为止,不自动发表。**\n\n按钮背后的三条路由(同源;写操作有方法 + CSRF 守卫;`wechat.enabled` 关着时一律 403):\n\n| 路由 | 方法 | 作用 |\n|---|---|---|\n| `/dsh-tiddlywiki/wechat/ready` | GET | 预检:`opencli --version` 能否跑通、adapter 缺哪些文件 |\n| `/dsh-tiddlywiki/wechat/publish` | POST | 起任务(body `{title, adapter?}`);单并发,第二次调用 409 |\n| `/dsh-tiddlywiki/wechat/publish/status` | GET | 轮询任务(`?id=`;不带 id 取最新/正在跑的那个) |\n\n可配项(设置页目前只暴露 `enabled`,其余写进配置 tiddler\n`$:/plugins/dsh-tiddlywiki/config` 的 `wechat` 块,保存即生效):\n\n| 键 | 默认 | 说明 |\n|---|---|---|\n| `wechat.enabled` | `false` | 总开关;关着时按钮不写入、三条路由 403 |\n| `wechat.adapter` | `publish-note` | 换成 `publish-note-imgs` 走正文内嵌多图(需该 adapter 已装) |\n| `wechat.command` | `opencli` | CLI 路径(装了别名、不在 PATH 时用) |\n| `wechat.token` | 空 | 非空时要求请求头 `x-wechat-publish-token`(按钮会自动带上) |\n| `wechat.dsn` | 空 | adapter 回连 DSH 的基址;留空按请求端口推导 `http://127.0.0.1:<端口>/dsh-tiddlywiki` |\n| `wechat.endpoint` | 空 | **只被 TW 侧读**:覆盖按钮请求的基址(默认 `location.origin + /dsh-tiddlywiki`);反向代理/远程访问场景用 |\n\n⚠️ 宿主用 **`--title-file`**(v0.23.3 新增参数)把标题经 UTF-8 文件交给 adapter:标题不进 argv,\n否则 Windows 上 `opencli` 的 `.cmd` shim 会把 `&`/`|`/`^` 当命令分隔符,中文标题还会被 cmd 的\n代码页解码成乱码。位置参数 `<title>` 照旧可用(两者都给时位置参数优先)。\n⚠️ 按钮**不替你发表**:发表要管理员扫码(§4.2),请在公众号后台点「发表」。\n\n---\n\n## 3.5 发布元数据(重要:避免重发/误发)\n\nwiki 里既有**早已发布**的文章,也有**明确不能发**的内容。没有记录,agent 只能猜——\n要么重复发已上线的,要么把不该发的推出去。\n\n**规范全文在 wiki 里**:笔记「**发布元数据规范**」(随插件 seed 分发,`dsh-docs` 标签,\n开机自动写入;也可手动跑 `npx tsx tools/wechat/seed-publish-spec-now.mts` 立即写入)。\n注入给 agent 的系统提示词里只有一句指针(slim 预算只剩几十字符,放不下全文)。\n\n### 字段(自定义字段,不是标签)\n\n| 字段 | 含义 | 示例 |\n|---|---|---|\n| `pub-state` | 能不能发 | `draft` / `published` / `excluded` |\n| `pub-platform` | 最近发布的平台(多平台逗号分隔) | `wechat` |\n| `pub-wechat-at` | 公众号发表时间 | `2026-09-17 15:30` |\n| `pub-wechat-title` | 发表时的标题(**仅当与 TW 标题不同才写**) | `中年失业自救指南` |\n| `pub-wechat-url` | 发表后的链接 | `https://mp.weixin.qq.com/s/xxx` |\n| `pub-note` | 备注 | `删改后重发过一版` |\n\n**没有 `pub-state` = 未知状态**,应当当作「需要人确认」,而不是「可以随便发」。\n\n标签 `no-publish` 是给人看的镜像(TW 界面一眼可见、可筛);`pub-state: excluded` 给\nagent 程序化判断。**两者应一致,冲突时以字段为准。**\n\n### ⚠️ 命名冲突(别占用)\n\n实测本 wiki 里这三个名字**已被占用**,方案特意避开:\n\n- `publish` / `publishyear` 字段 → Obsidian 导入的**书籍**条目用作「出版社 / 出版年」\n (值形如「新世界出版社」「2011-09-01」)。\n- `发布记录` 标签 → 本插件自己的**版本发布说明**。\n\n### 发布前检查:只告警,不阻断\n\n`publish-note` 会读 `pub-state` / `no-publish`,命中就打印醒目警告,**然后继续执行**\n(2026-09-17 明确选定的策略)。`--force` 只改措辞,不改变行为:\n\n```\n⚠️ 「某篇」已发布过(平台 wechat)。这可能是重复发布——确认无误再继续;确实要重发请加 --force。\n```\n\n### 回写(agent 负责)\n\n发布成功后,命令的 `detail` 里会带一句**建议回写值**,例如:\n\n```\n建议回写:pub-state=published, pub-platform=wechat, pub-wechat-at=\"2026-09-17 17:50\"\n```\n\nagent 据此用 `tiddlywiki_put` 写回(**不传 `tags` 就保留原标签**,只补 `fields`):\n\n```\ntiddlywiki_put(title=\"某篇\", text=<原正文>, fields={\"pub-state\":\"published\", \"pub-platform\":\"wechat\", \"pub-wechat-at\":\"...\"})\n```\n\n> ⚠️ 用工具时 `text` 会被覆盖,**务必带上原正文**。别像我一样用裸 REST PUT 只发一段\n> 文字——那会把笔记正文清空(真实踩过,靠 git 恢复)。\n\n### 存量回填\n\n早于本规范、来源可辨(`source-path` 含「公众号」)的文章,用脚本回填:\n\n```bash\n# 默认 dry-run,只打印将改什么\nnode tools/wechat/backfill-publish-state.mjs\n\n# 确认后真写\nnode tools/wechat/backfill-publish-state.mjs --write\n\n# 指定标题 / 平台 / 匹配子串\nnode tools/wechat/backfill-publish-state.mjs --title \"打工记\" --title \"鱼\" --write\nnode tools/wechat/backfill-publish-state.mjs --match 公众号 --platform wechat --write\n```\n\n脚本纪律(**请勿改成「只写字段」的裸 PUT**):每条都先 GET **完整** tiddler(含\ntext/tags/type/created),**只添加** `pub-*` 字段后整体写回;已有 `pub-state` 的跳过\n(幂等);正文为空的跳过(宁可不动);**发表时间无法考证就留空,不编造**。\n\n---\n\n## 4. ⚠️ 三个必须知道的坑\n\n### 4.1 所有命令都要带 `--trace retain-on-failure`\n\n不带会对 `mp.weixin.qq.com` 稳定报:\n\n```\nNavigation rejected\n```\n\n**实测**:trace 开 → 5/5 成功;trace 关 → 8/8 失败。这是 opencli 1.8.7 的 bug\n(源码里 trace 只做录像,理论上不该影响导航)。已试过无效的绕法:\n`--site-session ephemeral`、`--keep-tab false`、`--window foreground/background`、\n重置标签页、重启 daemon。**唯一有效就是加 trace。**\n\n### 4.2 发表必须管理员扫码(无法自动化)\n\n后台点「发表」后,微信要求**管理员微信扫码确认**。这不是技术能绕开的账号安全机制。\n所以「一键发布」的真实含义是:\n\n> agent/脚本 完成素材、排版、上传、填表、进草稿箱 —— **你只需扫一次码**。\n\n`--publish` 会自动点「发表」并在终端提示你扫码,然后轮询结果(默认等 180s,\n可用 `--timeout` 调整)。\n\n### 4.3 「发表」≠「群发」(别搞混)\n\n| 操作 | 推送粉丝 | 占群发额度 | 需要扫码 |\n|---|---|---|---|\n| **发表**(本方案默认) | 否(仅生成永久链接) | 否,不限次数 | 是 |\n| **群发** | 是 | 个人订阅号 1 天 1 次 | 是 |\n\n两者都要扫码 → 默认选**不吃额度**的「发表」。\n\n### 4.4 发表阶段的确认弹窗 adapter 不处理(v0.23.x 现状)\n\n`publishDraft` 点完「发表」后**只轮询成功信号**,不会点任何平台弹窗。发表链路上\n还有:原创声明/作者/留言设置等**确认对话框**(2026-09-17 人工发表实测必经)与最后\n的管理员扫码。所以**推荐流程到草稿箱为止**(见 §1),发表环节人工做;`--publish`\n只是 best-effort,遇到弹窗会卡到超时。排错见 §8。\n\n---\n\n## 5. 为什么不用官方 API(背景,避免走回头路)\n\n公众号发布的服务端链路是 `access_token → draft/add → freepublish/submit`。但:\n\n- **2025 年 7 月起,官方回收「发布能力」接口对个人主体、企业未认证、不支持认证账号的调用权限**\n ([发布能力文档](https://developers.weixin.qq.com/doc/subscription/guide/product/publish.html) 原文注)。\n 个人主体无法做微信认证 → `freepublish/submit` 不可用。\n- 草稿箱 `draft/add` 文档未列该限制,但 48001 / `api unauthorized` 是常见返回。\n- 即便可用,还叠加:**本机公网出口 IP 必须加入 API IP 白名单**(否则 61004/40164)、\n 封面 `thumb_media_id` 必须是**永久素材**、正文图片必须是 `media/uploadimg` 产出的\n mmbiz 地址(外链图被过滤)。\n\n**而后台网页端从未受影响**——这是所有个人号日常发文的方式。所以本方案驱动网页端。\n\n---\n\n## 6. 文件清单(本方案包含什么)\n\n```\ntools/wechat/\n├── wechat-html.js # 排版装饰器:语义 HTML → 内联样式 HTML(主题表在这里改)\n├── weixin-flow.js # 浏览器流程:登录检测/填表/写正文/传图/设封面/存草稿/发表\n├── create-article.js # 命令:从本地 HTML 文件建草稿(可选发表)\n├── publish-note.js # 命令:从 TW 笔记建草稿(主入口,含 --preview;单图 --cover)\n├── publish-note-imgs.js # 命令:多图版——正文内嵌 data URI 图全部上传 CDN 再发布\n└── install-wechat-adapters.mjs # 一键安装到 ~/.opencli/clis/weixin/\n```\n\n装到本机后对应 `~/.opencli/clis/weixin/` 下的同名文件。\n改排版只需改 `tools/wechat/wechat-html.js` 里的 `ELEMENT_STYLES`,重跑安装脚本即可。\n\n---\n\n## 7. 技术实现要点(改代码前必读)\n\n### 7.1 正文写入必须用 `insertHTML`,不能用 `insertText`\n\n微信编辑器是 **ProseMirror**。`document.execCommand('insertHTML', …)` 保留内联样式;\n`insertText` 会把 HTML/Markdown 当**字面文本**(opencli 内置的 `weixin create-draft`\n就是这样——实测后台 digest 里 `# 一级标题` 和 `**加粗**` 的符号原样保留)。\n\n### 7.2 图片上传必须用 DataTransfer,不能用 `page.setFileInput`\n\n`page.setFileInput` 依赖 CDP 的 `Page.fileChooserOpened` 事件,本机(opencli 1.8.7 +\n扩展 v1.0.24 + Edge)**稳定失败**:\n\n```\nPage.fileChooserOpened not received within 5s\n```\n\n([issue #1582](https://github.com/jackwener/OpenCLI/issues/1582) 显示扩展/CLI 版本不匹配确有历史问题。)\n\n**绕过办法**——在页面上下文用 DataTransfer 直接把 File 塞进 `input.files`:\n\n```js\nconst bin = atob(base64)\nconst arr = new Uint8Array(bin.length)\nfor (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)\nconst file = new File([arr], 'cover.png', { type: 'image/png' })\nconst dt = new DataTransfer()\ndt.items.add(file)\ninput.files = dt.files\ninput.dispatchEvent(new Event('change', { bubbles: true }))\n```\n\n实测图片真的上了 `mmbiz.qpic.cn`。**代价:字节要以 base64 穿过 evaluate → 单图限 8MB。**\n\n### 7.3 微信编辑器会剥 `<style>` 和 class,只认内联 `style`\n\n实测:注入 `<style>h1{color:red}</style>` → `hasStyleTag: false`;\n注入 `style=\"font-size:20px\"` → 原样保留。\n而 **TiddlyWiki 的 `/render` 输出零内联样式**(纯语义 HTML + class)——\n这正是需要 `wechat-html.js` 装饰器的原因。\n\n### 7.4 轮询里绝不能读 `document.body.innerText`(v0.23.0 实测踩坑)\n\n微信编辑器 DOM 极大,读一次 `body.innerText` **强制整页 layout,实测单次 evaluate\n≈17 秒**。早期 `saveDraft` / `publishDraft` 的轮询用它找成功提示,8 次轮询把整个命令\n拖过 210s 超时——**最坑的是草稿其实已经保存成功**,用户看到的是超时失败(假阴性)。\n\n现在只查少量 toast 节点(`.weui-desktop-toast` / `.weui-desktop-msg` / `#js_save_success`)\n和 URL,都是 O(1) 操作。修复后 `saveDraft` 从 **137 秒降到 3.9 秒**。\n\n`scripts/verify-wechat-adapters.mjs` 有回归守门(已反向验证:塞回去即红)。\n\n### 7.5 其他踩过的坑\n\n- 图片下拉必须**按文案**点「本地上传」:用 `items[0]` 点完 file input 仍不可见。\n- 正文是编辑器页**最后一个** `contenteditable`。\n- 回读校验必须**忽略所有空白**:编辑器 innerText 会在块级元素间插空白、把空格转 nbsp,\n 直接用原始字符串比对必假阴性。\n- 保存按钮的文案兜底**特意不含「发表/发布」**,防止误点发布。\n- 草稿列表显示「图文内容不完整」**只表示缺封面**,不代表写入失败。\n- `<pre>` 内的 `<code>` 不能套用行内代码样式(粉底 + 内边距会很难看),\n `wechat-html.js` 里用 `preDepth` 特判。\n\n---\n\n## 8. 排错\n\n| 现象 | 原因 / 处理 |\n|---|---|\n| `Navigation rejected` | 忘了 `--trace retain-on-failure` |\n| `AUTH_REQUIRED` / 提示登录 | 浏览器里 `mp.weixin.qq.com` 登录态过期 → 重新扫码登录 |\n| `Extension: not connected` | 扩展没装/没启用,或浏览器没运行 → 见 §2 |\n| `Page.fileChooserOpened not received` | 不应再出现(已改用 DataTransfer);若出现说明源码被改回 setFileInput |\n| 找不到笔记 | tiddler 标题要**完全精确**(含空格/标点);`opencli weixin publish-note \"标题\"` |\n| `/render 返回 HTTP 404` | 标题不存在;先用 `tiddlywiki_search` 或 TW 面板确认 |\n| 无法连接 DSH | `--dsn` 默认 `http://127.0.0.1:3080/dsh-tiddlywiki`;DSH 没跑或端口不同就改它 |\n| 草稿显示「内容不完整」 | 缺封面 → 传 `--cover`(或用 publish-note-imgs 从正文第一张自动设) |\n| 图片没上传 | 单图 > 8MB(DataTransfer 限制)→ 先压缩 |\n| 发表卡住超时 | 没扫码,或扫码没完成 → 调大 `--timeout`;若卡在原创声明/留言等确认弹窗 → 推荐流程本就到草稿箱为止,到后台人工点发表(见 §1、§4.4) |\n| `stale page identity` | 命令执行中标签页被手工关闭/导航了。**草稿不丢**——重开编辑页续作:`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&type=77&appmsgid=<id>&idx=0&token=<token>`(token 用 `opencli browser wx eval 'location.href.match(/token=(\\d+)/)[1]'` 从后台首页取) |\n| 封面警告「设置失败」但草稿其实有封面 | 已知**误报**(校验时机太早);以草稿箱实际显示为准(2026-09-17 实测:`list_ex` 接口 `cover` 字段已是 mmbiz 地址)。v0.23.2 起改为轮询校验 |\n| 用 eval 诊断后台状态时读到「未实名」「未设置头像和名称」等提示 | ⚠️ 后台 DOM 里常残留**不可见的历史 toast 节点**——查询必须过滤可见性(`offsetHeight > 0`),勿把残留文案当实时状态(2026-09-17 踩过:据此误判账号被平台拦截,实际账号正常并成功发表含原创声明)。同理,判断原创声明是否生效以草稿箱/发表记录为准,勿只看侧栏文案 |\n| 需要核实草稿是否落盘 | 后台 ajax:`/cgi-bin/appmsg?action=list_ex&type=77&orderby=create_time&token=<token>&f=json&begin=0&count=5`(在 mp.weixin.qq.com 页面上下文执行;`cover`/`digest`/`update_time` 一目了然);发表记录:`/cgi-bin/appmsgpublish?sub=list&...&f=json`(`publish_page.publish_list[].publish_info` 里是 JSON 字符串,含 `content_url`) |\n\n---\n\n## 9. 安全与隐私\n\n- **不存任何公众号凭据**:全程复用浏览器已有登录态(cookie),脚本不接触 AppSecret。\n- **不落盘密钥**:adapter 只读本地图片路径,不写任何 token。\n- **低频使用**:不做规避风控的行为伪装;默认走「发表」(不推送粉丝、不占额度)。\n- wiki 里的配置 tiddler 与本方案无关——本方案零配置。\n\n---\n\n## 10. 参考\n\n- [opencli](https://github.com/jackwener/opencli) — 把网站变成 CLI,复用浏览器登录态\n- [微信「发布能力」文档(2025-07 权限回收)](https://developers.weixin.qq.com/doc/subscription/guide/product/publish.html)\n- [微信「新增草稿」draft/add](https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_add.html)\n- [微信服务端 API 调用说明(IP 白名单)](https://developers.weixin.qq.com/doc/subscription/guide/dev/api/)\n- 设计文档:`docs/plans/2026-09-17-wechat-publish-design.md`\n";
3594
+ const WECHAT_DOCS_TEXT = "# 微信公众号发布:换机器还原指南\n\n> **这是可选功能,默认关闭,需要额外安装。** 插件本体**不含**它:真正干活的是仓库\n> `tools/wechat/` 下的 opencli adapter + Browser Bridge 浏览器扩展。**不安装/不开启,\n> 插件的其他功能完全不受影响**(不注入发布相关提示词、不往 wiki 写发布规范文档)。\n>\n> 目的:在**另一台机器**上无缝还原「TiddlyWiki 笔记 → 微信公众号」的发布能力。\n> 本机(开发机)已验证全程可跑;本文按顺序照做即可复现。\n>\n> 关键结论先讲:**整套发布走浏览器自动化,不依赖公众号服务端 API**——因为\n> 2025-07 起官方已回收个人主体账号的「发布能力」接口权限(详见 §5)。\n\n---\n\n## 0. 它是可选功能:需要什么、不影响什么\n\n| | 说明 |\n|---|---|\n| **默认状态** | **关闭**(`wechat.enabled` 默认 `false`) |\n| **开启方式** | DSH 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」→ 勾选 → 保存配置 |\n| **开启后有什么变化** | ① 注入提示词多一行「发布前先读发布元数据规范」;② 启动时把该规范文档写进 wiki(同名不覆盖)。**仅此两项**——不会安装任何东西、不会启用任何后台服务 |\n| **不开启会怎样** | 提示词里没有发布相关文字;wiki 里不会出现「发布元数据规范」;其他功能一切照旧 |\n| **插件会替你做安装吗** | **不会**。opencli 与浏览器扩展必须你手动装(见 §1–§2)——这属于插件外部的工具链 |\n| **要额外装什么** | ① opencli(npm 包)② Browser Bridge 浏览器扩展 ③ 浏览器登录公众号 |\n| **凭据如何保存** | 不保存任何凭据:复用浏览器已有登录态(cookie)。脚本不接触 AppSecret |\n\n> 一句话:**开关只控制「插件要不要配合这个流程」,不负责装工具链。**\n\n---\n\n## 1. 一分钟总览\n\n```\nTiddlyWiki 笔记(tiddler)\n │ 标题\n ▼\nDSH 的 POST /dsh-tiddlywiki/render ← TW 自己渲染成语义 HTML(含代码高亮)\n │ 纯语义 HTML(无内联样式)\n ▼\nwechat-html.js decorate() ← 补内联样式(微信唯一认的形式)\n │ 带内联样式的 HTML\n ▼\nopencli + Browser Bridge 扩展 ← 驱动你已登录的浏览器\n │ 填标题/作者/摘要 + insertHTML 写正文 + 传图 + 设封面 + 存草稿\n ▼\n公众号草稿箱(mp.weixin.qq.com)\n │ (可选 --publish)\n ▼\n点「发表」→ ⚠️ 管理员扫码确认 → 发布成功\n```\n\n**一条命令**(在第二台机器上,装好后):\n\n```bash\nopencli weixin publish-note \"笔记标题\" --trace retain-on-failure -f json\n```\n\n---\n\n## 1. 需要安装/准备的东西\n\n按顺序做;**最后一步才是打开插件里的开关**(开关只影响提示词与文档,装不上工具链)。\n\n| # | 项目 | 怎么装 | 验证 |\n|---|---|---|---|\n| 1 | **Node.js ≥ 22** | 官网安装包 | `node -v` |\n| 2 | **DSH** + **dsh-tiddlywiki 插件** | 本仓库的插件(含 wiki) | `curl http://127.0.0.1:3080/dsh-tiddlywiki/status` |\n| 3 | **opencli** | `npm install -g @jackwener/opencli` | `opencli --version` |\n| 4 | **Browser Bridge 扩展** | 见 §2(二选一) | `opencli doctor` 显示 `Extension: connected` |\n| 5 | **Chrome/Edge + 公众号登录** | 浏览器登录 mp.weixin.qq.com | 能进后台首页 |\n| 6 | **本仓库的 adapter** | `node tools/wechat/install-wechat-adapters.mjs` | 脚本自检输出 ✔ |\n| 7 | **打开插件开关** | 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」勾选 → 保存配置 | 提示词预览里出现「发布元数据规范」一行 |\n\n> **opencli 版本建议 ≥ 1.8.7**(本方案在该版本实测通过;1.7.x 的 `browser`\n> 子命令语法不同,且内置 weixin adapter 不完整)。\n>\n> **新版 npm 的 allowScripts 机制会拦 opencli 的 postinstall 脚本**(安装时打\n> warning):**无害,可忽略**——postinstall 只装 bash/zsh/fish 补全(Windows 用不上),\n> adapters 随 npm 包自带,功能不受影响。\n>\n> 第 7 步之前,插件不会注入任何发布相关提示词、也不会往 wiki 写「发布元数据规范」。\n> 也就是说:**没装好工具链时开关可以一直关着,不影响任何其他功能。**\n\n### 自动化的终点:草稿箱(2026-09-17 与用户确认的策略)\n\n**adapter 负责到「草稿落盘」为止**:填标题/作者/摘要、写正文、传图、设封面、存草稿。\n**发表由人工在后台完成**——点「发表」时平台会弹确认对话框(原创声明、作者、留言\n设置等)以及最后的管理员扫码;原创声明涉及账号权益,人工点最稳妥。`--publish`\n选项保留(best-effort:点「发表」后轮询等扫码),但**不处理中途弹窗**,遇到弹窗\n会一直等到超时。\n\n---\n\n## 2. 安装 Browser Bridge 扩展(关键前置)\n\n扩展**不在 npm 包里**,必须单独装。二选一:\n\n**方式 A — Chrome Web Store(推荐)**\n1. 打开 https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n2. 点「添加至 Chrome」\n\n**方式 B — GitHub Releases(Web Store 打不开时)**\n1. 到 https://github.com/jackwener/opencli/releases 下载 `opencli-extension-v*.zip`\n2. 解压到某个固定目录\n3. 浏览器打开 `chrome://extensions` → 开启右上角「开发者模式」\n4. 点「加载已解压的扩展程序」→ 选择解压出的目录\n\n> ⚠️ GitHub 的 ext release **经常落后于 Web Store**(2026-09-17 实测只到\n> `ext-v1.0.21`,Web Store 已是 1.0.24)。**扩展版本以 Chrome Web Store 为准**;\n> GitHub 方式只作 Web Store 打不开时的离线兜底。\n\n**验证**:\n\n```bash\nopencli doctor\n# 期望: [OK] Daemon: running ...\n# [OK] Extension: connected (vX.Y.Z)\n# [OK] Connectivity: connected\n# Everything looks good!\n```\n\n若报 `Extension: not connected`:确认浏览器在运行、扩展已启用;再 `opencli daemon stop` 后重跑 `opencli doctor`(daemon 会自动重启)。\n\n---\n\n## 3. 装 adapter 并试跑\n\n```bash\ncd <本仓库>\nnode tools/wechat/install-wechat-adapters.mjs\n```\n\n脚本会把 `tools/wechat/*.js` 复制到 `~/.opencli/clis/weixin/`(Windows:`C:\\Users\\<你>\\.opencli\\clis\\weixin\\`)。\n这些是**私有 adapter**,放在该目录即可被 opencli 自动发现,**无需任何构建**。\n\n然后:\n\n```bash\n# ① 先干跑:只做 TW 渲染 + 排版装饰,导出预览,不碰微信\nopencli weixin publish-note \"某篇笔记标题\" --preview ./out --trace retain-on-failure\n\n# → 用浏览器打开 ./out.html 肉眼确认排版\n# (注意:--preview 仍会继续执行发布流程;只想预览时看文件即可,或 Ctrl-C)\n\n# ② 真发到草稿箱\nopencli weixin publish-note \"某篇笔记标题\" --trace retain-on-failure -f json\n\n# ③ 带封面(公众号封面必填,否则草稿显示\"内容不完整\")\nopencli weixin publish-note \"某篇笔记标题\" --cover ./cover.png --trace retain-on-failure -f json\n\n# ④ 直接发表(⚠️ 会真发文章,需管理员扫码)\nopencli weixin publish-note \"某篇笔记标题\" --publish --trace retain-on-failure -f json\n\n# ⑤ 正文多图(v0.23.2+):TW 笔记内嵌 [img[...]] 经 /render 变成 data URI,\n# publish-note 传不了(微信存草稿时过滤非 mmbiz 图)——用 publish-note-imgs:\nopencli weixin publish-note-imgs \"某篇笔记标题\" --images <图片目录> --trace retain-on-failure -f json\nopencli weixin publish-note-imgs \"某篇笔记标题\" --images \"01.png|02.png|03.png\" -f json\n# · --images 传目录时按文件名排序 = 正文图片出现顺序(封面图排第一)\n# · 也可用 | 分隔的路径列表精确指定顺序\n# · 流程:先逐张上传 CDN → 按 DOM 顺序重写正文 src → insertHTML → 选封面 → 存草稿\n# · 发表前同样读 pub-state / no-publish(只告警不阻断),--publish 可直接发表\n```\n\n### 3.6 在 TiddlyWiki 里点按钮发布(v0.23.3,日常推荐)\n\n不想敲命令时,用**笔记工具栏的「发布到公众号」按钮**(嵌入式 TW 面板、右侧栏 tab、原生编辑弹窗里都在):\n\n1. 设置页勾选「可选功能:微信公众号发布」并保存 → 重启 dsh web 后,启动 seed 会把按钮插件\n `$:/plugins/dsh/wechat-publish` 写进 wiki(老 wiki 也可在设置页「初始化」区单独写它)。\n2. 打开任意笔记 → 工具栏点「发布到公众号」→ 按钮**先预检**(opencli 跑不跑得起来、adapter 缺哪些文件),\n 再弹确认框(自动列出 `no-publish` / `pub-state` 警告)→ 点「开始存草稿」。\n3. 宿主进程起一个**后台任务**,覆盖层每 2 秒显示状态与日志尾部。**只到草稿箱为止,不自动发表。**\n\n按钮背后的三条路由(同源;写操作有方法 + CSRF 守卫;`wechat.enabled` 关着时一律 403):\n\n| 路由 | 方法 | 作用 |\n|---|---|---|\n| `/dsh-tiddlywiki/wechat/ready` | GET | 预检:`opencli --version` 能否跑通、adapter 缺哪些文件 |\n| `/dsh-tiddlywiki/wechat/publish` | POST | 起任务(body `{title, adapter?}`);单并发,第二次调用 409 |\n| `/dsh-tiddlywiki/wechat/publish/status` | GET | 轮询任务(`?id=`;不带 id 取最新/正在跑的那个) |\n\n可配项(设置页目前只暴露 `enabled`,其余写进配置 tiddler\n`$:/plugins/dsh-tiddlywiki/config` 的 `wechat` 块,保存即生效):\n\n| 键 | 默认 | 说明 |\n|---|---|---|\n| `wechat.enabled` | `false` | 总开关;关着时按钮不写入、三条路由 403 |\n| `wechat.adapter` | `publish-note` | 换成 `publish-note-imgs` 走正文内嵌多图(需该 adapter 已装) |\n| `wechat.command` | `opencli` | CLI 路径(装了别名、不在 PATH 时用) |\n| `wechat.token` | 空 | 非空时要求请求头 `x-wechat-publish-token`(按钮会自动带上) |\n| `wechat.dsn` | 空 | adapter 回连 DSH 的基址;留空按请求端口推导 `http://127.0.0.1:<端口>/dsh-tiddlywiki` |\n| `wechat.endpoint` | 空 | **只被 TW 侧读**:覆盖按钮请求的基址(默认 `location.origin + /dsh-tiddlywiki`);反向代理/远程访问场景用 |\n\n⚠️ 宿主用 **`--title-file`**(v0.23.3 新增参数)把标题经 UTF-8 文件交给 adapter:标题不进 argv,\n否则 Windows 上 `opencli` 的 `.cmd` shim 会把 `&`/`|`/`^` 当命令分隔符,中文标题还会被 cmd 的\n代码页解码成乱码。位置参数 `<title>` 照旧可用(两者都给时位置参数优先)。\n⚠️ 按钮**不替你发表**:发表要管理员扫码(§4.2),请在公众号后台点「发表」。\n\n---\n\n## 3.5 发布元数据(重要:避免重发/误发)\n\nwiki 里既有**早已发布**的文章,也有**明确不能发**的内容。没有记录,agent 只能猜——\n要么重复发已上线的,要么把不该发的推出去。\n\n**规范全文在 wiki 里**:笔记「**发布元数据规范**」(随插件 seed 分发,`dsh-docs` 标签,\n开机自动写入;也可手动跑 `npx tsx tools/wechat/seed-publish-spec-now.mts` 立即写入)。\n注入给 agent 的系统提示词里**只有一句指针**(规范全文太长,不适合放进每个会话都要付的常驻预算 —— v0.29.0 把 slim 从 ~1.9KB 瘦到 ~1.0KB 之后依然如此,指针就够了:需要时 Agent 会去读那篇笔记)。\n\n### 字段(自定义字段,不是标签)\n\n| 字段 | 含义 | 示例 |\n|---|---|---|\n| `pub-state` | 能不能发 | `draft` / `published` / `excluded` |\n| `pub-platform` | 最近发布的平台(多平台逗号分隔) | `wechat` |\n| `pub-wechat-at` | 公众号发表时间 | `2026-09-17 15:30` |\n| `pub-wechat-title` | 发表时的标题(**仅当与 TW 标题不同才写**) | `中年失业自救指南` |\n| `pub-wechat-url` | 发表后的链接 | `https://mp.weixin.qq.com/s/xxx` |\n| `pub-note` | 备注 | `删改后重发过一版` |\n\n**没有 `pub-state` = 未知状态**,应当当作「需要人确认」,而不是「可以随便发」。\n\n标签 `no-publish` 是给人看的镜像(TW 界面一眼可见、可筛);`pub-state: excluded` 给\nagent 程序化判断。**两者应一致,冲突时以字段为准。**\n\n### ⚠️ 命名冲突(别占用)\n\n实测本 wiki 里这三个名字**已被占用**,方案特意避开:\n\n- `publish` / `publishyear` 字段 → Obsidian 导入的**书籍**条目用作「出版社 / 出版年」\n (值形如「新世界出版社」「2011-09-01」)。\n- `发布记录` 标签 → 本插件自己的**版本发布说明**。\n\n### 发布前检查:只告警,不阻断\n\n`publish-note` 会读 `pub-state` / `no-publish`,命中就打印醒目警告,**然后继续执行**\n(2026-09-17 明确选定的策略)。`--force` 只改措辞,不改变行为:\n\n```\n⚠️ 「某篇」已发布过(平台 wechat)。这可能是重复发布——确认无误再继续;确实要重发请加 --force。\n```\n\n### 回写(agent 负责)\n\n发布成功后,命令的 `detail` 里会带一句**建议回写值**,例如:\n\n```\n建议回写:pub-state=published, pub-platform=wechat, pub-wechat-at=\"2026-09-17 17:50\"\n```\n\nagent 据此用 `tiddlywiki_put` 写回(**不传 `tags` 就保留原标签**,只补 `fields`):\n\n```\ntiddlywiki_put(title=\"某篇\", text=<原正文>, fields={\"pub-state\":\"published\", \"pub-platform\":\"wechat\", \"pub-wechat-at\":\"...\"})\n```\n\n> ⚠️ 用工具时 `text` 会被覆盖,**务必带上原正文**。别像我一样用裸 REST PUT 只发一段\n> 文字——那会把笔记正文清空(真实踩过,靠 git 恢复)。\n\n### 存量回填\n\n早于本规范、来源可辨(`source-path` 含「公众号」)的文章,用脚本回填:\n\n```bash\n# 默认 dry-run,只打印将改什么\nnode tools/wechat/backfill-publish-state.mjs\n\n# 确认后真写\nnode tools/wechat/backfill-publish-state.mjs --write\n\n# 指定标题 / 平台 / 匹配子串\nnode tools/wechat/backfill-publish-state.mjs --title \"打工记\" --title \"鱼\" --write\nnode tools/wechat/backfill-publish-state.mjs --match 公众号 --platform wechat --write\n```\n\n脚本纪律(**请勿改成「只写字段」的裸 PUT**):每条都先 GET **完整** tiddler(含\ntext/tags/type/created),**只添加** `pub-*` 字段后整体写回;已有 `pub-state` 的跳过\n(幂等);正文为空的跳过(宁可不动);**发表时间无法考证就留空,不编造**。\n\n---\n\n## 4. ⚠️ 三个必须知道的坑\n\n### 4.1 所有命令都要带 `--trace retain-on-failure`\n\n不带会对 `mp.weixin.qq.com` 稳定报:\n\n```\nNavigation rejected\n```\n\n**实测**:trace 开 → 5/5 成功;trace 关 → 8/8 失败。这是 opencli 1.8.7 的 bug\n(源码里 trace 只做录像,理论上不该影响导航)。已试过无效的绕法:\n`--site-session ephemeral`、`--keep-tab false`、`--window foreground/background`、\n重置标签页、重启 daemon。**唯一有效就是加 trace。**\n\n### 4.2 发表必须管理员扫码(无法自动化)\n\n后台点「发表」后,微信要求**管理员微信扫码确认**。这不是技术能绕开的账号安全机制。\n所以「一键发布」的真实含义是:\n\n> agent/脚本 完成素材、排版、上传、填表、进草稿箱 —— **你只需扫一次码**。\n\n`--publish` 会自动点「发表」并在终端提示你扫码,然后轮询结果(默认等 180s,\n可用 `--timeout` 调整)。\n\n### 4.3 「发表」≠「群发」(别搞混)\n\n| 操作 | 推送粉丝 | 占群发额度 | 需要扫码 |\n|---|---|---|---|\n| **发表**(本方案默认) | 否(仅生成永久链接) | 否,不限次数 | 是 |\n| **群发** | 是 | 个人订阅号 1 天 1 次 | 是 |\n\n两者都要扫码 → 默认选**不吃额度**的「发表」。\n\n### 4.4 发表阶段的确认弹窗 adapter 不处理(v0.23.x 现状)\n\n`publishDraft` 点完「发表」后**只轮询成功信号**,不会点任何平台弹窗。发表链路上\n还有:原创声明/作者/留言设置等**确认对话框**(2026-09-17 人工发表实测必经)与最后\n的管理员扫码。所以**推荐流程到草稿箱为止**(见 §1),发表环节人工做;`--publish`\n只是 best-effort,遇到弹窗会卡到超时。排错见 §8。\n\n---\n\n## 5. 为什么不用官方 API(背景,避免走回头路)\n\n公众号发布的服务端链路是 `access_token → draft/add → freepublish/submit`。但:\n\n- **2025 年 7 月起,官方回收「发布能力」接口对个人主体、企业未认证、不支持认证账号的调用权限**\n ([发布能力文档](https://developers.weixin.qq.com/doc/subscription/guide/product/publish.html) 原文注)。\n 个人主体无法做微信认证 → `freepublish/submit` 不可用。\n- 草稿箱 `draft/add` 文档未列该限制,但 48001 / `api unauthorized` 是常见返回。\n- 即便可用,还叠加:**本机公网出口 IP 必须加入 API IP 白名单**(否则 61004/40164)、\n 封面 `thumb_media_id` 必须是**永久素材**、正文图片必须是 `media/uploadimg` 产出的\n mmbiz 地址(外链图被过滤)。\n\n**而后台网页端从未受影响**——这是所有个人号日常发文的方式。所以本方案驱动网页端。\n\n---\n\n## 6. 文件清单(本方案包含什么)\n\n```\ntools/wechat/\n├── wechat-html.js # 排版装饰器:语义 HTML → 内联样式 HTML(主题表在这里改)\n├── weixin-flow.js # 浏览器流程:登录检测/填表/写正文/传图/设封面/存草稿/发表\n├── create-article.js # 命令:从本地 HTML 文件建草稿(可选发表)\n├── publish-note.js # 命令:从 TW 笔记建草稿(主入口,含 --preview;单图 --cover)\n├── publish-note-imgs.js # 命令:多图版——正文内嵌 data URI 图全部上传 CDN 再发布\n└── install-wechat-adapters.mjs # 一键安装到 ~/.opencli/clis/weixin/\n```\n\n装到本机后对应 `~/.opencli/clis/weixin/` 下的同名文件。\n改排版只需改 `tools/wechat/wechat-html.js` 里的 `ELEMENT_STYLES`,重跑安装脚本即可。\n\n---\n\n## 7. 技术实现要点(改代码前必读)\n\n### 7.1 正文写入必须用 `insertHTML`,不能用 `insertText`\n\n微信编辑器是 **ProseMirror**。`document.execCommand('insertHTML', …)` 保留内联样式;\n`insertText` 会把 HTML/Markdown 当**字面文本**(opencli 内置的 `weixin create-draft`\n就是这样——实测后台 digest 里 `# 一级标题` 和 `**加粗**` 的符号原样保留)。\n\n### 7.2 图片上传必须用 DataTransfer,不能用 `page.setFileInput`\n\n`page.setFileInput` 依赖 CDP 的 `Page.fileChooserOpened` 事件,本机(opencli 1.8.7 +\n扩展 v1.0.24 + Edge)**稳定失败**:\n\n```\nPage.fileChooserOpened not received within 5s\n```\n\n([issue #1582](https://github.com/jackwener/OpenCLI/issues/1582) 显示扩展/CLI 版本不匹配确有历史问题。)\n\n**绕过办法**——在页面上下文用 DataTransfer 直接把 File 塞进 `input.files`:\n\n```js\nconst bin = atob(base64)\nconst arr = new Uint8Array(bin.length)\nfor (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)\nconst file = new File([arr], 'cover.png', { type: 'image/png' })\nconst dt = new DataTransfer()\ndt.items.add(file)\ninput.files = dt.files\ninput.dispatchEvent(new Event('change', { bubbles: true }))\n```\n\n实测图片真的上了 `mmbiz.qpic.cn`。**代价:字节要以 base64 穿过 evaluate → 单图限 8MB。**\n\n### 7.3 微信编辑器会剥 `<style>` 和 class,只认内联 `style`\n\n实测:注入 `<style>h1{color:red}</style>` → `hasStyleTag: false`;\n注入 `style=\"font-size:20px\"` → 原样保留。\n而 **TiddlyWiki 的 `/render` 输出零内联样式**(纯语义 HTML + class)——\n这正是需要 `wechat-html.js` 装饰器的原因。\n\n### 7.4 轮询里绝不能读 `document.body.innerText`(v0.23.0 实测踩坑)\n\n微信编辑器 DOM 极大,读一次 `body.innerText` **强制整页 layout,实测单次 evaluate\n≈17 秒**。早期 `saveDraft` / `publishDraft` 的轮询用它找成功提示,8 次轮询把整个命令\n拖过 210s 超时——**最坑的是草稿其实已经保存成功**,用户看到的是超时失败(假阴性)。\n\n现在只查少量 toast 节点(`.weui-desktop-toast` / `.weui-desktop-msg` / `#js_save_success`)\n和 URL,都是 O(1) 操作。修复后 `saveDraft` 从 **137 秒降到 3.9 秒**。\n\n`scripts/verify-wechat-adapters.mjs` 有回归守门(已反向验证:塞回去即红)。\n\n### 7.5 其他踩过的坑\n\n- 图片下拉必须**按文案**点「本地上传」:用 `items[0]` 点完 file input 仍不可见。\n- 正文是编辑器页**最后一个** `contenteditable`。\n- 回读校验必须**忽略所有空白**:编辑器 innerText 会在块级元素间插空白、把空格转 nbsp,\n 直接用原始字符串比对必假阴性。\n- 保存按钮的文案兜底**特意不含「发表/发布」**,防止误点发布。\n- 草稿列表显示「图文内容不完整」**只表示缺封面**,不代表写入失败。\n- `<pre>` 内的 `<code>` 不能套用行内代码样式(粉底 + 内边距会很难看),\n `wechat-html.js` 里用 `preDepth` 特判。\n\n---\n\n## 8. 排错\n\n| 现象 | 原因 / 处理 |\n|---|---|\n| `Navigation rejected` | 忘了 `--trace retain-on-failure` |\n| `AUTH_REQUIRED` / 提示登录 | 浏览器里 `mp.weixin.qq.com` 登录态过期 → 重新扫码登录 |\n| `Extension: not connected` | 扩展没装/没启用,或浏览器没运行 → 见 §2 |\n| `Page.fileChooserOpened not received` | 不应再出现(已改用 DataTransfer);若出现说明源码被改回 setFileInput |\n| 找不到笔记 | tiddler 标题要**完全精确**(含空格/标点);`opencli weixin publish-note \"标题\"` |\n| `/render 返回 HTTP 404` | 标题不存在;先用 `tiddlywiki_search` 或 TW 面板确认 |\n| 无法连接 DSH | `--dsn` 默认 `http://127.0.0.1:3080/dsh-tiddlywiki`;DSH 没跑或端口不同就改它 |\n| 草稿显示「内容不完整」 | 缺封面 → 传 `--cover`(或用 publish-note-imgs 从正文第一张自动设) |\n| 图片没上传 | 单图 > 8MB(DataTransfer 限制)→ 先压缩 |\n| 发表卡住超时 | 没扫码,或扫码没完成 → 调大 `--timeout`;若卡在原创声明/留言等确认弹窗 → 推荐流程本就到草稿箱为止,到后台人工点发表(见 §1、§4.4) |\n| `stale page identity` | 命令执行中标签页被手工关闭/导航了。**草稿不丢**——重开编辑页续作:`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&type=77&appmsgid=<id>&idx=0&token=<token>`(token 用 `opencli browser wx eval 'location.href.match(/token=(\\d+)/)[1]'` 从后台首页取) |\n| 封面警告「设置失败」但草稿其实有封面 | 已知**误报**(校验时机太早);以草稿箱实际显示为准(2026-09-17 实测:`list_ex` 接口 `cover` 字段已是 mmbiz 地址)。v0.23.2 起改为轮询校验 |\n| 用 eval 诊断后台状态时读到「未实名」「未设置头像和名称」等提示 | ⚠️ 后台 DOM 里常残留**不可见的历史 toast 节点**——查询必须过滤可见性(`offsetHeight > 0`),勿把残留文案当实时状态(2026-09-17 踩过:据此误判账号被平台拦截,实际账号正常并成功发表含原创声明)。同理,判断原创声明是否生效以草稿箱/发表记录为准,勿只看侧栏文案 |\n| 需要核实草稿是否落盘 | 后台 ajax:`/cgi-bin/appmsg?action=list_ex&type=77&orderby=create_time&token=<token>&f=json&begin=0&count=5`(在 mp.weixin.qq.com 页面上下文执行;`cover`/`digest`/`update_time` 一目了然);发表记录:`/cgi-bin/appmsgpublish?sub=list&...&f=json`(`publish_page.publish_list[].publish_info` 里是 JSON 字符串,含 `content_url`) |\n\n---\n\n## 9. 安全与隐私\n\n- **不存任何公众号凭据**:全程复用浏览器已有登录态(cookie),脚本不接触 AppSecret。\n- **不落盘密钥**:adapter 只读本地图片路径,不写任何 token。\n- **低频使用**:不做规避风控的行为伪装;默认走「发表」(不推送粉丝、不占额度)。\n- wiki 里的配置 tiddler 与本方案无关——本方案零配置。\n\n---\n\n## 10. 参考\n\n- [opencli](https://github.com/jackwener/opencli) — 把网站变成 CLI,复用浏览器登录态\n- [微信「发布能力」文档(2025-07 权限回收)](https://developers.weixin.qq.com/doc/subscription/guide/product/publish.html)\n- [微信「新增草稿」draft/add](https://developers.weixin.qq.com/doc/subscription/api/draftbox/draftmanage/api_draft_add.html)\n- [微信服务端 API 调用说明(IP 白名单)](https://developers.weixin.qq.com/doc/subscription/guide/dev/api/)\n- 设计文档:`docs/plans/2026-09-17-wechat-publish-design.md`\n";
3567
3595
  /** Write the doc + marker (ONE-SHOT when `force` is false). */
3568
3596
  async function seedWechatDocs(client, opts) {
3569
3597
  const force = opts?.force === true;
@@ -3740,6 +3768,23 @@ var ConfigUnreadableError = class extends Error {
3740
3768
  function describeUnreadableConfig() {
3741
3769
  return `配置 tiddler ${CONFIG_TIDDLER} 存在但不是合法 JSON(常见原因:git 冲突标记 <<<<<<< 残留,或手工编辑出错)。为避免抹掉其它设置,本次保存已被拒绝。请先修好该 tiddler(或删除它、回落到 cordis config 块)再保存。`;
3742
3770
  }
3771
+ /**
3772
+ * Same refusal, different cause (v0.29.0): the tiddler could not be READ at all
3773
+ * and we have never managed to read it — so we do not know what it contains.
3774
+ *
3775
+ * This is the v0.23.4 wipe through the other door. `set()` re-reads the tiddler
3776
+ * specifically so a transient startup failure cannot cost the user's other
3777
+ * overrides; that protection only exists when the re-read SUCCEEDS. When both
3778
+ * reads failed and the cache is still the empty placeholder, merging a one-key
3779
+ * patch onto it and writing persists a config tiddler containing ONLY that key —
3780
+ * silently dropping `prompt.extra`, `git.remote`, every `ui.*` switch.
3781
+ *
3782
+ * Rule #3 ("a read failure is not an absence") applied to a read-modify-write:
3783
+ * the honest answer is to refuse and let the user retry.
3784
+ */
3785
+ function describeUnconfirmedConfig() {
3786
+ return `读不出配置 tiddler ${CONFIG_TIDDLER}(TiddlyWiki 正在重启或超时),因此无法确认里面还有哪些设置。为避免只写入本次改动、抹掉其余设置,本次保存已中止,请稍后重试。`;
3787
+ }
3743
3788
  /** A settings-page patch that cannot be accepted (v0.25.0) — mapped to HTTP 400. */
3744
3789
  var ConfigPatchError = class extends Error {
3745
3790
  constructor(message) {
@@ -3870,6 +3915,13 @@ function normalizeConfigPatch(input) {
3870
3915
  var ConfigStore = class {
3871
3916
  base;
3872
3917
  overrides = {};
3918
+ /**
3919
+ * True once this store has successfully DETERMINED the stored overrides
3920
+ * (v0.29.0): either the tiddler parsed, or it was absent (404 = no overrides).
3921
+ * A failed read leaves it false, which is what makes `set()` refuse to merge
3922
+ * onto an empty cache it cannot vouch for — see describeUnconfirmedConfig().
3923
+ */
3924
+ loaded = false;
3873
3925
  /** Set when the stored tiddler exists but cannot be parsed (v0.23.4). */
3874
3926
  lastParseError;
3875
3927
  constructor(base) {
@@ -3913,9 +3965,10 @@ var ConfigStore = class {
3913
3965
  console.warn("[dsh-tiddlywiki] config tiddler unreadable, keeping cached overrides:", err instanceof Error ? err.message : err);
3914
3966
  return;
3915
3967
  }
3916
- if (tiddler === void 0) {
3968
+ if (tiddler == null) {
3917
3969
  this.overrides = {};
3918
3970
  this.lastParseError = void 0;
3971
+ this.loaded = true;
3919
3972
  return;
3920
3973
  }
3921
3974
  const raw = typeof tiddler.text === "string" ? tiddler.text : "";
@@ -3924,6 +3977,7 @@ var ConfigStore = class {
3924
3977
  if (!isPlainObject$2(parsed)) throw new Error("config tiddler is not a JSON object");
3925
3978
  this.overrides = parsed;
3926
3979
  this.lastParseError = void 0;
3980
+ this.loaded = true;
3927
3981
  } catch {
3928
3982
  this.lastParseError = describeUnreadableConfig();
3929
3983
  console.warn("[dsh-tiddlywiki] config tiddler is not valid JSON, keeping cached overrides; saving is blocked until it is fixed");
@@ -3945,18 +3999,22 @@ var ConfigStore = class {
3945
3999
  let storedText;
3946
4000
  try {
3947
4001
  const tiddler = await client.get(CONFIG_TIDDLER);
3948
- if (tiddler !== void 0 && typeof tiddler.text === "string") {
4002
+ if (tiddler != null && typeof tiddler.text === "string") {
3949
4003
  storedText = tiddler.text;
3950
4004
  const parsed = JSON.parse(tiddler.text);
3951
4005
  if (!isPlainObject$2(parsed)) throw new Error("config tiddler is not a JSON object");
3952
4006
  stored = parsed;
3953
4007
  }
3954
- if (tiddler !== void 0 && typeof tiddler.created === "string" && tiddler.created.trim().length > 0) existingCreated = tiddler.created;
4008
+ if (tiddler != null && typeof tiddler.created === "string" && tiddler.created.trim().length > 0) existingCreated = tiddler.created;
3955
4009
  } catch (err) {
3956
4010
  if (storedText !== void 0) {
3957
4011
  this.lastParseError = describeUnreadableConfig();
3958
4012
  throw new ConfigUnreadableError(describeUnreadableConfig(), storedText);
3959
4013
  }
4014
+ if (!this.loaded) {
4015
+ this.lastParseError = describeUnconfirmedConfig();
4016
+ throw new ConfigUnreadableError(describeUnconfirmedConfig(), "");
4017
+ }
3960
4018
  }
3961
4019
  const merged = deepMerge(stored, patch);
3962
4020
  const nextText = JSON.stringify(merged, null, 2);
@@ -7665,8 +7723,32 @@ function registerRoutes(ctx, deps) {
7665
7723
  * deployments.
7666
7724
  */
7667
7725
  const twProxyAbsoluteBase = (req) => absoluteHostBase(req.headers.host, TW_PROXY_PATH);
7726
+ /**
7727
+ * 503 when `?wiki=<id>` names a REGISTERED but STOPPED knowledge base (v0.29.0).
7728
+ *
7729
+ * Before this, such a request fell through to the DEFAULT wiki — the settings
7730
+ * page said 「正在配置:books」 and then synced/restarted/uploaded into the work
7731
+ * wiki, silently. `deps.targetProblem` is the single place that knows the
7732
+ * difference between "unknown id" (still falls back: stale bookmark) and
7733
+ * "known but not running" (must refuse), so every route that can act asks it.
7734
+ */
7735
+ const refuseStoppedTarget = (req, res) => {
7736
+ const problem = deps.targetProblem?.(req);
7737
+ if (problem === void 0) return false;
7738
+ json(res, {
7739
+ ok: false,
7740
+ error: problem
7741
+ }, 503);
7742
+ return true;
7743
+ };
7744
+ /**
7745
+ * The 503 body for "no running wiki behind this request": names the wiki when
7746
+ * the request asked for one that is stopped, otherwise the historical text.
7747
+ */
7748
+ const notRunning = (req) => deps.targetProblem?.(req) ?? "wiki service is not running";
7668
7749
  const handleStatus = async (req, res) => {
7669
7750
  if (rejectNonRead(req, res)) return;
7751
+ if (refuseStoppedTarget(req, res)) return;
7670
7752
  const view = deps.server(req)?.status() ?? {
7671
7753
  status: "stopped",
7672
7754
  wikiPath: deps.getWikiPath(req),
@@ -7740,7 +7822,7 @@ function registerRoutes(ctx, deps) {
7740
7822
  if (client === void 0) {
7741
7823
  json(res, {
7742
7824
  ok: false,
7743
- error: "wiki service is not running"
7825
+ error: notRunning(req)
7744
7826
  }, 503);
7745
7827
  return;
7746
7828
  }
@@ -7796,7 +7878,7 @@ function registerRoutes(ctx, deps) {
7796
7878
  if (client === void 0) {
7797
7879
  json(res, {
7798
7880
  ok: false,
7799
- error: "wiki service is not running"
7881
+ error: notRunning(req)
7800
7882
  }, 503);
7801
7883
  return;
7802
7884
  }
@@ -7850,7 +7932,7 @@ function registerRoutes(ctx, deps) {
7850
7932
  if (client === void 0) {
7851
7933
  json(res, {
7852
7934
  ok: false,
7853
- error: "wiki service is not running"
7935
+ error: notRunning(req)
7854
7936
  }, 503);
7855
7937
  return;
7856
7938
  }
@@ -7882,7 +7964,7 @@ function registerRoutes(ctx, deps) {
7882
7964
  if (client === void 0) {
7883
7965
  json(res, {
7884
7966
  ok: false,
7885
- error: "wiki service is not running"
7967
+ error: notRunning(req)
7886
7968
  }, 503);
7887
7969
  return;
7888
7970
  }
@@ -7916,7 +7998,7 @@ function registerRoutes(ctx, deps) {
7916
7998
  if (client === void 0) {
7917
7999
  json(res, {
7918
8000
  ok: false,
7919
- error: "wiki service is not running"
8001
+ error: notRunning(req)
7920
8002
  }, 503);
7921
8003
  return;
7922
8004
  }
@@ -7977,7 +8059,7 @@ function registerRoutes(ctx, deps) {
7977
8059
  if (client === void 0) {
7978
8060
  json(res, {
7979
8061
  ok: false,
7980
- error: "wiki service is not running"
8062
+ error: notRunning(req)
7981
8063
  }, 503);
7982
8064
  return;
7983
8065
  }
@@ -8045,6 +8127,7 @@ function registerRoutes(ctx, deps) {
8045
8127
  const handleRestart = async (req, res) => {
8046
8128
  try {
8047
8129
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
8130
+ if (refuseStoppedTarget(req, res)) return;
8048
8131
  if (!beginMutation("restart")) {
8049
8132
  json(res, {
8050
8133
  ok: false,
@@ -8085,6 +8168,7 @@ function registerRoutes(ctx, deps) {
8085
8168
  * UI reflects the pulled files instead of looking stale. */
8086
8169
  const handleSync = async (req, res) => {
8087
8170
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
8171
+ if (refuseStoppedTarget(req, res)) return;
8088
8172
  if (!beginMutation("sync")) {
8089
8173
  json(res, {
8090
8174
  ok: false,
@@ -8175,6 +8259,7 @@ function registerRoutes(ctx, deps) {
8175
8259
  const handleUpload = async (req, res) => {
8176
8260
  try {
8177
8261
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
8262
+ if (refuseStoppedTarget(req, res)) return;
8178
8263
  const buf = await readBodyBuffer(req);
8179
8264
  let name = sanitizeUploadName(new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "");
8180
8265
  if (name.length === 0 && typeof req.headers["x-filename"] === "string") {
@@ -9076,9 +9161,31 @@ function stripMaskedSecrets(patch, current) {
9076
9161
  * @module dsh-tiddlywiki/host/admin-routes
9077
9162
  */
9078
9163
  function registerAdminRoutes(ctx, deps) {
9164
+ /**
9165
+ * 503 when `?wiki=<id>` names a REGISTERED but STOPPED knowledge base (v0.29.0).
9166
+ *
9167
+ * The settings page is where this used to hurt most: the scope bar said
9168
+ * 「正在配置:books」 while `/admin/state` answered with the DEFAULT wiki's
9169
+ * path/git/`tiddlywiki.info` and `/admin/config` wrote the books patch into the
9170
+ * default wiki's config tiddler. Every wiki-scoped handler asks this FIRST,
9171
+ * because `deps.config` / `deps.getWikiPath` have harmless-looking fallbacks
9172
+ * that only ASK for the wrong-answer bug when the target is known-but-stopped.
9173
+ */
9174
+ const refuseStoppedTarget = (req, res) => {
9175
+ const problem = deps.targetProblem?.(req);
9176
+ if (problem === void 0) return false;
9177
+ json(res, {
9178
+ ok: false,
9179
+ error: problem
9180
+ }, 503);
9181
+ return true;
9182
+ };
9183
+ /** The 503 body for "no running wiki behind this request", wiki named if known. */
9184
+ const notRunning = (req) => deps.targetProblem?.(req) ?? "wiki service is not running";
9079
9185
  const handleState = async (req, res) => {
9080
9186
  try {
9081
9187
  if (rejectNonRead(req, res)) return;
9188
+ if (refuseStoppedTarget(req, res)) return;
9082
9189
  const wikiPath = deps.getWikiPath(req);
9083
9190
  const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())]);
9084
9191
  let git = null;
@@ -9126,6 +9233,7 @@ function registerAdminRoutes(ctx, deps) {
9126
9233
  const handleInfo = async (req, res) => {
9127
9234
  try {
9128
9235
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
9236
+ if (refuseStoppedTarget(req, res)) return;
9129
9237
  const body = JSON.parse(await readBody(req));
9130
9238
  const wikiPath = deps.getWikiPath(req);
9131
9239
  let info;
@@ -9248,7 +9356,7 @@ function registerAdminRoutes(ctx, deps) {
9248
9356
  if (client === void 0) {
9249
9357
  json(res, {
9250
9358
  ok: false,
9251
- error: "wiki service is not running"
9359
+ error: notRunning(req)
9252
9360
  }, 503);
9253
9361
  return;
9254
9362
  }
@@ -9315,7 +9423,7 @@ function registerAdminRoutes(ctx, deps) {
9315
9423
  if (client === void 0) {
9316
9424
  json(res, {
9317
9425
  ok: false,
9318
- error: "wiki service is not running"
9426
+ error: notRunning(req)
9319
9427
  }, 503);
9320
9428
  return;
9321
9429
  }
@@ -9345,7 +9453,7 @@ function registerAdminRoutes(ctx, deps) {
9345
9453
  if (client === void 0) {
9346
9454
  json(res, {
9347
9455
  ok: false,
9348
- error: "wiki service is not running"
9456
+ error: notRunning(req)
9349
9457
  }, 503);
9350
9458
  return;
9351
9459
  }
@@ -9397,7 +9505,7 @@ function registerAdminRoutes(ctx, deps) {
9397
9505
  if (client === void 0) {
9398
9506
  json(res, {
9399
9507
  ok: false,
9400
- error: "wiki service is not running"
9508
+ error: notRunning(req)
9401
9509
  }, 503);
9402
9510
  return;
9403
9511
  }
@@ -9430,6 +9538,7 @@ function registerAdminRoutes(ctx, deps) {
9430
9538
  */
9431
9539
  const handlePrompt = async (req, res) => {
9432
9540
  try {
9541
+ if (refuseStoppedTarget(req, res)) return;
9433
9542
  if ((req.method ?? "GET").toUpperCase() === "POST") {
9434
9543
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
9435
9544
  const raw = (await readBody(req)).trim();
@@ -11754,6 +11863,19 @@ var ClipBridge = class {
11754
11863
  deps;
11755
11864
  server;
11756
11865
  boundPort = 0;
11866
+ /**
11867
+ * 剪藏请求的串行队列(v0.29.0)。
11868
+ *
11869
+ * WHY: the note title is chosen with a check-then-write
11870
+ * (`resolveClipTitle(deps.exists, …)`), so two clips of the same page in flight
11871
+ * — a double bookmarklet click, or two tabs — both see "X is free" and the
11872
+ * second PUT silently replaces the first note (leaving the first note's image
11873
+ * tiddlers orphaned). TW's REST has no compare-and-set, so the cross-process
11874
+ * race cannot be closed; the same-process one can, and that IS the common case:
11875
+ * serialize `/clip` handling here. The second request then resolves the title
11876
+ * AFTER the first landed and picks 「X 2」.
11877
+ */
11878
+ clipChain = Promise.resolve();
11757
11879
  constructor(deps) {
11758
11880
  this.deps = deps;
11759
11881
  }
@@ -11765,7 +11887,9 @@ var ClipBridge = class {
11765
11887
  start(port) {
11766
11888
  if (this.server !== void 0) return Promise.resolve();
11767
11889
  const server = createServer$1((req, res) => {
11768
- this.handle(req, res).catch((err) => {
11890
+ const run = this.clipChain.then(() => this.handle(req, res));
11891
+ this.clipChain = run.then(() => void 0, () => void 0);
11892
+ run.catch((err) => {
11769
11893
  const message = err instanceof Error ? err.message : String(err);
11770
11894
  this.deps.log?.(`request failed: ${message}`);
11771
11895
  if (!res.headersSent) this.respond(res, {
@@ -12024,7 +12148,7 @@ function attachTool(env) {
12024
12148
  const { deps, requireWiki } = env;
12025
12149
  return defineTool({
12026
12150
  name: "tiddlywiki_attach",
12027
- description: "把一个本机文件或公网 http(s) 地址存成 wiki 的二进制附件 tiddler(图片 / PDF / 压缩包等,type + base64 正文,随 wiki 进 git)。可选把附件嵌入/链接进某篇笔记。这是 agent 唯一能写入二进制附件的途径。",
12151
+ description: "把一个本机文件或公网 http(s) 地址存成 wiki 的二进制附件 tiddler(图片 / PDF / 压缩包等,type + base64 正文,随 wiki 进 git)。可选把附件嵌入/链接进某篇笔记。**同名已有条目默认拒绝写入**(避免静默覆盖既有笔记;确认要覆盖才传 `force: true`,tags 与自定义字段仍会保留)。这是 agent 唯一能写入二进制附件的途径。",
12028
12152
  parameters: {
12029
12153
  title: {
12030
12154
  type: "string",
@@ -13589,14 +13713,14 @@ var WikiInstance = class WikiInstance {
13589
13713
  if (needsRestartAfterSeeds(results)) {
13590
13714
  if (!await waitForFileWrite(join(this.wikiPath, "tiddlers", "$__plugins_dsh_render.json"), 8e3, 150, seedStartedAt)) this.log("seeded render plugin file not seen on disk before restart");
13591
13715
  if (!await flushPendingWrites(seedClient, this.tiddlersDir)) this.log("seed writes may not have been flushed before restart");
13592
- if (!this.disposed) await this.server.restart();
13716
+ if (!this.disposed) await this.restart();
13593
13717
  } else if (pluginAdded) {
13594
- if (!this.disposed) await this.server.restart();
13718
+ if (!this.disposed) await this.restart();
13595
13719
  }
13596
13720
  const uiLang = this.eff().uiLanguage;
13597
13721
  if (typeof uiLang === "string" && uiLang.trim().length > 0) try {
13598
13722
  const code = uiLang.trim();
13599
- if (await ensureLanguage(this.wikiPath, this.options.twRoot(), code) && !this.disposed) await this.server.restart();
13723
+ if (await ensureLanguage(this.wikiPath, this.options.twRoot(), code) && !this.disposed) await this.restart();
13600
13724
  const langClient = this.client();
13601
13725
  if (langClient !== void 0) await pinLanguageTiddler(langClient, `$:/languages/${code}`, (message, err) => {
13602
13726
  if (err === void 0) this.log(message);
@@ -13618,7 +13742,7 @@ var WikiInstance = class WikiInstance {
13618
13742
  try {
13619
13743
  await this.server.start();
13620
13744
  if (this.disposed) {
13621
- await this.server.stop().catch(() => void 0);
13745
+ await this.drainStop().catch(() => void 0);
13622
13746
  return;
13623
13747
  }
13624
13748
  await this.config.load(this.client());
@@ -13739,20 +13863,62 @@ function wikiIdFromRequest(req) {
13739
13863
  /**
13740
13864
  * Which runtime does this request target?
13741
13865
  *
13742
- * `?wiki=<id>` naming a RUNNING wiki → that one
13743
- * anything else (no selector, unknown id, wiki not running)
13744
- * → the farm's default runtime
13866
+ * `?wiki=<id>` naming a RUNNING wiki → that one
13867
+ * `?wiki=<id>` naming a REGISTERED wiki that is STOPPED → UNDEFINED (refuse)
13868
+ * no selector, or an UNKNOWN id → the farm's default
13745
13869
  *
13746
- * An unknown id falls back instead of 404ing on purpose: the selector comes
13747
- * from links the user may have bookmarked, and a stale one must not strand them
13748
- * on a broken editor. It also means `?wiki=` can never be used to reach a wiki
13749
- * that is not running — starting one is an explicit action, not a side effect
13750
- * of a GET.
13870
+ * TWO FALLBACKS, TWO DIFFERENT REASONS (v0.29.0)
13871
+ * ----------------------------------------------
13872
+ * An UNKNOWN id falls back instead of 404ing on purpose: the selector comes from
13873
+ * links the user may have bookmarked, and a stale one must not strand them on a
13874
+ * broken editor. It also means `?wiki=` can never be used to START a wiki —
13875
+ * starting one is an explicit action, not a side effect of a GET.
13876
+ *
13877
+ * A REGISTERED but not-running id must NOT fall back. It used to, and the
13878
+ * failure was silent and destructive: the settings page ("正在配置:books")
13879
+ * wrote the books patch into the DEFAULT wiki's config tiddler, the quick-note
13880
+ * card listed stopped wikis and then saved into the default one, and
13881
+ * `/admin/info?wiki=<stopped>` rewrote the default wiki's `tiddlywiki.info`.
13882
+ * The named wiki is unambiguously known here, so there is nothing to guess:
13883
+ * callers get `undefined` and answer 503 with the wiki named (see
13884
+ * `stoppedWikiFromRequest` for the message). This is the same answer the
13885
+ * `/tw/<id>/` proxy already gave (it resolves LAST, exactly so a named-but-
13886
+ * stopped wiki cannot be proxied to whoever happens to be the default).
13751
13887
  */
13752
13888
  function targetRuntimeFor(farm, req) {
13889
+ if (farm === void 0) return void 0;
13890
+ const id = wikiIdFromRequest(req);
13891
+ if (id === void 0) return farm.defaultRuntime();
13892
+ const runtime = farm.runtime(id);
13893
+ if (runtime !== void 0) return runtime;
13894
+ if (farm.registry.wikis.some((entry) => entry.id === id)) return void 0;
13895
+ return farm.defaultRuntime();
13896
+ }
13897
+ /**
13898
+ * The REGISTERED wiki a request names which is not running — the reason a
13899
+ * request-scoped target resolved to `undefined` (v0.29.0).
13900
+ *
13901
+ * Exists so every 503 can NAME the knowledge base and tell the user what to do,
13902
+ * instead of the generic "wiki service is not running" that made the old silent
13903
+ * fallback look like a success. Returns `undefined` when the request names no
13904
+ * wiki, names a running one, or names an unknown id (that last case still falls
13905
+ * back to the default by design — see `targetRuntimeFor`).
13906
+ */
13907
+ function stoppedWikiFromRequest(farm, req) {
13908
+ if (farm === void 0) return void 0;
13753
13909
  const id = wikiIdFromRequest(req);
13754
- if (id === void 0) return farm?.defaultRuntime();
13755
- return farm?.runtime(id) ?? farm?.defaultRuntime();
13910
+ if (id === void 0) return void 0;
13911
+ if (farm.runtime(id) !== void 0) return void 0;
13912
+ return farm.registry.wikis.find((entry) => entry.id === id);
13913
+ }
13914
+ /**
13915
+ * The sentence every refusal carries (v0.29.0).
13916
+ *
13917
+ * Exported so the host wiring and the verification harness cannot drift apart on
13918
+ * what a user reads — the guard asserts this text appears in the 503 body.
13919
+ */
13920
+ function stoppedWikiMessage(entry) {
13921
+ return `知识库「${entry.label}」当前没有运行:先在「知识库」菜单里启动它再试`;
13756
13922
  }
13757
13923
  function resolveAgentScope(farm, scopes, sessionId) {
13758
13924
  if (farm === void 0) return { reason: "知识库插件尚未就绪(还没读完清单)" };
@@ -14167,7 +14333,22 @@ async function writeSessionScopes(state, file = defaultSessionScopeFile()) {
14167
14333
  async function setSessionScope(sessionId, wikiId, file = defaultSessionScopeFile()) {
14168
14334
  if (!isSafeSessionId(sessionId)) throw new Error(`会话 id 非法:${JSON.stringify(sessionId)}`);
14169
14335
  if (wikiId !== void 0 && !isSafeWikiId(wikiId)) throw new Error(`知识库 id 非法:${JSON.stringify(wikiId)}`);
14170
- const existing = await readFile(file, "utf8").catch(() => "");
14336
+ /**
14337
+ * 读失败 ≠ 没有文件(铁律 #3)。
14338
+ *
14339
+ * Only ENOENT may be treated as "no scope file yet". Any other error
14340
+ * (EACCES/EBUSY, a Windows file lock, or losing the race against a concurrent
14341
+ * `writeSessionScopes` tmp+rename) used to collapse into `''` — and because the
14342
+ * next lines are a read-modify-WRITE of the whole file, that silently dropped
14343
+ * every OTHER session's knowledge-base scope. Throwing keeps the file intact
14344
+ * and surfaces the failure to the selector instead.
14345
+ */
14346
+ let existing = "";
14347
+ try {
14348
+ existing = await readFile(file, "utf8");
14349
+ } catch (err) {
14350
+ if (err.code !== "ENOENT") throw err;
14351
+ }
14171
14352
  const pruned = pruneScopes(existing.length > 0 ? parseState(existing).state ?? {
14172
14353
  version: 1,
14173
14354
  sessions: {}
@@ -15467,6 +15648,19 @@ function apply(ctx, rawConfig = {}) {
15467
15648
  * selector (and an unknown id falls back instead of 404ing).
15468
15649
  */
15469
15650
  const target = (req) => targetRuntimeFor(farm, req);
15651
+ /**
15652
+ * Why this request cannot be served (v0.29.0): set when `?wiki=<id>` names a
15653
+ * REGISTERED but STOPPED knowledge base. `targetRuntimeFor` deliberately
15654
+ * returns undefined for that case instead of falling back to the default wiki
15655
+ * (an unknown id still falls back — a stale bookmark must not 404), so every
15656
+ * route that either 503s on a missing client or has a harmless-looking
15657
+ * fallback (config / wiki path / prompt / status) can say WHICH wiki is down
15658
+ * and what to do, instead of quietly acting on a different one.
15659
+ */
15660
+ const targetProblem = (req) => {
15661
+ const entry = stoppedWikiFromRequest(farm, req);
15662
+ return entry === void 0 ? void 0 : stoppedWikiMessage(entry);
15663
+ };
15470
15664
  const fallbackUi = WikiInstance.uiDefaultsFrom(config);
15471
15665
  const fallbackWechat = normalizeWechatConfig(config.wechat);
15472
15666
  /** Used only while nothing runs: base defaults, no wiki tiddler to read. */
@@ -15501,6 +15695,7 @@ function apply(ctx, rawConfig = {}) {
15501
15695
  noteDefaults: (req) => ({ tag: target(req)?.noteTag() ?? config.note.tag }),
15502
15696
  uiDefaults: (req) => target(req)?.uiDefaults() ?? fallbackUi,
15503
15697
  getWikiPath: (req) => target(req)?.path ?? defaultPath(),
15698
+ targetProblem,
15504
15699
  restartAffected: (_req, dir, changedFiles) => restartAffectedWikis({
15505
15700
  farm: () => farm,
15506
15701
  repoRootOf: (d) => repos.repoRootOf(d)
@@ -15515,7 +15710,8 @@ function apply(ctx, rawConfig = {}) {
15515
15710
  id: resolution.entry.id,
15516
15711
  label: resolution.entry.label
15517
15712
  } } : {},
15518
- ...resolution.reason !== void 0 ? { reason: resolution.reason } : {}
15713
+ ...resolution.reason !== void 0 ? { reason: resolution.reason } : {},
15714
+ mode: farm?.registry.mode ?? "single"
15519
15715
  };
15520
15716
  },
15521
15717
  set: async (sessionId, wikiId) => {
@@ -15556,6 +15752,7 @@ function apply(ctx, rawConfig = {}) {
15556
15752
  server: (req) => target(req)?.server,
15557
15753
  getClient: (req) => target(req)?.client(),
15558
15754
  getWikiPath: (req) => target(req)?.path ?? defaultPath(),
15755
+ targetProblem,
15559
15756
  twRoot: resolveTwRoot,
15560
15757
  config: (req) => target(req)?.config ?? idleConfig,
15561
15758
  onConfigChanged: () => {
@@ -15687,6 +15884,6 @@ function apply(ctx, rawConfig = {}) {
15687
15884
  }, "dsh-tiddlywiki: host teardown");
15688
15885
  }
15689
15886
  //#endregion
15690
- export { AGENT_WRITTEN_TAG, ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, ANON_USERNAME, AutoCommitter, CLIP_BRIDGE_BOOKMARKLET, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_DRAG_HREF, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigPatchError, ConfigStore, ConfigUnreadableError, DEFAULT_NOTE_TYPE, DEFAULT_PROMPT_MODE, DEFAULT_WECHAT_ADAPTER, DEFAULT_WECHAT_COMMAND, DEFAULT_WIKI_ID, DEFAULT_WIKI_MODE, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitConflictStateError, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, HUMAN_EDITED_TAG, LOCATION_STATE_VERSION, MARKDOWN_PLUGIN, MASKED_SECRET, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, MISSING_TYPE_FILTER, PATH_PREFIX, PROMPT_GOVERNANCE_BLOCKS, PROMPT_MODES, PROMPT_SECTION_NAME, PROMPT_SECTION_ORDER, READY_HARD_FACTOR, READY_POLL_MS, READY_SLOW_POLL_MS, READY_TIMEOUT_DEFAULT_MS, READY_TIMEOUT_MAX_MS, READY_TIMEOUT_MIN_MS, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_FILE, RENDER_PLUGIN_TITLE, RESERVED_WIKI_IDS, RepoCommitters, SEED_DEFS, SEED_MARKER_VERSION, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SCOPE_MAX_AGE_MS, SESSION_SCOPE_VERSION, SESSION_SUMMARY_PREFIX, SKILL_FILE_NAME, SPLIT_SKILL_DIR, SPLIT_SKILL_MARKER, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, TEXT_LIST_FILTER, TRASH_INDEX_TITLE, TRASH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, TrashIndexUnavailableError, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WECHAT_ADAPTERS, WECHAT_ADAPTER_FILES, WECHAT_ADAPTER_MARKER, WECHAT_DOCS_MARKER_TITLE, WECHAT_DOCS_TEXT, WECHAT_DOCS_TITLE, WECHAT_PUBLISH_BUNDLE_TEXT, WECHAT_PUBLISH_MARKER_TITLE, WECHAT_PUBLISH_PLUGIN_TITLE, WIKI_ICON_NAMES, WIKI_MODES, WIKI_REGISTRY_VERSION, WORKSPACE_FIELD, WORKSPACE_TAG_PREFIX, WechatPublishRunner, WikiFarm, WikiInstance, WikiServer, WriteConflictError, apply, applyWikiAction, assertNoConflict, assertPublicImageUrl, awaitReady, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildPromptText, buildPublishInvocation, buildVersionInvocation, buildWriteTiddler, bundledCatalog, capOutput, checkAllSeeds, checkWechatReady, cleanTiddler, clearLocationState, deepMerge, defaultAdaptersDir, defaultEntry, defaultLocationStateFile, defaultRegistryFile, defaultSessionScopeFile, defaultSkillRoot, defaultWechatJobDir, defineTool, deriveWikiId, describeConflict, describePrompt, describeUnreadableConfig, docNoteText, downloadClipImage, drainThenStop, dshHomePath, ensureLanguage, ensurePlugin, ensureTiddlerTimestamps, entryLocation, entryPath, escapeInline, escapePromptBraces, expandEnvPath, findEntry, findPathConflicts, flattenTiddlerFields, flushPendingWrites, formatTiddlerDate, hashText, hostAllowed, imageExtensionForMime, inject, installSplitSkill, interpretPublishOutcome, isBinaryType, isInsidePath, isJunkTag, isPrivateAddress, isSafeCliValue, isSafeDsn, isSafeSessionId, isSafeUrl, listWikiCandidates, locationPath, maskConfigSecrets, name, needsRestartAfterSeeds, normalizeConfigPatch, normalizeEntry, normalizeFieldsArg, normalizeLocation, normalizePromptMode, normalizePromptPreview, normalizeReadyTimeoutMs, normalizeThemes, normalizeWechatConfig, normalizeWikiIcon, normalizeWikiId, normalizeWorkspaceName, openInTwEditor, parseClipPayload, parseSeedMarker, parseTiddlerDate, pathComparisonKey, pickImageMime, pinLanguageTiddler, proxyBaseFor, pruneScopes, readActiveThemeName, readBundledSkillText, readLocationState, readRegistry, readSeedMarker, readSessionScopes, readWikiInfo, readyHardTimeoutMs, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, removeWiki, resolveAgentScope, resolveClipTitle, resolveNodeExecutable, resolveTwRoot, resolveWikiRoot, runAllSeeds, runSeedById, sanitizeTwFragment, scanAdapterDir, scopeBanner, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, seedWechatDocs, seedWechatPublish, setSessionScope, singleEntryRegistry, stripMaskedSecrets, switchWiki, tailOf, targetRuntimeFor, tiddlywikiToolSummary, toIsoDateString, toolSignatureLines, unseedClipBridge, unseedWechatDocs, unseedWechatPublish, upsertWiki, validateRegistry, waitForFileWrite, wikiIdFromRequest, withScopeBanner, workspaceMarkFromCwd, workspaceNameFromCwd, workspaceTagName, writeLocationState, writeRegistry, writeSeedMarker, writeSessionScopes, writeSessionSummary, writeWikiInfo };
15887
+ export { AGENT_WRITTEN_TAG, ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, ANON_USERNAME, AutoCommitter, CLIP_BRIDGE_BOOKMARKLET, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_DRAG_HREF, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigPatchError, ConfigStore, ConfigUnreadableError, DEFAULT_NOTE_TYPE, DEFAULT_PROMPT_MODE, DEFAULT_WECHAT_ADAPTER, DEFAULT_WECHAT_COMMAND, DEFAULT_WIKI_ID, DEFAULT_WIKI_MODE, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitConflictStateError, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, HUMAN_EDITED_TAG, LOCATION_STATE_VERSION, MARKDOWN_PLUGIN, MASKED_SECRET, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, MISSING_TYPE_FILTER, PATH_PREFIX, PROMPT_GOVERNANCE_BLOCKS, PROMPT_MODES, PROMPT_SECTION_NAME, PROMPT_SECTION_ORDER, READY_HARD_FACTOR, READY_POLL_MS, READY_SLOW_POLL_MS, READY_TIMEOUT_DEFAULT_MS, READY_TIMEOUT_MAX_MS, READY_TIMEOUT_MIN_MS, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_FILE, RENDER_PLUGIN_TITLE, RESERVED_WIKI_IDS, RepoCommitters, SEED_DEFS, SEED_MARKER_VERSION, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SCOPE_MAX_AGE_MS, SESSION_SCOPE_VERSION, SESSION_SUMMARY_PREFIX, SKILL_FILE_NAME, SPLIT_SKILL_DIR, SPLIT_SKILL_MARKER, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, TEXT_LIST_FILTER, TRASH_INDEX_TITLE, TRASH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, TrashIndexUnavailableError, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WECHAT_ADAPTERS, WECHAT_ADAPTER_FILES, WECHAT_ADAPTER_MARKER, WECHAT_DOCS_MARKER_TITLE, WECHAT_DOCS_TEXT, WECHAT_DOCS_TITLE, WECHAT_PUBLISH_BUNDLE_TEXT, WECHAT_PUBLISH_MARKER_TITLE, WECHAT_PUBLISH_PLUGIN_TITLE, WIKI_ICON_NAMES, WIKI_MODES, WIKI_REGISTRY_VERSION, WORKSPACE_FIELD, WORKSPACE_TAG_PREFIX, WechatPublishRunner, WikiFarm, WikiInstance, WikiServer, WriteConflictError, apply, applyWikiAction, assertNoConflict, assertPublicImageUrl, awaitReady, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildPromptText, buildPublishInvocation, buildVersionInvocation, buildWriteTiddler, bundledCatalog, capOutput, checkAllSeeds, checkWechatReady, cleanTiddler, clearLocationState, deepMerge, defaultAdaptersDir, defaultEntry, defaultLocationStateFile, defaultRegistryFile, defaultSessionScopeFile, defaultSkillRoot, defaultWechatJobDir, defineTool, deriveWikiId, describeConflict, describePrompt, describeUnreadableConfig, docNoteText, downloadClipImage, drainThenStop, dshHomePath, ensureLanguage, ensurePlugin, ensureTiddlerTimestamps, entryLocation, entryPath, escapeInline, escapePromptBraces, expandEnvPath, findEntry, findPathConflicts, flattenTiddlerFields, flushPendingWrites, formatTiddlerDate, hashText, hostAllowed, imageExtensionForMime, inject, installSplitSkill, interpretPublishOutcome, isBinaryType, isInsidePath, isJunkTag, isPrivateAddress, isSafeCliValue, isSafeDsn, isSafeSessionId, isSafeUrl, listWikiCandidates, locationPath, maskConfigSecrets, name, needsRestartAfterSeeds, normalizeConfigPatch, normalizeEntry, normalizeFieldsArg, normalizeLocation, normalizePromptMode, normalizePromptPreview, normalizeReadyTimeoutMs, normalizeThemes, normalizeWechatConfig, normalizeWikiIcon, normalizeWikiId, normalizeWorkspaceName, openInTwEditor, parseClipPayload, parseSeedMarker, parseTiddlerDate, pathComparisonKey, pickImageMime, pinLanguageTiddler, proxyBaseFor, pruneScopes, readActiveThemeName, readBundledSkillText, readLocationState, readRegistry, readSeedMarker, readSessionScopes, readWikiInfo, readyHardTimeoutMs, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, removeWiki, resolveAgentScope, resolveClipTitle, resolveNodeExecutable, resolveTwRoot, resolveWikiRoot, runAllSeeds, runSeedById, sanitizeTwFragment, scanAdapterDir, scopeBanner, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, seedWechatDocs, seedWechatPublish, setSessionScope, singleEntryRegistry, stoppedWikiFromRequest, stoppedWikiMessage, stripMaskedSecrets, switchWiki, tailOf, targetRuntimeFor, tiddlywikiToolSummary, toIsoDateString, toolSignatureLines, unseedClipBridge, unseedWechatDocs, unseedWechatPublish, upsertWiki, validateRegistry, waitForFileWrite, wikiIdFromRequest, withScopeBanner, workspaceMarkFromCwd, workspaceNameFromCwd, workspaceTagName, writeLocationState, writeRegistry, writeSeedMarker, writeSessionScopes, writeSessionSummary, writeWikiInfo };
15691
15888
 
15692
15889
  //# sourceMappingURL=index.js.map