dsh-tiddlywiki 0.19.2 → 0.19.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/lib/client.js +2 -2
- package/lib/index.js +398 -163
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/settings-page.ts +2 -2
- package/src/client/tool-views.ts +12 -8
- package/src/host/admin.ts +153 -52
- package/src/host/clip-bridge.ts +29 -2
- package/src/host/http.ts +35 -0
- package/src/host/routes.ts +184 -62
- package/src/host/session-summary.ts +30 -8
- package/src/host/tools.ts +18 -7
- package/src/host/tw-api.ts +14 -2
- package/src/index.ts +2 -1
package/src/host/routes.ts
CHANGED
|
@@ -21,16 +21,16 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
|
|
23
23
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
24
|
-
import { mkdir, writeFile,
|
|
24
|
+
import { mkdir, writeFile, stat } from 'node:fs/promises'
|
|
25
25
|
import { basename, extname, isAbsolute, join } from 'node:path'
|
|
26
26
|
import { Readable } from 'node:stream'
|
|
27
27
|
import type { TiddlyWebClient } from './tw-api.ts'
|
|
28
|
-
import {
|
|
28
|
+
import { isBinaryType, toIsoDateString } from './tw-api.ts'
|
|
29
29
|
import type { WikiServer } from './wiki.ts'
|
|
30
30
|
import type { GitFace } from './git.ts'
|
|
31
31
|
import { PATH_PREFIX, TW_PROXY_PREFIX, TW_PROXY_PATH } from './wiki.ts'
|
|
32
32
|
import { writeSessionSummary, type SessionQueryFace } from './session-summary.ts'
|
|
33
|
-
import { readBody, readBodyBuffer, json, rejectCrossSiteWrite, rejectNonRead, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
|
|
33
|
+
import { readBody, readBodyBuffer, json, guardHandler, errorStatus, rejectCrossSiteWrite, rejectNonRead, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
|
|
34
34
|
import { sanitizeTwFragment } from './sanitize.ts'
|
|
35
35
|
import { flushPendingWrites } from './seeds.ts'
|
|
36
36
|
import { WriteConflictError, assertNoConflict, buildWriteTiddler, flattenTiddlerFields } from './write-policy.ts'
|
|
@@ -247,6 +247,11 @@ function sanitizeUploadName(input: unknown): string {
|
|
|
247
247
|
.replace(/^\.+/, '')
|
|
248
248
|
.trim()
|
|
249
249
|
if (name.length === 0 || name === '.' || name === '..') return ''
|
|
250
|
+
// Windows stores a trailing dot/space verbatim but never strips it, and
|
|
251
|
+
// `extname('evil.html.')` is just `.` — so the extension denylist was
|
|
252
|
+
// bypassable with `evil.html.` (v0.19.3). Canonicalize before the check.
|
|
253
|
+
name = name.replace(/[. ]+$/, '')
|
|
254
|
+
if (name.length === 0) return ''
|
|
250
255
|
// `CON`, `NUL`, `COM1…` are not creatable on Windows — prefix instead of 500.
|
|
251
256
|
if (WINDOWS_RESERVED_NAMES.test(name)) name = `_${name}`
|
|
252
257
|
if (name.length > 160) name = name.slice(0, 160)
|
|
@@ -293,6 +298,28 @@ function snippetOf(text: string, max = 120): string {
|
|
|
293
298
|
return flat.length <= max ? flat : `${flat.slice(0, max)}…`
|
|
294
299
|
}
|
|
295
300
|
|
|
301
|
+
/** Max `limit` accepted by the list routes (bounded payloads, v0.19.4). */
|
|
302
|
+
const MAX_LIST_LIMIT = 200
|
|
303
|
+
|
|
304
|
+
/** Max `limit` accepted by `/tags` — the tag list is a small wrapper around one
|
|
305
|
+
* full listing, so a larger cap is fine while still bounding the payload. */
|
|
306
|
+
const MAX_TAGS_LIMIT = 500
|
|
307
|
+
|
|
308
|
+
/** Clamp a `limit` query param. `fallback` covers absent/unparsable values. */
|
|
309
|
+
function readLimit(url: URL, fallback: number, max = MAX_LIST_LIMIT): number {
|
|
310
|
+
const raw = Number(url.searchParams.get('limit') ?? fallback)
|
|
311
|
+
return Number.isFinite(raw) ? Math.max(1, Math.min(Math.floor(raw), max)) : fallback
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Optional `limit` query param: `undefined` when absent/unparsable (= no cap). */
|
|
315
|
+
function readOptionalLimit(url: URL, max: number): number | undefined {
|
|
316
|
+
const raw = url.searchParams.get('limit')
|
|
317
|
+
if (raw === null || raw.trim().length === 0) return undefined
|
|
318
|
+
const parsed = Number(raw)
|
|
319
|
+
if (!Number.isFinite(parsed)) return undefined
|
|
320
|
+
return Math.max(1, Math.min(Math.floor(parsed), max))
|
|
321
|
+
}
|
|
322
|
+
|
|
296
323
|
/** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
|
|
297
324
|
function timestampTitle(date = new Date()): string {
|
|
298
325
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
@@ -482,6 +509,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
482
509
|
json(res, { ok: false, error: 'session is required' }, 400)
|
|
483
510
|
return
|
|
484
511
|
}
|
|
512
|
+
// The id becomes part of a tiddler title (`$:/temp/dsh/session-summary/<id>`)
|
|
513
|
+
// and is echoed into the summary wikitext — keep it to a safe charset
|
|
514
|
+
// instead of trusting the request body (v0.19.3).
|
|
515
|
+
if (!/^[A-Za-z0-9._:-]{1,120}$/.test(session)) {
|
|
516
|
+
json(res, { ok: false, error: 'session id has an unsupported format' }, 400)
|
|
517
|
+
return
|
|
518
|
+
}
|
|
485
519
|
const client = deps.getClient()
|
|
486
520
|
if (client === undefined) {
|
|
487
521
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
@@ -495,7 +529,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
495
529
|
const result = await writeSessionSummary(client, sq, session)
|
|
496
530
|
json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH })
|
|
497
531
|
} catch (err) {
|
|
498
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
532
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
499
533
|
}
|
|
500
534
|
}
|
|
501
535
|
|
|
@@ -545,7 +579,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
545
579
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
546
580
|
json(res, { ok: true, items })
|
|
547
581
|
} catch (err) {
|
|
548
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
582
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
549
583
|
}
|
|
550
584
|
}
|
|
551
585
|
|
|
@@ -622,7 +656,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
622
656
|
permissions,
|
|
623
657
|
})
|
|
624
658
|
} catch (err) {
|
|
625
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
659
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
626
660
|
}
|
|
627
661
|
}
|
|
628
662
|
|
|
@@ -659,7 +693,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
659
693
|
}
|
|
660
694
|
json(res, { ok: true, requestId, sessionId })
|
|
661
695
|
} catch (err) {
|
|
662
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
696
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
663
697
|
}
|
|
664
698
|
}
|
|
665
699
|
|
|
@@ -709,6 +743,26 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
709
743
|
json(res, { ok: false, error: 'session service unavailable' }, 503)
|
|
710
744
|
return
|
|
711
745
|
}
|
|
746
|
+
// Validate the agent preset (工作模式) BEFORE any side effect (v0.19.3):
|
|
747
|
+
// the old code created the cwd directory + workspace first, so an unknown
|
|
748
|
+
// mode left an orphaned directory behind a 500 from `sc.create`.
|
|
749
|
+
if (mode !== undefined) {
|
|
750
|
+
const ap = deps.getAgentPresets()
|
|
751
|
+
if (ap === undefined) {
|
|
752
|
+
json(res, { ok: false, error: 'agent presets service unavailable' }, 503)
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
const presets = await ap.list()
|
|
757
|
+
if (!presets.some((preset) => preset.id === mode)) {
|
|
758
|
+
json(res, { ok: false, error: `unknown agent preset "${mode}" (available: ${presets.map((preset) => preset.id).join(', ')})` }, 400)
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
} catch (err) {
|
|
762
|
+
json(res, { ok: false, error: `cannot validate agent preset: ${err instanceof Error ? err.message : String(err)}` }, 503)
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
}
|
|
712
766
|
const ws = deps.getWorkspaceRegistry()
|
|
713
767
|
if (cwd.length > 0) {
|
|
714
768
|
// Only absolute paths, and never clobber an existing non-directory:
|
|
@@ -763,7 +817,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
763
817
|
permissionApplied,
|
|
764
818
|
})
|
|
765
819
|
} catch (err) {
|
|
766
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
820
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
767
821
|
}
|
|
768
822
|
}
|
|
769
823
|
|
|
@@ -812,7 +866,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
812
866
|
json(res, { ok: false, error: err.message, conflict: true }, 409)
|
|
813
867
|
return
|
|
814
868
|
}
|
|
815
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
869
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
816
870
|
}
|
|
817
871
|
}
|
|
818
872
|
|
|
@@ -843,14 +897,19 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
843
897
|
json(res, { ok: false, error: err.message, conflict: true }, 409)
|
|
844
898
|
return
|
|
845
899
|
}
|
|
846
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
900
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
847
901
|
}
|
|
848
902
|
}
|
|
849
903
|
|
|
850
904
|
/** Distinct non-system tags for the quick-note tag autocomplete. The flat
|
|
851
905
|
* `tags` array feeds note-widget's chip autocomplete; the parallel `items`
|
|
852
906
|
* (tag → tiddler count) feeds the reply-stream `tiddlywiki_list_tags` tool
|
|
853
|
-
* card, so both consumers share one endpoint.
|
|
907
|
+
* card, so both consumers share one endpoint.
|
|
908
|
+
*
|
|
909
|
+
* Query params (v0.19.4): `limit` caps the payload (absent = every tag, so
|
|
910
|
+
* the autocomplete keeps its full vocabulary), `sort=count` orders by usage
|
|
911
|
+
* (default `alpha`). `tags`/`items` are always the same set in the same
|
|
912
|
+
* order, plus `total`/`truncated` so a capped caller can say "N of M". */
|
|
854
913
|
const handleTags = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
855
914
|
if (rejectNonRead(req, res)) return
|
|
856
915
|
const client = deps.getClient()
|
|
@@ -859,23 +918,23 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
859
918
|
return
|
|
860
919
|
}
|
|
861
920
|
try {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
}
|
|
871
|
-
const tags = [...counts.keys()].sort((a, b) => a.localeCompare(b, 'zh'))
|
|
921
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
922
|
+
const limit = readOptionalLimit(url, MAX_TAGS_LIMIT)
|
|
923
|
+
const byCount = (url.searchParams.get('sort') ?? 'alpha') === 'count'
|
|
924
|
+
// Text-bearing tiddlers only (server-side filter) — the same counting the
|
|
925
|
+
// `tiddlywiki_list_tags` tool uses, so the card and the model agree.
|
|
926
|
+
const { total, tags: all } = await client.tagStats()
|
|
927
|
+
const ordered = byCount ? all : [...all].sort((a, b) => a.tag.localeCompare(b.tag, 'zh'))
|
|
928
|
+
const picked = limit === undefined ? ordered : ordered.slice(0, limit)
|
|
872
929
|
json(res, {
|
|
873
930
|
ok: true,
|
|
874
|
-
tags,
|
|
875
|
-
items:
|
|
931
|
+
tags: picked.map((t) => t.tag),
|
|
932
|
+
items: picked,
|
|
933
|
+
total,
|
|
934
|
+
truncated: picked.length < total,
|
|
876
935
|
})
|
|
877
936
|
} catch (err) {
|
|
878
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
937
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
879
938
|
}
|
|
880
939
|
}
|
|
881
940
|
|
|
@@ -889,8 +948,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
889
948
|
}
|
|
890
949
|
try {
|
|
891
950
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
892
|
-
const
|
|
893
|
-
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 15
|
|
951
|
+
const limit = readLimit(url, 15)
|
|
894
952
|
const items = await client.recent(limit)
|
|
895
953
|
json(res, {
|
|
896
954
|
ok: true,
|
|
@@ -903,7 +961,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
903
961
|
})),
|
|
904
962
|
})
|
|
905
963
|
} catch (err) {
|
|
906
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
964
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
907
965
|
}
|
|
908
966
|
}
|
|
909
967
|
|
|
@@ -955,7 +1013,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
955
1013
|
fields: flattenTiddlerFields(t),
|
|
956
1014
|
})
|
|
957
1015
|
} catch (err) {
|
|
958
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1016
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
959
1017
|
}
|
|
960
1018
|
}
|
|
961
1019
|
|
|
@@ -979,8 +1037,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
979
1037
|
const tag = url.searchParams.get('tag') ?? undefined
|
|
980
1038
|
const since = url.searchParams.get('since') ?? undefined
|
|
981
1039
|
const type = url.searchParams.get('type') ?? undefined
|
|
982
|
-
const
|
|
983
|
-
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(Math.floor(limitRaw), 200)) : 30
|
|
1040
|
+
const limit = readLimit(url, 30)
|
|
984
1041
|
const { items, total } = await client.search(query, { tags, tag, since, type, limit })
|
|
985
1042
|
json(res, {
|
|
986
1043
|
ok: true,
|
|
@@ -999,7 +1056,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
999
1056
|
})),
|
|
1000
1057
|
})
|
|
1001
1058
|
} catch (err) {
|
|
1002
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1059
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1003
1060
|
}
|
|
1004
1061
|
}
|
|
1005
1062
|
|
|
@@ -1017,7 +1074,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1017
1074
|
}
|
|
1018
1075
|
json(res, { ok: true, status: deps.server.status().status })
|
|
1019
1076
|
} catch (err) {
|
|
1020
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1077
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1021
1078
|
}
|
|
1022
1079
|
}
|
|
1023
1080
|
|
|
@@ -1087,7 +1144,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1087
1144
|
lastSync: new Date().toISOString(),
|
|
1088
1145
|
}, pushed.ok ? 200 : 502)
|
|
1089
1146
|
} catch (err) {
|
|
1090
|
-
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
1147
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1091
1148
|
} finally {
|
|
1092
1149
|
endMutation()
|
|
1093
1150
|
}
|
|
@@ -1126,16 +1183,31 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1126
1183
|
}
|
|
1127
1184
|
const filesDir = join(deps.getWikiPath(), 'files')
|
|
1128
1185
|
await mkdir(filesDir, { recursive: true })
|
|
1186
|
+
// Collision avoidance with an ATOMIC create (v0.19.3): the old code did
|
|
1187
|
+
// `access(candidate)` then `writeFile(candidate)`, so (a) any access error
|
|
1188
|
+
// (EACCES/EBUSY, not just ENOENT) was read as "name free" and (b) two
|
|
1189
|
+
// concurrent uploads could both pick the same name and one silently
|
|
1190
|
+
// replaced the other — contradicting the route's own "nothing is ever
|
|
1191
|
+
// overwritten" contract. `flag: 'wx'` fails with EEXIST instead, and we
|
|
1192
|
+
// walk the suffix chain on that error only. Bounded, so a pathological
|
|
1193
|
+
// directory cannot spin this loop forever.
|
|
1129
1194
|
const ext = extname(name)
|
|
1130
1195
|
const stem = ext.length > 0 ? name.slice(0, -ext.length) : name
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
let
|
|
1134
|
-
|
|
1135
|
-
try {
|
|
1136
|
-
|
|
1196
|
+
let candidate = ''
|
|
1197
|
+
let wrote = false
|
|
1198
|
+
for (let i = 0; i <= 10_000 && !wrote; i++) {
|
|
1199
|
+
candidate = i === 0 ? name : `${stem}-${i}${ext}`
|
|
1200
|
+
try {
|
|
1201
|
+
await writeFile(join(filesDir, candidate), buf, { flag: 'wx' })
|
|
1202
|
+
wrote = true
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (!wrote) {
|
|
1208
|
+
json(res, { ok: false, error: '同名文件过多,请换一个文件名' }, 409)
|
|
1209
|
+
return
|
|
1137
1210
|
}
|
|
1138
|
-
await writeFile(join(filesDir, candidate), buf)
|
|
1139
1211
|
deps.autoCommit()
|
|
1140
1212
|
invalidateGitStatus()
|
|
1141
1213
|
json(res, {
|
|
@@ -1216,6 +1288,32 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1216
1288
|
}
|
|
1217
1289
|
}
|
|
1218
1290
|
|
|
1291
|
+
/**
|
|
1292
|
+
* Titles the browser-facing TW proxies must never serve (v0.19.3).
|
|
1293
|
+
*
|
|
1294
|
+
* `$:/plugins/dsh-tiddlywiki/config` holds the shared tokens and the git
|
|
1295
|
+
* remote (possibly with a PAT); both `/tw` and `/api` forward ANY path to the
|
|
1296
|
+
* loopback TW child, whose TiddlyWeb REST answers for `$:/…` titles — a
|
|
1297
|
+
* route-level guard on `/get` was therefore trivially bypassed by
|
|
1298
|
+
* `GET /dsh-tiddlywiki/tw/recipes/default/tiddlers/%24%3A%2Fplugins%2F…`
|
|
1299
|
+
* (verified). The whole plugin namespace is blocked: nothing under it is
|
|
1300
|
+
* needed by the TW frontend, and the host itself talks to TW directly.
|
|
1301
|
+
*/
|
|
1302
|
+
const BLOCKED_PROXY_TITLE_PREFIXES = ['$:/plugins/dsh-tiddlywiki/']
|
|
1303
|
+
|
|
1304
|
+
/** True when a proxied pathname addresses a blocked (secret-bearing) tiddler. */
|
|
1305
|
+
const isBlockedProxyPath = (pathname: string): boolean => {
|
|
1306
|
+
const marker = '/tiddlers/'
|
|
1307
|
+
const at = pathname.indexOf(marker)
|
|
1308
|
+
if (at < 0) return false
|
|
1309
|
+
const raw = pathname.slice(at + marker.length)
|
|
1310
|
+
let title = raw
|
|
1311
|
+
try {
|
|
1312
|
+
title = decodeURIComponent(raw)
|
|
1313
|
+
} catch { /* malformed encoding: check the raw form */ }
|
|
1314
|
+
return BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix))
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1219
1317
|
/** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
|
|
1220
1318
|
const handleApiProxy = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1221
1319
|
if (rejectCrossSiteWrite(req, res)) return
|
|
@@ -1224,9 +1322,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1224
1322
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1225
1323
|
return
|
|
1226
1324
|
}
|
|
1227
|
-
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
1228
|
-
const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
|
|
1229
1325
|
try {
|
|
1326
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
1327
|
+
const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, '') || '/'
|
|
1328
|
+
if (isBlockedProxyPath(rest)) {
|
|
1329
|
+
json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
|
|
1330
|
+
return
|
|
1331
|
+
}
|
|
1230
1332
|
// Share the /tw proxy's header forwarding so `authorization`/`cookie`
|
|
1231
1333
|
// reach the TW child: in locked-down mode (auth.username configured) the
|
|
1232
1334
|
// /api passthrough used to 401 on every call because it dropped them.
|
|
@@ -1266,12 +1368,22 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1266
1368
|
}
|
|
1267
1369
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
1268
1370
|
const rest = url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/'
|
|
1371
|
+
if (isBlockedProxyPath(rest)) {
|
|
1372
|
+
json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
|
|
1373
|
+
return
|
|
1374
|
+
}
|
|
1269
1375
|
try {
|
|
1270
1376
|
const method = (req.method ?? 'GET').toUpperCase()
|
|
1271
1377
|
const headers = forwardHeaders(req.headers)
|
|
1272
1378
|
// TW's CSRF gate requires X-Requested-With on writes; forward it through.
|
|
1273
1379
|
if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
|
|
1274
|
-
|
|
1380
|
+
// Abort the upstream fetch when the CLIENT goes away: without this the TW
|
|
1381
|
+
// child kept streaming a large attachment into the DSH process until the
|
|
1382
|
+
// 30s timeout fired, long after the browser had cancelled (v0.19.3).
|
|
1383
|
+
const abort = new AbortController()
|
|
1384
|
+
const timeout = AbortSignal.timeout(30_000)
|
|
1385
|
+
const signal = typeof AbortSignal.any === 'function' ? AbortSignal.any([abort.signal, timeout]) : timeout
|
|
1386
|
+
const init: RequestInit = { method, headers, signal }
|
|
1275
1387
|
if (method === 'PUT' || method === 'POST') init.body = await readBodyBuffer(req, MAX_UPLOAD_BYTES)
|
|
1276
1388
|
const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init)
|
|
1277
1389
|
const responseHeaders: Record<string, string> = {
|
|
@@ -1300,7 +1412,13 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1300
1412
|
await new Promise<void>((resolveP, rejectP) => {
|
|
1301
1413
|
body.on('error', rejectP)
|
|
1302
1414
|
res.on('error', rejectP)
|
|
1303
|
-
res.on('close', () =>
|
|
1415
|
+
res.on('close', () => {
|
|
1416
|
+
// Client hung up (aborted download / closed tab): stop pulling from the
|
|
1417
|
+
// TW child instead of draining it until the timeout.
|
|
1418
|
+
try { abort.abort() } catch { /* already aborted */ }
|
|
1419
|
+
try { body.destroy() } catch { /* already closed */ }
|
|
1420
|
+
resolveP()
|
|
1421
|
+
})
|
|
1304
1422
|
res.on('finish', () => resolveP())
|
|
1305
1423
|
body.pipe(res)
|
|
1306
1424
|
})
|
|
@@ -1313,25 +1431,29 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1313
1431
|
}
|
|
1314
1432
|
}
|
|
1315
1433
|
|
|
1434
|
+
// Every handler goes through guardHandler (v0.19.3): a rejection — including a
|
|
1435
|
+
// synchronous throw before the handler's own try, e.g. `new URL(req.url)` —
|
|
1436
|
+
// becomes a 413/500 JSON response instead of an unhandled rejection that
|
|
1437
|
+
// would exit the dsh web process with the request hanging.
|
|
1316
1438
|
const disposers = [
|
|
1317
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: (
|
|
1318
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: (
|
|
1319
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: (
|
|
1320
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: (
|
|
1321
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/recent`, handler: (
|
|
1322
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/get`, handler: (
|
|
1323
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/search`, handler: (
|
|
1324
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/render`, handler: (
|
|
1325
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: (
|
|
1326
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: (
|
|
1327
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: (
|
|
1328
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: (
|
|
1329
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: (
|
|
1330
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: (
|
|
1331
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: (
|
|
1332
|
-
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: (
|
|
1333
|
-
ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: (
|
|
1334
|
-
ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: (
|
|
1439
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/status`, handler: guardHandler(handleStatus) }),
|
|
1440
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/note`, handler: guardHandler(handleNote) }),
|
|
1441
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/edit`, handler: guardHandler(handleEdit) }),
|
|
1442
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/tags`, handler: guardHandler(handleTags) }),
|
|
1443
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/recent`, handler: guardHandler(handleRecent) }),
|
|
1444
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/get`, handler: guardHandler(handleGet) }),
|
|
1445
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/search`, handler: guardHandler(handleSearch) }),
|
|
1446
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/render`, handler: guardHandler(handleRender) }),
|
|
1447
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/sync`, handler: guardHandler(handleSync) }),
|
|
1448
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: guardHandler(handleUpload) }),
|
|
1449
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: guardHandler(handleRestart) }),
|
|
1450
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: guardHandler(handleSessionSummary) }),
|
|
1451
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: guardHandler(handleAgentSessions) }),
|
|
1452
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: guardHandler(handleAgentModes) }),
|
|
1453
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: guardHandler(handleAgentSend) }),
|
|
1454
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/create`, handler: guardHandler(handleAgentCreate) }),
|
|
1455
|
+
ctx.webServer.register({ kind: 'prefix', path: `${ROUTE_PREFIX}/api`, handler: guardHandler(handleApiProxy) }),
|
|
1456
|
+
ctx.webServer.register({ kind: 'prefix', path: `${TW_PROXY_PREFIX}`, handler: guardHandler(handleTwProxy) }),
|
|
1335
1457
|
]
|
|
1336
1458
|
return () => {
|
|
1337
1459
|
for (const dispose of disposers) dispose()
|
|
@@ -204,7 +204,7 @@ function scanSnapshot(snap: SessionLogSnapshot | undefined, subagent: boolean, c
|
|
|
204
204
|
title: args.newTitle,
|
|
205
205
|
action: 'produced',
|
|
206
206
|
time: t,
|
|
207
|
-
detail: old !== undefined ? `重命名自「${old}」` : undefined,
|
|
207
|
+
detail: old !== undefined && isPlausibleTitle(old) ? `重命名自「${old}」` : undefined,
|
|
208
208
|
subagent,
|
|
209
209
|
})
|
|
210
210
|
}
|
|
@@ -243,12 +243,29 @@ function scanSnapshot(snap: SessionLogSnapshot | undefined, subagent: boolean, c
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* 内联文本净化(v0.19.3):汇总 wikitext 会被 TW 渲染成 HTML 片段再注入 DSH 页面,
|
|
248
|
+
* 而 TW 的解析器**原样透传 HTML**。凡是来自会话日志/请求体的字符串(sessionId、
|
|
249
|
+
* rename 的旧标题、检索词)都要先转义 HTML 元字符并去掉控制字符,别指望上层净化。
|
|
250
|
+
*/
|
|
251
|
+
export function escapeInline(text: string, max = 200): string {
|
|
252
|
+
return text
|
|
253
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
254
|
+
.replace(/&/g, '&')
|
|
255
|
+
.replace(/</g, '<')
|
|
256
|
+
.replace(/>/g, '>')
|
|
257
|
+
.replace(/[[\]|]/g, ' ')
|
|
258
|
+
.slice(0, max)
|
|
259
|
+
}
|
|
260
|
+
|
|
246
261
|
/** 当前状态(存在?标签?修改时间?摘要)。 */
|
|
247
262
|
interface TitleState {
|
|
248
263
|
exists: boolean
|
|
249
264
|
tags: string[]
|
|
250
265
|
modified?: string
|
|
251
266
|
snippet?: string
|
|
267
|
+
/** 查询失败(非 404):状态未知,不能报「已删除/不存在」。 */
|
|
268
|
+
unknown?: boolean
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
/** 并发(8 路)查询每篇笔记的当前状态(404 → 不存在/已删除)。 */
|
|
@@ -266,7 +283,10 @@ async function enrichTitles(client: TiddlyWebClient, titles: string[]): Promise<
|
|
|
266
283
|
snippet: flat.length > 48 ? `${flat.slice(0, 48)}…` : flat,
|
|
267
284
|
}]
|
|
268
285
|
} catch {
|
|
269
|
-
|
|
286
|
+
// 只有 404 才算「不存在」(tw-api 的 get 只在 404 返回 undefined):
|
|
287
|
+
// 超时/5xx/服务重启中的查询失败必须报「状态未知」,否则一篇还在的笔记
|
|
288
|
+
// 会被标成「已删除」——正是项目里反复强调的「读取失败 ≠ 条目不存在」。
|
|
289
|
+
return [title, { exists: false, tags: [], unknown: true }]
|
|
270
290
|
}
|
|
271
291
|
}
|
|
272
292
|
// Bounded batches: parallel within a batch, insertion order preserved across
|
|
@@ -305,7 +325,7 @@ function buildWikitext(
|
|
|
305
325
|
lines.push('! 会话相关 wiki 汇总')
|
|
306
326
|
lines.push('')
|
|
307
327
|
lines.push(
|
|
308
|
-
`本页列出会话 \`${sessionId}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`,
|
|
328
|
+
`本页列出会话 \`${escapeInline(sessionId, 120)}\`(含其后代子代理)在本会话中产生 / 读取 / 检索过的知识库笔记。`,
|
|
309
329
|
)
|
|
310
330
|
lines.push('')
|
|
311
331
|
const producedCount = producedTitles.length
|
|
@@ -332,16 +352,18 @@ function buildWikitext(
|
|
|
332
352
|
|
|
333
353
|
const entryLine = (title: string, state: TitleState | undefined, entry: NoteEntry): string => {
|
|
334
354
|
const bits: string[] = [`[[${title}]]`]
|
|
335
|
-
if (state
|
|
355
|
+
if (state !== undefined && state.unknown === true) {
|
|
356
|
+
bits.push('⚠️ 状态未知(查询失败)')
|
|
357
|
+
} else if (state === undefined || !state.exists) {
|
|
336
358
|
bits.push('⚠️ 已删除/不存在')
|
|
337
359
|
} else {
|
|
338
|
-
if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).join('、')}${state.tags.length > 6 ? '…' : ''}`)
|
|
360
|
+
if (state.tags.length > 0) bits.push(`标签 ${state.tags.slice(0, 6).map((tag) => escapeInline(tag, 40)).join('、')}${state.tags.length > 6 ? '…' : ''}`)
|
|
339
361
|
const mod = fmtModified(state.modified)
|
|
340
362
|
if (mod.length > 0) bits.push(`修改 ${mod}`)
|
|
341
363
|
}
|
|
342
364
|
const t = fmtTime(entry.time)
|
|
343
365
|
if (t.length > 0) bits.push(`会话内 ${t}`)
|
|
344
|
-
if (entry.detail !== undefined) bits.push(entry.detail)
|
|
366
|
+
if (entry.detail !== undefined) bits.push(escapeInline(entry.detail, 200))
|
|
345
367
|
if (entry.subagent) bits.push('(子代理)')
|
|
346
368
|
return `* ${bits.join(' · ')}`
|
|
347
369
|
}
|
|
@@ -367,8 +389,8 @@ function buildWikitext(
|
|
|
367
389
|
for (const r of collected.searches) {
|
|
368
390
|
const bits: string[] = []
|
|
369
391
|
if (r.kind === 'search') {
|
|
370
|
-
bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? r.query : '(空)'}」`)
|
|
371
|
-
if (r.tags.length > 0) bits.push(`tags ${r.tags.join('、')}`)
|
|
392
|
+
bits.push(`\`tiddlywiki_search\` query=「${r.query.length > 0 ? escapeInline(r.query, 120) : '(空)'}」`)
|
|
393
|
+
if (r.tags.length > 0) bits.push(`tags ${r.tags.map((tag) => escapeInline(tag, 40)).join('、')}`)
|
|
372
394
|
} else {
|
|
373
395
|
bits.push('`tiddlywiki_recent`(最近修改)')
|
|
374
396
|
}
|
package/src/host/tools.ts
CHANGED
|
@@ -345,21 +345,32 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
345
345
|
// ── tiddlywiki_list_tags ─────────────────────────────────────────────────
|
|
346
346
|
register(defineTool({
|
|
347
347
|
name: 'tiddlywiki_list_tags',
|
|
348
|
-
description: '列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。',
|
|
349
|
-
parameters: {
|
|
348
|
+
description: '列出 TiddlyWiki 知识库现有的非系统标签及各自计数(按使用次数降序),方便决定给笔记打什么 tag。默认最多返回 200 个(`limit` 可调,上限 1000),被截断时结果里会带 total/truncated。',
|
|
349
|
+
parameters: {
|
|
350
|
+
limit: { type: 'integer', description: '可选:最多返回多少个标签(按使用次数降序),默认 200,上限 1000。' },
|
|
351
|
+
},
|
|
350
352
|
output: {
|
|
351
353
|
schema: { type: 'json' },
|
|
352
354
|
render: (_args, value: TagListResult) => {
|
|
353
355
|
if (value.tags.length === 0) return [{ type: 'text', text: '知识库暂无标签。' }]
|
|
354
|
-
const lines = [
|
|
356
|
+
const lines = [
|
|
357
|
+
value.truncated
|
|
358
|
+
? `现有标签(共 ${value.total} 个,仅列出使用最多的 ${value.tags.length} 个,按使用次数降序):`
|
|
359
|
+
: `现有标签(${value.total} 个,按使用次数降序):`,
|
|
360
|
+
]
|
|
355
361
|
for (const t of value.tags) lines.push(`- ${t.tag} × ${t.count}`)
|
|
362
|
+
if (value.truncated) lines.push(`(其余 ${value.total - value.tags.length} 个较少使用的标签未列出;需要时可提高 limit 重试)`)
|
|
356
363
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
357
364
|
},
|
|
358
365
|
},
|
|
359
|
-
execute: async (): Promise<TagListResult> => {
|
|
366
|
+
execute: async (args: { limit?: number }): Promise<TagListResult> => {
|
|
360
367
|
const wiki = requireWiki()
|
|
361
|
-
const
|
|
362
|
-
|
|
368
|
+
const stats = await wiki.tagStats()
|
|
369
|
+
const limit = typeof args.limit === 'number' && Number.isFinite(args.limit)
|
|
370
|
+
? Math.max(1, Math.min(Math.floor(args.limit), 1000))
|
|
371
|
+
: 200
|
|
372
|
+
const tags = stats.tags.slice(0, limit)
|
|
373
|
+
return { count: tags.length, total: stats.total, truncated: tags.length < stats.total, tags }
|
|
363
374
|
},
|
|
364
375
|
}))
|
|
365
376
|
|
|
@@ -1100,7 +1111,7 @@ export function registerTiddlywikiTools(ctx: ToolsCtx, deps: ToolsDeps): Array<(
|
|
|
1100
1111
|
interface SearchHit { title: string; tags: string[]; modified: string | null; snippet: string }
|
|
1101
1112
|
interface SearchResult { query: string; tags: string[]; since: string | null; type: string | null; field: string | null; value: string | null; total: number; results: SearchHit[] }
|
|
1102
1113
|
interface RecentResult { since: string | null; results: SearchHit[] }
|
|
1103
|
-
interface TagListResult { count: number; tags: Array<{ tag: string; count: number }> }
|
|
1114
|
+
interface TagListResult { count: number; total: number; truncated: boolean; tags: Array<{ tag: string; count: number }> }
|
|
1104
1115
|
interface GetResult {
|
|
1105
1116
|
notFound: boolean
|
|
1106
1117
|
title: string
|
package/src/host/tw-api.ts
CHANGED
|
@@ -456,8 +456,19 @@ export class TiddlyWebClient {
|
|
|
456
456
|
/**
|
|
457
457
|
* Distinct non-system tags with their tiddler counts, most-used first then
|
|
458
458
|
* zh-locale. One skinny listing request (no text payloads).
|
|
459
|
+
*
|
|
460
|
+
* `limit` (v0.19.4) caps the returned array after sorting; the caller loses
|
|
461
|
+
* the tail count unless it also asks `tagStats()`.
|
|
459
462
|
*/
|
|
460
|
-
async listTags(): Promise<Array<{ tag: string; count: number }>> {
|
|
463
|
+
async listTags(limit?: number): Promise<Array<{ tag: string; count: number }>> {
|
|
464
|
+
const tags = (await this.tagStats()).tags
|
|
465
|
+
if (limit === undefined) return tags
|
|
466
|
+
return tags.slice(0, Math.max(1, Math.min(Math.floor(limit), 1000)))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Full tag vocabulary + its size, so callers that cap can still report the
|
|
470
|
+
* total ("showing 200 of N") — one listing request either way. */
|
|
471
|
+
async tagStats(): Promise<{ total: number; tags: Array<{ tag: string; count: number }> }> {
|
|
461
472
|
// TEXT_LIST_FILTER (server-side, whitelist self-healed) so a tag that only
|
|
462
473
|
// hangs off binary attachments is not counted as a knowledge-base tag
|
|
463
474
|
// (v0.19.0 — the unfiltered skinny listing included image/book-page tags).
|
|
@@ -470,9 +481,10 @@ export class TiddlyWebClient {
|
|
|
470
481
|
map.set(tag, (map.get(tag) ?? 0) + 1)
|
|
471
482
|
}
|
|
472
483
|
}
|
|
473
|
-
|
|
484
|
+
const tags = [...map.entries()]
|
|
474
485
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh'))
|
|
475
486
|
.map(([tag, count]) => ({ tag, count }))
|
|
487
|
+
return { total: tags.length, tags }
|
|
476
488
|
}
|
|
477
489
|
}
|
|
478
490
|
|
package/src/index.ts
CHANGED
|
@@ -57,7 +57,8 @@ export {
|
|
|
57
57
|
export { openInTwEditor, registerRoutes } from './host/routes.ts'
|
|
58
58
|
export { writeSessionSummary, SESSION_SUMMARY_PREFIX } from './host/routes.ts'
|
|
59
59
|
export type { SessionQueryFace, SessionSummaryResult } from './host/routes.ts'
|
|
60
|
-
export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, ensurePlugin, bundledCatalog, ensureLanguage, normalizeThemes } from './host/admin.ts'
|
|
60
|
+
export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, ensurePlugin, bundledCatalog, ensureLanguage, normalizeThemes, MASKED_SECRET, maskConfigSecrets, stripMaskedSecrets } from './host/admin.ts'
|
|
61
|
+
export { escapeInline } from './host/session-summary.ts'
|
|
61
62
|
export { seedDocNote, DOC_NOTE_TITLE, DOC_NOTE_TAG, DOC_NOTE_TEXT } from './host/seed-notes.ts'
|
|
62
63
|
export { seedStarterDocs, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, DSH_DOCS_TAG } from './host/seed-starter-docs.ts'
|
|
63
64
|
export { seedSendToAgent, SEND_TO_AGENT_PLUGIN_TITLE, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_BUNDLE_TEXT } from './host/seed-send-to-agent.ts'
|