dsh-tiddlywiki 0.27.2 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +3 -0
- package/cordis.patch.yml +13 -13
- package/docs/CHANGELOG.md +2 -1
- package/docs/code-review-2026-09-17.md +151 -0
- package/docs/features.md +5 -2
- package/docs/usage.md +34 -3
- package/docs/wiki-split.md +97 -0
- package/lib/client.js +54 -27
- package/lib/index.js +2803 -517
- package/lib/index.js.map +1 -1
- package/package.json +5 -4
- package/skills/wiki-split/SKILL.md +106 -0
- package/src/client/endpoints.ts +37 -0
- package/src/client/index.ts +14 -0
- package/src/client/knowledge-fab.ts +62 -3
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/panel.ts +2 -1
- package/src/client/settings-page.ts +217 -3
- package/src/client/state.ts +39 -39
- package/src/client/status-cache.ts +17 -0
- package/src/client/styles.ts +27 -0
- package/src/client/toast.ts +22 -22
- package/src/client/tw-frame.ts +28 -4
- package/src/client/wiki-focus.ts +70 -0
- package/src/client/wiki-scope-dock.ts +116 -0
- package/src/host/admin.ts +175 -35
- package/src/host/git.ts +50 -5
- package/src/host/path-key.ts +35 -0
- package/src/host/prompt.ts +24 -0
- package/src/host/repo-committers.ts +149 -0
- package/src/host/routes.ts +254 -81
- package/src/host/seeds.ts +51 -10
- package/src/host/session-scope.ts +154 -0
- package/src/host/skill-install.ts +121 -0
- package/src/host/tools.ts +141 -49
- package/src/host/wiki-farm.ts +417 -0
- package/src/host/wiki-instance.ts +596 -0
- package/src/host/wiki-registry.ts +601 -0
- package/src/host/wiki.ts +19 -0
- package/src/index.ts +686 -459
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-SESSION knowledge-base selector above the chat input box
|
|
3
|
+
* (需求: 会话栏上面可动态选择当前会话能用的 wiki).
|
|
4
|
+
*
|
|
5
|
+
* Registers into `conversation.input.dock` — the sanctioned "full-width entries
|
|
6
|
+
* above the composer" seat the quick-note button already uses. That slot is
|
|
7
|
+
* `kind: 'list', scope: 'session'`, so the component receives the `sessionId` it
|
|
8
|
+
* is mounted for, which is exactly what the host needs to answer "which wiki is
|
|
9
|
+
* THIS conversation working on".
|
|
10
|
+
*
|
|
11
|
+
* WHY THE SELECTION LIVES HOST-SIDE: it drives the agent's tool scope AND the
|
|
12
|
+
* injected prompt (see host/session-scope.ts), so it cannot be a browser-only
|
|
13
|
+
* preference. Picking here is all the user has to do.
|
|
14
|
+
*
|
|
15
|
+
* SINGLE-WIKI INSTALLS SEE NOTHING: the control renders only when MORE THAN ONE
|
|
16
|
+
* `agentVisible` wiki exists. Hidden wikis are deliberately absent from the
|
|
17
|
+
* list — offering one would contradict the setting the user just made (the host
|
|
18
|
+
* refuses it with a 400 as well).
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-tiddlywiki/client/wiki-scope-dock
|
|
21
|
+
*/
|
|
22
|
+
import * as React from 'react'
|
|
23
|
+
import { fetchStatus } from './status-cache.ts'
|
|
24
|
+
import { SESSION_WIKI_ENDPOINT } from './endpoints.ts'
|
|
25
|
+
|
|
26
|
+
interface WikiOption { id: string; label: string; running: boolean }
|
|
27
|
+
|
|
28
|
+
/** Props the shell passes to a `conversation.input.dock` entry (scope: session). */
|
|
29
|
+
interface DockProps { sessionId?: string }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The dock entry. Returning `null` is a supported way to occupy the slot and
|
|
33
|
+
* show nothing — which is what a single-wiki install gets.
|
|
34
|
+
*/
|
|
35
|
+
export function createWikiScopeDock(): (props: DockProps) => React.ReactElement | null {
|
|
36
|
+
return function WikiScopeDock(props: DockProps) {
|
|
37
|
+
const sessionId = typeof props.sessionId === 'string' && props.sessionId.length > 0 ? props.sessionId : undefined
|
|
38
|
+
const [wikis, setWikis] = React.useState<WikiOption[]>([])
|
|
39
|
+
const [scope, setScope] = React.useState('')
|
|
40
|
+
const [note, setNote] = React.useState<string | undefined>(undefined)
|
|
41
|
+
const [busy, setBusy] = React.useState(false)
|
|
42
|
+
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
if (sessionId === undefined) return
|
|
45
|
+
let alive = true
|
|
46
|
+
void (async () => {
|
|
47
|
+
const [status, current] = await Promise.all([
|
|
48
|
+
fetchStatus(),
|
|
49
|
+
fetch(`${SESSION_WIKI_ENDPOINT}?session=${encodeURIComponent(sessionId)}`, { signal: AbortSignal.timeout(8_000) })
|
|
50
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
51
|
+
.catch(() => null),
|
|
52
|
+
])
|
|
53
|
+
if (!alive) return
|
|
54
|
+
const visible = (status?.wikis ?? []).filter((wiki) => wiki.agentVisible)
|
|
55
|
+
setWikis(visible.map((wiki) => ({ id: wiki.id, label: wiki.label, running: wiki.running })))
|
|
56
|
+
setScope(typeof current?.scope === 'string' ? current.scope : '')
|
|
57
|
+
setNote(typeof current?.reason === 'string' ? current.reason : undefined)
|
|
58
|
+
})()
|
|
59
|
+
return () => { alive = false }
|
|
60
|
+
}, [sessionId])
|
|
61
|
+
|
|
62
|
+
if (sessionId === undefined || wikis.length <= 1) return null
|
|
63
|
+
|
|
64
|
+
const apply = (next: string): void => {
|
|
65
|
+
setBusy(true)
|
|
66
|
+
void (async () => {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(SESSION_WIKI_ENDPOINT, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'content-type': 'application/json' },
|
|
71
|
+
// A generous budget: selecting a wiki that is not running STARTS it
|
|
72
|
+
// host-side (the tools resolve the scope synchronously and never start
|
|
73
|
+
// anything themselves), and a cold child can take tens of seconds.
|
|
74
|
+
body: JSON.stringify({ session: sessionId, wiki: next.length > 0 ? next : null }),
|
|
75
|
+
signal: AbortSignal.timeout(120_000),
|
|
76
|
+
})
|
|
77
|
+
const payload = (await res.json().catch(() => ({}))) as { ok?: boolean; scope?: string; reason?: string; error?: string }
|
|
78
|
+
if (!res.ok || payload.ok !== true) {
|
|
79
|
+
setNote(payload.error ?? `切换失败(HTTP ${res.status})`)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
setScope(typeof payload.scope === 'string' ? payload.scope : next)
|
|
83
|
+
setNote(typeof payload.reason === 'string' ? payload.reason : undefined)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
setNote(err instanceof Error ? err.message : String(err))
|
|
86
|
+
} finally {
|
|
87
|
+
setBusy(false)
|
|
88
|
+
}
|
|
89
|
+
})()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return React.createElement(
|
|
93
|
+
'div',
|
|
94
|
+
{ className: 'dsh-tw-scope-dock' },
|
|
95
|
+
React.createElement('span', { className: 'dsh-tw-scope-label' }, '知识库'),
|
|
96
|
+
React.createElement(
|
|
97
|
+
'select',
|
|
98
|
+
{
|
|
99
|
+
className: 'dsh-tw-scope-select',
|
|
100
|
+
value: scope,
|
|
101
|
+
disabled: busy,
|
|
102
|
+
'aria-label': '本会话使用的知识库',
|
|
103
|
+
title: '本会话的 tiddlywiki_* 工具与注入提示词都作用于这个知识库(下一个模型步骤生效)',
|
|
104
|
+
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => { apply(event.target.value) },
|
|
105
|
+
},
|
|
106
|
+
React.createElement('option', { value: '' }, '默认库'),
|
|
107
|
+
...wikis.map((wiki) => React.createElement(
|
|
108
|
+
'option',
|
|
109
|
+
{ key: wiki.id, value: wiki.id },
|
|
110
|
+
`${wiki.label}${wiki.running ? '' : '(未运行,选中会启动)'}`,
|
|
111
|
+
)),
|
|
112
|
+
),
|
|
113
|
+
note !== undefined ? React.createElement('span', { className: 'dsh-tw-scope-note' }, note) : null,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/host/admin.ts
CHANGED
|
@@ -510,11 +510,19 @@ export async function ensureLanguage(wikiPath: string, twRoot: string, lang: str
|
|
|
510
510
|
}
|
|
511
511
|
|
|
512
512
|
export interface AdminDeps {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
513
|
+
/** The TW child serving the wiki this request targets (undefined = not up). */
|
|
514
|
+
server: (req: IncomingMessage) => WikiServer | undefined
|
|
515
|
+
getClient: (req: IncomingMessage) => TiddlyWebClient | undefined
|
|
516
|
+
getWikiPath: (req: IncomingMessage) => string
|
|
516
517
|
twRoot: () => string
|
|
517
|
-
|
|
518
|
+
/**
|
|
519
|
+
* The config overlay OF THE TARGETED WIKI (v0.28.0: per request).
|
|
520
|
+
*
|
|
521
|
+
* Every knowledge base keeps its own `$:/plugins/dsh-tiddlywiki/config`
|
|
522
|
+
* tiddler — that IS what "each wiki has independent settings" means in
|
|
523
|
+
* practice, so the settings-page routes must say WHICH wiki they configure.
|
|
524
|
+
*/
|
|
525
|
+
config: (req: IncomingMessage) => ConfigStore
|
|
518
526
|
/**
|
|
519
527
|
* Called after a successful settings-page save (v0.21.0). The plugin rebuilds
|
|
520
528
|
* its system-prompt section here, so a prompt.* edit applies to the running
|
|
@@ -527,7 +535,17 @@ export interface AdminDeps {
|
|
|
527
535
|
* effective config; called with a draft (v0.22.7) → those unsaved values.
|
|
528
536
|
* Read-only and side-effect free; absent in headless contexts.
|
|
529
537
|
*/
|
|
530
|
-
|
|
538
|
+
/**
|
|
539
|
+
* The system-prompt text that WOULD be injected for a configuration, for the
|
|
540
|
+
* settings page preview (v0.21.0). Called with no draft → the SAVED effective
|
|
541
|
+
* config OF THE TARGETED WIKI; called with a draft (v0.22.7) → those unsaved
|
|
542
|
+
* values. Takes the request since v0.28.0: every knowledge base carries its
|
|
543
|
+
* own `prompt.*` in its own config tiddler, so "the saved config" is
|
|
544
|
+
* meaningless without saying WHICH wiki.
|
|
545
|
+
*
|
|
546
|
+
* Read-only and side-effect free; absent in headless contexts.
|
|
547
|
+
*/
|
|
548
|
+
getPrompt?: (draft: PromptPreviewConfig | undefined, req: IncomingMessage) => { enabled: boolean; mode: string; text: string }
|
|
531
549
|
/**
|
|
532
550
|
* Runtime wiki location (v0.22.0): where the wiki folder is, how that was
|
|
533
551
|
* decided, and the two operations the settings page can perform. Absent in
|
|
@@ -538,6 +556,17 @@ export interface AdminDeps {
|
|
|
538
556
|
switch: (target: { root?: unknown; name?: unknown }) => Promise<WikiSwitchResult>
|
|
539
557
|
reset: () => Promise<WikiSwitchResult>
|
|
540
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* The knowledge-base LIST (v0.28.0): read it, and change it one action at a
|
|
561
|
+
* time. `info` reports the CONTROL FILE (what the user edits), which is not
|
|
562
|
+
* the same thing as what is running — in single mode the farm serves a
|
|
563
|
+
* one-entry registry synthesized from the legacy pointer, while the file may
|
|
564
|
+
* already list several candidates.
|
|
565
|
+
*/
|
|
566
|
+
wikis?: {
|
|
567
|
+
info: () => Promise<AdminWikisView>
|
|
568
|
+
apply: (body: unknown) => Promise<AdminWikisApplyResult>
|
|
569
|
+
}
|
|
541
570
|
/** Seed registry for the settings-page "初始化" section. */
|
|
542
571
|
seeds: {
|
|
543
572
|
/**
|
|
@@ -552,6 +581,50 @@ export interface AdminDeps {
|
|
|
552
581
|
}
|
|
553
582
|
}
|
|
554
583
|
|
|
584
|
+
/** One knowledge base as the settings page lists it. */
|
|
585
|
+
export interface AdminWikiItem {
|
|
586
|
+
id: string
|
|
587
|
+
label: string
|
|
588
|
+
root: string
|
|
589
|
+
name: string
|
|
590
|
+
/** Absolute folder (`root/name`); shown so the user can find it in a file manager. */
|
|
591
|
+
path: string
|
|
592
|
+
agentVisible: boolean
|
|
593
|
+
autostart: boolean
|
|
594
|
+
running: boolean
|
|
595
|
+
status: string
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The control file's view (what the settings page edits). */
|
|
599
|
+
export interface AdminWikisView {
|
|
600
|
+
mode: string
|
|
601
|
+
defaultId: string
|
|
602
|
+
/** Where the list came from: 'file' | 'legacy' (migrated pointer) | 'default'. */
|
|
603
|
+
source: string
|
|
604
|
+
/** Path of the control file (so a user can fix it by hand when told to). */
|
|
605
|
+
registryFile: string
|
|
606
|
+
/** Set when the control file exists but could not be fully trusted. */
|
|
607
|
+
error?: string
|
|
608
|
+
/** Non-fatal problems healed on the way (skipped entry, dangling default). */
|
|
609
|
+
warnings: string[]
|
|
610
|
+
wikis: AdminWikiItem[]
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** What one list change actually did (toast text + diagnostics). */
|
|
614
|
+
export interface AdminWikisChange {
|
|
615
|
+
started: string[]
|
|
616
|
+
stopped: string[]
|
|
617
|
+
updated: string[]
|
|
618
|
+
running: string[]
|
|
619
|
+
/** Wikis that failed to start/stop; the change itself still stands. */
|
|
620
|
+
errors: Array<{ id: string; message: string }>
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Result of a list change: either the new state, or why it was refused. */
|
|
624
|
+
export type AdminWikisApplyResult =
|
|
625
|
+
| { ok: true; info: AdminWikisView; change: AdminWikisChange }
|
|
626
|
+
| { ok: false; error: string }
|
|
627
|
+
|
|
555
628
|
/**
|
|
556
629
|
* Sentinel returned instead of a stored secret (`bridge.token` /
|
|
557
630
|
* `ui.sendToAgent.token`) by `GET /admin/state` and by `POST /admin/config`'s
|
|
@@ -663,7 +736,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
663
736
|
const handleState = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
664
737
|
try {
|
|
665
738
|
if (rejectNonRead(req, res)) return
|
|
666
|
-
const wikiPath = deps.getWikiPath()
|
|
739
|
+
const wikiPath = deps.getWikiPath(req)
|
|
667
740
|
const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())])
|
|
668
741
|
let git: unknown = null
|
|
669
742
|
try {
|
|
@@ -672,12 +745,12 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
672
745
|
} catch {
|
|
673
746
|
git = null
|
|
674
747
|
}
|
|
675
|
-
const view = deps.server
|
|
748
|
+
const view = deps.server(req)?.status() ?? { status: 'stopped' as const, wikiPath, logs: [] }
|
|
676
749
|
// Which theme the runtime actually shows (v0.22.3): the settings page used
|
|
677
750
|
// to derive it from the LAST entry of info.themes, so a non-last active
|
|
678
751
|
// theme displayed the wrong radio — and re-applying it (even without
|
|
679
752
|
// touching the radios) rewrote `$:/theme` to that wrong pick.
|
|
680
|
-
const themeActive = await readActiveThemeName(deps.getClient())
|
|
753
|
+
const themeActive = await readActiveThemeName(deps.getClient(req))
|
|
681
754
|
// Runtime plugin truth (v0.26.0): wiki-installed plugin tiddlers + the
|
|
682
755
|
// disabled markers, so 插件管理 rows can be labelled honestly. `null`
|
|
683
756
|
// (scan failed) is passed through — the client hides the section rather
|
|
@@ -690,11 +763,11 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
690
763
|
info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [], themeActive },
|
|
691
764
|
catalog,
|
|
692
765
|
runtimePlugins,
|
|
693
|
-
config: maskConfigSecrets(deps.config.get()),
|
|
766
|
+
config: maskConfigSecrets(deps.config(req).get()),
|
|
694
767
|
// Why the overrides above are NOT in effect (v0.23.4): a stored config
|
|
695
768
|
// that does not parse keeps being ignored silently otherwise, and the
|
|
696
769
|
// page would show the (masked) defaults as if the user had chosen them.
|
|
697
|
-
configError: deps.config.parseError() ?? null,
|
|
770
|
+
configError: deps.config(req).parseError() ?? null,
|
|
698
771
|
git,
|
|
699
772
|
})
|
|
700
773
|
} catch (err) {
|
|
@@ -706,7 +779,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
706
779
|
try {
|
|
707
780
|
if (rejectCrossSiteWrite(req, res, ['POST'])) return
|
|
708
781
|
const body = JSON.parse(await readBody(req)) as { plugins?: unknown; themes?: unknown; themeActive?: unknown; languages?: unknown }
|
|
709
|
-
const wikiPath = deps.getWikiPath()
|
|
782
|
+
const wikiPath = deps.getWikiPath(req)
|
|
710
783
|
// A read failure must NEVER be turned into "the wiki has no plugins":
|
|
711
784
|
// saving would then rewrite the file without them. Fail with 500 and
|
|
712
785
|
// leave the file untouched.
|
|
@@ -795,15 +868,15 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
795
868
|
// This path predates the "restarting over unflushed writes loses them"
|
|
796
869
|
// insight that the seed path below already documents (rule #1).
|
|
797
870
|
await drainThenStop({
|
|
798
|
-
client: deps.getClient(),
|
|
799
|
-
tiddlersDir: join(deps.getWikiPath(), 'tiddlers'),
|
|
800
|
-
stop: () => deps.server
|
|
871
|
+
client: deps.getClient(req),
|
|
872
|
+
tiddlersDir: join(deps.getWikiPath(req), 'tiddlers'),
|
|
873
|
+
stop: async () => { await deps.server(req)?.restart() },
|
|
801
874
|
log: (message) => console.warn('[dsh-tiddlywiki]', message),
|
|
802
875
|
})
|
|
803
876
|
}
|
|
804
877
|
// Activate the chosen theme tiddler (mirrors TW's own Control Panel).
|
|
805
878
|
if (Array.isArray(body.themes) && activatedTheme !== undefined) {
|
|
806
|
-
const client = deps.getClient()
|
|
879
|
+
const client = deps.getClient(req)
|
|
807
880
|
if (client !== undefined) {
|
|
808
881
|
await client
|
|
809
882
|
.put({ title: '$:/theme', text: `$:/themes/${activatedTheme}`, type: 'text/vnd.tiddlywiki', tags: [] })
|
|
@@ -814,7 +887,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
814
887
|
// enabled language, or en-GB when none is enabled. Only when the request
|
|
815
888
|
// actually carried a languages array (plugins/themes restarts skip this).
|
|
816
889
|
if (Array.isArray(body.languages)) {
|
|
817
|
-
const client = deps.getClient()
|
|
890
|
+
const client = deps.getClient(req)
|
|
818
891
|
if (client !== undefined) {
|
|
819
892
|
const langs = info.languages ?? []
|
|
820
893
|
const active = langs.length > 0 ? `$:/languages/${langs[0]}` : '$:/languages/en-GB'
|
|
@@ -829,8 +902,8 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
829
902
|
// the active language, so a later dsh-web restart doesn't re-enable
|
|
830
903
|
// a language the user just disabled here.
|
|
831
904
|
const hint = langs.length > 0 ? langs[0] : ''
|
|
832
|
-
if ((deps.config.get().uiLanguage ?? '') !== hint) {
|
|
833
|
-
await deps.config.set(client, { uiLanguage: hint }).catch(() => undefined)
|
|
905
|
+
if ((deps.config(req).get().uiLanguage ?? '') !== hint) {
|
|
906
|
+
await deps.config(req).set(client, { uiLanguage: hint }).catch(() => undefined)
|
|
834
907
|
}
|
|
835
908
|
}
|
|
836
909
|
}
|
|
@@ -844,7 +917,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
844
917
|
try {
|
|
845
918
|
if (rejectCrossSiteWrite(req, res, ['POST'])) return
|
|
846
919
|
const body = JSON.parse(await readBody(req)) as unknown
|
|
847
|
-
const client = deps.getClient()
|
|
920
|
+
const client = deps.getClient(req)
|
|
848
921
|
if (client === undefined) {
|
|
849
922
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
850
923
|
return
|
|
@@ -856,11 +929,11 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
856
929
|
const patch = normalizeConfigPatch(body)
|
|
857
930
|
// Never persist the masked placeholders the page read back from /state
|
|
858
931
|
// (v0.19.3): saving an untouched form must not clobber a real token.
|
|
859
|
-
await deps.config.set(client, stripMaskedSecrets(patch as Record<string, unknown>, deps.config.get()) as PluginConfigShape)
|
|
932
|
+
await deps.config(req).set(client, stripMaskedSecrets(patch as Record<string, unknown>, deps.config(req).get()) as PluginConfigShape)
|
|
860
933
|
// prompt.* may have changed: let the plugin re-register its prompt
|
|
861
934
|
// section now (a no-op when the built text is unchanged).
|
|
862
935
|
deps.onConfigChanged?.()
|
|
863
|
-
json(res, { ok: true, config: maskConfigSecrets(deps.config.get()) })
|
|
936
|
+
json(res, { ok: true, config: maskConfigSecrets(deps.config(req).get()) })
|
|
864
937
|
} catch (err) {
|
|
865
938
|
// A malformed patch is the caller's problem (400): the page can then show
|
|
866
939
|
// exactly which field was wrong instead of a generic 500.
|
|
@@ -888,12 +961,12 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
888
961
|
// v0.24.1: drain the syncer queue first (ironclad rule #1) — this route
|
|
889
962
|
// used to kill the child with writes still queued, losing them silently.
|
|
890
963
|
const drained = await drainThenStop({
|
|
891
|
-
client: deps.getClient(),
|
|
892
|
-
tiddlersDir: join(deps.getWikiPath(), 'tiddlers'),
|
|
893
|
-
stop: () => deps.server
|
|
964
|
+
client: deps.getClient(req),
|
|
965
|
+
tiddlersDir: join(deps.getWikiPath(req), 'tiddlers'),
|
|
966
|
+
stop: async () => { await deps.server(req)?.restart() },
|
|
894
967
|
log: (message) => console.warn('[dsh-tiddlywiki]', message),
|
|
895
968
|
})
|
|
896
|
-
json(res, { ok: true, status: deps.server
|
|
969
|
+
json(res, { ok: true, status: deps.server(req)?.status().status, drained })
|
|
897
970
|
} catch (err) {
|
|
898
971
|
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
899
972
|
}
|
|
@@ -907,7 +980,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
907
980
|
const handleSeeds = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
908
981
|
try {
|
|
909
982
|
if (rejectNonRead(req, res)) return
|
|
910
|
-
const client = deps.getClient()
|
|
983
|
+
const client = deps.getClient(req)
|
|
911
984
|
if (client === undefined) {
|
|
912
985
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
913
986
|
return
|
|
@@ -930,7 +1003,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
930
1003
|
const body = JSON.parse(await readBody(req)) as { id?: unknown; force?: unknown }
|
|
931
1004
|
const id = typeof body.id === 'string' && body.id.trim().length > 0 ? body.id.trim() : undefined
|
|
932
1005
|
const force = body.force === true
|
|
933
|
-
const client = deps.getClient()
|
|
1006
|
+
const client = deps.getClient(req)
|
|
934
1007
|
if (client === undefined) {
|
|
935
1008
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
936
1009
|
return
|
|
@@ -948,7 +1021,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
948
1021
|
let restartError: string | undefined
|
|
949
1022
|
if (needsRestartAfterSeeds(results)) {
|
|
950
1023
|
try {
|
|
951
|
-
const flushed = await waitForFileWrite(join(deps.getWikiPath(), 'tiddlers', RENDER_PLUGIN_FILE), 8_000, 150, seedStartedAt)
|
|
1024
|
+
const flushed = await waitForFileWrite(join(deps.getWikiPath(req), 'tiddlers', RENDER_PLUGIN_FILE), 8_000, 150, seedStartedAt)
|
|
952
1025
|
if (!flushed) console.warn('[dsh-tiddlywiki] seeded render plugin file not seen on disk before restart')
|
|
953
1026
|
// Drain the rest of the syncer queue too: force-all writes every seed,
|
|
954
1027
|
// and a restart that boots from a stale snapshot silently loses the
|
|
@@ -956,8 +1029,8 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
956
1029
|
// v0.24.1: the drain+restart pair is now the shared primitive.
|
|
957
1030
|
const drained = await drainThenStop({
|
|
958
1031
|
client,
|
|
959
|
-
tiddlersDir: join(deps.getWikiPath(), 'tiddlers'),
|
|
960
|
-
stop: () => deps.server
|
|
1032
|
+
tiddlersDir: join(deps.getWikiPath(req), 'tiddlers'),
|
|
1033
|
+
stop: async () => { await deps.server(req)?.restart() },
|
|
961
1034
|
log: (message) => console.warn('[dsh-tiddlywiki]', message),
|
|
962
1035
|
})
|
|
963
1036
|
if (!drained) console.warn('[dsh-tiddlywiki] seed writes may not have been flushed before restart')
|
|
@@ -985,7 +1058,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
985
1058
|
if (rejectCrossSiteWrite(req, res, ['POST'])) return
|
|
986
1059
|
const body = JSON.parse(await readBody(req)) as { id?: unknown }
|
|
987
1060
|
const id = typeof body.id === 'string' && body.id.trim().length > 0 ? body.id.trim() : undefined
|
|
988
|
-
const client = deps.getClient()
|
|
1061
|
+
const client = deps.getClient(req)
|
|
989
1062
|
if (client === undefined) {
|
|
990
1063
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
991
1064
|
return
|
|
@@ -1027,7 +1100,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
1027
1100
|
return
|
|
1028
1101
|
}
|
|
1029
1102
|
}
|
|
1030
|
-
const prompt = deps.getPrompt?.(normalizePromptPreview(body))
|
|
1103
|
+
const prompt = deps.getPrompt?.(normalizePromptPreview(body), req)
|
|
1031
1104
|
if (prompt === undefined) {
|
|
1032
1105
|
json(res, { ok: false, error: 'prompt preview is not available' }, 503)
|
|
1033
1106
|
return
|
|
@@ -1036,7 +1109,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
1036
1109
|
return
|
|
1037
1110
|
}
|
|
1038
1111
|
if (rejectNonRead(req, res)) return
|
|
1039
|
-
const prompt = deps.getPrompt?.()
|
|
1112
|
+
const prompt = deps.getPrompt?.(undefined, req)
|
|
1040
1113
|
if (prompt === undefined) {
|
|
1041
1114
|
json(res, { ok: false, error: 'prompt preview is not available' }, 503)
|
|
1042
1115
|
return
|
|
@@ -1081,7 +1154,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
1081
1154
|
}
|
|
1082
1155
|
const body = JSON.parse(await readBody(req)) as { root?: unknown; name?: unknown }
|
|
1083
1156
|
const result = await deps.wiki.switch({ root: body.root, name: body.name })
|
|
1084
|
-
json(res, result.ok ? { ...result, status: deps.server
|
|
1157
|
+
json(res, result.ok ? { ...result, status: deps.server(req)?.status().status } : result, result.ok ? 200 : 400)
|
|
1085
1158
|
} catch (err) {
|
|
1086
1159
|
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1087
1160
|
}
|
|
@@ -1099,16 +1172,83 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
1099
1172
|
return
|
|
1100
1173
|
}
|
|
1101
1174
|
const result = await deps.wiki.reset()
|
|
1102
|
-
json(res, result.ok ? { ...result, status: deps.server
|
|
1175
|
+
json(res, result.ok ? { ...result, status: deps.server(req)?.status().status } : result, result.ok ? 200 : 400)
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* GET /dsh-tiddlywiki/admin/wikis — the knowledge-base LIST as the settings
|
|
1183
|
+
* page edits it: the CONTROL FILE's contents (mode / list / default), which is
|
|
1184
|
+
* NOT the same as what is running. Read-only + CSRF-hardened.
|
|
1185
|
+
*/
|
|
1186
|
+
const handleWikis = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1187
|
+
try {
|
|
1188
|
+
if (rejectNonRead(req, res)) return
|
|
1189
|
+
if (deps.wikis === undefined) {
|
|
1190
|
+
json(res, { ok: false, error: '知识库列表不可用' }, 503)
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1193
|
+
json(res, { ok: true, ...(await deps.wikis.info()) })
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 500)
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* POST /dsh-tiddlywiki/admin/wikis { action, … } — add / update / remove /
|
|
1201
|
+
* set-default / set-mode. ONE action per request (see `applyWikiAction`): the
|
|
1202
|
+
* page then cannot post an inconsistent list, and every mutation goes through
|
|
1203
|
+
* the same validation the boot path uses.
|
|
1204
|
+
*
|
|
1205
|
+
* A REFUSED action is a 400 with the reason. A change whose new wiki fails to
|
|
1206
|
+
* START is still 200: the list is saved and the failure is reported in
|
|
1207
|
+
* `change.errors` — refusing the save would silently discard the user's edit
|
|
1208
|
+
* for a problem they can fix by restarting the wiki.
|
|
1209
|
+
*/
|
|
1210
|
+
const handleWikisApply = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1211
|
+
try {
|
|
1212
|
+
if (deps.wikis === undefined) {
|
|
1213
|
+
json(res, { ok: false, error: '知识库列表不可用' }, 503)
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
let body: unknown
|
|
1217
|
+
try {
|
|
1218
|
+
body = JSON.parse(await readBody(req))
|
|
1219
|
+
} catch {
|
|
1220
|
+
json(res, { ok: false, error: '请求体必须是 JSON' }, 400)
|
|
1221
|
+
return
|
|
1222
|
+
}
|
|
1223
|
+
const result = await deps.wikis.apply(body)
|
|
1224
|
+
if (!result.ok) {
|
|
1225
|
+
json(res, { ok: false, error: result.error }, 400)
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
json(res, { ok: true, ...result.info, change: result.change })
|
|
1103
1229
|
} catch (err) {
|
|
1104
1230
|
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1105
1231
|
}
|
|
1106
1232
|
}
|
|
1107
1233
|
|
|
1234
|
+
/**
|
|
1235
|
+
* `/admin/wikis` takes BOTH methods on ONE registration: the host webserver
|
|
1236
|
+
* dispatches by pathname only, and registering the same exact path twice would
|
|
1237
|
+
* throw `duplicate exact route`.
|
|
1238
|
+
*/
|
|
1239
|
+
const handleWikisRoute = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1240
|
+
if ((req.method ?? 'GET').toUpperCase() === 'POST') {
|
|
1241
|
+
if (rejectCrossSiteWrite(req, res, ['POST'])) return
|
|
1242
|
+
return handleWikisApply(req, res)
|
|
1243
|
+
}
|
|
1244
|
+
return handleWikis(req, res)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1108
1247
|
// Same rejection safety net as routes.ts (v0.19.3).
|
|
1109
1248
|
const disposers = [
|
|
1110
1249
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/state`, handler: guardHandler(handleState) }),
|
|
1111
1250
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/prompt`, handler: guardHandler(handlePrompt) }),
|
|
1251
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/wikis`, handler: guardHandler(handleWikisRoute) }),
|
|
1112
1252
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/wiki/location`, handler: guardHandler(handleWikiLocation) }),
|
|
1113
1253
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/wiki/switch`, handler: guardHandler(handleWikiSwitch) }),
|
|
1114
1254
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/admin/wiki/reset`, handler: guardHandler(handleWikiReset) }),
|
package/src/host/git.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { execFile } from 'node:child_process'
|
|
30
30
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
31
|
-
import { join } from 'node:path'
|
|
31
|
+
import { join, resolve } from 'node:path'
|
|
32
32
|
import { promisify } from 'node:util'
|
|
33
33
|
|
|
34
34
|
const execFileP = promisify(execFile)
|
|
@@ -148,6 +148,42 @@ export class GitFace {
|
|
|
148
148
|
return r.ok && r.stdout.trim() === 'true'
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The repository a folder belongs to (`git rev-parse --show-toplevel`), or
|
|
153
|
+
* undefined when it is not inside a work tree (v0.28.0).
|
|
154
|
+
*
|
|
155
|
+
* WHY THE PLUGIN NEEDS THIS: several knowledge bases may be SUBFOLDERS of one
|
|
156
|
+
* repository — the "工作 + 个人 共用现有仓库、按文件夹物理划分" topology. Git
|
|
157
|
+
* then has ONE index and `git add -A` (from any subdirectory) stages the WHOLE
|
|
158
|
+
* working tree, so the auto-committer must be keyed by the REPOSITORY rather
|
|
159
|
+
* than by the wiki; two per-folder committers would each commit the other's
|
|
160
|
+
* changes and one would forever report "nothing to commit" (see
|
|
161
|
+
* host/repo-committers.ts).
|
|
162
|
+
*/
|
|
163
|
+
async repoRoot(dir: string): Promise<string | undefined> {
|
|
164
|
+
const r = await this.exec(['rev-parse', '--show-toplevel'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
165
|
+
if (!r.ok) return undefined
|
|
166
|
+
const root = r.stdout.trim()
|
|
167
|
+
// git prints forward slashes even on Windows; `resolve` normalises to the
|
|
168
|
+
// platform form so the value can be compared/passed on directly.
|
|
169
|
+
return root.length > 0 ? resolve(root) : undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Repository-relative paths that differ between two commits (v0.28.0).
|
|
174
|
+
*
|
|
175
|
+
* Used after a pull to decide WHICH knowledge bases need their TW child
|
|
176
|
+
* restarted: with one repository holding several wikis, "the pull changed
|
|
177
|
+
* something" is not the same question as "THIS wiki's content changed".
|
|
178
|
+
* Returns [] for an unusable range (no commits yet, unknown ref).
|
|
179
|
+
*/
|
|
180
|
+
async filesChangedBetween(dir: string, from: string, to: string): Promise<string[]> {
|
|
181
|
+
if (from.length === 0 || to.length === 0 || from === to) return []
|
|
182
|
+
const r = await this.exec(['diff', '--name-only', from, to], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
183
|
+
if (!r.ok) return []
|
|
184
|
+
return r.stdout.split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
|
|
185
|
+
}
|
|
186
|
+
|
|
151
187
|
async init(dir: string, branch = 'main'): Promise<boolean> {
|
|
152
188
|
const r = await this.exec(['init', '-b', branch], { cwd: dir, timeout: HEAVY_TIMEOUT_MS })
|
|
153
189
|
return r.ok
|
|
@@ -219,13 +255,15 @@ export class GitFace {
|
|
|
219
255
|
/** `git pull --rebase --autostash`; on conflict: abort + report files.
|
|
220
256
|
* On success, `changed: true` means HEAD actually moved (files came in /
|
|
221
257
|
* commits were replayed) — callers use it to decide whether a running TW
|
|
222
|
-
* child needs a restart to drop its stale in-memory snapshot
|
|
258
|
+
* child needs a restart to drop its stale in-memory snapshot, and
|
|
259
|
+
* `changedFiles` (v0.28.0) says WHICH paths moved, so a caller serving
|
|
260
|
+
* several wikis out of one repository can restart only the affected ones.
|
|
223
261
|
* Serialized against `commit()` (index.lock). */
|
|
224
|
-
pull(dir: string): Promise<GitActionResult & { changed?: boolean }> {
|
|
262
|
+
pull(dir: string): Promise<GitActionResult & { changed?: boolean; changedFiles?: string[] }> {
|
|
225
263
|
return this.withLock(() => this.pullUnlocked(dir))
|
|
226
264
|
}
|
|
227
265
|
|
|
228
|
-
private async pullUnlocked(dir: string): Promise<GitActionResult & { changed?: boolean }> {
|
|
266
|
+
private async pullUnlocked(dir: string): Promise<GitActionResult & { changed?: boolean; changedFiles?: string[] }> {
|
|
229
267
|
const before = await this.exec(['rev-parse', 'HEAD'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
230
268
|
const beforeHead = before.ok ? before.stdout.trim() : ''
|
|
231
269
|
// Did a rebase already exist BEFORE we ran? (v0.23.5)
|
|
@@ -241,7 +279,14 @@ export class GitFace {
|
|
|
241
279
|
const after = await this.exec(['rev-parse', 'HEAD'], { cwd: dir, timeout: QUICK_TIMEOUT_MS })
|
|
242
280
|
const afterHead = after.ok ? after.stdout.trim() : ''
|
|
243
281
|
const changed = beforeHead.length > 0 && beforeHead !== afterHead
|
|
244
|
-
return { ok: true, message: r.stdout.trim() || 'pull ok'
|
|
282
|
+
if (!changed) return { ok: true, message: r.stdout.trim() || 'pull ok' }
|
|
283
|
+
const changedFiles = await this.filesChangedBetween(dir, beforeHead, afterHead)
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
message: r.stdout.trim() || 'pull ok',
|
|
287
|
+
changed: true,
|
|
288
|
+
...(changedFiles.length > 0 ? { changedFiles } : {}),
|
|
289
|
+
}
|
|
245
290
|
}
|
|
246
291
|
const conflictFiles = await this.unmergedFiles(dir)
|
|
247
292
|
// Only abort when WE left a rebase behind: either it did not exist before,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path comparison keys (v0.28.0).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS ITS OWN MODULE: three places must agree on "is this the same
|
|
5
|
+
* folder?" — the wiki registry (duplicate/nested entries), the repo grouping
|
|
6
|
+
* (two wikis sharing one repository) and the farm's path-change detection. A
|
|
7
|
+
* second copy of this rule is exactly the drift this repository keeps paying
|
|
8
|
+
* for, so there is one function and everybody imports it.
|
|
9
|
+
*
|
|
10
|
+
* THE RULES
|
|
11
|
+
* ---------
|
|
12
|
+
* - separators normalised to `/` (git prints forward slashes on Windows);
|
|
13
|
+
* - trailing separators dropped (`D:/notes/` === `D:/notes`);
|
|
14
|
+
* - **case-insensitive everywhere**, deliberately: on Windows and default
|
|
15
|
+
* macOS two paths differing only in case ARE the same folder, and no honest
|
|
16
|
+
* setup has two knowledge bases there.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-tiddlywiki/host/path-key
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The comparison key for a path. NOT a path: never hand it to the filesystem —
|
|
23
|
+
* on a case-sensitive filesystem a lowercased path may not exist.
|
|
24
|
+
*/
|
|
25
|
+
export function pathComparisonKey(path: string): string {
|
|
26
|
+
return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when `candidate` is strictly inside `parent` (both are absolute paths). */
|
|
30
|
+
export function isInsidePath(parent: string, candidate: string): boolean {
|
|
31
|
+
const from = pathComparisonKey(parent)
|
|
32
|
+
const to = pathComparisonKey(candidate)
|
|
33
|
+
if (from.length === 0 || to.length === 0) return false
|
|
34
|
+
return to.startsWith(`${from}/`)
|
|
35
|
+
}
|
package/src/host/prompt.ts
CHANGED
|
@@ -34,6 +34,30 @@
|
|
|
34
34
|
* @module dsh-tiddlywiki/host/prompt
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The scope banner prepended to the injected prompt when MORE THAN ONE knowledge
|
|
39
|
+
* base is visible to the agent (v0.28.0).
|
|
40
|
+
*
|
|
41
|
+
* Why a banner at all: with several wikis the model has no way to know which one
|
|
42
|
+
* its `tiddlywiki_*` calls act on, and the failure mode (writing a note into the
|
|
43
|
+
* wrong knowledge base) is silent. The banner names the scope, and every tool
|
|
44
|
+
* result repeats it. With a single visible wiki this returns `''`, so existing
|
|
45
|
+
* installs keep exactly the text they had.
|
|
46
|
+
*/
|
|
47
|
+
export function scopeBanner(scope: { id?: string; label?: string; reason?: string; ambiguous: boolean }): string {
|
|
48
|
+
if (!scope.ambiguous || scope.id === undefined) return ''
|
|
49
|
+
const why = scope.reason === undefined ? '' : `(${scope.reason})`
|
|
50
|
+
return `> **本会话作用域:${scope.label ?? scope.id}(${scope.id})**${why}\n`
|
|
51
|
+
+ '> 本会话所有 `tiddlywiki_*` 工具都只作用于这个知识库,每条工具回执也会标明它。'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Built prompt text plus the optional scope banner (the section's final text). */
|
|
55
|
+
export function withScopeBanner(text: string, scope: { id?: string; label?: string; reason?: string; ambiguous: boolean }): string {
|
|
56
|
+
if (text.length === 0) return ''
|
|
57
|
+
const banner = scopeBanner(scope)
|
|
58
|
+
return banner.length === 0 ? text : `${banner}\n\n${text}`
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
/** Prompt section name (stable id; a re-registration replaces the old one). */
|
|
38
62
|
export const PROMPT_SECTION_NAME = 'dsh-tiddlywiki'
|
|
39
63
|
|