dsh-tiddlywiki 0.4.0 → 0.6.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
@@ -275,6 +275,47 @@ var GitFace = class {
275
275
  message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
276
276
  };
277
277
  }
278
+ /** `git fetch` (no remote configured → failure reported by the caller). */
279
+ async fetch(dir) {
280
+ const r = await this.exec(["fetch"], {
281
+ cwd: dir,
282
+ timeout: HEAVY_TIMEOUT_MS
283
+ });
284
+ return r.ok ? {
285
+ ok: true,
286
+ message: r.stdout.trim() || "fetch ok"
287
+ } : {
288
+ ok: false,
289
+ message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
290
+ };
291
+ }
292
+ /**
293
+ * Restore the given files from the freshly fetched remote HEAD (FETCH_HEAD)
294
+ * into the working tree + index — the "keep remote version" half of
295
+ * tiddler-granular conflict resolution. Callers must `git fetch` first.
296
+ */
297
+ async checkoutFetchHead(dir, files) {
298
+ if (files.length === 0) return {
299
+ ok: true,
300
+ message: "no files given"
301
+ };
302
+ const r = await this.exec([
303
+ "checkout",
304
+ "FETCH_HEAD",
305
+ "--",
306
+ ...files
307
+ ], {
308
+ cwd: dir,
309
+ timeout: HEAVY_TIMEOUT_MS
310
+ });
311
+ return r.ok ? {
312
+ ok: true,
313
+ message: `已从远端检出 ${files.length} 个文件`
314
+ } : {
315
+ ok: false,
316
+ message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
317
+ };
318
+ }
278
319
  /** Ensure `origin` points at `url` (add or set-url). */
