dsh-tiddlywiki 0.30.47 → 0.30.49

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/src/index.ts CHANGED
@@ -18,37 +18,29 @@
18
18
  *
19
19
  * @module dsh-tiddlywiki
20
20
  */
21
- import type { IncomingMessage } from 'node:http'
22
21
  import { AutoCommitter, GitFace } from './host/git.ts'
23
- import { registerRoutes, type AgentPresetsFace, type PermissionPresetsFace, type SessionControllerFace, type SessionPersistenceFace, type SessionsFace, type SessionQueryFace, type WebServerFace, type WorkspaceRegistryFace } from './host/routes.ts'
24
- import { ConfigStore, type PluginConfigShape } from './host/config.ts'
25
- import { registerAdminRoutes, resolveTwRoot, type AdminDeps, type AdminWikisApplyResult, type AdminWikisView } from './host/admin.ts'
26
- import { checkAllSeeds, runSeedById, removeSeedById } from './host/seeds.ts'
22
+ import { type SessionsFace, type WebServerFace } from './host/routes.ts'
23
+ import { type PluginConfigShape } from './host/config.ts'
24
+ import { resolveTwRoot } from './host/admin.ts'
27
25
  import { TiddlyWebClient, isBinaryType, TEXT_LIST_FILTER } from './host/tw-api.ts'
28
- import { WechatPublishRunner, checkWechatReady, normalizeWechatConfig } from './host/wechat-publish.ts'
29
- import { registerTiddlywikiTools, tiddlywikiToolSummary, type ToolsDeps, type ToolScope } from './host/tools.ts'
30
- import { describePrompt, type PromptConfig } from './host/prompt.ts'
26
+ import { WechatPublishRunner } from './host/wechat-publish.ts'
27
+ import { registerTiddlywikiTools, type ToolsDeps, type ToolScope } from './host/tools.ts'
31
28
  import {
32
29
  defaultLocationStateFile,
33
30
  locationPath,
34
31
  readLocationState,
35
- writeLocationState,
36
32
  type WikiLocation,
37
33
  } from './host/wiki-location.ts'
38
34
  import { WikiInstance } from './host/wiki-instance.ts'
39
- import { WikiFarm, resolveAgentScope, stoppedWikiFromRequest, stoppedWikiMessage, targetRuntimeFor } from './host/wiki-farm.ts'
40
- import { defaultSessionScopeFile, isSafeSessionId, readSessionScopes, setSessionScope } from './host/session-scope.ts'
35
+ import { WikiFarm, resolveAgentScope, targetRuntimeFor } from './host/wiki-farm.ts'
36
+ import { defaultSessionScopeFile, readSessionScopes } from './host/session-scope.ts'
41
37
  import { installSplitSkill } from './host/skill-install.ts'
42
38
  import {
43
39
  DEFAULT_WIKI_ID,
44
40
  DEFAULT_WIKI_MODE,
45
- applyWikiAction,
46
- defaultEntry,
47
41
  defaultRegistryFile,
48
- entryPath,
49
42
  readRegistry,
50
43
  singleEntryRegistry,
51
- writeRegistry,
52
44
  type WikiEntry,
53
45
  type WikiRegistry,
54
46
  } from './host/wiki-registry.ts'
