dsh-tiddlywiki 0.20.0 → 0.20.1

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/README.md CHANGED
@@ -26,7 +26,7 @@
26
26
  | 🏠 **文档中心起步包** | 首次安装自动 seed:插件说明 + 「示例与文档」(主题汇总模板 / 教程 / 三个示例主题页),首页「📚 插件文档」栏一键查阅;**同名 tiddler 已存在一律安全跳过,绝不覆盖你的数据**(v0.16.22) |
27
27
  | 🎨 **自定义样式** | 「自定义样式」seed:编辑器美化 / 窄屏侧栏隐藏 / menubar 加高 / 批注弹窗等 5 张通用样式表,新 wiki 也能一键初始化(可选,v0.16.22) |
28
28
  | 🤖 **Agent 工具** | 15 个 `tiddlywiki_*` 工具:检索(**相关度排序 + 命中处片段 + 字段过滤**)、读写、**增量追加**、批量、重命名、**软删除/回收站**、**反向链接**、**附件入库**、**知识库体检**、git 同步与冲突解决(v0.19.0;检索/最近仍在**服务端**排除二进制附件,大 wiki 上从 515MB/17s 降到 ~0.4s) |
29
- | 🛡️ **不会被覆盖的写入** | 所有写入路径(agent 工具 **与** 快速笔记/编辑器路由)都**先读后写**:不传 tags 就保留原有标签与自定义字段;`tiddlywiki_put(..., expectedModified/expectedRevision)` 与 `tiddlywiki_delete(..., expectedModified/expectedRevision)` 乐观并发——读取后若有人(在 TW 编辑器里)改过,写入/删除被拒绝(HTTP 409)而不是静默覆盖或丢进回收站;`tiddlywiki_attach` 的同名标题**默认拒绝**(要覆盖必须 `force: true`);`tiddlywiki_delete` 默认**软删除进回收站**,`tiddlywiki_trash` 可恢复(v0.19.0 / v0.19.1 / v0.19.5) |
29
+ | 🛡️ **不会被覆盖的写入** | 所有写入路径(agent 工具 **与** 快速笔记/编辑器路由)都**先读后写**:不传 tags 就保留原有标签、自定义字段与**内容类型**(`text/css`/wikitext 等不会被重置成 Markdown,v0.20.1);`tiddlywiki_put(..., expectedModified/expectedRevision)` 与 `tiddlywiki_delete(..., expectedModified/expectedRevision)` 乐观并发——读取后若有人(在 TW 编辑器里)改过,写入/删除被拒绝(HTTP 409)而不是静默覆盖或丢进回收站;`tiddlywiki_attach` 的同名标题**默认拒绝**(要覆盖必须 `force: true`);`tiddlywiki_delete` 默认**软删除进回收站**,`tiddlywiki_trash` 可恢复(v0.19.0 / v0.19.1 / v0.19.5) |
30
30
  | 🧼 **渲染片段净化** | 回复流卡片与会话汇总注入的 TW 片段先经 **host 白名单净化**(丢 `iframe`/`script`/`svg`/`on*`/`javascript:`/`data:text/html` 等)——TW 自己的解析器只剥 `on*`,`<iframe src="javascript:…">` 会原样通过并在 DSH 页面里执行(v0.19.1 修复的存储型 XSS) |
31
31
  | 🔒 **写路由方法校验** | 每个写路由只接受自己的 HTTP 方法:跨站 `GET /sync`、`GET /restart`、`GET /upload` 一律 405 且无副作用(v0.19.0 修复了「任意网页一张 `<img>` 即可触发 pull/commit/push」的 CSRF 面) |
32
32
  | 🔑 **密钥不外泄** | `bridge.token` / `ui.sendToAgent.token` / **`auth.password`**(v0.20.0)在 `/admin/state`、`/admin/config` 的回包里是 `********`(设置页原样保存 ≠ 覆盖,清空即删除),`git.remote` 里的 PAT 打码;**`/tw`、`/api` 与 `POST /render` 都拒绝 `$:/plugins/dsh-tiddlywiki/` 命名空间**——此前 `/render` 能把配置 tiddler 连 token 与 PAT 一起渲染出来(v0.19.3 / v0.20.0) |
@@ -87,9 +87,9 @@ dsh plugin --profile web add link:/path/to/dsh-tiddlywiki
87
87
  | `tiddlywiki_recent` | 最近修改的笔记(倒序),开工快速了解动态 |
88
88
  | `tiddlywiki_list_tags` | 现有非系统 tag 及计数(已排除只挂在二进制附件上的 tag);默认列使用最多的 200 个(`limit` 可调、上限 1000),截断时返回 `total`/`truncated` |
89
89
  | `tiddlywiki_get` | 读单个 tiddler 全文(`modified` 以 ISO 返回,可直接用作 `expectedModified`) |
