dsh-tiddlywiki 0.16.11 → 0.16.13

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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared node:http helpers for the DSH webserver routes (routes.ts, admin.ts).
3
+ *
4
+ * One implementation of body reading + JSON response writing so the route
5
+ * layers cannot drift (they previously carried two/three copies).
6
+ *
7
+ * @module dsh-tiddlywiki/host/http
8
+ */
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+
11
+ /** Default cap for small JSON bodies (note/restart/config…). */
12
+ export const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024
13
+
14
+ /** Cap for the /api passthrough body (tiddler content can be large). */
15
+ export const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
16
+
17
+ /** Cap for uploaded file bodies (/tw proxy, /upload). */
18
+ export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
19
+
20
+ function readBodyStream(req: IncomingMessage, limit: number, encoding: true): Promise<string>
21
+ function readBodyStream(req: IncomingMessage, limit: number, encoding: false): Promise<Buffer>
22
+ function readBodyStream(req: IncomingMessage, limit: number, encoding: boolean): Promise<string | Buffer> {
23
+ return new Promise((resolveP, rejectP) => {
24
+ let size = 0
25
+ const chunks: Buffer[] = []
26
+ req.on('data', (chunk: Buffer) => {
27
+ size += chunk.length
28
+ if (size > limit) {
29
+ rejectP(new Error('body too large'))
30
+ req.destroy()
31
+ return
32
+ }
33
+ chunks.push(chunk)
34
+ })
35
+ req.on('end', () => {
36
+ const buf = Buffer.concat(chunks)
37
+ resolveP(encoding ? buf.toString('utf8') : buf)
38
+ })
39
+ req.on('error', rejectP)
40
+ })
41
+ }
42
+
43
+ /** Read a JSON (utf8) request body up to `limit` bytes (default MAX_JSON_BODY_BYTES). */
44
+ export function readBody(req: IncomingMessage, limit = MAX_JSON_BODY_BYTES): Promise<string> {
45
+ return readBodyStream(req, limit, true)
46
+ }
47
+
48
+ /** Read a raw (binary-safe) request body up to `limit` bytes (default MAX_UPLOAD_BYTES). */
49
+ export function readBodyBuffer(req: IncomingMessage, limit = MAX_UPLOAD_BYTES): Promise<Buffer> {
50
+ return readBodyStream(req, limit, false)
51
+ }
52
+
53
+ /** Write a JSON response (no-store, utf-8). */
54
+ export function json(res: ServerResponse, payload: unknown, status = 200): void {
55
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
56
+ res.end(JSON.stringify(payload))
57
+ }
@@ -27,21 +27,13 @@ import type { WikiServer } from './wiki.ts'
27
27
  import type { GitFace } from './git.ts'
28
28
  import { PATH_PREFIX, TW_PROXY_PREFIX, TW_PROXY_PATH } from './wiki.ts'
29
29
  import { writeSessionSummary, type SessionQueryFace } from './session-summary.ts'