279
320
  async ensureRemote(dir, url) {
280
321
  const cur = await this.exec([
@@ -410,6 +451,16 @@ var AutoCommitter = class {
410
451
  */
411
452
  /** The DSH webserver route prefix (NOT a TW path-prefix; see module header). */
412
453
  const PATH_PREFIX = "/dsh-tiddlywiki";
454
+ /**
455
+ * Same-origin TW proxy route on the DSH webserver (remote-access mode, R1).
456
+ * The browser only ever talks to the DSH origin — which it already reaches
457
+ * over loopback, LAN, Tailscale, a domain or HTTPS — and DSH proxies to the
458
+ * loopback TW child. TW's frontend is pointed at this prefix via the
459
+ * `$:/config/tiddlyweb/host` tiddler so every API call stays same-origin.
460
+ */
461
+ const TW_PROXY_PREFIX = `${PATH_PREFIX}/tw`;
462
+ /** The proxy base path (trailing slash) handed to browsers / TW's frontend. */
463
+ const TW_PROXY_PATH = `${TW_PROXY_PREFIX}/`;
413
464
  /** How long to wait for the wiki to answer /status. */
414
465
  const READY_TIMEOUT_MS = 2e4;
415
466
  /** Poll cadence while waiting for readiness. */
@@ -679,6 +730,29 @@ function readBodyBuffer(req, limit = MAX_UPLOAD_BYTES) {
679
730
  req.on("error", rejectP);
680
731
  });
681
732
  }
733
+ /** Header names forwarded to the upstream TW service by the proxy routes. */
734
+ const FORWARD_HEADER_NAMES = [
735
+ "accept",
736
+ "accept-encoding",
737
+ "content-type",
738
+ "cookie",
739
+ "authorization",
740
+ "if-none-match",
741
+ "if-modified-since",
742
+ "origin",
743
+ "referer",
744
+ "user-agent"
745
+ ];
746
+ /** Copy a safe, string-valued subset of the request headers upstream. */
747
+ function forwardHeaders(headers) {
748
+ const out = {};
749
+ for (const name of FORWARD_HEADER_NAMES) {
750
+ const value = headers[name];
751
+ if (typeof value === "string") out[name] = value;
752
+ else if (Array.isArray(value) && value.length > 0) out[name] = value.join(", ");
753
+ }
754
+ return out;
755
+ }
682
756
  /**
683
757
  * Sanitize an uploaded filename into a safe bare name (no path separators,
684
758
  * no `..`, no control characters). Returns '' when nothing usable remains.
@@ -701,6 +775,11 @@ function json$1(res, payload, status = 200) {
701
775
  function pad(n) {
702
776
  return n < 10 ? `0${n}` : String(n);
703
777
  }
778
+ /** Flat one-line snippet for the recent-notes picker. */
779
+ function snippetOf$1(text, max = 120) {
780
+ const flat = text.replace(/\s+/g, " ").trim();
781
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
782
+ }
704
783
  /** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
705
784
  function timestampTitle(date = /* @__PURE__ */ new Date()) {
706
785
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
@@ -767,6 +846,7 @@ function registerRoutes(ctx, deps) {
767
846
  json$1(res, {
768
847
  ok: true,
769
848
  ...view,
849
+ twProxy: TW_PROXY_PATH,
770
850
  git: gitSummary,
771
851
  note: { tag: deps.noteDefaults().tag },
772
852
  ui: deps.uiDefaults()
@@ -833,7 +913,7 @@ function registerRoutes(ctx, deps) {
833
913
  json$1(res, {
834
914
  ok: true,
835
915
  ...result,
836
- twUrl: deps.server.url
916
+ twUrl: TW_PROXY_PATH
837
917
  });
838
918
  } catch (err) {
839
919
  json$1(res, {
@@ -867,6 +947,85 @@ function registerRoutes(ctx, deps) {
867
947
  }, 500);
868
948
  }
869
949
  };
950
+ /** Recent non-system tiddlers for the quick-note "最近" picker (newest first). */
951
+ const handleRecent = async (req, res) => {
952
+ const client = deps.getClient();
953
+ if (client === void 0) {
954
+ json$1(res, {
955
+ ok: false,
956
+ error: "wiki service is not running"
957
+ }, 503);
958
+ return;
959
+ }
960
+ try {
961
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
962
+ const limitRaw = Number(url.searchParams.get("limit") ?? 15);
963
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 15;
964
+ json$1(res, {
965
+ ok: true,
966
+ limit,
967
+ items: (await client.recent(limit)).map((t) => ({
968
+ title: t.title,
969
+ tags: t.tags ?? [],
970
+ modified: typeof t.modified === "string" ? t.modified : null,
971
+ snippet: snippetOf$1(t.text ?? "")
972
+ }))
973
+ });
974
+ } catch (err) {
975
+ json$1(res, {
976
+ ok: false,
977
+ error: err instanceof Error ? err.message : String(err)
978
+ }, 500);
979
+ }
980
+ };
981
+ /** Full tiddler for the quick-note "最近" picker (load into the editor). */
982
+ const handleGet = async (req, res) => {
983
+ const client = deps.getClient();
984
+ if (client === void 0) {
985
+ json$1(res, {
986
+ ok: false,
987
+ error: "wiki service is not running"
988
+ }, 503);
989
+ return;
990
+ }
991
+ try {
992
+ const title = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("title") ?? "";
993
+ if (title.length === 0) {
994
+ json$1(res, {
995
+ ok: false,
996
+ error: "missing title"
997
+ }, 400);
998
+ return;
999
+ }
1000
+ const t = await client.get(title);
1001
+ if (t === void 0) {
1002
+ json$1(res, {
1003
+ ok: false,
1004
+ notFound: true,
1005
+ title
1006
+ }, 404);
1007
+ return;
1008
+ }
1009
+ const fields = {};
1010
+ for (const [k, v] of Object.entries(t)) {
1011
+ if (k === "title" || k === "text" || k === "tags") continue;
1012
+ fields[k] = v;
1013
+ }
1014
+ json$1(res, {
1015
+ ok: true,
1016
+ title: t.title,
1017
+ text: t.text ?? "",
1018
+ tags: t.tags ?? [],
1019
+ type: t.type ?? "text/vnd.tiddlywiki",
1020
+ fields
1021
+ });
1022
+ } catch (err) {
1023
+ json$1(res, {
1024
+ ok: false,
1025
+ error: err instanceof Error ? err.message : String(err)
1026
+ }, 500);
1027
+ }
1028
+ };
870
1029
  const handleRestart = async (_req, res) => {
871
1030
  try {
872
1031
  await deps.server.restart();
@@ -984,7 +1143,7 @@ function registerRoutes(ctx, deps) {
984
1143
  ok: true,
985
1144
  name: candidate,
986
1145
  path: `files/${candidate}`,
987
- url: `/files/${encodeURIComponent(candidate)}`,
1146
+ url: `${TW_PROXY_PATH}files/${encodeURIComponent(candidate)}`,
988
1147
  size: buf.length,
989
1148
  type: req.headers["content-type"] ?? "application/octet-stream"
990
1149
  });
@@ -1032,6 +1191,62 @@ function registerRoutes(ctx, deps) {
1032
1191
  }, 502);
1033
1192
  }
1034
1193
  };
1194
+ /**
1195
+ * SAME-ORIGIN proxy /dsh-tiddlywiki/tw/<rest> → TW root /<rest>. Serves the
1196
+ * ENTIRE TW frontend (index HTML, /files/*, the TiddlyWeb API) to the
1197
+ * browser through the DSH origin, so the embedded editor works from any
1198
+ * host/domain the user reaches DSH on (loopback, LAN, Tailscale, domain,
1199
+ * HTTPS). The browser never talks to the loopback TW child directly; DSH
1200
+ * does, on the same machine. Binary responses are buffered losslessly
1201
+ * (arrayBuffer) — unlike the /api JSON proxy, this route must never .text().
1202
+ */
1203
+ const handleTwProxy = async (req, res) => {
1204
+ if (deps.getClient() === void 0) {
1205
+ json$1(res, {
1206
+ ok: false,
1207
+ error: "wiki service is not running"
1208
+ }, 503);
1209
+ return;
1210
+ }
1211
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
1212
+ const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), "") || "/";
1213
+ try {
1214
+ const method = (req.method ?? "GET").toUpperCase();
1215
+ const headers = forwardHeaders(req.headers);
1216
+ if (method === "PUT" || method === "DELETE" || method === "POST") headers["x-requested-with"] = "TiddlyWiki";
1217
+ const init = {
1218
+ method,
1219
+ headers,
1220
+ signal: AbortSignal.timeout(3e4)
1221
+ };
1222
+ if (method === "PUT" || method === "POST") init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES);
1223
+ const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init);
1224
+ const data = Buffer.from(await upstream.arrayBuffer());
1225
+ const responseHeaders = {
1226
+ "content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
1227
+ "cache-control": upstream.headers.get("cache-control") ?? "no-store"
1228
+ };
1229
+ for (const name of [
1230
+ "etag",
1231
+ "last-modified",
1232
+ "content-disposition"
1233
+ ]) {
1234
+ const value = upstream.headers.get(name);
1235
+ if (value !== null) responseHeaders[name] = value;
1236
+ }
1237
+ res.writeHead(upstream.status, responseHeaders);
1238
+ res.end(data);
1239
+ } catch (err) {
1240
+ if (res.headersSent) {
1241
+ res.end();
1242
+ return;
1243
+ }
1244
+ json$1(res, {
1245
+ ok: false,
1246
+ error: err instanceof Error ? err.message : String(err)
1247
+ }, 502);
1248
+ }
1249
+ };
1035
1250
  const disposers = [
1036
1251
  ctx.webServer.register({
1037
1252
  kind: "exact",
@@ -1061,6 +1276,20 @@ function registerRoutes(ctx, deps) {
1061
1276
  handleTags(req, res);
1062
1277
  }
1063
1278
  }),
1279
+ ctx.webServer.register({
1280
+ kind: "exact",
1281
+ path: `${ROUTE_PREFIX}/recent`,
1282
+ handler: (req, res) => {
1283
+ handleRecent(req, res);
1284
+ }
1285
+ }),
1286
+ ctx.webServer.register({
1287
+ kind: "exact",
1288
+ path: `${ROUTE_PREFIX}/get`,
1289
+ handler: (req, res) => {
1290
+ handleGet(req, res);
1291
+ }
1292
+ }),
1064
1293
  ctx.webServer.register({
1065
1294
  kind: "exact",
1066
1295
  path: `${ROUTE_PREFIX}/sync`,
@@ -1088,6 +1317,13 @@ function registerRoutes(ctx, deps) {
1088
1317
  handler: (req, res) => {
1089
1318
  handleApiProxy(req, res);
1090
1319
  }
1320
+ }),
1321
+ ctx.webServer.register({
1322
+ kind: "prefix",
1323
+ path: `${TW_PROXY_PREFIX}`,
1324
+ handler: (req, res) => {
1325
+ handleTwProxy(req, res);
1326
+ }
1091
1327
  })
1092
1328
  ];
1093
1329
  return () => {
@@ -1542,17 +1778,19 @@ const DOC_NOTE_TEXT = `! dsh-tiddlywiki 插件说明
1542
1778
 
1543
1779
  !! 它能做什么
1544
1780
 
1545
- * **5 个 agent 工具**:\`tiddlywiki_search\`(检索)/ \`tiddlywiki_get\`(读)/ \`tiddlywiki_put\`(写)/ \`tiddlywiki_delete\`(删)/ \`tiddlywiki_git_sync\`(git 同步)。
1781
+ * **10 个 agent 工具**:\`tiddlywiki_search\`(检索,支持 tags/since/type/limit 过滤)/ \`tiddlywiki_get\`(读)/ \`tiddlywiki_put\`(写)/ \`tiddlywiki_batch_put\`(批量写)/ \`tiddlywiki_rename\`(重命名+同步引用)/ \`tiddlywiki_delete\`(删)/ \`tiddlywiki_recent\`(最近修改)/ \`tiddlywiki_list_tags\`(标签清单)/ \`tiddlywiki_git_sync\`(git 同步)/ \`tiddlywiki_git_resolve\`(冲突按 tiddler 二选一)。
1546
1782
  * **TW 编辑器面板**:侧边栏「TiddlyWiki」按钮 → 在界面中央打开完整版 TW 编辑器。
1547
- * **快速笔记**:右下角悬浮「📝 快速笔记」写随手记(可多选/自动补全 tag),\`Ctrl+Enter\` 保存;点「✏️ 在 TW 中编辑」会弹出独立小窗用 TW 原生编辑器编辑。
1783
+ * **快速笔记**:右下角「知识库」悬浮按钮 → 「📝 快速笔记」写随手记(Markdown 高亮、文件上传、多选/自动补全 tag,草稿自动保存到本地),\`Ctrl+Enter\` 保存;「🕘 最近」可一键载入旧笔记继续编辑;「✏️ 在 TW 中编辑」会弹出独立小窗用 TW 原生编辑器编辑。
1784
+ * **一键同步**:「知识库」按钮 → 「🔁 同步」一键 pull → commit → push,按钮上的状态点实时反映 git 状态。
1548
1785
  * **git 同步**:写入自动防抖 commit(默认 60 秒);手动 \`tiddlywiki_git_sync action=sync\` 做 pull → commit → push。
1549
- * **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置(含「快速笔记」显示开关、面板「状态/重载」悬浮按钮开关)。
1786
+ * **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置(含「知识库」按钮相关显示开关)。
1550
1787
 
1551
1788
  !! 知识库纪律(三条)
1552
1789
 
1553
1790
  1. 开工先 \`tiddlywiki_git_sync action=pull\`(rebase + autostash,真冲突会自动 abort 并报文件)。
1554
- 2. 收工 \`tiddlywiki_git_sync action=sync\`。
1555
- 3. 插件自动 commit 兜底,手动 sync 用于需要主动推送的场合。
1791
+ 2. 冲突后:\`tiddlywiki_git_resolve files=[冲突文件] strategy=keep-local|keep-remote\` 按 tiddler 二选一解决,再重新 sync。
1792
+ 3. 收工 \`tiddlywiki_git_sync action=sync\`。
1793
+ 4. 插件自动 commit 兜底,手动 sync 用于需要主动推送的场合。
1556
1794
 
1557
1795
  !! 主题与语言
1558
1796
 
@@ -1689,20 +1927,82 @@ var TiddlyWebClient = class {
1689
1927
  /**
1690
1928
  * Search non-system tiddlers: one request (default listing with text) plus
1691
1929
  * local case-insensitive substring matching on title + text, optional exact
1692
- * tag, capped at `limit`. Robust against the server's external-filter 403.
1930
+ * tags (AND), a `since` modified-time floor, an exact `type`, capped at
1931
+ * `limit`. Robust against the server's external-filter 403.
1693
1932
  */
1694
- async search(query, tag, limit = 30) {
1933
+ async search(query, options = {}) {
1695
1934
  const items = await this.list(void 0, true);
1696
1935
  const needle = query.toLowerCase();
1697
- return items.filter((t) => {
1936
+ const sinceTime = parseSince(options.since);
1937
+ const wantedTags = [...options.tags ?? [], options.tag].filter((t) => typeof t === "string" && t.trim().length > 0);
1938
+ const limit = options.limit ?? 30;
1939
+ const matched = items.filter((t) => {
1940
+ if (t.title.startsWith("$:/")) return false;
1698
1941
  if (!t.title.toLowerCase().includes(needle) && !(t.text ?? "").toLowerCase().includes(needle)) return false;
1699
- if (tag !== void 0 && tag.length > 0) {
1700
- if (!(t.tags ?? []).some((t2) => t2.toLowerCase() === tag.toLowerCase())) return false;
1942
+ if (sinceTime !== void 0) {
1943
+ const modified = typeof t.modified === "string" ? new Date(t.modified).getTime() : NaN;
1944
+ if (Number.isNaN(modified) || modified < sinceTime) return false;
1945
+ }
1946
+ if (options.type !== void 0 && options.type.length > 0 && (t.type ?? "text/vnd.tiddlywiki") !== options.type) return false;
1947
+ if (wantedTags.length > 0) {
1948
+ const tags = (t.tags ?? []).map((tag) => tag.toLowerCase());
1949
+ if (!wantedTags.every((w) => tags.includes(w.toLowerCase()))) return false;
1950
+ }
1951
+ return true;
1952
+ });
1953
+ return {
1954
+ items: matched.slice(0, limit),
1955
+ total: matched.length
1956
+ };
1957
+ }
1958
+ /**
1959
+ * List the most recently modified NON-SYSTEM tiddlers, newest first
1960
+ * (missing/modified-less tiddlers sort to the tail). `since` keeps only
1961
+ * tiddlers modified at/after that instant.
1962
+ */
1963
+ async recent(limit = 15, since) {
1964
+ const items = await this.list(void 0, true);
1965
+ const sinceTime = parseSince(since);
1966
+ const filtered = items.filter((t) => {
1967
+ if (t.title.startsWith("$:/")) return false;
1968
+ if (sinceTime !== void 0) {
1969
+ const modified = typeof t.modified === "string" ? new Date(t.modified).getTime() : NaN;
1970
+ if (Number.isNaN(modified) || modified < sinceTime) return false;
1701
1971
  }
1702
1972
  return true;
1703
- }).slice(0, limit);
1973
+ });
1974
+ filtered.sort((a, b) => {
1975
+ const am = typeof a.modified === "string" ? new Date(a.modified).getTime() : 0;
1976
+ return (typeof b.modified === "string" ? new Date(b.modified).getTime() : 0) - am;
1977
+ });
1978
+ return filtered.slice(0, Math.max(1, Math.min(limit, 200)));
1979
+ }
1980
+ /**
1981
+ * Distinct non-system tags with their tiddler counts, most-used first then
1982
+ * zh-locale. One skinny listing request (no text payloads).
1983
+ */
1984
+ async listTags() {
1985
+ const items = await this.list(void 0, false);
1986
+ const map = /* @__PURE__ */ new Map();
1987
+ for (const t of items) {
1988
+ if (t.title.startsWith("$:/")) continue;
1989
+ for (const tag of t.tags ?? []) {
1990
+ if (tag.startsWith("$:/")) continue;
1991
+ map.set(tag, (map.get(tag) ?? 0) + 1);
1992
+ }
1993
+ }
1994
+ return [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh")).map(([tag, count]) => ({
1995
+ tag,
1996
+ count
1997
+ }));
1704
1998
  }
1705
1999
  };
2000
+ /** Parse a `since` value into an epoch ms, or undefined when absent/invalid. */
2001
+ function parseSince(since) {
2002
+ if (typeof since !== "string" || since.trim().length === 0) return void 0;
2003
+ const ms = new Date(since.trim()).getTime();
2004
+ return Number.isNaN(ms) ? void 0 : ms;
2005
+ }
1706
2006
  //#endregion
1707
2007
  //#region src/sdk.ts
1708
2008
  /**
@@ -1738,7 +2038,11 @@ function compileValue(spec) {
1738
2038
  const objectSpec = spec;
1739
2039
  node.type = "object";
1740
2040
  node.additionalProperties = objectSpec.additionalProperties;
1741
- if (objectSpec.properties !== void 0) node.properties = compilePropertyMap(objectSpec.properties).properties;
2041
+ if (objectSpec.properties !== void 0) {
2042
+ const compiled = compilePropertyMap(objectSpec.properties);
2043
+ node.properties = compiled.properties;
2044
+ if (compiled.required !== void 0) node.required = compiled.required;
2045
+ }
1742
2046
  return node;
1743
2047
  }
1744
2048
  if (type === "array") {
@@ -1842,10 +2146,14 @@ function defineTool(options) {
1842
2146
  //#endregion
1843
2147
  //#region src/host/tools.ts
1844
2148
  /**
1845
- * The five `tiddlywiki_*` agent tools (design doc §11, D8) plus the extension
2149
+ * The `tiddlywiki_*` agent tools (design doc §11, D8) plus the extension
1846
2150
  * point: `registerTiddlywikiTools(ctx, deps)` registers tools list-style, so a
1847
2151
  * new tool is just one more `defineTool` in the array — index.ts never changes.
1848
2152
  *
2153
+ * Toolset (v0.5):
2154
+ * search / get / put / batch_put / rename / delete / recent / list_tags /
2155
+ * git_sync / git_resolve
2156
+ *
1849
2157
  * RENDER CONTRACT (design doc §4.3): the registry feeds `output.render(args,
1850
2158
  * value)` into the loop — the model sees ONLY the rendered text, never the raw
1851
2159
  * JSON `value`. Every render must carry the complete facts an agent needs to
@@ -1866,6 +2174,42 @@ function pickFields(t) {
1866
2174
  }
1867
2175
  return out;
1868
2176
  }
2177
+ /** A put-ready copy of a tiddler (no created/modified, tags as array). */
2178
+ function cleanTiddler(t) {
2179
+ const out = {
2180
+ title: t.title,
2181
+ text: t.text ?? "",
2182
+ tags: t.tags ?? []
2183
+ };
2184
+ if (typeof t.type === "string") out.type = t.type;
2185
+ for (const [k, v] of Object.entries(t)) {
2186
+ if (k === "title" || k === "text" || k === "tags" || k === "type" || k === "created" || k === "modified" || k === "fields") continue;
2187
+ out[k] = v;
2188
+ }
2189
+ return out;
2190
+ }
2191
+ /**
2192
+ * Rewrite TiddlyWiki references to a title inside wiki text: `[[Title]]`,
2193
+ * `[[display|Title]]`, `{{Title}}` → the new title. Best-effort link/text
2194
+ * migration for tiddlywiki_rename; returns the rewritten text + hit count.
2195
+ */
2196
+ function rewriteRefs(text, oldTitle, newTitle) {
2197
+ if (oldTitle.length === 0) return {
2198
+ text,
2199
+ count: 0
2200
+ };
2201
+ const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2202
+ const re = new RegExp(`(\\[\\[[^\\]|]*\\|)${escaped}(\\]\\])|(\\[\\[)${escaped}(\\]\\])|(\\{\\{)${escaped}(\\}\\})`, "g");
2203
+ let count = 0;
2204
+ return {
2205
+ text: text.replace(re, (...args) => {
2206
+ const p = args;
2207
+ count++;
2208
+ return `${p[1] ?? p[3] ?? p[5] ?? ""}${newTitle}${p[2] ?? p[4] ?? p[6] ?? ""}`;
2209
+ }),
2210
+ count
2211
+ };
2212
+ }
1869
2213
  function registerTiddlywikiTools(ctx, deps) {
1870
2214
  const disposers = [];
1871
2215
  const register = (tool) => {
@@ -1873,29 +2217,51 @@ function registerTiddlywikiTools(ctx, deps) {
1873
2217
  };
1874
2218
  register(defineTool({
1875
2219
  name: "tiddlywiki_search",
1876
- description: "检索 TiddlyWiki 持久知识库:按关键词(可选 tag 精确匹配)搜索非系统 tiddler,返回标题、标签与摘要片段。",
2220
+ description: "检索 TiddlyWiki 持久知识库:按关键词(可选 tags 数组 / since 修改时间 / type / limit)搜索非系统 tiddler,返回标题、标签、修改时间与摘要片段。",
1877
2221
  parameters: {
1878
2222
  query: {
1879
2223
  type: "string",
1880
2224
  description: "搜索关键词(大小写不敏感,子串匹配)",
1881
2225
  required: true
1882
2226
  },
2227
+ tags: {
2228
+ type: "array",
2229
+ items: { type: "string" },
2230
+ description: "可选:要求同时包含的标签(AND)"
2231
+ },
1883
2232
  tag: {
1884
2233
  type: "string",
1885
- description: "可选:只返回带该 tag 的 tiddler"
2234
+ description: "可选:单个精确标签(与 tags 同为 AND)"
2235
+ },
2236
+ since: {
2237
+ type: "string",
2238
+ description: "可选:ISO 时间(如 2026-09-01 或 2026-09-01T00:00:00Z),只返回修改时间不早于它的 tiddler"
2239
+ },
2240
+ type: {
2241
+ type: "string",
2242
+ description: "可选:精确 tiddler 类型(默认 text/vnd.tiddlywiki)"
2243
+ },
2244
+ limit: {
2245
+ type: "integer",
2246
+ description: "可选:返回条数上限(默认 30,最大 200)"
1886
2247
  }
1887
2248
  },
1888
2249
  output: {
1889
2250
  schema: { type: "json" },
1890
2251
  render: (_args, value) => {
1891
- const lines = [`TiddlyWiki 搜索「${value.query}」${value.tag !== null ? ` (tag=${value.tag})` : ""}:命中 ${value.count} 条。`];
2252
+ const filters = [];
2253
+ if (value.tags.length > 0) filters.push(`tags=${value.tags.join(",")}`);
2254
+ if (value.since !== null) filters.push(`since=${value.since}`);
2255
+ if (value.type !== null) filters.push(`type=${value.type}`);
2256
+ const lines = [`TiddlyWiki 搜索「${value.query}」${filters.length > 0 ? ` (${filters.join(" · ")})` : ""}:命中 ${value.total} 条。`];
1892
2257
  if (value.results.length === 0) lines.push("没有匹配的 tiddler。");
1893
2258
  for (const r of value.results) {
1894
2259
  const tags = r.tags.length > 0 ? ` [${r.tags.join(", ")}]` : "";
1895
- lines.push(`- ${r.title}${tags}`);
2260
+ const modified = r.modified !== null ? ` (${r.modified})` : "";
2261
+ lines.push(`- ${r.title}${tags}${modified}`);
1896
2262
  if (r.snippet.length > 0) lines.push(` ${r.snippet}`);
1897
2263
  }
1898
- if (value.count > value.results.length) lines.push(`(另有 ${value.count - value.results.length} 条未展开,可用 tiddlywiki_get 读取具体标题)`);
2264
+ if (value.total > value.results.length) lines.push(`(另有 ${value.total - value.results.length} 条未展开,可用 tiddlywiki_get 读取具体标题,或提高 limit)`);
1899
2265
  return [{
1900
2266
  type: "text",
1901
2267
  text: lines.join("\n")
@@ -1905,19 +2271,101 @@ function registerTiddlywikiTools(ctx, deps) {
1905
2271
  execute: async (args) => {
1906
2272
  const wiki = deps.wiki();
1907
2273
  if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
1908
- const results = await wiki.search(args.query, args.tag);
2274
+ const { items, total } = await wiki.search(args.query, {
2275
+ tags: args.tags,
2276
+ tag: args.tag,
2277
+ since: args.since,
2278
+ type: args.type,
2279
+ limit: args.limit
2280
+ });
1909
2281
  return {
1910
2282
  query: args.query,
1911
- tag: args.tag ?? null,
1912
- count: results.length,
1913
- results: results.map((t) => ({
2283
+ tags: args.tags ?? [],
2284
+ since: args.since ?? null,
2285
+ type: args.type ?? null,
2286
+ total,
2287
+ results: items.map((t) => ({
2288
+ title: t.title,
2289
+ tags: t.tags ?? [],
2290
+ modified: typeof t.modified === "string" ? t.modified : null,
2291
+ snippet: snippetOf(t.text ?? "")
2292
+ }))
2293
+ };
2294
+ }
2295
+ }));
2296
+ register(defineTool({
2297
+ name: "tiddlywiki_recent",
2298
+ description: "查看 TiddlyWiki 知识库最近修改的笔记(按修改时间倒序,排除系统 tiddler),返回标题、标签、修改时间与摘要。适合开工时快速了解近期动态。",
2299
+ parameters: {
2300
+ limit: {
2301
+ type: "integer",
2302
+ description: "可选:返回条数(默认 15,最大 200)"
2303
+ },
2304
+ since: {
2305
+ type: "string",
2306
+ description: "可选:只返回修改时间不早于该 ISO 时间的 tiddler"
2307
+ }
2308
+ },
2309
+ output: {
2310
+ schema: { type: "json" },
2311
+ render: (_args, value) => {
2312
+ const lines = [`TiddlyWiki 最近修改(最近 ${value.results.length} 条${value.since !== null ? `,since=${value.since}` : ""}):`];
2313
+ if (value.results.length === 0) lines.push("暂无笔记。");
2314
+ for (const r of value.results) {
2315
+ const tags = r.tags.length > 0 ? ` [${r.tags.join(", ")}]` : "";
2316
+ lines.push(`- ${r.title}${tags} (${r.modified ?? "?"})`);
2317
+ if (r.snippet.length > 0) lines.push(` ${r.snippet}`);
2318
+ }
2319
+ return [{
2320
+ type: "text",
2321
+ text: lines.join("\n")
2322
+ }];
2323
+ }
2324
+ },
2325
+ execute: async (args) => {
2326
+ const wiki = deps.wiki();
2327
+ if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
2328
+ const items = await wiki.recent(args.limit ?? 15, args.since);
2329
+ return {
2330
+ since: args.since ?? null,
2331
+ results: items.map((t) => ({
1914
2332
  title: t.title,
1915
2333
  tags: t.tags ?? [],
2334
+ modified: typeof t.modified === "string" ? t.modified : null,
1916
2335
  snippet: snippetOf(t.text ?? "")
1917
2336
  }))
1918
2337
  };
1919
2338
  }
1920
2339
  }));
2340
+ register(defineTool({
2341
+ name: "tiddlywiki_list_tags",
2342
+ description: "列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。",
2343
+ parameters: {},
2344
+ output: {
2345
+ schema: { type: "json" },
2346
+ render: (_args, value) => {
2347
+ if (value.tags.length === 0) return [{
2348
+ type: "text",
2349
+ text: "知识库暂无标签。"
2350
+ }];
2351
+ const lines = [`现有标签(${value.tags.length} 个,按使用次数降序):`];
2352
+ for (const t of value.tags) lines.push(`- ${t.tag} × ${t.count}`);
2353
+ return [{
2354
+ type: "text",
2355
+ text: lines.join("\n")
2356
+ }];
2357
+ }
2358
+ },
2359
+ execute: async () => {
2360
+ const wiki = deps.wiki();
2361
+ if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
2362
+ const tags = await wiki.listTags();
2363
+ return {
2364
+ count: tags.length,
2365
+ tags
2366
+ };
2367
+ }
2368
+ }));
1921
2369
  register(defineTool({
1922
2370
  name: "tiddlywiki_get",
1923
2371
  description: "读取一个 TiddlyWiki tiddler 的完整内容(标题、全文、标签、自定义字段)。",
@@ -1935,6 +2383,7 @@ function registerTiddlywikiTools(ctx, deps) {
1935
2383
  }];
1936
2384
  const lines = [`tiddler「${value.title}」`];
1937
2385
  if (value.tags.length > 0) lines.push(`标签: ${value.tags.join(", ")}`);
2386
+ if (value.modified !== null) lines.push(`修改: ${value.modified}`);
1938
2387
  const fields = Object.entries(value.fields);
1939
2388
  if (fields.length > 0) lines.push(`字段: ${fields.map(([k, v]) => `${k}=${String(v)}`).join(", ")}`);
1940
2389
  lines.push("--- 全文 ---");
@@ -1954,14 +2403,16 @@ function registerTiddlywikiTools(ctx, deps) {
1954
2403
  title: args.title,
1955
2404
  text: "",
1956
2405
  tags: [],
1957
- fields: {}
2406
+ fields: {},
2407
+ modified: null
1958
2408
  };
1959
2409
  return {
1960
2410
  notFound: false,
1961
2411
  title: t.title,
1962
2412
  text: t.text ?? "",
1963
2413
  tags: t.tags ?? [],
1964
- fields: pickFields(t)
2414
+ fields: pickFields(t),
2415
+ modified: typeof t.modified === "string" ? t.modified : null
1965
2416
  };
1966
2417
  }
1967
2418
  }));
@@ -2023,6 +2474,186 @@ function registerTiddlywikiTools(ctx, deps) {
2023
2474
  };
2024
2475
  }
2025
2476
  }));
2477
+ register(defineTool({
2478
+ name: "tiddlywiki_batch_put",
2479
+ description: "批量写入/覆盖多个 TiddlyWiki tiddler(一次工具调用)。overwrite=false 时跳过已存在的标题;返回逐条结果。写入后触发自动 commit。",
2480
+ parameters: {
2481
+ items: {
2482
+ type: "array",
2483
+ items: {
2484
+ type: "object",
2485
+ additionalProperties: true,
2486
+ description: "要写入的 tiddler 数组",
2487
+ properties: {
2488
+ title: {
2489
+ type: "string",
2490
+ description: "标题(精确匹配,覆盖同名)",
2491
+ required: true
2492
+ },
2493
+ text: {
2494
+ type: "string",
2495
+ description: "全文(wiki 文本)",
2496
+ required: true
2497
+ },
2498
+ tags: {
2499
+ type: "array",
2500
+ items: { type: "string" },
2501
+ description: "标签数组(可选)"
2502
+ },
2503
+ fields: {
2504
+ type: "json",
2505
+ description: "附加自定义字段(可选)"
2506
+ }
2507
+ }
2508
+ }
2509
+ },
2510
+ overwrite: {
2511
+ type: "boolean",
2512
+ description: "可选:true=覆盖同名(默认),false=跳过已存在的标题"
2513
+ }
2514
+ },
2515
+ output: {
2516
+ schema: { type: "json" },
2517
+ render: (_args, value) => {
2518
+ const lines = [`批量写入完成:成功 ${value.written},跳过 ${value.skipped},共 ${value.items.length} 条。`];
2519
+ for (const r of value.items) lines.push(`- ${r.title}:${r.written ? "已写入" : "已跳过(存在)"}`);
2520
+ return [{
2521
+ type: "text",
2522
+ text: lines.join("\n")
2523
+ }];
2524
+ }
2525
+ },
2526
+ execute: async (args) => {
2527
+ const wiki = deps.wiki();
2528
+ if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
2529
+ const list = Array.isArray(args.items) ? args.items : [];
2530
+ if (list.length === 0) return {
2531
+ ok: true,
2532
+ written: 0,
2533
+ skipped: 0,
2534
+ items: []
2535
+ };
2536
+ const overwrite = args.overwrite !== false;
2537
+ const results = [];
2538
+ let written = 0;
2539
+ let skipped = 0;
2540
+ for (const item of list) {
2541
+ if (typeof item.title !== "string" || item.title.length === 0) throw new Error("batch_put: 每条 items 都需要非空 title");
2542
+ if (typeof item.text !== "string") throw new Error(`batch_put: items「${item.title}」缺少 text`);
2543
+ if (!overwrite) {
2544
+ if (await wiki.get(item.title).catch(() => void 0) !== void 0) {
2545
+ skipped++;
2546
+ results.push({
2547
+ title: item.title,
2548
+ written: false,
2549
+ skipped: true
2550
+ });
2551
+ continue;
2552
+ }
2553
+ }
2554
+ const tiddler = {
2555
+ title: item.title,
2556
+ text: item.text
2557
+ };
2558
+ if (Array.isArray(item.tags) && item.tags.length > 0) tiddler.tags = item.tags;
2559
+ if (item.fields !== void 0 && typeof item.fields === "object" && item.fields !== null) Object.assign(tiddler, item.fields);
2560
+ await wiki.put(tiddler);
2561
+ written++;
2562
+ results.push({
2563
+ title: item.title,
2564
+ written: true,
2565
+ skipped: false
2566
+ });
2567
+ }
2568
+ deps.autoCommit();
2569
+ return {
2570
+ ok: true,
2571
+ written,
2572
+ skipped,
2573
+ items: results
2574
+ };
2575
+ }
2576
+ }));
2577
+ register(defineTool({
2578
+ name: "tiddlywiki_rename",
2579
+ description: "重命名一个 TiddlyWiki tiddler:把旧标题的内容复制到新标题、删除旧标题,并可选地更新其他 tiddler 里的 [[旧标题]] / {{旧标题}} 引用(最佳努力)。",
2580
+ parameters: {
2581
+ oldTitle: {
2582
+ type: "string",
2583
+ description: "当前标题",
2584
+ required: true
2585
+ },
2586
+ newTitle: {
2587
+ type: "string",
2588
+ description: "新标题",
2589
+ required: true
2590
+ },
2591
+ updateRefs: {
2592
+ type: "boolean",
2593
+ description: "可选:是否同步更新其他 tiddler 里的引用(默认 true)"
2594
+ }
2595
+ },
2596
+ output: {
2597
+ schema: { type: "json" },
2598
+ render: (_args, value) => {
2599
+ const lines = [`已重命名「${value.from}」→「${value.to}」`];
2600
+ lines.push(`更新了 ${value.refsUpdated} 处引用(${value.refsTiddlers} 个 tiddler)`);
2601
+ if (value.warning !== void 0) lines.push(`注意: ${value.warning}`);
2602
+ return [{
2603
+ type: "text",
2604
+ text: lines.join("\n")
2605
+ }];
2606
+ }
2607
+ },
2608
+ execute: async (args) => {
2609
+ const wiki = deps.wiki();
2610
+ if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
2611
+ const { oldTitle, newTitle } = args;
2612
+ if (oldTitle === newTitle) return {
2613
+ ok: true,
2614
+ from: oldTitle,
2615
+ to: newTitle,
2616
+ refsUpdated: 0,
2617
+ refsTiddlers: 0
2618
+ };
2619
+ const existing = await wiki.get(oldTitle);
2620
+ if (existing === void 0) throw new Error(`tiddler「${oldTitle}」不存在`);
2621
+ if (await wiki.get(newTitle) !== void 0) throw new Error(`新标题「${newTitle}」已存在(可先用 tiddlywiki_delete 删除)`);
2622
+ let refsUpdated = 0;
2623
+ let refsTiddlers = 0;
2624
+ let warning;
2625
+ if (args.updateRefs !== false) {
2626
+ const all = await wiki.list(void 0, true);
2627
+ for (const t of all) {
2628
+ if (t.title === oldTitle || t.title === newTitle) continue;
2629
+ if (t.title.startsWith("$:/")) continue;
2630
+ const text = t.text ?? "";
2631
+ if (text.length === 0) continue;
2632
+ const rewritten = rewriteRefs(text, oldTitle, newTitle);
2633
+ if (rewritten.count > 0) {
2634
+ await wiki.put({
2635
+ ...cleanTiddler(t),
2636
+ text: rewritten.text
2637
+ });
2638
+ refsUpdated += rewritten.count;
2639
+ refsTiddlers++;
2640
+ }
2641
+ }
2642
+ }
2643
+ await wiki.put(cleanTiddler(existing));
2644
+ await wiki.delete(oldTitle);
2645
+ if (refsTiddlers === 0) warning = "未找到任何其他 tiddler 引用旧标题;如确实需要,可手动补充链接。";
2646
+ deps.autoCommit();
2647
+ return {
2648
+ ok: true,
2649
+ from: oldTitle,
2650
+ to: newTitle,
2651
+ refsUpdated,
2652
+ refsTiddlers,
2653
+ ...warning !== void 0 ? { warning } : {}
2654
+ };
2655
+ }
2656
+ }));
2026
2657
  register(defineTool({
2027
2658
  name: "tiddlywiki_delete",
2028
2659
  description: "删除一个 TiddlyWiki tiddler(不存在时是幂等空操作)。删除后触发自动 commit。",
@@ -2132,6 +2763,98 @@ function registerTiddlywikiTools(ctx, deps) {
2132
2763
  }
2133
2764
  }
2134
2765
  }));
2766
+ register(defineTool({
2767
+ name: "tiddlywiki_git_resolve",
2768
+ description: "在 tiddlywiki_git_sync action=pull 冲突(已 abort)后,按 tiddler 二选一解决:keep-local 保留本地版本;keep-remote 用 git fetch 拉取远端并检出远端版本(需已配置 git.remote);list 仅报告当前状态。解决后建议重新 pull/sync 整合其余改动。",
2769
+ parameters: {
2770
+ strategy: {
2771
+ type: "string",
2772
+ enum: [
2773
+ "keep-local",
2774
+ "keep-remote",
2775
+ "list"
2776
+ ],
2777
+ description: "keep-local=保留本地;keep-remote=改用远端版本;list=仅报告当前 git 状态",
2778
+ required: true
2779
+ },
2780
+ files: {
2781
+ type: "array",
2782
+ items: { type: "string" },
2783
+ description: "冲突文件名数组(来自 pull 返回的 conflictFiles;list 时忽略)"
2784
+ }
2785
+ },
2786
+ output: {
2787
+ schema: { type: "json" },
2788
+ render: (_args, value) => {
2789
+ const lines = [`git resolve ${value.action}: ${value.ok ? "成功" : "失败"}`];
2790
+ lines.push(` ${value.message}`);
2791
+ if (value.files !== void 0 && value.files.length > 0) lines.push(`涉及文件: ${value.files.join(", ")}`);
2792
+ if (value.commit !== void 0) lines.push(`本地 commit: ${value.commit}`);
2793
+ if (value.status !== void 0) {
2794
+ const s = value.status;
2795
+ const bits = [`分支 ${s.branch}`];
2796
+ if (s.ahead !== void 0) bits.push(`领先 ${s.ahead}`);
2797
+ if (s.behind !== void 0) bits.push(`落后 ${s.behind}`);
2798
+ if (s.dirty) bits.push(`工作区有 ${s.dirtyFiles.length} 个未提交改动`);
2799
+ if (s.lastCommit !== void 0) bits.push(`最近提交 ${s.lastCommit}`);
2800
+ lines.push(`状态: ${bits.join(" · ")}`);
2801
+ }
2802
+ return [{
2803
+ type: "text",
2804
+ text: lines.join("\n")
2805
+ }];
2806
+ }
2807
+ },
2808
+ execute: async (args) => {
2809
+ const dir = deps.wikiPath();
2810
+ if (args.strategy === "list") return {
2811
+ ok: true,
2812
+ action: "list",
2813
+ message: "当前仓库状态(pull 冲突已 abort,工作区即本地版本,不会残留未合并状态)",
2814
+ status: await deps.git.status(dir)
2815
+ };
2816
+ const files = (args.files ?? []).filter((f) => typeof f === "string" && f.length > 0);
2817
+ if (files.length === 0) return {
2818
+ ok: false,
2819
+ action: args.strategy,
2820
+ message: "请提供 conflictFiles(来自 pull 返回)",
2821
+ hint: "可先执行 tiddlywiki_git_sync action=pull 查看冲突文件"
2822
+ };
2823
+ if (args.strategy === "keep-local") {
2824
+ const status = await deps.git.status(dir);
2825
+ return {
2826
+ ok: true,
2827
+ action: "keep-local",
2828
+ message: `已保留本地版本(${files.length} 个文件;abort 后本地即为工作区内容)。建议重新 tiddlywiki_git_sync action=sync 整合远端其余改动。`,
2829
+ files,
2830
+ status
2831
+ };
2832
+ }
2833
+ const fetched = await deps.git.fetch(dir);
2834
+ if (!fetched.ok) return {
2835
+ ok: false,
2836
+ action: "keep-remote",
2837
+ message: `fetch 失败(可能未配置 git.remote):${fetched.message}`
2838
+ };
2839
+ const checked = await deps.git.checkoutFetchHead(dir, files);
2840
+ if (!checked.ok) return {
2841
+ ok: false,
2842
+ action: "keep-remote",
2843
+ message: `从远端检出失败:${checked.message}`
2844
+ };
2845
+ const committed = await deps.git.commit(dir, `resolve conflict (keep remote) ${(/* @__PURE__ */ new Date()).toISOString()}`);
2846
+ deps.autoCommit();
2847
+ const status = await deps.git.status(dir);
2848
+ return {
2849
+ ok: true,
2850
+ action: "keep-remote",
2851
+ message: `已把 ${files.length} 个冲突文件改为远端版本并提交。建议继续 tiddlywiki_git_sync action=sync 完成整合与推送。`,
2852
+ files,
2853
+ commit: committed.message,
2854
+ status
2855
+ };
2856
+ }
2857
+ }));
2135
2858
  return disposers;
2136
2859
  }
2137
2860
  function renderSync(value) {
@@ -2140,7 +2863,7 @@ function renderSync(value) {
2140
2863
  if (value.conflictFiles !== void 0 && value.conflictFiles.length > 0) {
2141
2864
  lines.push(`冲突文件(rebase 已 abort,勿自动覆盖):`);
2142
2865
  for (const f of value.conflictFiles) lines.push(` - ${f}`);
2143
- lines.push("处理方式:git checkout --ours <file> 保留本地,或人工编辑后 git add + git rebase --continue;也可以直接让用户处理。");
2866
+ lines.push("处理方式:用 tiddlywiki_git_resolve files=[以上文件] strategy=keep-local|keep-remote 按 tiddler 二选一解决,再重新 sync;也可以直接让用户处理。");
2144
2867
  }
2145
2868
  if (value.commit !== void 0) lines.push(`本地 commit: ${value.commit}`);
2146
2869
  if (value.push !== void 0) lines.push(`远端 push: ${value.push}`);
@@ -2208,6 +2931,34 @@ const DEFAULTS = {
2208
2931
  password: ""
2209
2932
  }
2210
2933
  };
2934
+ /** Config tiddler steering TW's frontend API base (tiddlywebadaptor). */
2935
+ const TW_WEB_HOST_TIDDLER = "$:/config/tiddlyweb/host";
2936
+ /** The legacy default value this plugin replaces with the same-origin proxy. */
2937
+ const TW_WEB_HOST_DEFAULT = "$protocol$//$host$/";
2938
+ /**
2939
+ * Point TW's frontend at the same-origin DSH proxy (remote-access mode, R1).
2940
+ * The tiddlywebadaptor builds every API URL from $:/config/tiddlyweb/host; its
2941
+ * default `$protocol$//$host$/` resolves to the iframe's origin ROOT, which
2942
+ * 404s whenever the browser is not on the same machine as TW. Written only
2943
+ * when the tiddler is missing or still the legacy default — a user override is
2944
+ * honored (e.g. someone who really does expose TW on a dedicated origin).
2945
+ */
2946
+ async function ensureTwWebHost(client) {
2947
+ if (client === void 0) return;
2948
+ let current;
2949
+ try {
2950
+ current = (await client.get(TW_WEB_HOST_TIDDLER))?.text;
2951
+ } catch {
2952
+ current = void 0;
2953
+ }
2954
+ if (current !== void 0 && current.trim() !== TW_WEB_HOST_DEFAULT) return;
2955
+ await client.put({
2956
+ title: TW_WEB_HOST_TIDDLER,
2957
+ text: TW_PROXY_PATH,
2958
+ type: "text/plain",
2959
+ tags: []
2960
+ });
2961
+ }
2211
2962
  /** Expand $VAR / ${VAR} / %VAR% from process.env (config uses $DSH_HOME). */
2212
2963
  function expandEnvPath(input) {
2213
2964
  return input.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => process.env[k] ?? "").replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, k) => process.env[k] ?? "").replace(/%([A-Za-z_][A-Za-z0-9_]*%)/g, (_, k) => process.env[k.slice(0, -1)] ?? "");
