dsh-tiddlywiki 0.27.2 → 0.28.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.
Files changed (41) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -0
  3. package/cordis.patch.yml +13 -13
  4. package/docs/CHANGELOG.md +2 -1
  5. package/docs/code-review-2026-09-17.md +151 -0
  6. package/docs/features.md +5 -2
  7. package/docs/usage.md +34 -3
  8. package/docs/wiki-split.md +97 -0
  9. package/lib/client.js +54 -27
  10. package/lib/index.js +2803 -517
  11. package/lib/index.js.map +1 -1
  12. package/package.json +5 -4
  13. package/skills/wiki-split/SKILL.md +106 -0
  14. package/src/client/endpoints.ts +37 -0
  15. package/src/client/index.ts +14 -0
  16. package/src/client/knowledge-fab.ts +62 -3
  17. package/src/client/markdown-editor.ts +128 -128
  18. package/src/client/panel.ts +2 -1
  19. package/src/client/settings-page.ts +217 -3
  20. package/src/client/state.ts +39 -39
  21. package/src/client/status-cache.ts +17 -0
  22. package/src/client/styles.ts +27 -0
  23. package/src/client/toast.ts +22 -22
  24. package/src/client/tw-frame.ts +28 -4
  25. package/src/client/wiki-focus.ts +70 -0
  26. package/src/client/wiki-scope-dock.ts +116 -0
  27. package/src/host/admin.ts +175 -35
  28. package/src/host/git.ts +50 -5
  29. package/src/host/path-key.ts +35 -0
  30. package/src/host/prompt.ts +24 -0
  31. package/src/host/repo-committers.ts +149 -0
  32. package/src/host/routes.ts +254 -81
  33. package/src/host/seeds.ts +51 -10
  34. package/src/host/session-scope.ts +154 -0
  35. package/src/host/skill-install.ts +121 -0
  36. package/src/host/tools.ts +141 -49
  37. package/src/host/wiki-farm.ts +417 -0
  38. package/src/host/wiki-instance.ts +596 -0
  39. package/src/host/wiki-registry.ts +601 -0
  40. package/src/host/wiki.ts +19 -0
  41. package/src/index.ts +686 -459
package/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
+ import { execFile, spawn, spawnSync } from "node:child_process";
2
3
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
3
- import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
5
- import { execFile, spawn, spawnSync } from "node:child_process";
6
5
  import { promisify } from "node:util";
6
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { Readable } from "node:stream";
8
8
  import { createServer, isIP } from "node:net";
9
9
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
11
11
  import { createServer as createServer$1, get } from "node:http";
12
12
  import { get as get$1 } from "node:https";
13
13
  import { lookup } from "node:dns/promises";
14
+ import { fileURLToPath } from "node:url";
14
15
  //#region src/host/git.ts
