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/LICENSE +21 -0
- package/README.md +143 -2
- package/client.js +1353 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +15 -0
- package/index.js +927 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
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
|
+
}
|