90
- | `tiddlywiki_put` | 写/覆盖;未指定类型时自动默认 `text/markdown`(`$:/` 系统条目除外);`expectedModified` + `force` 提供乐观并发保护 |
90
+ | `tiddlywiki_put` | 写/覆盖;**新建**未指定类型时默认 `text/markdown`(`$:/` 系统条目除外),**覆盖既有条目时保留原 `type`/tags/自定义字段**(v0.20.1,改类型要显式 `fields.type`);`expectedModified` + `force` 提供乐观并发保护 |
91
91
  | `tiddlywiki_batch_put` | 批量写入(`overwrite=false` 跳过已存在;单条失败不影响其余、逐条报错;4 路并发但结果保持入参顺序) |
92
- | `tiddlywiki_append` | **增量追加**:`mode=append\|prepend`、`heading=某标题` 定位段落,写日志/批注无需读全文 |
92
+ | `tiddlywiki_append` | **增量追加**:`mode=append\|prepend`、`heading=某标题` 定位段落,写日志/批注无需读全文;与 `put` 同一套写策略(保留原 `type`/tags/自定义字段,新条目才默认 markdown),支持 `fields` 显式覆盖 |
93
93
  | `tiddlywiki_rename` | 重命名 + 尽量同步其他条目里的引用;旧标题删除失败时如实回报「两份副本都在」 |
94
94
  | `tiddlywiki_delete` | 删除(幂等)。默认**软删除**进 `$:/dsh-tiddlywiki/trash/`,`permanent=true` 才真删;支持 `expectedModified`/`expectedRevision`/`force` 乐观并发(读后被改动则拒绝,不把人类的新改动丢进回收站) |
95
95
  | `tiddlywiki_trash` | 回收站:`action=list\|restore\|empty`(索引读不到或损坏时显式报错,绝不把回收站当空的重建) |
@@ -298,6 +298,8 @@ lib/ # 预构建产物(发布含 lib/**,提交入库;
298
298
 