@@ -63,6 +55,7 @@ import { createWikiViews, proxyBaseForEntry, restartAffectedWikis, resolveWikiRo
63
55
  import { resolveConfig, type ResolvedConfig, type TiddlywikiConfig } from './index-config.ts'
64
56
  import { createMutationLock } from './host/mutation-lock.ts'
65
57
  import { createGitLayer } from './index-git.ts'
58
+ import { createRouteStage } from './index-routes.ts'
66
59
  import { createPromptSurface, createServerTuning } from './index-prompt.ts'
67
60
  import { createClipSurface } from './index-clip.ts'
68
61
 
@@ -169,6 +162,18 @@ export {
169
162
  locationPath,
170
163
  normalizeLocation,
171
164
  readLocationState,
165
+ // v0.30.49:这条 **barrel 再导出** 是给 `lib/index.js` 的**外部**使用者的
166
+ // (scripts/verify-wiki-switch.mjs 就 import 它)。⚠️ 再导出是**独立语句**,
167
+ // 它不消费上面那条 import 的绑定 —— 所以 `noUnusedLocals` 会把 import 里的
168
+ // 同名行点名成「未使用」,而**删掉 import 会让这条导出引用不到绑定**,
169
+ // `lib/index.js` 随即少一个导出(v0.30.48 实测:typecheck / verify:unit /
170
+ // verify:static 全绿,只有 e2e 的 verify-wiki-switch 崩在缺导出那行)。
171
+ // 判据现在由 scripts/verify-index-barrel.mjs 守住(反向验证过:删掉即红)。
172
+ //
173
+ // ⚠️ 写这段注释时踩到一个**就在这条语句内部**的坑:注释里**不能出现右花括号**,
174
+ // 否则任何按花括号配对的解析器(tsc 自己的扫描器也在内)都会把它当成 export
175
+ // 列表的结尾,后面的 from 子句变成语法垃圾 —— 症状是「导出了却仍报缺」,
176
+ // 而 tsc 的报错指向完全无关的 import 行。要描述这个语法请用文字。
172
177
  writeLocationState,
173
178
  LOCATION_STATE_VERSION,
174
179
  type WikiLocation,
@@ -371,7 +376,6 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
371
376
  noteConfig: () => config.note,
372
377
  defaultPath: () => wikiViews.defaultPath(),
373
378
  registryFile: () => registryFile,
374
- isDisposed: () => disposed,
375
379
  })
376
380
  const { repos, gitTargets, teardownCommitter, reapplyGitConfig, effectiveWorkspaceMark, effectiveBridge, effectiveWechat } = gitLayer
377
381
 
@@ -656,179 +660,38 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
656
660
  isDisposed: () => disposed,
657
661
  applyPrompt,
658
662
  })
659
- const { defaultPath, defaultCurrentLocation, locationInfo, switchWikiLocation, resetWikiLocation, buildWikisView } = wikiViews
663
+ const { defaultPath, defaultCurrentLocation } = wikiViews
660
664
 
