dsh-tiddlywiki 0.24.1 → 0.24.2

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
@@ -413,6 +413,7 @@ lib/ # 预构建产物(发布含 lib/**,提交入库;
413
413
 
414
414
  > 最近几个主要版本的一句话记录(完整变更见 [Releases](https://github.com/bbqisbbq/dsh-tiddlywiki/releases) / git log)。
415
415
 
416
+ - **v0.24.2**(2026-09-20):**修「`$:/language` 每次 dsh web 启动都被无条件重写 → 两台机器每次 pull 都冲突」**。现象:`git pull` 稳定卡在 `tiddlers/$__language.txt.meta` 上,而**正文文件根本不冲突、diff 出来只有 `created`/`modified` 两行**;本仓库的 git 历史里甚至已有一条专门清理该冲突残留的提交,而 2026-09-20 一次会话内又撞了两次。根因定位到行:启动自举应用 `uiLanguage` 时、以及设置页改动语言时,两处都**无条件 PUT** `$:/language`(正文每次都一样,但 TW 会给 `.meta` 盖新时间戳)——两台机器各自启动过就都改了同几行。现在抽出 `pinLanguageTiddler()`(`src/host/admin.ts`):**先读,内容一致就不写**,内容不同/条目不存在才写。⚠️ 关键边界:**读失败绝不等于「内容相同」**(铁律第三条)——`client.get()` 只有 404 返回 undefined、其余抛错,抛错时**照写并记一条日志**,否则用户的语言会永久卡在错的值上且重启也修不回来。**守门**:新增 `scripts/verify-language-pin.mjs`(进 `verify:unit`)——4 条桩 client 行为断言(一致不写 / 不存在要写 / 不同要写 / 读失败照写并报警)+ 源码级断言(`index.ts` 与 `admin.ts` 除原语自身外**一处裸 PUT 都不许有**,且两处都必须经 `pinLanguageTiddler`)。**已反向验证**:还原启动路径为无条件 PUT → 红 1 条;把读失败分支改成「直接 return false」→ 红 1 条。(附带一条方法论教训:第一次做这条反向验证时**构建因我的改写而失败**,而我把构建输出丢进了 `$null`,于是守门测的是旧 `lib/`、假绿了一轮;重做时把构建输出放回台面才抓到真红。反向验证必须确认「改动真的被编译进去了」。)
416
417
  - **v0.24.1**(2026-09-20):**修「重启/停止 TW 的四个路径里有三个没排干 syncer 队列」——一类静默丢写入的缺陷**。本仓库铁律第一条要求「重启 TW 前必须把 syncer 队列排干」,但那条规则**只写在文档里**:`POST /restart`、`POST /admin/restart`、`POST /admin/info`(改插件/主题后重启)与「知识库切换」的 `stopServer` 全都是**直接杀 TW 子进程**,而 TW 的 REST PUT 返回 204 时条目还在内存里、文件系统 syncer 要 ~250ms 后才落盘 —— 在那一秒内点「重启 TW」,刚写的笔记就随子进程一起消失(与 v0.19.0 丢 `tw-web-host`、v0.22.0 才发现 throttle 竞态同一类)。只有启动自举 / seed / `/sync` 三处走了 `flushPendingWrites`。**根因不是谁写错了某一行,而是「规则」和「代码」之间没有强制关系** —— 所以这一版不是补四个调用点,而是把不变量变成**原语**:新增 `drainThenStop()`(`src/host/seeds.ts`,**排干发生在它内部**,签名是 `{client, tiddlersDir, stop}`),四个路径全部改为经它停/重启,调用方**没法**绕过;`stop` 的返回类型放宽为 `Promise<unknown>`(`restart()` 回状态视图、`stop()` 回 void,排干都不关心)。`/restart` 与 `/admin/restart` 的回包新增 `drained`,排干不完整时如实上报而不是静默。**守门**:新增 `scripts/verify-restart-drain.mjs`(进 `verify:unit`)——① 顺序断言:`drainThenStop` 内部「排干的 await」必须早于「stop 的 await」,且排干失败必须走显式分支后**仍然 stop**(best-effort:不能因为排不干就把用户锁死在起不来的 TW 上);② 源码级断言:`routes.ts`/`admin.ts` 里**每个** `deps.server.restart()` 都必须作为 `drainThenStop({stop: …})` 的参数出现(裸 restart 即红),`index.ts` 的 `stopServer` 必须包在它里面;③ 行为断言:用桩 client 真跑一遍,确认「写入真的落盘之后才 stop」「client 缺失(TW 没在跑)是安全 no-op」「排干失败也继续 stop 并把警告交出去」。**已反向验证**:分别把 `/sync` 路径、知识库切换还原成裸调用、并删掉 `drainThenStop` 里的 `stop`,三次都稳定变红。顺带把 `/sync` 与 seed 两条**本来就在 flush** 的路径也收敛到同一原语,从此「重启前排干」只有一个实现。
417
418
  - **v0.24.0**(2026-09-20):**三处「Agent 读写 wiki 的规矩」按用户要求改进 + 一个 lint 误报修复**。① **新建笔记自动标工作区**:`put`/`batch_put`/`append` 新建条目时自动带上 **`ws/<项目名>` 标签 + `workspace` 字段**,项目名取自调用会话的工作目录(新 `src/host/workspace.ts` 纯函数:Windows/Unix 路径、尾斜杠、CJK、非法字符规范化;`home`/`tmp`/`desktop` 这类通用目录与**驱动器根目录**一律拒绝——后者是写这个模块的守门脚本抓出来的真缺陷:`C:\` 会被算成 `C:`,在盘根开会话时全盘笔记都挂上 `ws/C:`)。标记是**附加式**(调用方标签在前)、**仅新建**(覆盖不追打,也不会改写已有 `workspace` 字段)、`$:/` 豁免、`note.workspaceMark: false` 可关、调用方显式 `fields.workspace` 优先;`exec` 缺失 / 会话无 `cwd` / 未知会话一律**安全降级为不标记**(不猜、不抛错)。为什么标签必须带 `ws/` 前缀:裸项目名与真实业务标签撞车——作者库里 `dsh-tiddlywiki` 已挂着 2510 篇导入的书籍章节。② **检索先窄后宽 + 多词 AND**:`tiddlywiki_search` 先在工作区内查、**区内 0 条才自动扩大到全库**,回执明确写出「已在工作区 ws/X 内缩小范围」或「工作区 ws/X 内 0 条,已扩大到全库」(**「工作区内 0 条」不等于库里没有**,这是把「搜不到」错判成「不存在」的主要来源);`query` 从**整串子串匹配**改为按空白切词、**全部词命中**才算(旧行为连 `alphaprobe betaprobe` 这种自然的双词查询都永远匹配不到);调用方显式传 `tag`/`field`/`value` 时**不自动收窄**(显式范围不被二次猜测)。③ **过期内容淘汰策略(只提示,绝不自动删)**:阶段性笔记写 `valid-until`(硬过期)/ `review-after`(该复查),被取代时写 `superseded-by` + `superseded` 标签、**保留旧笔记**;`tiddlywiki_lint` 新增 `stale` 检查(默认开启、`staleAfterDays` 默认 180),报 `stale-expired` / `stale-review` 两类**硬判定**加一类 `stale-candidates`(带版本号或 `done` 标签且长期未改动,**明确标注「候选,非判定」**)。**淘汰只由人决定**——报告全程只读,不删不改不归档。④ **修 lint 死链误报**(v0.23.6 的内容并入本版):死链正则 `\{\{([^}]+)\}\}` 会把 `{{{ [tag[todo]count[]] }}}` 这类**三花括号过滤器表达式**从第 1 个 `{` 开始捕获成「转写目标」并报死链(实测线上 480 条死链报告里 8 条属此类),现在用前后瞻 + 捕获内容守卫排除。**明确不改的**:≈440 条真实死链**保留不修**——多数是用户手工删掉的笔记,属预期状态(只读报告是对的,自动「修复」是错的)。**守门**:新增 `scripts/verify-workspace.mjs`(进 `verify:unit`,9 组纯函数断言,含「任何 cwd 算出的标签都不得是垃圾标签 / 含空白 / 丢前缀」这条与 `isJunkTag()` 的跨模块一致性检查,以及 `ws/` 前缀不得被「简化」掉的回归),`verify-tools.mjs` +12 条真实 TW 断言(工作区标记六种形态 + 多词 AND + 先窄后宽 + `fellBack` + `stale` 三类),`verify-prompt.mjs` +1 条「三条新治理规则必须在场」并把 slim 长度预算从 1800 调至 2100(**预算上调与新增的逐条断言绑定**:删掉任何一条规则即红)。**所有新守门都做过反向验证**:分别还原旧行为(整串子串检索 / 关掉工作区收窄 / 关掉自动标记 / 关掉 stale 检查 / 去掉驱动器根目录判定 / 从规范化字符集里删掉 `|`)后,对应断言稳定变红。slim 正文实测 1918 字符(full 2722)。
418
419
 
package/lib/index.js CHANGED
@@ -7893,6 +7893,44 @@ function normalizeThemes(selected, deps = {}) {
7893
7893
  return out;
7894
7894
  }
7895
7895
  /**
7896
+ * Point `$:/language` at a language plugin — but ONLY when the stored text
7897
+ * differs (v0.24.2).
7898
+ *
7899
+ * WHY THE CONDITIONAL WRITE MATTERS
7900
+ * ---------------------------------
7901
+ * Both callers used to PUT this tiddler unconditionally: on EVERY dsh web
7902
+ * startup (`uiLanguage` auto-apply) and on every languages change. The body was
7903
+ * identical every time, but TW stamps a fresh `created`/`modified` into the
7904
+ * `.meta` file, so on a two-machine setup BOTH sides rewrote the same lines and
7905
+ * `git pull` conflicted on `tiddlers/$__language.txt.meta` **every single time**
7906
+ * — the repo's git log already carries a commit whose whole job was cleaning up
7907
+ * one of those leftovers. Same cause produced two conflict rounds in one session
7908
+ * on 2026-09-20.
7909
+ *
7910
+ * Returns whether anything was written (`changed: false` is the common case).
7911
+ *
7912
+ * ⚠️ A READ FAILURE MUST NOT BE TREATED AS "ALREADY CORRECT" (ironclad rule #3):
7913
+ * `client.get()` only returns undefined on a 404; anything else throws, and we
7914
+ * then write. Skipping on a transient read error would leave the user's language
7915
+ * pinned to the wrong value with no way to fix it.
7916
+ */
7917
+ async function pinLanguageTiddler(client, desired, log) {
7918
+ let existing;
7919
+ try {
7920
+ existing = await client.get("$:/language");
7921
+ } catch (err) {
7922
+ log?.("reading $:/language failed — writing it anyway", err);
7923
+ }
7924
+ if (existing !== void 0 && existing.text === desired) return false;
7925
+ await client.put({
7926
+ title: "$:/language",
7927
+ text: desired,
7928
+ type: "text/plain",
7929
+ tags: []
7930
+ });
7931
+ return true;
7932
+ }
7933
+ /**
7896
7934
  * Ensure a language code (e.g. "zh-Hans") is in tiddlywiki.info `languages`.
7897
7935
  * Returns whether tiddlywiki.info changed (caller decides whether to restart).
7898
7936
  */
@@ -8136,14 +8174,9 @@ function registerAdminRoutes(ctx, deps) {
8136
8174
  const client = deps.getClient();
8137
8175
  if (client !== void 0) {
8138
8176
  const langs = info.languages ?? [];
8139
- const active = langs.length > 0 ? `$:/languages/${langs[0]}` : "$:/languages/en-GB";
8140
- await client.put({
8141
- title: "$:/language",
8142
- text: active,
8143
- type: "text/plain",
8144
- tags: []
8145
- }).catch((err) => {
8146
- console.warn("[dsh-tiddlywiki] pinning $:/language failed:", err);
8177
+ await pinLanguageTiddler(client, langs.length > 0 ? `$:/languages/${langs[0]}` : "$:/languages/en-GB", (message, err) => {
8178
+ if (err === void 0) console.warn("[dsh-tiddlywiki]", message);
8179
+ else console.warn("[dsh-tiddlywiki] pinning $:/language failed:", err);
8147
8180
  });
8148
8181
  const hint = langs.length > 0 ? langs[0] : "";
8149
8182
  if ((deps.config.get().uiLanguage ?? "") !== hint) await deps.config.set(client, { uiLanguage: hint }).catch(() => void 0);
@@ -11803,11 +11836,9 @@ function apply(ctx, rawConfig = {}) {
11803
11836
  const code = uiLang.trim();
11804
11837
  if (await ensureLanguage(wikiPath, resolveTwRoot(), code) && !disposed) await server.restart();
11805
11838
  const langClient = client();
11806
- if (langClient !== void 0) await langClient.put({
11807
- title: "$:/language",
11808
- text: `$:/languages/${code}`,
11809
- type: "text/plain",
11810
- tags: []
11839
+ if (langClient !== void 0) await pinLanguageTiddler(langClient, `$:/languages/${code}`, (message, err) => {
11840
+ if (err === void 0) console.warn("[dsh-tiddlywiki]", message);
11841
+ else console.warn("[dsh-tiddlywiki] pinning $:/language failed:", err);
11811
11842
  }).catch(() => void 0);
11812
11843
  } catch (err) {
11813
11844
  console.warn("[dsh-tiddlywiki] applying uiLanguage:", err);
@@ -12059,6 +12090,6 @@ function apply(ctx, rawConfig = {}) {
12059
12090
  }, "dsh-tiddlywiki: host teardown");
12060
12091
  }
12061
12092
  //#endregion
12062
- 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, ConfigStore, ConfigUnreadableError, DEFAULT_NOTE_TYPE, DEFAULT_PROMPT_MODE, DEFAULT_WECHAT_ADAPTER, DEFAULT_WECHAT_COMMAND, 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, SEED_DEFS, SEED_MARKER_VERSION, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SUMMARY_PREFIX, 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, WORKSPACE_FIELD, WORKSPACE_TAG_PREFIX, WechatPublishRunner, WikiServer, WriteConflictError, apply, assertNoConflict, assertPublicImageUrl, awaitReady, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildPromptText, buildPublishInvocation, buildVersionInvocation, buildWriteTiddler, bundledCatalog, capOutput, checkAllSeeds, checkWechatReady, cleanTiddler, clearLocationState, deepMerge, defaultAdaptersDir, defaultLocationStateFile, defaultWechatJobDir, defineTool, describeConflict, describePrompt, describeUnreadableConfig, docNoteText, downloadClipImage, drainThenStop, dshHomePath, ensureLanguage, ensurePlugin, ensureTiddlerTimestamps, escapeInline, escapePromptBraces, expandEnvPath, flattenTiddlerFields, flushPendingWrites, formatTiddlerDate, hashText, hostAllowed, imageExtensionForMime, inject, interpretPublishOutcome, isBinaryType, isJunkTag, isPrivateAddress, isSafeCliValue, isSafeDsn, isSafeUrl, listWikiCandidates, locationPath, maskConfigSecrets, name, needsRestartAfterSeeds, normalizeLocation, normalizePromptMode, normalizePromptPreview, normalizeReadyTimeoutMs, normalizeThemes, normalizeWechatConfig, normalizeWorkspaceName, openInTwEditor, parseClipPayload, parseSeedMarker, parseTiddlerDate, pickImageMime, readActiveThemeName, readLocationState, readSeedMarker, readWikiInfo, readyHardTimeoutMs, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, sanitizeTwFragment, scanAdapterDir, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, seedWechatDocs, seedWechatPublish, stripMaskedSecrets, switchWiki, tailOf, tiddlywikiToolSummary, toIsoDateString, toolSignatureLines, unseedClipBridge, unseedWechatDocs, unseedWechatPublish, waitForFileWrite, workspaceMarkFromCwd, workspaceNameFromCwd, workspaceTagName, writeLocationState, writeSeedMarker, writeSessionSummary, writeWikiInfo };
12093
+ 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, ConfigStore, ConfigUnreadableError, DEFAULT_NOTE_TYPE, DEFAULT_PROMPT_MODE, DEFAULT_WECHAT_ADAPTER, DEFAULT_WECHAT_COMMAND, 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, SEED_DEFS, SEED_MARKER_VERSION, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SUMMARY_PREFIX, 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, WORKSPACE_FIELD, WORKSPACE_TAG_PREFIX, WechatPublishRunner, WikiServer, WriteConflictError, apply, assertNoConflict, assertPublicImageUrl, awaitReady, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildPromptText, buildPublishInvocation, buildVersionInvocation, buildWriteTiddler, bundledCatalog, capOutput, checkAllSeeds, checkWechatReady, cleanTiddler, clearLocationState, deepMerge, defaultAdaptersDir, defaultLocationStateFile, defaultWechatJobDir, defineTool, describeConflict, describePrompt, describeUnreadableConfig, docNoteText, downloadClipImage, drainThenStop, dshHomePath, ensureLanguage, ensurePlugin, ensureTiddlerTimestamps, escapeInline, escapePromptBraces, expandEnvPath, flattenTiddlerFields, flushPendingWrites, formatTiddlerDate, hashText, hostAllowed, imageExtensionForMime, inject, interpretPublishOutcome, isBinaryType, isJunkTag, isPrivateAddress, isSafeCliValue, isSafeDsn, isSafeUrl, listWikiCandidates, locationPath, maskConfigSecrets, name, needsRestartAfterSeeds, normalizeLocation, normalizePromptMode, normalizePromptPreview, normalizeReadyTimeoutMs, normalizeThemes, normalizeWechatConfig, normalizeWorkspaceName, openInTwEditor, parseClipPayload, parseSeedMarker, parseTiddlerDate, pickImageMime, pinLanguageTiddler, readActiveThemeName, readLocationState, readSeedMarker, readWikiInfo, readyHardTimeoutMs, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, sanitizeTwFragment, scanAdapterDir, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, seedWechatDocs, seedWechatPublish, stripMaskedSecrets, switchWiki, tailOf, tiddlywikiToolSummary, toIsoDateString, toolSignatureLines, unseedClipBridge, unseedWechatDocs, unseedWechatPublish, waitForFileWrite, workspaceMarkFromCwd, workspaceNameFromCwd, workspaceTagName, writeLocationState, writeSeedMarker, writeSessionSummary, writeWikiInfo };
12063
12094
 
12064
12095
  //# sourceMappingURL=index.js.map