30
+ import { readBody, readBodyBuffer, json, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
30
31
 
31
32
  export { writeSessionSummary, SESSION_SUMMARY_PREFIX } from './session-summary.ts'
32
33
  export type { SessionQueryFace, SessionSummaryResult } from './session-summary.ts'
33
34
 
34
35
  export const ROUTE_PREFIX = PATH_PREFIX
35
36
 
36
- /** Max JSON body for note/restart. */
37
- const MAX_BODY_BYTES = 2 * 1024 * 1024
38
-
39
- /** Max passthrough body (tiddler content can be large). */
40
- const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
41
-
42
- /** Max uploaded file body. */
43
- const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
44
-
45
37
  /** Tiddler type for quick-notes: Markdown, so the uploaded images/links and
46
38
  * any Markdown in the note actually render in TW (a type-less tiddler is
47
39
  * treated as plain wiki text and shows raw `![..]`/`[..]` instead). */
@@ -196,43 +188,6 @@ export interface RouteDeps {
196
188
  sendToAgentToken: () => string
197
189
  }
198
190
 
199
- function readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string> {
200
- return new Promise((resolveP, rejectP) => {
201
- let size = 0
202
- const chunks: Buffer[] = []
203
- req.on('data', (chunk: Buffer) => {
204
- size += chunk.length
205
- if (size > limit) {
206
- rejectP(new Error('body too large'))
207
- req.destroy()
208
- return
209
- }
210
- chunks.push(chunk)
211
- })
212
- req.on('end', () => resolveP(Buffer.concat(chunks).toString('utf8')))
213
- req.on('error', rejectP)
214
- })
215
- }
216
-
217
- /** Read a raw (binary-safe) request body up to `limit` bytes. */
218
- function readBodyBuffer(req: IncomingMessage, limit = MAX_UPLOAD_BYTES): Promise<Buffer> {
219
- return new Promise((resolveP, rejectP) => {
220
- let size = 0
221
- const chunks: Buffer[] = []
222
- req.on('data', (chunk: Buffer) => {
223
- size += chunk.length
224
- if (size > limit) {
225
- rejectP(new Error('file too large'))
226
- req.destroy()
227
- return
228
- }
229
- chunks.push(chunk)
230
- })
231
- req.on('end', () => resolveP(Buffer.concat(chunks)))
232
- req.on('error', rejectP)
233
- })
234
- }
235
-
236
191
  /** Header names forwarded to the upstream TW service by the proxy routes. */
237
192
  const FORWARD_HEADER_NAMES = [
238
193
  'accept', 'accept-encoding', 'content-type', 'cookie', 'authorization',
@@ -269,12 +224,6 @@ function sanitizeUploadName(input: unknown): string {
269
224
  return name
270
225
  }
271
226
 
272
- function json(res: ServerResponse, payload: unknown, status = 200): void {
273
- const body = JSON.stringify(payload)
274
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
275
- res.end(body)
276
- }
277
-
278
227
  function pad(n: number): string {
279
228
  return n < 10 ? `0${n}` : String(n)
280
229
  }
@@ -443,6 +392,26 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
443
392
  }
444
393
  }
445
394
 
395
+ /**
396
+ * Shared gate for the TW-side send-to-agent routes: feature switch
397
+ * (`ui.sendToAgent.enabled`) then, when a shared token is configured, the
398
+ * `x-send-to-agent-token` header must match. Returns false after writing the
399
+ * error response — the caller just does `if (!guard(...)) return`.
400
+ */
401
+ const guardSendToAgent = (req: IncomingMessage, res: ServerResponse): boolean => {
402
+ if (!deps.sendToAgentEnabled()) {
403
+ json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
404
+ return false
405
+ }
406
+ const token = deps.sendToAgentToken().trim()
407
+ if (token.length === 0) return true
408
+ const got = req.headers['x-send-to-agent-token']
409
+ const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
410
+ if (value === token) return true
411
+ json(res, { ok: false, error: 'unauthorized' }, 401)
412
+ return false
413
+ }
414
+
446
415
  /**
447
416
  * GET /dsh-tiddlywiki/agent/modes — available "工作模式" (Agent presets) for
448
417
  * the TW picker: id/name/description per preset plus the deployment default.
@@ -450,19 +419,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
450
419
  */
451
420
  const handleAgentModes = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
