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.
- package/LICENSE +21 -21
- package/README.md +3 -0
- package/cordis.patch.yml +13 -13
- package/docs/CHANGELOG.md +2 -1
- package/docs/code-review-2026-09-17.md +151 -0
- package/docs/features.md +5 -2
- package/docs/usage.md +34 -3
- package/docs/wiki-split.md +97 -0
- package/lib/client.js +54 -27
- package/lib/index.js +2803 -517
- package/lib/index.js.map +1 -1
- package/package.json +5 -4
- package/skills/wiki-split/SKILL.md +106 -0
- package/src/client/endpoints.ts +37 -0
- package/src/client/index.ts +14 -0
- package/src/client/knowledge-fab.ts +62 -3
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/panel.ts +2 -1
- package/src/client/settings-page.ts +217 -3
- package/src/client/state.ts +39 -39
- package/src/client/status-cache.ts +17 -0
- package/src/client/styles.ts +27 -0
- package/src/client/toast.ts +22 -22
- package/src/client/tw-frame.ts +28 -4
- package/src/client/wiki-focus.ts +70 -0
- package/src/client/wiki-scope-dock.ts +116 -0
- package/src/host/admin.ts +175 -35
- package/src/host/git.ts +50 -5
- package/src/host/path-key.ts +35 -0
- package/src/host/prompt.ts +24 -0
- package/src/host/repo-committers.ts +149 -0
- package/src/host/routes.ts +254 -81
- package/src/host/seeds.ts +51 -10
- package/src/host/session-scope.ts +154 -0
- package/src/host/skill-install.ts +121 -0
- package/src/host/tools.ts +141 -49
- package/src/host/wiki-farm.ts +417 -0
- package/src/host/wiki-instance.ts +596 -0
- package/src/host/wiki-registry.ts +601 -0
- package/src/host/wiki.ts +19 -0
- 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
|
-
|
|
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
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 ===
|
|
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
|
-
|
|
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:
|
|
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
|
|
6616
|
-
* then shares ONE probe instead of each spawning up to five git
|
|
6617
|
-
* (v0.19.0 — value-caching still let every concurrent miss run its
|
|
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
|
-
|
|
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(
|
|
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
|
|
6723
|
-
|
|
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: () =>
|
|
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
|
|
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
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
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
|
-
|
|
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(`${
|
|
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
|
-
|
|
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: "
|
|
7819
|
-
},
|
|
8047
|
+
error: "system tiddler not exposed"
|
|
8048
|
+
}, 403);
|
|
7820
8049
|
return;
|
|
7821
8050
|
}
|
|
7822
|
-
const
|
|
7823
|
-
|
|
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: "
|
|
7828
|
-
},
|
|
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(`${
|
|
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
|
|
8733
|
-
|
|
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: () =>
|
|
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: () =>
|
|
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
|
|
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: () =>
|
|
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
|
|
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
|
|
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
|
|
10430
|
-
if (
|
|
10431
|
-
return
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
11675
|
-
return {
|
|
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 {
|
|
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
|
|
11854
|
-
if (value.
|
|
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/
|
|
12488
|
+
//#region src/host/path-key.ts
|
|
12095
12489
|
/**
|
|
12096
|
-
*
|
|
12490
|
+
* Path comparison keys (v0.28.0).
|
|
12097
12491
|
*
|
|
12098
|
-
*
|
|
12099
|
-
*
|
|
12100
|
-
*
|
|
12101
|
-
*
|
|
12102
|
-
*
|
|
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
|
-
*
|
|
12107
|
-
*
|
|
12108
|
-
*
|
|
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
|
-
*
|
|
12111
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
12116
|
-
|
|
12117
|
-
|
|
12118
|
-
|
|
12119
|
-
|
|
12120
|
-
|
|
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
|
-
*
|
|
12123
|
-
*
|
|
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
|
-
|
|
12126
|
-
const
|
|
12127
|
-
|
|
12128
|
-
|
|
12129
|
-
|
|
12130
|
-
|
|
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
|
-
*
|
|
12177
|
-
*
|
|
12178
|
-
*
|
|
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
|
-
|
|
12181
|
-
|
|
12182
|
-
|
|
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
|
-
*
|
|
12186
|
-
*
|
|
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
|
-
*
|
|
12189
|
-
*
|
|
12190
|
-
*
|
|
12191
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
12236
|
-
*
|
|
12237
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
12297
|
-
const
|
|
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
|
-
*
|
|
12313
|
-
*
|
|
12314
|
-
*
|
|
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
|
|
12317
|
-
|
|
12318
|
-
|
|
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
|
-
*
|
|
12322
|
-
*
|
|
12323
|
-
*
|
|
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
|
|
12326
|
-
const
|
|
12327
|
-
const
|
|
12328
|
-
const
|
|
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
|
-
|
|
12345
|
-
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
12349
|
-
|
|
12350
|
-
|
|
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
|
-
*
|
|
12386
|
-
*
|
|
12387
|
-
*
|
|
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
|
-
* `
|
|
12390
|
-
*
|
|
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
|
|
12394
|
-
const
|
|
12395
|
-
|
|
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:
|
|
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
|
|
12428
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
12471
|
-
|
|
12472
|
-
|
|
12473
|
-
|
|
12474
|
-
|
|
12475
|
-
|
|
12476
|
-
|
|
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
|
-
|
|
12486
|
-
|
|
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
|
|
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
|
-
*
|
|
12509
|
-
*
|
|
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
|
-
|
|
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
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
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
|
-
|
|
14560
|
+
scope: toolScope,
|
|
12554
14561
|
git,
|
|
12555
|
-
wikiPath: () =>
|
|
12556
|
-
autoCommit: () =>
|
|
12557
|
-
|
|
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
|
-
*
|
|
12571
|
-
*
|
|
12572
|
-
*
|
|
12573
|
-
*
|
|
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
|
-
|
|
12580
|
-
|
|
12581
|
-
|
|
12582
|
-
|
|
12583
|
-
|
|
12584
|
-
|
|
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
|
|
12619
|
-
|
|
12620
|
-
|
|
12621
|
-
|
|
12622
|
-
|
|
12623
|
-
|
|
12624
|
-
|
|
12625
|
-
|
|
12626
|
-
|
|
12627
|
-
|
|
12628
|
-
|
|
12629
|
-
|
|
12630
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
12668
|
-
|
|
12669
|
-
|
|
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) ===
|
|
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 =
|
|
14679
|
+
const current = defaultCurrentLocation();
|
|
12680
14680
|
return {
|
|
12681
14681
|
current: {
|
|
12682
14682
|
...current,
|
|
12683
|
-
path:
|
|
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: () =>
|
|
14718
|
+
currentLocation: () => runtime.server.currentLocation,
|
|
14719
|
+
currentPath: () => runtime.path,
|
|
12713
14720
|
stopServer: async () => {
|
|
12714
|
-
await
|
|
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
|
-
|
|
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: () =>
|
|
14733
|
+
teardownExtras: () => runtime.teardownExtras(),
|
|
12734
14734
|
setupExtras: () => {
|
|
12735
|
-
|
|
14735
|
+
runtime.setupExtras();
|
|
12736
14736
|
},
|
|
12737
14737
|
reloadConfig: async () => {
|
|
12738
|
-
await
|
|
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)
|
|
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
|
-
*
|
|
12760
|
-
*
|
|
12761
|
-
*
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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: () =>
|
|
12790
|
-
noteDefaults: () => ({ tag:
|
|
12791
|
-
uiDefaults: () =>
|
|
12792
|
-
getWikiPath: () =>
|
|
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: () =>
|
|
12801
|
-
sendToAgentToken: () =>
|
|
12802
|
-
|
|
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: () =>
|
|
12808
|
-
|
|
12809
|
-
|
|
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: () =>
|
|
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:
|
|
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
|
|
15140
|
+
await defaultInstance()?.flushCommitter();
|
|
12856
15141
|
} catch {}
|
|
12857
15142
|
disposeAll();
|
|
12858
|
-
await
|
|
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
|