661
- // Routes + settings-panel admin surface (lazy webServer).
665
+ // Routes + settings-panel admin surface (lazy webServer). v0.30.48: the whole
666
+ // assembly stage (the two Deps objects + both route tables) lives in
667
+ // index-routes.ts; the entry point keeps ownership of `disposed` and builds
668
+ // the ONE mutation lock, and hands both in. Registration still happens HERE,
669
+ // at the position it always had — nothing about the lifecycle order moved.
662
670
  ctx.inject(['webServer'], (webCtx: HostCtx) => {
663
671
  const ws = (webCtx as unknown as { webServer: WebServerFace }).webServer
664
- // sessionController is a core host service; read it LAZILY per request (via
665
- // the plugin root ctx) because it may be registered after webServer, and the
666
- // agent routes must work whenever a request actually arrives.
667
- const getSessionController = (): SessionControllerFace | undefined =>
668
- ctx.get('sessionController') as SessionControllerFace | undefined
669
- const getWorkspaceRegistry = (): WorkspaceRegistryFace | undefined =>
670
- ctx.get('workspaceRegistry') as WorkspaceRegistryFace | undefined
671
- // agentPresets (工作模式 roster) + sessionPersistence (per-session preset
672
- // badges) are also core host services — resolve them lazily like the above.
673
- const getAgentPresets = (): AgentPresetsFace | undefined =>
674
- ctx.get('agentPresets') as AgentPresetsFace | undefined
675
- const getSessionPersistence = (): SessionPersistenceFace | undefined =>
676
- ctx.get('sessionPersistence') as SessionPersistenceFace | undefined
677
- // permissionPresets (权限 preset roster for the picker + applying the
678
- // chosen permission to new sessions) and the `sessions` in-memory store
679
- // (the created live session handed to permissionPresets.set) are core host
680
- // services — resolve them lazily like the ones above.
681
- const getPermissionPresets = (): PermissionPresetsFace | undefined =>
682
- ctx.get('permissionPresets') as PermissionPresetsFace | undefined
683
- const getSessions = (): SessionsFace | undefined =>
684
- ctx.get('sessions') as SessionsFace | undefined
685
- // sessionQuery (会话日志查询,供「知识库」Tab 判定「本会话相关笔记」) 也是核心
686
- // host 服务 —— 可选注入(本部署存在),懒解析。
687
- const getSessionQuery = (): SessionQueryFace | undefined =>
688
- ctx.get('sessionQuery') as SessionQueryFace | undefined
689
- // v0.28.0: the request decides WHICH wiki. M1b-2b resolves every request to
690
- // the farm's DEFAULT runtime (the wiki this plugin has always served); the
691
- // `?wiki=<id>` selector arrives with the per-wiki GUI (M5). Every accessor
692
- // falls back to the cordis BASE when nothing is running, so a stopped farm
693
- // degrades to "base defaults + 503 on writes" instead of throwing.
694
- /**
695
- * Which wiki does this request target? `?wiki=<id>` (a running wiki), else
696
- * the farm's default — resolved by the SHARED helper in host/wiki-farm.ts,
697
- * so the host wiring and the verification harness cannot disagree about the
698
- * selector (and an unknown id falls back instead of 404ing).
699
- */
700
- const target = (req: IncomingMessage): WikiInstance | undefined => targetRuntimeFor(farm, req)
701
- /**
702
- * Why this request cannot be served (v0.29.0): set when `?wiki=<id>` names a
703
- * REGISTERED but STOPPED knowledge base. `targetRuntimeFor` deliberately
704
- * returns undefined for that case instead of falling back to the default wiki
705
- * (an unknown id still falls back — a stale bookmark must not 404), so every
706
- * route that either 503s on a missing client or has a harmless-looking
707
- * fallback (config / wiki path / prompt / status) can say WHICH wiki is down
708
- * and what to do, instead of quietly acting on a different one.
709
- */
710
- const targetProblem = (req: IncomingMessage): string | undefined => {
711
- const entry = stoppedWikiFromRequest(farm, req)
712
- return entry === undefined ? undefined : stoppedWikiMessage(entry)
713
- }
714
- const fallbackUi = WikiInstance.uiDefaultsFrom(config)
715
- const fallbackWechat = normalizeWechatConfig(config.wechat)
716
- /** Used only while nothing runs: base defaults, no wiki tiddler to read. */
717
- const idleConfig = new ConfigStore(baseShape)
718
- // ONE mutation lock for the whole plugin (v0.30.12): /restart, /sync,
719
- // /admin/restart and the seed restart all stop the TW child — before this,
720
- // the admin half had no lock at all and could race the others.
721
- const mutationLock = createMutationLock()
722
- const disposeRoutes = registerRoutes({ webServer: ws }, {
723
- mutationLock,
724
- server: (req) => target(req)?.server,
725
- // The roster the GUI selector + the settings page read. It reports what
726
- // the FARM serves right now (in single mode that is the one synthesized
727
- // entry); the control file's full candidate list is the admin route's job.
728
- wikiSummaries: (req) => {
729
- const registry = farm?.registry
730
- return {
731
- mode: registry?.mode ?? 'single',
732
- defaultId: registry?.defaultId ?? DEFAULT_WIKI_ID,
733
- items: (registry?.wikis ?? []).map((entry) => {
734
- const runtime = farm?.runtime(entry.id)
735
- return {
736
- id: entry.id,
737
- label: entry.label,
738
- status: runtime?.server.status().status ?? 'stopped',
739
- agentVisible: entry.agentVisible,
740
- autostart: entry.autostart,
741
- running: runtime !== undefined,
742
- path: entryPath(entry),
743
- // 每库图标(v0.28.4):侧边栏入口与设置页选择器共用这个来源。
744
- icon: entry.icon,
745
- }
746
- }),
747
- }
748
- },
749
- // The `/tw/<id>/…` form: the proxy resolves the child by NAME (it cannot
750
- // use `target()`, whose `?wiki=` would be lost inside the iframe).
751
- serverById: (id) => farm?.runtime(id)?.server,
752
- wikiIds: () => farm?.registry.wikis.map((entry) => entry.id) ?? [],
753
- getClient: (req) => target(req)?.client(),
672
+ const routeStage = createRouteStage({
673
+ webServer: ws,
674
+ get: (name) => ctx.get(name),
754
675
  git,
755
- autoCommit: (req) => target(req)?.touchAutoCommit(),
756
- noteDefaults: (req) => ({ tag: target(req)?.noteTag() ?? config.note.tag }),
757
- uiDefaults: (req) => target(req)?.uiDefaults() ?? fallbackUi,
758
- langOf: (req) => target(req)?.uiLanguage() ?? (typeof baseShape.uiLanguage === 'string' && baseShape.uiLanguage.trim().length > 0 ? baseShape.uiLanguage.trim() : 'zh'),
759
- getWikiPath: (req) => target(req)?.path ?? defaultPath(),
760
- targetProblem,
761
- // Same helper as the agent tool (one implementation, two callers): a pull
762
- // can change several knowledge bases that share one repository.
763
- restartAffected: (_req, dir, changedFiles) => restartAffectedWikis({ farm: () => farm, repoRootOf: (d) => repos.repoRootOf(d) }, dir, changedFiles),
764
- // The composer's per-session selector (v0.28.0). Reading is a Map lookup;
765
- // writing persists to session-scope.ts AND starts the wiki on demand — the
766
- // tools resolve the scope SYNCHRONOUSLY and never start anything, so the
767
- // selection itself has to bring the wiki up, or the very next tool call
768
- // would have to refuse.
769
- sessionScope: {
770
- get: (sessionId: string) => {
771
- const resolution = resolveAgentScope(farm, sessionScopes, sessionId)
772
- const scopeId = sessionScopes[sessionId]
773
- return {
774
- ...(scopeId !== undefined ? { scope: scopeId } : {}),
775
- ...(resolution.entry !== undefined ? { resolved: { id: resolution.entry.id, label: resolution.entry.label } } : {}),
776
- ...(resolution.reason !== undefined ? { reason: resolution.reason } : {}),
777
- // v0.29.0: the client needs to know whether "resolved" is a real
778
- // answer (multi: this session's tools act on THAT wiki, so its cards'
779
- // links must open it) or just the single install's only wiki (keep
780
- // the pre-multi DOM, which renders no wiki attribute at all).
781
- mode: farm?.registry.mode ?? 'single',
782
- }
783
- },
784
- set: async (sessionId: string, wikiId: string | undefined) => {
785
- if (!isSafeSessionId(sessionId)) throw new Error('会话 id 非法')
786
- if (wikiId !== undefined) {
787
- const entry = farm?.registry.wikis.find((item) => item.id === wikiId)
788
- if (entry === undefined) throw new Error(`知识库「${wikiId}」不在清单里`)
789
- // `agentVisible: false` means "the agent never reaches this one"; a
790
- // selector that allowed it would contradict the setting silently.
791
- if (!entry.agentVisible) throw new Error(`知识库「${entry.label}」对 Agent 隐身,不能作为会话作用域`)
792
- if (farm !== undefined && farm.runtime(entry.id) === undefined) await farm.startEntry(entry)
793
- }
794
- await setSessionScope(sessionId, wikiId, sessionScopeFile)
795
- const next = { ...sessionScopes }
796
- if (wikiId === undefined) delete next[sessionId]
797
- else next[sessionId] = wikiId
798
- sessionScopes = next
799
- },
800
- },
801
- getSessionController,
802
- getWorkspaceRegistry,
803
- getAgentPresets,
804
- getSessionPersistence,
805
- getPermissionPresets,
806
- getSessions,
807
- getSessionQuery,
808
- sendToAgentEnabled: (req) => target(req)?.sendToAgent().enabled ?? config.ui.sendToAgent.enabled,
809
- sendToAgentToken: (req) => target(req)?.sendToAgent().token ?? (config.ui.sendToAgent.token ?? ''),
810
- // 公众号发布(v0.23.3,可选功能):config 每请求重读(开关/adapter/token
811
- // 保存即生效,且每个知识库各有一份);就绪探测每次都真跑一次
812
- // `opencli --version`(几百毫秒),只在按钮预检与每次起任务前发生,
813
- // 不做缓存以免装完 adapter 还要等 TTL。
814
- wechatConfig: (req) => target(req)?.wechatConfig() ?? fallbackWechat,
815
- wechatRunner: () => wechatRunner,
816
- wechatReady: async (req) => {
817
- const cfg = target(req)?.wechatConfig() ?? fallbackWechat
818
- return checkWechatReady({ enabled: cfg.enabled, command: cfg.command })
676
+ repos,
677
+ getFarm: () => farm,
678
+ defaultPath,
679
+ defaultCurrentLocation,
680
+ controlRegistryNow,
681
+ setControlRegistry: (registry) => {
682
+ controlRegistry = registry
683
+ controlSource = 'file'
684
+ controlWarnings = []
685
+ controlError = undefined
819
686
  },
820
- })
821
- const adminDeps: AdminDeps = {
822
- mutationLock,
823
- server: (req) => target(req)?.server,
824
- getClient: (req) => target(req)?.client(),
825
- getWikiPath: (req) => target(req)?.path ?? defaultPath(),
826
- targetProblem,
827
- twRoot: resolveTwRoot,
828
- config: (req) => target(req)?.config ?? idleConfig,
829
- // A settings-page save may change prompt.*: re-register the section (no
830
- // dsh web restart) and expose the built text for the preview panel.
831
- // startup.readyTimeoutMs is re-applied here too (next start/restart).
687
+ getSessionScopes: () => sessionScopes,
688
+ setSessionScopes: (next) => { sessionScopes = next },
689
+ sessionScopeFile,
690
+ registryFile,
691
+ locationStateFile,
692
+ baseShape,
693
+ config,
694
+ wikiViews,
832
695
  onConfigChanged: () => {
833
696
  applyPrompt()
834
697
  applyServerTuning()
@@ -836,94 +699,13 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
836
699
  // reapplyGitConfig: enabled/debounceMs/remote used to need a dsh web restart.
837
700
  void reapplyGitConfig()
838
701
  },
839
- // No draft → the SAVED config of the TARGETED wiki (what is injected right
840
- // now); with a draft → the settings form's unsaved values (v0.22.7),
841
- // through the same builder.
842
- getPrompt: (draft, req) =>
843
- describePrompt(draft ?? ((target(req)?.eff() ?? baseShape).prompt ?? {}) as PromptConfig, tiddlywikiToolSummary()),
844
- // Runtime wiki location (v0.22.0): read the current folder + how it was
845
- // decided, switch to another one, or drop back to the cordis default.
846
- wiki: {
847
- info: locationInfo,
848
- switch: switchWikiLocation,
849
- reset: resetWikiLocation,
850
- },
851
- // The knowledge-base LIST (v0.28.0): `info` reports the CONTROL FILE; every
852
- // action is validated by the pure `applyWikiAction` and persisted BEFORE
853
- // the farm reconciles — the file is the user's intent, and a reconcile
854
- // failure is reported per wiki instead of silently discarding the edit.
855
- wikis: {
856
- info: async (): Promise<AdminWikisView> => buildWikisView(),
857
- apply: async (body: unknown): Promise<AdminWikisApplyResult> => {
858
- if (disposed) return { ok: false, error: '插件正在卸载,已取消修改' }
859
- // RUNTIME actions first (v0.28.0): `start`/`stop` do not change the list,
860
- // they change what is running. They live here rather than in the pure
861
- // `applyWikiAction` because they touch processes, not configuration —
862
- // and the GUI needs them: opening a wiki's panel must be able to bring
863
- // a stopped knowledge base up.
864
- const runtimeAction = (body as { action?: unknown } | null)?.action
865
- if (runtimeAction === 'start' || runtimeAction === 'stop') {
866
- if (farm === undefined) return { ok: false, error: '插件尚未就绪,请稍后再试' }
867
- const id = (body as { id?: unknown }).id
868
- const entry = typeof id === 'string' ? farm.registry.wikis.find((item) => item.id === id.trim().toLowerCase()) : undefined
869
- if (entry === undefined) return { ok: false, error: `知识库「${String(id)}」不在清单里` }
870
- const change = { started: [] as string[], stopped: [] as string[], updated: [] as string[], running: farm.runningIds(), errors: [] as Array<{ id: string; message: string }> }
871
- if (runtimeAction === 'start') {
872
- if (farm.runtime(entry.id) === undefined) {
873
- await farm.startEntry(entry)
874
- change.started.push(entry.id)
875
- }
876
- } else {
877
- await farm.stopEntry(entry.id)
878
- change.stopped.push(entry.id)
879
- }
880
- change.running = farm.runningIds()
881
- return { ok: true, info: buildWikisView(), change }
882
- }
883
- const action = applyWikiAction(controlRegistryNow(), body)
884
- if (action.registry === undefined) return { ok: false, error: action.error ?? '动作被拒绝' }
885
- const registry = action.registry
886
- try {
887
- await writeRegistry(registry, registryFile)
888
- } catch (err) {
889
- return { ok: false, error: `清单写入失败:${err instanceof Error ? err.message : String(err)}` }
890
- }
891
- controlRegistry = registry
892
- controlSource = 'file'
893
- controlWarnings = []
894
- controlError = undefined
895
- // Reconcile. multi → the farm runs the whole list. single → it must
896
- // keep serving the ONE wiki the legacy pointer names, so the
897
- // synthesized run-registry is used and the pointer is kept in step
898
- // (otherwise a restart in single mode would land somewhere else).
899
- const single = defaultEntry(registry)
900
- const runRegistry = registry.mode === 'multi'
901
- ? registry
902
- : singleEntryRegistry(single === undefined ? defaultCurrentLocation() : { root: single.root, name: single.name }, DEFAULT_WIKI_ID, 'single')
903
- const change = farm === undefined
904
- ? { started: [], stopped: [], updated: [], running: [], errors: [] }
905
- : await farm.apply(runRegistry)
906
- if (registry.mode === 'single' && single !== undefined) {
907
- await writeLocationState({ root: single.root, name: single.name }, locationStateFile).catch(() => undefined)
908
- }
909
- return { ok: true, info: buildWikisView(), change }
910
- },
911
- },
912
- seeds: {
913
- // Tool summaries feed the GENERATED seed content (the doc note's tool
914
- // list, v0.22.0) — pass them everywhere the registry can be re-run.
915
- checkAll: async (c) => checkAllSeeds({ client: c, tools: tiddlywikiToolSummary() }),
916
- run: async (c, id, force) => runSeedById({ client: c, tools: tiddlywikiToolSummary() }, id, force),
917
- remove: async (c, id) => removeSeedById({ client: c }, id),
918
- },
919
- }
920
- const disposeAdmin = registerAdminRoutes({ webServer: ws }, adminDeps)
921
- return () => {
922
- disposeRoutes()
923
- disposeAdmin()
924
- }
702
+ effectivePromptFor: (req) => ((req === undefined ? undefined : targetRuntimeFor(farm, req)?.eff()) ?? baseShape).prompt ?? {},
703
+ mutationLock: createMutationLock(),
704
+ wechatRunner,
705
+ isDisposed: () => disposed,
706
+ })
707
+ return () => routeStage.dispose()
925
708
  })
926
-
927
709
  // Teardown: everything reversible (R6 — hot reload must not leak).
928
710
  // The disposer RETURNS its promise: cordis fiber.dispose() awaits effect
929
711
  // disposers, and dsh web's shutdown controller awaits fiber.dispose() with a