452
421
  try {
453
- if (!deps.sendToAgentEnabled()) {
454
- json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
455
- return
456
- }
457
- const token = deps.sendToAgentToken().trim()
458
- if (token.length > 0) {
459
- const got = req.headers['x-send-to-agent-token']
460
- const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
461
- if (value !== token) {
462
- json(res, { ok: false, error: 'unauthorized' }, 401)
463
- return
464
- }
465
- }
422
+ if (!guardSendToAgent(req, res)) return
466
423
  const ap = deps.getAgentPresets()
467
424
  if (ap === undefined) {
468
425
  json(res, { ok: false, error: 'agent presets service unavailable' }, 503)
@@ -516,19 +473,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
516
473
  */
517
474
  const handleAgentSend = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
518
475
  try {
519
- if (!deps.sendToAgentEnabled()) {
520
- json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
521
- return
522
- }
523
- const token = deps.sendToAgentToken().trim()
524
- if (token.length > 0) {
525
- const got = req.headers['x-send-to-agent-token']
526
- const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
527
- if (value !== token) {
528
- json(res, { ok: false, error: 'unauthorized' }, 401)
529
- return
530
- }
531
- }
476
+ if (!guardSendToAgent(req, res)) return
532
477
  const body = JSON.parse(await readBody(req)) as { sessionId?: unknown; text?: unknown }
533
478
  const sessionId = typeof body.sessionId === 'string' && body.sessionId.trim().length > 0 ? body.sessionId.trim() : ''
534
479
  const text = typeof body.text === 'string' && body.text.trim().length > 0 ? body.text.trim() : ''
@@ -574,19 +519,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
574
519
  */
575
520
  const handleAgentCreate = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
576
521
  try {
577
- if (!deps.sendToAgentEnabled()) {
578
- json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
579
- return
580
- }
581
- const token = deps.sendToAgentToken().trim()
582
- if (token.length > 0) {
583
- const got = req.headers['x-send-to-agent-token']
584
- const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
585
- if (value !== token) {
586
- json(res, { ok: false, error: 'unauthorized' }, 401)
587
- return
588
- }
589
- }
522
+ if (!guardSendToAgent(req, res)) return
590
523
  const body = JSON.parse(await readBody(req)) as { cwd?: unknown; mode?: unknown; permission?: unknown }
591
524
  const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : ''
592
525
  const mode = typeof body.mode === 'string' && body.mode.trim().length > 0 ? body.mode.trim() : undefined
@@ -781,7 +714,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
781
714
  text: t.text ?? '',
782
715
  tags: t.tags ?? [],
783
716
  type: t.type ?? 'text/vnd.tiddlywiki',
784
- modified: typeof t.modified === 'string' ? (t.modified as string) : null,
717
+ modified: typeof t.modified === 'string' ? t.modified : null,
785
718
  fields,
786
719
  })
787
720
  } catch (err) {
package/src/host/seeds.ts CHANGED
@@ -18,8 +18,8 @@
18
18
  * - `remove` — optional seeds only: delete the seeded tiddlers + markers,
19
19
  * returning the wiki to the "never seeded" state.
20
20
  *
21
- * Registry: doc-note / send-to-agent / home-index / all-articles /
22
- * menubar-theme / tw-web-host.
21
+ * Registry: doc-note / send-to-agent / render-route / home-index /
22
+ * all-articles / menubar-theme / tw-web-host.
23
23
  *
24
24
  * @module dsh-tiddlywiki/host/seeds
25
25
  */
@@ -93,182 +93,129 @@ const removedDetail = (id: string, removed: string[]): SeedRunResult => ({
93
93
  : '本就不存在,无需移除',
94
94
  })
95
95
 
96
+ /** Metadata shared by every seed and echoed in its status/result objects. */
97
+ interface SeedMeta {
98
+ id: string
99
+ title: string
100
+ description: string
101
+ core: boolean
102
+ }
103
+
104
+ type SeedWriter = (client: TiddlyWebClient, opts?: { force?: boolean }) => Promise<boolean>
105
+ type SeedUnseeder = (client: TiddlyWebClient) => Promise<{ removed: string[] }>
106
+
107
+ /**
108
+ * Define a seed from a small spec, killing the id/title/description
109
+ * triplication and the try/catch boilerplate every registry entry used to
110
+ * repeat by hand:
111
+ * - `presentTitle` → default check: is that tiddler present?
112
+ * - `check` → custom presence check (overrides presentTitle).
113
+ * - `write` → built-in content writer; true = (re)written this call.
114
+ * - `run` → custom runner (overrides the standard write wrapper;
115
+ * e.g. tw-web-host honors a user-chosen host value).
116
+ * - `unseed` → remove the seeded tiddlers + markers (removable seeds).
117
+ */
118
+ function defineSeed(meta: SeedMeta, impl: {
119
+ presentTitle?: string
120
+ check?: (ctx: SeedContext) => Promise<SeedStatus>
121
+ write?: SeedWriter
122
+ run?: (ctx: SeedContext, force: boolean) => Promise<SeedRunResult>
123
+ unseed?: SeedUnseeder
124
+ }): SeedDef {
125
+ const { id, title, description, core } = meta
126
+ const removable = !core
127
+ const check: SeedDef['check'] = impl.check ?? (async (ctx) => {
128
+ const present = await presentOf(ctx, impl.presentTitle ?? '')
129
+ return { id, title, description, present, removable, detail: present ? '已存在' : '缺失' }
130
+ })
131
+ const run: SeedDef['run'] = impl.run ?? (async (ctx, force) => {
132
+ try {
133
+ const wrote = await impl.write!(ctx.client, { force })
134
+ return { id, ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
135
+ } catch (err) {
136
+ return { id, ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
137
+ }
138
+ })
139
+ const remove: SeedDef['remove'] = impl.unseed === undefined ? undefined : async (ctx) => {
140
+ try {
141
+ return removedDetail(id, (await impl.unseed!(ctx.client)).removed)
142
+ } catch (err) {
143
+ return { id, ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
144
+ }
145
+ }
146
+ return { id, title, description, core, check, run, ...(remove === undefined ? {} : { remove }) }
147
+ }
148
+
96
149
  /** The full registry, in display order. */
97
150
  export const SEED_DEFS: SeedDef[] = [
98
- {
99
- id: 'doc-note',
100
- title: '插件说明笔记',
101
- description: '「dsh-tiddlywiki 插件说明」——新 wiki 首启自动写入的入门说明(ONE-SHOT,用户可改可删)。',
102
- core: false,
103
- check: async (ctx) => {
104
- const present = await presentOf(ctx, DOC_NOTE_TITLE)
105
- return { id: 'doc-note', title: '插件说明笔记', description: '「dsh-tiddlywiki 插件说明」——新 wiki 首启自动写入的入门说明(ONE-SHOT,用户可改可删)。', present, removable: true, detail: present ? '已存在' : '缺失' }
106
- },
107
- run: async (ctx, force) => {
108
- try {
109
- const wrote = await seedDocNote(ctx.client, { force })
110
- return { id: 'doc-note', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
111
- } catch (err) {
112
- return { id: 'doc-note', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
113
- }
114
- },
115
- remove: async (ctx) => {
116
- try {
117
- return removedDetail('doc-note', (await unseedDocNote(ctx.client)).removed)
118
- } catch (err) {
119
- return { id: 'doc-note', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
120
- }
121
- },
122
- },
123
- {
124
- id: 'send-to-agent',
125
- title: '「发送给 Agent」按钮',
126
- description: 'TW 笔记工具栏「发送给 Agent」按钮插件($:/plugins/dsh/send-to-agent)——把笔记一键注入 DSH 会话。',
127
- core: true,
128
- check: async (ctx) => {
129
- const present = await presentOf(ctx, SEND_TO_AGENT_PLUGIN_TITLE)
130
- return { id: 'send-to-agent', title: '「发送给 Agent」按钮', description: 'TW 笔记工具栏「发送给 Agent」按钮插件($:/plugins/dsh/send-to-agent)——把笔记一键注入 DSH 会话。', present, removable: false, detail: present ? '已存在' : '缺失' }
131
- },
132
- run: async (ctx, force) => {
133
- try {
134
- const wrote = await seedSendToAgent(ctx.client, { force })
135
- return { id: 'send-to-agent', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
136
- } catch (err) {
137
- return { id: 'send-to-agent', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
138
- }
139
- },
140
- },
141
- {
142
- id: 'render-route',
143
- title: '原生渲染路由(/render)',
144
- description: 'TW 服务端路由插件($:/plugins/dsh/render,server-routes/render.js)——把 wiki 文本在运行中的 TW 里原生渲染成 HTML 片段,回复流工具卡与 wiki 链接跳转依赖它。seed 写入后需重启 TW 使路由生效。',
145
- core: true,
146
- check: async (ctx) => {
147
- const present = await presentOf(ctx, RENDER_PLUGIN_TITLE)
148
- return { id: 'render-route', title: '原生渲染路由(/render)', description: 'TW 服务端路由插件($:/plugins/dsh/render,server-routes/render.js)——把 wiki 文本在运行中的 TW 里原生渲染成 HTML 片段,回复流工具卡与 wiki 链接跳转依赖它。seed 写入后需重启 TW 使路由生效。', present, removable: false, detail: present ? '已存在' : '缺失' }
149
- },
150
- run: async (ctx, force) => {
151
- try {
152
- const wrote = await seedRenderRoute(ctx.client, { force })
153
- return { id: 'render-route', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
154
- } catch (err) {
155
- return { id: 'render-route', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
156
- }
157
- },
158
- },
159
- {
160
- id: 'home-index',
161
- title: '首页(主页 / 所有标签 / 标签笔记)',
162
- description: '默认主页:四象限待办 + 「所有标签」「所有文章」入口;所有标签:标签统计 + Agent 区块(纯 Agent / Agent+人工);标签笔记:按标签浏览。系统提示承诺的首页由这里 seed,主页同时写入 $:/DefaultTiddlers。',
163
- core: false,
164
- check: async (ctx) => {
165
- const missing: string[] = []
166
- for (const item of HOME_INDEX_ITEMS) {
167
- if (!(await presentOf(ctx, item.title))) missing.push(item.title)
168
- }
169
- return { id: 'home-index', title: '首页(主页 / 所有标签 / 标签笔记)', description: '默认主页:四象限待办 + 「所有标签」「所有文章」入口;所有标签:标签统计 + Agent 区块(纯 Agent / Agent+人工);标签笔记:按标签浏览。系统提示承诺的首页由这里 seed,主页同时写入 $:/DefaultTiddlers。', present: missing.length === 0, removable: true, detail: missing.length === 0 ? '已存在' : `缺失:${missing.join('、')}` }
170
- },
171
- run: async (ctx, force) => {
172
- try {
173
- const wrote = await seedHomeIndex(ctx.client, { force })
174
- return { id: 'home-index', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
175
- } catch (err) {
176
- return { id: 'home-index', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
177
- }
178
- },
179
- remove: async (ctx) => {
180
- try {
181
- return removedDetail('home-index', (await unseedHomeIndex(ctx.client)).removed)
182
- } catch (err) {
183
- return { id: 'home-index', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
184
- }
185
- },
186
- },
187
- {
188
- id: 'all-articles',
189
- title: '所有文章(两列分页总览)',
190
- description: '「所有文章」——全部条目分两列(🤖 Agent 撰写 / 👤 人工·人类)各自分页展示。每页条数取插件设置 ui.allArticles.pageSize(默认 10)。',
191
- core: false,
192
- check: async (ctx) => {
193
- const present = await presentOf(ctx, ALL_ARTICLES_TITLE)
194
- return { id: 'all-articles', title: '所有文章(两列分页总览)', description: '「所有文章」——全部条目分两列(🤖 Agent 撰写 / 👤 人工·人类)各自分页展示。每页条数取插件设置 ui.allArticles.pageSize(默认 10)。', present, removable: true, detail: present ? '已存在' : '缺失' }
195
- },
196
- run: async (ctx, force) => {
197
- try {
198
- const wrote = await seedAllArticles(ctx.client, { force })
199
- return { id: 'all-articles', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
200
- } catch (err) {
201
- return { id: 'all-articles', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
202
- }
203
- },
204
- remove: async (ctx) => {
205
- try {
206
- return removedDetail('all-articles', (await unseedAllArticles(ctx.client)).removed)
207
- } catch (err) {
208
- return { id: 'all-articles', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
209
- }
210
- },
211
- },
212
- {
213
- id: 'menubar-theme',
214
- title: 'menubar 顶栏主题自适应',
215
- description: '样式表覆盖($:/plugins/dsh-tiddlywiki/menubar-theme,tag $:/tags/Stylesheet)——把 tiddlywiki/menubar 顶栏从「默认色映射的蓝色」改为跟随当前 palette 的 background/foreground,随 DSH 主题切换($:/palette 翻转)自动换色。',
216
- core: false,
217
- check: async (ctx) => {
218
- const present = await presentOf(ctx, MENUBAR_THEME_TIDDLER)
219
- return { id: 'menubar-theme', title: 'menubar 顶栏主题自适应', description: '样式表覆盖($:/plugins/dsh-tiddlywiki/menubar-theme,tag $:/tags/Stylesheet)——把 tiddlywiki/menubar 顶栏从「默认色映射的蓝色」改为跟随当前 palette 的 background/foreground,随 DSH 主题切换($:/palette 翻转)自动换色。', present, removable: true, detail: present ? '已存在' : '缺失' }
220
- },
221
- run: async (ctx, force) => {
222
- try {
223
- const wrote = await seedMenubarTheme(ctx.client, { force })
224
- return { id: 'menubar-theme', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
225
- } catch (err) {
226
- return { id: 'menubar-theme', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
227
- }
228
- },
229
- remove: async (ctx) => {
230
- try {
231
- return removedDetail('menubar-theme', (await unseedMenubarTheme(ctx.client)).removed)
232
- } catch (err) {
233
- return { id: 'menubar-theme', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
234
- }
235
- },
236
- },
237
- {
238
- id: 'tw-web-host',
239
- title: 'TW 前端 API 基址(同源代理)',
240
- description: '把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。',
241
- core: true,
242
- check: async (ctx) => {
243
- let current: string | undefined
244
- try {
245
- current = (await ctx.client.get(TW_WEB_HOST_TIDDLER))?.text?.trim()
246
- } catch {
247
- current = undefined
248
- }
249
- const ok = current === TW_PROXY_PATH
250
- return { id: 'tw-web-host', title: 'TW 前端 API 基址(同源代理)', description: '把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。', present: ok, removable: false, detail: ok ? `已指向 ${TW_PROXY_PATH}` : `当前:${current ?? '(缺失)'},应为 ${TW_PROXY_PATH}` }
151
+ defineSeed(
152
+ { id: 'doc-note', title: '插件说明笔记', description: '「dsh-tiddlywiki 插件说明」——新 wiki 首启自动写入的入门说明(ONE-SHOT,用户可改可删)。', core: false },
153
+ { presentTitle: DOC_NOTE_TITLE, write: seedDocNote, unseed: unseedDocNote },
154
+ ),
155
+ defineSeed(
156
+ { id: 'send-to-agent', title: '「发送给 Agent」按钮', description: 'TW 笔记工具栏「发送给 Agent」按钮插件($:/plugins/dsh/send-to-agent)——把笔记一键注入 DSH 会话。', core: true },
157
+ { presentTitle: SEND_TO_AGENT_PLUGIN_TITLE, write: seedSendToAgent },
158
+ ),
159
+ defineSeed(
160
+ { id: 'render-route', title: '原生渲染路由(/render)', description: 'TW 服务端路由插件($:/plugins/dsh/render,server-routes/render.js)——把 wiki 文本在运行中的 TW 里原生渲染成 HTML 片段,回复流工具卡与 wiki 链接跳转依赖它。seed 写入后需重启 TW 使路由生效。', core: true },
161
+ { presentTitle: RENDER_PLUGIN_TITLE, write: seedRenderRoute },
162
+ ),
163
+ defineSeed(
164
+ { id: 'home-index', title: '首页(主页 / 所有标签 / 标签笔记)', description: '默认主页:四象限待办 + 「所有标签」「所有文章」入口;所有标签:标签统计 + Agent 区块(纯 Agent / Agent+人工);标签笔记:按标签浏览。系统提示承诺的首页由这里 seed,主页同时写入 $:/DefaultTiddlers。', core: false },
165
+ {
166
+ check: async (ctx) => {
167
+ const missing: string[] = []
168
+ for (const item of HOME_INDEX_ITEMS) {
169
+ if (!(await presentOf(ctx, item.title))) missing.push(item.title)
170
+ }
171
+ return { id: 'home-index', title: '首页(主页 / 所有标签 / 标签笔记)', description: '默认主页:四象限待办 + 「所有标签」「所有文章」入口;所有标签:标签统计 + Agent 区块(纯 Agent / Agent+人工);标签笔记:按标签浏览。系统提示承诺的首页由这里 seed,主页同时写入 $:/DefaultTiddlers。', present: missing.length === 0, removable: true, detail: missing.length === 0 ? '已存在' : `缺失:${missing.join('、')}` }
172
+ },
173
+ write: seedHomeIndex,
174
+ unseed: unseedHomeIndex,
251
175
  },
252
- run: async (ctx, force) => {
253
- try {
176
+ ),
177
+ defineSeed(
178
+ { id: 'all-articles', title: '所有文章(两列分页总览)', description: '「所有文章」——全部条目分两列(🤖 Agent 撰写 / 👤 人工·人类)各自分页展示。每页条数取插件设置 ui.allArticles.pageSize(默认 10)。', core: false },
179
+ { presentTitle: ALL_ARTICLES_TITLE, write: seedAllArticles, unseed: unseedAllArticles },
180
+ ),
181
+ defineSeed(
182
+ { id: 'menubar-theme', title: 'menubar 顶栏主题自适应', description: '样式表覆盖($:/plugins/dsh-tiddlywiki/menubar-theme,tag $:/tags/Stylesheet)——把 tiddlywiki/menubar 顶栏从「默认色映射的蓝色」改为跟随当前 palette 的 background/foreground,随 DSH 主题切换($:/palette 翻转)自动换色。', core: false },
183
+ { presentTitle: MENUBAR_THEME_TIDDLER, write: seedMenubarTheme, unseed: unseedMenubarTheme },
184
+ ),
185
+ defineSeed(
186
+ { id: 'tw-web-host', title: 'TW 前端 API 基址(同源代理)', description: '把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。', core: true },
187
+ {
188
+ check: async (ctx) => {
254
189
  let current: string | undefined
255
190
  try {
256
191
  current = (await ctx.client.get(TW_WEB_HOST_TIDDLER))?.text?.trim()
257
192
  } catch {
258
193
  current = undefined
259
194
  }
260
- // Non-force keeps the ensure semantics: write only when missing or still
261
- // the legacy default (a user override pointing elsewhere is honored).
262
- if (!force && current !== undefined && current !== TW_WEB_HOST_DEFAULT) {
263
- return { id: 'tw-web-host', ok: true, wrote: false, detail: '已指向自定义基址,未覆盖' }
195
+ const ok = current === TW_PROXY_PATH
196
+ return { id: 'tw-web-host', title: 'TW 前端 API 基址(同源代理)', description: '把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。', present: ok, removable: false, detail: ok ? `已指向 ${TW_PROXY_PATH}` : `当前:${current ?? '(缺失)'},应为 ${TW_PROXY_PATH}` }
197
+ },
198
+ run: async (ctx, force) => {
199
+ try {
200
+ let current: string | undefined
201
+ try {
202
+ current = (await ctx.client.get(TW_WEB_HOST_TIDDLER))?.text?.trim()
203
+ } catch {
204
+ current = undefined
205
+ }
206
+ // Non-force keeps the ensure semantics: write only when missing or still
207
+ // the legacy default (a user override pointing elsewhere is honored).
208
+ if (!force && current !== undefined && current !== TW_WEB_HOST_DEFAULT) {
209
+ return { id: 'tw-web-host', ok: true, wrote: false, detail: '已指向自定义基址,未覆盖' }
210
+ }
211
+ await ctx.client.put({ title: TW_WEB_HOST_TIDDLER, text: TW_PROXY_PATH, type: 'text/plain', tags: [] })
212
+ return { id: 'tw-web-host', ok: true, wrote: true, detail: force ? '已重新初始化(强制写回代理基址)' : '已写入代理基址' }
213
+ } catch (err) {
214
+ return { id: 'tw-web-host', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
264
215
  }
265
- await ctx.client.put({ title: TW_WEB_HOST_TIDDLER, text: TW_PROXY_PATH, type: 'text/plain', tags: [] })
266
- return { id: 'tw-web-host', ok: true, wrote: true, detail: force ? '已重新初始化(强制写回代理基址)' : '已写入代理基址' }
267
- } catch (err) {
268
- return { id: 'tw-web-host', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
269
- }
216
+ },
270
217
  },
271
- },
218
+ ),
272
219
  ]
273
220
 
274
221
  /** Check every seed, returning statuses in registry order. */
@@ -105,9 +105,24 @@ function collectDescendantIds(nodes: SessionLineageNode[] | undefined, out: stri
105
105
  }
106
106
  }
107
107
 
108
+ /**
109
+ * 汇总条目会直接写进 `[[标题]]` wikitext——标题既要能安全渲染、又不能制造死链。
110
+ * 会话日志里常混入模型的示例/残缺写法(`#…`、`#标题`、带反引号的截断链接、
111
+ * 控制字符等),这类收进来会被 TW 渲染成「佚失条目」;一律过滤。
112
+ */
113
+ function isPlausibleTitle(title: string): boolean {
114
+ if (title.length === 0 || title.length > 300) return false
115
+ if (title.trim().length === 0) return false
116
+ if (/[\u0000-\u001f\u007f]/.test(title)) return false // 控制字符 / 换行
117
+ if (/[\]|`#]/.test(title)) return false // 会破坏 [[…]] 链接语法
118
+ if (title.startsWith('…') || title === '标题') return false // 占位 / 示例写法
119
+ return true
120
+ }
121
+
108
122
  /** 记录一篇笔记的触达;同标题合并(时间取最新、detail 取最新、subagent 取 AND)。 */
109
123
  function record(map: Map<string, NoteEntry>, entry: NoteEntry): void {
110
124
  if (entry.title.startsWith('$:/')) return
125
+ if (!isPlausibleTitle(entry.title)) return
111
126
  const prev = map.get(entry.title)
112
127
  if (prev !== undefined) {
113
128
  prev.time = Math.max(prev.time, entry.time)
@@ -120,7 +135,9 @@ function record(map: Map<string, NoteEntry>, entry: NoteEntry): void {
120
135
 
121
136
  /** 扫助手回复文本里的 `/dsh-tiddlywiki/tw/#标题` 引用链接 → 读取。 */
122
137
  function scanRefs(text: string, time: number, subagent: boolean, collected: Collected): void {
123
- const re = /\/dsh-tiddlywiki\/tw\/#([^)\s\]]+)/g
138
+ // 只认到下一个 `)` / 空白 / `]` / `#` 为止——`#` 会截断后续碎片(模型示例常写成
139
+ // `#…` 或 `#标题`,这些由 isPlausibleTitle 在 record 时过滤)。
140
+ const re = /\/dsh-tiddlywiki\/tw\/#([^#)\s\]]+)/g
124
141
  let m: RegExpExecArray | null
125
142
  while ((m = re.exec(text)) !== null) {
126
143
  const raw = m[1] ?? ''
@@ -221,27 +238,30 @@ interface TitleState {
221
238
  snippet?: string
222
239
  }
223
240
 
224
- /** 逐个查询每篇笔记的当前状态(404 → 不存在/已删除)。 */
241
+ /** 并发(8 路)查询每篇笔记的当前状态(404 → 不存在/已删除)。 */
225
242
  async function enrichTitles(client: TiddlyWebClient, titles: string[]): Promise<Map<string, TitleState>> {
226
243
  const out = new Map<string, TitleState>()
227
- for (const title of titles) {
244
+ const enquire = async (title: string): Promise<[string, TitleState]> => {
228
245
  try {
229
246
  const t = await client.get(title)
230
- if (t === undefined) {
231
- out.set(title, { exists: false, tags: [] })
232
- } else {
233
- const flat = typeof t.text === 'string' ? t.text.replace(/\s+/g, ' ').trim() : ''
234
- out.set(title, {
235
- exists: true,
236
- tags: t.tags ?? [],
237
- modified: typeof t.modified === 'string' ? t.modified : undefined,
238
- snippet: flat.length > 48 ? `${flat.slice(0, 48)}…` : flat,
239
- })
240
- }
247
+ if (t === undefined) return [title, { exists: false, tags: [] }]
248
+ const flat = typeof t.text === 'string' ? t.text.replace(/\s+/g, ' ').trim() : ''
249
+ return [title, {
250
+ exists: true,
251
+ tags: t.tags ?? [],
252
+ modified: typeof t.modified === 'string' ? t.modified : undefined,
253
+ snippet: flat.length > 48 ? `${flat.slice(0, 48)}…` : flat,
254
+ }]
241
255
  } catch {
242
- out.set(title, { exists: false, tags: [] })
256
+ return [title, { exists: false, tags: [] }]
243
257
  }
244
258
  }
259
+ // Bounded batches: parallel within a batch, insertion order preserved across
260
+ // batches (Map order = the caller's title order, same as the old sequential).
261
+ for (let i = 0; i < titles.length; i += 8) {
262
+ const batch = titles.slice(i, i + 8)
263
+ for (const [title, state] of await Promise.all(batch.map(enquire))) out.set(title, state)
264
+ }
245
265
  return out
246
266
  }
247
267