299
299
  > 最近几个主要版本的一句话记录(完整变更见 [Releases](https://github.com/bbqisbbq/dsh-tiddlywiki/releases) / git log)。
300
300
 
301
+ - **v0.20.1**(2026-09-11):**修复「覆盖已有条目时内容类型被静默重置」**(数据正确性)。`tiddlywiki_put` / `tiddlywiki_append` / `tiddlywiki_batch_put` 覆盖既有条目时会丢掉它的 `type`:`cleanTiddler()` 把 `type` 当成「构造 PUT body 时跳过的字段」,于是 `finalTypeForWrite()` 再补默认值——`put` 把 `text/css` 的样式条目改成 `text/markdown`(整篇 CSS 被当 Markdown 渲染、样式静默失效)、把 wikitext 笔记改成 Markdown;`append` 更彻底:它自己手写 PUT body,连 `type` 都不写,TW 于是回落 `text/vnd.tiddlywiki`(磁盘上 `.md` + `.meta` 变成 `.tid`,`##`/`**粗体**`/表格全按 wikitext 解析)。现在:**覆盖路径一律保留原 `type`**,Markdown 默认值只给**新建**条目(`$:/` 系统条目除外);`tiddlywiki_append` 与 `put` 共用同一套写策略(`buildWriteTiddler`),并补上了此前缺失的 `fields` 参数;`rename` 与回收站恢复同样不再丢类型。回执也补了诚实提示:新建默认时写「新建且未指定,已默认 markdown」,覆盖时若类型真的变了会写「⚠️ 内容类型已从 X 改为 Y」。守门:新增 `scripts/verify-write-policy.mjs`(7 条纯函数单测,已进 `verify:unit`)与 `verify-audit-fixes.mjs` 的 6 条 E2E(css 保持 css / wikitext 保持 wikitext / markdown 追加不变 / 新建仍默认 markdown / `fields.type` 是唯一改类型入口 / rename+回收站恢复保类型)。
302
+
301
303
  - **v0.20.0**(2026-09-11):**第四轮代码审计的修复版**(安全 / 用户可见缺陷 / 工程守门 / 死代码清理)。**安全**:① `POST /dsh-tiddlywiki/render` 没有像 `/get`、`/tw`、`/api` 那样拦插件命名空间——TW 的 `/render` 对**任意标题**都渲染,于是 `{"title":"$:/plugins/dsh-tiddlywiki/config"}` 能把配置 tiddler 连 `bridge.token` 与带 PAT 的 `git.remote` 一起渲染出来(本机临时 wiki 实测复现;片段净化器只管标签、管不住正文)。现在抽出一份 `isBlockedProxyTitle()` 并覆盖 `/render`(selftest 断言 403)。② `maskConfigSecrets()` 此前**只遮 token 与 git remote**,而文档一直声称 `auth.password` 也被遮——实测原样返回;现在 `auth.password` 一并打码并附 `passwordSet` 展示位,回填掩码同样被 `stripMaskedSecrets()` 丢弃。**用户可见缺陷**:③ TW 的 `$tw.notifier.display()` 只认**已存在的 tiddler 标题**、不存在时**什么都不做**,而「发送给 Agent」的 `notify()` 传的是自由文本——**所有成功/失败提示都是空操作**(用户点完按钮毫无反馈);现在先写 `$:/temp/dsh/send-to-agent/notice` 再 display(bundle 0.3.4 → 0.3.5)。④ 快速笔记卡片丢掉了 `404 + {notFound:true}` 的响应体,`notFound` 分支永不成立,于是「条目不存在」被显示成「wiki 服务不可用」;`fetchJson` 现在保留 404 正文。⑤ `/edit` 的草稿查找在 v0.19.5 重构时把分支写反:规范草稿**已存在**时反而直接覆盖(丢用户未保存的编辑),扫描到的异构草稿被改名为新条目(留孤儿);恢复 `free ? canonical : 复用既有草稿 ?? 时间戳` 的避让语义,并补 E2E 断言。⑥ 会话「知识库」Tab 的「修改」时间用 `Date.parse()` 解析 TW 紧凑格式(`20260101000000000` → NaN)后原样回显成 17 位数字;改用 `parseTiddlerDate()`。⑦ 设置页点「同步 / 重启 TW」会 `refresh()` 重建整块配置表单,**静默丢弃未保存的输入**;现在只有服务端配置真的变了才重建。⑧ `tiddlywiki_search` 的 `field`/`value` 只在工具层生效,客户端卡片不发送、host 路由也不读——卡片列出的是**未过滤**结果;两侧已打通。⑨ 草稿自动保存调用了带副作用的 `getTags()`,会把 tag 框里没回车的半截词提交成标签;新增纯读的 `peekTags()`。**工程守门**:⑩ CI 之前各自维护一份脚本清单,与 `package.json` 双向漂移——`verify-status-cache` 与 `verify-audit-fixes`(v0.19.5 全部数据安全修复的唯一回归)**从未在 CI 跑过**;现在 CI 只调 `npm run verify:static|verify:unit|verify:e2e|verify:large`。⑪ `verify-render-bundle` 的 `module-type` 断言查的是源码注释而不是 tiddler 字段(假断言,字段丢了照样绿);`verify-package-contents` 不守 `src`/`docs` 也不禁 `scripts/`;均已修正。⑫ 三个浏览器脚本硬编码 `D:/npm-global/...puppeteer-core` 且 Chrome 候选表漂移(Linux 上恒 SKIP),抽出 `scripts/lib/browser-env.mjs`;`waitFor` 与迷你路由分发器各抽成 `scripts/lib/tw-harness.mjs`;`TEXT_LIST_FILTER` 长度预算移进新的 `verify-constants.mjs`(static 档,不再只在两个最重的 TW 套件里断言)。**死代码 / 重复**:⑬ 开启 `tsconfig.noUnusedLocals` 并清掉 16 处死 import/死类型;删 `FLUSH_PROBE_FILE`、`fetchUiConfig({force})`、`sidebar-entry` 的 `initialLabel`、`ToolCallOwnerProps` 未用字段、四个卡片的死 `text` prop、`headerTitle` 的 7 个空 case、`__twDebug`、`panel.ts` 的兼容 re-export、一行包装 `setSessionSummaryTabLabel`;`invalidateStatus()` 由 `invalidateUiConfig()` 一并调用(设置页保存后 FAB/侧栏立即读新值);`GitStatusView` 三份合成一份,`snippetOf`/`safeEqual`/`assertPublicImageUrl` 各合成一份;客户端端点字面量统一收进 `endpoints.ts`;`defineTool` 从不被读取的 `output.schema` 参数移除。**文档**:修掉过期注释与文档(客户端仍写 `/tw/render`、`tool-views` 头部仍称「HTML 来自本地 wiki 所以安全」、`host/routes.ts` 文件头只列 6 条路由、生成的 seed docstring 仍写 `Never throws.`)。守门:`verify-send-to-agent-bundle` 补外层 version 断言与 notify 回归,`verify-secret-masking` 补 auth 用例,`verify-status-cache` 补 invalidate 联动,selftest 补 `/render` 403。
302
304
 
303
305
  - **v0.19.5**(2026-09-11):**第三轮代码审计的修复版**(数据安全 / 正确性 / 客户端网络卫生 / 工具性能)。**数据安全**:① `tiddlywiki_attach` 此前**完全不读旧条目**就 `PUT`——附件标题一旦撞上既有笔记(`会议纪要.png` 之类)会把那篇笔记连 tags/自定义字段/正文一起**静默替换成 base64**,是本轮最危险的一条。现在先读后写(004 = 新建,其余错误照常抛出),撞名时**默认拒绝**并要求 `force: true`(并发令牌只证明「读过」、不等于「同意覆盖」),覆盖时仍保留原 tags 与自定义字段,新建附件也会补 `agent-written`;嵌入笔记(`noteTitle`)同样走写策略。② `/edit`(快速笔记直达 TW 原生编辑器)此前把草稿写死 `text/markdown`,而 TW 保存草稿会把字段抄回原条目——**任何 wikitext 笔记被这样编辑一次就降级成 Markdown**,`!` 标题 / `<$list>` / `[[链接]]` 下次全渲染成源码。现在草稿类型跟随原条目,只有新笔记才回落 Markdown。③ 回收站索引读失败/JSON 损坏时,`delete` 此前把它当「空索引」再全量覆盖 → **之前所有回收站记录变成既查不到、也清不掉的孤儿**(体积还留在 git 里)。现在读失败即中止(新增 `TrashIndexUnavailableError`),损坏也不静默重建,`trash list/restore/empty` 一律显式报错。④ `rename` 先写新标题、再删旧标题,此前若删除失败会**抛裸错**让调用方以为整个重命名没发生;现在如实回报「两份副本都在,请手动删除旧标题」。⑤ `tiddlywiki_delete` 支持 `expectedModified`/`expectedRevision`/`force`——删除比覆盖更具破坏性,此前却没有任何乐观并发保护(`get` → 人类在 TW 里改动 → `delete` 会把新改动一起丢进回收站)。**正确性**:⑥ 会话汇总只探测前 300 篇,却把**所有**条目按探测结果渲染,超出上限的会被谎报成「⚠️ 已删除/不存在」;现在未探测的条目标「(未探测,超出单次查询上限)」。⑦ `/api` 透传补上方法白名单(宿主按 pathname 分发,此前 TRACE 之类的任意方法都会被转发给 TW)。⑧ `/tw` 代理遇到带 body 的 DELETE 不再把请求体留在 socket 上(此前会挂到 30s 超时)。⑨ `/admin/config` 不再把所有失败都当 400(服务不可用/超限走 413/500);`/upload` 的「超限」判定与 `errorStatus` 统一。⑩ `WikiServer` 成功启动时清掉上一次失败的 `error`(自愈成功后 `/status` 与面板提示不再长期显示过期故障)。**工具性能**:⑪ `batch_put` 从「顺序 2N 次 REST」改为 **4 路有界并发**(结果按入参下标回填,顺序与逐条容错完全不变),`autoCommit` 移出循环。⑫ 快速笔记找草稿从「拉整份 listing」改为**先单条 GET 规范草稿名**,listing 只作兜底。**客户端网络卫生**:⑬ 新增共享的 `/status` 读取器(2s TTL + **在途合并**)——此前中央面板、侧栏框架、同步按钮、快速笔记配置**四处各有一份 `fetchStatus`**,页面加载时会同时发多次请求,而 host 每处理一次 `/status` 要起最多 5 个 git 进程;⑭ `ui-config` 的缓存同样改为 promise(并发调用共享一次请求),失败不缓存。**其它**:`ToolsDeps.noteTag` 死代码删除;`put`/`batch_put` 描述改为「防抖自动 commit(默认 60s)」以免模型误以为写完即提交。守门:新增 `scripts/verify-audit-fixes.mjs`(10 条 E2E,真实 TW + 真实路由,每条都能复现旧缺陷)与 `scripts/verify-status-cache.mjs`(5 条单测,`tsx` 直跑源码),均已并入 `verify:unit` / `verify:e2e`。
package/lib/index.js CHANGED
@@ -3555,12 +3555,22 @@ const AGENT_WRITTEN_TAG = "agent-written";
3555
3555
  const HUMAN_EDITED_TAG = "human-edited";
3556
3556
  /** Agent / 人类笔记的默认内容类型(TW 对无 type 的条目按 wikitext 解析)。 */
3557
3557
  const DEFAULT_NOTE_TYPE = "text/markdown";
3558
- /** 构造 PUT body 时跳过的字段(身份/内容/TW 自己的时间戳)。 */
3558
+ /**
3559
+ * 构造 PUT body 时跳过的字段(身份/内容/TW 自己的时间戳)。
3560
+ *
3561
+ * ⚠️ `type` **不在**这里(v0.20.1 修复):它曾被误列为跳过字段,于是
3562
+ * `cleanTiddler(existing)` 把条目的内容类型丢掉,`finalTypeForWrite()` 随后又
3563
+ * 补上默认的 `text/markdown`(或干脆不写 type → TW 回落 `text/vnd.tiddlywiki`)。
3564
+ * 结果:任何「覆盖已有条目」的路径都会静默改掉类型——
3565
+ * - `text/css` 的样式条目被改成 `text/markdown` → 整篇 CSS 被当 Markdown 渲染;
3566
+ * - `text/markdown` 笔记被改成 `text/vnd.tiddlywiki` → `##`/`**粗体**`/表格全按
3567
+ * wikitext 解析(磁盘上 `.md` + `.meta` 也变成 `.tid`)。
3568
+ * 内容类型是条目的解析方式,必须与 tags/自定义字段一样按「以已有条目为基底」保留。
3569
+ */
3559
3570
  const CLEAN_SKIP_FIELDS = /* @__PURE__ */ new Set([
3560
3571
  "title",
3561
3572
  "text",
3562
3573
  "tags",
3563
- "type",
3564
3574
  "created",
3565
3575
  "modified",
3566
3576
  "fields"
@@ -3666,9 +3676,17 @@ function finalTagsForWrite(title, existing, tags, agentTag) {
3666
3676
  if (tags.includes("agent-written")) return tags;
3667
3677
  return [...tags, AGENT_WRITTEN_TAG];
3668
3678
  }
3669
- /** 计算最终内容类型(原地改 tiddler):显式 type 优先,`$:/` 保持 TW 默认,其余默认 Markdown。 */
3670
- function finalTypeForWrite(title, tiddler) {
3679
+ /**
3680
+ * 计算最终内容类型(原地改 tiddler):显式 type 优先;**只有新建条目**才回落
3681
+ * Markdown,`$:/` 条目保持 TW 默认。
3682
+ *
3683
+ * v0.20.1:`isNew=false`(覆盖既有条目)时**绝不**发明类型——基底里有什么就是
3684
+ * 什么;基底没有 type(极少数情况)也保持「没有」,让 TW 继续按 wikitext 处理,
3685
+ * 而不是把它升级成 Markdown。改类型是调用方的显式动作(`fields.type`)。
3686
+ */
3687
+ function finalTypeForWrite(title, tiddler, isNew = true) {
3671
3688
  if (typeof tiddler.type === "string" && tiddler.type.length > 0) return { defaulted: false };
3689
+ if (!isNew) return { defaulted: false };
3672
3690
  if (title.startsWith("$:/")) return { defaulted: false };
3673
3691
  tiddler.type = DEFAULT_NOTE_TYPE;
3674
3692
  return { defaulted: true };
@@ -3676,9 +3694,9 @@ function finalTypeForWrite(title, tiddler) {
3676
3694
  /**
3677
3695
  * 构造一次写要 PUT 的 tiddler。
3678
3696
  *
3679
- * - 已有条目 → 以其为基底(标签、自定义字段、内容类型全部保留);
3697
+ * - 已有条目 → 以其为基底(标签、自定义字段、**内容类型**全部保留);
3680
3698
  * - 新条目 → `{title, text}` + 默认/显式标签(`$:/` 不带 agent 标签);
3681
- * - `fields` 逐个覆盖;`type` 未指定时补 Markdown(`$:/` 除外)。
3699
+ * - `fields` 逐个覆盖;`type` 未指定时**仅新建条目**补 Markdown(`$:/` 除外)。
3682
3700
  */
3683
3701
  function buildWriteTiddler(title, text, options = {}) {
3684
3702
  const { existing, fields } = options;
@@ -3700,7 +3718,7 @@ function buildWriteTiddler(title, text, options = {}) {
3700
3718
  if (finalTags.length > 0) tiddler.tags = finalTags;
3701
3719
  }
3702
3720
  applyCustomFields(tiddler, fields);
3703
- const { defaulted } = finalTypeForWrite(title, tiddler);
3721
+ const { defaulted } = finalTypeForWrite(title, tiddler, existing === void 0);
3704
3722
  return {
3705
3723
  tiddler,
3706
3724
  typeDefaulted: defaulted
@@ -6875,6 +6893,21 @@ function pickFields(t) {
6875
6893
  return out;
6876
6894
  }
6877
6895
  /**
6896
+ * 覆盖写入前后内容类型的差异(v0.20.1)。内容类型决定 TW 用哪个 parser,静默变化
6897
+ * 会让 CSS 被当 Markdown、Markdown 笔记被当 wikitext,所以覆盖路径必须把这个差异
6898
+ * 回执给模型。只有两边都拿得到 `type` 且不同才报告(新建 → 无 from)。
6899
+ */
6900
+ function typeChangeOf(existing, next) {
6901
+ if (existing === void 0) return {};
6902
+ const from = typeof existing.type === "string" && existing.type.length > 0 ? existing.type : void 0;
6903
+ const to = typeof next.type === "string" && next.type.length > 0 ? next.type : void 0;
6904
+ if (from === void 0 || to === void 0 || from === to) return {};
6905
+ return { typeChanged: {
6906
+ from,
6907
+ to
6908
+ } };
6909
+ }
6910
+ /**
6878
6911
  * Rewrite TiddlyWiki references to a title inside wiki text: `[[Title]]`,
6879
6912
  * `[[display|Title]]`, `{{Title}}` → the new title. Best-effort link/text
6880
6913
  * migration for tiddlywiki_rename; returns the rewritten text + hit count.
@@ -7158,7 +7191,7 @@ function registerTiddlywikiTools(ctx, deps) {
7158
7191
  }));
7159
7192
  register(defineTool({
7160
7193
  name: "tiddlywiki_put",
7161
- description: "写入(新建或覆盖)一个 TiddlyWiki tiddler。同名覆盖;**覆盖已有条目时,未传的 tags / 自定义字段会原样保留**(不会静默丢掉笔记原有的标签与字段),显式传 tags 才整体替换标签。写入后触发防抖自动 commit(默认 60s;手动同步用 tiddlywiki_git_sync)。新建(title 不存在)时自动补打 agent-written 标签标记「由 Agent 撰写」,无需手动添加。未指定内容类型时自动默认 text/markdown($:/ 系统条目除外);要写原生 wikitext 需显式在 fields 传 {\"type\":\"text/vnd.tiddlywiki\"}。⚠️ fields.type 是 TW 的内容类型保留字段,不要把业务分类值(如 \"meeting\")写进去——业务分类请放 tags。",
7194
+ description: "写入(新建或覆盖)一个 TiddlyWiki tiddler。同名覆盖;**覆盖已有条目时,未传的 tags / 自定义字段 / 内容类型都会原样保留**(不会静默丢掉笔记原有的标签、字段或 type;显式传 tags 才整体替换标签)。写入后触发防抖自动 commit(默认 60s;手动同步用 tiddlywiki_git_sync)。新建(title 不存在)时自动补打 agent-written 标签标记「由 Agent 撰写」,无需手动添加。内容类型:**只有新建**条目且未指定时才默认 text/markdown($:/ 系统条目除外)——覆盖 text/css、wikitext 等既有条目时保持原类型;要改类型用 fields 传 {\"type\":\"...\"}。⚠️ fields.type 是 TW 的内容类型保留字段,不要把业务分类值(如 \"meeting\")写进去——业务分类请放 tags。",
7162
7195
  parameters: {
7163
7196
  title: {
7164
7197
  type: "string",
@@ -7177,7 +7210,7 @@ function registerTiddlywikiTools(ctx, deps) {
7177
7210
  },
7178
7211
  fields: {
7179
7212
  type: "json",
7180
- description: "附加自定义字段,如 {\"date\":\"2026-09-02\"}(可选)。注意:fields.type 是 TW 内容类型(保留字段,默认已自动补 text/markdown),不要写业务分类值"
7213
+ description: "附加自定义字段,如 {\"date\":\"2026-09-02\"}(可选)。fields.type 是**改内容类型的正规入口**(如 {\"type\":\"text/css\"}):新建条目未指定时默认 text/markdown,覆盖既有条目时保留原类型。注意不要把业务分类值(如 \"meeting\")写进 type——业务分类请放 tags"
7181
7214
  },
7182
7215
  expectedModified: {
7183
7216
  type: "string",
@@ -7195,7 +7228,8 @@ function registerTiddlywikiTools(ctx, deps) {
7195
7228
  output: { render: (_args, value) => {
7196
7229
  const lines = [`已写入 tiddler「${value.title}」`];
7197
7230
  if (value.tags.length > 0) lines.push(`标签: ${value.tags.join(", ")}`);
7198
- if (value.type !== null) lines.push(`类型: ${value.type}${value.typeDefaulted === true ? "(未指定,已默认 markdown)" : ""}`);
7231
+ if (value.type !== null) lines.push(`类型: ${value.type}${value.typeDefaulted === true ? "(新建且未指定,已默认 markdown)" : ""}`);
7232
+ if (value.typeChanged !== void 0) lines.push(`⚠️ 内容类型已从 ${value.typeChanged.from} 改为 ${value.typeChanged.to}(覆盖前请确认这是你要的)`);
7199
7233
  if (value.fields !== null) {
7200
7234
  const entries = Object.entries(value.fields);
7201
7235
  if (entries.length > 0) lines.push(`字段: ${entries.map(([k, v]) => `${k}=${String(v)}`).join(", ")}`);
@@ -7228,13 +7262,14 @@ function registerTiddlywikiTools(ctx, deps) {
7228
7262
  tags: tiddler.tags ?? [],
7229
7263
  type: typeof tiddler.type === "string" ? tiddler.type : null,
7230
7264
  ...typeDefaulted ? { typeDefaulted: true } : {},
7265
+ ...typeChangeOf(existing, tiddler),
7231
7266
  fields: args.fields ?? null
7232
7267
  };
7233
7268
  }
7234
7269
  }));
7235
7270
  register(defineTool({
7236
7271
  name: "tiddlywiki_batch_put",
7237
- description: "批量写入/覆盖多个 TiddlyWiki tiddler(一次工具调用)。overwrite=false 时跳过已存在的标题;返回逐条结果(单条失败不影响其余条目,失败原因逐条列出)。写入后触发防抖自动 commit(默认 60s)。新建(title 不存在)的条目会自动补打 agent-written 标签,无需手动添加。未指定内容类型(fields.type)的条目自动默认 text/markdown($:/ 系统条目除外)。",
7272
+ description: "批量写入/覆盖多个 TiddlyWiki tiddler(一次工具调用)。overwrite=false 时跳过已存在的标题;返回逐条结果(单条失败不影响其余条目,失败原因逐条列出)。写入后触发防抖自动 commit(默认 60s)。新建(title 不存在)的条目会自动补打 agent-written 标签,无需手动添加。内容类型:只有**新建**条目未指定 fields.type 时才默认 text/markdown($:/ 系统条目除外);**覆盖既有条目时保留其原有 type/tags/自定义字段**。",
7238
7273
  parameters: {
7239
7274
  items: {
7240
7275
  type: "array",
@@ -7598,7 +7633,7 @@ function registerTiddlywikiTools(ctx, deps) {
7598
7633
  }));
7599
7634
  register(defineTool({
7600
7635
  name: "tiddlywiki_append",
7601
- description: "向已有 tiddler 追加/前插文本,或写入指定标题段落的末尾(无需先读全文、不会整篇覆盖)——适合日志、批注、清单的增量写入。条目不存在时默认新建(createIfMissing=false 则报错)。",
7636
+ description: "向已有 tiddler 追加/前插文本,或写入指定标题段落的末尾(无需先读全文、不会整篇覆盖)——适合日志、批注、清单的增量写入。条目不存在时默认新建(createIfMissing=false 则报错)。**写入既有条目走与 tiddlywiki_put 同一套写策略**:原有 tags、自定义字段与**内容类型**全部保留(不会把 Markdown 笔记改成 wikitext、也不会把 CSS 改成 Markdown);可用 fields 显式覆盖字段/类型。新建条目未指定内容类型时才默认 text/markdown。",
7602
7637
  parameters: {
7603
7638
  title: {
7604
7639
  type: "string",
@@ -7607,7 +7642,7 @@ function registerTiddlywikiTools(ctx, deps) {
7607
7642
  },
7608
7643
  text: {
7609
7644
  type: "string",
7610
- description: "要追加/前插的文本(Markdown)",
7645
+ description: "要追加/前插的文本(默认按 Markdown 写;既有条目保持它自己的内容类型)",
7611
7646
  required: true
7612
7647
  },
7613
7648
  mode: {
@@ -7626,13 +7661,22 @@ function registerTiddlywikiTools(ctx, deps) {
7626
7661
  tags: {
7627
7662
  type: "array",
7628
7663
  items: { type: "string" },
7629
- description: "可选:仅新建时使用的标签"
7664
+ description: "可选:标签(不传则保留既有条目的原标签;新建条目会额外自动补 agent-written)"
7665
+ },
7666
+ fields: {
7667
+ type: "json",
7668
+ description: "可选:显式覆盖的自定义字段(如 {\"type\":\"text/css\"})。不传则保留既有条目的原字段与内容类型"
7630
7669
  }
7631
7670
  },
7632
- output: { render: (_args, value) => [{
7633
- type: "text",
7634
- text: `${value.created ? "已新建并写入" : "已增量写入"} tiddler「${value.title}」(${value.mode}${value.heading !== null ? ` · 段落「${value.heading}」` : ""}):新增 ${value.added} 字符,现共 ${value.total} 字符。`
7635
- }] },
7671
+ output: { render: (_args, value) => {
7672
+ const lines = [`${value.created ? "已新建并写入" : "已增量写入"} tiddler「${value.title}」(${value.mode}${value.heading !== null ? ` · 段落「${value.heading}」` : ""}):新增 ${value.added} 字符,现共 ${value.total} 字符。`];
7673
+ if (value.type !== null) lines.push(`类型: ${value.type}${value.typeDefaulted === true ? "(新建且未指定,已默认 markdown)" : ""}`);
7674
+ if (value.typeChanged !== void 0) lines.push(`⚠️ 内容类型已从 ${value.typeChanged.from} 改为 ${value.typeChanged.to}`);
7675
+ return [{
7676
+ type: "text",
7677
+ text: lines.join("\n")
7678
+ }];
7679
+ } },
7636
7680
  execute: async (args) => {
7637
7681
  const wiki = requireWiki();
7638
7682
  const title = args.title.trim();
@@ -7646,20 +7690,12 @@ function registerTiddlywikiTools(ctx, deps) {
7646
7690
  if (mode === "append" && typeof args.heading === "string" && args.heading.trim().length > 0) next = insertIntoSection(base, args.heading.trim(), addition);
7647
7691
  else if (mode === "prepend") next = base.trim().length === 0 ? addition : `${addition}\n\n${base}`;
7648
7692
  else next = base.trim().length === 0 ? addition : `${base.replace(/\s+$/, "")}\n\n${addition}`;
7649
- if (existing !== void 0) await wiki.put({
7650
- ...cleanTiddler(existing),
7651
- text: next
7693
+ const { tiddler, typeDefaulted } = buildWriteTiddler(title, next, {
7694
+ existing,
7695
+ tags: normalizeTagArg(args.tags),
7696
+ fields: args.fields
7652
7697
  });
7653
- else {
7654
- const tags = finalTagsForWrite(title, void 0, Array.isArray(args.tags) ? args.tags.filter((t) => typeof t === "string" && t.trim().length > 0) : [], true);
7655
- const tiddler = {
7656
- title,
7657
- text: next
7658
- };
7659
- if (tags.length > 0) tiddler.tags = tags;
7660
- finalTypeForWrite(title, tiddler);
7661
- await wiki.put(tiddler);
7662
- }
7698
+ await wiki.put(tiddler);
7663
7699
  deps.autoCommit();
7664
7700
  return {
7665
7701
  ok: true,
@@ -7668,7 +7704,10 @@ function registerTiddlywikiTools(ctx, deps) {
7668
7704
  heading: typeof args.heading === "string" && args.heading.trim().length > 0 ? args.heading.trim() : null,
7669
7705
  created: existing === void 0,
7670
7706
  added: addition.length,
7671
- total: next.length
7707
+ total: next.length,
7708
+ type: typeof tiddler.type === "string" ? tiddler.type : null,
7709
+ ...typeDefaulted ? { typeDefaulted: true } : {},
7710
+ ...typeChangeOf(existing, tiddler)
7672
7711
  };
7673
7712
  }
7674
7713
  }));
@@ -8331,7 +8370,7 @@ const PROMPT_TEXT = `## TiddlyWiki 持久知识库
8331
8370
  - \`tiddlywiki_recent\`(limit?, since?)看最近修改的笔记(不含图片等二进制附件);\`tiddlywiki_list_tags\` 看现有 tag 及计数。
8332
8371
  - \`tiddlywiki_git_sync\`(pull|push|sync)做 git 同步;\`tiddlywiki_git_resolve\`(files, strategy=keep-local|keep-remote|list)在 pull 冲突后按 tiddler 二选一解决。
8333
8372
 
8334
- **不要覆盖人类正在编辑的笔记**:覆盖一篇已有笔记前先 \`tiddlywiki_get\`,把读到的 \`revision\`(或 \`modified\`)作为 \`expectedRevision\`(或 \`expectedModified\`)传入写回;若期间有人(在 TW 编辑器里)改过,写入会被拒绝并告诉你当前值——此时重新读一遍再决定,不要用 \`force\` 硬覆盖。纯增量内容优先用 \`tiddlywiki_append\`。覆盖已有条目时**不传 tags 就保留原有标签与自定义字段**(只改正文),显式传 tags 才整体替换标签。
8373
+ **不要覆盖人类正在编辑的笔记**:覆盖一篇已有笔记前先 \`tiddlywiki_get\`,把读到的 \`revision\`(或 \`modified\`)作为 \`expectedRevision\`(或 \`expectedModified\`)传入写回;若期间有人(在 TW 编辑器里)改过,写入会被拒绝并告诉你当前值——此时重新读一遍再决定,不要用 \`force\` 硬覆盖。纯增量内容优先用 \`tiddlywiki_append\`。覆盖已有条目时**不传 tags 就保留原有标签、自定义字段与内容类型**(只改正文),显式传 tags 才整体替换标签;要改内容类型用 \`fields.type\`。\`tiddlywiki_append\` 与 \`tiddlywiki_put\` 共用同一套写策略(同样保留原类型/tags/字段,也支持 \`fields\`)。
8335
8374
 
8336
8375
  知识库同步纪律(三条):
8337
8376
  1. 开工先 pull:\`tiddlywiki_git_sync action=pull\`(rebase + autostash;真冲突会自动 abort 并报冲突文件)。
@@ -8348,7 +8387,7 @@ pull 冲突后:先 \`tiddlywiki_git_resolve files=[冲突文件] strategy=keep
8348
8387
 
8349
8388
  **Agent 笔记标签约定**:\`tiddlywiki_put\` / \`tiddlywiki_batch_put\` 新建笔记时,插件会自动补打 \`agent-written\` 标签(标记「由 Agent 撰写」),无需手动添加,也不要手动移除它(除非用户明确要求)。首页会把 Agent 笔记单独列在「Agent 区块」,主标签列表只统计人类笔记。若某篇 Agent 笔记后续被人类编辑过,请在该笔记上补打 \`human-edited\` 标签,首页会把它归入「Agent + 人工」档。覆盖写入已有的(人类)笔记时不会自动加 agent-written,请保持笔记原本的归属。
8350
8389
 
8351
- **内容类型约定**:agent 笔记正文默认用 **Markdown** 写;\`tiddlywiki_put\` / \`tiddlywiki_batch_put\` 未指定内容类型时自动按 \`text/markdown\` 写入(\`$:/\` 系统条目除外),无需手动指定。要写 TW 原生 wikitext 才需要在 fields 里显式传 \`{"type":"text/vnd.tiddlywiki"}\`。⚠️ \`fields.type\` 是 TW 的**内容类型**保留字段——不要把业务分类值(如 \`"meeting"\`)写进去(会破坏渲染),业务分类请放 \`tags\`。
8390
+ **内容类型约定**:agent 笔记正文默认用 **Markdown** 写;**只有新建**条目且未指定内容类型时工具才自动按 \`text/markdown\` 写入(\`$:/\` 系统条目除外)——**覆盖或追加既有条目时保留它原有的 \`type\`**(\`text/css\`、\`text/vnd.tiddlywiki\`、JS 等都不会被改掉;类型真的变了回执会明说)。要改内容类型就显式传 \`fields: {"type": "text/vnd.tiddlywiki"}\`。⚠️ \`fields.type\` 是 TW 的**内容类型**保留字段——不要把业务分类值(如 \`"meeting"\`)写进去(会破坏渲染),业务分类请放 \`tags\`。
8352
8391
 
8353
8392
  **引用 wiki 笔记用可点击链接**:在回复流中引用某篇笔记时,用格式 \`[标题](/dsh-tiddlywiki/tw/#标题)\` 输出(标题含空格/特殊字符时做 URL 编码,如 \`A%20B\`;中文标题可直接写)。这类链接会被界面自动接管:点击后打开中央 TW 面板并跳转到该笔记的原生页面。回复里也优先用这个链接格式代替纯文本标题,让用户能一键跳到 wiki。`;
8354
8393
  /**