dsh-documents 0.0.0-stage → 1.0.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/index.js ADDED
@@ -0,0 +1,927 @@
1
+ /**
2
+ * Host half of the Document Library bundle.
3
+ *
4
+ * Documents are derived from DSH session logs: only files a session wrote,
5
+ * edited, or presented as a deliverable appear here, so every entry carries
6
+ * its originating and editing sessions with exact event coordinates.
7
+ *
8
+ * One local JSON endpoint (`/dsh-documents/api`) serves the Web panel and one
9
+ * agent tool (`documents`) shares the same implementation.
10
+ */
11
+ import { promises as fs, createReadStream } from 'node:fs'
12
+ import path from 'node:path'
13
+ import os from 'node:os'
14
+ import { spawn } from 'node:child_process'
15
+
16
+ const ROUTE_PREFIX = '/dsh-documents/api'
17
+ const TRASH_DIR = path.join(os.homedir(), '.dsh', 'documents-trash')
18
+ const RENAME_FILE = path.join(os.homedir(), '.dsh', 'documents-renames.json')
19
+ const PREVIEW_LIMIT = 400 * 1024
20
+ const MAX_DOCUMENTS = 800
21
+ const DOCS_CACHE_MS = 15000
22
+
23
+ const KINDS = [
24
+ { id: 'word', color: '#2b579a', exts: ['doc', 'docx', 'docm', 'rtf', 'odt', 'pages'] },
25
+ { id: 'excel', color: '#217346', exts: ['xls', 'xlsx', 'xlsm', 'csv', 'ods'] },
26
+ { id: 'slides', color: '#c2410c', exts: ['ppt', 'pptx', 'pptm', 'odp', 'key'] },
27
+ { id: 'pdf', color: '#b42318', exts: ['pdf'] },
28
+ { id: 'markdown', color: '#6d5bd0', exts: ['md', 'markdown', 'mdx'] },
29
+ { id: 'text', color: '#5b6472', exts: ['txt', 'log', 'tex'] },
30
+ ]
31
+
32
+ const KIND_BY_EXT = new Map()
33
+ for (const kind of KINDS) {
34
+ for (const ext of kind.exts) KIND_BY_EXT.set(ext, kind)
35
+ }
36
+
37
+ const TEXT_EXT = new Set([
38
+ 'md', 'markdown', 'mdx', 'txt', 'log', 'tex', 'json', 'yml', 'yaml', 'xml', 'html', 'htm', 'tsv', 'csv',
39
+ ])
40
+
41
+ const IMAGE_MIME = {
42
+ png: 'image/png',
43
+ jpg: 'image/jpeg',
44
+ jpeg: 'image/jpeg',
45
+ webp: 'image/webp',
46
+ gif: 'image/gif',
47
+ }
48
+
49
+ const WRITE_TOOLS = new Set([
50
+ 'write', 'edit', 'write_file', 'create_file', 'str_replace_editor', 'apply_patch', 'notebook_edit',
51
+ ])
52
+
53
+ const PATH_ARG_KEYS = ['path', 'file_path', 'filePath', 'target_file', 'file']
54
+
55
+
56
+ let docsCache = { at: 0, key: '', value: null }
57
+ let renameMap = null
58
+ const sessionCache = new Map()
59
+
60
+ /**
61
+ * Renames performed through this plugin are remembered because session events
62
+ * keep the original path; without the mapping the renamed file would vanish
63
+ * from the library.
64
+ */
65
+ async function loadRenames() {
66
+ if (renameMap !== null) return renameMap
67
+ renameMap = new Map()
68
+ try {
69
+ const raw = await fs.readFile(RENAME_FILE, 'utf8')
70
+ const parsed = JSON.parse(raw)
71
+ if (parsed !== null && typeof parsed === 'object') {
72
+ for (const [from, to] of Object.entries(parsed)) {
73
+ if (typeof from === 'string' && typeof to === 'string') renameMap.set(from.toLowerCase(), to)
74
+ }
75
+ }
76
+ } catch {
77
+ // No rename history yet.
78
+ }
79
+ return renameMap
80
+ }
81
+
82
+ async function saveRenames() {
83
+ if (renameMap === null) return
84
+ try {
85
+ await fs.mkdir(path.dirname(RENAME_FILE), { recursive: true })
86
+ const plain = Object.fromEntries(renameMap)
87
+ await fs.writeFile(RENAME_FILE, JSON.stringify(plain, null, 2), 'utf8')
88
+ } catch {
89
+ // Rename history is a convenience, not a requirement.
90
+ }
91
+ }
92
+
93
+ function resolveRenamedPath(map, candidate) {
94
+ let current = candidate
95
+ for (let hop = 0; hop < 10; hop += 1) {
96
+ const next = map.get(current.toLowerCase())
97
+ if (next === undefined || next === current) break
98
+ current = next
99
+ }
100
+ return current
101
+ }
102
+
103
+ function messageOf(error) {
104
+ if (error instanceof Error) return error.message
105
+ return String(error)
106
+ }
107
+
108
+ /**
109
+ * Resolve the directories this plugin may read and manage.
110
+ * @param ctx plugin context
111
+ * @param config plugin row config
112
+ */
113
+ function rootsFor(ctx, config) {
114
+ const roots = []
115
+ const push = (value, title, id) => {
116
+ if (typeof value !== 'string' || value.trim() === '') return
117
+ const abs = path.resolve(value)
118
+ for (const root of roots) {
119
+ const within = path.relative(root.path, abs)
120
+ const covers = path.relative(abs, root.path)
121
+ const nested = (within !== '' && !within.startsWith('..') && !path.isAbsolute(within))
122
+ || (covers !== '' && !covers.startsWith('..') && !path.isAbsolute(covers))
123
+ if (nested) return
124
+ }
125
+ roots.push({ id: id ?? abs, title: title || path.basename(abs) || abs, path: abs })
126
+ }
127
+ const registry = ctx.get('workspaceRegistry')
128
+ if (registry && typeof registry.list === 'function') {
129
+ try {
130
+ for (const workspace of registry.list()) {
131
+ if (workspace && typeof workspace.path === 'string') {
132
+ push(workspace.path, workspace.title, String(workspace.id))
133
+ }
134
+ }
135
+ } catch {
136
+ // Fall back to cwd when the registry cannot answer.
137
+ }
138
+ }
139
+ if (Array.isArray(config?.extraRoots)) {
140
+ for (const extra of config.extraRoots) push(extra, undefined, `extra:${extra}`)
141
+ }
142
+ if (roots.length === 0) push(process.cwd(), 'workspace', 'cwd')
143
+ return roots
144
+ }
145
+
146
+ async function realRootOf(root) {
147
+ try {
148
+ return await fs.realpath(root.path)
149
+ } catch {
150
+ return root.path
151
+ }
152
+ }
153
+
154
+ function insideRoots(candidate, roots) {
155
+ if (typeof candidate !== 'string' || candidate === '') return false
156
+ for (const root of roots) {
157
+ const rel = path.relative(root.path, candidate)
158
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return true
159
+ }
160
+ return false
161
+ }
162
+
163
+ function rootForPath(candidate, roots) {
164
+ for (const root of roots) {
165
+ const rel = path.relative(root.path, candidate)
166
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return { root, rel }
167
+ }
168
+ return undefined
169
+ }
170
+
171
+ /**
172
+ * Resolve a caller-supplied path and prove it sits inside one configured root.
173
+ */
174
+ async function resolveWithinRoots(target, roots) {
175
+ if (typeof target !== 'string' || target.trim() === '') throw new Error('缺少文件路径')
176
+ const abs = path.resolve(target)
177
+ let resolved = abs
178
+ try {
179
+ resolved = await fs.realpath(abs)
180
+ } catch (error) {
181
+ if (error && error.code !== 'ENOENT') throw error
182
+ }
183
+ for (const root of roots) {
184
+ const realRoot = await realRootOf(root)
185
+ const rel = path.relative(realRoot, resolved)
186
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
187
+ return { resolved, root: { ...root, path: realRoot } }
188
+ }
189
+ }
190
+ throw new Error('该文件不在 DSH 工作区内')
191
+ }
192
+
193
+ function lineCount(value) {
194
+ if (typeof value !== 'string' || value === '') return 0
195
+ return value.split('\n').length
196
+ }
197
+
198
+ function previewOf(value, limit = 320) {
199
+ if (typeof value !== 'string') return ''
200
+ const text = value.replace(/\r\n/g, '\n').trim()
201
+ return text.length > limit ? text.slice(0, limit) + '…' : text
202
+ }
203
+
204
+ function fileEventFromToolCall(event, sessionCwd) {
205
+ const name = event.data?.name
206
+ if (typeof name !== 'string' || !WRITE_TOOLS.has(name)) return []
207
+ let args
208
+ try {
209
+ args = JSON.parse(event.data.arguments ?? '{}')
210
+ } catch {
211
+ return []
212
+ }
213
+ if (args === null || typeof args !== 'object') return []
214
+ const targets = []
215
+ for (const key of PATH_ARG_KEYS) {
216
+ if (typeof args[key] === 'string' && args[key].trim() !== '') targets.push(args[key])
217
+ }
218
+ if (Array.isArray(args.files)) {
219
+ for (const file of args.files) {
220
+ if (file && typeof file.path === 'string') targets.push(file.path)
221
+ }
222
+ }
223
+ const out = []
224
+ for (const target of targets) {
225
+ const absolute = path.isAbsolute(target) ? path.resolve(target) : path.resolve(sessionCwd || '.', target)
226
+ let kind = 'write'
227
+ let summary = '写入文件'
228
+ let added = 0
229
+ let removed = 0
230
+ let excerpt = ''
231
+ if (name === 'edit' || name === 'str_replace_editor') {
232
+ kind = 'edit'
233
+ const before = lineCount(args.old_string ?? args.old_str ?? '')
234
+ const after = lineCount(args.new_string ?? args.new_str ?? '')
235
+ added = Math.max(0, after - before)
236
+ removed = Math.max(0, before - after)
237
+ summary = added === 0 && removed === 0
238
+ ? '修改内容'
239
+ : `修改内容(+${added} −${removed} 行)`
240
+ excerpt = previewOf(args.new_string ?? args.new_str ?? '')
241
+ } else if (name === 'present') {
242
+ kind = 'present'
243
+ summary = '呈现为交付文档'
244
+ } else {
245
+ const content = args.content ?? args.text ?? ''
246
+ const lines = lineCount(content)
247
+ summary = `写入 ${lines} 行`
248
+ excerpt = previewOf(content)
249
+ }
250
+ out.push({
251
+ kind,
252
+ tool: name,
253
+ path: absolute,
254
+ seq: Number(event.seq),
255
+ time: Number(event.time),
256
+ turn: Number(event.data?.turn ?? 0),
257
+ summary,
258
+ added,
259
+ removed,
260
+ preview: excerpt,
261
+ })
262
+ }
263
+ return out
264
+ }
265
+
266
+ function fileEventsFromDeliverable(event, sessionCwd) {
267
+ const files = event.data?.files
268
+ if (!Array.isArray(files)) return []
269
+ const out = []
270
+ for (const file of files) {
271
+ if (!file || typeof file.path !== 'string') continue
272
+ const absolute = path.isAbsolute(file.path) ? path.resolve(file.path) : path.resolve(sessionCwd || '.', file.path)
273
+ out.push({
274
+ kind: 'present',
275
+ tool: 'present',
276
+ path: absolute,
277
+ seq: Number(event.seq),
278
+ time: Number(event.time),
279
+ turn: Number(event.data?.turn ?? 0),
280
+ summary: file.description ? `交付:${file.description}` : '呈现为交付文档',
281
+ added: 0,
282
+ removed: 0,
283
+ preview: '',
284
+ })
285
+ }
286
+ return out
287
+ }
288
+
289
+ /**
290
+ * Extract file-touching events from one session log.
291
+ */
292
+ function extractSessionFiles(events, sessionCwd) {
293
+ const out = []
294
+ for (const event of events) {
295
+ if (event === null || typeof event !== 'object') continue
296
+ if (event.type === 'deliverables/presented') {
297
+ out.push(...fileEventsFromDeliverable(event, sessionCwd))
298
+ } else if (event.type === 'tool/call') {
299
+ out.push(...fileEventFromToolCall(event, sessionCwd))
300
+ }
301
+ }
302
+ return out
303
+ }
304
+
305
+ async function sessionRecords(ctx, signal) {
306
+ const query = ctx.get('sessionQuery')
307
+ const persistence = ctx.get('sessionPersistence')
308
+ if (!query || typeof query.listSessions !== 'function' || typeof query.readSession !== 'function') {
309
+ throw new Error('会话查询服务不可用')
310
+ }
311
+ const records = await query.listSessions(signal)
312
+ let titles = new Map()
313
+ try {
314
+ const observed = await query.readTitleSnapshots(records.map((record) => record.header.id), signal)
315
+ for (const item of observed) {
316
+ if (item && item.status === 'fulfilled' && item.value && item.value.title) {
317
+ titles.set(String(item.sessionId), String(item.value.title.title ?? ''))
318
+ }
319
+ }
320
+ } catch {
321
+ titles = new Map()
322
+ }
323
+ const out = []
324
+ for (const record of records) {
325
+ const header = record.header
326
+ if (!header || typeof header.cwd !== 'string') continue
327
+ const id = String(header.id)
328
+ let revision = ''
329
+ if (persistence && typeof persistence.stat === 'function') {
330
+ try {
331
+ const stat = await persistence.stat(header.id, { signal })
332
+ revision = String(stat?.revision ?? '')
333
+ } catch {
334
+ revision = ''
335
+ }
336
+ }
337
+ let cached = sessionCache.get(id)
338
+ if (cached === undefined || cached.revision !== revision) {
339
+ let snapshot
340
+ try {
341
+ snapshot = await query.readSession(header.id)
342
+ } catch {
343
+ continue
344
+ }
345
+ const events = Array.isArray(snapshot?.events) ? snapshot.events : []
346
+ cached = {
347
+ revision,
348
+ header,
349
+ files: extractSessionFiles(events, header.cwd),
350
+ }
351
+ sessionCache.set(id, cached)
352
+ }
353
+ out.push({
354
+ id,
355
+ header,
356
+ title: titles.get(id) || '',
357
+ files: cached.files,
358
+ })
359
+ }
360
+ return out
361
+ }
362
+
363
+ function kindOf(ext) {
364
+ return KIND_BY_EXT.get(ext)
365
+ }
366
+
367
+ async function collectDocuments(ctx, config, signal) {
368
+ const roots = rootsFor(ctx, config)
369
+ const renames = await loadRenames()
370
+ const sessions = await sessionRecords(ctx, signal)
371
+ const relevant = sessions.filter((session) => insideRoots(path.resolve(session.header.cwd), roots))
372
+ const byPath = new Map()
373
+ for (const session of relevant) {
374
+ const rootInfo = rootForPath(path.resolve(session.header.cwd), roots)
375
+ for (const file of session.files) {
376
+ const sourcePath = path.resolve(file.path)
377
+ const absolute = resolveRenamedPath(renames, sourcePath)
378
+ if (!insideRoots(absolute, roots)) continue
379
+ const ext = path.extname(absolute).slice(1).toLowerCase()
380
+ const kind = kindOf(ext)
381
+ if (kind === undefined) continue
382
+ const key = absolute.toLowerCase()
383
+ let doc = byPath.get(key)
384
+ if (doc === undefined) {
385
+ const pathRoot = rootForPath(absolute, roots) ?? rootInfo
386
+ doc = {
387
+ path: absolute,
388
+ key,
389
+ name: path.basename(absolute),
390
+ ext,
391
+ kind: kind.id,
392
+ color: kind.color,
393
+ rootTitle: pathRoot ? pathRoot.root.title : '',
394
+ rel: pathRoot ? pathRoot.rel : '',
395
+ sessions: new Map(),
396
+ firstTime: file.time,
397
+ lastTime: file.time,
398
+ }
399
+ byPath.set(key, doc)
400
+ }
401
+ let entry = doc.sessions.get(session.id)
402
+ if (entry === undefined) {
403
+ entry = {
404
+ id: session.id,
405
+ title: session.title || `会话 ${session.id.slice(-6)}`,
406
+ workspace: rootInfo ? rootInfo.root.title : '',
407
+ events: [],
408
+ }
409
+ doc.sessions.set(session.id, entry)
410
+ }
411
+ entry.events.push({
412
+ seq: file.seq,
413
+ time: file.time,
414
+ turn: file.turn,
415
+ kind: file.kind,
416
+ tool: file.tool,
417
+ summary: file.summary,
418
+ added: file.added,
419
+ removed: file.removed,
420
+ preview: file.preview,
421
+ })
422
+ if (file.time < doc.firstTime) doc.firstTime = file.time
423
+ if (file.time > doc.lastTime) doc.lastTime = file.time
424
+ }
425
+ }
426
+
427
+ const documents = []
428
+ for (const doc of byPath.values()) {
429
+ let info
430
+ try {
431
+ info = await fs.stat(doc.path)
432
+ } catch {
433
+ continue
434
+ }
435
+ if (!info.isFile()) continue
436
+ const entries = [...doc.sessions.values()]
437
+ for (const entry of entries) entry.events.sort((a, b) => a.seq - b.seq)
438
+ entries.sort((a, b) => {
439
+ const left = a.events[0]?.time ?? 0
440
+ const right = b.events[0]?.time ?? 0
441
+ return left - right
442
+ })
443
+ const origin = entries[0]
444
+ const editors = entries.filter((entry) => entry.id !== origin?.id)
445
+ documents.push({
446
+ path: doc.path,
447
+ name: doc.name,
448
+ ext: doc.ext,
449
+ kind: doc.kind,
450
+ color: doc.color,
451
+ rootTitle: doc.rootTitle,
452
+ rel: doc.rel,
453
+ bytes: info.size,
454
+ mtime: info.mtimeMs,
455
+ firstTime: doc.firstTime,
456
+ lastTime: doc.lastTime,
457
+ sessionCount: entries.length,
458
+ editCount: entries.reduce((total, entry) => total + entry.events.filter((event) => event.kind === 'edit' || event.kind === 'write').length, 0),
459
+ origin: origin
460
+ ? { sessionId: origin.id, title: origin.title, time: origin.events[0]?.time ?? doc.firstTime }
461
+ : undefined,
462
+ editedByOthers: editors.length > 0,
463
+ sessions: entries.map((entry) => ({
464
+ id: entry.id,
465
+ title: entry.title,
466
+ workspace: entry.workspace,
467
+ role: entry.id === origin?.id ? 'origin' : 'editor',
468
+ events: entry.events,
469
+ })),
470
+ })
471
+ }
472
+ documents.sort((a, b) => b.lastTime - a.lastTime)
473
+ return {
474
+ roots,
475
+ documents: documents.slice(0, MAX_DOCUMENTS),
476
+ truncated: documents.length > MAX_DOCUMENTS,
477
+ sessionCount: relevant.length,
478
+ }
479
+ }
480
+
481
+ async function listDocuments(ctx, config) {
482
+ const roots = rootsFor(ctx, config)
483
+ const key = roots.map((root) => root.path).join('|')
484
+ const now = Date.now()
485
+ if (docsCache.key === key && now - docsCache.at < DOCS_CACHE_MS && docsCache.value) {
486
+ return docsCache.value
487
+ }
488
+ const value = await collectDocuments(ctx, config)
489
+ docsCache = { key, at: now, value }
490
+ return value
491
+ }
492
+
493
+ function invalidateCache() {
494
+ docsCache = { at: 0, key: '', value: null }
495
+ }
496
+
497
+ async function readPreview(target, roots) {
498
+ const { resolved } = await resolveWithinRoots(target, roots)
499
+ const ext = path.extname(resolved).slice(1).toLowerCase()
500
+ const info = await fs.stat(resolved)
501
+ if (!info.isFile()) throw new Error('不是普通文件')
502
+ if (!TEXT_EXT.has(ext)) {
503
+ return { ok: true, supported: false, ext, bytes: info.size, mtime: info.mtimeMs }
504
+ }
505
+ const length = Math.min(info.size, PREVIEW_LIMIT)
506
+ const handle = await fs.open(resolved, 'r')
507
+ try {
508
+ const buffer = Buffer.alloc(length)
509
+ const { bytesRead } = await handle.read(buffer, 0, length, 0)
510
+ return {
511
+ ok: true,
512
+ supported: true,
513
+ ext,
514
+ bytes: info.size,
515
+ mtime: info.mtimeMs,
516
+ truncated: info.size > bytesRead,
517
+ text: buffer.subarray(0, bytesRead).toString('utf8'),
518
+ }
519
+ } finally {
520
+ await handle.close()
521
+ }
522
+ }
523
+
524
+ function launch(target, reveal) {
525
+ return new Promise((resolve) => {
526
+ const options = { detached: true, stdio: 'ignore', windowsHide: true }
527
+ try {
528
+ let child
529
+ if (process.platform === 'win32') {
530
+ if (reveal) {
531
+ child = spawn('explorer.exe', [`/select,${target}`], options)
532
+ } else {
533
+ // `cmd /c start` performs the ShellExecute that opens the file with its
534
+ // registered application; Start-Process cannot be used safely here
535
+ // because -LiteralPath is not a valid parameter.
536
+ const command = `start "" "${target.replace(/"/g, '""')}"`
537
+ child = spawn('cmd.exe', ['/d', '/s', '/c', command], {
538
+ ...options,
539
+ windowsVerbatimArguments: true,
540
+ })
541
+ }
542
+ } else if (process.platform === 'darwin') {
543
+ child = spawn('open', reveal ? ['-R', target] : [target], options)
544
+ } else {
545
+ child = spawn('xdg-open', [reveal ? path.dirname(target) : target], options)
546
+ }
547
+ child.once('error', (error) => {
548
+ resolve({ ok: false, error: messageOf(error) })
549
+ })
550
+ child.once('spawn', () => {
551
+ try {
552
+ child.unref()
553
+ } catch {
554
+ // The child already left.
555
+ }
556
+ resolve({ ok: true })
557
+ })
558
+ } catch (error) {
559
+ resolve({ ok: false, error: messageOf(error) })
560
+ }
561
+ })
562
+ }
563
+
564
+ async function openOnDesktop(ctx, target, reveal) {
565
+ const controller = ctx.get('sessionController')
566
+ if (!controller || typeof controller.openWorkspacePath !== 'function') return null
567
+ let available = true
568
+ try {
569
+ if (typeof controller.canOpenWorkspacePath === 'function') available = controller.canOpenWorkspacePath() === true
570
+ } catch {
571
+ available = false
572
+ }
573
+ if (!available) return null
574
+ try {
575
+ const request = reveal ? { path: target, action: 'reveal' } : { path: target }
576
+ await controller.openWorkspacePath(request, AbortSignal.timeout(15000))
577
+ return { ok: true, via: 'desktop' }
578
+ } catch (error) {
579
+ return { ok: false, error: messageOf(error), via: 'desktop' }
580
+ }
581
+ }
582
+
583
+ async function runAction(ctx, config, body) {
584
+ const action = typeof body?.action === 'string' ? body.action : ''
585
+ const roots = rootsFor(ctx, config)
586
+ const { resolved } = await resolveWithinRoots(body?.path, roots)
587
+ let info
588
+ try {
589
+ info = await fs.stat(resolved)
590
+ } catch (error) {
591
+ if (error && error.code === 'ENOENT') throw new Error('文件不存在或已被移动')
592
+ throw error
593
+ }
594
+
595
+ if (action === 'open' || action === 'reveal') {
596
+ if (!info.isFile()) throw new Error('只能打开文件')
597
+ const desktop = await openOnDesktop(ctx, resolved, action === 'reveal')
598
+ if (desktop !== null && desktop.ok) return { ...desktop, action, path: resolved }
599
+ const spawned = await launch(resolved, action === 'reveal')
600
+ if (spawned.ok) return { ...spawned, action, path: resolved }
601
+ return {
602
+ ok: false,
603
+ action,
604
+ path: resolved,
605
+ error: desktop?.error || spawned.error || '无法用桌面应用打开该文件',
606
+ }
607
+ }
608
+
609
+ if (action === 'rename') {
610
+ if (!info.isFile()) throw new Error('只能重命名文件')
611
+ let name = typeof body?.name === 'string' ? body.name.trim() : ''
612
+ if (name === '') throw new Error('名称不能为空')
613
+ if (/[\\/:*?"<>|]/.test(name) || name.startsWith('.') || name === '.' || name === '..') {
614
+ throw new Error('名称包含非法字符')
615
+ }
616
+ const originalExt = path.extname(resolved)
617
+ if (path.extname(name) === '' && originalExt !== '') name += originalExt
618
+ const destination = path.join(path.dirname(resolved), name)
619
+ if (destination === resolved) return { ok: true, action, path: resolved, name }
620
+ try {
621
+ await fs.access(destination)
622
+ throw new Error('同名文件已存在')
623
+ } catch (error) {
624
+ if (error && error.code !== 'ENOENT') throw error
625
+ }
626
+ await fs.rename(resolved, destination)
627
+ const renames = await loadRenames()
628
+ renames.set(resolved.toLowerCase(), destination)
629
+ await saveRenames()
630
+ invalidateCache()
631
+ return { ok: true, action, path: destination, name }
632
+ }
633
+
634
+ if (action === 'trash') {
635
+ if (!info.isFile()) throw new Error('只能移除文件')
636
+ await fs.mkdir(TRASH_DIR, { recursive: true })
637
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
638
+ const destination = path.join(TRASH_DIR, `${stamp}-${path.basename(resolved)}`)
639
+ try {
640
+ await fs.rename(resolved, destination)
641
+ } catch (error) {
642
+ if (error && error.code === 'EXDEV') {
643
+ await fs.copyFile(resolved, destination)
644
+ await fs.unlink(resolved)
645
+ } else {
646
+ throw error
647
+ }
648
+ }
649
+ const renames = await loadRenames()
650
+ renames.delete(resolved.toLowerCase())
651
+ for (const [from, to] of [...renames]) {
652
+ if (to.toLowerCase() === resolved.toLowerCase()) renames.delete(from)
653
+ }
654
+ await saveRenames()
655
+ invalidateCache()
656
+ return { ok: true, action, path: resolved, trash: destination }
657
+ }
658
+
659
+ throw new Error(`未知操作:${action || '(空)'}`)
660
+ }
661
+
662
+ function sendJson(res, status, value) {
663
+ const body = JSON.stringify(value)
664
+ res.writeHead(status, {
665
+ 'content-type': 'application/json; charset=utf-8',
666
+ 'cache-control': 'no-store',
667
+ })
668
+ res.end(body)
669
+ }
670
+
671
+ async function readJsonBody(req) {
672
+ let size = 0
673
+ const chunks = []
674
+ for await (const chunk of req) {
675
+ size += chunk.length
676
+ if (size > 256 * 1024) throw new Error('请求体过大')
677
+ chunks.push(chunk)
678
+ }
679
+ if (chunks.length === 0) return {}
680
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
681
+ }
682
+
683
+ async function serveRaw(req, res, url, ctx, config) {
684
+ const roots = rootsFor(ctx, config)
685
+ const { resolved } = await resolveWithinRoots(url.searchParams.get('path') ?? '', roots)
686
+ const info = await fs.stat(resolved)
687
+ if (!info.isFile()) throw new Error('不是普通文件')
688
+ const ext = path.extname(resolved).slice(1).toLowerCase()
689
+ const isPdf = ext === 'pdf'
690
+ const imageType = IMAGE_MIME[ext]
691
+ if (!isPdf && imageType === undefined) {
692
+ sendJson(res, 415, { ok: false, error: '该类型不支持内嵌预览' })
693
+ return
694
+ }
695
+ const mime = isPdf ? 'application/pdf' : imageType
696
+ let start = 0
697
+ let end = info.size - 1
698
+ const range = req.headers.range
699
+ if (typeof range === 'string') {
700
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim())
701
+ if (match) {
702
+ if (match[1] !== '') start = Number(match[1])
703
+ if (match[2] !== '') end = Number(match[2])
704
+ if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= info.size) {
705
+ res.writeHead(416, { 'content-range': `bytes */${info.size}` })
706
+ res.end()
707
+ return
708
+ }
709
+ end = Math.min(end, info.size - 1)
710
+ }
711
+ }
712
+ const headers = {
713
+ 'content-type': mime,
714
+ 'content-length': String(end - start + 1),
715
+ 'accept-ranges': 'bytes',
716
+ 'cache-control': 'no-store',
717
+ 'content-disposition': `inline; filename*=UTF-8''${encodeURIComponent(path.basename(resolved))}`,
718
+ 'x-content-type-options': 'nosniff',
719
+ }
720
+ if (isPdf) headers['content-security-policy'] = "sandbox; default-src 'none'; style-src 'unsafe-inline'"
721
+ res.writeHead(range === undefined ? 200 : 206, range === undefined ? headers : {
722
+ ...headers,
723
+ 'content-range': `bytes ${start}-${end}/${info.size}`,
724
+ })
725
+ createReadStream(resolved, { start, end }).pipe(res)
726
+ }
727
+
728
+ function createHandler(ctx, config) {
729
+ return async function handleDocumentsRequest(req, res) {
730
+ try {
731
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
732
+ if (url.pathname !== ROUTE_PREFIX && !url.pathname.startsWith(ROUTE_PREFIX + '/')) {
733
+ res.writeHead(404)
734
+ res.end('Not Found')
735
+ return
736
+ }
737
+ const route = url.pathname.slice(ROUTE_PREFIX.length)
738
+
739
+ if (req.method === 'GET' || req.method === 'HEAD') {
740
+ if (route === '/list') {
741
+ const result = await listDocuments(ctx, config)
742
+ sendJson(res, 200, { ok: true, ...result })
743
+ return
744
+ }
745
+ if (route === '/preview') {
746
+ const roots = rootsFor(ctx, config)
747
+ sendJson(res, 200, await readPreview(url.searchParams.get('path') ?? '', roots))
748
+ return
749
+ }
750
+ if (route === '/raw') {
751
+ await serveRaw(req, res, url, ctx, config)
752
+ return
753
+ }
754
+ sendJson(res, 404, { ok: false, error: '未知接口' })
755
+ return
756
+ }
757
+
758
+ if (req.method === 'POST' && route === '/action') {
759
+ if (req.headers['x-dsh-documents'] !== '1') {
760
+ sendJson(res, 403, { ok: false, error: 'forbidden' })
761
+ return
762
+ }
763
+ const body = await readJsonBody(req)
764
+ sendJson(res, 200, await runAction(ctx, config, body))
765
+ return
766
+ }
767
+
768
+ sendJson(res, 405, { ok: false, error: 'Method Not Allowed' })
769
+ } catch (error) {
770
+ sendJson(res, 400, { ok: false, error: messageOf(error) })
771
+ }
772
+ }
773
+ }
774
+
775
+ function formatBytes(bytes) {
776
+ if (!Number.isFinite(bytes) || bytes < 0) return '—'
777
+ if (bytes < 1024) return `${bytes} B`
778
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
779
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
780
+ }
781
+
782
+ function formatTime(ms) {
783
+ const date = new Date(ms)
784
+ if (Number.isNaN(date.getTime())) return '—'
785
+ const pad = (value) => String(value).padStart(2, '0')
786
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
787
+ }
788
+
789
+ function createTool(ctx, config) {
790
+ return {
791
+ name: 'documents',
792
+ description:
793
+ 'List, open, or reveal documents that a DSH session generated or edited. Every result carries the originating session and its edit events with event coordinates, so you can report or open where a document came from. Use action="list" to discover session-linked documents, action="open" to open one in its default desktop application, and action="reveal" to show its folder. Paths must be absolute and inside a DSH workspace.',
794
+ parameters: {
795
+ type: 'object',
796
+ additionalProperties: false,
797
+ properties: {
798
+ action: {
799
+ type: 'string',
800
+ enum: ['list', 'open', 'reveal'],
801
+ description: 'Operation to perform.',
802
+ },
803
+ path: {
804
+ type: 'string',
805
+ description: 'Absolute document path for open or reveal.',
806
+ },
807
+ query: {
808
+ type: 'string',
809
+ description: 'Optional case-insensitive file name, folder, or session title filter for list.',
810
+ },
811
+ kind: {
812
+ type: 'string',
813
+ enum: KINDS.map((kind) => kind.id),
814
+ description: 'Optional document kind filter for list.',
815
+ },
816
+ },
817
+ required: ['action'],
818
+ },
819
+ output: {
820
+ schema: {
821
+ type: 'object',
822
+ additionalProperties: true,
823
+ properties: {
824
+ ok: { type: 'boolean' },
825
+ action: { type: 'string' },
826
+ error: { type: 'string' },
827
+ total: { type: 'number' },
828
+ path: { type: 'string' },
829
+ documents: {
830
+ type: 'array',
831
+ items: {
832
+ type: 'object',
833
+ additionalProperties: true,
834
+ properties: {
835
+ name: { type: 'string' },
836
+ path: { type: 'string' },
837
+ kind: { type: 'string' },
838
+ bytes: { type: 'number' },
839
+ mtime: { type: 'number' },
840
+ origin: { type: 'object' },
841
+ sessions: { type: 'array' },
842
+ },
843
+ },
844
+ },
845
+ },
846
+ },
847
+ render(_args, value) {
848
+ if (!value || value.ok !== true) {
849
+ return [{ type: 'text', text: `documents failed: ${value?.error ?? 'unknown error'}` }]
850
+ }
851
+ if (Array.isArray(value.documents)) {
852
+ const lines = value.documents.slice(0, 50).map((doc) => {
853
+ const origin = doc.origin ? `origin: ${doc.origin.title}` : 'origin: unknown'
854
+ const sessions = Array.isArray(doc.sessions) ? doc.sessions.length : 0
855
+ return `- ${doc.name} [${doc.kind}] ${formatBytes(doc.bytes)} · ${origin} · ${sessions} session(s) — ${doc.path}`
856
+ })
857
+ if (value.documents.length > 50) lines.push(`… and ${value.documents.length - 50} more`)
858
+ return [{
859
+ type: 'text',
860
+ text: lines.length === 0
861
+ ? 'No session-generated documents found in the DSH workspaces.'
862
+ : `${value.total ?? value.documents.length} document(s):\n${lines.join('\n')}`,
863
+ }]
864
+ }
865
+ return [{ type: 'text', text: `documents ${value.action ?? ''} ${value.path ?? ''}`.trim() }]
866
+ },
867
+ },
868
+ async execute(args, exec) {
869
+ const input = args && typeof args === 'object' ? args : {}
870
+ const action = typeof input.action === 'string' ? input.action : 'list'
871
+ if (action === 'list') {
872
+ const result = await listDocuments(ctx, config)
873
+ const query = typeof input.query === 'string' ? input.query.trim().toLowerCase() : ''
874
+ const kind = typeof input.kind === 'string' ? input.kind : ''
875
+ const documents = result.documents.filter((doc) => {
876
+ if (kind !== '' && doc.kind !== kind) return false
877
+ if (query === '') return true
878
+ const titles = doc.sessions.map((session) => session.title).join(' ').toLowerCase()
879
+ return doc.name.toLowerCase().includes(query) || doc.rel.toLowerCase().includes(query) || titles.includes(query)
880
+ })
881
+ return {
882
+ ok: true,
883
+ action,
884
+ total: documents.length,
885
+ truncated: result.truncated,
886
+ documents: documents.map((doc) => ({
887
+ name: doc.name,
888
+ path: doc.path,
889
+ kind: doc.kind,
890
+ bytes: doc.bytes,
891
+ mtime: doc.mtime,
892
+ origin: doc.origin,
893
+ sessions: doc.sessions.map((session) => ({
894
+ id: session.id,
895
+ title: session.title,
896
+ role: session.role,
897
+ events: session.events,
898
+ })),
899
+ })),
900
+ }
901
+ }
902
+ exec.signal.throwIfAborted()
903
+ const result = await runAction(ctx, config, { action, path: input.path })
904
+ return result
905
+ },
906
+ }
907
+ }
908
+
909
+ export const inject = ['tools']
910
+
911
+ export function apply(ctx, config) {
912
+ ctx.effect(() => {
913
+ invalidateCache()
914
+ return ctx.tools.register(createTool(ctx, config))
915
+ }, 'documents: agent tool')
916
+
917
+ ctx.inject(['webServer'], (child) => {
918
+ child.effect(
919
+ () => child.webServer.register({
920
+ kind: 'prefix',
921
+ path: ROUTE_PREFIX,
922
+ handler: createHandler(ctx, config),
923
+ }),
924
+ 'documents: local api',
925
+ )
926
+ })
927
+ }