15
16
  /**
16
17
  * Git face (design doc §7, D11) — the ONLY place dsh-tiddlywiki shells out to
@@ -136,6 +137,49 @@ var GitFace = class {
136
137
  });
137
138
  return r.ok && r.stdout.trim() === "true";
138
139
  }
140
+ /**
141
+ * The repository a folder belongs to (`git rev-parse --show-toplevel`), or
142
+ * undefined when it is not inside a work tree (v0.28.0).
143
+ *
144
+ * WHY THE PLUGIN NEEDS THIS: several knowledge bases may be SUBFOLDERS of one
145
+ * repository — the "工作 + 个人 共用现有仓库、按文件夹物理划分" topology. Git
146
+ * then has ONE index and `git add -A` (from any subdirectory) stages the WHOLE
147
+ * working tree, so the auto-committer must be keyed by the REPOSITORY rather
148
+ * than by the wiki; two per-folder committers would each commit the other's
149
+ * changes and one would forever report "nothing to commit" (see
150
+ * host/repo-committers.ts).
151
+ */
152
+ async repoRoot(dir) {
153
+ const r = await this.exec(["rev-parse", "--show-toplevel"], {
154
+ cwd: dir,
155
+ timeout: QUICK_TIMEOUT_MS
156
+ });
157
+ if (!r.ok) return void 0;
158
+ const root = r.stdout.trim();
159
+ return root.length > 0 ? resolve(root) : void 0;
160
+ }
161
+ /**
162
+ * Repository-relative paths that differ between two commits (v0.28.0).
163
+ *
164
+ * Used after a pull to decide WHICH knowledge bases need their TW child
165
+ * restarted: with one repository holding several wikis, "the pull changed
166
+ * something" is not the same question as "THIS wiki's content changed".
167
+ * Returns [] for an unusable range (no commits yet, unknown ref).
168
+ */
169
+ async filesChangedBetween(dir, from, to) {
170
+ if (from.length === 0 || to.length === 0 || from === to) return [];
171
+ const r = await this.exec([
172
+ "diff",
173
+ "--name-only",
174
+ from,
175
+ to
176
+ ], {
177
+ cwd: dir,
178
+ timeout: QUICK_TIMEOUT_MS
179
+ });
180
+ if (!r.ok) return [];
181
+ return r.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
182
+ }
139
183
  async init(dir, branch = "main") {
140
184
  return (await this.exec([
141
185
  "init",
@@ -263,7 +307,9 @@ var GitFace = class {
263
307
  /** `git pull --rebase --autostash`; on conflict: abort + report files.
264
308
  * On success, `changed: true` means HEAD actually moved (files came in /
265
309
  * commits were replayed) — callers use it to decide whether a running TW
266
- * child needs a restart to drop its stale in-memory snapshot.
310
+ * child needs a restart to drop its stale in-memory snapshot, and
311
+ * `changedFiles` (v0.28.0) says WHICH paths moved, so a caller serving
312
+ * several wikis out of one repository can restart only the affected ones.
267
313
  * Serialized against `commit()` (index.lock). */
268
314
  pull(dir) {
269
315
  return this.withLock(() => this.pullUnlocked(dir));
@@ -289,11 +335,16 @@ var GitFace = class {
289
335
  timeout: QUICK_TIMEOUT_MS
290
336
  });
291
337
  const afterHead = after.ok ? after.stdout.trim() : "";
292
- const changed = beforeHead.length > 0 && beforeHead !== afterHead;
338
+ if (!(beforeHead.length > 0 && beforeHead !== afterHead)) return {
339
+ ok: true,
340
+ message: r.stdout.trim() || "pull ok"
341
+ };
342
+ const changedFiles = await this.filesChangedBetween(dir, beforeHead, afterHead);
293
343
  return {
294
344
  ok: true,
295
345
  message: r.stdout.trim() || "pull ok",
296
- ...changed ? { changed: true } : {}
346
+ changed: true,
347
+ ...changedFiles.length > 0 ? { changedFiles } : {}
297
348
  };
298
349
  }
299
350
  const conflictFiles = await this.unmergedFiles(dir);
@@ -1296,6 +1347,24 @@ const PATH_PREFIX = "/dsh-tiddlywiki";
1296
1347
  const TW_PROXY_PREFIX = `${PATH_PREFIX}/tw`;
1297
1348
  /** The proxy base path (trailing slash) handed to browsers / TW's frontend. */
1298
1349
  const TW_PROXY_PATH = `${TW_PROXY_PREFIX}/`;
1350
+ /**
1351
+ * The same-origin proxy base a wiki's TW frontend must use (v0.28.0).
1352
+ *
1353
+ * SINGLE mode returns the LEGACY bare path: that wiki IS the default one, and
1354
+ * every existing install already has exactly that value in its host tiddler — so
1355
+ * the upgrade produces no diff at all.
1356
+ *
1357
+ * MULTI mode gives each wiki `/dsh-tiddlywiki/tw/<id>/`. That is load-bearing:
1358
+ * TW builds every API URL from this tiddler, so a wiki using the bare path while
1359
+ * another wiki is default would render one wiki's UI over another's data.
1360
+ *
1361
+ * Both forms route to the same child, so a stale value left by a mode flip is
1362
+ * harmless. Exported so the host wiring and the verification harness use ONE
1363
+ * implementation of the rule.
1364
+ */
1365
+ function proxyBaseFor(mode, wikiId) {
1366
+ return mode === "multi" ? `${TW_PROXY_PATH}${encodeURIComponent(wikiId)}/` : TW_PROXY_PATH;
1367
+ }
1299
1368
  /** Late-ready watch: cadence and lifetime after the hard deadline passed. */
1300
1369
  const LATE_READY_POLL_MS = 5e3;
1301
1370
  /** How long the late-ready watch keeps probing a child that outlived the deadline. */
@@ -3252,6 +3321,27 @@ async function unseedStarterDocs(client) {
3252
3321
  *
3253
3322
  * @module dsh-tiddlywiki/host/prompt
3254
3323
  */
3324
+ /**
3325
+ * The scope banner prepended to the injected prompt when MORE THAN ONE knowledge
3326
+ * base is visible to the agent (v0.28.0).
3327
+ *
3328
+ * Why a banner at all: with several wikis the model has no way to know which one
3329
+ * its `tiddlywiki_*` calls act on, and the failure mode (writing a note into the
3330
+ * wrong knowledge base) is silent. The banner names the scope, and every tool
3331
+ * result repeats it. With a single visible wiki this returns `''`, so existing
3332
+ * installs keep exactly the text they had.
3333
+ */
3334
+ function scopeBanner(scope) {
3335
+ if (!scope.ambiguous || scope.id === void 0) return "";
3336
+ const why = scope.reason === void 0 ? "" : `(${scope.reason})`;
3337
+ return `> **本会话作用域:${scope.label ?? scope.id}(${scope.id})**${why}\n> 本会话所有 \`tiddlywiki_*\` 工具都只作用于这个知识库,每条工具回执也会标明它。`;
3338
+ }
3339
+ /** Built prompt text plus the optional scope banner (the section's final text). */
3340
+ function withScopeBanner(text, scope) {
3341
+ if (text.length === 0) return "";
3342
+ const banner = scopeBanner(scope);
3343
+ return banner.length === 0 ? text : `${banner}\n\n${text}`;
3344
+ }
3255
3345
  /** Prompt section name (stable id; a re-registration replaces the old one). */
3256
3346
  const PROMPT_SECTION_NAME = "dsh-tiddlywiki";
3257
3347
  /** Prompt section order (the plugin's section sits before plan/team policy). */
@@ -4375,7 +4465,7 @@ const TW_WEB_HOST_DEFAULT = "$protocol$//$host$/";
4375
4465
  * (mirrored in src/client/theme-sync.ts — the two bundles cannot share code).
4376
4466
  */
4377
4467
  const DARK_PALETTE_DEFAULT = "$:/palettes/CupertinoDark";
4378
- function isPlainObject$1(value) {
4468
+ function isPlainObject$2(value) {
4379
4469
  return typeof value === "object" && value !== null && !Array.isArray(value);
4380
4470
  }
4381
4471
  /** Deep-merge: `over` wins; nested plain objects merge recursively. */
@@ -4383,7 +4473,7 @@ function deepMerge(base, over) {
4383
4473
  const out = { ...base };
4384
4474
  for (const [key, value] of Object.entries(over)) {
4385
4475
  if (value === void 0) continue;
4386
- if (isPlainObject$1(value) && isPlainObject$1(out[key])) out[key] = deepMerge(out[key], value);
4476
+ if (isPlainObject$2(value) && isPlainObject$2(out[key])) out[key] = deepMerge(out[key], value);
4387
4477
  else out[key] = value;
4388
4478
  }
4389
4479
  return out;
@@ -4498,13 +4588,13 @@ const FORBIDDEN_PATCH_KEYS = /* @__PURE__ */ new Set([
4498
4588
  * - `__proto__` / `constructor` / `prototype` anywhere → rejected.
4499
4589
  */
4500
4590
  function normalizeConfigPatch(input) {
4501
- if (!isPlainObject$1(input)) throw new ConfigPatchError("配置补丁必须是一个 JSON 对象(例如 {\"note\":{\"tag\":\"inbox\"}})");
4591
+ if (!isPlainObject$2(input)) throw new ConfigPatchError("配置补丁必须是一个 JSON 对象(例如 {\"note\":{\"tag\":\"inbox\"}})");
4502
4592
  const out = {};
4503
4593
  const walk = (source, target, prefix) => {
4504
4594
  for (const [key, value] of Object.entries(source)) {
4505
4595
  if (FORBIDDEN_PATCH_KEYS.has(key)) throw new ConfigPatchError(`配置补丁里不允许出现键「${key}」`);
4506
4596
  const path = prefix.length > 0 ? `${prefix}.${key}` : key;
4507
- if (isPlainObject$1(value)) {
4597
+ if (isPlainObject$2(value)) {
4508
4598
  const child = {};
4509
4599
  target[key] = child;
4510
4600
  walk(value, child, path);
@@ -4591,7 +4681,7 @@ var ConfigStore = class {
4591
4681
  const raw = typeof tiddler.text === "string" ? tiddler.text : "";
4592
4682
  try {
4593
4683
  const parsed = JSON.parse(raw);
4594
- if (!isPlainObject$1(parsed)) throw new Error("config tiddler is not a JSON object");
4684
+ if (!isPlainObject$2(parsed)) throw new Error("config tiddler is not a JSON object");
4595
4685
  this.overrides = parsed;
4596
4686
  this.lastParseError = void 0;
4597
4687
  } catch {
@@ -4618,7 +4708,7 @@ var ConfigStore = class {
4618
4708
  if (tiddler !== void 0 && typeof tiddler.text === "string") {
4619
4709
  storedText = tiddler.text;
4620
4710
  const parsed = JSON.parse(tiddler.text);
4621
- if (!isPlainObject$1(parsed)) throw new Error("config tiddler is not a JSON object");
4711
+ if (!isPlainObject$2(parsed)) throw new Error("config tiddler is not a JSON object");
4622
4712
  stored = parsed;
4623
4713
  }
4624
4714
  if (tiddler !== void 0 && typeof tiddler.created === "string" && tiddler.created.trim().length > 0) existingCreated = tiddler.created;
@@ -4645,6 +4735,17 @@ var ConfigStore = class {
4645
4735
  //#endregion
4646
4736
  //#region src/host/seeds.ts
4647
4737
  /**
4738
+ * Is this value a same-origin proxy base WE wrote — for this wiki or a sibling?
4739
+ *
4740
+ * Used to tell "our own stale value" (safe to update) from "a base the user
4741
+ * deliberately chose" (never touched). Matches the bare legacy path and any
4742
+ * `/dsh-tiddlywiki/tw/<id>/` form.
4743
+ */
4744
+ function isOurProxyBase(value) {
4745
+ if (typeof value !== "string") return false;
4746
+ return /^\/dsh-tiddlywiki\/tw\/(?:[a-z0-9][a-z0-9._-]{0,63}\/)?$/.test(value.trim());
4747
+ }
4748
+ /**
4648
4749
  * Wait until a file exists on disk (polling), up to `timeoutMs`.
4649
4750
  *
4650
4751
  * TW's syncer flushes REST writes to the filesystem on a ~250ms task timer, so
@@ -5198,6 +5299,7 @@ const SEED_DEFS = [
5198
5299
  core: true
5199
5300
  }, {
5200
5301
  check: async (ctx) => {
5302
+ const desired = ctx.proxyBase ?? TW_PROXY_PATH;
5201
5303
  const current = (await ctx.client.get(TW_WEB_HOST_TIDDLER))?.text?.trim();
5202
5304
  const present = typeof current === "string" && current.length > 0 && current !== "$protocol$//$host$/";
5203
5305
  return {
@@ -5206,21 +5308,29 @@ const SEED_DEFS = [
5206
5308
  description: "把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。",
5207
5309
  present,
5208
5310
  removable: false,
5209
- detail: present ? current === TW_PROXY_PATH ? `已指向 ${TW_PROXY_PATH}` : `已指向自定义基址 ${current}(保留,不会覆盖)` : `当前:${current ?? "(缺失)"},应为 ${TW_PROXY_PATH}`
5311
+ detail: !present ? `当前:${current ?? "(缺失)"},应为 ${desired}` : current === desired ? `已指向 ${desired}` : isOurProxyBase(current) ? `已指向 ${current}(本库应改为 ${desired},下次启动会更新)` : `已指向自定义基址 ${current}(保留,不会覆盖)`
5210
5312
  };
5211
5313
  },
5212
5314
  run: async (ctx, force) => {
5213
5315
  try {
5214
5316
  const current = (await ctx.client.get(TW_WEB_HOST_TIDDLER))?.text?.trim();
5215
- if (!force && current !== void 0 && current !== "$protocol$//$host$/") return {
5317
+ const desired = ctx.proxyBase ?? TW_PROXY_PATH;
5318
+ const replaceable = current === void 0 || current.length === 0 || current === "$protocol$//$host$/" || isOurProxyBase(current);
5319
+ if (!force && !replaceable) return {
5216
5320
  id: "tw-web-host",
5217
5321
  ok: true,
5218
5322
  wrote: false,
5219
5323
  detail: "已指向自定义基址,未覆盖"
5220
5324
  };
5325
+ if (!force && current === desired) return {
5326
+ id: "tw-web-host",
5327
+ ok: true,
5328
+ wrote: false,
5329
+ detail: `已指向 ${desired}`
5330
+ };
5221
5331
  await ctx.client.put({
5222
5332
  title: TW_WEB_HOST_TIDDLER,
5223
- text: TW_PROXY_PATH,
5333
+ text: desired,
5224
5334
  type: "text/plain",
5225
5335
  tags: []
5226
5336
  });
@@ -5228,7 +5338,7 @@ const SEED_DEFS = [
5228
5338
  id: "tw-web-host",
5229
5339
  ok: true,
5230
5340
  wrote: true,
5231
- detail: force ? "已重新初始化(强制写回代理基址)" : "已写入代理基址"
5341
+ detail: force ? "已重新初始化(强制写回代理基址)" : `已写入代理基址 ${desired}`
5232
5342
  };
5233
5343
  } catch (err) {
5234
5344
  return {
@@ -5437,7 +5547,7 @@ function cleanTiddler(t) {
5437
5547
  return out;
5438
5548
  }
5439
5549
  /** 是不是一个「平铺对象」(`fields` 唯一合法的形状;数组不算)。 */
5440
- function isPlainObject(value) {
5550
+ function isPlainObject$1(value) {
5441
5551
  return typeof value === "object" && value !== null && !Array.isArray(value);
5442
5552
  }
5443
5553
  /** 错误信息里的短样本(不把整段正文塞进回执)。 */
@@ -5480,10 +5590,10 @@ function normalizeFieldsArg(fields) {
5480
5590
  } catch {
5481
5591
  parsed = void 0;
5482
5592
  }
5483
- if (isPlainObject(parsed)) return parsed;
5593
+ if (isPlainObject$1(parsed)) return parsed;
5484
5594
  throw new Error(`fields 必须是一个对象(如 {"review-after":"2026-12-22"}),收到无法解析成对象的字符串:${excerpt(fields)}`);
5485
5595
  }
5486
- if (isPlainObject(fields)) return fields;
5596
+ if (isPlainObject$1(fields)) return fields;
5487
5597
  throw new Error(`fields 必须是一个对象(如 {"review-after":"2026-12-22"}),收到 ${Array.isArray(fields) ? "数组" : typeof fields}`);
5488
5598
  }
5489
5599
  /**
@@ -5500,7 +5610,7 @@ function normalizeFieldsArg(fields) {
5500
5610
  */
5501
5611
  function applyCustomFields(tiddler, fields) {
5502
5612
  if (fields === void 0 || fields === null) return;
5503
- if (!isPlainObject(fields)) throw new Error(`内部错误:fields 必须是对象,收到 ${Array.isArray(fields) ? "数组" : typeof fields}`);
5613
+ if (!isPlainObject$1(fields)) throw new Error(`内部错误:fields 必须是对象,收到 ${Array.isArray(fields) ? "数组" : typeof fields}`);
5504
5614
  for (const [key, value] of Object.entries(fields)) {
5505
5615
  if (RESERVED_TIDDLER_FIELDS.has(key)) continue;
5506
5616
  if (value === void 0) continue;
@@ -6612,18 +6722,21 @@ function registerRoutes(ctx, deps) {
6612
6722
  * invalidates it so a sync/upload is reflected immediately.
6613
6723
  */
6614
6724
  const GIT_STATUS_TTL_MS = 2e3;
6615
- /** Cached git-status PROMISE (not value): a burst of concurrent /status polls
6616
- * then shares ONE probe instead of each spawning up to five git processes
6617
- * (v0.19.0 — value-caching still let every concurrent miss run its own). */
6725
+ /** Cached git-status PROMISE **per wiki** (not value): a burst of concurrent
6726
+ * /status polls then shares ONE probe instead of each spawning up to five git
6727
+ * processes (v0.19.0 — value-caching still let every concurrent miss run its
6728
+ * own). The key is the wiki folder: with several knowledge bases a
6729
+ * single-slot cache would hand wiki A the answer probed for wiki B (v0.28.0). */
6618
6730
  let gitStatusCache;
6619
6731
  const invalidateGitStatus = () => {
6620
6732
  gitStatusCache = void 0;
6621
6733
  };
6622
- const cachedGitStatus = () => {
6623
- if (gitStatusCache !== void 0 && Date.now() - gitStatusCache.at < GIT_STATUS_TTL_MS) return gitStatusCache.value;
6734
+ const cachedGitStatus = (req) => {
6735
+ const key = deps.getWikiPath(req);
6736
+ if (gitStatusCache !== void 0 && gitStatusCache.key === key && Date.now() - gitStatusCache.at < GIT_STATUS_TTL_MS) return gitStatusCache.value;
6624
6737
  const pending = (async () => {
6625
6738
  try {
6626
- const view = await deps.git.status(deps.getWikiPath());
6739
+ const view = await deps.git.status(key);
6627
6740
  return {
6628
6741
  ...view,
6629
6742
  remote: redactRemoteUrl(typeof view.remote === "string" ? view.remote : "")
@@ -6633,6 +6746,7 @@ function registerRoutes(ctx, deps) {
6633
6746
  }
6634
6747
  })();
6635
6748
  gitStatusCache = {
6749
+ key,
6636
6750
  at: Date.now(),
6637
6751
  value: pending
6638
6752
  };
@@ -6719,8 +6833,12 @@ function registerRoutes(ctx, deps) {
6719
6833
  const twProxyAbsoluteBase = (req) => absoluteHostBase(req.headers.host, TW_PROXY_PATH);
6720
6834
  const handleStatus = async (req, res) => {
6721
6835
  if (rejectNonRead(req, res)) return;
6722
- const view = deps.server.status();
6723
- const gitSummary = await cachedGitStatus();
6836
+ const view = deps.server(req)?.status() ?? {
6837
+ status: "stopped",
6838
+ wikiPath: deps.getWikiPath(req),
6839
+ logs: []
6840
+ };
6841
+ const gitSummary = await cachedGitStatus(req);
6724
6842
  json(res, {
6725
6843
  ok: true,
6726
6844
  ...view,
@@ -6728,8 +6846,16 @@ function registerRoutes(ctx, deps) {
6728
6846
  twProxy: TW_PROXY_PATH,
6729
6847
  twProxyAbsolute: twProxyAbsoluteBase(req),
6730
6848
  git: gitSummary,
6731
- note: { tag: deps.noteDefaults().tag },
6732
- ui: deps.uiDefaults()
6849
+ note: { tag: deps.noteDefaults(req).tag },
6850
+ ui: deps.uiDefaults(req),
6851
+ ...(() => {
6852
+ const farm = deps.wikiSummaries(req);
6853
+ return {
6854
+ mode: farm.mode,
6855
+ defaultId: farm.defaultId,
6856
+ wikis: farm.items
6857
+ };
6858
+ })()
6733
6859
  });
6734
6860
  };
6735
6861
  /**
@@ -6744,6 +6870,103 @@ function registerRoutes(ctx, deps) {
6744
6870
  const SUMMARY_REUSE_MS = 3e3;
6745
6871
  const summaryInFlight = /* @__PURE__ */ new Map();
6746
6872
  /**
6873
+ * GET /dsh-tiddlywiki/session/wiki?session=<id> — which knowledge base this
6874
+ * conversation works on, plus what it resolves to right now.
6875
+ *
6876
+ * The per-wiki selector in the composer reads this; the agent tools and the
6877
+ * injected prompt are driven by the SAME value, so what the user picks is what
6878
+ * the model gets.
6879
+ */
6880
+ const handleSessionWiki = async (req, res) => {
6881
+ try {
6882
+ if (rejectNonRead(req, res)) return;
6883
+ if (deps.sessionScope === void 0) {
6884
+ json(res, {
6885
+ ok: false,
6886
+ error: "会话作用域不可用(宿主未接线)"
6887
+ }, 503);
6888
+ return;
6889
+ }
6890
+ const session = (new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("session") ?? "").trim();
6891
+ if (session.length === 0) {
6892
+ json(res, {
6893
+ ok: false,
6894
+ error: "session is required"
6895
+ }, 400);
6896
+ return;
6897
+ }
6898
+ json(res, {
6899
+ ok: true,
6900
+ session,
6901
+ ...deps.sessionScope.get(session)
6902
+ });
6903
+ } catch (err) {
6904
+ json(res, {
6905
+ ok: false,
6906
+ error: err instanceof Error ? err.message : String(err)
6907
+ }, errorStatus(err));
6908
+ }
6909
+ };
6910
+ /**
6911
+ * POST /dsh-tiddlywiki/session/wiki { session, wiki } — point this conversation
6912
+ * at a knowledge base (`wiki: null`/`''` clears the choice and falls back to
6913
+ * the default).
6914
+ *
6915
+ * A HIDDEN wiki is refused (400): `agentVisible: false` means "the agent never
6916
+ * reaches this one", and a selector that lets a session pick it would quietly
6917
+ * contradict the setting the user just made.
6918
+ */
6919
+ const handleSessionWikiSet = async (req, res) => {
6920
+ try {
6921
+ if (deps.sessionScope === void 0) {
6922
+ json(res, {
6923
+ ok: false,
6924
+ error: "会话作用域不可用(宿主未接线)"
6925
+ }, 503);
6926
+ return;
6927
+ }
6928
+ let body = {};
6929
+ try {
6930
+ body = JSON.parse(await readBody(req));
6931
+ } catch {
6932
+ json(res, {
6933
+ ok: false,
6934
+ error: "请求体必须是 JSON"
6935
+ }, 400);
6936
+ return;
6937
+ }
6938
+ const session = typeof body.session === "string" ? body.session.trim() : "";
6939
+ if (session.length === 0) {
6940
+ json(res, {
6941
+ ok: false,
6942
+ error: "session is required"
6943
+ }, 400);
6944
+ return;
6945
+ }
6946
+ const raw = typeof body.wiki === "string" ? body.wiki.trim() : "";
6947
+ const wikiId = raw.length === 0 ? void 0 : raw;
6948
+ await deps.sessionScope.set(session, wikiId);
6949
+ json(res, {
6950
+ ok: true,
6951
+ session,
6952
+ ...deps.sessionScope.get(session)
6953
+ });
6954
+ } catch (err) {
6955
+ json(res, {
6956
+ ok: false,
6957
+ error: err instanceof Error ? err.message : String(err)
6958
+ }, 400);
6959
+ }
6960
+ };
6961
+ /** `/session/wiki` takes both methods on ONE registration (see /admin/wikis). */
6962
+ const handleSessionWikiRoute = async (req, res) => {
6963
+ if ((req.method ?? "GET").toUpperCase() === "POST") {
6964
+ if (rejectCrossSiteWrite(req, res, ["POST"])) return;
6965
+ return handleSessionWikiSet(req, res);
6966
+ }
6967
+ return handleSessionWiki(req, res);
6968
+ };
6969
+ /**
6747
6970
  * POST /dsh-tiddlywiki/session/summary — 生成当前会话的 wiki 汇总页(「知识库」
6748
6971
  * Tab 的后端)。body `{ session: <会话ID> }`;后端用 sessionQuery 读本会话(含
6749
6972
  * 后代 subagent)的完整事件日志,按「产生/读取/检索」收集 tiddlywiki_* 笔记,
@@ -6772,7 +6995,7 @@ function registerRoutes(ctx, deps) {
6772
6995
  }, 400);
6773
6996
  return;
6774
6997
  }
6775
- const client = deps.getClient();
6998
+ const client = deps.getClient(req);
6776
6999
  if (client === void 0) {
6777
7000
  json(res, {
6778
7001
  ok: false,
@@ -6865,14 +7088,14 @@ function registerRoutes(ctx, deps) {
6865
7088
  * error response — the caller just does `if (!guard(...)) return`.
6866
7089
  */
6867
7090
  const guardSendToAgent = (req, res) => {
6868
- if (!deps.sendToAgentEnabled()) {
7091
+ if (!deps.sendToAgentEnabled(req)) {
6869
7092
  json(res, {
6870
7093
  ok: false,
6871
7094
  error: "send-to-agent is disabled"
6872
7095
  }, 403);
6873
7096
  return false;
6874
7097
  }
6875
- const token = deps.sendToAgentToken().trim();
7098
+ const token = deps.sendToAgentToken(req).trim();
6876
7099
  if (token.length === 0) return true;
6877
7100
  const got = req.headers["x-send-to-agent-token"];
6878
7101
  if (safeTokenEqual(typeof got === "string" ? got : Array.isArray(got) ? got[0] ?? "" : "", token)) return true;
@@ -7145,7 +7368,7 @@ function registerRoutes(ctx, deps) {
7145
7368
  }, 400);
7146
7369
  return;
7147
7370
  }
7148
- const client = deps.getClient();
7371
+ const client = deps.getClient(req);
7149
7372
  if (client === void 0) {
7150
7373
  json(res, {
7151
7374
  ok: false,
@@ -7167,11 +7390,11 @@ function registerRoutes(ctx, deps) {
7167
7390
  const { tiddler } = buildWriteTiddler(title, text, {
7168
7391
  existing,
7169
7392
  tags,
7170
- defaultTags: [deps.noteDefaults().tag],
7393
+ defaultTags: [deps.noteDefaults(req).tag],
7171
7394
  agentTag: false
7172
7395
  });
7173
7396
  await client.put(tiddler);
7174
- deps.autoCommit();
7397
+ deps.autoCommit(req);
7175
7398
  invalidateGitStatus();
7176
7399
  const finalTags = tiddler.tags ?? [];
7177
7400
  json(res, {
@@ -7201,7 +7424,7 @@ function registerRoutes(ctx, deps) {
7201
7424
  try {
7202
7425
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
7203
7426
  const body = JSON.parse(await readBody(req));
7204
- const client = deps.getClient();
7427
+ const client = deps.getClient(req);
7205
7428
  if (client === void 0) {
7206
7429
  json(res, {
7207
7430
  ok: false,
@@ -7218,10 +7441,10 @@ function registerRoutes(ctx, deps) {
7218
7441
  return;
7219
7442
  }
7220
7443
  const result = await openInTwEditor(client, title, typeof body.text === "string" ? body.text : "", resolveTags(body), {
7221
- defaultTags: [deps.noteDefaults().tag],
7444
+ defaultTags: [deps.noteDefaults(req).tag],
7222
7445
  ...conflictTokens(body)
7223
7446
  });
7224
- deps.autoCommit();
7447
+ deps.autoCommit(req);
7225
7448
  invalidateGitStatus();
7226
7449
  json(res, {
7227
7450
  ok: true,
@@ -7255,7 +7478,7 @@ function registerRoutes(ctx, deps) {
7255
7478
  * order, plus `total`/`truncated` so a capped caller can say "N of M". */
7256
7479
  const handleTags = async (req, res) => {
7257
7480
  if (rejectNonRead(req, res)) return;
7258
- const client = deps.getClient();
7481
+ const client = deps.getClient(req);
7259
7482
  if (client === void 0) {
7260
7483
  json(res, {
7261
7484
  ok: false,
@@ -7287,7 +7510,7 @@ function registerRoutes(ctx, deps) {
7287
7510
  /** Recent non-system tiddlers for the quick-note "最近" picker (newest first). */
7288
7511
  const handleRecent = async (req, res) => {
7289
7512
  if (rejectNonRead(req, res)) return;
7290
- const client = deps.getClient();
7513
+ const client = deps.getClient(req);
7291
7514
  if (client === void 0) {
7292
7515
  json(res, {
7293
7516
  ok: false,
@@ -7321,7 +7544,7 @@ function registerRoutes(ctx, deps) {
7321
7544
  /** Full tiddler for the quick-note "最近" picker (load into the editor). */
7322
7545
  const handleGet = async (req, res) => {
7323
7546
  if (rejectNonRead(req, res)) return;
7324
- const client = deps.getClient();
7547
+ const client = deps.getClient(req);
7325
7548
  if (client === void 0) {
7326
7549
  json(res, {
7327
7550
  ok: false,
@@ -7382,7 +7605,7 @@ function registerRoutes(ctx, deps) {
7382
7605
  */
7383
7606
  const handleSearch = async (req, res) => {
7384
7607
  if (rejectNonRead(req, res)) return;
7385
- const client = deps.getClient();
7608
+ const client = deps.getClient(req);
7386
7609
  if (client === void 0) {
7387
7610
  json(res, {
7388
7611
  ok: false,
@@ -7464,9 +7687,11 @@ function registerRoutes(ctx, deps) {
7464
7687
  let drained = true;
7465
7688
  try {
7466
7689
  drained = await drainThenStop({
7467
- client: deps.getClient(),
7468
- tiddlersDir: join(deps.getWikiPath(), "tiddlers"),
7469
- stop: () => deps.server.restart(),
7690
+ client: deps.getClient(req),
7691
+ tiddlersDir: join(deps.getWikiPath(req), "tiddlers"),
7692
+ stop: async () => {
7693
+ await deps.server(req)?.restart();
7694
+ },
7470
7695
  log: (message) => console.warn("[dsh-tiddlywiki]", message)
7471
7696
  });
7472
7697
  } finally {
@@ -7474,7 +7699,7 @@ function registerRoutes(ctx, deps) {
7474
7699
  }
7475
7700
  json(res, {
7476
7701
  ok: true,
7477
- status: deps.server.status().status,
7702
+ status: deps.server(req)?.status().status,
7478
7703
  drained
7479
7704
  });
7480
7705
  } catch (err) {
@@ -7500,7 +7725,7 @@ function registerRoutes(ctx, deps) {
7500
7725
  return;
7501
7726
  }
7502
7727
  invalidateGitStatus();
7503
- const dir = deps.getWikiPath();
7728
+ const dir = deps.getWikiPath(req);
7504
7729
  const status = async () => {
7505
7730
  try {
7506
7731
  return await deps.git.status(dir);
@@ -7521,15 +7746,13 @@ function registerRoutes(ctx, deps) {
7521
7746
  return;
7522
7747
  }
7523
7748
  let restarted = false;
7749
+ let restartedWikis;
7524
7750
  let restartError;
7525
- if (pulled.changed === true) try {
7526
- if (!await drainThenStop({
7527
- client: deps.getClient(),
7528
- tiddlersDir: join(dir, "tiddlers"),
7529
- stop: () => deps.server.restart(),
7530
- log: (message) => console.warn("[dsh-tiddlywiki]", message)
7531
- })) console.warn("[dsh-tiddlywiki] sync: syncer queue may not be drained before restart");
7532
- restarted = true;
7751
+ if (pulled.changed === true && deps.restartAffected !== void 0) try {
7752
+ const outcome = await deps.restartAffected(req, dir, pulled.changedFiles ?? []);
7753
+ restarted = outcome.restarted.length > 0;
7754
+ if (outcome.restarted.length > 0) restartedWikis = outcome.restarted;
7755
+ if (outcome.failed.length > 0) restartError = outcome.failed.map((item) => `${item.id}: ${item.message}`).join("; ");
7533
7756
  } catch (err) {
7534
7757
  restartError = err instanceof Error ? err.message : String(err);
7535
7758
  }
@@ -7559,6 +7782,7 @@ function registerRoutes(ctx, deps) {
7559
7782
  pull: "ok",
7560
7783
  ...pulled.changed === true ? { changed: true } : {},
7561
7784
  restarted,
7785
+ ...restartedWikis !== void 0 ? { restartedWikis } : {},
7562
7786
  ...restartError !== void 0 ? { restartError } : {},
7563
7787
  commit: committed.message,
7564
7788
  push: pushed.message,
@@ -7609,7 +7833,7 @@ function registerRoutes(ctx, deps) {
7609
7833
  }, 400);
7610
7834
  return;
7611
7835
  }
7612
- const filesDir = join(deps.getWikiPath(), "files");
7836
+ const filesDir = join(deps.getWikiPath(req), "files");
7613
7837
  await mkdir(filesDir, { recursive: true });
7614
7838
  const ext = extname(name);
7615
7839
  const stem = ext.length > 0 ? name.slice(0, -ext.length) : name;
@@ -7631,7 +7855,7 @@ function registerRoutes(ctx, deps) {
7631
7855
  }, 409);
7632
7856
  return;
7633
7857
  }
7634
- deps.autoCommit();
7858
+ deps.autoCommit(req);
7635
7859
  invalidateGitStatus();
7636
7860
  json(res, {
7637
7861
  ok: true,
@@ -7667,7 +7891,7 @@ function registerRoutes(ctx, deps) {
7667
7891
  const handleRender = async (req, res) => {
7668
7892
  try {
7669
7893
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
7670
- const client = deps.getClient();
7894
+ const client = deps.getClient(req);
7671
7895
  if (client === void 0) {
7672
7896
  json(res, {
7673
7897
  ok: false,
@@ -7754,7 +7978,8 @@ function registerRoutes(ctx, deps) {
7754
7978
  "DELETE",
7755
7979
  "OPTIONS"
7756
7980
  ])) return;
7757
- if (deps.getClient() === void 0) {
7981
+ const server = deps.server(req);
7982
+ if (server?.url === void 0) {
7758
7983
  json(res, {
7759
7984
  ok: false,
7760
7985
  error: "wiki service is not running"
@@ -7780,7 +8005,7 @@ function registerRoutes(ctx, deps) {
7780
8005
  signal: AbortSignal.timeout(15e3)
7781
8006
  };
7782
8007
  if (method === "PUT" || method === "POST") init.body = await readBody(req, MAX_PROXY_BODY_BYTES);
7783
- const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init);
8008
+ const upstream = await fetch(`${server.url}${rest}${url.search}`, init);
7784
8009
  const data = await upstream.text();
7785
8010
  res.writeHead(upstream.status, {
7786
8011
  "content-type": upstream.headers.get("content-type") ?? "application/json; charset=utf-8",
@@ -7812,20 +8037,23 @@ function registerRoutes(ctx, deps) {
7812
8037
  "DELETE",
7813
8038
  "OPTIONS"
7814
8039
  ])) return;
7815
- if (deps.getClient() === void 0) {
8040
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
8041
+ const [firstSegment = "", ...tail] = url.pathname.slice(TW_PROXY_PREFIX.length).replace(/^\//, "").split("/");
8042
+ const named = deps.wikiIds().includes(firstSegment) ? firstSegment : void 0;
8043
+ const rest = named === void 0 ? url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), "") || "/" : `/${tail.join("/")}`;
8044
+ if (isBlockedProxyPath(url.pathname) || isBlockedProxyPath(rest)) {
7816
8045
  json(res, {
7817
8046
  ok: false,
7818
- error: "wiki service is not running"
7819
- }, 503);
8047
+ error: "system tiddler not exposed"
8048
+ }, 403);
7820
8049
  return;
7821
8050
  }
7822
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
7823
- const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), "") || "/";
7824
- if (isBlockedProxyPath(rest)) {
8051
+ const server = named === void 0 ? deps.server(req) : deps.serverById(named);
8052
+ if (server?.url === void 0) {
7825
8053
  json(res, {
7826
8054
  ok: false,
7827
- error: "system tiddler not exposed"
7828
- }, 403);
8055
+ error: "wiki service is not running"
8056
+ }, 503);
7829
8057
  return;
7830
8058
  }
7831
8059
  try {
@@ -7841,7 +8069,7 @@ function registerRoutes(ctx, deps) {
7841
8069
  };
7842
8070
  if (method === "PUT" || method === "POST") init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES);
7843
8071
  else if (req.readableEnded === false && (req.headers["content-length"] !== void 0 || req.headers["transfer-encoding"] !== void 0)) req.resume();
7844
- const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init);
8072
+ const upstream = await fetch(`${server.url}${rest}${url.search}`, init);
7845
8073
  const responseHeaders = {
7846
8074
  "content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
7847
8075
  "cache-control": upstream.headers.get("cache-control") ?? "no-store"
@@ -7897,7 +8125,7 @@ function registerRoutes(ctx, deps) {
7897
8125
  * deployment answers 403 instead of starting browser automation.
7898
8126
  */
7899
8127
  const guardWechat = (req, res) => {
7900
- const config = deps.wechatConfig();
8128
+ const config = deps.wechatConfig(req);
7901
8129
  if (!config.enabled) {
7902
8130
  json(res, {
7903
8131
  ok: false,
@@ -7925,7 +8153,7 @@ function registerRoutes(ctx, deps) {
7925
8153
  try {
7926
8154
  if (rejectNonRead(req, res)) return;
7927
8155
  if (!guardWechat(req, res)) return;
7928
- const ready = await deps.wechatReady();
8156
+ const ready = await deps.wechatReady(req);
7929
8157
  json(res, {
7930
8158
  ok: true,
7931
8159
  enabled: ready.enabled,
@@ -7947,7 +8175,7 @@ function registerRoutes(ctx, deps) {
7947
8175
  * overrides it for exotic setups.
7948
8176
  */
7949
8177
  const wechatDsn = (req) => {
7950
- const configured = deps.wechatConfig().dsn;
8178
+ const configured = deps.wechatConfig(req).dsn;
7951
8179
  if (configured.length > 0) return configured;
7952
8180
  const port = req.socket.localPort;
7953
8181
  return port === void 0 ? "" : `http://127.0.0.1:${port}${ROUTE_PREFIX}`;
@@ -8006,7 +8234,7 @@ function registerRoutes(ctx, deps) {
8006
8234
  }, 503);
8007
8235
  return;
8008
8236
  }
8009
- const client = deps.getClient();
8237
+ const client = deps.getClient(req);
8010
8238
  if (client !== void 0) {
8011
8239
  if (await client.get(title) === void 0) {
8012
8240
  json(res, {
@@ -8016,8 +8244,8 @@ function registerRoutes(ctx, deps) {
8016
8244
  return;
8017
8245
  }
8018
8246
  }
8019
- const adapter = requested ?? deps.wechatConfig().adapter;
8020
- const ready = await deps.wechatReady();
8247
+ const adapter = requested ?? deps.wechatConfig(req).adapter;
8248
+ const ready = await deps.wechatReady(req);
8021
8249
  const adapterReady = adapter === "publish-note-imgs" ? ready.adapters.publishNoteImgs : ready.adapters.publishNote;
8022
8250
  if (!ready.opencli.ok || !adapterReady) {
8023
8251
  const stale = ready.adapters.stale ?? [];
@@ -8167,6 +8395,11 @@ function registerRoutes(ctx, deps) {
8167
8395
  path: `${ROUTE_PREFIX}/session/summary`,
8168
8396
  handler: guardHandler(handleSessionSummary)
8169
8397
  }),
8398
+ ctx.webServer.register({
8399
+ kind: "exact",
8400
+ path: `${ROUTE_PREFIX}/session/wiki`,
8401
+ handler: guardHandler(handleSessionWikiRoute)
8402
+ }),
8170
8403
  ctx.webServer.register({
8171
8404
  kind: "exact",
8172
8405
  path: `${ROUTE_PREFIX}/agent/sessions`,
@@ -8717,7 +8950,7 @@ function registerAdminRoutes(ctx, deps) {
8717
8950
  const handleState = async (req, res) => {
8718
8951
  try {
8719
8952
  if (rejectNonRead(req, res)) return;
8720
- const wikiPath = deps.getWikiPath();
8953
+ const wikiPath = deps.getWikiPath(req);
8721
8954
  const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())]);
8722
8955
  let git = null;
8723
8956
  try {
@@ -8729,8 +8962,12 @@ function registerAdminRoutes(ctx, deps) {
8729
8962
  } catch {
8730
8963
  git = null;
8731
8964
  }
8732
- const view = deps.server.status();
8733
- const themeActive = await readActiveThemeName(deps.getClient());
8965
+ const view = deps.server(req)?.status() ?? {
8966
+ status: "stopped",
8967
+ wikiPath,
8968
+ logs: []
8969
+ };
8970
+ const themeActive = await readActiveThemeName(deps.getClient(req));
8734
8971
  const runtimePlugins = await scanWikiRuntimePlugins(wikiPath);
8735
8972
  json(res, {
8736
8973
  ok: true,
@@ -8746,8 +8983,8 @@ function registerAdminRoutes(ctx, deps) {
8746
8983
  },
8747
8984
  catalog,
8748
8985
  runtimePlugins,
8749
- config: maskConfigSecrets(deps.config.get()),
8750
- configError: deps.config.parseError() ?? null,
8986
+ config: maskConfigSecrets(deps.config(req).get()),
8987
+ configError: deps.config(req).parseError() ?? null,
8751
8988
  git
8752
8989
  });
8753
8990
  } catch (err) {
@@ -8761,7 +8998,7 @@ function registerAdminRoutes(ctx, deps) {
8761
8998
  try {
8762
8999
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
8763
9000
  const body = JSON.parse(await readBody(req));
8764
- const wikiPath = deps.getWikiPath();
9001
+ const wikiPath = deps.getWikiPath(req);
8765
9002
  let info;
8766
9003
  try {
8767
9004
  info = await readWikiInfo(wikiPath);
@@ -8827,14 +9064,16 @@ function registerAdminRoutes(ctx, deps) {
8827
9064
  if (changed) {
8828
9065
  await writeWikiInfo(wikiPath, info);
8829
9066
  await drainThenStop({
8830
- client: deps.getClient(),
8831
- tiddlersDir: join(deps.getWikiPath(), "tiddlers"),
8832
- stop: () => deps.server.restart(),
9067
+ client: deps.getClient(req),
9068
+ tiddlersDir: join(deps.getWikiPath(req), "tiddlers"),
9069
+ stop: async () => {
9070
+ await deps.server(req)?.restart();
9071
+ },
8833
9072
  log: (message) => console.warn("[dsh-tiddlywiki]", message)
8834
9073
  });
8835
9074
  }
8836
9075
  if (Array.isArray(body.themes) && activatedTheme !== void 0) {
8837
- const client = deps.getClient();
9076
+ const client = deps.getClient(req);
8838
9077
  if (client !== void 0) await client.put({
8839
9078
  title: "$:/theme",
8840
9079
  text: `$:/themes/${activatedTheme}`,
@@ -8845,7 +9084,7 @@ function registerAdminRoutes(ctx, deps) {
8845
9084
  });
8846
9085
  }
8847
9086
  if (Array.isArray(body.languages)) {
8848
- const client = deps.getClient();
9087
+ const client = deps.getClient(req);
8849
9088
  if (client !== void 0) {
8850
9089
  const langs = info.languages ?? [];
8851
9090
  await pinLanguageTiddler(client, langs.length > 0 ? `$:/languages/${langs[0]}` : "$:/languages/en-GB", (message, err) => {
@@ -8853,7 +9092,7 @@ function registerAdminRoutes(ctx, deps) {
8853
9092
  else console.warn("[dsh-tiddlywiki] pinning $:/language failed:", err);
8854
9093
  });
8855
9094
  const hint = langs.length > 0 ? langs[0] : "";
8856
- if ((deps.config.get().uiLanguage ?? "") !== hint) await deps.config.set(client, { uiLanguage: hint }).catch(() => void 0);
9095
+ if ((deps.config(req).get().uiLanguage ?? "") !== hint) await deps.config(req).set(client, { uiLanguage: hint }).catch(() => void 0);
8857
9096
  }
8858
9097
  }
8859
9098
  json(res, {
@@ -8876,7 +9115,7 @@ function registerAdminRoutes(ctx, deps) {
8876
9115
  try {
8877
9116
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
8878
9117
  const body = JSON.parse(await readBody(req));
8879
- const client = deps.getClient();
9118
+ const client = deps.getClient(req);
8880
9119
  if (client === void 0) {
8881
9120
  json(res, {
8882
9121
  ok: false,
@@ -8885,11 +9124,11 @@ function registerAdminRoutes(ctx, deps) {
8885
9124
  return;
8886
9125
  }
8887
9126
  const patch = normalizeConfigPatch(body);
8888
- await deps.config.set(client, stripMaskedSecrets(patch, deps.config.get()));
9127
+ await deps.config(req).set(client, stripMaskedSecrets(patch, deps.config(req).get()));
8889
9128
  deps.onConfigChanged?.();
8890
9129
  json(res, {
8891
9130
  ok: true,
8892
- config: maskConfigSecrets(deps.config.get())
9131
+ config: maskConfigSecrets(deps.config(req).get())
8893
9132
  });
8894
9133
  } catch (err) {
8895
9134
  if (err instanceof ConfigPatchError) {
@@ -8916,14 +9155,16 @@ function registerAdminRoutes(ctx, deps) {
8916
9155
  try {
8917
9156
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
8918
9157
  const drained = await drainThenStop({
8919
- client: deps.getClient(),
8920
- tiddlersDir: join(deps.getWikiPath(), "tiddlers"),
8921
- stop: () => deps.server.restart(),
9158
+ client: deps.getClient(req),
9159
+ tiddlersDir: join(deps.getWikiPath(req), "tiddlers"),
9160
+ stop: async () => {
9161
+ await deps.server(req)?.restart();
9162
+ },
8922
9163
  log: (message) => console.warn("[dsh-tiddlywiki]", message)
8923
9164
  });
8924
9165
  json(res, {
8925
9166
  ok: true,
8926
- status: deps.server.status().status,
9167
+ status: deps.server(req)?.status().status,
8927
9168
  drained
8928
9169
  });
8929
9170
  } catch (err) {
@@ -8941,7 +9182,7 @@ function registerAdminRoutes(ctx, deps) {
8941
9182
  const handleSeeds = async (req, res) => {
8942
9183
  try {
8943
9184
  if (rejectNonRead(req, res)) return;
8944
- const client = deps.getClient();
9185
+ const client = deps.getClient(req);
8945
9186
  if (client === void 0) {
8946
9187
  json(res, {
8947
9188
  ok: false,
@@ -8971,7 +9212,7 @@ function registerAdminRoutes(ctx, deps) {
8971
9212
  const body = JSON.parse(await readBody(req));
8972
9213
  const id = typeof body.id === "string" && body.id.trim().length > 0 ? body.id.trim() : void 0;
8973
9214
  const force = body.force === true;
8974
- const client = deps.getClient();
9215
+ const client = deps.getClient(req);
8975
9216
  if (client === void 0) {
8976
9217
  json(res, {
8977
9218
  ok: false,
@@ -8984,11 +9225,13 @@ function registerAdminRoutes(ctx, deps) {
8984
9225
  let restarted = false;
8985
9226
  let restartError;
8986
9227
  if (needsRestartAfterSeeds(results)) try {
8987
- if (!await waitForFileWrite(join(deps.getWikiPath(), "tiddlers", "$__plugins_dsh_render.json"), 8e3, 150, seedStartedAt)) console.warn("[dsh-tiddlywiki] seeded render plugin file not seen on disk before restart");
9228
+ if (!await waitForFileWrite(join(deps.getWikiPath(req), "tiddlers", "$__plugins_dsh_render.json"), 8e3, 150, seedStartedAt)) console.warn("[dsh-tiddlywiki] seeded render plugin file not seen on disk before restart");
8988
9229
  if (!await drainThenStop({
8989
9230
  client,
8990
- tiddlersDir: join(deps.getWikiPath(), "tiddlers"),
8991
- stop: () => deps.server.restart(),
9231
+ tiddlersDir: join(deps.getWikiPath(req), "tiddlers"),
9232
+ stop: async () => {
9233
+ await deps.server(req)?.restart();
9234
+ },
8992
9235
  log: (message) => console.warn("[dsh-tiddlywiki]", message)
8993
9236
  })) console.warn("[dsh-tiddlywiki] seed writes may not have been flushed before restart");
8994
9237
  restarted = true;
@@ -9021,7 +9264,7 @@ function registerAdminRoutes(ctx, deps) {
9021
9264
  if (rejectCrossSiteWrite(req, res, ["POST"])) return;
9022
9265
  const body = JSON.parse(await readBody(req));
9023
9266
  const id = typeof body.id === "string" && body.id.trim().length > 0 ? body.id.trim() : void 0;
9024
- const client = deps.getClient();
9267
+ const client = deps.getClient(req);
9025
9268
  if (client === void 0) {
9026
9269
  json(res, {
9027
9270
  ok: false,
@@ -9071,7 +9314,7 @@ function registerAdminRoutes(ctx, deps) {
9071
9314
  }, 400);
9072
9315
  return;
9073
9316
  }
9074
- const prompt = deps.getPrompt?.(normalizePromptPreview(body));
9317
+ const prompt = deps.getPrompt?.(normalizePromptPreview(body), req);
9075
9318
  if (prompt === void 0) {
9076
9319
  json(res, {
9077
9320
  ok: false,
@@ -9090,7 +9333,7 @@ function registerAdminRoutes(ctx, deps) {
9090
9333
  return;
9091
9334
  }
9092
9335
  if (rejectNonRead(req, res)) return;
9093
- const prompt = deps.getPrompt?.();
9336
+ const prompt = deps.getPrompt?.(void 0, req);
9094
9337
  if (prompt === void 0) {
9095
9338
  json(res, {
9096
9339
  ok: false,
@@ -9163,7 +9406,7 @@ function registerAdminRoutes(ctx, deps) {
9163
9406
  });
9164
9407
  json(res, result.ok ? {
9165
9408
  ...result,
9166
- status: deps.server.status().status
9409
+ status: deps.server(req)?.status().status
9167
9410
  } : result, result.ok ? 200 : 400);
9168
9411
  } catch (err) {
9169
9412
  json(res, {
@@ -9189,7 +9432,7 @@ function registerAdminRoutes(ctx, deps) {
9189
9432
  const result = await deps.wiki.reset();
9190
9433
  json(res, result.ok ? {
9191
9434
  ...result,
9192
- status: deps.server.status().status
9435
+ status: deps.server(req)?.status().status
9193
9436
  } : result, result.ok ? 200 : 400);
9194
9437
  } catch (err) {
9195
9438
  json(res, {
@@ -9198,6 +9441,94 @@ function registerAdminRoutes(ctx, deps) {
9198
9441
  }, errorStatus(err));
9199
9442
  }
9200
9443
  };
9444
+ /**
9445
+ * GET /dsh-tiddlywiki/admin/wikis — the knowledge-base LIST as the settings
9446
+ * page edits it: the CONTROL FILE's contents (mode / list / default), which is
9447
+ * NOT the same as what is running. Read-only + CSRF-hardened.
9448
+ */
9449
+ const handleWikis = async (req, res) => {
9450
+ try {
9451
+ if (rejectNonRead(req, res)) return;
9452
+ if (deps.wikis === void 0) {
9453
+ json(res, {
9454
+ ok: false,
9455
+ error: "知识库列表不可用"
9456
+ }, 503);
9457
+ return;
9458
+ }
9459
+ json(res, {
9460
+ ok: true,
9461
+ ...await deps.wikis.info()
9462
+ });
9463
+ } catch (err) {
9464
+ json(res, {
9465
+ ok: false,
9466
+ error: err instanceof Error ? err.message : String(err)
9467
+ }, 500);
9468
+ }
9469
+ };
9470
+ /**
9471
+ * POST /dsh-tiddlywiki/admin/wikis { action, … } — add / update / remove /
9472
+ * set-default / set-mode. ONE action per request (see `applyWikiAction`): the
9473
+ * page then cannot post an inconsistent list, and every mutation goes through
9474
+ * the same validation the boot path uses.
9475
+ *
9476
+ * A REFUSED action is a 400 with the reason. A change whose new wiki fails to
9477
+ * START is still 200: the list is saved and the failure is reported in
9478
+ * `change.errors` — refusing the save would silently discard the user's edit
9479
+ * for a problem they can fix by restarting the wiki.
9480
+ */
9481
+ const handleWikisApply = async (req, res) => {
9482
+ try {
9483
+ if (deps.wikis === void 0) {
9484
+ json(res, {
9485
+ ok: false,
9486
+ error: "知识库列表不可用"
9487
+ }, 503);
9488
+ return;
9489
+ }
9490
+ let body;
9491
+ try {
9492
+ body = JSON.parse(await readBody(req));
9493
+ } catch {
9494
+ json(res, {
9495
+ ok: false,
9496
+ error: "请求体必须是 JSON"
9497
+ }, 400);
9498
+ return;
9499
+ }
9500
+ const result = await deps.wikis.apply(body);
9501
+ if (!result.ok) {
9502
+ json(res, {
9503
+ ok: false,
9504
+ error: result.error
9505
+ }, 400);
9506
+ return;
9507
+ }
9508
+ json(res, {
9509
+ ok: true,
9510
+ ...result.info,
9511
+ change: result.change
9512
+ });
9513
+ } catch (err) {
9514
+ json(res, {
9515
+ ok: false,
9516
+ error: err instanceof Error ? err.message : String(err)
9517
+ }, errorStatus(err));
9518
+ }
9519
+ };
9520
+ /**
9521
+ * `/admin/wikis` takes BOTH methods on ONE registration: the host webserver
9522
+ * dispatches by pathname only, and registering the same exact path twice would
9523
+ * throw `duplicate exact route`.
9524
+ */
9525
+ const handleWikisRoute = async (req, res) => {
9526
+ if ((req.method ?? "GET").toUpperCase() === "POST") {
9527
+ if (rejectCrossSiteWrite(req, res, ["POST"])) return;
9528
+ return handleWikisApply(req, res);
9529
+ }
9530
+ return handleWikis(req, res);
9531
+ };
9201
9532
  const disposers = [
9202
9533
  ctx.webServer.register({
9203
9534
  kind: "exact",
@@ -9209,6 +9540,11 @@ function registerAdminRoutes(ctx, deps) {
9209
9540
  path: `${ROUTE_PREFIX}/admin/prompt`,
9210
9541
  handler: guardHandler(handlePrompt)
9211
9542
  }),
9543
+ ctx.webServer.register({
9544
+ kind: "exact",
9545
+ path: `${ROUTE_PREFIX}/admin/wikis`,
9546
+ handler: guardHandler(handleWikisRoute)
9547
+ }),
9212
9548
  ctx.webServer.register({
9213
9549
  kind: "exact",
9214
9550
  path: `${ROUTE_PREFIX}/admin/wiki/location`,
@@ -10411,6 +10747,50 @@ function insertIntoSection(base, heading, addition) {
10411
10747
  function registerTiddlywikiTools(ctx, deps) {
10412
10748
  const disposers = [];
10413
10749
  REGISTERED_TOOL_SUMMARY.length = 0;
10750
+ /**
10751
+ * Give EVERY tool result a 「知识库:…」 header — but only when more than one
10752
+ * knowledge base is visible to the agent (v0.28.0).
10753
+ *
10754
+ * Done HERE, once, instead of inside 15 renders: a tool result is the only
10755
+ * place the model can learn WHICH knowledge base it just wrote to, and "wrote
10756
+ * into the wrong wiki" is the failure this feature most needs to make visible.
10757
+ * With a single visible wiki (`ambiguous === false`) the output stays
10758
+ * byte-identical to before, so existing installs see no change at all.
10759
+ */
10760
+ const withScopeHeader = (tool) => {
10761
+ const render = tool.output?.render;
10762
+ const execute = tool.execute;
10763
+ if (render === void 0 || execute === void 0) return tool;
10764
+ return {
10765
+ ...tool,
10766
+ async execute(args, exec) {
10767
+ const scope = deps.scope(sessionIdOf(exec));
10768
+ const value = await execute(args, exec);
10769
+ if (!scope.ambiguous || scope.id === void 0) return value;
10770
+ if (value === null || typeof value !== "object") return value;
10771
+ return {
10772
+ ...value,
10773
+ wiki: {
10774
+ id: scope.id,
10775
+ label: scope.label ?? scope.id
10776
+ }
10777
+ };
10778
+ },
10779
+ output: {
10780
+ ...tool.output,
10781
+ render(args, value) {
10782
+ const lines = render(args, value);
10783
+ const wiki = value?.wiki;
10784
+ if (wiki === void 0 || lines.length === 0) return lines;
10785
+ const [first, ...rest] = lines;
10786
+ return [{
10787
+ ...first,
10788
+ text: `【知识库:${wiki.label}(${wiki.id})】\n${first.text}`
10789
+ }, ...rest];
10790
+ }
10791
+ }
10792
+ };
10793
+ };
10414
10794
  const register = (tool) => {
10415
10795
  const properties = tool.parameters.properties ?? {};
10416
10796
  const required = Array.isArray(tool.parameters.required) ? tool.parameters.required : [];
@@ -10421,14 +10801,14 @@ function registerTiddlywikiTools(ctx, deps) {
10421
10801
  required: required.includes(name)
10422
10802
  }))
10423
10803
  });
10424
- disposers.push(ctx.tools.register(tool));
10804
+ disposers.push(ctx.tools.register(withScopeHeader(tool)));
10425
10805
  };
10426
10806
  /** Every read/write tool needs a live TW client — shared guard for the 8
10427
10807
  * wiki-facing tools (git tools operate on the repo path instead). */
10428
- const requireWiki = () => {
10429
- const wiki = deps.wiki();
10430
- if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
10431
- return wiki;
10808
+ const requireWiki = (sessionId) => {
10809
+ const scope = deps.scope(sessionId);
10810
+ if (scope.client === void 0) throw new Error(scope.reason ?? "TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
10811
+ return scope.client;
10432
10812
  };
10433
10813
  register(defineTool({
10434
10814
  name: "tiddlywiki_search",
@@ -10492,7 +10872,7 @@ function registerTiddlywikiTools(ctx, deps) {
10492
10872
  }];
10493
10873
  } },
10494
10874
  execute: async (args, exec) => {
10495
- const wiki = requireWiki();
10875
+ const wiki = requireWiki(sessionIdOf(exec));
10496
10876
  const options = {
10497
10877
  tags: args.tags,
10498
10878
  tag: args.tag,
@@ -10556,8 +10936,8 @@ function registerTiddlywikiTools(ctx, deps) {
10556
10936
  text: lines.join("\n")
10557
10937
  }];
10558
10938
  } },
10559
- execute: async (args) => {
10560
- const items = await requireWiki().recent(args.limit ?? 15, args.since);
10939
+ execute: async (args, exec) => {
10940
+ const items = await requireWiki(sessionIdOf(exec)).recent(args.limit ?? 15, args.since);
10561
10941
  return {
10562
10942
  since: args.since ?? null,
10563
10943
  results: items.map((t) => ({
@@ -10589,8 +10969,8 @@ function registerTiddlywikiTools(ctx, deps) {
10589
10969
  text: lines.join("\n")
10590
10970
  }];
10591
10971
  } },
10592
- execute: async (args) => {
10593
- const stats = await requireWiki().tagStats();
10972
+ execute: async (args, exec) => {
10973
+ const stats = await requireWiki(sessionIdOf(exec)).tagStats();
10594
10974
  const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.max(1, Math.min(Math.floor(args.limit), 1e3)) : 200;
10595
10975
  const tags = stats.tags.slice(0, limit);
10596
10976
  return {
@@ -10638,8 +11018,8 @@ function registerTiddlywikiTools(ctx, deps) {
10638
11018
  text: lines.join("\n")
10639
11019
  }];
10640
11020
  } },
10641
- execute: async (args) => {
10642
- const t = await requireWiki().get(args.title);
11021
+ execute: async (args, exec) => {
11022
+ const t = await requireWiki(sessionIdOf(exec)).get(args.title);
10643
11023
  if (t === void 0) return {
10644
11024
  notFound: true,
10645
11025
  title: args.title,
@@ -10719,7 +11099,7 @@ function registerTiddlywikiTools(ctx, deps) {
10719
11099
  }];
10720
11100
  } },
10721
11101
  execute: async (args, exec) => {
10722
- const wiki = requireWiki();
11102
+ const wiki = requireWiki(sessionIdOf(exec));
10723
11103
  if (args.title.trim().length === 0) throw new Error("tiddlywiki_put: title 不能为空");
10724
11104
  const existing = await wiki.get(args.title);
10725
11105
  assertNoConflict(args.title, existing, {
@@ -10812,7 +11192,7 @@ function registerTiddlywikiTools(ctx, deps) {
10812
11192
  }];
10813
11193
  } },
10814
11194
  execute: async (args, exec) => {
10815
- const wiki = requireWiki();
11195
+ const wiki = requireWiki(sessionIdOf(exec));
10816
11196
  const list = Array.isArray(args.items) ? args.items : [];
10817
11197
  if (list.length === 0) return {
10818
11198
  ok: true,
@@ -10935,8 +11315,8 @@ function registerTiddlywikiTools(ctx, deps) {
10935
11315
  text: lines.join("\n")
10936
11316
  }];
10937
11317
  } },
10938
- execute: async (args) => {
10939
- const wiki = requireWiki();
11318
+ execute: async (args, exec) => {
11319
+ const wiki = requireWiki(sessionIdOf(exec));
10940
11320
  const { oldTitle, newTitle } = args;
10941
11321
  if (oldTitle === newTitle) return {
10942
11322
  ok: true,
@@ -11043,8 +11423,8 @@ function registerTiddlywikiTools(ctx, deps) {
11043
11423
  type: "text",
11044
11424
  text: value.trashed === true ? `已把 tiddler「${value.title}」移入回收站($:/dsh-tiddlywiki/trash/,可用 tiddlywiki_trash action=restore 恢复)。` : `已永久删除 tiddler「${value.title}」。`
11045
11425
  }] },
11046
- execute: async (args) => {
11047
- const wiki = requireWiki();
11426
+ execute: async (args, exec) => {
11427
+ const wiki = requireWiki(sessionIdOf(exec));
11048
11428
  const existing = await wiki.get(args.title);
11049
11429
  assertNoConflict(args.title, existing, {
11050
11430
  expectedModified: args.expectedModified,
@@ -11121,8 +11501,8 @@ function registerTiddlywikiTools(ctx, deps) {
11121
11501
  text: lines.join("\n")
11122
11502
  }];
11123
11503
  } },
11124
- execute: async (args) => {
11125
- const wiki = requireWiki();
11504
+ execute: async (args, exec) => {
11505
+ const wiki = requireWiki(sessionIdOf(exec));
11126
11506
  const index = await readTrashIndex(wiki);
11127
11507
  if (!index.readOk) throw new TrashIndexUnavailableError();
11128
11508
  if (index.corrupted) throw new Error("回收站索引已损坏(JSON 解析失败),本次操作已中止以免误删记录;可手动检查 $:/dsh-tiddlywiki/trash-index 后重试。");
@@ -11237,7 +11617,7 @@ function registerTiddlywikiTools(ctx, deps) {
11237
11617
  }];
11238
11618
  } },
11239
11619
  execute: async (args, exec) => {
11240
- const wiki = requireWiki();
11620
+ const wiki = requireWiki(sessionIdOf(exec));
11241
11621
  const title = args.title.trim();
11242
11622
  if (title.length === 0) throw new Error("tiddlywiki_append: title 不能为空");
11243
11623
  const existing = await wiki.get(title);
@@ -11312,8 +11692,8 @@ function registerTiddlywikiTools(ctx, deps) {
11312
11692
  text: lines.join("\n")
11313
11693
  }];
11314
11694
  } },
11315
- execute: async (args) => {
11316
- const wiki = requireWiki();
11695
+ execute: async (args, exec) => {
11696
+ const wiki = requireWiki(sessionIdOf(exec));
11317
11697
  const target = args.title.trim();
11318
11698
  if (target.length === 0) throw new Error("tiddlywiki_backlinks: title 不能为空");
11319
11699
  const includeTags = args.includeTags !== false;
@@ -11395,8 +11775,8 @@ function registerTiddlywikiTools(ctx, deps) {
11395
11775
  text: lines.join("\n")
11396
11776
  }];
11397
11777
  } },
11398
- execute: async (args) => {
11399
- const wiki = requireWiki();
11778
+ execute: async (args, exec) => {
11779
+ const wiki = requireWiki(sessionIdOf(exec));
11400
11780
  const title = args.title.trim();
11401
11781
  if (title.length === 0) throw new Error("tiddlywiki_attach: title 不能为空");
11402
11782
  const hasPath = typeof args.path === "string" && args.path.trim().length > 0;
@@ -11497,8 +11877,8 @@ function registerTiddlywikiTools(ctx, deps) {
11497
11877
  text: lines.join("\n")
11498
11878
  }];
11499
11879
  } },
11500
- execute: async (args) => {
11501
- const wiki = requireWiki();
11880
+ execute: async (args, exec) => {
11881
+ const wiki = requireWiki(sessionIdOf(exec));
11502
11882
  const limit = Math.max(1, Math.min(args.limit ?? 10, 100));
11503
11883
  const requested = Array.isArray(args.checks) ? args.checks.filter((c) => typeof c === "string" && c.trim().length > 0).map((c) => c.trim()) : [];
11504
11884
  const unknownChecks = requested.filter((c) => !LINT_CHECKS.includes(c));
@@ -11664,17 +12044,31 @@ function registerTiddlywikiTools(ctx, deps) {
11664
12044
  }
11665
12045
  },
11666
12046
  output: { render: (_args, value) => renderSync(value) },
11667
- execute: async (args) => {
12047
+ execute: async (args, exec) => {
11668
12048
  const dir = deps.wikiPath();
11669
- /** Restart TW after a pull that changed the tree (stale snapshot drop). */
12049
+ /**
12050
+ * Restart the RUNNING wikis whose content this pull changed.
12051
+ *
12052
+ * `changedFiles` is what makes this correct with several knowledge bases in
12053
+ * one repository: restarting "the wiki I happened to pull from" would miss
12054
+ * the one that actually changed, and would interrupt a wiki nothing
12055
+ * happened to (v0.28.0).
12056
+ */
11670
12057
  const restartIfChanged = async (pulled) => {
11671
- if (pulled.changed !== true || deps.restartWiki === void 0) return {};
12058
+ if (pulled.changed !== true) return {};
12059
+ if (deps.restartAffected === void 0) return {};
11672
12060
  try {
11673
- await flushPendingWrites(requireWiki(), join(dir, "tiddlers")).catch(() => void 0);
11674
- await deps.restartWiki();
11675
- return { restarted: true };
12061
+ await flushPendingWrites(requireWiki(sessionIdOf(exec)), join(dir, "tiddlers")).catch(() => void 0);
12062
+ const outcome = await deps.restartAffected(dir, pulled.changedFiles ?? []);
12063
+ return {
12064
+ ...outcome.restarted.length > 0 ? { restarted: outcome.restarted } : {},
12065
+ ...outcome.failed.length > 0 ? { restartFailed: outcome.failed } : {}
12066
+ };
11676
12067
  } catch (err) {
11677
- return { restartError: err instanceof Error ? err.message : String(err) };
12068
+ return { restartFailed: [{
12069
+ id: "(全部)",
12070
+ message: err instanceof Error ? err.message : String(err)
12071
+ }] };
11678
12072
  }
11679
12073
  };
11680
12074
  switch (args.action) {
@@ -11768,7 +12162,7 @@ function registerTiddlywikiTools(ctx, deps) {
11768
12162
  text: lines.join("\n")
11769
12163
  }];
11770
12164
  } },
11771
- execute: async (args) => {
12165
+ execute: async (args, exec) => {
11772
12166
  const dir = deps.wikiPath();
11773
12167
  if (args.strategy === "list") return {
11774
12168
  ok: true,
@@ -11850,8 +12244,8 @@ function renderSync(value) {
11850
12244
  }
11851
12245
  if (value.commit !== void 0) lines.push(`本地 commit: ${value.commit}`);
11852
12246
  if (value.push !== void 0) lines.push(`远端 push: ${value.push}`);
11853
- if (value.changed === true) lines.push(value.restarted === true ? "本次 pull 拉取了新内容,TW 服务已自动重启(同端口),读取/搜索均为最新快照。" : "本次 pull 拉取了新内容,但 TW 服务未能自动重启(如需最新快照,请手动重启 TW)。");
11854
- if (value.restartError !== void 0) lines.push(`TW 重启失败: ${value.restartError}`);
12247
+ if (value.changed === true) lines.push(value.restarted !== void 0 && value.restarted.length > 0 ? `本次 pull 拉取了新内容,已重启:${value.restarted.join("、")}(同端口),其读取/搜索均为最新快照。` : "本次 pull 拉取了新内容,但没有正在运行的知识库受影响(无需重启,或改动落在未运行的库里)。");
12248
+ if (value.restartFailed !== void 0) for (const item of value.restartFailed) lines.push(`TW 重启失败:${item.id} —— ${item.message}`);
11855
12249
  if (value.status !== void 0) {
11856
12250
  lines.push(`状态: ${gitStatusBits(value.status)}`);
11857
12251
  const s = value.status;
@@ -12091,104 +12485,566 @@ async function switchWiki(deps, input) {
12091
12485
  };
12092
12486
  }
12093
12487
  //#endregion
12094
- //#region src/index.ts
12488
+ //#region src/host/path-key.ts
12095
12489
  /**
12096
- * dsh-tiddlywiki — host half.
12490
+ * Path comparison keys (v0.28.0).
12097
12491
  *
12098
- * TiddlyWiki 5 as the DSH persistent knowledge base. Wiring:
12099
- * - WikiServer spawns/kills/self-heals the TW 5 child process (loopback, auto
12100
- * port) and scaffolds the wiki folder on first run;
12101
- * - the git face bootstraps the wiki folder as a repository and wires the
12102
- * debounced auto-committer;
12103
- * - `tiddlywiki_*` agent tools + a system-prompt section;
12104
- * - /dsh-tiddlywiki routes when a webServer is present.
12492
+ * WHY THIS IS ITS OWN MODULE: three places must agree on "is this the same
12493
+ * folder?" — the wiki registry (duplicate/nested entries), the repo grouping
12494
+ * (two wikis sharing one repository) and the farm's path-change detection. A
12495
+ * second copy of this rule is exactly the drift this repository keeps paying
12496
+ * for, so there is one function and everybody imports it.
12105
12497
  *
12106
- * Export shape follows dsh-taskboard: a function/namespace plugin —
12107
- * `name` / `inject` / `apply`, NO default export. Config arrives as the
12108
- * second apply() argument (Cordis `runtime.callback(ctx, config)`).
12498
+ * THE RULES
12499
+ * ---------
12500
+ * - separators normalised to `/` (git prints forward slashes on Windows);
12501
+ * - trailing separators dropped (`D:/notes/` === `D:/notes`);
12502
+ * - **case-insensitive everywhere**, deliberately: on Windows and default
12503
+ * macOS two paths differing only in case ARE the same folder, and no honest
12504
+ * setup has two knowledge bases there.
12109
12505
  *
12110
- * Extra exports (WikiServer / TiddlyWebClient / GitFace / ...) exist for the
12111
- * headless selftest and future reuse; the loader only reads name/inject/apply.
12506
+ * @module dsh-tiddlywiki/host/path-key
12507
+ */
12508
+ /**
12509
+ * The comparison key for a path. NOT a path: never hand it to the filesystem —
12510
+ * on a case-sensitive filesystem a lowercased path may not exist.
12511
+ */
12512
+ function pathComparisonKey(path) {
12513
+ return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
12514
+ }
12515
+ /** True when `candidate` is strictly inside `parent` (both are absolute paths). */
12516
+ function isInsidePath(parent, candidate) {
12517
+ const from = pathComparisonKey(parent);
12518
+ const to = pathComparisonKey(candidate);
12519
+ if (from.length === 0 || to.length === 0) return false;
12520
+ return to.startsWith(`${from}/`);
12521
+ }
12522
+ //#endregion
12523
+ //#region src/host/wiki-registry.ts
12524
+ /**
12525
+ * Multi-wiki registry (v0.28.0) — which knowledge bases exist, which one is the
12526
+ * default, and which of them the agent may see at all.
12527
+ *
12528
+ * WHY A FILE OUTSIDE EVERY WIKI
12529
+ * -----------------------------
12530
+ * Same reason as the single-location pointer (`host/wiki-location.ts`): the
12531
+ * settings page stores its overrides INSIDE the wiki it configures
12532
+ * (`$:/plugins/dsh-tiddlywiki/config`), so "which wikis exist / which is the
12533
+ * default" cannot live there — the answer must outlive every one of them.
12534
+ * The registry therefore lives next to the pointer:
12535
+ *
12536
+ * $DSH_HOME/dsh-tiddlywiki/wikis.json
12537
+ * { "version": 1, "defaultId": "main",
12538
+ * "wikis": [ { "id": "main", "label": "工作", "root": "D:/notes",
12539
+ * "name": "main", "agentVisible": true, "autostart": true } ] }
12540
+ *
12541
+ * THREE SOURCES, ONE PRECEDENCE
12542
+ * -----------------------------
12543
+ * wikis.json > legacy location.json (one `active` location) > cordis config default
12544
+ *
12545
+ * A user upgrading from a single-wiki install has only `location.json`; it is
12546
+ * migrated IN MEMORY into a one-entry registry (id `main`, agentVisible +
12547
+ * autostart true, so the upgrade reproduces the old behaviour exactly). The
12548
+ * caller writes the migrated registry back at its leisure — this module only
12549
+ * reads and validates.
12550
+ *
12551
+ * FAIL LOUD, DEGRADE TO A KNOWN-GOOD STATE
12552
+ * ----------------------------------------
12553
+ * A malformed registry is REPORTED and the fallback chain is used — it is
12554
+ * never silently acted upon (the same rule `readLocationState()` follows, and
12555
+ * for the same reason: a hand-edited JSON must not make the plugin boot
12556
+ * somewhere unexpected). Two severities:
12557
+ *
12558
+ * - `fatal` — the file is structurally unusable, or keeping it would be
12559
+ * DANGEROUS (duplicate id, two entries on one folder, nested
12560
+ * folders, a future schema version). → registry comes from
12561
+ * the fallback chain.
12562
+ * - `warnings` — healed or skipped (one unparsable entry, `defaultId`
12563
+ * pointing at a wiki that is gone). → registry is usable.
12564
+ *
12565
+ * NESTED FOLDERS ARE REFUSED ON PURPOSE
12566
+ * -------------------------------------
12567
+ * Two registered wikis that contain one another (`D:/notes` and
12568
+ * `D:/notes/work`) are rejected rather than warned about: path-prefix
12569
+ * attribution becomes ambiguous for every git operation, and the OUTER wiki's
12570
+ * `git add -A` commits the inner wiki's tiddlers too (see the repo-keyed commit
12571
+ * model in `host/git.ts`). Both failure modes are silent and expensive, so the
12572
+ * cheapest correct answer is "no".
12573
+ *
12574
+ * @module dsh-tiddlywiki/host/wiki-registry
12575
+ */
12576
+ /** Registry schema version (bumped only on an incompatible change). */
12577
+ const WIKI_REGISTRY_VERSION = 1;
12578
+ /** Id of the entry a migrated single-wiki install gets. */
12579
+ const DEFAULT_WIKI_ID = "main";
12580
+ /** The accepted modes, in declaration order (settings-page radio list). */
12581
+ const WIKI_MODES = ["single", "multi"];
12582
+ /** Mode assumed when the registry says nothing (upgrades, fresh installs). */
12583
+ const DEFAULT_WIKI_MODE = "single";
12584
+ /** Default registry file: `$DSH_HOME/dsh-tiddlywiki/wikis.json`. */
12585
+ function defaultRegistryFile() {
12586
+ return dshHomePath("dsh-tiddlywiki", "wikis.json");
12587
+ }
12588
+ function isPlainObject(value) {
12589
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12590
+ }
12591
+ /**
12592
+ * Normalise/validate a wiki id, or undefined when unusable.
12112
12593
  *
12113
- * @module dsh-tiddlywiki
12594
+ * Lowercased on purpose: the id is a URL path segment, and `/tw/Work/` vs
12595
+ * `/tw/work/` resolving to two different wikis on a case-insensitive Windows
12596
+ * filesystem would be a trap. Derivation (`deriveWikiId`) lowercases too, so a
12597
+ * round trip is stable.
12114
12598
  */
12115
- /** Cordis plugin name (also the client loader id / profile row id). */
12116
- const name = "dsh-tiddlywiki";
12117
- /** Required host services (tool registry + prompt assembly). */
12118
- const inject = ["tools", "systemPrompt"];
12119
- /** 剪藏桥默认端口(与 seed 文档书签代码里的地址保持一致)。 */
12120
- const CLIP_BRIDGE_DEFAULT_PORT = 8618;
12599
+ function normalizeWikiId(raw) {
12600
+ if (typeof raw !== "string") return void 0;
12601
+ const id = raw.trim().toLowerCase();
12602
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) return void 0;
12603
+ return id;
12604
+ }
12605
+ /**
12606
+ * Wiki ids that must NOT be used, because a wiki id is a PATH SEGMENT of the
12607
+ * same-origin proxy (`/dsh-tiddlywiki/tw/<id>/…`) while the TW child serves its
12608
+ * own endpoints at its ROOT (`/status`, `/recipes/…`, `/files/…`). An id equal
12609
+ * to one of those would make `/tw/status` ambiguous: is it the default wiki's
12610
+ * TW status, or the wiki named `status`? Nothing in the request can tell them
12611
+ * apart, so the id is simply refused (v0.28.0).
12612
+ *
12613
+ * This list is what lets the proxy treat "first segment looks like a known id"
12614
+ * as the per-wiki form and everything else as a TW path.
12615
+ */
12616
+ const RESERVED_WIKI_IDS = [
12617
+ "status",
12618
+ "recipes",
12619
+ "bags",
12620
+ "files",
12621
+ "render",
12622
+ "login",
12623
+ "logout",
12624
+ "favicon.ico",
12625
+ "index.html",
12626
+ "static",
12627
+ "assets"
12628
+ ];
12629
+ /** Filesystem-safe slug for a wiki id (may be empty for junk input). */
12630
+ function slugifyIdPart(raw) {
12631
+ return String(raw ?? "").trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 48);
12632
+ }
12121
12633
  /**
12122
- * Official plugin whose parser every note this plugin writes depends on
12123
- * (`text/markdown`). `--init server` does NOT include it — see ensurePlugin().
12634
+ * Derive a unique wiki id from a folder name (or any label), avoiding `taken`.
12635
+ * Falls back to `wiki`, `wiki-2`, … when the slug is empty or already used.
12124
12636
  */
12125
- const MARKDOWN_PLUGIN = "tiddlywiki/markdown";
12126
- const DEFAULTS = {
12127
- wikiRoot: "",
12128
- wiki: "main",
12129
- port: 0,
12130
- git: {
12131
- autoCommit: true,
12132
- debounceMs: 6e4,
12133
- remote: "",
12134
- branch: "main"
12135
- },
12136
- note: {
12137
- tag: "inbox",
12138
- workspaceMark: true
12139
- },
12140
- bridge: {
12141
- enabled: false,
12142
- port: CLIP_BRIDGE_DEFAULT_PORT,
12143
- token: "",
12144
- tag: "clip"
12145
- },
12146
- ui: {
12147
- showQuickNote: true,
12148
- showQuickNoteDock: true,
12149
- quickNoteMode: "native",
12150
- sidebarLabel: "TiddlyWiki",
12151
- showPanelStatus: true,
12152
- showSyncButton: true,
12153
- followDshTheme: true,
12154
- darkPalette: DARK_PALETTE_DEFAULT,
12155
- tabLabel: "知识库",
12156
- showSessionTab: true,
12157
- showRightbarTab: true,
12158
- sendToAgent: { enabled: true },
12159
- allArticles: { pageSize: 10 }
12160
- },
12161
- startup: { readyTimeoutMs: READY_TIMEOUT_DEFAULT_MS },
12162
- wechat: {
12163
- enabled: false,
12164
- command: "opencli",
12165
- token: "",
12166
- adapter: "publish-note",
12167
- dsn: ""
12168
- },
12169
- uiLanguage: "",
12170
- auth: {
12171
- username: "",
12172
- password: ""
12637
+ function deriveWikiId(source, taken = []) {
12638
+ const used = /* @__PURE__ */ new Set();
12639
+ for (const reserved of RESERVED_WIKI_IDS) used.add(reserved);
12640
+ for (const item of taken) {
12641
+ const id = normalizeWikiId(item);
12642
+ if (id !== void 0) used.add(id);
12173
12643
  }
12174
- };
12644
+ const base = normalizeWikiId(slugifyIdPart(source)) ?? "wiki";
12645
+ if (!used.has(base)) return base;
12646
+ for (let n = 2; n < 1e4; n += 1) {
12647
+ const candidate = `${base}-${n}`.slice(0, 64);
12648
+ if (!used.has(candidate)) return candidate;
12649
+ }
12650
+ return `wiki-${Date.now().toString(36)}`.slice(0, 64);
12651
+ }
12652
+ /** The `{root, name}` location this entry points at. */
12653
+ function entryLocation(entry) {
12654
+ return {
12655
+ root: entry.root,
12656
+ name: entry.name
12657
+ };
12658
+ }
12659
+ /** Absolute folder this entry serves. */
12660
+ function entryPath(entry) {
12661
+ return locationPath(entryLocation(entry));
12662
+ }
12175
12663
  /**
12176
- * Resolve the DEFAULT wikiRoot: explicit config (env-expanded) else
12177
- * $DSH_HOME/tiddlywiki. The runtime pointer file can override it — see
12178
- * `readLocationState()` in host/wiki-location.ts.
12664
+ * Comparison key for duplicate/nesting detection — the SHARED one from
12665
+ * host/path-key.ts (the repo grouping and the farm's path-change check need the
12666
+ * same answer to "is this the same folder?", so there is exactly one rule).
12667
+ *
12668
+ * @see pathComparisonKey
12179
12669
  */
12180
- function resolveWikiRoot(config) {
12181
- if (config.wikiRoot !== void 0 && config.wikiRoot.trim().length > 0) return expandEnvPath(config.wikiRoot.trim());
12182
- return dshHomePath("tiddlywiki");
12670
+ /** Display label for an entry that came without one. */
12671
+ function defaultLabel(location) {
12672
+ if (location.name !== ".") return location.name.slice(0, 60);
12673
+ return basename(locationPath(location)).slice(0, 60) || ".";
12183
12674
  }
12184
12675
  /**
12185
- * Ensure the wiki's `.gitignore` covers TW's transient artifacts, WITHOUT
12186
- * clobbering rules the user added.
12676
+ * Validate one entry. Missing `label` is derived, missing flags take their
12677
+ * defaults (`agentVisible` true, `autostart` false) — those are not errors.
12678
+ */
12679
+ function normalizeEntry(input) {
12680
+ if (!isPlainObject(input)) return { error: "条目必须是一个对象" };
12681
+ const id = normalizeWikiId(input.id);
12682
+ if (id === void 0) return { error: `知识库 id 非法(需以小写字母或数字开头,后接 a-z0-9._-,最长 64):${JSON.stringify(input.id)}` };
12683
+ if (RESERVED_WIKI_IDS.includes(id)) return { error: `知识库 id「${id}」被保留:它会和 TW 自己的根路径 /${id} 撞名,同源代理无法区分两者` };
12684
+ const normalized = normalizeLocation({
12685
+ root: input.root,
12686
+ name: input.name
12687
+ });
12688
+ if (normalized.location === void 0) return { error: normalized.error ?? "位置非法" };
12689
+ return { entry: {
12690
+ id,
12691
+ label: typeof input.label === "string" && input.label.trim().length > 0 ? input.label.trim().slice(0, 60) : defaultLabel(normalized.location),
12692
+ root: normalized.location.root,
12693
+ name: normalized.location.name,
12694
+ agentVisible: input.agentVisible !== false,
12695
+ autostart: input.autostart === true
12696
+ } };
12697
+ }
12698
+ /**
12699
+ * Duplicate-folder and nested-folder problems across a candidate entry list.
12700
+ *
12701
+ * Nested = one entry's folder is strictly inside another's. Checked in both
12702
+ * directions and reported once per pair (the outer entry is named first).
12703
+ */
12704
+ function findPathConflicts(entries) {
12705
+ const problems = [];
12706
+ const byKey = /* @__PURE__ */ new Map();
12707
+ for (const entry of entries) {
12708
+ const path = entryPath(entry);
12709
+ const key = pathComparisonKey(path);
12710
+ const duplicate = byKey.get(key);
12711
+ if (duplicate !== void 0) {
12712
+ problems.push(`知识库「${entry.id}」与「${duplicate.id}」指向同一个目录:${path}`);
12713
+ continue;
12714
+ }
12715
+ byKey.set(key, entry);
12716
+ }
12717
+ const unique = [...byKey.values()];
12718
+ for (const outer of unique) {
12719
+ const outerPath = entryPath(outer);
12720
+ for (const inner of unique) {
12721
+ if (inner === outer) continue;
12722
+ if (!isInsidePath(outerPath, entryPath(inner))) continue;
12723
+ problems.push(`知识库「${inner.id}」位于「${outer.id}」目录内部(${entryPath(inner)} ⊂ ${entryPath(outer)}):嵌套登记会让外层知识库的 git 一并提交内层内容,已拒绝`);
12724
+ }
12725
+ }
12726
+ return problems;
12727
+ }
12728
+ /**
12729
+ * Validate a parsed registry object.
12730
+ *
12731
+ * Structural/ambiguous problems are FATAL (empty `registry`): a duplicate id,
12732
+ * two entries on one folder, nested folders, a future schema version, or a
12733
+ * list where nothing parses. Per-entry junk and a stale `defaultId` are healed
12734
+ * and reported as warnings, because dropping one wiki is recoverable while
12735
+ * refusing to boot is not.
12736
+ */
12737
+ function validateRegistry(input) {
12738
+ const fatal = [];
12739
+ const warnings = [];
12740
+ if (!isPlainObject(input)) return {
12741
+ fatal: ["清单必须是一个 JSON 对象"],
12742
+ warnings
12743
+ };
12744
+ const rawVersion = input.version;
12745
+ if (typeof rawVersion === "number" && Number.isFinite(rawVersion) && rawVersion > 1) return {
12746
+ fatal: [`清单版本 ${rawVersion} 由更新版本的插件写入,本版本(1)无法安全读取`],
12747
+ warnings
12748
+ };
12749
+ if (!Array.isArray(input.wikis) || input.wikis.length === 0) return {
12750
+ fatal: ["清单里没有任何知识库(wikis 必须是非空数组)"],
12751
+ warnings
12752
+ };
12753
+ const entries = [];
12754
+ const seenIds = /* @__PURE__ */ new Set();
12755
+ for (const [index, item] of input.wikis.entries()) {
12756
+ const normalized = normalizeEntry(item);
12757
+ if (normalized.entry === void 0) {
12758
+ warnings.push(`跳过第 ${index + 1} 个条目:${normalized.error}`);
12759
+ continue;
12760
+ }
12761
+ if (seenIds.has(normalized.entry.id)) return {
12762
+ fatal: [`知识库 id 重复:${normalized.entry.id}`],
12763
+ warnings
12764
+ };
12765
+ seenIds.add(normalized.entry.id);
12766
+ entries.push(normalized.entry);
12767
+ }
12768
+ if (entries.length === 0) return {
12769
+ fatal: warnings.length > 0 ? [...warnings, "没有任何条目可用"] : ["清单里没有任何可用的知识库"],
12770
+ warnings
12771
+ };
12772
+ const conflicts = findPathConflicts(entries);
12773
+ if (conflicts.length > 0) return {
12774
+ fatal: conflicts,
12775
+ warnings
12776
+ };
12777
+ const rawDefault = typeof input.defaultId === "string" ? input.defaultId.trim().toLowerCase() : "";
12778
+ let defaultId = rawDefault;
12779
+ if (!seenIds.has(defaultId)) {
12780
+ if (rawDefault.length > 0) warnings.push(`defaultId「${rawDefault}」不在清单里,已回退到「${entries[0]?.id ?? "main"}」`);
12781
+ defaultId = entries[0]?.id ?? "main";
12782
+ }
12783
+ const rawMode = input.mode;
12784
+ let mode = DEFAULT_WIKI_MODE;
12785
+ if (rawMode !== void 0) if (rawMode === "single" || rawMode === "multi") mode = rawMode;
12786
+ else warnings.push(`未知的 mode「${String(rawMode)}」,已按 ${DEFAULT_WIKI_MODE} 处理`);
12787
+ return {
12788
+ registry: {
12789
+ version: 1,
12790
+ mode,
12791
+ defaultId,
12792
+ wikis: entries
12793
+ },
12794
+ fatal,
12795
+ warnings
12796
+ };
12797
+ }
12798
+ /** One localised entry for the current location, or undefined. */
12799
+ function findEntry(registry, id) {
12800
+ if (typeof id !== "string") return void 0;
12801
+ const key = id.trim().toLowerCase();
12802
+ return registry.wikis.find((entry) => entry.id === key);
12803
+ }
12804
+ /** The default entry (falls back to the first when `defaultId` dangles). */
12805
+ function defaultEntry(registry) {
12806
+ return findEntry(registry, registry.defaultId) ?? registry.wikis[0];
12807
+ }
12808
+ /** Insert or replace one entry by id (immutable); `defaultId` stays valid. */
12809
+ function upsertWiki(registry, entry) {
12810
+ const index = registry.wikis.findIndex((item) => item.id === entry.id);
12811
+ const wikis = index >= 0 ? registry.wikis.map((item, i) => i === index ? entry : item) : [...registry.wikis, entry];
12812
+ const defaultId = findEntry({
12813
+ ...registry,
12814
+ wikis
12815
+ }, registry.defaultId) !== void 0 ? registry.defaultId : wikis[0]?.id ?? entry.id;
12816
+ return {
12817
+ ...registry,
12818
+ defaultId,
12819
+ wikis
12820
+ };
12821
+ }
12822
+ /**
12823
+ * Drop one entry (immutable). Refuses to empty the list — the plugin always
12824
+ * needs at least one wiki — and re-points `defaultId` when it was removed.
12825
+ */
12826
+ function removeWiki(registry, id) {
12827
+ const target = findEntry(registry, id);
12828
+ if (target === void 0) return { error: `知识库「${String(id)}」不在清单里` };
12829
+ if (registry.wikis.length <= 1) return { error: "至少要保留一个知识库" };
12830
+ const wikis = registry.wikis.filter((entry) => entry !== target);
12831
+ const defaultId = target.id === registry.defaultId ? wikis[0]?.id ?? target.id : registry.defaultId;
12832
+ return { registry: {
12833
+ ...registry,
12834
+ defaultId,
12835
+ wikis
12836
+ } };
12837
+ }
12838
+ /**
12839
+ * Apply ONE action to a registry, purely (v0.28.0).
12840
+ *
12841
+ * Pure on purpose: the side effects (write the file, reconcile the farm) stay in
12842
+ * the host wiring, so the RULES — which id gets derived, what a duplicate means,
12843
+ * what a folder move may collide with — are unit-testable in milliseconds
12844
+ * (`scripts/verify-wiki-registry.mjs`).
12845
+ *
12846
+ * Every action re-runs `validateRegistry()` on the RESULT, because a change can
12847
+ * create exactly the situations the registry exists to refuse: two entries on
12848
+ * one folder, or one entry nested inside another.
12849
+ */
12850
+ function applyWikiAction(registry, input) {
12851
+ if (!isPlainObject(input) || typeof input.action !== "string") return { error: "动作必须是一个带 action 字段的对象" };
12852
+ const action = input.action;
12853
+ const finish = (next) => {
12854
+ const validated = validateRegistry(next);
12855
+ if (validated.registry === void 0) return { error: validated.fatal.join(";") };
12856
+ return { registry: validated.registry };
12857
+ };
12858
+ if (action === "add") {
12859
+ const raw = isPlainObject(input.wiki) ? input.wiki : void 0;
12860
+ if (raw === void 0) return { error: "add 需要 wiki 字段" };
12861
+ const id = normalizeWikiId(raw.id) ?? deriveWikiId(typeof raw.label === "string" && raw.label.trim().length > 0 ? raw.label : raw.name, registry.wikis.map((entry) => entry.id));
12862
+ if (findEntry(registry, id) !== void 0) return { error: `知识库「${id}」已存在(改它请用 update)` };
12863
+ const normalized = normalizeEntry({
12864
+ ...raw,
12865
+ id
12866
+ });
12867
+ if (normalized.entry === void 0) return { error: normalized.error ?? "条目非法" };
12868
+ return finish({
12869
+ ...registry,
12870
+ wikis: [...registry.wikis, normalized.entry]
12871
+ });
12872
+ }
12873
+ if (action === "update") {
12874
+ const raw = isPlainObject(input.wiki) ? input.wiki : void 0;
12875
+ if (raw === void 0) return { error: "update 需要 wiki 字段" };
12876
+ const id = normalizeWikiId(raw.id);
12877
+ if (id === void 0) return { error: "update 必须带一个合法的 id" };
12878
+ if (findEntry(registry, id) === void 0) return { error: `知识库「${id}」不在清单里(新增请用 add)` };
12879
+ const normalized = normalizeEntry({
12880
+ ...raw,
12881
+ id
12882
+ });
12883
+ if (normalized.entry === void 0) return { error: normalized.error ?? "条目非法" };
12884
+ return finish(upsertWiki(registry, normalized.entry));
12885
+ }
12886
+ if (action === "remove") {
12887
+ const removed = removeWiki(registry, input.id);
12888
+ if (removed.registry === void 0) return { error: removed.error ?? "删除失败" };
12889
+ return finish(removed.registry);
12890
+ }
12891
+ if (action === "set-default") {
12892
+ const target = findEntry(registry, input.id);
12893
+ if (target === void 0) return { error: `知识库「${String(input.id)}」不在清单里,无法设为默认` };
12894
+ return finish({
12895
+ ...registry,
12896
+ defaultId: target.id
12897
+ });
12898
+ }
12899
+ if (action === "set-mode") {
12900
+ if (input.mode !== "single" && input.mode !== "multi") return { error: `mode 只能是 ${WIKI_MODES.join(" 或 ")}(收到 ${JSON.stringify(input.mode)})` };
12901
+ return finish({
12902
+ ...registry,
12903
+ mode: input.mode
12904
+ });
12905
+ }
12906
+ return { error: `未知动作「${action}」(可用:add / update / remove / set-default / set-mode)` };
12907
+ }
12908
+ /**
12909
+ * A one-entry registry for a single location (migration / first run).
12910
+ *
12911
+ * `mode` defaults to `single` — a migrated install must reproduce exactly the
12912
+ * behaviour it had before the registry existed.
12913
+ */
12914
+ function singleEntryRegistry(location, id = DEFAULT_WIKI_ID, mode = DEFAULT_WIKI_MODE) {
12915
+ const resolved = normalizeLocation({
12916
+ root: location.root,
12917
+ name: location.name
12918
+ }).location ?? {
12919
+ root: location.root,
12920
+ name: location.name
12921
+ };
12922
+ const entryId = normalizeWikiId(id) ?? "main";
12923
+ return {
12924
+ version: 1,
12925
+ mode,
12926
+ defaultId: entryId,
12927
+ wikis: [{
12928
+ id: entryId,
12929
+ label: defaultLabel(resolved),
12930
+ root: resolved.root,
12931
+ name: resolved.name,
12932
+ agentVisible: true,
12933
+ autostart: true
12934
+ }]
12935
+ };
12936
+ }
12937
+ /** Write the registry atomically (tmp + rename, mkdir -p). */
12938
+ async function writeRegistry(registry, file = defaultRegistryFile()) {
12939
+ const payload = JSON.stringify({
12940
+ ...registry,
12941
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
12942
+ }, null, 2);
12943
+ await mkdir(dirname(file), { recursive: true });
12944
+ const tmp = `${file}.tmp`;
12945
+ await writeFile(tmp, payload, "utf8");
12946
+ await rename(tmp, file);
12947
+ }
12948
+ /**
12949
+ * Read + validate the registry, falling back through `location.json` to the
12950
+ * cordis default. Never throws: every failure is a reported error plus a
12951
+ * usable one-entry registry.
12952
+ */
12953
+ async function readRegistry(deps) {
12954
+ const file = deps.file ?? defaultRegistryFile();
12955
+ const legacyFile = deps.legacyFile ?? defaultLocationStateFile();
12956
+ /**
12957
+ * The chain below the registry: legacy pointer, then the cordis default.
12958
+ * `reason` is prepended so the user learns WHY the registry was ignored.
12959
+ */
12960
+ const fallbackChain = async (reason, warnings) => {
12961
+ const legacy = await readLocationState(legacyFile);
12962
+ const legacyWarnings = legacy.error !== void 0 ? [legacy.error] : [];
12963
+ if (legacy.active !== void 0) return {
12964
+ registry: singleEntryRegistry(legacy.active, DEFAULT_WIKI_ID),
12965
+ source: "legacy",
12966
+ ...reason.length > 0 ? { error: `${reason};已回退到位置指针文件里的知识库(${locationPath(legacy.active)})` } : {},
12967
+ warnings: [...warnings, ...legacyWarnings]
12968
+ };
12969
+ return {
12970
+ registry: singleEntryRegistry(deps.fallback, DEFAULT_WIKI_ID),
12971
+ source: "default",
12972
+ ...reason.length > 0 ? { error: `${reason};已回退到配置默认知识库(${locationPath(deps.fallback)})` } : {},
12973
+ warnings: [...warnings, ...legacyWarnings]
12974
+ };
12975
+ };
12976
+ let raw;
12977
+ try {
12978
+ raw = await readFile(file, "utf8");
12979
+ } catch (err) {
12980
+ if (err.code !== "ENOENT") return fallbackChain(`知识库清单读不到(${file}):${err instanceof Error ? err.message : String(err)}`, []);
12981
+ return fallbackChain("", []);
12982
+ }
12983
+ let parsed;
12984
+ try {
12985
+ parsed = JSON.parse(raw);
12986
+ } catch {
12987
+ return fallbackChain(`知识库清单不是合法 JSON(${file})`, []);
12988
+ }
12989
+ const validated = validateRegistry(parsed);
12990
+ if (validated.registry === void 0) return fallbackChain(`知识库清单不可用(${validated.fatal.join(";")})`, validated.warnings);
12991
+ const warnings = validated.warnings;
12992
+ return {
12993
+ registry: validated.registry,
12994
+ source: "file",
12995
+ ...warnings.length > 0 ? { error: warnings.join(";") } : {},
12996
+ warnings
12997
+ };
12998
+ }
12999
+ //#endregion
13000
+ //#region src/host/wiki-instance.ts
13001
+ /**
13002
+ * One knowledge base's runtime (v0.28.0) — the "wiki farm" replaces the single
13003
+ * `WikiServer` + client + config + committer quartet that used to live as loose
13004
+ * closure variables inside `apply()`.
13005
+ *
13006
+ * WHAT IS PER-WIKI, AND WHAT IS NOT
13007
+ * ---------------------------------
13008
+ * Per instance (everything that holds a folder or a port):
13009
+ *
13010
+ * - `WikiServer` one TW child process, one loopback port;
13011
+ * - `TiddlyWebClient` the REST client for THAT port;
13012
+ * - `ConfigStore` the wiki's own `$:/plugins/dsh-tiddlywiki/config`
13013
+ * overlay (which is why per-wiki config already worked
13014
+ * before this refactor — it just was not addressable);
13015
+ * - fs watcher for its own folder (the AUTO-COMMITTER is per REPOSITORY — see
13016
+ * host/repo-committers.ts — because several wikis may share one repository
13017
+ * and only one `git add -A` per repository may run);
12187
13018
  *
12188
- * This used to rewrite the whole file on every start, silently deleting any
12189
- * custom ignore rules the user had put in `wiki/.gitignore`. Now the file is
12190
- * only touched when one of the managed lines is missing, and the user's own
12191
- * content is preserved verbatim.
13019
+ * Shared by the whole plugin, therefore NOT here:
13020
+ *
13021
+ * - `GitFace` — every method takes the directory as an argument, so it is
13022
+ * stateless; M2 keys the COMMITTER by repository, not by wiki;
13023
+ * - the clip bridge (one listener on one port) and the WeChat runner;
13024
+ * - the registered agent tools, routes and the prompt section (singletons by
13025
+ * construction: the host rejects a duplicate tool/route/section name).
13026
+ *
13027
+ * LIFECYCLE CONTRACT
13028
+ * ------------------
13029
+ * start() server.start → load config tiddler → bootstrap seeds → git → arm extras
13030
+ * restart() drain the syncer queue, THEN restart (ironclad rule #1)
13031
+ * stop() drain, then stop, then release the extras
13032
+ * dispose() flush a pending commit, release extras, stop the child
13033
+ *
13034
+ * A failed `start()` must not leave a half-wired instance behind: the caller
13035
+ * decides whether to roll back, and `dispose()` is idempotent.
13036
+ *
13037
+ * @module dsh-tiddlywiki/host/wiki-instance
13038
+ */
13039
+ /**
13040
+ * Official plugin whose parser every note this plugin writes depends on
13041
+ * (`text/markdown`). `--init server` does NOT include it — see ensurePlugin().
13042
+ */
13043
+ const MARKDOWN_PLUGIN = "tiddlywiki/markdown";
13044
+ /**
13045
+ * Ensure the wiki's `.gitignore` covers TW's transient artifacts, WITHOUT
13046
+ * clobbering rules the user added. Only touched when one of the managed lines
13047
+ * is missing; the user's own content is preserved verbatim.
12192
13048
  */
12193
13049
  const MANAGED_GITIGNORE_LINES = [
12194
13050
  "tiddlers/$__temp_*",
@@ -12215,7 +13071,19 @@ async function writeGitignore(wikiPath) {
12215
13071
  ].join("\n");
12216
13072
  await writeFile(file, `${current.length === 0 ? "" : current.endsWith("\n") ? `${current}\n` : `${current}\n\n`}${block}`, "utf8");
12217
13073
  }
12218
- /** Watch the wiki folders and touch the auto-committer on changes. */
13074
+ /**
13075
+ * Watch one wiki's folders and touch the auto-committer on changes.
13076
+ *
13077
+ * ALWAYS attach an 'error' listener: `fs.watch` reports failures (Windows EPERM,
13078
+ * Linux ENOSPC when the inotify watch limit is exhausted) as an ASYNC 'error'
13079
+ * event, which a surrounding try/catch cannot catch. Node rethrows an unhandled
13080
+ * 'error' as an uncaught exception, and the host installs no handler → the whole
13081
+ * `dsh web` process dies. Degrading to "no watcher" is safe: our own writes still
13082
+ * call `touch()`.
13083
+ *
13084
+ * ⚠️ With N wikis there are N watchers. On Linux the inotify watch limit is a
13085
+ * real ceiling; the degradation above is what keeps that survivable.
13086
+ */
12219
13087
  function watchWiki(wikiPath, onChange) {
12220
13088
  const watchers = [];
12221
13089
  for (const dir of [join(wikiPath, "tiddlers"), wikiPath]) try {
@@ -12231,17 +13099,1157 @@ function watchWiki(wikiPath, onChange) {
12231
13099
  } catch {}
12232
13100
  };
12233
13101
  }
13102
+ /** One knowledge base: its child process, client, config overlay and committer. */
13103
+ var WikiInstance = class WikiInstance {
13104
+ options;
13105
+ /** The TW child process this knowledge base runs on its own loopback port. */
13106
+ server;
13107
+ /** This wiki's own `$:/plugins/dsh-tiddlywiki/config` overlay. */
13108
+ config;
13109
+ entryValue;
13110
+ wikiPath;
13111
+ clientCache;
13112
+ clientPort;
13113
+ unwatch;
13114
+ disposed = false;
13115
+ log;
13116
+ constructor(options) {
13117
+ this.options = options;
13118
+ this.entryValue = options.entry;
13119
+ this.wikiPath = entryPath(options.entry);
13120
+ this.log = options.log ?? ((message) => {
13121
+ console.warn("[dsh-tiddlywiki]", message);
13122
+ });
13123
+ this.server = new WikiServer({
13124
+ wikiRoot: options.entry.root,
13125
+ wiki: options.entry.name,
13126
+ port: 0,
13127
+ username: options.base.auth.username,
13128
+ password: options.base.auth.password,
13129
+ readyTimeoutMs: options.base.startup.readyTimeoutMs
13130
+ });
13131
+ this.config = new ConfigStore({
13132
+ note: options.base.note,
13133
+ git: options.base.git,
13134
+ ui: options.base.ui,
13135
+ uiLanguage: options.base.uiLanguage,
13136
+ bridge: options.base.bridge,
13137
+ startup: options.base.startup,
13138
+ wechat: options.base.wechat
13139
+ });
13140
+ }
13141
+ /** Registry entry currently served (id/label/flags live here). */
13142
+ get entry() {
13143
+ return this.entryValue;
13144
+ }
13145
+ /** Absolute folder of this wiki. */
13146
+ get path() {
13147
+ return this.wikiPath;
13148
+ }
13149
+ /** True once {@link dispose} ran: no path may spawn or start anything after it. */
13150
+ get isDisposed() {
13151
+ return this.disposed;
13152
+ }
13153
+ /** Folder key used by the repo-keyed git model (M2). */
13154
+ get tiddlersDir() {
13155
+ return join(this.wikiPath, "tiddlers");
13156
+ }
13157
+ /** Effective config = cordis base overlaid with THIS wiki's config tiddler. */
13158
+ eff() {
13159
+ return this.config.get();
13160
+ }
13161
+ /** The note tag new notes get here (config tiddler overrides the base). */
13162
+ noteTag() {
13163
+ const tag = this.eff().note?.tag;
13164
+ return typeof tag === "string" && tag.trim().length > 0 ? tag : this.options.base.note.tag;
13165
+ }
13166
+ /**
13167
+ * Whether agent-created notes get the automatic workspace marker (v0.24.0):
13168
+ * tag `ws/<project>` + field `workspace`. Default ON; an explicit `false` in
13169
+ * this wiki's config tiddler wins.
13170
+ */
13171
+ workspaceMark() {
13172
+ const value = this.eff().note?.workspaceMark;
13173
+ return typeof value === "boolean" ? value : this.options.base.note.workspaceMark;
13174
+ }
13175
+ /**
13176
+ * Effective clip-bridge config for THIS wiki. Used per request by the shared
13177
+ * bridge: enabled/token/tag apply immediately; only the port is fixed at
13178
+ * startup (the listener binds once, plugin-wide).
13179
+ */
13180
+ bridgeConfig() {
13181
+ const b = this.eff().bridge ?? {};
13182
+ const port = typeof b.port === "number" && Number.isInteger(b.port) && b.port > 0 && b.port < 65536 ? b.port : this.options.base.bridge.port;
13183
+ const token = typeof b.token === "string" ? b.token : "";
13184
+ const tag = typeof b.tag === "string" && b.tag.trim().length > 0 ? b.tag.trim() : this.options.base.bridge.tag;
13185
+ return {
13186
+ enabled: b.enabled === true,
13187
+ port,
13188
+ token,
13189
+ tag
13190
+ };
13191
+ }
13192
+ /**
13193
+ * Effective UI flags from a config overlay.
13194
+ *
13195
+ * STATIC on purpose (v0.28.0): the plugin also needs them when NO wiki is
13196
+ * running — a stopped farm must answer the settings page with the cordis base
13197
+ * values instead of inventing them, and duplicating this mapping in index.ts
13198
+ * is exactly the kind of drift this repo keeps paying for.
13199
+ */
13200
+ static uiDefaultsFrom(base, ui = {}) {
13201
+ const palette = typeof ui.darkPalette === "string" && ui.darkPalette.trim().length > 0 ? ui.darkPalette.trim() : DARK_PALETTE_DEFAULT;
13202
+ const label = typeof ui.sidebarLabel === "string" && ui.sidebarLabel.trim().length > 0 ? ui.sidebarLabel.trim() : base.ui.sidebarLabel;
13203
+ const tabLabel = typeof ui.tabLabel === "string" && ui.tabLabel.trim().length > 0 ? ui.tabLabel.trim() : base.ui.tabLabel;
13204
+ const quickNoteMode = ui.quickNoteMode === "card" ? "card" : "native";
13205
+ return {
13206
+ showQuickNote: ui.showQuickNote !== false,
13207
+ showQuickNoteDock: ui.showQuickNoteDock !== false,
13208
+ quickNoteMode,
13209
+ sidebarLabel: label,
13210
+ showPanelStatus: ui.showPanelStatus !== false,
13211
+ showSyncButton: ui.showSyncButton !== false,
13212
+ followDshTheme: ui.followDshTheme !== false,
13213
+ darkPalette: palette,
13214
+ tabLabel,
13215
+ showSessionTab: ui.showSessionTab !== false,
13216
+ showRightbarTab: ui.showRightbarTab !== false
13217
+ };
13218
+ }
13219
+ /** Effective UI flags for THIS wiki. */
13220
+ uiDefaults() {
13221
+ return WikiInstance.uiDefaultsFrom(this.options.base, this.eff().ui);
13222
+ }
13223
+ /** Effective 公众号发布 config for THIS wiki (read per request). */
13224
+ wechatConfig() {
13225
+ return normalizeWechatConfig(this.eff().wechat);
13226
+ }
13227
+ /** This wiki's send-to-agent switch / shared token. */
13228
+ sendToAgent() {
13229
+ const s2a = this.eff().ui?.sendToAgent ?? {};
13230
+ return {
13231
+ enabled: s2a.enabled !== false,
13232
+ token: typeof s2a.token === "string" ? s2a.token : ""
13233
+ };
13234
+ }
13235
+ /** Whether the wechat feature is on for this wiki (gates seeds + prompt line). */
13236
+ wechatEnabled() {
13237
+ return (this.eff().wechat ?? {}).enabled === true;
13238
+ }
13239
+ /**
13240
+ * Lazy TW client. Rebuilt whenever the bound PORT changes (a crash before
13241
+ * readiness makes the child re-probe a free port, so a cached client must not
13242
+ * stay pinned to a dead one). Credentials are attached preemptively: with
13243
+ * `auth.username` configured the child runs behind `readers`/`writers`, so
13244
+ * EVERY request needs Basic auth.
13245
+ */
13246
+ client() {
13247
+ const port = this.server.currentPort;
13248
+ if (port === void 0) return void 0;
13249
+ if (this.clientCache === void 0 || this.clientPort !== port) {
13250
+ this.clientCache = new TiddlyWebClient(`http://127.0.0.1:${port}`, {
13251
+ username: this.options.base.auth.username,
13252
+ password: this.options.base.auth.password
13253
+ });
13254
+ this.clientPort = port;
13255
+ }
13256
+ return this.clientCache;
13257
+ }
13258
+ /** Drop the cached client (its cached listing belongs to the old folder). */
13259
+ resetClient() {
13260
+ this.clientCache = void 0;
13261
+ this.clientPort = void 0;
13262
+ }
13263
+ /**
13264
+ * Point this instance at another folder. THROWS while the child runs — the
13265
+ * switch orchestrator stops it first (see host/wiki-switch.ts).
13266
+ */
13267
+ updateEntry(entry) {
13268
+ const nextPath = entryPath(entry);
13269
+ if (nextPath !== this.wikiPath) {
13270
+ this.server.setLocation({
13271
+ root: entry.root,
13272
+ name: entry.name
13273
+ });
13274
+ this.wikiPath = nextPath;
13275
+ this.resetClient();
13276
+ }
13277
+ this.entryValue = entry;
13278
+ }
13279
+ /** Re-apply the effective readiness window (next start/restart). */
13280
+ applyServerTuning() {
13281
+ this.server.setReadyTimeout(this.eff().startup?.readyTimeoutMs);
13282
+ }
13283
+ /**
13284
+ * Debounced auto-commit touch for this wiki's folder. Routed to the
13285
+ * REPOSITORY's committer (several wikis may share one repository).
13286
+ */
13287
+ touchAutoCommit() {
13288
+ this.options.touchCommit?.(this.wikiPath);
13289
+ }
13290
+ /** Flush this wiki's repository commit now (teardown / explicit sync). */
13291
+ async flushCommitter() {
13292
+ try {
13293
+ await this.options.flushCommits?.(this.wikiPath);
13294
+ } catch {}
13295
+ }
13296
+ /**
13297
+ * Arm the fs watcher for the CURRENT folder.
13298
+ *
13299
+ * The committer is NOT here any more (v0.28.0): it is per repository, in
13300
+ * host/repo-committers.ts, and this watcher only reports "something changed in
13301
+ * this wiki's folder" to it.
13302
+ */
13303
+ setupExtras() {
13304
+ this.unwatch = watchWiki(this.wikiPath, () => this.touchAutoCommit());
13305
+ }
13306
+ /** Release the fs watcher (the repository's committer outlives it). */
13307
+ async teardownExtras() {
13308
+ try {
13309
+ this.unwatch?.();
13310
+ } catch {}
13311
+ this.unwatch = void 0;
13312
+ }
13313
+ /**
13314
+ * Git bootstrap: repo init + initial commit + .gitignore (+ remote/first push).
13315
+ * Kept per-wiki in M1; M2 makes it repo-keyed (two wikis may share one repo).
13316
+ */
13317
+ async bootstrapGit() {
13318
+ const g = this.eff().git ?? {};
13319
+ const branch = g.branch ?? this.options.base.git.branch;
13320
+ const remote = g.remote ?? this.options.base.git.remote;
13321
+ if (!await this.options.git.isRepo(this.wikiPath)) {
13322
+ await this.options.git.init(this.wikiPath, branch);
13323
+ await writeGitignore(this.wikiPath);
13324
+ await this.options.git.initialCommit(this.wikiPath);
13325
+ } else await writeGitignore(this.wikiPath);
13326
+ if (remote.trim().length > 0) {
13327
+ const ensured = await this.options.git.ensureRemote(this.wikiPath, remote.trim());
13328
+ if (ensured.ok) {
13329
+ const first = await this.options.git.firstPush(this.wikiPath);
13330
+ if (!first.ok) this.log(`first push failed (retry with tiddlywiki_git_sync): ${first.message}`);
13331
+ } else this.log(`git remote setup: ${ensured.message}`);
13332
+ }
13333
+ }
13334
+ /**
13335
+ * Core-bootstrap this wiki: markdown parser plugin, the core + starter seeds,
13336
+ * and the configured UI language.
13337
+ *
13338
+ * Only `render-route` needs a TW restart (it carries a SERVER route loaded at
13339
+ * boot); plain-content seeds must NOT restart, or the syncer's unflushed REST
13340
+ * writes would be lost.
13341
+ */
13342
+ async bootstrapWiki() {
13343
+ const seedClient = this.client();
13344
+ if (seedClient === void 0) return;
13345
+ let pluginAdded = false;
13346
+ try {
13347
+ pluginAdded = await ensurePlugin(this.wikiPath, this.options.twRoot(), MARKDOWN_PLUGIN);
13348
+ if (pluginAdded) this.log(`enabled ${MARKDOWN_PLUGIN} for this wiki`);
13349
+ } catch (err) {
13350
+ this.log(`enabling ${MARKDOWN_PLUGIN}: ${err instanceof Error ? err.message : String(err)}`);
13351
+ }
13352
+ const seedStartedAt = Date.now();
13353
+ const results = await runAllSeeds({
13354
+ client: seedClient,
13355
+ tools: tiddlywikiToolSummary(),
13356
+ wechat: this.wechatEnabled(),
13357
+ proxyBase: this.options.proxyBase?.() ?? TW_PROXY_PATH
13358
+ });
13359
+ for (const r of results) if (!r.ok) this.log(`seed ${r.id} failed: ${r.error ?? r.detail}`);
13360
+ if (needsRestartAfterSeeds(results)) {
13361
+ 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");
13362
+ if (!await flushPendingWrites(seedClient, this.tiddlersDir)) this.log("seed writes may not have been flushed before restart");
13363
+ if (!this.disposed) await this.server.restart();
13364
+ } else if (pluginAdded) {
13365
+ if (!this.disposed) await this.server.restart();
13366
+ }
13367
+ const uiLang = this.eff().uiLanguage;
13368
+ if (typeof uiLang === "string" && uiLang.trim().length > 0) try {
13369
+ const code = uiLang.trim();
13370
+ if (await ensureLanguage(this.wikiPath, this.options.twRoot(), code) && !this.disposed) await this.server.restart();
13371
+ const langClient = this.client();
13372
+ if (langClient !== void 0) await pinLanguageTiddler(langClient, `$:/languages/${code}`, (message, err) => {
13373
+ if (err === void 0) this.log(message);
13374
+ else this.log(`pinning $:/language failed: ${err}`);
13375
+ }).catch(() => void 0);
13376
+ } catch (err) {
13377
+ this.log(`applying uiLanguage: ${err instanceof Error ? err.message : String(err)}`);
13378
+ }
13379
+ }
13380
+ /**
13381
+ * Bring this wiki up: spawn the child, load its config overlay, bootstrap it,
13382
+ * then arm the committer + watcher.
13383
+ *
13384
+ * Disposal-aware: teardown may run while this is awaiting (hot reload / dsh web
13385
+ * exit within the first seconds), so the child is stopped rather than orphaned.
13386
+ */
13387
+ async start() {
13388
+ if (this.disposed) throw new Error(`知识库「${this.entryValue.id}」已卸载,无法启动`);
13389
+ try {
13390
+ await this.server.start();
13391
+ if (this.disposed) {
13392
+ await this.server.stop().catch(() => void 0);
13393
+ return;
13394
+ }
13395
+ await this.config.load(this.client());
13396
+ this.applyServerTuning();
13397
+ await this.bootstrapWiki();
13398
+ } finally {
13399
+ if (!this.disposed) try {
13400
+ await this.bootstrapGit();
13401
+ if (!this.disposed) this.setupExtras();
13402
+ } catch (err) {
13403
+ this.log(`git bootstrap failed: ${err instanceof Error ? err.message : String(err)}`);
13404
+ }
13405
+ }
13406
+ }
13407
+ /**
13408
+ * Drain the syncer queue, then restart the child (ironclad rule #1). Returns
13409
+ * whether the store was provably quiescent, so callers can surface an
13410
+ * incomplete drain instead of losing writes silently.
13411
+ */
13412
+ async restart() {
13413
+ return drainThenStop({
13414
+ client: this.client(),
13415
+ tiddlersDir: this.tiddlersDir,
13416
+ stop: () => this.server.restart(),
13417
+ log: this.log
13418
+ });
13419
+ }
13420
+ /** Drain the syncer queue, then stop the child. */
13421
+ async drainStop() {
13422
+ return drainThenStop({
13423
+ client: this.client(),
13424
+ tiddlersDir: this.tiddlersDir,
13425
+ stop: () => this.server.stop(),
13426
+ log: this.log
13427
+ });
13428
+ }
13429
+ /** Drain + stop + release the extras. */
13430
+ async stop() {
13431
+ await this.drainStop();
13432
+ await this.teardownExtras();
13433
+ }
13434
+ /**
13435
+ * Deterministic teardown. Idempotent: the plugin disposer and a rollback path
13436
+ * may both call it.
13437
+ *
13438
+ * NOTE: this intentionally does NOT drain the syncer queue (the pre-v0.28
13439
+ * teardown did not either) — it flushes the git committer and stops the child.
13440
+ * Shutdown has a 5s grace in DSH, and a drain has its own bounded waits.
13441
+ */
13442
+ async dispose() {
13443
+ if (this.disposed) return;
13444
+ this.disposed = true;
13445
+ await this.flushCommitter();
13446
+ await this.teardownExtras();
13447
+ await this.server.stop().catch(() => void 0);
13448
+ }
13449
+ };
13450
+ //#endregion
13451
+ //#region src/host/wiki-farm.ts
13452
+ /**
13453
+ * The multi-wiki farm (v0.28.0) — owns "which knowledge bases should be
13454
+ * RUNNING", and reconciles the running set against the registry.
13455
+ *
13456
+ * WHY THIS IS A SEPARATE, PURE-ORCHESTRATION CLASS
13457
+ * ------------------------------------------------
13458
+ * The dangerous part of multi-wiki is not spawning a process; it is the
13459
+ * DIFF — what to start, what to stop, what to leave alone, and what must be
13460
+ * recycled because its folder changed. Getting that wrong either leaks TW
13461
+ * children (orphan processes holding ports) or silently drops a wiki the user
13462
+ * still has open.
13463
+ *
13464
+ * So the farm knows nothing about TiddlyWiki: `createRuntime` is injected, and
13465
+ * `scripts/verify-wiki-farm.mjs` drives the whole reconciliation with fakes that
13466
+ * only record the calls they received. The rules can then be asserted in
13467
+ * milliseconds instead of minutes.
13468
+ *
13469
+ * THE RUNNING-SET RULES
13470
+ * ---------------------
13471
+ * single mode → ONLY the default entry runs (the registry in single mode is
13472
+ * normally a one-entry registry synthesized from the legacy
13473
+ * pointer file, so this is defensive as well as correct).
13474
+ * multi mode → every entry with `autostart: true` runs at boot; anything
13475
+ * else starts on demand (`startEntry`) — e.g. when a human
13476
+ * opens that wiki's panel or a session is scoped to it.
13477
+ *
13478
+ * A FOLDER CHANGE RECYCLES THE RUNTIME. `WikiServer.setLocation()` refuses to
13479
+ * repoint while a child is running, and a running instance holds an fs watcher
13480
+ * + auto-committer on the OLD folder. Stopping and creating a fresh runtime is
13481
+ * the only sequence that cannot leave half the object describing wiki A while
13482
+ * the other half serves wiki B.
13483
+ *
13484
+ * ONE FAILURE MUST NOT TAKE THE FARM DOWN. A wiki that fails to start is KEPT
13485
+ * in the running map (its child may still be coming up — WikiServer arms a
13486
+ * late-ready watch) and the failure is reported in `FarmChange.errors`, so one
13487
+ * broken folder cannot stop the other knowledge bases from serving.
13488
+ *
13489
+ * @module dsh-tiddlywiki/host/wiki-farm
13490
+ */
13491
+ /**
13492
+ * `T` is the concrete runtime type: the host instantiates
13493
+ * `WikiFarm<WikiInstance>` so it can reach instance-only members (config store,
13494
+ * bootstrap, committer) without a cast; tests instantiate the default with fakes.
13495
+ */
13496
+ /**
13497
+ * The wiki id a request asks for (`?wiki=<id>`), or undefined when it asks for
13498
+ * none. Extracted so the host wiring and the verification harness read the
13499
+ * selector the SAME way — a second copy of "how do I parse ?wiki=" is exactly
13500
+ * the drift this repo keeps paying for.
13501
+ */
13502
+ function wikiIdFromRequest(req) {
13503
+ try {
13504
+ const id = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("wiki");
13505
+ return id !== null && id.length > 0 ? id : void 0;
13506
+ } catch {
13507
+ return;
13508
+ }
13509
+ }
13510
+ /**
13511
+ * Which runtime does this request target?
13512
+ *
13513
+ * `?wiki=<id>` naming a RUNNING wiki → that one
13514
+ * anything else (no selector, unknown id, wiki not running)
13515
+ * → the farm's default runtime
13516
+ *
13517
+ * An unknown id falls back instead of 404ing on purpose: the selector comes
13518
+ * from links the user may have bookmarked, and a stale one must not strand them
13519
+ * on a broken editor. It also means `?wiki=` can never be used to reach a wiki
13520
+ * that is not running — starting one is an explicit action, not a side effect
13521
+ * of a GET.
13522
+ */
13523
+ function targetRuntimeFor(farm, req) {
13524
+ const id = wikiIdFromRequest(req);
13525
+ if (id === void 0) return farm?.defaultRuntime();
13526
+ return farm?.runtime(id) ?? farm?.defaultRuntime();
13527
+ }
13528
+ function resolveAgentScope(farm, scopes, sessionId) {
13529
+ if (farm === void 0) return { reason: "知识库插件尚未就绪(还没读完清单)" };
13530
+ const registry = farm.registry;
13531
+ const byId = new Map(registry.wikis.map((entry) => [entry.id, entry]));
13532
+ if (sessionId !== void 0) {
13533
+ const wantedId = scopes[sessionId];
13534
+ const explicit = wantedId === void 0 ? void 0 : byId.get(wantedId);
13535
+ if (explicit !== void 0 && explicit.agentVisible) {
13536
+ const runtime = farm.runtime(explicit.id);
13537
+ if (runtime !== void 0) return {
13538
+ runtime,
13539
+ entry: explicit
13540
+ };
13541
+ return {
13542
+ entry: explicit,
13543
+ reason: `本会话作用域指定的知识库「${explicit.label}」当前没有运行,请先在界面上启动它再试`
13544
+ };
13545
+ }
13546
+ }
13547
+ const visible = registry.wikis.filter((entry) => entry.agentVisible);
13548
+ if (visible.length === 0) return { reason: "当前没有对 Agent 可见的知识库(可在设置里把某个库设为对 Agent 可见)" };
13549
+ const preferred = defaultEntry({
13550
+ ...registry,
13551
+ wikis: visible
13552
+ });
13553
+ const order = preferred === void 0 ? visible : [preferred, ...visible.filter((entry) => entry.id !== preferred.id)];
13554
+ for (const entry of order) {
13555
+ const runtime = farm.runtime(entry.id);
13556
+ if (runtime !== void 0) return {
13557
+ runtime,
13558
+ entry
13559
+ };
13560
+ }
13561
+ return {
13562
+ entry: order[0],
13563
+ reason: `知识库「${order[0]?.label ?? ""}」当前没有运行,请先在界面上启动它再试`
13564
+ };
13565
+ }
13566
+ var WikiFarm = class {
13567
+ options;
13568
+ registryValue;
13569
+ runtimes = /* @__PURE__ */ new Map();
13570
+ errors = [];
13571
+ log;
13572
+ constructor(registry, options) {
13573
+ this.options = options;
13574
+ this.registryValue = registry;
13575
+ this.log = options.log ?? ((message) => {
13576
+ console.warn("[dsh-tiddlywiki]", message);
13577
+ });
13578
+ }
13579
+ /** The registry the farm is currently reconciling against. */
13580
+ get registry() {
13581
+ return this.registryValue;
13582
+ }
13583
+ /** Ids with a live runtime (order = start order). */
13584
+ runningIds() {
13585
+ return [...this.runtimes.keys()];
13586
+ }
13587
+ /** The runtime for `id`, or undefined when it is not running. */
13588
+ runtime(id) {
13589
+ return this.runtimes.get(id);
13590
+ }
13591
+ /** Every running runtime, in start order (plugin-wide tuning walks this). */
13592
+ allRuntimes() {
13593
+ return [...this.runtimes.values()];
13594
+ }
13595
+ /**
13596
+ * The running wikis whose CONTENT a pull just touched (v0.28.0).
13597
+ *
13598
+ * `changedFiles` are REPOSITORY-relative (what `git diff --name-only` prints),
13599
+ * and a wiki is affected when the absolute form of one of them lands inside
13600
+ * its folder. With several wikis sharing one repository this is what keeps a
13601
+ * pull from restarting — and interrupting whoever is editing — a wiki nothing
13602
+ * happened to.
13603
+ */
13604
+ affectedBy(repoRoot, changedFiles) {
13605
+ if (changedFiles.length === 0) return [];
13606
+ const affected = [];
13607
+ for (const runtime of this.runtimes.values()) if (changedFiles.some((file) => isInsidePath(runtime.path, join(repoRoot, file)))) affected.push(runtime);
13608
+ return affected;
13609
+ }
13610
+ /**
13611
+ * The runtime legacy/agent traffic falls back to: the registry default when
13612
+ * it is running, else the first running one. Undefined = nothing is up.
13613
+ */
13614
+ defaultRuntime() {
13615
+ const preferred = defaultEntry(this.registryValue);
13616
+ if (preferred !== void 0) {
13617
+ const match = this.runtimes.get(preferred.id);
13618
+ if (match !== void 0) return match;
13619
+ }
13620
+ return this.runtimes.values().next().value;
13621
+ }
13622
+ /**
13623
+ * Adopt a registry WITHOUT reconciling the running set.
13624
+ *
13625
+ * The single-mode folder switch MUST keep `switchWiki()`'s rollback semantics
13626
+ * (stop → repoint → start → bootstrap, and on failure put the old wiki back),
13627
+ * so it performs the move itself and only needs the farm to agree afterwards.
13628
+ * Going through `apply()` there would reconcile a runtime the caller just
13629
+ * repointed — stopping and rebuilding the very wiki it had just started.
13630
+ */
13631
+ syncRegistry(next) {
13632
+ this.registryValue = next;
13633
+ }
13634
+ /** May this entry run AT ALL under the current mode? (removed / mode flip) */
13635
+ visible(entry) {
13636
+ if (this.registryValue.mode === "multi") return true;
13637
+ return entry.id === this.registryValue.defaultId;
13638
+ }
13639
+ /** Should this entry ATOMICALLY come up at boot (mode + autostart)? */
13640
+ shouldRun(entry) {
13641
+ if (!this.visible(entry)) return false;
13642
+ if (this.registryValue.mode === "single") return true;
13643
+ return entry.autostart === true;
13644
+ }
13645
+ /** Ids that boot with the plugin (settings page shows this as 「开局自启」). */
13646
+ autoStartIds() {
13647
+ return this.registryValue.wikis.filter((entry) => this.shouldRun(entry)).map((entry) => entry.id);
13648
+ }
13649
+ /**
13650
+ * BOOT: start every entry the mode/autostart asks for.
13651
+ *
13652
+ * Nothing is running yet, so there is nothing to reconcile — this IS the
13653
+ * "开局全起" rule, literally. `apply()` below is the config-CHANGE path and
13654
+ * deliberately does less.
13655
+ */
13656
+ async startAll() {
13657
+ const change = {
13658
+ started: [],
13659
+ stopped: [],
13660
+ updated: [],
13661
+ running: [],
13662
+ errors: []
13663
+ };
13664
+ this.errors = [];
13665
+ for (const entry of this.registryValue.wikis) {
13666
+ if (!this.shouldRun(entry)) continue;
13667
+ await this.startEntry(entry);
13668
+ change.started.push(entry.id);
13669
+ }
13670
+ change.running = this.runningIds();
13671
+ change.errors = [...this.errors];
13672
+ return change;
13673
+ }
13674
+ /**
13675
+ * Reconcile the running set against `next` — a CONFIG CHANGE (add / edit /
13676
+ * remove an entry, or flip the mode).
13677
+ *
13678
+ * The rules are deliberately the CONSERVATIVE ones, because a config edit is
13679
+ * not a boot:
13680
+ *
13681
+ * · an entry that is GONE, or hidden by a mode flip, must stop;
13682
+ * · an entry that is NEWLY ADDED and should auto-start does start;
13683
+ * · everything else keeps whatever running state it has —
13684
+ * turning `autostart` OFF does not kill a wiki the user has open
13685
+ * (`autostart` means "come up at boot"; the next boot honours the new
13686
+ * value), and re-saving an unrelated field never resurrects a wiki the
13687
+ * user stopped by hand;
13688
+ * · a folder change recycles the runtime (setLocation refuses while running).
13689
+ *
13690
+ * This was found by `scripts/verify-wiki-farm-boot.mjs`: re-asserting the boot
13691
+ * rule on every save meant "I started C by hand, then added a wiki" silently
13692
+ * stopped C. Explicit start/stop stay explicit (`startEntry`/`stopEntry`).
13693
+ */
13694
+ async apply(next) {
13695
+ const change = {
13696
+ started: [],
13697
+ stopped: [],
13698
+ updated: [],
13699
+ running: [],
13700
+ errors: []
13701
+ };
13702
+ this.errors = [];
13703
+ const previousIds = new Set(this.registryValue.wikis.map((entry) => entry.id));
13704
+ this.registryValue = next;
13705
+ const byId = new Map(next.wikis.map((entry) => [entry.id, entry]));
13706
+ for (const id of [...this.runtimes.keys()]) {
13707
+ const entry = byId.get(id);
13708
+ if (entry === void 0 || !this.visible(entry)) {
13709
+ await this.stopEntry(id);
13710
+ change.stopped.push(id);
13711
+ }
13712
+ }
13713
+ for (const entry of next.wikis) {
13714
+ const runtime = this.runtimes.get(entry.id);
13715
+ if (runtime === void 0) {
13716
+ if (!previousIds.has(entry.id) && this.shouldRun(entry)) {
13717
+ await this.startEntry(entry);
13718
+ change.started.push(entry.id);
13719
+ }
13720
+ continue;
13721
+ }
13722
+ if (runtime.path !== entryPath(entry)) {
13723
+ await this.stopEntry(entry.id);
13724
+ change.stopped.push(entry.id);
13725
+ if (this.shouldRun(entry)) {
13726
+ await this.startEntry(entry);
13727
+ change.started.push(entry.id);
13728
+ }
13729
+ continue;
13730
+ }
13731
+ runtime.updateEntry(entry);
13732
+ change.updated.push(entry.id);
13733
+ }
13734
+ change.running = this.runningIds();
13735
+ change.errors = [...this.errors];
13736
+ return change;
13737
+ }
13738
+ /**
13739
+ * Start ONE wiki on demand (a panel was opened, a session was scoped to it, a
13740
+ * route named it). No-op when it is already running.
13741
+ */
13742
+ async startEntry(entry) {
13743
+ if (this.runtimes.has(entry.id)) return;
13744
+ let runtime;
13745
+ try {
13746
+ runtime = this.options.createRuntime(entry);
13747
+ } catch (err) {
13748
+ this.record(entry.id, err);
13749
+ return;
13750
+ }
13751
+ this.runtimes.set(entry.id, runtime);
13752
+ try {
13753
+ await runtime.start();
13754
+ } catch (err) {
13755
+ this.record(entry.id, err);
13756
+ }
13757
+ }
13758
+ /** Stop one wiki (drain + stop) and release its runtime. No-op when absent. */
13759
+ async stopEntry(id) {
13760
+ const runtime = this.runtimes.get(id);
13761
+ if (runtime === void 0) return;
13762
+ this.runtimes.delete(id);
13763
+ try {
13764
+ if (runtime.isDisposed) return;
13765
+ await runtime.stop();
13766
+ } catch (err) {
13767
+ this.record(id, err);
13768
+ } finally {
13769
+ try {
13770
+ await runtime.dispose();
13771
+ } catch (err) {
13772
+ this.record(id, err);
13773
+ }
13774
+ }
13775
+ }
13776
+ /**
13777
+ * Release every runtime (plugin teardown / disable). Idempotent, and it keeps
13778
+ * going after a failure so one wedged wiki cannot leave the others running.
13779
+ */
13780
+ async disposeAll() {
13781
+ for (const id of [...this.runtimes.keys()]) await this.stopEntry(id);
13782
+ }
13783
+ record(id, err) {
13784
+ const message = err instanceof Error ? err.message : String(err);
13785
+ this.errors.push({
13786
+ id,
13787
+ message
13788
+ });
13789
+ this.log(`知识库「${id}」:${message}`);
13790
+ }
13791
+ };
13792
+ //#endregion
13793
+ //#region src/host/session-scope.ts
13794
+ /**
13795
+ * Per-SESSION knowledge-base scope (v0.28.0) — which wiki a conversation works on.
13796
+ *
13797
+ * WHY A FILE OUTSIDE EVERY WIKI
13798
+ * -----------------------------
13799
+ * Same reason as the registry and the legacy pointer: the answer must not live
13800
+ * inside any single wiki (it would be unreadable while deciding which wiki to
13801
+ * talk to) and it must SURVIVE a `dsh web` restart — sessions are durable, so
13802
+ * "which knowledge base is this conversation using" has to be too.
13803
+ *
13804
+ * $DSH_HOME/dsh-tiddlywiki/sessions.json
13805
+ * { "version": 1, "sessions": { "<sessionId>": { "wikiId": "work", "at": "…" } } }
13806
+ *
13807
+ * FAIL SOFT, NEVER LOUD
13808
+ * ---------------------
13809
+ * This is a PREFERENCE, not configuration: a missing, malformed or unreadable
13810
+ * file simply means "no explicit scope" and every session falls back to the
13811
+ * farm's default wiki. It therefore never blocks anything and never throws —
13812
+ * unlike the registry, where a malformed file must be reported because it
13813
+ * decides WHERE the data lives.
13814
+ *
13815
+ * Stale entries are pruned on write (`SESSION_SCOPE_MAX_AGE_MS`): sessions come
13816
+ * and go, and this file has no other bound.
13817
+ *
13818
+ * @module dsh-tiddlywiki/host/session-scope
13819
+ */
13820
+ /** Pointer-file schema version (bumped only on an incompatible change). */
13821
+ const SESSION_SCOPE_VERSION = 1;
13822
+ /** How long an untouched session→wiki mapping is kept (pruned on write). */
13823
+ const SESSION_SCOPE_MAX_AGE_MS = 2160 * 60 * 60 * 1e3;
13824
+ /** Default file: `$DSH_HOME/dsh-tiddlywiki/sessions.json`. */
13825
+ function defaultSessionScopeFile() {
13826
+ return dshHomePath("dsh-tiddlywiki", "sessions.json");
13827
+ }
13828
+ /**
13829
+ * Is this a usable session id?
13830
+ *
13831
+ * Session ids reach here from HTTP (the GUI selector) and are used as JSON keys,
13832
+ * so they are validated rather than trusted. The charset mirrors the one the
13833
+ * session-summary route already enforces.
13834
+ */
13835
+ function isSafeSessionId(id) {
13836
+ return typeof id === "string" && /^[A-Za-z0-9._:-]{1,120}$/.test(id);
13837
+ }
13838
+ /** Is this a usable wiki id? (Shape only — the registry decides existence.) */
13839
+ function isSafeWikiId(id) {
13840
+ return typeof id === "string" && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(id);
13841
+ }
13842
+ /** Drop mappings older than `maxAgeMs` (or with an unusable timestamp). */
13843
+ function pruneScopes(state, now, maxAgeMs = SESSION_SCOPE_MAX_AGE_MS) {
13844
+ const sessions = {};
13845
+ for (const [sessionId, entry] of Object.entries(state.sessions)) {
13846
+ const at = Date.parse(entry.at);
13847
+ if (!Number.isFinite(at)) continue;
13848
+ if (now - at > maxAgeMs) continue;
13849
+ sessions[sessionId] = entry;
13850
+ }
13851
+ return {
13852
+ version: 1,
13853
+ sessions
13854
+ };
13855
+ }
13856
+ /** Parse a stored document into the loose shape, dropping anything unusable. */
13857
+ function parseState(raw) {
13858
+ let parsed;
13859
+ try {
13860
+ parsed = JSON.parse(raw);
13861
+ } catch {
13862
+ return { error: "会话作用域文件不是合法 JSON" };
13863
+ }
13864
+ if (typeof parsed !== "object" || parsed === null) return { error: "会话作用域文件格式不对" };
13865
+ const sessionsRaw = parsed.sessions;
13866
+ if (typeof sessionsRaw !== "object" || sessionsRaw === null) return { error: "会话作用域文件缺少 sessions 字段" };
13867
+ const sessions = {};
13868
+ let skipped = 0;
13869
+ for (const [sessionId, value] of Object.entries(sessionsRaw)) {
13870
+ if (!isSafeSessionId(sessionId)) {
13871
+ skipped += 1;
13872
+ continue;
13873
+ }
13874
+ const entry = value;
13875
+ if (entry === null || typeof entry !== "object" || !isSafeWikiId(entry.wikiId) || typeof entry.at !== "string") {
13876
+ skipped += 1;
13877
+ continue;
13878
+ }
13879
+ sessions[sessionId] = {
13880
+ wikiId: entry.wikiId,
13881
+ at: entry.at
13882
+ };
13883
+ }
13884
+ return {
13885
+ state: {
13886
+ version: 1,
13887
+ sessions
13888
+ },
13889
+ ...skipped > 0 ? { error: `会话作用域文件里有 ${skipped} 条无效记录,已忽略` } : {}
13890
+ };
13891
+ }
13892
+ /**
13893
+ * Read the mappings. Never throws: an unreadable/malformed file yields `{}` plus
13894
+ * an explanation (the caller may log it; nothing depends on this file).
13895
+ */
13896
+ async function readSessionScopes(file = defaultSessionScopeFile()) {
13897
+ let raw;
13898
+ try {
13899
+ raw = await readFile(file, "utf8");
13900
+ } catch (err) {
13901
+ if (err.code === "ENOENT") return { scopes: {} };
13902
+ return {
13903
+ scopes: {},
13904
+ error: `会话作用域文件读不到(${file}):${err instanceof Error ? err.message : String(err)}`
13905
+ };
13906
+ }
13907
+ const parsed = parseState(raw);
13908
+ if (parsed.state === void 0) return {
13909
+ scopes: {},
13910
+ ...parsed.error !== void 0 ? { error: parsed.error } : {}
13911
+ };
13912
+ const scopes = {};
13913
+ for (const [sessionId, entry] of Object.entries(parsed.state.sessions)) scopes[sessionId] = entry.wikiId;
13914
+ return {
13915
+ scopes,
13916
+ ...parsed.error !== void 0 ? { error: parsed.error } : {}
13917
+ };
13918
+ }
13919
+ /** Write the state atomically (tmp + rename, mkdir -p). */
13920
+ async function writeSessionScopes(state, file = defaultSessionScopeFile()) {
13921
+ const payload = JSON.stringify({
13922
+ ...state,
13923
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
13924
+ }, null, 2);
13925
+ await mkdir(dirname(file), { recursive: true });
13926
+ const tmp = `${file}.tmp`;
13927
+ await writeFile(tmp, payload, "utf8");
13928
+ await rename(tmp, file);
13929
+ }
13930
+ /**
13931
+ * Point one session at a wiki, or clear its scope (`wikiId === undefined`).
13932
+ *
13933
+ * Reads, prunes and writes in one go — the file is tiny and this happens once
13934
+ * per selection, so a read-modify-write is cheaper than any locking scheme.
13935
+ * A write failure is REPORTED (the caller surfaces it) but changes nothing else:
13936
+ * the session simply keeps falling back to the default.
13937
+ */
13938
+ async function setSessionScope(sessionId, wikiId, file = defaultSessionScopeFile()) {
13939
+ if (!isSafeSessionId(sessionId)) throw new Error(`会话 id 非法:${JSON.stringify(sessionId)}`);
13940
+ if (wikiId !== void 0 && !isSafeWikiId(wikiId)) throw new Error(`知识库 id 非法:${JSON.stringify(wikiId)}`);
13941
+ const existing = await readFile(file, "utf8").catch(() => "");
13942
+ const pruned = pruneScopes(existing.length > 0 ? parseState(existing).state ?? {
13943
+ version: 1,
13944
+ sessions: {}
13945
+ } : {
13946
+ version: 1,
13947
+ sessions: {}
13948
+ }, Date.now());
13949
+ if (wikiId === void 0) delete pruned.sessions[sessionId];
13950
+ else pruned.sessions[sessionId] = {
13951
+ wikiId,
13952
+ at: (/* @__PURE__ */ new Date()).toISOString()
13953
+ };
13954
+ await writeSessionScopes(pruned, file);
13955
+ }
13956
+ //#endregion
13957
+ //#region src/host/repo-committers.ts
13958
+ /**
13959
+ * ONE debounced auto-committer per REPOSITORY (v0.28.0).
13960
+ *
13961
+ * WHY NOT ONE PER WIKI
13962
+ * --------------------
13963
+ * Two knowledge bases may be subfolders of one repository (the "工作 + 个人 共用现有
13964
+ * 仓库、按文件夹物理划分" topology). Git then has ONE index, and `git add -A` run
13965
+ * from ANY subdirectory stages the WHOLE working tree — so two per-folder
13966
+ * committers would each commit the other's changes, and one of them would
13967
+ * forever report `nothing to commit`. The unit that can safely commit is the
13968
+ * REPOSITORY, so that is the unit this class keys on.
13969
+ *
13970
+ * It also decides where the commit runs: at the REPOSITORY ROOT, never in a
13971
+ * wiki subfolder (`commit(dir)` runs `git add -A` with `cwd = dir`).
13972
+ *
13973
+ * THE DIR → REPO LOOKUP IS ASYMMETRICALLY CACHED
13974
+ * ----------------------------------------------
13975
+ * A POSITIVE answer (this folder belongs to repo X) is cached forever. A
13976
+ * NEGATIVE one is not cached at all: `git init` happens moments after the first
13977
+ * touch in a fresh install, and a cached "not a repo" would silently disable
13978
+ * auto-commit for the rest of the session — exactly the class of bug this
13979
+ * repository has paid for more than once.
13980
+ *
13981
+ * @module dsh-tiddlywiki/host/repo-committers
13982
+ */
13983
+ var RepoCommitters = class {
13984
+ options;
13985
+ /** Keyed by `pathComparisonKey(repoRoot)`. */
13986
+ repos = /* @__PURE__ */ new Map();
13987
+ /** Positive `dir → repoRoot` cache only (see the module header). */
13988
+ dirToRepo = /* @__PURE__ */ new Map();
13989
+ log;
13990
+ constructor(options) {
13991
+ this.options = options;
13992
+ this.log = options.log ?? ((message) => {
13993
+ console.warn("[dsh-tiddlywiki]", message);
13994
+ });
13995
+ }
13996
+ /** Repositories with a live committer (diagnostics / tests). */
13997
+ repoRoots() {
13998
+ return [...this.repos.values()].map((state) => state.repoRoot);
13999
+ }
14000
+ /** The repository root owning `dir`, or undefined when it is not in one. */
14001
+ async repoRootOf(dir) {
14002
+ const key = pathComparisonKey(dir);
14003
+ const cached = this.dirToRepo.get(key);
14004
+ if (cached !== void 0) return cached;
14005
+ const root = await this.options.git.repoRoot(dir);
14006
+ if (root !== void 0) this.dirToRepo.set(key, root);
14007
+ return root;
14008
+ }
14009
+ /**
14010
+ * Debounced commit for the repository owning `dir`.
14011
+ *
14012
+ * Fire-and-forget friendly (the fs watcher calls it synchronously): the first
14013
+ * call for a folder pays one `git rev-parse`, every later one is a Map hit.
14014
+ */
14015
+ async touch(dir) {
14016
+ (await this.stateFor(dir))?.committer.touch();
14017
+ }
14018
+ /** Commit now. Omit `dir` to flush every repository (teardown / switch). */
14019
+ async flush(dir) {
14020
+ if (dir === void 0) {
14021
+ for (const state of this.repos.values()) try {
14022
+ await state.committer.flush();
14023
+ } catch {}
14024
+ return;
14025
+ }
14026
+ const root = await this.repoRootOf(dir);
14027
+ if (root === void 0) return;
14028
+ try {
14029
+ await this.repos.get(pathComparisonKey(root))?.committer.flush();
14030
+ } catch {}
14031
+ }
14032
+ /**
14033
+ * Rebuild every committer from the CURRENT settings (v0.25.0's "git.* takes
14034
+ * effect on the running plugin"): `AutoCommitter` snapshots `enabled` and
14035
+ * `debounceMs` at construction, so a settings-page save has to rebuild.
14036
+ */
14037
+ rebuild() {
14038
+ for (const [key, state] of [...this.repos]) {
14039
+ state.committer.dispose();
14040
+ this.repos.set(key, {
14041
+ repoRoot: state.repoRoot,
14042
+ committer: this.create(state.repoRoot)
14043
+ });
14044
+ }
14045
+ }
14046
+ /** Release every committer (plugin teardown). */
14047
+ dispose() {
14048
+ for (const state of this.repos.values()) state.committer.dispose();
14049
+ this.repos.clear();
14050
+ this.dirToRepo.clear();
14051
+ }
14052
+ create(repoRoot) {
14053
+ const settings = this.options.settings(repoRoot);
14054
+ return new AutoCommitter({
14055
+ git: this.options.git,
14056
+ dir: repoRoot,
14057
+ enabled: settings.autoCommit,
14058
+ debounceMs: settings.debounceMs,
14059
+ message: this.options.message,
14060
+ onError: (err) => this.log(`autocommit(${repoRoot}): ${err instanceof Error ? err.message : String(err)}`)
14061
+ });
14062
+ }
14063
+ async stateFor(dir) {
14064
+ const root = await this.repoRootOf(dir);
14065
+ if (root === void 0) return void 0;
14066
+ const key = pathComparisonKey(root);
14067
+ const existing = this.repos.get(key);
14068
+ if (existing !== void 0) return existing;
14069
+ const state = {
14070
+ repoRoot: root,
14071
+ committer: this.create(root)
14072
+ };
14073
+ this.repos.set(key, state);
14074
+ this.log(`自动提交按仓库分组:${root}`);
14075
+ return state;
14076
+ }
14077
+ };
14078
+ //#endregion
14079
+ //#region src/host/skill-install.ts
14080
+ /**
14081
+ * Ship the "split my wiki" skill into the user's skill root (v0.28.0).
14082
+ *
14083
+ * WHY A SKILL, AND WHY HERE
14084
+ * -------------------------
14085
+ * The feature the plugin needs to explain — "how do I split my existing wiki into
14086
+ * several" — is a multi-step conversation with a human, not a setting. DSH's
14087
+ * container for exactly that is a skill: the agent loads it on demand, it costs
14088
+ * nothing in the prompt budget when unused, and it lives in a directory the skill
14089
+ * provider already scans (`$DSH_HOME/skills`).
14090
+ *
14091
+ * The plugin CANNOT register a skill root (a cordis patch cannot compute install
14092
+ * paths), so the only zero-config path is writing the file into the user's root.
14093
+ *
14094
+ * NEVER CLOBBER ANYTHING THAT IS NOT OURS
14095
+ * ---------------------------------------
14096
+ * The shipped file carries a marker line (`dsh-tiddlywiki-skill:wiki-split`). This
14097
+ * installer writes only when the target is MISSING, IDENTICAL (no-op), or OURS
14098
+ * (marker present → update it). A file without the marker is left alone and the
14099
+ * result says so — the alternative (overwriting whatever is at that path) would
14100
+ * silently destroy a user's own skill the day they happen to pick our name.
14101
+ *
14102
+ * @module dsh-tiddlywiki/host/skill-install
14103
+ */
14104
+ /** Directory name under the skill root (also the skill's stable identity). */
14105
+ const SPLIT_SKILL_DIR = "dsh-tiddlywiki-wiki-split";
14106
+ /** File name inside that directory (the skill provider's discovery contract). */
14107
+ const SKILL_FILE_NAME = "SKILL.md";
14108
+ /**
14109
+ * Marker proving a file at our target path is OURS. Present in the shipped
14110
+ * SKILL.md; a user who edits the skill can delete the line to take ownership
14111
+ * (and then the installer will never touch it again).
14112
+ */
14113
+ const SPLIT_SKILL_MARKER = "dsh-tiddlywiki-skill:wiki-split";
14114
+ /** Where the file lives once installed: `$DSH_HOME/skills/<dir>/SKILL.md`. */
14115
+ function defaultSkillRoot() {
14116
+ return dshHomePath("skills");
14117
+ }
14118
+ /**
14119
+ * Read the skill that ships inside this package.
14120
+ *
14121
+ * Two candidates because the same module runs from two places: `lib/index.js`
14122
+ * (published / loaded) has `skills/` as a SIBLING, while `src/host/*.ts` (tests
14123
+ * via tsx) sits two levels down. Returns undefined when neither exists.
14124
+ */
14125
+ async function readBundledSkillText() {
14126
+ for (const url of [new URL("../skills/wiki-split/SKILL.md", import.meta.url), new URL("../../skills/wiki-split/SKILL.md", import.meta.url)]) try {
14127
+ return await readFile(fileURLToPath(url), "utf8");
14128
+ } catch {}
14129
+ }
14130
+ /**
14131
+ * Install (or refresh) the split-wiki skill. Never throws: every problem comes
14132
+ * back as `action: 'failed'` with a message, because a skill that could not be
14133
+ * written must not take the plugin's startup down with it.
14134
+ */
14135
+ async function installSplitSkill(options = {}) {
14136
+ const path = join(options.root ?? defaultSkillRoot(), SPLIT_SKILL_DIR, SKILL_FILE_NAME);
14137
+ const text = options.text ?? await readBundledSkillText();
14138
+ if (text === void 0 || text.trim().length === 0) return {
14139
+ path,
14140
+ action: "failed",
14141
+ error: "找不到随包提供的 SKILL.md(package 里缺 skills/wiki-split/SKILL.md)"
14142
+ };
14143
+ let existing;
14144
+ try {
14145
+ existing = await readFile(path, "utf8");
14146
+ } catch (err) {
14147
+ if (err.code !== "ENOENT") return {
14148
+ path,
14149
+ action: "failed",
14150
+ error: `读不到 ${path}:${err instanceof Error ? err.message : String(err)}`
14151
+ };
14152
+ }
14153
+ if (existing !== void 0) {
14154
+ if (existing === text) return {
14155
+ path,
14156
+ action: "kept",
14157
+ detail: "已是最新"
14158
+ };
14159
+ if (!existing.includes("dsh-tiddlywiki-skill:wiki-split")) return {
14160
+ path,
14161
+ action: "kept",
14162
+ detail: "该路径已有你自己的文件(没有插件标记),未覆盖"
14163
+ };
14164
+ }
14165
+ try {
14166
+ await mkdir(dirname(path), { recursive: true });
14167
+ const tmp = `${path}.tmp`;
14168
+ await writeFile(tmp, text, "utf8");
14169
+ await rename(tmp, path);
14170
+ } catch (err) {
14171
+ return {
14172
+ path,
14173
+ action: "failed",
14174
+ error: err instanceof Error ? err.message : String(err)
14175
+ };
14176
+ }
14177
+ return existing === void 0 ? {
14178
+ path,
14179
+ action: "written",
14180
+ detail: "已安装「拆分知识库」skill"
14181
+ } : {
14182
+ path,
14183
+ action: "updated",
14184
+ detail: "已更新到随包版本"
14185
+ };
14186
+ }
14187
+ //#endregion
14188
+ //#region src/index.ts
14189
+ /** Cordis plugin name (also the client loader id / profile row id). */
14190
+ const name = "dsh-tiddlywiki";
14191
+ /** Required host services (tool registry + prompt assembly). */
14192
+ const inject = ["tools", "systemPrompt"];
14193
+ /** 剪藏桥默认端口(与 seed 文档书签代码里的地址保持一致)。 */
14194
+ const CLIP_BRIDGE_DEFAULT_PORT = 8618;
14195
+ const DEFAULTS = {
14196
+ wikiRoot: "",
14197
+ wiki: "main",
14198
+ port: 0,
14199
+ git: {
14200
+ autoCommit: true,
14201
+ debounceMs: 6e4,
14202
+ remote: "",
14203
+ branch: "main"
14204
+ },
14205
+ note: {
14206
+ tag: "inbox",
14207
+ workspaceMark: true
14208
+ },
14209
+ bridge: {
14210
+ enabled: false,
14211
+ port: CLIP_BRIDGE_DEFAULT_PORT,
14212
+ token: "",
14213
+ tag: "clip"
14214
+ },
14215
+ ui: {
14216
+ showQuickNote: true,
14217
+ showQuickNoteDock: true,
14218
+ quickNoteMode: "native",
14219
+ sidebarLabel: "TiddlyWiki",
14220
+ showPanelStatus: true,
14221
+ showSyncButton: true,
14222
+ followDshTheme: true,
14223
+ darkPalette: DARK_PALETTE_DEFAULT,
14224
+ tabLabel: "知识库",
14225
+ showSessionTab: true,
14226
+ showRightbarTab: true,
14227
+ sendToAgent: { enabled: true },
14228
+ allArticles: { pageSize: 10 }
14229
+ },
14230
+ startup: { readyTimeoutMs: READY_TIMEOUT_DEFAULT_MS },
14231
+ wechat: {
14232
+ enabled: false,
14233
+ command: "opencli",
14234
+ token: "",
14235
+ adapter: "publish-note",
14236
+ dsn: ""
14237
+ },
14238
+ uiLanguage: "",
14239
+ auth: {
14240
+ username: "",
14241
+ password: ""
14242
+ }
14243
+ };
12234
14244
  /**
12235
- * Wait until a file exists on disk (polling), up to `timeoutMs`. TW's syncer
12236
- * flushes REST writes to the filesystem on a ~250ms task timer, so a freshly
12237
- * seeded tiddler is not on disk the moment PUT resolves — a caller that must
12238
- * restart TW right after (so a seeded server route loads) has to wait for the
12239
- * flush first, or the restarted TW would boot from a stale snapshot and the
12240
- * seeded module would be missing.
12241
- *
12242
- * The implementation lives in host/seeds.ts (shared with the settings page's
12243
- * seed-run route), which is where `waitForFileWrite` is imported from.
14245
+ * Resolve the DEFAULT wikiRoot: explicit config (env-expanded) else
14246
+ * $DSH_HOME/tiddlywiki. The runtime pointer file can override it — see
14247
+ * `readLocationState()` in host/wiki-location.ts.
12244
14248
  */
14249
+ function resolveWikiRoot(config) {
14250
+ if (config.wikiRoot !== void 0 && config.wikiRoot.trim().length > 0) return expandEnvPath(config.wikiRoot.trim());
14251
+ return dshHomePath("tiddlywiki");
14252
+ }
12245
14253
  /**
12246
14254
  * Mount the host half.
12247
14255
  * @param ctx - the plugin context (tools + systemPrompt injected).
@@ -12290,11 +14298,12 @@ function apply(ctx, rawConfig = {}) {
12290
14298
  ...rawConfig.wechat ?? {}
12291
14299
  }
12292
14300
  };
12293
- let wikiPath = join(config.wikiRoot, config.wiki);
12294
- /** Pointer file the runtime switch persists (outside every wiki, see wiki-location.ts). */
14301
+ /** Pointer file the LEGACY single-wiki switch persists (wiki-location.ts). */
12295
14302
  const locationStateFile = defaultLocationStateFile();
12296
- const git = new GitFace();
12297
- const configStore = new ConfigStore({
14303
+ /** The multi-wiki control file: mode + the wiki list + the default id. */
14304
+ const registryFile = defaultRegistryFile();
14305
+ /** The cordis `config:` block, as the base layer of every wiki's config. */
14306
+ const baseShape = {
12298
14307
  note: config.note,
12299
14308
  git: config.git,
12300
14309
  ui: config.ui,
@@ -12302,56 +14311,77 @@ function apply(ctx, rawConfig = {}) {
12302
14311
  bridge: config.bridge,
12303
14312
  startup: config.startup,
12304
14313
  wechat: config.wechat
12305
- });
12306
- const eff = () => configStore.get();
12307
- const effectiveNoteTag = () => {
12308
- const tag = eff().note?.tag;
12309
- return typeof tag === "string" && tag.trim().length > 0 ? tag : config.note.tag;
12310
14314
  };
14315
+ /** The cordis-level default location (last resort in the registry chain). */
14316
+ const defaultLocation = {
14317
+ root: config.wikiRoot,
14318
+ name: config.wiki
14319
+ };
14320
+ const git = new GitFace();
12311
14321
  /**
12312
- * Whether agent-created notes get the automatic workspace marker (v0.24.0):
12313
- * tag `ws/<project>` + field `workspace`. Default ON (cordis default true);
12314
- * the settings-page overlay can turn it off, and an explicit `false` there wins.
14322
+ * Auto-commit is keyed by REPOSITORY, not by wiki (v0.28.0): several knowledge
14323
+ * bases may be subfolders of one repository, and only one `git add -A` per
14324
+ * repository may run (see host/repo-committers.ts).
12315
14325
  */
12316
- const effectiveWorkspaceMark = () => {
12317
- const value = eff().note?.workspaceMark;
12318
- return typeof value === "boolean" ? value : config.note.workspaceMark;
12319
- };
14326
+ const repos = new RepoCommitters({
14327
+ git,
14328
+ /**
14329
+ * Per REPOSITORY (v0.28.0): several knowledge bases may share one repository,
14330
+ * so the setting is resolved from the first wiki INSIDE that repository (in
14331
+ * list order — deterministic), falling back to the default wiki and then to
14332
+ * the cordis base.
14333
+ *
14334
+ * Wikis in one repository disagreeing about `git.*` is almost always a
14335
+ * mistake (a repository has ONE remote, ONE branch, ONE index), so it is
14336
+ * called out rather than silently resolved.
14337
+ */
14338
+ settings: (repoRoot) => {
14339
+ const members = (farm?.registry.wikis ?? []).filter((entry) => entryIsInRepo(entry, repoRoot));
14340
+ const chosen = members[0];
14341
+ const runtimeConfig = (entry) => entry === void 0 ? void 0 : farm?.runtime(entry.id)?.eff();
14342
+ const g = (runtimeConfig(chosen) ?? eff()).git ?? {};
14343
+ if (members.length > 1) {
14344
+ if (new Set(members.map((entry) => JSON.stringify(runtimeConfig(entry)?.git ?? {}))).size > 1) console.warn(`[dsh-tiddlywiki] 知识库 ${members.map((entry) => entry.id).join("、")} 共用同一个仓库(${repoRoot}),但各自配了不同的 git.* —— 一个仓库只有一份 git 设置,本次以「${chosen?.id ?? "默认库"}」为准`);
14345
+ }
14346
+ return {
14347
+ autoCommit: g.autoCommit ?? config.git.autoCommit,
14348
+ debounceMs: g.debounceMs ?? config.git.debounceMs
14349
+ };
14350
+ },
14351
+ message: () => `wiki autocommit ${(/* @__PURE__ */ new Date()).toISOString()}`,
14352
+ log: (message) => console.warn("[dsh-tiddlywiki]", message)
14353
+ });
14354
+ /** Created by the startup task, once the control file has been read. */
14355
+ let farm;
14356
+ /** The runtime legacy/agent traffic falls back to (undefined = nothing up). */
14357
+ const defaultInstance = () => farm?.defaultRuntime();
14358
+ const eff = () => defaultInstance()?.eff() ?? baseShape;
14359
+ const effectiveWorkspaceMark = () => defaultInstance()?.workspaceMark() ?? config.note.workspaceMark;
14360
+ const effectiveBridge = () => defaultInstance()?.bridgeConfig() ?? config.bridge;
14361
+ /** sessionId → wikiId (v0.28.0). Loaded at boot, written by the GUI selector. */
14362
+ let sessionScopes = {};
14363
+ /** File the session scopes live in (outside every wiki, see session-scope.ts). */
14364
+ const sessionScopeFile = defaultSessionScopeFile();
12320
14365
  /**
12321
- * Effective bridge config (defaults + settings-page overlay). Used PER
12322
- * REQUEST by the clip bridge — enabled/token/tag edits on the settings page
12323
- * apply immediately; only the port is fixed at startup (listener binds once).
14366
+ * What the AGENT may act on for one session: the client, the wiki's identity
14367
+ * and — when there is none — an actionable reason.
14368
+ *
14369
+ * `ambiguous` is true when more than one knowledge base is visible; that is
14370
+ * what makes every tool result (and the injected prompt) name the wiki. With a
14371
+ * single visible wiki nothing changes, so existing installs see no difference.
12324
14372
  */
12325
- const effectiveBridge = () => {
12326
- const b = eff().bridge ?? {};
12327
- const port = typeof b.port === "number" && Number.isInteger(b.port) && b.port > 0 && b.port < 65536 ? b.port : config.bridge.port;
12328
- const token = typeof b.token === "string" ? b.token : "";
12329
- const tag = typeof b.tag === "string" && b.tag.trim().length > 0 ? b.tag.trim() : config.bridge.tag;
12330
- return {
12331
- enabled: b.enabled === true,
12332
- port,
12333
- token,
12334
- tag
12335
- };
12336
- };
12337
- const effectiveUi = () => {
12338
- const ui = eff().ui ?? {};
12339
- const palette = typeof ui.darkPalette === "string" && ui.darkPalette.trim().length > 0 ? ui.darkPalette.trim() : DARK_PALETTE_DEFAULT;
12340
- const label = typeof ui.sidebarLabel === "string" && ui.sidebarLabel.trim().length > 0 ? ui.sidebarLabel.trim() : config.ui.sidebarLabel;
12341
- const tabLabel = typeof ui.tabLabel === "string" && ui.tabLabel.trim().length > 0 ? ui.tabLabel.trim() : config.ui.tabLabel;
12342
- const quickNoteMode = ui.quickNoteMode === "card" ? "card" : "native";
14373
+ const toolScope = (sessionId) => {
14374
+ const resolution = resolveAgentScope(farm, sessionScopes, sessionId);
14375
+ const visible = farm?.registry.wikis.filter((entry) => entry.agentVisible).length ?? 0;
14376
+ const client = resolution.runtime?.client();
12343
14377
  return {
12344
- showQuickNote: ui.showQuickNote !== false,
12345
- showQuickNoteDock: ui.showQuickNoteDock !== false,
12346
- quickNoteMode,
12347
- sidebarLabel: label,
12348
- showPanelStatus: ui.showPanelStatus !== false,
12349
- showSyncButton: ui.showSyncButton !== false,
12350
- followDshTheme: ui.followDshTheme !== false,
12351
- darkPalette: palette,
12352
- tabLabel,
12353
- showSessionTab: ui.showSessionTab !== false,
12354
- showRightbarTab: ui.showRightbarTab !== false
14378
+ ...client !== void 0 ? { client } : {},
14379
+ ...resolution.entry !== void 0 ? {
14380
+ id: resolution.entry.id,
14381
+ label: resolution.entry.label
14382
+ } : {},
14383
+ ...client === void 0 ? { reason: resolution.reason ?? "知识库服务尚未就绪,请稍后重试" } : {},
14384
+ ambiguous: visible > 1
12355
14385
  };
12356
14386
  };
12357
14387
  const disposers = [];
@@ -12382,21 +14412,38 @@ function apply(ctx, rawConfig = {}) {
12382
14412
  let disposePromptSection;
12383
14413
  let currentPromptText;
12384
14414
  /**
12385
- * Built text for the current effective config (also the saved-state preview).
12386
- * The signature catalogue for `full` mode comes from the live tool registry,
12387
- * never from hand-written prose (v0.21.0 — the old copy had drifted).
14415
+ * The prompt section's text for ONE session.
14416
+ *
14417
+ * It is a FUNCTION (v0.28.0): the section is registered once, but the text is
14418
+ * evaluated per assembly with that assembly's agent, so a session scoped to
14419
+ * wiki B is told it is working on wiki B. Single-wiki installs get exactly the
14420
+ * previous text — the scope line appears only when more than one knowledge
14421
+ * base is visible.
12388
14422
  *
12389
- * `wechat.enabled` (v0.23.0) is read here too: it is NOT a `prompt.*` field but
12390
- * it gates the publish rule, and the WeChat feature is opt-in + separately
12391
- * installed, so users who never enabled it must see no publishing text.
14423
+ * Per-wiki `prompt.*` is honoured too: the config read is the SCOPE wiki's, so
14424
+ * the 「每库定制提示词」decision from the design doc actually takes effect.
12392
14425
  */
12393
- const promptText = () => {
12394
- const cfg = eff();
12395
- return describePrompt({
14426
+ const promptTextFor = (sessionId) => {
14427
+ const scope = toolScope(sessionId);
14428
+ const cfg = resolveAgentScope(farm, sessionScopes, sessionId).runtime?.eff() ?? baseShape;
14429
+ return withScopeBanner(describePrompt({
12396
14430
  ...cfg.prompt ?? {},
12397
14431
  wechat: (cfg.wechat ?? {}).enabled === true
12398
- }, tiddlywikiToolSummary()).text;
14432
+ }, tiddlywikiToolSummary()).text, scope);
12399
14433
  };
14434
+ /**
14435
+ * Built text for the DEFAULT scope (also the saved-state preview / the
14436
+ * re-registration key). The signature catalogue for `full` mode comes from the
14437
+ * live tool registry, never from hand-written prose (v0.21.0 — the old copy had
14438
+ * drifted).
14439
+ */
14440
+ const promptText = () => promptTextFor(void 0);
14441
+ /**
14442
+ * (Re-)register the section. The text is a function, so a scope change needs
14443
+ * no re-registration at all; this still runs on config saves so the
14444
+ * `system-prompt/change` signal (and the history re-render) fires, and the
14445
+ * skip-if-unchanged check keeps an unrelated save from churning anything.
14446
+ */
12400
14447
  const applyPrompt = () => {
12401
14448
  const next = promptText();
12402
14449
  if (next === currentPromptText) return;
@@ -12407,44 +14454,33 @@ function apply(ctx, rawConfig = {}) {
12407
14454
  disposePromptSection = ctx.systemPrompt.section({
12408
14455
  name: PROMPT_SECTION_NAME,
12409
14456
  order: 100,
12410
- text: next
14457
+ text: (context) => {
14458
+ const agent = context?.scope;
14459
+ return promptTextFor(typeof agent?.id === "string" ? agent.id : void 0);
14460
+ }
12411
14461
  });
12412
14462
  };
12413
14463
  disposers.push(() => {
12414
14464
  disposePromptSection?.();
12415
14465
  disposePromptSection = void 0;
12416
14466
  });
12417
- const server = new WikiServer({
12418
- wikiRoot: config.wikiRoot,
12419
- wiki: config.wiki,
12420
- port: config.port,
12421
- username: config.auth.username,
12422
- password: config.auth.password,
12423
- readyTimeoutMs: config.startup.readyTimeoutMs
12424
- });
12425
14467
  /**
12426
14468
  * Readiness window from the EFFECTIVE config (v0.22.5): the settings page
12427
- * saves `startup.readyTimeoutMs`, and this re-applies it to the running
12428
- * server — it takes effect on the next start()/restart() without a dsh web
14469
+ * saves `startup.readyTimeoutMs`, and this re-applies it to EVERY running
14470
+ * wiki — it takes effect on the next start()/restart() without a dsh web
12429
14471
  * restart. Values are clamped by host/ready-policy.ts.
12430
14472
  */
12431
14473
  const applyServerTuning = () => {
12432
- server.setReadyTimeout(eff().startup?.readyTimeoutMs);
12433
- };
12434
- let clientCache;
12435
- let clientPort;
12436
- const client = () => {
12437
- const port = server.currentPort;
12438
- if (port === void 0) return void 0;
12439
- if (clientCache === void 0 || clientPort !== port) {
12440
- clientCache = new TiddlyWebClient(`http://127.0.0.1:${port}`, {
12441
- username: config.auth.username,
12442
- password: config.auth.password
12443
- });
12444
- clientPort = port;
12445
- }
12446
- return clientCache;
14474
+ for (const runtime of farm?.allRuntimes() ?? []) runtime.applyServerTuning();
12447
14475
  };
14476
+ /**
14477
+ * Lazy TW client. host/wiki-instance.ts owns both the port-change invalidation
14478
+ * (a crash before readiness makes the child re-probe a free port, so a cached
14479
+ * client must not stay pinned to a dead one) and the preemptive Basic
14480
+ * credentials (`auth.username` puts the child behind `readers`/`writers`, so
14481
+ * EVERY request — reads included — needs auth).
14482
+ */
14483
+ const client = () => defaultInstance()?.client();
12448
14484
  const clipBridge = new ClipBridge({
12449
14485
  getConfig: effectiveBridge,
12450
14486
  write: async (tiddler) => {
@@ -12467,96 +14503,65 @@ function apply(ctx, rawConfig = {}) {
12467
14503
  log: (m) => console.info("[dsh-tiddlywiki] clip bridge:", m)
12468
14504
  });
12469
14505
  ctx.effect(() => () => clipBridge.stop(), "dsh-tiddlywiki: clip bridge");
12470
- let committer;
12471
- let unwatch;
12472
- const setupCommitter = () => {
12473
- const g = eff().git ?? {};
12474
- committer = new AutoCommitter({
12475
- git,
12476
- dir: wikiPath,
12477
- enabled: g.autoCommit ?? config.git.autoCommit,
12478
- debounceMs: g.debounceMs ?? config.git.debounceMs,
12479
- message: () => `wiki autocommit ${(/* @__PURE__ */ new Date()).toISOString()}`,
12480
- onError: (err) => console.warn("[dsh-tiddlywiki] autocommit:", err)
12481
- });
12482
- unwatch = watchWiki(wikiPath, () => committer?.touch());
12483
- };
14506
+ /**
14507
+ * Release every running wiki's fs watcher AND flush every repository's pending
14508
+ * commit (plugin teardown / a wiki switch). The COMMITTERS themselves are
14509
+ * plugin-wide — one per repository — so they are NOT dropped here: a switch
14510
+ * must not throw away the debounce window of unrelated wikis that share the
14511
+ * same repository.
14512
+ */
12484
14513
  const teardownCommitter = async () => {
12485
- try {
12486
- unwatch?.();
12487
- } catch {}
12488
- unwatch = void 0;
12489
- const current = committer;
12490
- committer = void 0;
12491
- try {
12492
- await current?.flush();
12493
- } catch {}
12494
- current?.dispose();
14514
+ for (const runtime of farm?.allRuntimes() ?? []) await runtime.teardownExtras();
14515
+ await repos.flush();
12495
14516
  };
12496
14517
  disposers.push(() => {
12497
14518
  teardownCommitter();
12498
14519
  });
12499
14520
  /**
12500
- * Re-apply the EFFECTIVE git config to the running plugin (v0.25.0).
14521
+ * Re-apply the EFFECTIVE git config to the running instance (v0.25.0).
12501
14522
  *
12502
14523
  * Until then, `git.*` was read ONCE: `AutoCommitter` snapshots
12503
14524
  * `enabled`/`debounceMs` into immutable options at construction, and the remote
12504
14525
  * only reached the repo through `bootstrapGit()` at startup. The settings page
12505
14526
  * happily saved + echoed new values that changed nothing until a dsh web
12506
14527
  * restart — turning 自动提交 off kept committing, changing the remote kept
12507
- * pushing to the old one. Rebuilding the committer closes that gap; the
12508
- * `gitReconfiguring` flag (plus `teardownCommitter`'s own idempotence) keeps a
12509
- * burst of saves from leaving two committers or a dangling fs watcher.
14528
+ * pushing to the old one. Rebuilding the committer closes that gap; the guard
14529
+ * flag (plus teardownExtras' own idempotence) keeps a burst of saves from
14530
+ * leaving two committers or a dangling fs watcher.
12510
14531
  *
12511
14532
  * Not covered on purpose: an already-initialised repository keeps its branch
12512
14533
  * (`git.branch` is only consulted by `git.init`), and clearing the remote
12513
14534
  * field does not delete `origin` (that is a destructive repo change the UI
12514
14535
  * never promised). Both are documented in the settings page + README.
12515
14536
  */
12516
- let gitReconfiguring = false;
12517
14537
  const reapplyGitConfig = async () => {
12518
- if (gitReconfiguring) return;
12519
- gitReconfiguring = true;
12520
- try {
12521
- await teardownCommitter();
12522
- const g = eff().git ?? {};
12523
- const remote = (typeof g.remote === "string" && g.remote.trim().length > 0 ? g.remote : config.git.remote).trim();
12524
- if (remote.length > 0) {
12525
- const ensured = await git.ensureRemote(wikiPath, remote);
12526
- if (!ensured.ok) console.warn("[dsh-tiddlywiki] git remote update:", ensured.message);
12527
- }
12528
- setupCommitter();
12529
- } catch (err) {
12530
- console.warn("[dsh-tiddlywiki] applying git config:", err);
12531
- } finally {
12532
- gitReconfiguring = false;
12533
- }
12534
- };
12535
- const bootstrapGit = async () => {
14538
+ await teardownCommitter();
12536
14539
  const g = eff().git ?? {};
12537
- const branch = g.branch ?? config.git.branch;
12538
- const remote = g.remote ?? config.git.remote;
12539
- if (!await git.isRepo(wikiPath)) {
12540
- await git.init(wikiPath, branch);
12541
- await writeGitignore(wikiPath);
12542
- await git.initialCommit(wikiPath);
12543
- } else await writeGitignore(wikiPath);
12544
- if (remote.trim().length > 0) {
12545
- const ensured = await git.ensureRemote(wikiPath, remote.trim());
12546
- if (ensured.ok) {
12547
- const first = await git.firstPush(wikiPath);
12548
- if (!first.ok) console.warn("[dsh-tiddlywiki] first push failed (retry with tiddlywiki_git_sync):", first.message);
12549
- } else console.warn("[dsh-tiddlywiki] git remote setup:", ensured.message);
14540
+ const remote = (typeof g.remote === "string" && g.remote.trim().length > 0 ? g.remote : config.git.remote).trim();
14541
+ if (remote.length > 0) {
14542
+ const ensured = await git.ensureRemote(defaultPath(), remote);
14543
+ if (!ensured.ok) console.warn("[dsh-tiddlywiki] git remote update:", ensured.message);
12550
14544
  }
14545
+ repos.rebuild();
14546
+ for (const runtime of farm?.allRuntimes() ?? []) runtime.setupExtras();
14547
+ };
14548
+ /**
14549
+ * The same-origin proxy base a wiki's TW frontend must use (v0.28.0).
14550
+ * The rule itself lives in host/wiki.ts (`proxyBaseFor`) so the harness and the
14551
+ * host cannot disagree about it — see that function for why it matters.
14552
+ */
14553
+ const proxyBaseForEntry = (entry) => proxyBaseFor(farm?.registry.mode ?? "single", entry.id);
14554
+ /** Does this wiki live inside that repository (or is it the repository root)? */
14555
+ const entryIsInRepo = (entry, repoRoot) => {
14556
+ const path = entryPath(entry);
14557
+ return pathComparisonKey(path) === pathComparisonKey(repoRoot) || isInsidePath(repoRoot, path);
12551
14558
  };
12552
14559
  const toolsDeps = {
12553
- wiki: client,
14560
+ scope: toolScope,
12554
14561
  git,
12555
- wikiPath: () => wikiPath,
12556
- autoCommit: () => committer?.touch(),
12557
- restartWiki: async () => {
12558
- await server.restart();
12559
- },
14562
+ wikiPath: () => defaultInstance()?.path ?? locationPath(defaultLocation),
14563
+ autoCommit: () => defaultInstance()?.touchAutoCommit(),
14564
+ restartAffected: async (dir, changedFiles) => restartAffectedWikis(dir, changedFiles),
12560
14565
  workspaceName: (sessionId) => {
12561
14566
  const header = (ctx.get("sessions")?.get(sessionId))?.header;
12562
14567
  return typeof header?.cwd === "string" ? header.cwd : void 0;
@@ -12566,74 +14571,69 @@ function apply(ctx, rawConfig = {}) {
12566
14571
  disposers.push(...registerTiddlywikiTools(ctx, toolsDeps));
12567
14572
  applyPrompt();
12568
14573
  let disposed = false;
14574
+ /** single 模式的位置:旧指针文件 > cordis 默认(v0.22.0 的优先级,逐字保留)。 */
14575
+ const singleModeLocation = async () => {
14576
+ const state = await readLocationState(locationStateFile);
14577
+ if (state.error !== void 0) console.warn("[dsh-tiddlywiki]", state.error);
14578
+ return state.active ?? defaultLocation;
14579
+ };
12569
14580
  /**
12570
- * Core-bootstrap the CURRENT `wikiPath`: markdown parser plugin, the core +
12571
- * starter seeds, and the configured UI language. Extracted from the startup
12572
- * task (v0.22.0) because a runtime wiki switch must run exactly this on the
12573
- * new folder — the two paths must not drift apart.
12574
- *
12575
- * Only `render-route` needs a TW restart (it carries a SERVER route loaded at
12576
- * boot); plain-content seeds must NOT restart, or the syncer's unflushed REST
12577
- * writes would be lost.
14581
+ * The CONTROL FILE as the settings page edits it — deliberately NOT the
14582
+ * registry the farm reconciles. In single mode they differ: the farm runs a
14583
+ * one-entry registry synthesized from the legacy pointer, while the file may
14584
+ * already list several candidates the user is about to switch to.
12578
14585
  */
12579
- const bootstrapWiki = async () => {
12580
- const seedClient = client();
12581
- if (seedClient === void 0) return;
12582
- let pluginAdded = false;
12583
- try {
12584
- pluginAdded = await ensurePlugin(wikiPath, resolveTwRoot(), MARKDOWN_PLUGIN);
12585
- if (pluginAdded) console.info(`[dsh-tiddlywiki] enabled ${MARKDOWN_PLUGIN} for this wiki`);
12586
- } catch (err) {
12587
- console.warn(`[dsh-tiddlywiki] enabling ${MARKDOWN_PLUGIN}:`, err);
12588
- }
12589
- const seedStartedAt = Date.now();
12590
- const results = await runAllSeeds({
12591
- client: seedClient,
12592
- tools: tiddlywikiToolSummary(),
12593
- wechat: (eff().wechat ?? {}).enabled === true
12594
- });
12595
- for (const r of results) if (!r.ok) console.warn(`[dsh-tiddlywiki] seed ${r.id} failed:`, r.error ?? r.detail);
12596
- if (needsRestartAfterSeeds(results)) {
12597
- if (!await waitForFileWrite(join(wikiPath, "tiddlers", "$__plugins_dsh_render.json"), 8e3, 150, seedStartedAt)) console.warn("[dsh-tiddlywiki] seeded render plugin file not seen on disk before restart");
12598
- if (!await flushPendingWrites(seedClient, join(wikiPath, "tiddlers"))) console.warn("[dsh-tiddlywiki] seed writes may not have been flushed before restart");
12599
- if (!disposed) await server.restart();
12600
- } else if (pluginAdded) {
12601
- if (!disposed) await server.restart();
12602
- }
12603
- const uiLang = eff().uiLanguage;
12604
- if (typeof uiLang === "string" && uiLang.trim().length > 0) try {
12605
- const code = uiLang.trim();
12606
- if (await ensureLanguage(wikiPath, resolveTwRoot(), code) && !disposed) await server.restart();
12607
- const langClient = client();
12608
- if (langClient !== void 0) await pinLanguageTiddler(langClient, `$:/languages/${code}`, (message, err) => {
12609
- if (err === void 0) console.warn("[dsh-tiddlywiki]", message);
12610
- else console.warn("[dsh-tiddlywiki] pinning $:/language failed:", err);
12611
- }).catch(() => void 0);
12612
- } catch (err) {
12613
- console.warn("[dsh-tiddlywiki] applying uiLanguage:", err);
12614
- }
12615
- };
14586
+ let controlRegistry;
14587
+ let controlSource = "default";
14588
+ let controlWarnings = [];
14589
+ let controlError;
14590
+ /** What the control file says right now (synthesized when it does not exist yet). */
14591
+ const controlRegistryNow = () => controlRegistry ?? singleEntryRegistry(defaultCurrentLocation(), "main", "single");
12616
14592
  const startupTask = (async () => {
12617
14593
  try {
12618
- const saved = await readLocationState(locationStateFile);
12619
- if (saved.error !== void 0) console.warn("[dsh-tiddlywiki]", saved.error);
12620
- if (saved.active !== void 0) {
12621
- server.setLocation(saved.active);
12622
- wikiPath = locationPath(saved.active);
12623
- console.info(`[dsh-tiddlywiki] wiki location (pointer file): ${wikiPath}`);
12624
- }
12625
- await server.start();
12626
- if (disposed) {
12627
- await server.stop().catch(() => void 0);
12628
- return;
12629
- }
12630
- await configStore.load(client());
14594
+ const read = await readRegistry({
14595
+ file: registryFile,
14596
+ legacyFile: locationStateFile,
14597
+ fallback: defaultLocation
14598
+ });
14599
+ const scopes = await readSessionScopes(sessionScopeFile);
14600
+ if (scopes.error !== void 0) console.warn("[dsh-tiddlywiki]", scopes.error);
14601
+ sessionScopes = scopes.scopes;
14602
+ controlRegistry = read.registry;
14603
+ controlSource = read.source;
14604
+ controlWarnings = read.warnings;
14605
+ controlError = read.error;
14606
+ if (read.error !== void 0) console.warn("[dsh-tiddlywiki]", read.error);
14607
+ for (const warning of read.warnings) console.warn("[dsh-tiddlywiki]", warning);
14608
+ console.info(`[dsh-tiddlywiki] ${read.registry.mode} 模式 · ${read.registry.wikis.length} 个知识库(来源:${read.source})`);
14609
+ farm = new WikiFarm(read.registry.mode === "multi" ? read.registry : singleEntryRegistry(await singleModeLocation(), DEFAULT_WIKI_ID, "single"), {
14610
+ createRuntime: (entry) => new WikiInstance({
14611
+ entry,
14612
+ base: config,
14613
+ git,
14614
+ twRoot: resolveTwRoot,
14615
+ touchCommit: (dir) => {
14616
+ repos.touch(dir);
14617
+ },
14618
+ flushCommits: (dir) => repos.flush(dir),
14619
+ proxyBase: () => proxyBaseForEntry(entry)
14620
+ }),
14621
+ log: (message) => console.warn("[dsh-tiddlywiki]", message)
14622
+ });
14623
+ const change = await farm.startAll();
14624
+ if (change.started.length > 0) console.info(`[dsh-tiddlywiki] 已启动:${change.started.join(", ")}`);
12631
14625
  applyPrompt();
12632
- applyServerTuning();
12633
14626
  if (disposed) {
12634
- await server.stop().catch(() => void 0);
14627
+ await farm.disposeAll();
12635
14628
  return;
12636
14629
  }
14630
+ if (!disposed) try {
14631
+ const installed = await installSplitSkill();
14632
+ if (installed.action === "failed") console.warn("[dsh-tiddlywiki] 拆库 skill 安装失败:", installed.error);
14633
+ else console.info(`[dsh-tiddlywiki] 拆库 skill:${installed.action}(${installed.path})`);
14634
+ } catch (err) {
14635
+ console.warn("[dsh-tiddlywiki] 拆库 skill 安装异常:", err);
14636
+ }
12637
14637
  try {
12638
14638
  await clipBridge.start(effectiveBridge().port);
12639
14639
  console.info(`[dsh-tiddlywiki] clip bridge listening on 127.0.0.1:${clipBridge.port} (enabled=${effectiveBridge().enabled})`);
@@ -12644,43 +14644,43 @@ function apply(ctx, rawConfig = {}) {
12644
14644
  try {
12645
14645
  await clipBridge.stop();
12646
14646
  } catch {}
12647
- await server.stop().catch(() => void 0);
14647
+ await farm.disposeAll();
12648
14648
  return;
12649
14649
  }
12650
- try {
12651
- await bootstrapWiki();
12652
- } catch (err) {
12653
- console.warn("[dsh-tiddlywiki] seeding wiki:", err);
12654
- }
12655
14650
  } catch (err) {
12656
14651
  console.warn("[dsh-tiddlywiki] startup issue (self-healing is armed):", err);
12657
14652
  }
12658
- if (disposed) return;
12659
- try {
12660
- await bootstrapGit();
12661
- if (disposed) return;
12662
- setupCommitter();
12663
- } catch (err) {
12664
- console.warn("[dsh-tiddlywiki] git bootstrap failed:", err);
12665
- }
12666
14653
  })();
12667
- const defaultLocation = {
12668
- root: config.wikiRoot,
12669
- name: config.wiki
14654
+ /** The folder the default wiki occupies, running or not. */
14655
+ const defaultPath = () => {
14656
+ const running = defaultInstance();
14657
+ if (running !== void 0) return running.path;
14658
+ const entry = farm === void 0 ? void 0 : defaultEntry(farm.registry);
14659
+ return entry === void 0 ? locationPath(defaultLocation) : entryPath(entry);
14660
+ };
14661
+ /** The `{root,name}` the default wiki currently occupies (running or not). */
14662
+ const defaultCurrentLocation = () => {
14663
+ const running = defaultInstance();
14664
+ if (running !== void 0) return running.server.currentLocation;
14665
+ const entry = farm === void 0 ? void 0 : defaultEntry(farm.registry);
14666
+ return entry === void 0 ? defaultLocation : {
14667
+ root: entry.root,
14668
+ name: entry.name
14669
+ };
12670
14670
  };
12671
14671
  /** Source of the CURRENT location, for the settings page. */
12672
14672
  const locationSource = async () => {
12673
14673
  const state = await readLocationState(locationStateFile);
12674
- if (state.active !== void 0 && locationPath(state.active) === wikiPath) return "state";
14674
+ if (state.active !== void 0 && locationPath(state.active) === defaultPath()) return "state";
12675
14675
  return typeof rawConfig.wikiRoot === "string" && rawConfig.wikiRoot.trim().length > 0 ? "config" : "default";
12676
14676
  };
12677
14677
  const locationInfo = async () => {
12678
14678
  const state = await readLocationState(locationStateFile);
12679
- const current = server.currentLocation;
14679
+ const current = defaultCurrentLocation();
12680
14680
  return {
12681
14681
  current: {
12682
14682
  ...current,
12683
- path: wikiPath,
14683
+ path: defaultPath(),
12684
14684
  source: await locationSource()
12685
14685
  },
12686
14686
  default: {
@@ -12694,6 +14694,7 @@ function apply(ctx, rawConfig = {}) {
12694
14694
  };
12695
14695
  /** Single-flight guard: two concurrent switches would fight over the child. */
12696
14696
  let switching = false;
14697
+ /** SINGLE mode: the legacy orchestrator, on the farm's default runtime. */
12697
14698
  const runSwitch = async (target, persist) => {
12698
14699
  if (switching) return {
12699
14700
  ok: false,
@@ -12705,74 +14706,214 @@ function apply(ctx, rawConfig = {}) {
12705
14706
  error: "插件正在卸载,已取消切换",
12706
14707
  rolledBack: false
12707
14708
  };
14709
+ const runtime = defaultInstance();
14710
+ if (runtime === void 0) return {
14711
+ ok: false,
14712
+ error: "默认知识库当前没有运行,无法切换位置",
14713
+ rolledBack: false
14714
+ };
12708
14715
  switching = true;
12709
14716
  try {
12710
14717
  const result = await switchWiki({
12711
- currentLocation: () => server.currentLocation,
12712
- currentPath: () => wikiPath,
14718
+ currentLocation: () => runtime.server.currentLocation,
14719
+ currentPath: () => runtime.path,
12713
14720
  stopServer: async () => {
12714
- await drainThenStop({
12715
- client: client(),
12716
- tiddlersDir: join(wikiPath, "tiddlers"),
12717
- stop: () => server.stop(),
12718
- log: (message) => console.warn("[dsh-tiddlywiki]", message)
12719
- });
14721
+ await runtime.drainStop();
12720
14722
  },
12721
14723
  applyLocation: (nextLocation) => {
12722
- server.setLocation({
14724
+ runtime.updateEntry({
14725
+ ...runtime.entry,
12723
14726
  root: nextLocation.root,
12724
14727
  name: nextLocation.name
12725
14728
  });
12726
- wikiPath = locationPath(nextLocation);
12727
- clientCache = void 0;
12728
- clientPort = void 0;
12729
14729
  },
12730
14730
  startServer: async () => {
12731
- await server.start();
14731
+ await runtime.server.start();
12732
14732
  },
12733
- teardownExtras: () => teardownCommitter(),
14733
+ teardownExtras: () => runtime.teardownExtras(),
12734
14734
  setupExtras: () => {
12735
- setupCommitter();
14735
+ runtime.setupExtras();
12736
14736
  },
12737
14737
  reloadConfig: async () => {
12738
- await configStore.load(client());
14738
+ await runtime.config.load(runtime.client());
12739
14739
  applyPrompt();
12740
14740
  },
12741
14741
  bootstrap: async () => {
12742
- await bootstrapWiki();
12743
- await bootstrapGit();
14742
+ await runtime.bootstrapWiki();
14743
+ await runtime.bootstrapGit();
12744
14744
  },
12745
14745
  savePointer: persist,
12746
14746
  log: (message) => console.warn("[dsh-tiddlywiki]", message)
12747
14747
  }, target);
12748
- if (disposed) teardownCommitter();
14748
+ if (disposed) await runtime.teardownExtras();
14749
+ if (result.ok && farm !== void 0) {
14750
+ const moved = runtime.entry;
14751
+ farm.syncRegistry({
14752
+ ...farm.registry,
14753
+ wikis: farm.registry.wikis.map((entry) => entry.id === moved.id ? moved : entry)
14754
+ });
14755
+ }
12749
14756
  return result;
12750
14757
  } finally {
12751
14758
  switching = false;
12752
14759
  }
12753
14760
  };
12754
- /** Switch to another folder and remember the choice. */
12755
- const switchWikiLocation = (target) => runSwitch(target, async (t) => {
12756
- await writeLocationState(t, locationStateFile);
12757
- });
12758
14761
  /**
12759
- * 「恢复为配置默认」: drop the pointer and go back to the cordis default. The
12760
- * pointer must be cleared EVEN when the current folder already IS the default
12761
- * (otherwise the stale pointer would win again after the next restart).
14762
+ * MULTI mode: move the DEFAULT wiki to another folder. The farm's reconcile
14763
+ * stops the old child, recycles the runtime and starts it on the new folder;
14764
+ * the registry is persisted LAST (a failed write costs restart-survival, not
14765
+ * the running wiki — same trade-off the pointer file already makes).
14766
+ */
14767
+ const repointDefault = async (target) => {
14768
+ if (disposed) return {
14769
+ ok: false,
14770
+ error: "插件正在卸载,已取消切换",
14771
+ rolledBack: false
14772
+ };
14773
+ if (farm === void 0) return {
14774
+ ok: false,
14775
+ error: "插件尚未就绪,请稍后再试",
14776
+ rolledBack: true
14777
+ };
14778
+ if (switching) return {
14779
+ ok: false,
14780
+ error: "正在切换知识库,请稍候再试",
14781
+ rolledBack: true
14782
+ };
14783
+ const normalized = normalizeLocation(target);
14784
+ if (normalized.location === void 0) return {
14785
+ ok: false,
14786
+ error: normalized.error ?? "位置非法",
14787
+ rolledBack: true
14788
+ };
14789
+ const next = normalized.location;
14790
+ const current = defaultEntry(farm.registry);
14791
+ if (current === void 0) return {
14792
+ ok: false,
14793
+ error: "清单里没有默认知识库",
14794
+ rolledBack: true
14795
+ };
14796
+ const moved = {
14797
+ ...current,
14798
+ root: next.root,
14799
+ name: next.name
14800
+ };
14801
+ const validated = validateRegistry({
14802
+ ...farm.registry,
14803
+ wikis: farm.registry.wikis.map((entry) => entry.id === moved.id ? moved : entry)
14804
+ });
14805
+ if (validated.registry === void 0) return {
14806
+ ok: false,
14807
+ error: validated.fatal.join(";"),
14808
+ rolledBack: true
14809
+ };
14810
+ switching = true;
14811
+ try {
14812
+ await farm.apply(validated.registry);
14813
+ const applied = {
14814
+ ok: true,
14815
+ location: {
14816
+ root: moved.root,
14817
+ name: moved.name
14818
+ },
14819
+ path: entryPath(moved)
14820
+ };
14821
+ try {
14822
+ await writeRegistry(validated.registry, registryFile);
14823
+ } catch (err) {
14824
+ return {
14825
+ ...applied,
14826
+ warning: `位置已切换,但清单写入失败(重启 dsh web 后会回到原位置):${err instanceof Error ? err.message : String(err)}`
14827
+ };
14828
+ }
14829
+ return applied;
14830
+ } finally {
14831
+ switching = false;
14832
+ }
14833
+ };
14834
+ /** Switch the DEFAULT wiki to another folder and remember the choice. */
14835
+ const switchWikiLocation = (target) => {
14836
+ if (farm?.registry.mode === "multi") return repointDefault(target);
14837
+ return runSwitch(target, async (t) => {
14838
+ await writeLocationState(t, locationStateFile);
14839
+ });
14840
+ };
14841
+ /**
14842
+ * 「恢复为配置默认」: single mode drops the pointer and goes back to the cordis
14843
+ * default (cleared EVEN when the current folder already IS the default, or the
14844
+ * stale pointer wins again after the next restart); multi mode moves the
14845
+ * default wiki back to the configured location.
12762
14846
  */
12763
14847
  const resetWikiLocation = async () => {
12764
- if (locationPath(defaultLocation) === wikiPath) {
14848
+ if (farm?.registry.mode === "multi") return repointDefault(defaultLocation);
14849
+ if (locationPath(defaultLocation) === defaultPath()) {
12765
14850
  await clearLocationState(locationStateFile);
12766
14851
  return {
12767
14852
  ok: true,
12768
14853
  location: defaultLocation,
12769
- path: wikiPath
14854
+ path: defaultPath()
12770
14855
  };
12771
14856
  }
12772
14857
  return runSwitch(defaultLocation, async () => {
12773
14858
  await clearLocationState(locationStateFile);
12774
14859
  });
12775
14860
  };
14861
+ /** The control file's view for the settings page (see controlRegistryNow). */
14862
+ const buildWikisView = () => {
14863
+ const registry = controlRegistryNow();
14864
+ return {
14865
+ mode: registry.mode,
14866
+ defaultId: registry.defaultId,
14867
+ source: controlSource,
14868
+ registryFile,
14869
+ ...controlError !== void 0 ? { error: controlError } : {},
14870
+ warnings: controlWarnings,
14871
+ wikis: registry.wikis.map((entry) => {
14872
+ const runtime = farm?.runtime(entry.id);
14873
+ return {
14874
+ id: entry.id,
14875
+ label: entry.label,
14876
+ root: entry.root,
14877
+ name: entry.name,
14878
+ path: entryPath(entry),
14879
+ agentVisible: entry.agentVisible,
14880
+ autostart: entry.autostart,
14881
+ running: runtime !== void 0,
14882
+ status: runtime?.server.status().status ?? "stopped"
14883
+ };
14884
+ })
14885
+ };
14886
+ };
14887
+ /**
14888
+ * Restart the RUNNING wikis whose content a pull just changed (v0.28.0).
14889
+ *
14890
+ * Shared by the agent tool and the browser `/sync` route so the two cannot
14891
+ * drift. `dir` is where the pull ran (the repository is resolved from it) and
14892
+ * `changedFiles` are repository-relative — which is what makes this correct
14893
+ * when several knowledge bases share one repository.
14894
+ */
14895
+ const restartAffectedWikis = async (dir, changedFiles) => {
14896
+ if (farm === void 0) return {
14897
+ restarted: [],
14898
+ failed: []
14899
+ };
14900
+ const repoRoot = await repos.repoRootOf(dir) ?? dir;
14901
+ const restarted = [];
14902
+ const failed = [];
14903
+ for (const runtime of farm.affectedBy(repoRoot, changedFiles)) try {
14904
+ await runtime.restart();
14905
+ restarted.push(runtime.entry.id);
14906
+ } catch (err) {
14907
+ failed.push({
14908
+ id: runtime.entry.id,
14909
+ message: err instanceof Error ? err.message : String(err)
14910
+ });
14911
+ }
14912
+ return {
14913
+ restarted,
14914
+ failed
14915
+ };
14916
+ };
12776
14917
  ctx.inject(["webServer"], (webCtx) => {
12777
14918
  const ws = webCtx.webServer;
12778
14919
  const getSessionController = () => ctx.get("sessionController");
@@ -12782,14 +14923,75 @@ function apply(ctx, rawConfig = {}) {
12782
14923
  const getPermissionPresets = () => ctx.get("permissionPresets");
12783
14924
  const getSessions = () => ctx.get("sessions");
12784
14925
  const getSessionQuery = () => ctx.get("sessionQuery");
14926
+ /**
14927
+ * Which wiki does this request target? `?wiki=<id>` (a running wiki), else
14928
+ * the farm's default — resolved by the SHARED helper in host/wiki-farm.ts,
14929
+ * so the host wiring and the verification harness cannot disagree about the
14930
+ * selector (and an unknown id falls back instead of 404ing).
14931
+ */
14932
+ const target = (req) => targetRuntimeFor(farm, req);
14933
+ const fallbackUi = WikiInstance.uiDefaultsFrom(config);
14934
+ const fallbackWechat = normalizeWechatConfig(config.wechat);
14935
+ /** Used only while nothing runs: base defaults, no wiki tiddler to read. */
14936
+ const idleConfig = new ConfigStore(baseShape);
12785
14937
  const disposeRoutes = registerRoutes({ webServer: ws }, {
12786
- server,
12787
- getClient: client,
14938
+ server: (req) => target(req)?.server,
14939
+ wikiSummaries: (req) => {
14940
+ const registry = farm?.registry;
14941
+ return {
14942
+ mode: registry?.mode ?? "single",
14943
+ defaultId: registry?.defaultId ?? "main",
14944
+ items: (registry?.wikis ?? []).map((entry) => {
14945
+ const runtime = farm?.runtime(entry.id);
14946
+ return {
14947
+ id: entry.id,
14948
+ label: entry.label,
14949
+ status: runtime?.server.status().status ?? "stopped",
14950
+ agentVisible: entry.agentVisible,
14951
+ autostart: entry.autostart,
14952
+ running: runtime !== void 0,
14953
+ path: entryPath(entry)
14954
+ };
14955
+ })
14956
+ };
14957
+ },
14958
+ serverById: (id) => farm?.runtime(id)?.server,
14959
+ wikiIds: () => farm?.registry.wikis.map((entry) => entry.id) ?? [],
14960
+ getClient: (req) => target(req)?.client(),
12788
14961
  git,
12789
- autoCommit: () => committer?.touch(),
12790
- noteDefaults: () => ({ tag: effectiveNoteTag() }),
12791
- uiDefaults: () => effectiveUi(),
12792
- getWikiPath: () => wikiPath,
14962
+ autoCommit: (req) => target(req)?.touchAutoCommit(),
14963
+ noteDefaults: (req) => ({ tag: target(req)?.noteTag() ?? config.note.tag }),
14964
+ uiDefaults: (req) => target(req)?.uiDefaults() ?? fallbackUi,
14965
+ getWikiPath: (req) => target(req)?.path ?? defaultPath(),
14966
+ restartAffected: (_req, dir, changedFiles) => restartAffectedWikis(dir, changedFiles),
14967
+ sessionScope: {
14968
+ get: (sessionId) => {
14969
+ const resolution = resolveAgentScope(farm, sessionScopes, sessionId);
14970
+ const scopeId = sessionScopes[sessionId];
14971
+ return {
14972
+ ...scopeId !== void 0 ? { scope: scopeId } : {},
14973
+ ...resolution.entry !== void 0 ? { resolved: {
14974
+ id: resolution.entry.id,
14975
+ label: resolution.entry.label
14976
+ } } : {},
14977
+ ...resolution.reason !== void 0 ? { reason: resolution.reason } : {}
14978
+ };
14979
+ },
14980
+ set: async (sessionId, wikiId) => {
14981
+ if (!isSafeSessionId(sessionId)) throw new Error("会话 id 非法");
14982
+ if (wikiId !== void 0) {
14983
+ const entry = farm?.registry.wikis.find((item) => item.id === wikiId);
14984
+ if (entry === void 0) throw new Error(`知识库「${wikiId}」不在清单里`);
14985
+ if (!entry.agentVisible) throw new Error(`知识库「${entry.label}」对 Agent 隐身,不能作为会话作用域`);
14986
+ if (farm !== void 0 && farm.runtime(entry.id) === void 0) await farm.startEntry(entry);
14987
+ }
14988
+ await setSessionScope(sessionId, wikiId, sessionScopeFile);
14989
+ const next = { ...sessionScopes };
14990
+ if (wikiId === void 0) delete next[sessionId];
14991
+ else next[sessionId] = wikiId;
14992
+ sessionScopes = next;
14993
+ }
14994
+ },
12793
14995
  getSessionController,
12794
14996
  getWorkspaceRegistry,
12795
14997
  getAgentPresets,
@@ -12797,35 +14999,118 @@ function apply(ctx, rawConfig = {}) {
12797
14999
  getPermissionPresets,
12798
15000
  getSessions,
12799
15001
  getSessionQuery,
12800
- sendToAgentEnabled: () => eff().ui?.sendToAgent?.enabled !== false,
12801
- sendToAgentToken: () => {
12802
- const token = eff().ui?.sendToAgent?.token;
12803
- return typeof token === "string" ? token : "";
12804
- },
12805
- wechatConfig: () => effectiveWechat(),
15002
+ sendToAgentEnabled: (req) => target(req)?.sendToAgent().enabled ?? config.ui.sendToAgent.enabled,
15003
+ sendToAgentToken: (req) => target(req)?.sendToAgent().token ?? config.ui.sendToAgent.token ?? "",
15004
+ wechatConfig: (req) => target(req)?.wechatConfig() ?? fallbackWechat,
12806
15005
  wechatRunner: () => wechatRunner,
12807
- wechatReady: () => checkWechatReady({
12808
- enabled: effectiveWechat().enabled,
12809
- command: effectiveWechat().command
12810
- })
15006
+ wechatReady: async (req) => {
15007
+ const cfg = target(req)?.wechatConfig() ?? fallbackWechat;
15008
+ return checkWechatReady({
15009
+ enabled: cfg.enabled,
15010
+ command: cfg.command
15011
+ });
15012
+ }
12811
15013
  });
12812
15014
  const disposeAdmin = registerAdminRoutes({ webServer: ws }, {
12813
- server,
12814
- getClient: client,
12815
- getWikiPath: () => wikiPath,
15015
+ server: (req) => target(req)?.server,
15016
+ getClient: (req) => target(req)?.client(),
15017
+ getWikiPath: (req) => target(req)?.path ?? defaultPath(),
12816
15018
  twRoot: resolveTwRoot,
12817
- config: configStore,
15019
+ config: (req) => target(req)?.config ?? idleConfig,
12818
15020
  onConfigChanged: () => {
12819
15021
  applyPrompt();
12820
15022
  applyServerTuning();
12821
15023
  reapplyGitConfig();
12822
15024
  },
12823
- getPrompt: (draft) => describePrompt(draft ?? eff().prompt ?? {}, tiddlywikiToolSummary()),
15025
+ getPrompt: (draft, req) => describePrompt(draft ?? (target(req)?.eff() ?? baseShape).prompt ?? {}, tiddlywikiToolSummary()),
12824
15026
  wiki: {
12825
15027
  info: locationInfo,
12826
15028
  switch: switchWikiLocation,
12827
15029
  reset: resetWikiLocation
12828
15030
  },
15031
+ wikis: {
15032
+ info: async () => buildWikisView(),
15033
+ apply: async (body) => {
15034
+ if (disposed) return {
15035
+ ok: false,
15036
+ error: "插件正在卸载,已取消修改"
15037
+ };
15038
+ const runtimeAction = body?.action;
15039
+ if (runtimeAction === "start" || runtimeAction === "stop") {
15040
+ if (farm === void 0) return {
15041
+ ok: false,
15042
+ error: "插件尚未就绪,请稍后再试"
15043
+ };
15044
+ const id = body.id;
15045
+ const entry = typeof id === "string" ? farm.registry.wikis.find((item) => item.id === id.trim().toLowerCase()) : void 0;
15046
+ if (entry === void 0) return {
15047
+ ok: false,
15048
+ error: `知识库「${String(id)}」不在清单里`
15049
+ };
15050
+ const change = {
15051
+ started: [],
15052
+ stopped: [],
15053
+ updated: [],
15054
+ running: farm.runningIds(),
15055
+ errors: []
15056
+ };
15057
+ if (runtimeAction === "start") {
15058
+ if (farm.runtime(entry.id) === void 0) {
15059
+ await farm.startEntry(entry);
15060
+ change.started.push(entry.id);
15061
+ }
15062
+ } else {
15063
+ await farm.stopEntry(entry.id);
15064
+ change.stopped.push(entry.id);
15065
+ }
15066
+ change.running = farm.runningIds();
15067
+ return {
15068
+ ok: true,
15069
+ info: buildWikisView(),
15070
+ change
15071
+ };
15072
+ }
15073
+ const action = applyWikiAction(controlRegistryNow(), body);
15074
+ if (action.registry === void 0) return {
15075
+ ok: false,
15076
+ error: action.error ?? "动作被拒绝"
15077
+ };
15078
+ const registry = action.registry;
15079
+ try {
15080
+ await writeRegistry(registry, registryFile);
15081
+ } catch (err) {
15082
+ return {
15083
+ ok: false,
15084
+ error: `清单写入失败:${err instanceof Error ? err.message : String(err)}`
15085
+ };
15086
+ }
15087
+ controlRegistry = registry;
15088
+ controlSource = "file";
15089
+ controlWarnings = [];
15090
+ controlError = void 0;
15091
+ const single = defaultEntry(registry);
15092
+ const runRegistry = registry.mode === "multi" ? registry : singleEntryRegistry(single === void 0 ? defaultCurrentLocation() : {
15093
+ root: single.root,
15094
+ name: single.name
15095
+ }, DEFAULT_WIKI_ID, "single");
15096
+ const change = farm === void 0 ? {
15097
+ started: [],
15098
+ stopped: [],
15099
+ updated: [],
15100
+ running: [],
15101
+ errors: []
15102
+ } : await farm.apply(runRegistry);
15103
+ if (registry.mode === "single" && single !== void 0) await writeLocationState({
15104
+ root: single.root,
15105
+ name: single.name
15106
+ }, locationStateFile).catch(() => void 0);
15107
+ return {
15108
+ ok: true,
15109
+ info: buildWikisView(),
15110
+ change
15111
+ };
15112
+ }
15113
+ },
12829
15114
  seeds: {
12830
15115
  checkAll: async (c) => checkAllSeeds({
12831
15116
  client: c,
@@ -12852,14 +15137,15 @@ function apply(ctx, rawConfig = {}) {
12852
15137
  })]);
12853
15138
  } catch {}
12854
15139
  try {
12855
- await committer?.flush();
15140
+ await defaultInstance()?.flushCommitter();
12856
15141
  } catch {}
12857
15142
  disposeAll();
12858
- await server.stop();
15143
+ await farm?.disposeAll();
15144
+ repos.dispose();
12859
15145
  })();
12860
15146
  }, "dsh-tiddlywiki: host teardown");
12861
15147
  }
12862
15148
  //#endregion
12863
- 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, 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, normalizeConfigPatch, normalizeFieldsArg, 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 };
15149
+ 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_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, 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, resolveTwRoot, 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 };
12864
15150
 
12865
15151
  //# sourceMappingURL=index.js.map