@@ -2248,14 +2999,17 @@ const PROMPT_TEXT = `## TiddlyWiki 持久知识库
2248
2999
 
2249
3000
  本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。你可以用工具读写 tiddler:
2250
3001
 
2251
- - \`tiddlywiki_search\`(query, tag?)检索;\`tiddlywiki_get\`(title)读全文;\`tiddlywiki_put\`(title, text, tags?, fields?)写/覆盖;\`tiddlywiki_delete\`(title)删除。
2252
- - \`tiddlywiki_git_sync\`(pull|push|sync)做 git 同步。
3002
+ - \`tiddlywiki_search\`(query 必填;可选 tags[]/tag、since 修改时间、type、limit)检索;\`tiddlywiki_get\`(title)读全文;\`tiddlywiki_put\`(title, text, tags?, fields?)写/覆盖;\`tiddlywiki_batch_put\`(items[])批量写;\`tiddlywiki_rename\`(oldTitle, newTitle, updateRefs?)重命名并尽量同步引用;\`tiddlywiki_delete\`(title)删除。
3003
+ - \`tiddlywiki_recent\`(limit?, since?)看最近修改的笔记;\`tiddlywiki_list_tags\` 看现有 tag 及计数。
3004
+ - \`tiddlywiki_git_sync\`(pull|push|sync)做 git 同步;\`tiddlywiki_git_resolve\`(files, strategy=keep-local|keep-remote|list)在 pull 冲突后按 tiddler 二选一解决。
2253
3005
 
2254
3006
  知识库同步纪律(三条):
2255
3007
  1. 开工先 pull:\`tiddlywiki_git_sync action=pull\`(rebase + autostash;真冲突会自动 abort 并报冲突文件)。
2256
3008
  2. 收工 commit + push:\`tiddlywiki_git_sync action=sync\`(pull → commit → push)。
2257
3009
  3. 插件会自动防抖 commit(默认 60s),手动同步用上面的工具。
2258
3010
 
3011
+ pull 冲突后:先 \`tiddlywiki_git_resolve files=[冲突文件] strategy=keep-local\`(保留本地)或 \`strategy=keep-remote\`(改用远端版本),再重新 pull/sync 整合其余改动。
3012
+
2259
3013
  把 wiki 当作长期记忆与知识沉淀的地方:会议纪要、决策记录、调研笔记、随手的想法都可存成独立 tiddler(tag 建议用 inbox/meeting/decision 等便于检索)。
2260
3014
 
2261
3015
  用本插件自动创建笔记时,除了业务性 tag 外,请把「当前工作区(项目)的名字」也作为标签之一加上去(例如 \`tiddlywiki_put\` 的 tags 里带上当前 workspace 名),这样笔记能按项目归集、检索。`;
