dsh-tiddlywiki 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,2153 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync, watch } from "node:fs";
3
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { execFile, spawn } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { createServer } from "node:net";
8
+ import { homedir } from "node:os";
9
+ //#region \0rolldown/runtime.js
10
+ var __defProp = Object.defineProperty;
11
+ var __exportAll = (all, no_symbols) => {
12
+ let target = {};
13
+ for (var name in all) __defProp(target, name, {
14
+ get: all[name],
15
+ enumerable: true
16
+ });
17
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
18
+ return target;
19
+ };
20
+ //#endregion
21
+ //#region src/host/git.ts
22
+ /**
23
+ * Git face (design doc §7, D11) — the ONLY place dsh-tiddlywiki shells out to
24
+ * git. The wiki folder itself is the repository; the folder is pure text
25
+ * (FileSystemAdaptor writes one file per tiddler), so git is a natural sync /
26
+ * backup channel.
27
+ *
28
+ * Sync model is the single-thread alternating one:
29
+ * 1. start of work: `git pull --rebase --autostash`
30
+ * 2. end of work: `git add -A && git commit && git push`
31
+ * 3. auto-commit: debounced 60s commit after wiki writes (AutoCommitter)
32
+ *
33
+ * Conflict policy (user-confirmed, no complex handling): a rebase conflict
34
+ * (only reachable by "forgot to pull before writing") → `git rebase --abort`
35
+ * + report the unmerged files. Never auto-merge data.
36
+ *
37
+ * @module dsh-tiddlywiki/host/git
38
+ */
39
+ var git_exports = /* @__PURE__ */ __exportAll({
40
+ AutoCommitter: () => AutoCommitter,
41
+ GitFace: () => GitFace
42
+ });
43
+ const execFileP = promisify(execFile);
44
+ /** Timeout for quick read-only queries. */
45
+ const QUICK_TIMEOUT_MS = 5e3;
46
+ /** Timeout for structural/network operations. */
47
+ const HEAVY_TIMEOUT_MS = 6e4;
48
+ /** Default exec layer: run `git <args>` under a cwd with a timeout. */
49
+ const defaultExec = async (args, options) => {
50
+ try {
51
+ const { stdout, stderr } = await execFileP("git", args, {
52
+ cwd: options.cwd,
53
+ timeout: options.timeout ?? QUICK_TIMEOUT_MS,
54
+ windowsHide: true,
55
+ encoding: "utf8",
56
+ maxBuffer: 32 * 1024 * 1024
57
+ });
58
+ return {
59
+ ok: true,
60
+ stdout,
61
+ stderr
62
+ };
63
+ } catch (err) {
64
+ const e = err;
65
+ return {
66
+ ok: false,
67
+ stdout: e.stdout ?? "",
68
+ stderr: e.stderr ?? String(e.message ?? err)
69
+ };
70
+ }
71
+ };
72
+ function parseCount(line, re) {
73
+ const m = line.match(re);
74
+ return m === null ? void 0 : Number(m[1]);
75
+ }
76
+ var GitFace = class {
77
+ exec;
78
+ constructor(exec = defaultExec) {
79
+ this.exec = exec;
80
+ }
81
+ async isRepo(dir) {
82
+ const r = await this.exec(["rev-parse", "--is-inside-work-tree"], {
83
+ cwd: dir,
84
+ timeout: 2e3
85
+ });
86
+ return r.ok && r.stdout.trim() === "true";
87
+ }
88
+ async init(dir, branch = "main") {
89
+ return (await this.exec([
90
+ "init",
91
+ "-b",
92
+ branch
93
+ ], {
94
+ cwd: dir,
95
+ timeout: HEAVY_TIMEOUT_MS
96
+ })).ok;
97
+ }
98
+ /** Initial commit for a fresh repo (tolerates an empty index). */
99
+ async initialCommit(dir) {
100
+ await this.exec(["add", "-A"], {
101
+ cwd: dir,
102
+ timeout: HEAVY_TIMEOUT_MS
103
+ });
104
+ const r = await this.exec([
105
+ ...identity(),
106
+ "commit",
107
+ "-m",
108
+ "chore(dsh-tiddlywiki): initial commit"
109
+ ], {
110
+ cwd: dir,
111
+ timeout: HEAVY_TIMEOUT_MS
112
+ });
113
+ return r.ok || /nothing to commit/.test(r.stderr + r.stdout);
114
+ }
115
+ /**
116
+ * Stage everything and commit; a local identity is always provided so the
117
+ * plugin never depends on the machine's global git config. Returns whether
118
+ * a commit actually happened.
119
+ */
120
+ async commit(dir, message) {
121
+ await this.exec(["add", "-A"], {
122
+ cwd: dir,
123
+ timeout: HEAVY_TIMEOUT_MS
124
+ });
125
+ if ((await this.exec([
126
+ "diff",
127
+ "--cached",
128
+ "--quiet"
129
+ ], {
130
+ cwd: dir,
131
+ timeout: QUICK_TIMEOUT_MS
132
+ })).ok) return {
133
+ committed: false,
134
+ message: "nothing to commit"
135
+ };
136
+ const r = await this.exec([
137
+ ...identity(),
138
+ "commit",
139
+ "-m",
140
+ message
141
+ ], {
142
+ cwd: dir,
143
+ timeout: HEAVY_TIMEOUT_MS
144
+ });
145
+ return r.ok ? {
146
+ committed: true,
147
+ message
148
+ } : {
149
+ committed: false,
150
+ message: `commit failed: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 500)}`
151
+ };
152
+ }
153
+ async status(dir) {
154
+ const empty = {
155
+ exists: false,
156
+ branch: "",
157
+ dirty: false,
158
+ dirtyFiles: [],
159
+ remote: ""
160
+ };
161
+ const r = await this.exec([
162
+ "status",
163
+ "--porcelain",
164
+ "-b"
165
+ ], {
166
+ cwd: dir,
167
+ timeout: QUICK_TIMEOUT_MS
168
+ });
169
+ if (!r.ok) return empty;
170
+ const lines = r.stdout.split("\n").filter((l) => l.length > 0);
171
+ const branchLine = lines.find((l) => l.startsWith("## "));
172
+ const branch = branchLine === void 0 ? "" : branchLine.slice(3).split("...")[0] ?? "";
173
+ const ahead = branchLine === void 0 ? void 0 : parseCount(branchLine, /ahead (\d+)/);
174
+ const behind = branchLine === void 0 ? void 0 : parseCount(branchLine, /behind (\d+)/);
175
+ const dirty = lines.some((l) => !l.startsWith("## "));
176
+ const dirtyFiles = lines.filter((l) => !l.startsWith("## ")).map((l) => l.slice(3).trim()).filter(Boolean);
177
+ const remoteR = await this.exec(["remote", "-v"], {
178
+ cwd: dir,
179
+ timeout: QUICK_TIMEOUT_MS
180
+ });
181
+ const remote = remoteR.ok ? remoteR.stdout.split("\n").map((l) => l.trim()).find(Boolean) ?? "" : "";
182
+ const lastR = await this.exec([
183
+ "log",
184
+ "-1",
185
+ "--format=%h %s"
186
+ ], {
187
+ cwd: dir,
188
+ timeout: QUICK_TIMEOUT_MS
189
+ });
190
+ const lastCommit = lastR.ok && lastR.stdout.trim().length > 0 ? lastR.stdout.trim() : void 0;
191
+ return {
192
+ exists: true,
193
+ branch,
194
+ dirty,
195
+ dirtyFiles,
196
+ remote,
197
+ ...lastCommit !== void 0 ? { lastCommit } : {},
198
+ ...ahead !== void 0 ? { ahead } : {},
199
+ ...behind !== void 0 ? { behind } : {}
200
+ };
201
+ }
202
+ /** `git pull --rebase --autostash`; on conflict: abort + report files. */
203
+ async pull(dir) {
204
+ const r = await this.exec([
205
+ "pull",
206
+ "--rebase",
207
+ "--autostash"
208
+ ], {
209
+ cwd: dir,
210
+ timeout: HEAVY_TIMEOUT_MS
211
+ });
212
+ if (r.ok) return {
213
+ ok: true,
214
+ message: r.stdout.trim() || "pull ok"
215
+ };
216
+ const conflictFiles = await this.unmergedFiles(dir);
217
+ await this.exec(["rebase", "--abort"], {
218
+ cwd: dir,
219
+ timeout: HEAVY_TIMEOUT_MS
220
+ });
221
+ const reason = (r.stderr.trim() || r.stdout.trim()).slice(0, 500);
222
+ return {
223
+ ok: false,
224
+ message: conflictFiles.length > 0 ? `conflict in ${conflictFiles.join(", ")} (rebase aborted): ${reason}` : `pull failed: ${reason}`,
225
+ ...conflictFiles.length > 0 ? { conflictFiles } : {}
226
+ };
227
+ }
228
+ async push(dir) {
229
+ const r = await this.exec(["push"], {
230
+ cwd: dir,
231
+ timeout: HEAVY_TIMEOUT_MS
232
+ });
233
+ return r.ok ? {
234
+ ok: true,
235
+ message: r.stdout.trim() || "push ok"
236
+ } : {
237
+ ok: false,
238
+ message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
239
+ };
240
+ }
241
+ /** First push with upstream tracking (called once after a remote is set). */
242
+ async firstPush(dir) {
243
+ const branch = (await this.status(dir)).branch || "main";
244
+ const r = await this.exec([
245
+ "push",
246
+ "-u",
247
+ "origin",
248
+ branch
249
+ ], {
250
+ cwd: dir,
251
+ timeout: HEAVY_TIMEOUT_MS
252
+ });
253
+ return r.ok ? {
254
+ ok: true,
255
+ message: `pushed ${branch} to origin`
256
+ } : {
257
+ ok: false,
258
+ message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
259
+ };
260
+ }
261
+ /** Ensure `origin` points at `url` (add or set-url). */
262
+ async ensureRemote(dir, url) {
263
+ const cur = await this.exec([
264
+ "remote",
265
+ "get-url",
266
+ "origin"
267
+ ], {
268
+ cwd: dir,
269
+ timeout: QUICK_TIMEOUT_MS
270
+ });
271
+ if (cur.ok) {
272
+ if (cur.stdout.trim() === url) return {
273
+ ok: true,
274
+ message: "remote origin already set"
275
+ };
276
+ const set = await this.exec([
277
+ "remote",
278
+ "set-url",
279
+ "origin",
280
+ url
281
+ ], {
282
+ cwd: dir,
283
+ timeout: HEAVY_TIMEOUT_MS
284
+ });
285
+ return set.ok ? {
286
+ ok: true,
287
+ message: `remote origin → ${url}`
288
+ } : {
289
+ ok: false,
290
+ message: set.stderr.trim() || "remote set-url failed"
291
+ };
292
+ }
293
+ const add = await this.exec([
294
+ "remote",
295
+ "add",
296
+ "origin",
297
+ url
298
+ ], {
299
+ cwd: dir,
300
+ timeout: HEAVY_TIMEOUT_MS
301
+ });
302
+ return add.ok ? {
303
+ ok: true,
304
+ message: `remote origin → ${url}`
305
+ } : {
306
+ ok: false,
307
+ message: add.stderr.trim() || "remote add failed"
308
+ };
309
+ }
310
+ async unmergedFiles(dir) {
311
+ const r = await this.exec([
312
+ "diff",
313
+ "--name-only",
314
+ "--diff-filter=U"
315
+ ], {
316
+ cwd: dir,
317
+ timeout: QUICK_TIMEOUT_MS
318
+ });
319
+ return r.ok ? r.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : [];
320
+ }
321
+ };
322
+ /** Always-on local identity so commits never depend on global git config. */
323
+ function identity() {
324
+ return [
325
+ "-c",
326
+ "user.name=dsh-tiddlywiki",
327
+ "-c",
328
+ "user.email=dsh-tiddlywiki@local"
329
+ ];
330
+ }
331
+ /**
332
+ * Debounced auto-committer: every wiki write calls `touch()`; the commit
333
+ * fires once writes settle for `debounceMs`. Disable with git.autoCommit.
334
+ */
335
+ var AutoCommitter = class {
336
+ options;
337
+ timer;
338
+ disposed = false;
339
+ constructor(options) {
340
+ this.options = options;
341
+ }
342
+ touch() {
343
+ if (!this.options.enabled || this.disposed) return;
344
+ if (this.timer !== void 0) clearTimeout(this.timer);
345
+ this.timer = setTimeout(() => {
346
+ this.flush();
347
+ }, this.options.debounceMs);
348
+ }
349
+ /** Run a commit now (also cancels the pending debounce). */
350
+ async flush() {
351
+ if (this.timer !== void 0) {
352
+ clearTimeout(this.timer);
353
+ this.timer = void 0;
354
+ }
355
+ if (!this.options.enabled || this.disposed) return;
356
+ try {
357
+ const result = await this.options.git.commit(this.options.dir, this.options.message());
358
+ this.options.onCommit?.(result);
359
+ } catch (err) {
360
+ this.options.onError?.(err);
361
+ }
362
+ }
363
+ dispose() {
364
+ this.disposed = true;
365
+ if (this.timer !== void 0) {
366
+ clearTimeout(this.timer);
367
+ this.timer = void 0;
368
+ }
369
+ }
370
+ };
371
+ //#endregion
372
+ //#region src/host/wiki.ts
373
+ /**
374
+ * WikiServer — the TiddlyWiki 5 child-process lifecycle (design doc §9, D3).
375
+ *
376
+ * Zero-friction rules:
377
+ * - ensure the wiki folder exists (scaffold with `--init server` once)
378
+ * - git bootstrap is NOT this class's job (index.ts owns the GitFace)
379
+ * - auto-detect a free loopback port unless one is pinned in config
380
+ * - spawn `node <tw>/tiddlywiki.js <wiki> --listen host=127.0.0.1 ...`
381
+ * and poll /status until it answers 200
382
+ * - the TW child serves at the ROOT of its own dedicated loopback port (no
383
+ * `path-prefix`): TW's browser frontend builds its API URLs from
384
+ * `$protocol$//$host$/` only, so any path-prefix makes every frontend call
385
+ * ../../status → 404 (verified against tiddlywiki 5.4.1). Namespacing lives
386
+ * on the DSH webserver side (/dsh-tiddlywiki/* routes), never in TW itself.
387
+ * - crash → restart with exponential backoff (1s,2s,4s… cap 30s), reset on
388
+ * a successful readiness
389
+ * - stop() is deterministic: SIGTERM, escalate to SIGKILL after a grace
390
+ * period, and never leave a timer that would respawn during teardown
391
+ *
392
+ * @module dsh-tiddlywiki/host/wiki
393
+ */
394
+ /** The DSH webserver route prefix (NOT a TW path-prefix; see module header). */
395
+ const PATH_PREFIX = "/dsh-tiddlywiki";
396
+ /** How long to wait for the wiki to answer /status. */
397
+ const READY_TIMEOUT_MS = 2e4;
398
+ /** Poll cadence while waiting for readiness. */
399
+ const READY_POLL_MS = 500;
400
+ /** Backoff ceiling for crash restarts. */
401
+ const MAX_RESTART_BACKOFF_MS = 3e4;
402
+ /** SIGTERM → SIGKILL escalation grace. */
403
+ const KILL_GRACE_MS = 3e3;
404
+ /** Ring-buffer cap for the stdout/stderr log. */
405
+ const LOG_BUFFER_LIMIT = 200;
406
+ /** One-shot scaffold timeout for `--init server`. */
407
+ const INIT_TIMEOUT_MS = 3e4;
408
+ /** Resolve the absolute entry of the installed `tiddlywiki` package. */
409
+ function resolveTwEntry() {
410
+ return createRequire(import.meta.url).resolve("tiddlywiki/tiddlywiki.js");
411
+ }
412
+ var WikiServer = class {
413
+ options;
414
+ child;
415
+ wikiPath;
416
+ logs = [];
417
+ logLimit;
418
+ health = "stopped";
419
+ port;
420
+ stopping = false;
421
+ restartTimer;
422
+ restartDelay = 1e3;
423
+ lastStartedAt;
424
+ error;
425
+ constructor(options) {
426
+ this.options = options;
427
+ this.wikiPath = resolve(options.wikiRoot, options.wiki);
428
+ this.logLimit = options.logBufferLimit ?? LOG_BUFFER_LIMIT;
429
+ }
430
+ /** Base URL of the TW service, once a port is bound (root, no path prefix). */
431
+ get url() {
432
+ return this.port === void 0 ? void 0 : `http://127.0.0.1:${this.port}`;
433
+ }
434
+ /** The currently bound port (undefined until first spawn). */
435
+ get currentPort() {
436
+ return this.port;
437
+ }
438
+ log(line) {
439
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
440
+ this.logs.push(`[${ts}] ${line}`);
441
+ if (this.logs.length > this.logLimit) this.logs.splice(0, this.logs.length - this.logLimit);
442
+ }
443
+ /** Scaffold the wiki folder with `--init server` when it is absent. */
444
+ async ensureWiki() {
445
+ await mkdir(this.wikiPath, { recursive: true });
446
+ if (existsSync(join(this.wikiPath, "tiddlywiki.info"))) return;
447
+ const tw = resolveTwEntry();
448
+ this.log(`init: ${process.execPath} ${tw} ${this.wikiPath} --init server`);
449
+ await new Promise((resolveP, rejectP) => {
450
+ execFile(process.execPath, [
451
+ tw,
452
+ this.wikiPath,
453
+ "--init",
454
+ "server"
455
+ ], {
456
+ timeout: INIT_TIMEOUT_MS,
457
+ windowsHide: true
458
+ }, (err) => {
459
+ if (err) rejectP(err);
460
+ else resolveP();
461
+ });
462
+ });
463
+ }
464
+ /** Probe a free loopback port. */
465
+ async findFreePort() {
466
+ return new Promise((resolveP, rejectP) => {
467
+ const server = createServer();
468
+ server.unref();
469
+ server.once("error", rejectP);
470
+ server.listen(0, "127.0.0.1", () => {
471
+ const address = server.address();
472
+ if (address === null || typeof address === "string") {
473
+ server.close();
474
+ rejectP(/* @__PURE__ */ new Error("cannot resolve a free port"));
475
+ return;
476
+ }
477
+ const port = address.port;
478
+ server.close(() => resolveP(port));
479
+ });
480
+ });
481
+ }
482
+ /**
483
+ * Start (or restart) the TW child. Resolves once `/status` answers 200 or
484
+ * the readiness deadline passes. Never throws on a crash — the exit handler
485
+ * schedules a self-healing restart unless we are stopping.
486
+ */
487
+ async start() {
488
+ this.stopping = false;
489
+ this.restartDelay = 1e3;
490
+ await this.ensureWiki();
491
+ if (this.child !== void 0) return this.status();
492
+ this.health = "starting";
493
+ const port = this.options.port > 0 ? this.options.port : (this.port ?? 0) > 0 ? this.port : await this.findFreePort();
494
+ this.port = port;
495
+ const args = [
496
+ resolveTwEntry(),
497
+ this.wikiPath,
498
+ "--listen",
499
+ "host=127.0.0.1",
500
+ `port=${port}`
501
+ ];
502
+ if (this.options.username) {
503
+ args.push(`username=${this.options.username}`);
504
+ args.push(`password=${this.options.password ?? ""}`);
505
+ args.push(`readers=${this.options.username}`);
506
+ args.push(`writers=${this.options.username}`);
507
+ }
508
+ this.log(`spawn: ${process.execPath} ${args.join(" ")}`);
509
+ const child = spawn(process.execPath, args, {
510
+ cwd: this.wikiPath,
511
+ stdio: [
512
+ "ignore",
513
+ "pipe",
514
+ "pipe"
515
+ ],
516
+ windowsHide: true
517
+ });
518
+ this.child = child;
519
+ child.stdout.on("data", (chunk) => this.log(`[out] ${String(chunk).trimEnd()}`));
520
+ child.stderr.on("data", (chunk) => this.log(`[err] ${String(chunk).trimEnd()}`));
521
+ child.once("exit", (code, signal) => {
522
+ this.log(`exit code=${code} signal=${signal ?? ""} stopping=${this.stopping}`);
523
+ this.child = void 0;
524
+ this.health = "stopped";
525
+ if (!this.stopping) this.scheduleRestart();
526
+ });
527
+ child.once("error", (err) => {
528
+ this.log(`spawn error: ${err.message}`);
529
+ this.error = err.message;
530
+ this.child = void 0;
531
+ this.health = "failed";
532
+ if (!this.stopping) this.scheduleRestart();
533
+ });
534
+ this.lastStartedAt = Date.now();
535
+ await this.waitReady();
536
+ return this.status();
537
+ }
538
+ /** Poll /status until 200 or the deadline; throws only on deadline/crash. */
539
+ async waitReady() {
540
+ const deadline = Date.now() + READY_TIMEOUT_MS;
541
+ for (;;) {
542
+ if (this.child === void 0) throw new Error("wiki process exited before ready");
543
+ try {
544
+ if ((await fetch(`${this.url}/status`, { signal: AbortSignal.timeout(2e3) })).ok) {
545
+ this.health = "running";
546
+ this.log("ready: /status 200");
547
+ return;
548
+ }
549
+ } catch {}
550
+ if (Date.now() > deadline) {
551
+ this.health = "failed";
552
+ this.error = "wiki server did not become ready in time";
553
+ this.log(this.error);
554
+ throw new Error(this.error);
555
+ }
556
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
557
+ }
558
+ }
559
+ scheduleRestart() {
560
+ if (this.stopping || this.restartTimer !== void 0) return;
561
+ const delay = this.restartDelay;
562
+ this.restartDelay = Math.min(this.restartDelay * 2, MAX_RESTART_BACKOFF_MS);
563
+ this.log(`restart scheduled in ${delay}ms`);
564
+ this.health = "starting";
565
+ this.restartTimer = setTimeout(() => {
566
+ this.restartTimer = void 0;
567
+ this.start().catch((err) => {
568
+ this.health = "failed";
569
+ this.error = err instanceof Error ? err.message : String(err);
570
+ this.log(`restart failed: ${this.error}`);
571
+ });
572
+ }, delay);
573
+ }
574
+ /** One-click restart (route /dsh-tiddlywiki/restart, panel retry button). */
575
+ async restart() {
576
+ await this.stop();
577
+ return this.start();
578
+ }
579
+ /** Deterministic teardown: cancel timers, SIGTERM, escalate to SIGKILL. */
580
+ async stop() {
581
+ this.stopping = true;
582
+ if (this.restartTimer !== void 0) {
583
+ clearTimeout(this.restartTimer);
584
+ this.restartTimer = void 0;
585
+ }
586
+ const child = this.child;
587
+ this.child = void 0;
588
+ if (child !== void 0 && child.exitCode === null && child.signalCode === null) {
589
+ try {
590
+ child.kill("SIGTERM");
591
+ } catch {}
592
+ await Promise.race([new Promise((r) => child.once("exit", () => r())), new Promise((r) => {
593
+ setTimeout(() => {
594
+ try {
595
+ child.kill("SIGKILL");
596
+ } catch {}
597
+ r();
598
+ }, KILL_GRACE_MS).unref?.();
599
+ })]);
600
+ }
601
+ this.health = "stopped";
602
+ }
603
+ /** Live status view (health, url, git-independent, recent logs). */
604
+ status() {
605
+ return {
606
+ status: this.health,
607
+ url: this.url,
608
+ port: this.port,
609
+ wikiPath: this.wikiPath,
610
+ pid: this.child?.pid,
611
+ lastStartedAt: this.lastStartedAt,
612
+ ...this.error !== void 0 ? { error: this.error } : {},
613
+ logs: [...this.logs]
614
+ };
615
+ }
616
+ };
617
+ //#endregion
618
+ //#region src/host/routes.ts
619
+ const ROUTE_PREFIX = PATH_PREFIX;
620
+ /** Max JSON body for note/restart. */
621
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
622
+ /** Max passthrough body (tiddler content can be large). */
623
+ const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024;
624
+ function readBody$1(req, limit = MAX_BODY_BYTES) {
625
+ return new Promise((resolveP, rejectP) => {
626
+ let size = 0;
627
+ const chunks = [];
628
+ req.on("data", (chunk) => {
629
+ size += chunk.length;
630
+ if (size > limit) {
631
+ rejectP(/* @__PURE__ */ new Error("body too large"));
632
+ req.destroy();
633
+ return;
634
+ }
635
+ chunks.push(chunk);
636
+ });
637
+ req.on("end", () => resolveP(Buffer.concat(chunks).toString("utf8")));
638
+ req.on("error", rejectP);
639
+ });
640
+ }
641
+ function json$1(res, payload, status = 200) {
642
+ const body = JSON.stringify(payload);
643
+ res.writeHead(status, {
644
+ "content-type": "application/json; charset=utf-8",
645
+ "cache-control": "no-store"
646
+ });
647
+ res.end(body);
648
+ }
649
+ function pad(n) {
650
+ return n < 10 ? `0${n}` : String(n);
651
+ }
652
+ /** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
653
+ function timestampTitle(date = /* @__PURE__ */ new Date()) {
654
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
655
+ }
656
+ /**
657
+ * Open a tiddler in TW's NATIVE editor: save the tiddler (when text is
658
+ * non-empty), reuse or create a DRAFT tiddler carrying `draft.of`/`draft.title`
659
+ * (TW's story view renders drafts with the EditTemplate — list.js:
660
+ * `isDraft && editTemplate`), and return the draft title so the client can
661
+ * navigate the panel iframe to `#<draftTitle>`.
662
+ */
663
+ async function openInTwEditor(client, title, text, tag) {
664
+ if (text.trim().length > 0) await client.put({
665
+ title,
666
+ text,
667
+ tags: [tag]
668
+ });
669
+ let draftText = text;
670
+ if (draftText.trim().length === 0) draftText = (await client.get(title))?.text ?? "";
671
+ let draftTitle;
672
+ try {
673
+ const items = await client.list(void 0, true);
674
+ for (const item of items) if (item["draft.of"] === title && typeof item.title === "string") {
675
+ draftTitle = item.title;
676
+ break;
677
+ }
678
+ } catch {}
679
+ if (draftTitle === void 0) draftTitle = `Draft of "${title}" ${Date.now()}`;
680
+ await client.put({
681
+ title: draftTitle,
682
+ text: draftText,
683
+ "draft.of": title,
684
+ "draft.title": title,
685
+ type: "text/vnd.tiddlywiki"
686
+ });
687
+ return {
688
+ title,
689
+ draftTitle
690
+ };
691
+ }
692
+ function registerRoutes(ctx, deps) {
693
+ const handleStatus = async (_req, res) => {
694
+ const view = deps.server.status();
695
+ let gitSummary = null;
696
+ try {
697
+ gitSummary = await deps.git.status(deps.getWikiPath());
698
+ } catch {
699
+ gitSummary = null;
700
+ }
701
+ json$1(res, {
702
+ ok: true,
703
+ ...view,
704
+ git: gitSummary,
705
+ note: { tag: deps.noteDefaults().tag }
706
+ });
707
+ };
708
+ const handleNote = async (req, res) => {
709
+ try {
710
+ const body = JSON.parse(await readBody$1(req));
711
+ const text = typeof body.text === "string" && body.text.trim().length > 0 ? body.text.trim() : null;
712
+ if (text === null) {
713
+ json$1(res, {
714
+ ok: false,
715
+ error: "text is required"
716
+ }, 400);
717
+ return;
718
+ }
719
+ const client = deps.getClient();
720
+ if (client === void 0) {
721
+ json$1(res, {
722
+ ok: false,
723
+ error: "wiki service is not running"
724
+ }, 503);
725
+ return;
726
+ }
727
+ const title = typeof body.title === "string" && body.title.trim().length > 0 ? body.title.trim() : timestampTitle();
728
+ const tag = typeof body.tag === "string" && body.tag.trim().length > 0 ? body.tag.trim() : deps.noteDefaults().tag;
729
+ await client.put({
730
+ title,
731
+ text,
732
+ tags: [tag]
733
+ });
734
+ deps.autoCommit();
735
+ json$1(res, {
736
+ ok: true,
737
+ title,
738
+ tag,
739
+ text
740
+ });
741
+ } catch (err) {
742
+ json$1(res, {
743
+ ok: false,
744
+ error: err instanceof Error ? err.message : String(err)
745
+ }, 500);
746
+ }
747
+ };
748
+ const handleEdit = async (req, res) => {
749
+ try {
750
+ const body = JSON.parse(await readBody$1(req));
751
+ const client = deps.getClient();
752
+ if (client === void 0) {
753
+ json$1(res, {
754
+ ok: false,
755
+ error: "wiki service is not running"
756
+ }, 503);
757
+ return;
758
+ }
759
+ const title = typeof body.title === "string" && body.title.trim().length > 0 ? body.title.trim() : timestampTitle();
760
+ const tag = typeof body.tag === "string" && body.tag.trim().length > 0 ? body.tag.trim() : deps.noteDefaults().tag;
761
+ const result = await openInTwEditor(client, title, typeof body.text === "string" ? body.text : "", tag);
762
+ deps.autoCommit();
763
+ json$1(res, {
764
+ ok: true,
765
+ ...result,
766
+ twUrl: deps.server.url
767
+ });
768
+ } catch (err) {
769
+ json$1(res, {
770
+ ok: false,
771
+ error: err instanceof Error ? err.message : String(err)
772
+ }, 500);
773
+ }
774
+ };
775
+ const handleRestart = async (_req, res) => {
776
+ try {
777
+ await deps.server.restart();
778
+ json$1(res, {
779
+ ok: true,
780
+ status: deps.server.status().status
781
+ });
782
+ } catch (err) {
783
+ json$1(res, {
784
+ ok: false,
785
+ error: err instanceof Error ? err.message : String(err)
786
+ }, 500);
787
+ }
788
+ };
789
+ /** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
790
+ const handleApiProxy = async (req, res) => {
791
+ if (deps.getClient() === void 0) {
792
+ json$1(res, {
793
+ ok: false,
794
+ error: "wiki service is not running"
795
+ }, 503);
796
+ return;
797
+ }
798
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
799
+ const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, "") || "/";
800
+ try {
801
+ const headers = {};
802
+ const ct = req.headers["content-type"];
803
+ if (typeof ct === "string") headers["content-type"] = ct;
804
+ const method = (req.method ?? "GET").toUpperCase();
805
+ if (method === "PUT" || method === "DELETE" || method === "POST") headers["x-requested-with"] = "TiddlyWiki";
806
+ const init = {
807
+ method,
808
+ headers,
809
+ signal: AbortSignal.timeout(15e3)
810
+ };
811
+ if (method === "PUT" || method === "POST") init.body = await readBody$1(req, MAX_PROXY_BODY_BYTES);
812
+ const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init);
813
+ const data = await upstream.text();
814
+ res.writeHead(upstream.status, {
815
+ "content-type": upstream.headers.get("content-type") ?? "application/json; charset=utf-8",
816
+ "cache-control": "no-store"
817
+ });
818
+ res.end(data);
819
+ } catch (err) {
820
+ json$1(res, {
821
+ ok: false,
822
+ error: err instanceof Error ? err.message : String(err)
823
+ }, 502);
824
+ }
825
+ };
826
+ const disposers = [
827
+ ctx.webServer.register({
828
+ kind: "exact",
829
+ path: `${ROUTE_PREFIX}/status`,
830
+ handler: (req, res) => {
831
+ handleStatus(req, res);
832
+ }
833
+ }),
834
+ ctx.webServer.register({
835
+ kind: "exact",
836
+ path: `${ROUTE_PREFIX}/note`,
837
+ handler: (req, res) => {
838
+ handleNote(req, res);
839
+ }
840
+ }),
841
+ ctx.webServer.register({
842
+ kind: "exact",
843
+ path: `${ROUTE_PREFIX}/edit`,
844
+ handler: (req, res) => {
845
+ handleEdit(req, res);
846
+ }
847
+ }),
848
+ ctx.webServer.register({
849
+ kind: "exact",
850
+ path: `${ROUTE_PREFIX}/restart`,
851
+ handler: (req, res) => {
852
+ handleRestart(req, res);
853
+ }
854
+ }),
855
+ ctx.webServer.register({
856
+ kind: "prefix",
857
+ path: `${ROUTE_PREFIX}/api`,
858
+ handler: (req, res) => {
859
+ handleApiProxy(req, res);
860
+ }
861
+ })
862
+ ];
863
+ return () => {
864
+ for (const dispose of disposers) dispose();
865
+ };
866
+ }
867
+ //#endregion
868
+ //#region src/host/config.ts
869
+ /** Config tiddler (JSON string) where the settings page stores overrides. */
870
+ const CONFIG_TIDDLER = "$:/plugins/dsh-tiddlywiki/config";
871
+ function isPlainObject(value) {
872
+ return typeof value === "object" && value !== null && !Array.isArray(value);
873
+ }
874
+ /** Deep-merge: `over` wins; nested plain objects merge recursively. */
875
+ function deepMerge(base, over) {
876
+ const out = { ...base };
877
+ for (const [key, value] of Object.entries(over)) {
878
+ if (value === void 0) continue;
879
+ if (isPlainObject(value) && isPlainObject(out[key])) out[key] = deepMerge(out[key], value);
880
+ else out[key] = value;
881
+ }
882
+ return out;
883
+ }
884
+ /**
885
+ * Runtime config store: caches the override tiddler and exposes the effective
886
+ * (merged) config. `load` runs at startup and after every write/restart.
887
+ */
888
+ var ConfigStore = class {
889
+ base;
890
+ overrides = {};
891
+ constructor(base) {
892
+ this.base = base;
893
+ }
894
+ /** Effective config = cordis base overlaid with the user override tiddler. */
895
+ get() {
896
+ return deepMerge(this.base, this.overrides);
897
+ }
898
+ /** Reload the override tiddler (no-op when the wiki is unavailable). */
899
+ async load(client) {
900
+ this.overrides = {};
901
+ if (client === void 0) return;
902
+ try {
903
+ const tiddler = await client.get(CONFIG_TIDDLER);
904
+ if (tiddler !== void 0 && typeof tiddler.text === "string") {
905
+ const parsed = JSON.parse(tiddler.text);
906
+ if (isPlainObject(parsed)) this.overrides = parsed;
907
+ }
908
+ } catch {
909
+ this.overrides = {};
910
+ }
911
+ }
912
+ /** Merge a patch into the overrides and persist the tiddler. */
913
+ async set(client, patch) {
914
+ this.overrides = deepMerge(this.overrides, patch);
915
+ await client.put({
916
+ title: CONFIG_TIDDLER,
917
+ text: JSON.stringify(this.overrides, null, 2),
918
+ type: "application/json",
919
+ tags: []
920
+ });
921
+ return this.get();
922
+ }
923
+ };
924
+ //#endregion
925
+ //#region src/host/admin.ts
926
+ /**
927
+ * Admin surface for the plugin settings page (design doc §13, config panel).
928
+ *
929
+ * - dynamic plugin/theme management: enumerate the bundled catalog from the
930
+ * installed tiddlywiki package, read/write the wiki's `tiddlywiki.info`
931
+ * plugins/themes arrays, then restart the TW child so the change applies;
932
+ * - extensible config: the settings page reads/writes a config tiddler
933
+ * ($:/plugins/dsh-tiddlywiki/config, a JSON string) that overlays the
934
+ * cordis `config:` block — future config fields just extend the shape.
935
+ *
936
+ * Routes (all under ROUTE_PREFIX/admin, JSON):
937
+ * GET /admin/state current info + catalog + effective config + status
938
+ * POST /admin/info { plugins?, themes? } → write info → restart TW
939
+ * POST /admin/config { ...patch } → write config tiddler
940
+ * POST /admin/restart restart the TW child
941
+ *
942
+ * @module dsh-tiddlywiki/host/admin
943
+ */
944
+ /** Resolve the installed tiddlywiki package root (for the catalog). */
945
+ function resolveTwRoot() {
946
+ return dirname(createRequire(import.meta.url).resolve("tiddlywiki/package.json"));
947
+ }
948
+ /** Read the wiki's tiddlywiki.info. */
949
+ async function readWikiInfo(wikiPath) {
950
+ let raw;
951
+ try {
952
+ raw = await readFile(join(wikiPath, "tiddlywiki.info"), "utf8");
953
+ } catch {
954
+ return {
955
+ plugins: [],
956
+ themes: [],
957
+ languages: []
958
+ };
959
+ }
960
+ const parsed = JSON.parse(raw);
961
+ return {
962
+ description: parsed.description,
963
+ plugins: parsed.plugins ?? [],
964
+ themes: parsed.themes ?? [],
965
+ languages: parsed.languages ?? [],
966
+ ...parsed
967
+ };
968
+ }
969
+ /** Write the wiki's tiddlywiki.info (pretty-printed, ordering preserved). */
970
+ async function writeWikiInfo(wikiPath, info) {
971
+ await writeFile(join(wikiPath, "tiddlywiki.info"), `${JSON.stringify(info, null, 4)}\n`, "utf8");
972
+ }
973
+ /** Enumerate bundled official plugins + themes + languages of tiddlywiki. */
974
+ async function bundledCatalog(twRoot) {
975
+ const themeHasCss = async (dir) => {
976
+ for (const name of ["base.tid", "styles.tid"]) try {
977
+ if ((await readFile(join(twRoot, "themes", "tiddlywiki", dir, name), "utf8")).replace(/^[\s\S]*?\r?\n\r?\n/, "").split("\n").filter((line) => !/^\\rules\b/.test(line.trim())).join("\n").trim().length > 0) return true;
978
+ } catch {}
979
+ return false;
980
+ };
981
+ const scan = async (sub) => {
982
+ const root = join(twRoot, sub, "tiddlywiki");
983
+ let dirs;
984
+ try {
985
+ dirs = await readdir(root);
986
+ } catch {
987
+ return [];
988
+ }
989
+ const out = [];
990
+ for (const dir of dirs) {
991
+ let info = {};
992
+ try {
993
+ info = JSON.parse(await readFile(join(root, dir, "plugin.info"), "utf8"));
994
+ } catch {
995
+ info = {};
996
+ }
997
+ if (sub === "themes" && dir !== "vanilla" && !await themeHasCss(dir)) continue;
998
+ out.push({
999
+ name: `tiddlywiki/${dir}`,
1000
+ title: sub === "plugins" ? `$:/plugins/tiddlywiki/${dir}` : `$:/themes/tiddlywiki/${dir}`,
1001
+ label: info.name ?? dir,
1002
+ description: info.description ?? "",
1003
+ dependents: Array.isArray(info.dependents) ? info.dependents.map((dep) => dep.replace(/^\$:\/themes\/tiddlywiki\//, "tiddlywiki/")) : void 0
1004
+ });
1005
+ }
1006
+ out.sort((a, b) => a.name.localeCompare(b.name));
1007
+ return out;
1008
+ };
1009
+ const scanLanguages = async () => {
1010
+ const root = join(twRoot, "languages");
1011
+ let dirs;
1012
+ try {
1013
+ dirs = await readdir(root);
1014
+ } catch {
1015
+ return [];
1016
+ }
1017
+ const out = [];
1018
+ for (const dir of dirs) {
1019
+ let info = {};
1020
+ try {
1021
+ info = JSON.parse(await readFile(join(root, dir, "plugin.info"), "utf8"));
1022
+ } catch {
1023
+ info = {};
1024
+ }
1025
+ out.push({
1026
+ name: dir,
1027
+ title: `$:/languages/${dir}`,
1028
+ label: info.name ?? dir,
1029
+ description: info.description ?? ""
1030
+ });
1031
+ }
1032
+ out.sort((a, b) => a.name.localeCompare(b.name));
1033
+ return out;
1034
+ };
1035
+ const [plugins, themes, languages] = await Promise.all([
1036
+ scan("plugins"),
1037
+ scan("themes"),
1038
+ scanLanguages()
1039
+ ]);
1040
+ return {
1041
+ plugins,
1042
+ themes,
1043
+ languages
1044
+ };
1045
+ }
1046
+ /**
1047
+ * Normalize a theme selection into the tiddlywiki.info `themes` array.
1048
+ *
1049
+ * TW themes are SKINS with a dependency chain (plugin.info `dependents`):
1050
+ * vanilla ← snowwhite ← heavier / centralised / readonly / starlight
1051
+ * vanilla ← tight / seamless
1052
+ * The ACTIVE theme is `$:/theme`, and switching to it registers the theme PLUS
1053
+ * its transitive dependents (boot.js accumulatePlugin) — if a dependent isn't
1054
+ * loaded, the vanilla base stylesheet is lost and the UI breaks. So we always
1055
+ * emit the transitive closure, dependency-first (base first, active overlay
1056
+ * last), and force vanilla in as the base. Empty selection → vanilla.
1057
+ */
1058
+ function normalizeThemes(selected, deps = {}) {
1059
+ const sel = selected.filter((name) => typeof name === "string" && name.length > 0);
1060
+ if (sel.length === 0) sel.push("tiddlywiki/vanilla");
1061
+ const out = [];
1062
+ const seen = /* @__PURE__ */ new Set();
1063
+ const visit = (name) => {
1064
+ if (seen.has(name)) return;
1065
+ seen.add(name);
1066
+ for (const dep of deps[name] ?? []) if (dep !== name) visit(dep);
1067
+ out.push(name);
1068
+ };
1069
+ for (const name of sel) visit(name);
1070
+ if (!out.includes("tiddlywiki/vanilla")) out.unshift("tiddlywiki/vanilla");
1071
+ return out;
1072
+ }
1073
+ /**
1074
+ * Ensure a language code (e.g. "zh-Hans") is in tiddlywiki.info `languages`.
1075
+ * Returns whether tiddlywiki.info changed (caller decides whether to restart).
1076
+ */
1077
+ async function ensureLanguage(wikiPath, twRoot, lang) {
1078
+ if (typeof lang !== "string" || lang.trim().length === 0) return false;
1079
+ const code = lang.trim();
1080
+ if (!(await bundledCatalog(twRoot)).languages.some((l) => l.name === code)) throw new Error(`unknown language plugin: ${code}`);
1081
+ const info = await readWikiInfo(wikiPath);
1082
+ const current = info.languages ?? [];
1083
+ if (current.includes(code)) return false;
1084
+ info.languages = [...current, code];
1085
+ await writeWikiInfo(wikiPath, info);
1086
+ return true;
1087
+ }
1088
+ function json(res, payload, status = 200) {
1089
+ res.writeHead(status, {
1090
+ "content-type": "application/json; charset=utf-8",
1091
+ "cache-control": "no-store"
1092
+ });
1093
+ res.end(JSON.stringify(payload));
1094
+ }
1095
+ async function readBody(req, limit = 1024 * 1024) {
1096
+ return new Promise((resolveP, rejectP) => {
1097
+ let size = 0;
1098
+ const chunks = [];
1099
+ req.on("data", (chunk) => {
1100
+ size += chunk.length;
1101
+ if (size > limit) {
1102
+ rejectP(/* @__PURE__ */ new Error("body too large"));
1103
+ req.destroy();
1104
+ return;
1105
+ }
1106
+ chunks.push(chunk);
1107
+ });
1108
+ req.on("end", () => resolveP(Buffer.concat(chunks).toString("utf8")));
1109
+ req.on("error", rejectP);
1110
+ });
1111
+ }
1112
+ function registerAdminRoutes(ctx, deps) {
1113
+ const handleState = async (_req, res) => {
1114
+ try {
1115
+ const wikiPath = deps.getWikiPath();
1116
+ const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())]);
1117
+ let git = null;
1118
+ try {
1119
+ const { GitFace } = await Promise.resolve().then(() => git_exports);
1120
+ git = await new GitFace().status(wikiPath);
1121
+ } catch {
1122
+ git = null;
1123
+ }
1124
+ json(res, {
1125
+ ok: true,
1126
+ server: deps.server.status(),
1127
+ info: {
1128
+ plugins: info.plugins,
1129
+ themes: info.themes,
1130
+ languages: info.languages ?? []
1131
+ },
1132
+ catalog,
1133
+ config: deps.config.get(),
1134
+ git
1135
+ });
1136
+ } catch (err) {
1137
+ json(res, {
1138
+ ok: false,
1139
+ error: err instanceof Error ? err.message : String(err)
1140
+ }, 500);
1141
+ }
1142
+ };
1143
+ const handleInfo = async (req, res) => {
1144
+ try {
1145
+ const body = JSON.parse(await readBody(req));
1146
+ const wikiPath = deps.getWikiPath();
1147
+ const info = await readWikiInfo(wikiPath);
1148
+ const catalog = await bundledCatalog(deps.twRoot());
1149
+ const known = new Set([...catalog.plugins, ...catalog.themes].map((c) => c.name));
1150
+ const knownLangs = new Set(catalog.languages.map((c) => c.name));
1151
+ const applyList = (field, raw) => {
1152
+ if (!Array.isArray(raw)) return info[field];
1153
+ const next = [];
1154
+ for (const name of raw) {
1155
+ if (typeof name !== "string") continue;
1156
+ if (!known.has(name) && !info[field].includes(name)) throw new Error(`unknown plugin/theme: ${name}`);
1157
+ if (!next.includes(name)) next.push(name);
1158
+ }
1159
+ return next;
1160
+ };
1161
+ const applyLanguages = (raw) => {
1162
+ if (!Array.isArray(raw)) return info.languages ?? [];
1163
+ const next = [];
1164
+ for (const code of raw) {
1165
+ if (typeof code !== "string") continue;
1166
+ if (!knownLangs.has(code) && !(info.languages ?? []).includes(code)) throw new Error(`unknown language plugin: ${code}`);
1167
+ if (!next.includes(code)) next.push(code);
1168
+ }
1169
+ return next;
1170
+ };
1171
+ info.plugins = applyList("plugins", body.plugins);
1172
+ let activatedTheme;
1173
+ if (Array.isArray(body.themes)) {
1174
+ const themeDeps = {};
1175
+ for (const theme of catalog.themes) if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents;
1176
+ let selected = applyList("themes", body.themes);
1177
+ if (typeof body.themeActive === "string" && body.themeActive.length > 0) {
1178
+ const activeName = body.themeActive;
1179
+ if (known.has(activeName) || info.themes.includes(activeName)) {
1180
+ if (!selected.includes(activeName)) selected.push(activeName);
1181
+ activatedTheme = activeName;
1182
+ }
1183
+ }
1184
+ info.themes = normalizeThemes(selected, themeDeps);
1185
+ if (activatedTheme === void 0 && info.themes.length > 0) activatedTheme = info.themes[info.themes.length - 1];
1186
+ } else info.themes = applyList("themes", body.themes);
1187
+ if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages);
1188
+ await writeWikiInfo(wikiPath, info);
1189
+ await deps.server.restart();
1190
+ if (activatedTheme !== void 0) {
1191
+ const client = deps.getClient();
1192
+ if (client !== void 0) await client.put({
1193
+ title: "$:/theme",
1194
+ text: `$:/themes/${activatedTheme}`,
1195
+ type: "text/vnd.tiddlywiki",
1196
+ tags: []
1197
+ }).catch(() => void 0);
1198
+ }
1199
+ if (Array.isArray(body.languages)) {
1200
+ const client = deps.getClient();
1201
+ if (client !== void 0) {
1202
+ const langs = info.languages ?? [];
1203
+ const active = langs.length > 0 ? `$:/languages/${langs[0]}` : "$:/languages/en-GB";
1204
+ await client.put({
1205
+ title: "$:/language",
1206
+ text: active,
1207
+ type: "text/plain",
1208
+ tags: []
1209
+ }).catch(() => void 0);
1210
+ const hint = langs.length > 0 ? langs[0] : "";
1211
+ if ((deps.config.get().uiLanguage ?? "") !== hint) await deps.config.set(client, { uiLanguage: hint }).catch(() => void 0);
1212
+ }
1213
+ }
1214
+ json(res, {
1215
+ ok: true,
1216
+ info: {
1217
+ plugins: info.plugins,
1218
+ themes: info.themes,
1219
+ languages: info.languages ?? []
1220
+ }
1221
+ });
1222
+ } catch (err) {
1223
+ json(res, {
1224
+ ok: false,
1225
+ error: err instanceof Error ? err.message : String(err)
1226
+ }, 400);
1227
+ }
1228
+ };
1229
+ const handleConfig = async (req, res) => {
1230
+ try {
1231
+ const body = JSON.parse(await readBody(req));
1232
+ const client = deps.getClient();
1233
+ if (client === void 0) {
1234
+ json(res, {
1235
+ ok: false,
1236
+ error: "wiki service is not running"
1237
+ }, 503);
1238
+ return;
1239
+ }
1240
+ await deps.config.set(client, body);
1241
+ json(res, {
1242
+ ok: true,
1243
+ config: deps.config.get()
1244
+ });
1245
+ } catch (err) {
1246
+ json(res, {
1247
+ ok: false,
1248
+ error: err instanceof Error ? err.message : String(err)
1249
+ }, 400);
1250
+ }
1251
+ };
1252
+ const handleRestart = async (_req, res) => {
1253
+ try {
1254
+ await deps.server.restart();
1255
+ json(res, {
1256
+ ok: true,
1257
+ status: deps.server.status().status
1258
+ });
1259
+ } catch (err) {
1260
+ json(res, {
1261
+ ok: false,
1262
+ error: err instanceof Error ? err.message : String(err)
1263
+ }, 500);
1264
+ }
1265
+ };
1266
+ const disposers = [
1267
+ ctx.webServer.register({
1268
+ kind: "exact",
1269
+ path: `${ROUTE_PREFIX}/admin/state`,
1270
+ handler: (req, res) => {
1271
+ handleState(req, res);
1272
+ }
1273
+ }),
1274
+ ctx.webServer.register({
1275
+ kind: "exact",
1276
+ path: `${ROUTE_PREFIX}/admin/info`,
1277
+ handler: (req, res) => {
1278
+ handleInfo(req, res);
1279
+ }
1280
+ }),
1281
+ ctx.webServer.register({
1282
+ kind: "exact",
1283
+ path: `${ROUTE_PREFIX}/admin/config`,
1284
+ handler: (req, res) => {
1285
+ handleConfig(req, res);
1286
+ }
1287
+ }),
1288
+ ctx.webServer.register({
1289
+ kind: "exact",
1290
+ path: `${ROUTE_PREFIX}/admin/restart`,
1291
+ handler: (req, res) => {
1292
+ handleRestart(req, res);
1293
+ }
1294
+ })
1295
+ ];
1296
+ return () => {
1297
+ for (const dispose of disposers) dispose();
1298
+ };
1299
+ }
1300
+ //#endregion
1301
+ //#region src/host/seed-notes.ts
1302
+ /** Note tiddler title (a normal, searchable note — not a system tiddler). */
1303
+ const DOC_NOTE_TITLE = "dsh-tiddlywiki 插件说明";
1304
+ /** Tag that makes the note easy to find via `tiddlywiki_search tag=docs`. */
1305
+ const DOC_NOTE_TAG = "docs";
1306
+ /** The note body, TiddlyWiki wiki-text. */
1307
+ const DOC_NOTE_TEXT = `! dsh-tiddlywiki 插件说明
1308
+
1309
+ 本插件把 **TiddlyWiki 5** 作为 DSH 的持久知识库(wiki 文件夹本身就是一个 git 仓库,随内容自动提交/同步)。
1310
+
1311
+ !! 它能做什么
1312
+
1313
+ * **5 个 agent 工具**:\`tiddlywiki_search\`(检索)/ \`tiddlywiki_get\`(读)/ \`tiddlywiki_put\`(写)/ \`tiddlywiki_delete\`(删)/ \`tiddlywiki_git_sync\`(git 同步)。
1314
+ * **TW 编辑器面板**:侧边栏「TiddlyWiki」按钮 → 在界面中央打开完整版 TW 编辑器。
1315
+ * **快速笔记**:右下角悬浮「📝 快速笔记」写随手记,\`Ctrl+Enter\` 保存;点「✏️ 在 TW 中编辑」会弹出独立小窗用 TW 原生编辑器编辑。
1316
+ * **git 同步**:写入自动防抖 commit(默认 60 秒);手动 \`tiddlywiki_git_sync action=sync\` 做 pull → commit → push。
1317
+ * **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置。
1318
+
1319
+ !! 知识库纪律(三条)
1320
+
1321
+ 1. 开工先 \`tiddlywiki_git_sync action=pull\`(rebase + autostash,真冲突会自动 abort 并报文件)。
1322
+ 2. 收工 \`tiddlywiki_git_sync action=sync\`。
1323
+ 3. 插件自动 commit 兜底,手动 sync 用于需要主动推送的场合。
1324
+
1325
+ !! 主题与语言
1326
+
1327
+ * **主题**分两层:每行一个「☑ 加载」(多选 = TW 里可用的主题,依赖链自动带上)和「◉ 活动」(单选 = 当前视觉主题)。应用后自动重启 TW。
1328
+ * **语言**:设置页勾选 \`zh-Hans\`(简体)并应用,TW 界面即切换为中文。
1329
+
1330
+ !! 说明
1331
+
1332
+ * 本笔记由插件在首次启动时自动写入 wiki(幂等:不存在才写)。删除后重启 dsh web 会重建;手动编辑过的内容不会被覆盖。
1333
+ * 更多细节见插件仓库 README。`;
1334
+ /**
1335
+ * Seed the doc note when it is absent. Returns whether a note was written.
1336
+ * Never throws (missing wiki or note already present → no-op / false).
1337
+ */
1338
+ async function seedDocNote(client) {
1339
+ if (await client.get("dsh-tiddlywiki 插件说明").catch(() => void 0) !== void 0) return false;
1340
+ await client.put({
1341
+ title: DOC_NOTE_TITLE,
1342
+ text: DOC_NOTE_TEXT,
1343
+ type: "text/vnd.tiddlywiki",
1344
+ tags: [DOC_NOTE_TAG]
1345
+ });
1346
+ return true;
1347
+ }
1348
+ //#endregion
1349
+ //#region src/host/tw-api.ts
1350
+ const REQUEST_TIMEOUT_MS = 1e4;
1351
+ /** TW's CSRF gate: writes must carry this header (TW's own UI always does). */
1352
+ const CSRF_HEADER = { "x-requested-with": "TiddlyWiki" };
1353
+ /** Sentinel `exclude` value: excludes nothing, so `text` stays in the list. */
1354
+ const LIST_WITH_TEXT_EXCLUDE = "__dsh_tw_none__";
1355
+ /** Split TW's whitespace-joined tags string into an array. */
1356
+ function normalizeTags(tags) {
1357
+ if (tags === void 0) return void 0;
1358
+ if (Array.isArray(tags)) return tags.map(String);
1359
+ if (typeof tags === "string") {
1360
+ const parts = tags.trim().split(/\s+/).filter(Boolean);
1361
+ return parts.length > 0 ? parts : [];
1362
+ }
1363
+ return [];
1364
+ }
1365
+ /** Normalize a raw server tiddler (tags string → array, unknown fields nested). */
1366
+ function normalizeTiddler(raw) {
1367
+ const out = { ...raw };
1368
+ const tags = normalizeTags(raw.tags);
1369
+ if (tags !== void 0) out.tags = tags;
1370
+ return out;
1371
+ }
1372
+ var TiddlyWebClient = class {
1373
+ baseUrl;
1374
+ constructor(baseUrl) {
1375
+ this.baseUrl = baseUrl;
1376
+ }
1377
+ async request(path, init) {
1378
+ return fetch(`${this.baseUrl}${path}`, {
1379
+ ...init,
1380
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1381
+ });
1382
+ }
1383
+ /** GET /status → { username, anonymous, space, tiddlywiki_version, ... }. */
1384
+ async status() {
1385
+ const res = await this.request("/status");
1386
+ if (!res.ok) throw new Error(`TiddlyWeb /status HTTP ${res.status}`);
1387
+ return res.json();
1388
+ }
1389
+ /** Read one tiddler; undefined when it does not exist (404). */
1390
+ async get(title) {
1391
+ const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`);
1392
+ if (res.status === 404) return void 0;
1393
+ if (!res.ok) throw new Error(`TiddlyWeb GET /recipes/default/tiddlers/${title} HTTP ${res.status}`);
1394
+ return normalizeTiddler(await res.json());
1395
+ }
1396
+ /** Write (create or overwrite) one tiddler via PUT (204 on success). */
1397
+ async put(tiddler) {
1398
+ const title = tiddler.title;
1399
+ const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`, {
1400
+ method: "PUT",
1401
+ headers: {
1402
+ "content-type": "application/json",
1403
+ ...CSRF_HEADER
1404
+ },
1405
+ body: JSON.stringify(tiddler)
1406
+ });
1407
+ if (!res.ok) {
1408
+ const detail = await res.text().catch(() => "");
1409
+ throw new Error(`TiddlyWeb PUT /recipes/default/tiddlers/${title} HTTP ${res.status}: ${detail.slice(0, 300)}`);
1410
+ }
1411
+ return tiddler;
1412
+ }
1413
+ /** Delete one tiddler via the bags route (204); a missing one is a no-op. */
1414
+ async delete(title) {
1415
+ const res = await this.request(`/bags/default/tiddlers/${encodeURIComponent(title)}`, {
1416
+ method: "DELETE",
1417
+ headers: CSRF_HEADER
1418
+ });
1419
+ if (res.status === 404) return;
1420
+ if (!res.ok) throw new Error(`TiddlyWeb DELETE /bags/default/tiddlers/${title} HTTP ${res.status}`);
1421
+ }
1422
+ /**
1423
+ * List tiddlers via the default server filter. Arbitrary `filter=` queries
1424
+ * are blocked by the server (403) unless whitelisted, so callers needing a
1425
+ * subset should use search(); a supplied filter that is 403-blocked falls
1426
+ * back to the default listing.
1427
+ */
1428
+ async list(filter, includeText = false) {
1429
+ const params = new URLSearchParams();
1430
+ if (includeText) params.set("exclude", LIST_WITH_TEXT_EXCLUDE);
1431
+ if (filter !== void 0 && filter.length > 0) params.set("filter", filter);
1432
+ const query = params.toString();
1433
+ let res = await this.request(`/recipes/default/tiddlers.json${query.length > 0 ? `?${query}` : ""}`);
1434
+ if (!res.ok && res.status === 403 && filter !== void 0 && filter.length > 0) {
1435
+ const retry = new URLSearchParams();
1436
+ if (includeText) retry.set("exclude", LIST_WITH_TEXT_EXCLUDE);
1437
+ const retryQuery = retry.toString();
1438
+ res = await this.request(`/recipes/default/tiddlers.json${retryQuery.length > 0 ? `?${retryQuery}` : ""}`);
1439
+ }
1440
+ if (!res.ok) throw new Error(`TiddlyWeb recipe list HTTP ${res.status}`);
1441
+ const data = await res.json();
1442
+ return (Array.isArray(data) ? data : data.tiddlers ?? []).map(normalizeTiddler);
1443
+ }
1444
+ /**
1445
+ * Search non-system tiddlers: one request (default listing with text) plus
1446
+ * local case-insensitive substring matching on title + text, optional exact
1447
+ * tag, capped at `limit`. Robust against the server's external-filter 403.
1448
+ */
1449
+ async search(query, tag, limit = 30) {
1450
+ const items = await this.list(void 0, true);
1451
+ const needle = query.toLowerCase();
1452
+ return items.filter((t) => {
1453
+ if (!t.title.toLowerCase().includes(needle) && !(t.text ?? "").toLowerCase().includes(needle)) return false;
1454
+ if (tag !== void 0 && tag.length > 0) {
1455
+ if (!(t.tags ?? []).some((t2) => t2.toLowerCase() === tag.toLowerCase())) return false;
1456
+ }
1457
+ return true;
1458
+ }).slice(0, limit);
1459
+ }
1460
+ };
1461
+ //#endregion
1462
+ //#region src/sdk.ts
1463
+ /**
1464
+ * Self-contained replacements for the @deepseek-ai runtime imports the host
1465
+ * half must NEVER take from npm-mirror SDK packages (dsh-home-paths,
1466
+ * dsh-tools' defineTool).
1467
+ *
1468
+ * Why (design doc §4.4, taskboard lesson): a published copy must not resolve
1469
+ * `@deepseek-ai/dsh-tools` from the profile's node_modules — an npm-mirror
1470
+ * dsh-tools there shadows the CLI-internal build for the WHOLE base layer and
1471
+ * breaks the agent loop. Everything here is a pure, structure-compatible
1472
+ * reimplementation of the exact behavior the registry relies on:
1473
+ *
1474
+ * - `dshHomePath` mirrors `join(resolve(env.DSH_HOME ?? ~/.dsh), ...segments)`;
1475
+ * - `defineTool` compiles author-facing parameter specs into the same raw
1476
+ * JSON-Schema subset the registry expects and pre-validates model arguments.
1477
+ *
1478
+ * @module dsh-tiddlywiki/sdk
1479
+ */
1480
+ /** The DSH user home (DSH_HOME overrides). */
1481
+ function dshHomePath(...segments) {
1482
+ const override = process.env.DSH_HOME;
1483
+ return join(resolve(override !== void 0 && override.length > 0 ? override : join(homedir(), ".dsh")), ...segments);
1484
+ }
1485
+ /** Compile one value spec to the raw subset (json → annotation-only). */
1486
+ function compileValue(spec) {
1487
+ const node = {};
1488
+ const description = spec.description;
1489
+ if (typeof description === "string" && description.length > 0) node.description = description;
1490
+ const type = spec.type;
1491
+ if (type === void 0 || type === "json") return node;
1492
+ if (type === "object") {
1493
+ const objectSpec = spec;
1494
+ node.type = "object";
1495
+ node.additionalProperties = objectSpec.additionalProperties;
1496
+ if (objectSpec.properties !== void 0) node.properties = compilePropertyMap(objectSpec.properties).properties;
1497
+ return node;
1498
+ }
1499
+ if (type === "array") {
1500
+ node.type = "array";
1501
+ const items = spec.items;
1502
+ if (items !== void 0) node.items = compileValue(items);
1503
+ return node;
1504
+ }
1505
+ node.type = type;
1506
+ const enumValues = spec.enum;
1507
+ if (enumValues !== void 0) node.enum = [...enumValues];
1508
+ const constValue = spec.const;
1509
+ if (constValue !== void 0) node.const = constValue;
1510
+ return node;
1511
+ }
1512
+ /** Compile a property map: properties + collected required list. */
1513
+ function compilePropertyMap(spec) {
1514
+ const properties = {};
1515
+ const required = [];
1516
+ for (const [name, entry] of Object.entries(spec)) {
1517
+ const { required: isRequired, ...valueSpec } = entry;
1518
+ properties[name] = compileValue(valueSpec);
1519
+ if (isRequired === true) required.push(name);
1520
+ }
1521
+ return required.length > 0 ? {
1522
+ properties,
1523
+ required
1524
+ } : { properties };
1525
+ }
1526
+ /** Does a JS value match a raw-subset scalar type? */
1527
+ function matchesScalarType(value, type) {
1528
+ switch (type) {
1529
+ case "string": return typeof value === "string";
1530
+ case "number": return typeof value === "number";
1531
+ case "integer": return typeof value === "number" && Number.isInteger(value);
1532
+ case "boolean": return typeof value === "boolean";
1533
+ case "null": return value === null;
1534
+ default: return true;
1535
+ }
1536
+ }
1537
+ /** Validate a value against the compiled subset; returns path-qualified violations. */
1538
+ function validateValue(schema, value, path) {
1539
+ if (typeof schema.type !== "string" || schema.type.length === 0) return [];
1540
+ if (schema.type === "object") {
1541
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return [`${path} must be an object`];
1542
+ const violations = [];
1543
+ const present = value;
1544
+ for (const key of schema.required ?? []) if (!(key in present)) violations.push(`${path}.${key} is required`);
1545
+ if (schema.additionalProperties === false) {
1546
+ const known = new Set(Object.keys(schema.properties ?? {}));
1547
+ for (const key of Object.keys(present)) if (!known.has(key)) violations.push(`${path}.${key} is not a declared property`);
1548
+ }
1549
+ for (const [key, child] of Object.entries(schema.properties ?? {})) if (key in present) violations.push(...validateValue(child, present[key], `${path}.${key}`));
1550
+ return violations;
1551
+ }
1552
+ if (schema.type === "array") {
1553
+ if (!Array.isArray(value)) return [`${path} must be an array`];
1554
+ const violations = [];
1555
+ const items = schema.items;
1556
+ if (items !== void 0) value.forEach((item, index) => {
1557
+ violations.push(...validateValue(items, item, `${path}[${index}]`));
1558
+ });
1559
+ return violations;
1560
+ }
1561
+ if (!matchesScalarType(value, schema.type)) return [`${path} must be ${schema.type}`];
1562
+ const enumValues = schema.enum;
1563
+ if (enumValues !== void 0 && !enumValues.some((v) => v === value)) return [`${path} must be one of ${enumValues.map(String).join(", ")}`];
1564
+ const constValue = schema.const;
1565
+ if (constValue !== void 0 && constValue !== value) return [`${path} must be ${String(constValue)}`];
1566
+ return [];
1567
+ }
1568
+ /**
1569
+ * Define a first-party tool: compile the parameter spec, pre-validate
1570
+ * arguments, and pass through the execution.
1571
+ */
1572
+ function defineTool(options) {
1573
+ const compiled = compilePropertyMap(options.parameters);
1574
+ const parameters = {
1575
+ type: "object",
1576
+ properties: compiled.properties
1577
+ };
1578
+ if (compiled.required !== void 0) parameters.required = compiled.required;
1579
+ const userExecute = options.execute;
1580
+ return {
1581
+ name: options.name,
1582
+ description: options.description,
1583
+ parameters,
1584
+ output: {
1585
+ schema: {},
1586
+ render(args, value) {
1587
+ return options.output.render(args, value);
1588
+ }
1589
+ },
1590
+ async execute(args, exec) {
1591
+ const violations = validateValue(parameters, args, "arguments");
1592
+ if (violations.length > 0) throw new Error(`Error: invalid arguments: ${violations.join("; ")}`);
1593
+ return userExecute(args, exec);
1594
+ }
1595
+ };
1596
+ }
1597
+ //#endregion
1598
+ //#region src/host/tools.ts
1599
+ /**
1600
+ * The five `tiddlywiki_*` agent tools (design doc §11, D8) plus the extension
1601
+ * point: `registerTiddlywikiTools(ctx, deps)` registers tools list-style, so a
1602
+ * new tool is just one more `defineTool` in the array — index.ts never changes.
1603
+ *
1604
+ * RENDER CONTRACT (design doc §4.3): the registry feeds `output.render(args,
1605
+ * value)` into the loop — the model sees ONLY the rendered text, never the raw
1606
+ * JSON `value`. Every render must carry the complete facts an agent needs to
1607
+ * act (titles, tags, snippets, git state); a terse UI summary starves it.
1608
+ *
1609
+ * @module dsh-tiddlywiki/host/tools
1610
+ */
1611
+ function snippetOf(text, max = 160) {
1612
+ const flat = text.replace(/\s+/g, " ").trim();
1613
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
1614
+ }
1615
+ /** Strip dsh-tiddlywiki internal fields from a tiddler for the model. */
1616
+ function pickFields(t) {
1617
+ const out = {};
1618
+ for (const [k, v] of Object.entries(t)) {
1619
+ if (k === "title" || k === "text" || k === "tags") continue;
1620
+ out[k] = v;
1621
+ }
1622
+ return out;
1623
+ }
1624
+ function registerTiddlywikiTools(ctx, deps) {
1625
+ const disposers = [];
1626
+ const register = (tool) => {
1627
+ disposers.push(ctx.tools.register(tool));
1628
+ };
1629
+ register(defineTool({
1630
+ name: "tiddlywiki_search",
1631
+ description: "检索 TiddlyWiki 持久知识库:按关键词(可选 tag 精确匹配)搜索非系统 tiddler,返回标题、标签与摘要片段。",
1632
+ parameters: {
1633
+ query: {
1634
+ type: "string",
1635
+ description: "搜索关键词(大小写不敏感,子串匹配)",
1636
+ required: true
1637
+ },
1638
+ tag: {
1639
+ type: "string",
1640
+ description: "可选:只返回带该 tag 的 tiddler"
1641
+ }
1642
+ },
1643
+ output: {
1644
+ schema: { type: "json" },
1645
+ render: (_args, value) => {
1646
+ const lines = [`TiddlyWiki 搜索「${value.query}」${value.tag !== null ? ` (tag=${value.tag})` : ""}:命中 ${value.count} 条。`];
1647
+ if (value.results.length === 0) lines.push("没有匹配的 tiddler。");
1648
+ for (const r of value.results) {
1649
+ const tags = r.tags.length > 0 ? ` [${r.tags.join(", ")}]` : "";
1650
+ lines.push(`- ${r.title}${tags}`);
1651
+ if (r.snippet.length > 0) lines.push(` ${r.snippet}`);
1652
+ }
1653
+ if (value.count > value.results.length) lines.push(`(另有 ${value.count - value.results.length} 条未展开,可用 tiddlywiki_get 读取具体标题)`);
1654
+ return [{
1655
+ type: "text",
1656
+ text: lines.join("\n")
1657
+ }];
1658
+ }
1659
+ },
1660
+ execute: async (args) => {
1661
+ const wiki = deps.wiki();
1662
+ if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
1663
+ const results = await wiki.search(args.query, args.tag);
1664
+ return {
1665
+ query: args.query,
1666
+ tag: args.tag ?? null,
1667
+ count: results.length,
1668
+ results: results.map((t) => ({
1669
+ title: t.title,
1670
+ tags: t.tags ?? [],
1671
+ snippet: snippetOf(t.text ?? "")
1672
+ }))
1673
+ };
1674
+ }
1675
+ }));
1676
+ register(defineTool({
1677
+ name: "tiddlywiki_get",
1678
+ description: "读取一个 TiddlyWiki tiddler 的完整内容(标题、全文、标签、自定义字段)。",
1679
+ parameters: { title: {
1680
+ type: "string",
1681
+ description: "tiddler 标题(精确匹配)",
1682
+ required: true
1683
+ } },
1684
+ output: {
1685
+ schema: { type: "json" },
1686
+ render: (_args, value) => {
1687
+ if (value.notFound) return [{
1688
+ type: "text",
1689
+ text: `tiddler「${value.title}」不存在。可用 tiddlywiki_search 检索,或用 tiddlywiki_put 新建。`
1690
+ }];
1691
+ const lines = [`tiddler「${value.title}」`];
1692
+ if (value.tags.length > 0) lines.push(`标签: ${value.tags.join(", ")}`);
1693
+ const fields = Object.entries(value.fields);
1694
+ if (fields.length > 0) lines.push(`字段: ${fields.map(([k, v]) => `${k}=${String(v)}`).join(", ")}`);
1695
+ lines.push("--- 全文 ---");
1696
+ lines.push(value.text.length > 0 ? value.text : "(空)");
1697
+ return [{
1698
+ type: "text",
1699
+ text: lines.join("\n")
1700
+ }];
1701
+ }
1702
+ },
1703
+ execute: async (args) => {
1704
+ const wiki = deps.wiki();
1705
+ if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
1706
+ const t = await wiki.get(args.title);
1707
+ if (t === void 0) return {
1708
+ notFound: true,
1709
+ title: args.title,
1710
+ text: "",
1711
+ tags: [],
1712
+ fields: {}
1713
+ };
1714
+ return {
1715
+ notFound: false,
1716
+ title: t.title,
1717
+ text: t.text ?? "",
1718
+ tags: t.tags ?? [],
1719
+ fields: pickFields(t)
1720
+ };
1721
+ }
1722
+ }));
1723
+ register(defineTool({
1724
+ name: "tiddlywiki_put",
1725
+ description: "写入(新建或覆盖)一个 TiddlyWiki tiddler。同名覆盖;tags 为标签数组,fields 为附加自定义字段(json 对象,会写入 tiddler 字段)。写入后触发自动 commit。",
1726
+ parameters: {
1727
+ title: {
1728
+ type: "string",
1729
+ description: "tiddler 标题(精确匹配,覆盖同名)",
1730
+ required: true
1731
+ },
1732
+ text: {
1733
+ type: "string",
1734
+ description: "tiddler 全文(wiki 文本)",
1735
+ required: true
1736
+ },
1737
+ tags: {
1738
+ type: "array",
1739
+ items: { type: "string" },
1740
+ description: "标签数组(可选)"
1741
+ },
1742
+ fields: {
1743
+ type: "json",
1744
+ description: "附加自定义字段,如 {\"type\":\"meeting\",\"date\":\"2026-09-02\"}(可选)"
1745
+ }
1746
+ },
1747
+ output: {
1748
+ schema: { type: "json" },
1749
+ render: (_args, value) => {
1750
+ const lines = [`已写入 tiddler「${value.title}」`];
1751
+ if (value.tags.length > 0) lines.push(`标签: ${value.tags.join(", ")}`);
1752
+ if (value.fields !== null) {
1753
+ const entries = Object.entries(value.fields);
1754
+ if (entries.length > 0) lines.push(`字段: ${entries.map(([k, v]) => `${k}=${String(v)}`).join(", ")}`);
1755
+ }
1756
+ return [{
1757
+ type: "text",
1758
+ text: lines.join("\n")
1759
+ }];
1760
+ }
1761
+ },
1762
+ execute: async (args) => {
1763
+ const wiki = deps.wiki();
1764
+ if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
1765
+ const tiddler = {
1766
+ title: args.title,
1767
+ text: args.text
1768
+ };
1769
+ if (Array.isArray(args.tags) && args.tags.length > 0) tiddler.tags = args.tags;
1770
+ if (args.fields !== void 0 && typeof args.fields === "object" && args.fields !== null) Object.assign(tiddler, args.fields);
1771
+ await wiki.put(tiddler);
1772
+ deps.autoCommit();
1773
+ return {
1774
+ ok: true,
1775
+ title: args.title,
1776
+ tags: args.tags ?? [],
1777
+ fields: args.fields ?? null
1778
+ };
1779
+ }
1780
+ }));
1781
+ register(defineTool({
1782
+ name: "tiddlywiki_delete",
1783
+ description: "删除一个 TiddlyWiki tiddler(不存在时是幂等空操作)。删除后触发自动 commit。",
1784
+ parameters: { title: {
1785
+ type: "string",
1786
+ description: "tiddler 标题(精确匹配)",
1787
+ required: true
1788
+ } },
1789
+ output: {
1790
+ schema: { type: "json" },
1791
+ render: (_args, value) => [{
1792
+ type: "text",
1793
+ text: `已删除 tiddler「${value.title}」。`
1794
+ }]
1795
+ },
1796
+ execute: async (args) => {
1797
+ const wiki = deps.wiki();
1798
+ if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
1799
+ await wiki.delete(args.title);
1800
+ deps.autoCommit();
1801
+ return {
1802
+ ok: true,
1803
+ title: args.title
1804
+ };
1805
+ }
1806
+ }));
1807
+ register(defineTool({
1808
+ name: "tiddlywiki_git_sync",
1809
+ description: "对 TiddlyWiki 知识库的 git 仓库做同步:pull(拉取远端并 rebase 本地,冲突则 abort 并报文件)、push(推送本地提交到远端)、sync(pull → commit 本地改动 → push)。未配置 git.remote 时 push 会失败并提示。",
1810
+ parameters: {
1811
+ action: {
1812
+ type: "string",
1813
+ enum: [
1814
+ "pull",
1815
+ "push",
1816
+ "sync"
1817
+ ],
1818
+ description: "要执行的 git 操作",
1819
+ required: true
1820
+ },
1821
+ message: {
1822
+ type: "string",
1823
+ description: "commit 信息(可选,仅 sync 的本地 commit 使用)"
1824
+ }
1825
+ },
1826
+ output: {
1827
+ schema: { type: "json" },
1828
+ render: (_args, value) => renderSync(value)
1829
+ },
1830
+ execute: async (args) => {
1831
+ const dir = deps.wikiPath();
1832
+ switch (args.action) {
1833
+ case "pull": {
1834
+ const r = await deps.git.pull(dir);
1835
+ return {
1836
+ action: args.action,
1837
+ ok: r.ok,
1838
+ message: r.message,
1839
+ ...r.conflictFiles !== void 0 ? { conflictFiles: r.conflictFiles } : {}
1840
+ };
1841
+ }
1842
+ case "push": {
1843
+ const r = await deps.git.push(dir);
1844
+ return {
1845
+ action: args.action,
1846
+ ok: r.ok,
1847
+ message: r.message
1848
+ };
1849
+ }
1850
+ case "sync": {
1851
+ const pulled = await deps.git.pull(dir);
1852
+ if (!pulled.ok) return {
1853
+ action: args.action,
1854
+ ok: false,
1855
+ message: pulled.message,
1856
+ ...pulled.conflictFiles !== void 0 ? { conflictFiles: pulled.conflictFiles } : {}
1857
+ };
1858
+ const committed = await deps.git.commit(dir, args.message ?? `sync ${(/* @__PURE__ */ new Date()).toISOString()}`);
1859
+ const pushed = await deps.git.push(dir);
1860
+ const status = await deps.git.status(dir);
1861
+ return {
1862
+ action: args.action,
1863
+ ok: pushed.ok,
1864
+ message: pushed.ok ? "同步完成" : pushed.message,
1865
+ pull: "ok",
1866
+ commit: committed.message,
1867
+ push: pushed.message,
1868
+ status
1869
+ };
1870
+ }
1871
+ }
1872
+ }
1873
+ }));
1874
+ return disposers;
1875
+ }
1876
+ function renderSync(value) {
1877
+ const lines = [`git ${value.action}: ${value.ok ? "成功" : "失败"}`];
1878
+ lines.push(` ${value.message}`);
1879
+ if (value.conflictFiles !== void 0 && value.conflictFiles.length > 0) {
1880
+ lines.push(`冲突文件(rebase 已 abort,勿自动覆盖):`);
1881
+ for (const f of value.conflictFiles) lines.push(` - ${f}`);
1882
+ lines.push("处理方式:git checkout --ours <file> 保留本地,或人工编辑后 git add + git rebase --continue;也可以直接让用户处理。");
1883
+ }
1884
+ if (value.commit !== void 0) lines.push(`本地 commit: ${value.commit}`);
1885
+ if (value.push !== void 0) lines.push(`远端 push: ${value.push}`);
1886
+ if (value.status !== void 0) {
1887
+ const s = value.status;
1888
+ const bits = [`分支 ${s.branch}`];
1889
+ if (s.ahead !== void 0) bits.push(`领先 ${s.ahead}`);
1890
+ if (s.behind !== void 0) bits.push(`落后 ${s.behind}`);
1891
+ if (s.dirty) bits.push(`工作区有 ${s.dirtyFiles.length} 个未提交改动`);
1892
+ if (s.lastCommit !== void 0) bits.push(`最近提交 ${s.lastCommit}`);
1893
+ lines.push(`状态: ${bits.join(" · ")}`);
1894
+ if (s.dirty && s.dirtyFiles.length > 0) lines.push(` 未提交: ${s.dirtyFiles.join(", ")}`);
1895
+ }
1896
+ return [{
1897
+ type: "text",
1898
+ text: lines.join("\n")
1899
+ }];
1900
+ }
1901
+ //#endregion
1902
+ //#region src/index.ts
1903
+ /**
1904
+ * dsh-tiddlywiki — host half.
1905
+ *
1906
+ * TiddlyWiki 5 as the DSH persistent knowledge base. Wiring:
1907
+ * - WikiServer spawns/kills/self-heals the TW 5 child process (loopback, auto
1908
+ * port) and scaffolds the wiki folder on first run;
1909
+ * - the git face bootstraps the wiki folder as a repository and wires the
1910
+ * debounced auto-committer;
1911
+ * - `tiddlywiki_*` agent tools + a system-prompt section;
1912
+ * - /dsh-tiddlywiki routes when a webServer is present.
1913
+ *
1914
+ * Export shape follows dsh-taskboard: a function/namespace plugin —
1915
+ * `name` / `inject` / `apply`, NO default export. Config arrives as the
1916
+ * second apply() argument (Cordis `runtime.callback(ctx, config)`).
1917
+ *
1918
+ * Extra exports (WikiServer / TiddlyWebClient / GitFace / ...) exist for the
1919
+ * headless selftest and future reuse; the loader only reads name/inject/apply.
1920
+ *
1921
+ * @module dsh-tiddlywiki
1922
+ */
1923
+ /** Cordis plugin name (also the client loader id / profile row id). */
1924
+ const name = "dsh-tiddlywiki";
1925
+ /** Required host services (tool registry + prompt assembly). */
1926
+ const inject = ["tools", "systemPrompt"];
1927
+ const DEFAULTS = {
1928
+ wikiRoot: "",
1929
+ wiki: "main",
1930
+ port: 0,
1931
+ git: {
1932
+ autoCommit: true,
1933
+ debounceMs: 6e4,
1934
+ remote: "",
1935
+ branch: "main"
1936
+ },
1937
+ note: { tag: "inbox" },
1938
+ auth: {
1939
+ username: "",
1940
+ password: ""
1941
+ }
1942
+ };
1943
+ /** Expand $VAR / ${VAR} / %VAR% from process.env (config uses $DSH_HOME). */
1944
+ function expandEnvPath(input) {
1945
+ return input.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => process.env[k] ?? "").replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, k) => process.env[k] ?? "").replace(/%([A-Za-z_][A-Za-z0-9_]*%)/g, (_, k) => process.env[k.slice(0, -1)] ?? "");
1946
+ }
1947
+ /** Resolve wikiRoot: explicit config (env-expanded) else $DSH_HOME/tiddlywiki. */
1948
+ function resolveWikiRoot(config) {
1949
+ if (config.wikiRoot !== void 0 && config.wikiRoot.trim().length > 0) return expandEnvPath(config.wikiRoot.trim());
1950
+ return dshHomePath("tiddlywiki");
1951
+ }
1952
+ /** Write the .gitignore for TW transient artifacts (idempotent). */
1953
+ async function writeGitignore(wikiPath) {
1954
+ await writeFile(join(wikiPath, ".gitignore"), [
1955
+ "# TiddlyWiki transient artifacts (auto-managed by dsh-tiddlywiki)",
1956
+ "tiddlers/$__temp_*",
1957
+ "tiddlers/$__StoryList*",
1958
+ "tiddlers/$__HistoryList*",
1959
+ "*.meta.tmp",
1960
+ ""
1961
+ ].join("\n"), "utf8");
1962
+ }
1963
+ /** Watch the wiki folders and touch the auto-committer on changes. */
1964
+ function watchWiki(wikiPath, onChange) {
1965
+ const watchers = [];
1966
+ for (const dir of [join(wikiPath, "tiddlers"), wikiPath]) try {
1967
+ const watcher = watch(dir, { persistent: false }, () => onChange());
1968
+ watchers.push(watcher);
1969
+ } catch {}
1970
+ return () => {
1971
+ for (const watcher of watchers) try {
1972
+ watcher.close();
1973
+ } catch {}
1974
+ };
1975
+ }
1976
+ /** System-prompt section text (design doc §11 D8). */
1977
+ const PROMPT_SECTION_NAME = "dsh-tiddlywiki";
1978
+ const PROMPT_SECTION_ORDER = 100;
1979
+ const PROMPT_TEXT = `## TiddlyWiki 持久知识库
1980
+
1981
+ 本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。你可以用工具读写 tiddler:
1982
+
1983
+ - \`tiddlywiki_search\`(query, tag?)检索;\`tiddlywiki_get\`(title)读全文;\`tiddlywiki_put\`(title, text, tags?, fields?)写/覆盖;\`tiddlywiki_delete\`(title)删除。
1984
+ - \`tiddlywiki_git_sync\`(pull|push|sync)做 git 同步。
1985
+
1986
+ 知识库同步纪律(三条):
1987
+ 1. 开工先 pull:\`tiddlywiki_git_sync action=pull\`(rebase + autostash;真冲突会自动 abort 并报冲突文件)。
1988
+ 2. 收工 commit + push:\`tiddlywiki_git_sync action=sync\`(pull → commit → push)。
1989
+ 3. 插件会自动防抖 commit(默认 60s),手动同步用上面的工具。
1990
+
1991
+ 把 wiki 当作长期记忆与知识沉淀的地方:会议纪要、决策记录、调研笔记、随手的想法都可存成独立 tiddler(tag 建议用 inbox/meeting/decision 等便于检索)。`;
1992
+ /**
1993
+ * Mount the host half.
1994
+ * @param ctx - the plugin context (tools + systemPrompt injected).
1995
+ * @param rawConfig - the plugin row's `config:` block (Cordis second arg).
1996
+ */
1997
+ function apply(ctx, rawConfig = {}) {
1998
+ const config = {
1999
+ wikiRoot: resolveWikiRoot(rawConfig),
2000
+ wiki: rawConfig.wiki ?? DEFAULTS.wiki,
2001
+ port: rawConfig.port ?? DEFAULTS.port,
2002
+ git: {
2003
+ ...DEFAULTS.git,
2004
+ ...rawConfig.git ?? {}
2005
+ },
2006
+ note: {
2007
+ ...DEFAULTS.note,
2008
+ ...rawConfig.note ?? {}
2009
+ },
2010
+ auth: {
2011
+ ...DEFAULTS.auth,
2012
+ ...rawConfig.auth ?? {}
2013
+ }
2014
+ };
2015
+ const wikiPath = join(config.wikiRoot, config.wiki);
2016
+ const git = new GitFace();
2017
+ const configStore = new ConfigStore({
2018
+ note: config.note,
2019
+ git: config.git
2020
+ });
2021
+ const eff = () => configStore.get();
2022
+ const effectiveNoteTag = () => {
2023
+ const tag = eff().note?.tag;
2024
+ return typeof tag === "string" && tag.trim().length > 0 ? tag : config.note.tag;
2025
+ };
2026
+ const disposers = [];
2027
+ const disposeAll = () => {
2028
+ for (const dispose of disposers.splice(0)) dispose();
2029
+ };
2030
+ const disposeSection = ctx.systemPrompt.section({
2031
+ name: PROMPT_SECTION_NAME,
2032
+ order: PROMPT_SECTION_ORDER,
2033
+ text: PROMPT_TEXT
2034
+ });
2035
+ ctx.effect(() => disposeSection, "dsh-tiddlywiki: prompt section");
2036
+ const server = new WikiServer({
2037
+ wikiRoot: config.wikiRoot,
2038
+ wiki: config.wiki,
2039
+ port: config.port,
2040
+ username: config.auth.username,
2041
+ password: config.auth.password
2042
+ });
2043
+ let clientCache;
2044
+ const client = () => {
2045
+ const port = server.currentPort;
2046
+ if (port === void 0) return void 0;
2047
+ clientCache ??= new TiddlyWebClient(`http://127.0.0.1:${port}`);
2048
+ return clientCache;
2049
+ };
2050
+ let committer;
2051
+ let unwatch;
2052
+ const setupCommitter = () => {
2053
+ const g = eff().git ?? {};
2054
+ committer = new AutoCommitter({
2055
+ git,
2056
+ dir: wikiPath,
2057
+ enabled: g.autoCommit ?? config.git.autoCommit,
2058
+ debounceMs: g.debounceMs ?? config.git.debounceMs,
2059
+ message: () => `wiki autocommit ${(/* @__PURE__ */ new Date()).toISOString()}`,
2060
+ onError: (err) => console.warn("[dsh-tiddlywiki] autocommit:", err)
2061
+ });
2062
+ unwatch = watchWiki(wikiPath, () => committer?.touch());
2063
+ disposers.push(() => {
2064
+ committer?.dispose();
2065
+ unwatch?.();
2066
+ });
2067
+ };
2068
+ const bootstrapGit = async () => {
2069
+ const g = eff().git ?? {};
2070
+ const branch = g.branch ?? config.git.branch;
2071
+ const remote = g.remote ?? config.git.remote;
2072
+ if (!await git.isRepo(wikiPath)) {
2073
+ await git.init(wikiPath, branch);
2074
+ await writeGitignore(wikiPath);
2075
+ await git.initialCommit(wikiPath);
2076
+ } else await writeGitignore(wikiPath);
2077
+ if (remote.trim().length > 0) {
2078
+ const ensured = await git.ensureRemote(wikiPath, remote.trim());
2079
+ if (ensured.ok) {
2080
+ const first = await git.firstPush(wikiPath);
2081
+ if (!first.ok) console.warn("[dsh-tiddlywiki] first push failed (retry with tiddlywiki_git_sync):", first.message);
2082
+ } else console.warn("[dsh-tiddlywiki] git remote setup:", ensured.message);
2083
+ }
2084
+ };
2085
+ const toolsDeps = {
2086
+ wiki: client,
2087
+ git,
2088
+ wikiPath: () => wikiPath,
2089
+ noteTag: effectiveNoteTag,
2090
+ autoCommit: () => committer?.touch()
2091
+ };
2092
+ disposers.push(...registerTiddlywikiTools(ctx, toolsDeps));
2093
+ (async () => {
2094
+ try {
2095
+ await server.start();
2096
+ await configStore.load(client());
2097
+ try {
2098
+ const seedClient = client();
2099
+ if (seedClient !== void 0) await seedDocNote(seedClient);
2100
+ } catch (err) {
2101
+ console.warn("[dsh-tiddlywiki] seeding doc note:", err);
2102
+ }
2103
+ const uiLang = eff().uiLanguage;
2104
+ if (typeof uiLang === "string" && uiLang.trim().length > 0) try {
2105
+ const code = uiLang.trim();
2106
+ if (await ensureLanguage(wikiPath, resolveTwRoot(), code)) await server.restart();
2107
+ const langClient = client();
2108
+ if (langClient !== void 0) await langClient.put({
2109
+ title: "$:/language",
2110
+ text: `$:/languages/${code}`,
2111
+ type: "text/plain",
2112
+ tags: []
2113
+ }).catch(() => void 0);
2114
+ } catch (err) {
2115
+ console.warn("[dsh-tiddlywiki] applying uiLanguage:", err);
2116
+ }
2117
+ await bootstrapGit();
2118
+ setupCommitter();
2119
+ } catch (err) {
2120
+ console.warn("[dsh-tiddlywiki] startup issue (self-healing is armed):", err);
2121
+ }
2122
+ })();
2123
+ ctx.inject(["webServer"], (webCtx) => {
2124
+ const ws = webCtx.webServer;
2125
+ const disposeRoutes = registerRoutes({ webServer: ws }, {
2126
+ server,
2127
+ getClient: client,
2128
+ git,
2129
+ autoCommit: () => committer?.touch(),
2130
+ noteDefaults: () => ({ tag: effectiveNoteTag() }),
2131
+ getWikiPath: () => wikiPath
2132
+ });
2133
+ const disposeAdmin = registerAdminRoutes({ webServer: ws }, {
2134
+ server,
2135
+ getClient: client,
2136
+ getWikiPath: () => wikiPath,
2137
+ twRoot: resolveTwRoot,
2138
+ config: configStore
2139
+ });
2140
+ return () => {
2141
+ disposeRoutes();
2142
+ disposeAdmin();
2143
+ };
2144
+ });
2145
+ ctx.effect(() => () => {
2146
+ disposeAll();
2147
+ server.stop();
2148
+ }, "dsh-tiddlywiki: host teardown");
2149
+ }
2150
+ //#endregion
2151
+ export { AutoCommitter, ConfigStore, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, apply, bundledCatalog, deepMerge, defineTool, dshHomePath, ensureLanguage, inject, name, normalizeThemes, openInTwEditor, readWikiInfo, registerAdminRoutes, resolveTwRoot, seedDocNote, writeWikiInfo };
2152
+
2153
+ //# sourceMappingURL=index.js.map