dsh-tiddlywiki 0.27.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -0
  3. package/cordis.patch.yml +13 -13
  4. package/docs/CHANGELOG.md +2 -1
  5. package/docs/code-review-2026-09-17.md +151 -0
  6. package/docs/features.md +5 -2
  7. package/docs/usage.md +34 -3
  8. package/docs/wiki-split.md +97 -0
  9. package/lib/client.js +54 -27
  10. package/lib/index.js +2803 -517
  11. package/lib/index.js.map +1 -1
  12. package/package.json +5 -4
  13. package/skills/wiki-split/SKILL.md +106 -0
  14. package/src/client/endpoints.ts +37 -0
  15. package/src/client/index.ts +14 -0
  16. package/src/client/knowledge-fab.ts +62 -3
  17. package/src/client/markdown-editor.ts +128 -128
  18. package/src/client/panel.ts +2 -1
  19. package/src/client/settings-page.ts +217 -3
  20. package/src/client/state.ts +39 -39
  21. package/src/client/status-cache.ts +17 -0
  22. package/src/client/styles.ts +27 -0
  23. package/src/client/toast.ts +22 -22
  24. package/src/client/tw-frame.ts +28 -4
  25. package/src/client/wiki-focus.ts +70 -0
  26. package/src/client/wiki-scope-dock.ts +116 -0
  27. package/src/host/admin.ts +175 -35
  28. package/src/host/git.ts +50 -5
  29. package/src/host/path-key.ts +35 -0
  30. package/src/host/prompt.ts +24 -0
  31. package/src/host/repo-committers.ts +149 -0
  32. package/src/host/routes.ts +254 -81
  33. package/src/host/seeds.ts +51 -10
  34. package/src/host/session-scope.ts +154 -0
  35. package/src/host/skill-install.ts +121 -0
  36. package/src/host/tools.ts +141 -49
  37. package/src/host/wiki-farm.ts +417 -0
  38. package/src/host/wiki-instance.ts +596 -0
  39. package/src/host/wiki-registry.ts +601 -0
  40. package/src/host/wiki.ts +19 -0
  41. package/src/index.ts +686 -459
@@ -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
- server: WikiServer
514
- getClient: () => TiddlyWebClient | undefined
515
- getWikiPath: () => string
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
- config: ConfigStore
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
- getPrompt?: (draft?: PromptPreviewConfig) => { enabled: boolean; mode: string; text: string }
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.status()
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.restart(),
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.restart(),
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.status().status, drained })
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.restart(),
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.status().status } : result, result.ok ? 200 : 400)
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.status().status } : result, result.ok ? 200 : 400)
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', ...(changed ? { changed: true } : {}) }
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
+ }
@@ -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