@@ -2377,6 +3131,11 @@ function apply(ctx, rawConfig = {}) {
2377
3131
  try {
2378
3132
  await server.start();
2379
3133
  await configStore.load(client());
3134
+ try {
3135
+ await ensureTwWebHost(client());
3136
+ } catch (err) {
3137
+ console.warn("[dsh-tiddlywiki] ensuring $:/config/tiddlyweb/host:", err);
3138
+ }
2380
3139
  try {
2381
3140
  const seedClient = client();
2382
3141
  if (seedClient !== void 0) await seedDocNote(seedClient);
@@ -2427,11 +3186,16 @@ function apply(ctx, rawConfig = {}) {
2427
3186
  };
2428
3187
  });
2429
3188
  ctx.effect(() => () => {
2430
- disposeAll();
2431
- server.stop();
3189
+ (async () => {
3190
+ try {
3191
+ await committer?.flush();
3192
+ } catch {}
3193
+ disposeAll();
3194
+ await server.stop();
3195
+ })();
2432
3196
  }, "dsh-tiddlywiki: host teardown");
2433
3197
  }
2434
3198
  //#endregion
2435
- export { AutoCommitter, ConfigStore, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, apply, bundledCatalog, deepMerge, defineTool, dshHomePath, ensureLanguage, inject, name, normalizeThemes, openInTwEditor, readWikiInfo, registerAdminRoutes, registerRoutes, resolveTwRoot, seedDocNote, writeWikiInfo };
3199
+ export { AutoCommitter, ConfigStore, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, GitFace, PATH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_TIDDLER, TiddlyWebClient, WikiServer, apply, bundledCatalog, deepMerge, defineTool, dshHomePath, ensureLanguage, ensureTwWebHost, inject, name, normalizeThemes, openInTwEditor, readWikiInfo, registerAdminRoutes, registerRoutes, resolveTwRoot, seedDocNote, writeWikiInfo };
2436
3200
 
2437
3201
  //# sourceMappingURL=index.js.map