dsh-synapse 0.4.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,802 @@
1
+ import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { dirname } from 'node:path'
4
+
5
+ export const name = 'synapse'
6
+ export const inject = ['webServer', 'sessions']
7
+
8
+ const MAX_BODY_BYTES = 32 * 1024
9
+ const MAX_TITLE_LENGTH = 120
10
+ const MAX_NOTE_LENGTH = 4_000
11
+ // Projected message text cap: longer replies truncate with a marker pointing
12
+ // at the detail view instead of silently cutting mid-sentence.
13
+ const MAX_PROJECTION_LENGTH = 8_000
14
+ const PROJECTION_TRUNCATED_SUFFIX = '\n——…(详情查看全文)'
15
+ const TOPIC_COLORS = ['#0f766e', '#2563eb', '#be123c', '#7c3aed', '#b45309']
16
+ const LOCK_STALE_MS = 60_000
17
+ // Deferred (event-projection) writes coalesce into one save per window, so a
18
+ // burst of session events costs a single full-state write instead of one per
19
+ // event (issue #13: per-event saves pinned the main thread at ~90% CPU).
20
+ const SAVE_DEBOUNCE_MS = 800
21
+
22
+ /** JSON persistence for the Synapse workspace graph. */
23
+ export class WorkspaceStore {
24
+ constructor(dataFile) {
25
+ if (typeof dataFile !== 'string' || dataFile.length === 0) throw new Error('synapse: config.dataFile must be a non-empty path')
26
+ this.dataFile = dataFile
27
+ this.state = undefined
28
+ this.serial = Promise.resolve()
29
+ this.ready = this.load()
30
+ this.lastKnownMtime = null
31
+ this.externalModWarned = false
32
+ this.lockWarned = false
33
+ this.dirty = false
34
+ this.flushTimer = null
35
+ }
36
+
37
+ async list() {
38
+ await this.ready
39
+ return this.state.workspaces.map(workspace => this.summary(workspace))
40
+ }
41
+
42
+ async get(workspaceId) {
43
+ await this.ready
44
+ const workspace = this.workspace(workspaceId)
45
+ return structuredClone(workspace)
46
+ }
47
+
48
+ async create(title) {
49
+ return this.mutate(() => {
50
+ const now = new Date().toISOString()
51
+ const workspace = { id: randomUUID(), title: requiredText(title, MAX_TITLE_LENGTH, 'title'), createdAt: now, updatedAt: now, threads: [] }
52
+ this.state.workspaces.unshift(workspace)
53
+ return this.summary(workspace)
54
+ })
55
+ }
56
+
57
+ async createThread(workspaceId, input) {
58
+ return this.mutate(() => {
59
+ const workspace = this.workspace(workspaceId)
60
+ const now = new Date().toISOString()
61
+ const thread = this.thread({
62
+ title: input?.title,
63
+ parentId: input?.parentId,
64
+ dshSessionId: input?.dshSessionId,
65
+ dshSessionTitle: input?.dshSessionTitle,
66
+ position: input?.position,
67
+ color: input?.color,
68
+ now,
69
+ order: workspace.threads.length,
70
+ })
71
+ if (thread.parentId !== null && !workspace.threads.some(item => item.id === thread.parentId)) throw new InputError('分支来源不存在')
72
+ workspace.threads.push(thread)
73
+ workspace.updatedAt = now
74
+ return structuredClone(thread)
75
+ })
76
+ }
77
+
78
+ async branch(threadId, input) {
79
+ return this.mutate(() => {
80
+ const { workspace, thread: parent } = this.locateThread(threadId)
81
+ const now = new Date().toISOString()
82
+ const sessionId = typeof input?.dshSessionId === 'string' && input.dshSessionId.length > 0 ? input.dshSessionId : null
83
+ // A DSH fork emits session/created while the browser receives its fork
84
+ // response. Either path may win the race, but both must resolve to one node.
85
+ if (sessionId !== null) {
86
+ const existing = workspace.threads.find(item => item.dshSessionId === sessionId)
87
+ if (existing !== undefined) {
88
+ existing.parentId ??= parent.id
89
+ if (typeof input?.title === 'string' && input.title.trim() !== '') existing.title = requiredText(input.title, MAX_TITLE_LENGTH, 'title')
90
+ if (typeof input?.dshSessionTitle === 'string') existing.dshSessionTitle = input.dshSessionTitle.slice(0, MAX_TITLE_LENGTH)
91
+ existing.updatedAt = now
92
+ workspace.updatedAt = now
93
+ return structuredClone(existing)
94
+ }
95
+ }
96
+ const siblings = workspace.threads.filter(item => item.parentId === parent.id)
97
+ const thread = this.thread({
98
+ title: input?.title,
99
+ parentId: parent.id,
100
+ dshSessionId: input?.dshSessionId,
101
+ dshSessionTitle: input?.dshSessionTitle,
102
+ position: input?.position ?? { x: parent.position.x + 420, y: parent.position.y + siblings.length * 248 },
103
+ color: input?.color ?? parent.color,
104
+ now,
105
+ order: workspace.threads.length,
106
+ })
107
+ workspace.threads.push(thread)
108
+ workspace.updatedAt = now
109
+ return structuredClone(thread)
110
+ })
111
+ }
112
+
113
+ /** Keep only the canvas graph in Synapse; DSH remains the source of session truth. */
114
+ async syncSessions(sessions, removedSessionIds = []) {
115
+ return this.mutate(() => {
116
+ if (!Array.isArray(sessions)) throw new InputError('sessions 必须是数组')
117
+ if (!Array.isArray(removedSessionIds) || removedSessionIds.some(item => typeof item !== 'string')) throw new InputError('removedSessionIds 必须是字符串数组')
118
+ const blankIds = new Set(sessions.filter(item => item?.blank === true && typeof item.id === 'string').map(item => item.id))
119
+ const removedIds = new Set(removedSessionIds)
120
+ for (const workspace of this.state.workspaces) {
121
+ if (workspace.kind !== 'dsh') continue
122
+ workspace.threads = workspace.threads.filter(thread => !blankIds.has(thread.dshSessionId) && !removedIds.has(thread.dshSessionId))
123
+ }
124
+ this.state.workspaces = this.state.workspaces.filter(workspace => workspace.kind !== 'dsh' || workspace.threads.length > 0)
125
+ for (const item of sessions) {
126
+ if (typeof item?.id !== 'string' || item.id === '' || typeof item.cwd !== 'string' || item.cwd === '') continue
127
+ if (item.blank === true) continue
128
+ // Canvas archiving is persistent UI state. A normal DSH list refresh
129
+ // must not recreate a session that the user deliberately archived.
130
+ if (this.state.hiddenSessionIds.includes(item.id)) continue
131
+ const workspace = this.dshWorkspace(item.cwd, 'DSH 任务')
132
+ const session = { id: item.id, header: { meta: { cwd: item.cwd }, parentSession: typeof item.parentId === 'string' ? item.parentId : undefined }, title: typeof item.title === 'string' ? item.title : undefined, events: [] }
133
+ const thread = this.dshThread(workspace, session)
134
+ if (typeof item.title === 'string' && item.title.trim() !== '') {
135
+ thread.title = item.title.slice(0, MAX_TITLE_LENGTH)
136
+ thread.dshSessionTitle = thread.title
137
+ }
138
+ }
139
+ return this.list()
140
+ }, { deferred: true })
141
+ }
142
+
143
+ async addMessage(threadId, text) {
144
+ return this.mutate(() => {
145
+ const { workspace, thread } = this.locateThread(threadId)
146
+ const at = new Date().toISOString()
147
+ const message = { id: randomUUID(), text: requiredText(text, MAX_NOTE_LENGTH, 'text'), kind: 'user', at }
148
+ thread.messages.push(message)
149
+ thread.updatedAt = at
150
+ workspace.updatedAt = at
151
+ return structuredClone(thread)
152
+ })
153
+ }
154
+
155
+ async updateThread(threadId, input) {
156
+ return this.mutate(() => {
157
+ const { workspace, thread } = this.locateThread(threadId)
158
+ if (input?.title !== undefined) thread.title = requiredText(input.title, MAX_TITLE_LENGTH, 'title')
159
+ if (input?.position !== undefined) thread.position = positionOf(input.position)
160
+ thread.updatedAt = new Date().toISOString()
161
+ workspace.updatedAt = thread.updatedAt
162
+ return structuredClone(thread)
163
+ })
164
+ }
165
+
166
+ async removeThread(threadId) {
167
+ return this.mutate(() => {
168
+ const { workspace, thread } = this.locateThread(threadId)
169
+ const removal = new Set([thread.id])
170
+ for (let changed = true; changed;) {
171
+ changed = false
172
+ for (const item of workspace.threads) {
173
+ if (item.parentId !== null && removal.has(item.parentId) && !removal.has(item.id)) { removal.add(item.id); changed = true }
174
+ }
175
+ }
176
+ for (const item of workspace.threads) {
177
+ if (removal.has(item.id) && item.dshSessionId !== null && !this.state.hiddenSessionIds.includes(item.dshSessionId)) this.state.hiddenSessionIds.push(item.dshSessionId)
178
+ }
179
+ workspace.threads = workspace.threads.filter(item => !removal.has(item.id))
180
+ workspace.updatedAt = new Date().toISOString()
181
+ if (workspace.threads.length === 0) this.state.workspaces = this.state.workspaces.filter(item => item.id !== workspace.id)
182
+ return { removed: removal.size }
183
+ })
184
+ }
185
+
186
+ async clearLegacy(sessions) {
187
+ return this.mutate(() => {
188
+ const hidden = new Set(this.state.hiddenSessionIds)
189
+ for (const workspace of this.state.workspaces) for (const thread of workspace.threads) if (thread.dshSessionId !== null) hidden.add(thread.dshSessionId)
190
+ for (const session of sessions) hidden.add(session.id)
191
+ this.state.hiddenSessionIds = [...hidden]
192
+ this.state.workspaces = []
193
+ return { cleared: true }
194
+ })
195
+ }
196
+
197
+ /** Replay one live DSH session into the dedicated projection workspace. */
198
+ async projectSession(session, replayFrom = 0, workspaceTitle = 'DSH 任务') {
199
+ return this.mutate(() => {
200
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
201
+ const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
202
+ const thread = this.dshThread(workspace, session)
203
+ for (const event of session.events) {
204
+ if (event.seq >= replayFrom) this.projectEventInto(workspace, thread, event)
205
+ }
206
+ return structuredClone(thread)
207
+ }, { deferred: true })
208
+ }
209
+
210
+ /** Project one committed DSH session event. Repeated sequence numbers are ignored. */
211
+ async projectEvent(session, event, workspaceTitle = 'DSH 任务') {
212
+ return this.mutate(() => {
213
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
214
+ const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
215
+ const thread = this.dshThread(workspace, session)
216
+ this.projectEventInto(workspace, thread, event)
217
+ return structuredClone(thread)
218
+ }, { deferred: true })
219
+ }
220
+
221
+ /** Project a batch of committed events for one session in a single write. */
222
+ async projectEvents(session, events, workspaceTitle = 'DSH 任务') {
223
+ if (events.length === 0) return null
224
+ return this.mutate(() => {
225
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
226
+ const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
227
+ const thread = this.dshThread(workspace, session)
228
+ for (const event of events) this.projectEventInto(workspace, thread, event)
229
+ return structuredClone(thread)
230
+ }, { deferred: true })
231
+ }
232
+
233
+ async load() {
234
+ await mkdir(dirname(this.dataFile), { recursive: true })
235
+ try {
236
+ const parsed = JSON.parse(await readFile(this.dataFile, 'utf8'))
237
+ const { state, migrated } = normalizeState(parsed)
238
+ this.state = state
239
+ if (migrated) await this.save()
240
+ } catch (error) {
241
+ if (error?.code !== 'ENOENT') throw new Error(`synapse: cannot read ${this.dataFile}: ${error.message}`)
242
+ this.state = { version: 4, hiddenSessionIds: [], workspaces: [] }
243
+ await this.save()
244
+ }
245
+ }
246
+
247
+ async mutate(action, { deferred = false } = {}) {
248
+ await this.ready
249
+ const task = this.serial.then(async () => {
250
+ const result = action()
251
+ if (deferred) this.markDirty()
252
+ else await this.save()
253
+ return result
254
+ })
255
+ this.serial = task.catch(() => undefined)
256
+ return task
257
+ }
258
+
259
+ /** Mark the state dirty and schedule one trailing flush for the window. */
260
+ markDirty() {
261
+ this.dirty = true
262
+ if (this.flushTimer !== null) return
263
+ this.flushTimer = setTimeout(() => {
264
+ this.flushTimer = null
265
+ void this.flush()
266
+ }, SAVE_DEBOUNCE_MS)
267
+ }
268
+
269
+ /** Persist the current state when dirty, ordered after in-flight mutations. */
270
+ flush() {
271
+ if (!this.dirty) return Promise.resolve()
272
+ this.dirty = false
273
+ const task = this.serial.then(() => this.save())
274
+ this.serial = task.catch(() => undefined)
275
+ return task
276
+ }
277
+
278
+ async save() {
279
+ // Two dsh web instances sharing one profile clobber each other's canvas
280
+ // state. Warn loudly instead of silently losing work; a live lock held by
281
+ // another process or a file mtime that moved since our last write both
282
+ // indicate a second writer.
283
+ const before = await this.fileMtime()
284
+ if (this.lastKnownMtime !== null && before !== null && before !== this.lastKnownMtime) {
285
+ this.lastKnownMtime = before
286
+ if (!this.externalModWarned) {
287
+ this.externalModWarned = true
288
+ process.stderr.write('synapse: workspaces.json 已被另一个 dsh web 实例修改,本实例的写入可能覆盖其更改——请只运行一个实例\n')
289
+ }
290
+ }
291
+ await this.acquireLock()
292
+ try {
293
+ const temporaryFile = `${this.dataFile}.${process.pid}.tmp`
294
+ await writeFile(temporaryFile, `${JSON.stringify(this.state)}\n`, 'utf8')
295
+ await rename(temporaryFile, this.dataFile)
296
+ this.lastKnownMtime = (await stat(this.dataFile)).mtimeMs
297
+ } finally {
298
+ await this.releaseLock()
299
+ }
300
+ }
301
+
302
+ async fileMtime() {
303
+ try { return (await stat(this.dataFile)).mtimeMs } catch { return null }
304
+ }
305
+
306
+ /** Take an exclusive cross-process lock, breaking a stale one; warn when a live process holds it. */
307
+ async acquireLock() {
308
+ const lockFile = `${this.dataFile}.lock`
309
+ if (await this.tryAcquire(lockFile)) return
310
+ if (await this.lockIsStale(lockFile)) {
311
+ await unlink(lockFile).catch(() => {})
312
+ if (await this.tryAcquire(lockFile)) return
313
+ }
314
+ if (!this.lockWarned) {
315
+ this.lockWarned = true
316
+ process.stderr.write('synapse: 另一个 dsh web 实例正在写入 workspaces.json——请只运行一个实例,否则画布数据可能互相覆盖\n')
317
+ }
318
+ }
319
+
320
+ async tryAcquire(lockFile) {
321
+ try {
322
+ await writeFile(lockFile, `${process.pid}\n`, { flag: 'wx' })
323
+ return true
324
+ } catch {
325
+ return false
326
+ }
327
+ }
328
+
329
+ /** A lock is stale when its owner PID is gone or the lock file is older than the stale window. */
330
+ async lockIsStale(lockFile) {
331
+ try {
332
+ const [content, stats] = await Promise.all([readFile(lockFile, 'utf8'), stat(lockFile)])
333
+ const tooOld = Date.now() - stats.mtimeMs > LOCK_STALE_MS
334
+ const pid = Number.parseInt(content, 10)
335
+ if (!Number.isInteger(pid)) return tooOld
336
+ if (pid === process.pid) return false
337
+ try {
338
+ process.kill(pid, 0)
339
+ return tooOld
340
+ } catch {
341
+ return true
342
+ }
343
+ } catch {
344
+ return false
345
+ }
346
+ }
347
+
348
+ async releaseLock() {
349
+ await unlink(`${this.dataFile}.lock`).catch(() => {})
350
+ }
351
+
352
+ workspace(workspaceId) {
353
+ const workspace = this.state.workspaces.find(item => item.id === workspaceId)
354
+ if (workspace === undefined) throw new NotFoundError('工作空间不存在')
355
+ return workspace
356
+ }
357
+
358
+ locateThread(threadId) {
359
+ for (const workspace of this.state.workspaces) {
360
+ const thread = workspace.threads.find(item => item.id === threadId)
361
+ if (thread !== undefined) return { workspace, thread }
362
+ }
363
+ throw new NotFoundError('节点不存在')
364
+ }
365
+
366
+ dshWorkspace(cwd, fallbackTitle) {
367
+ let workspace = this.state.workspaces.find(item => item.kind === 'dsh' && item.cwd === cwd)
368
+ if (workspace !== undefined) return workspace
369
+ const now = new Date().toISOString()
370
+ workspace = { id: randomUUID(), kind: 'dsh', cwd, title: workspaceTitle(cwd, fallbackTitle), createdAt: now, updatedAt: now, threads: [] }
371
+ this.state.workspaces.unshift(workspace)
372
+ return workspace
373
+ }
374
+
375
+ dshThread(workspace, session) {
376
+ let thread = workspace.threads.find(item => item.dshSessionId === session.id)
377
+ if (thread !== undefined) {
378
+ if (typeof session.title === 'string' && session.title.trim() !== '') {
379
+ const title = session.title.slice(0, MAX_TITLE_LENGTH)
380
+ thread.title = title
381
+ thread.dshSessionTitle = title
382
+ }
383
+ // `seedLength` is DSH's durable fork cut. Keep it even after the
384
+ // session has been restored, when its in-process `firstLiveSeq` moves.
385
+ const seedLength = session.header?.seedLength
386
+ if (Number.isSafeInteger(seedLength) && seedLength >= 0) thread.sourceSeedLength = seedLength
387
+ return thread
388
+ }
389
+ const parentSessionId = typeof session.header?.parentSession === 'string' ? session.header.parentSession : null
390
+ const parent = parentSessionId === null ? undefined : workspace.threads.find(item => item.dshSessionId === parentSessionId)
391
+ const siblings = workspace.threads.filter(item => item.sourceParentSessionId === parentSessionId)
392
+ const now = new Date().toISOString()
393
+ thread = {
394
+ id: randomUUID(),
395
+ title: typeof session.title === 'string' && session.title.trim() !== '' ? session.title.slice(0, MAX_TITLE_LENGTH) : (parent === undefined ? 'DSH 会话' : `${parent.title} 分支`),
396
+ parentId: parent?.id ?? null,
397
+ sourceParentSessionId: parentSessionId,
398
+ sourceSeedLength: Number.isSafeInteger(session.header?.seedLength) && session.header.seedLength >= 0 ? session.header.seedLength : null,
399
+ dshSessionId: session.id,
400
+ dshSessionTitle: typeof session.title === 'string' ? session.title.slice(0, MAX_TITLE_LENGTH) : null,
401
+ color: TOPIC_COLORS[workspace.threads.length % TOPIC_COLORS.length],
402
+ // DSH projection stores only a neutral semantic anchor. The visual map
403
+ // lays out visible cards from the current conversation graph each render,
404
+ // so old/archived session counts must never leak into future coordinates.
405
+ position: parent === undefined ? { x: 86, y: 82 } : { x: parent.position.x + 400, y: parent.position.y },
406
+ createdAt: now,
407
+ updatedAt: now,
408
+ messages: [],
409
+ }
410
+ workspace.threads.push(thread)
411
+ // A child may arrive before its parent during startup replay. Repair that
412
+ // relation when the missing parent later reaches the projection.
413
+ for (const child of workspace.threads) {
414
+ if (child.sourceParentSessionId === session.id && child.parentId === null) child.parentId = thread.id
415
+ }
416
+ workspace.updatedAt = now
417
+ return thread
418
+ }
419
+
420
+ projectEventInto(workspace, thread, event) {
421
+ if (event.type === 'session/title' && typeof event.data?.title === 'string') {
422
+ thread.title = event.data.title.slice(0, MAX_TITLE_LENGTH)
423
+ thread.dshSessionTitle = thread.title
424
+ thread.updatedAt = new Date(event.time).toISOString()
425
+ workspace.updatedAt = thread.updatedAt
426
+ return
427
+ }
428
+ if (event.type === 'tool/call' || event.type === 'tool/result') {
429
+ this.foldToolProcess(thread, event)
430
+ workspace.updatedAt = thread.updatedAt
431
+ return
432
+ }
433
+ const projection = projectableEvent(event)
434
+ if (projection === null || thread.messages.some(message => message.sourceSeq === event.seq)) return
435
+ const at = new Date(event.time).toISOString()
436
+ const message = {
437
+ id: randomUUID(),
438
+ text: projection.text,
439
+ kind: projection.kind,
440
+ sourceSeq: event.seq,
441
+ at,
442
+ ...(projection.kind === 'assistant'
443
+ ? { turn: event.data.turn, step: event.data.step, process: [] }
444
+ : {}),
445
+ }
446
+ thread.messages.push(message)
447
+ thread.updatedAt = at
448
+ workspace.updatedAt = at
449
+ if (thread.dshSessionTitle === null && projection.kind === 'user') {
450
+ thread.title = titleFromText(projection.text)
451
+ thread.dshSessionTitle = thread.title
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Fold one tool call or result into the assistant message of its own
457
+ * turn/step, keyed by `callId`, so a tool invocation never becomes a
458
+ * separate canvas card. A legacy assistant message without `turn`/`step` is
459
+ * the fallback target; an event with no such target is dropped.
460
+ */
461
+ foldToolProcess(thread, event) {
462
+ const at = new Date(event.time).toISOString()
463
+ const target = [...thread.messages].reverse().find(message =>
464
+ message.kind === 'assistant'
465
+ && (message.turn === event.data.turn && message.step === event.data.step
466
+ || message.turn === undefined && message.step === undefined))
467
+ if (target === undefined) return
468
+ const process = target.process ??= []
469
+ const callId = String(event.type === 'tool/call' ? event.data.callId : event.data.message?.source?.callId ?? '')
470
+ const entry = process.find(item => item.callId === callId)
471
+ if (event.type === 'tool/call') {
472
+ if (entry === undefined) {
473
+ process.push({ callId, name: event.data.name, arguments: event.data.arguments, result: null, error: null })
474
+ } else {
475
+ entry.name = event.data.name
476
+ entry.arguments = event.data.arguments
477
+ }
478
+ } else {
479
+ const outcome = contentText(event.data.message?.content)
480
+ const error = event.data.error === undefined ? null : `${event.data.error.name}: ${event.data.error.code}`
481
+ if (entry === undefined) {
482
+ process.push({ callId, name: '工具调用', arguments: null, result: outcome, error })
483
+ } else {
484
+ entry.result = outcome
485
+ entry.error = error
486
+ }
487
+ }
488
+ thread.updatedAt = at
489
+ }
490
+
491
+ thread({ title, parentId, dshSessionId, dshSessionTitle, position, color, now, order }) {
492
+ return {
493
+ id: randomUUID(),
494
+ title: requiredText(title, MAX_TITLE_LENGTH, 'title'),
495
+ parentId: typeof parentId === 'string' && parentId.length > 0 ? parentId : null,
496
+ dshSessionId: typeof dshSessionId === 'string' && dshSessionId.length > 0 ? dshSessionId : null,
497
+ dshSessionTitle: typeof dshSessionTitle === 'string' ? dshSessionTitle.slice(0, MAX_TITLE_LENGTH) : null,
498
+ color: TOPIC_COLORS.includes(color) ? color : TOPIC_COLORS[order % TOPIC_COLORS.length],
499
+ position: positionOf(position ?? { x: 86 + (order % 3) * 410, y: 82 + Math.floor(order / 3) * 260 }),
500
+ createdAt: now,
501
+ updatedAt: now,
502
+ messages: [],
503
+ }
504
+ }
505
+
506
+ summary(workspace) {
507
+ return { id: workspace.id, kind: workspace.kind ?? 'manual', cwd: workspace.cwd ?? null, title: workspace.title, createdAt: workspace.createdAt, updatedAt: workspace.updatedAt, threadCount: workspace.threads.length }
508
+ }
509
+ }
510
+
511
+ class InputError extends Error {}
512
+ class NotFoundError extends Error {}
513
+
514
+ function normalizeState(value) {
515
+ let migrated = false
516
+ let state
517
+ if ((value?.version === 2 || value?.version === 3 || value?.version === 4) && Array.isArray(value.workspaces)) {
518
+ const hiddenSessionIds = Array.isArray(value.hiddenSessionIds) ? value.hiddenSessionIds.filter(item => typeof item === 'string') : []
519
+ migrated = value.version < 3 || !Array.isArray(value.hiddenSessionIds)
520
+ const workspaces = value.workspaces.map(workspace => ({
521
+ ...workspace,
522
+ threads: Array.isArray(workspace.threads) ? workspace.threads.map(thread => {
523
+ if (Array.isArray(thread.messages)) {
524
+ const messages = thread.messages.filter(message => !isRuntimeContextMessage(message))
525
+ if (messages.length !== thread.messages.length) migrated = true
526
+ return { ...thread, messages }
527
+ }
528
+ migrated = true
529
+ const notes = Array.isArray(thread.notes) ? thread.notes : []
530
+ const { notes: _notes, ...rest } = thread
531
+ return { ...rest, messages: notes }
532
+ }) : [],
533
+ }))
534
+ state = { ...value, version: value.version, hiddenSessionIds, workspaces }
535
+ } else if (value?.version === 1 && Array.isArray(value.workspaces)) {
536
+ const now = typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString()
537
+ state = {
538
+ version: 3,
539
+ hiddenSessionIds: [],
540
+ workspaces: value.workspaces.map((workspace, index) => {
541
+ const events = Array.isArray(workspace.events) ? workspace.events : []
542
+ const workspaceNow = typeof workspace.updatedAt === 'string' ? workspace.updatedAt : now
543
+ return {
544
+ id: typeof workspace.id === 'string' ? workspace.id : randomUUID(),
545
+ title: typeof workspace.title === 'string' && workspace.title.trim() ? workspace.title : '未命名工作空间',
546
+ createdAt: typeof workspace.createdAt === 'string' ? workspace.createdAt : workspaceNow,
547
+ updatedAt: workspaceNow,
548
+ threads: events.length === 0 ? [] : [{
549
+ id: randomUUID(), title: workspace.title || '历史记录', parentId: null, dshSessionId: null, dshSessionTitle: null,
550
+ color: TOPIC_COLORS[index % TOPIC_COLORS.length], position: { x: 86, y: 82 }, createdAt: workspaceNow, updatedAt: workspaceNow,
551
+ messages: events.map(event => ({ id: typeof event.id === 'string' ? event.id : randomUUID(), text: String(event.text ?? ''), at: typeof event.at === 'string' ? event.at : workspaceNow })),
552
+ }],
553
+ }
554
+ }),
555
+ }
556
+ migrated = true
557
+ } else {
558
+ throw new Error('expected Synapse data version 1, 2, 3, or 4')
559
+ }
560
+ if (state.version !== 4) {
561
+ if (foldLegacyToolCards(state.workspaces)) migrated = true
562
+ state.version = 4
563
+ migrated = true
564
+ }
565
+ return { state, migrated }
566
+ }
567
+
568
+ /**
569
+ * Fold v3-era standalone tool cards (kinds `tool` / `tool-result`) into the
570
+ * preceding assistant message's `process` list, pairing each call with the
571
+ * result that follows it in order, so every tool invocation lives in one
572
+ * home: the assistant turn card.
573
+ */
574
+ function foldLegacyToolCards(workspaces) {
575
+ let changed = false
576
+ for (const workspace of workspaces) {
577
+ for (const thread of workspace.threads ?? []) {
578
+ if (!Array.isArray(thread.messages)) continue
579
+ const folded = []
580
+ let assistant = null
581
+ let pending = []
582
+ for (const message of thread.messages) {
583
+ if (message.kind === 'assistant') {
584
+ assistant = message
585
+ assistant.process ??= []
586
+ pending = []
587
+ folded.push(message)
588
+ continue
589
+ }
590
+ if (message.kind !== 'tool' && message.kind !== 'tool-result') {
591
+ folded.push(message)
592
+ continue
593
+ }
594
+ if (assistant === null) {
595
+ folded.push(message)
596
+ continue
597
+ }
598
+ changed = true
599
+ if (message.kind === 'tool') {
600
+ const [name = '工具调用', ...argumentLines] = message.text.split('\n')
601
+ const entry = { callId: `legacy-${assistant.process.length}`, name, arguments: argumentLines.join('\n'), result: null, error: null }
602
+ pending.push(entry)
603
+ assistant.process.push(entry)
604
+ } else {
605
+ const entry = pending.shift() ?? (() => {
606
+ const orphan = { callId: `legacy-orphan-${assistant.process.length}`, name: '工具调用', arguments: null, result: null, error: null }
607
+ assistant.process.push(orphan)
608
+ return orphan
609
+ })()
610
+ entry.result = message.text
611
+ }
612
+ }
613
+ thread.messages = folded
614
+ }
615
+ }
616
+ return changed
617
+ }
618
+
619
+ function positionOf(value) {
620
+ const x = Number(value?.x)
621
+ const y = Number(value?.y)
622
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new InputError('position 必须包含有效坐标')
623
+ return { x: Math.round(Math.max(-2000, Math.min(5000, x))), y: Math.round(Math.max(-2000, Math.min(5000, y))) }
624
+ }
625
+
626
+ function requiredText(value, maxLength, field) {
627
+ if (typeof value !== 'string') throw new InputError(`${field} 必须是文本`)
628
+ const text = value.trim()
629
+ if (text.length === 0) throw new InputError(`${field} 不能为空`)
630
+ if (text.length > maxLength) throw new InputError(`${field} 超过长度限制`)
631
+ return text
632
+ }
633
+
634
+ function projectableEvent(event) {
635
+ switch (event.type) {
636
+ case 'user/message': {
637
+ const text = contentText(event.data.content)
638
+ return isRuntimeContextText(text) ? null : noteProjection('user', text)
639
+ }
640
+ case 'assistant/message':
641
+ return noteProjection('assistant', contentText(event.data.message.content))
642
+ case 'todo/write':
643
+ return noteProjection('todo', event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n'))
644
+ case 'turn/end':
645
+ return event.data.reason.kind === 'error' ? noteProjection('error', event.data.reason.error.message) : null
646
+ default:
647
+ return null
648
+ }
649
+ }
650
+
651
+ function noteProjection(kind, text) {
652
+ const normalized = text.trim()
653
+ if (normalized === '') return null
654
+ if (normalized.length <= MAX_PROJECTION_LENGTH) return { kind, text: normalized }
655
+ return { kind, text: `${normalized.slice(0, MAX_PROJECTION_LENGTH)}${PROJECTION_TRUNCATED_SUFFIX}` }
656
+ }
657
+
658
+ function isRuntimeContextText(text) {
659
+ return typeof text === 'string' && text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')
660
+ }
661
+
662
+ function isRuntimeContextMessage(message) {
663
+ return message?.kind === 'user' && isRuntimeContextText(message.text)
664
+ }
665
+
666
+ function contentText(content) {
667
+ if (!Array.isArray(content)) return ''
668
+ return content.flatMap(block => {
669
+ if (block?.type === 'text') return [block.text]
670
+ if (block?.type === 'tool-call') return [block.name, block.arguments]
671
+ if (block?.type === 'tool-result') return contentText(block.content)
672
+ return []
673
+ }).filter(value => typeof value === 'string' && value.trim() !== '').join('\n')
674
+ }
675
+
676
+ function titleFromText(text) {
677
+ const line = text.replaceAll(/\s+/g, ' ').trim()
678
+ return (line.length > 42 ? `${line.slice(0, 42)}...` : line) || 'DSH 会话'
679
+ }
680
+
681
+ function sessionCwd(session) {
682
+ const cwd = session.header?.meta?.cwd ?? session.header?.cwd
683
+ return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : '未指定工作目录'
684
+ }
685
+
686
+ function workspaceTitle(cwd, fallbackTitle) {
687
+ if (cwd === '未指定工作目录') return fallbackTitle
688
+ const segment = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1)
689
+ return segment && segment.trim() !== '' ? segment : fallbackTitle
690
+ }
691
+
692
+ async function readJson(req) {
693
+ const chunks = []
694
+ let length = 0
695
+ for await (const chunk of req) {
696
+ length += chunk.length
697
+ if (length > MAX_BODY_BYTES) throw new InputError('请求内容过大')
698
+ chunks.push(chunk)
699
+ }
700
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new InputError('请求不是有效 JSON') }
701
+ }
702
+
703
+ function sendJson(res, status, body) {
704
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
705
+ res.end(JSON.stringify(body))
706
+ }
707
+
708
+ function sendFile(res, contentType, body) {
709
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
710
+ res.end(body)
711
+ }
712
+
713
+ function page() {
714
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Synapse for DSH</title><link rel="stylesheet" href="/synapse/styles.css"></head><body><div id="app"></div><script src="/synapse/app.js"></script></body></html>`
715
+ }
716
+
717
+ /** Mount Synapse routes on the existing DSH Web Server. */
718
+ export function apply(ctx, config) {
719
+ const store = new WorkspaceStore(config?.dataFile)
720
+ const autoProjection = config?.autoProjection !== false
721
+ const projectionWorkspaceTitle = typeof config?.projectionWorkspaceTitle === 'string' && config.projectionWorkspaceTitle.trim() !== ''
722
+ ? config.projectionWorkspaceTitle.trim().slice(0, MAX_TITLE_LENGTH)
723
+ : 'DSH 任务'
724
+ const reportProjectionFailure = error => {
725
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
726
+ }
727
+ const replaySession = session => {
728
+ // Forks inherit their parent's log. The canvas already represents that
729
+ // history through the parent node, so only project the child's live tail.
730
+ const replayFrom = session.header?.parentSession === undefined ? 0 : session.firstLiveSeq
731
+ void store.projectSession(session, replayFrom, projectionWorkspaceTitle).catch(reportProjectionFailure)
732
+ }
733
+ // Buffer live events per session and flush them in one write per microtask,
734
+ // so a burst of turn events coalesces into a single save instead of N.
735
+ const projectionQueue = []
736
+ let projectionScheduled = false
737
+ const enqueueProjection = (session, event) => {
738
+ projectionQueue.push({ session, event })
739
+ if (projectionScheduled) return
740
+ projectionScheduled = true
741
+ queueMicrotask(() => {
742
+ projectionScheduled = false
743
+ const batch = projectionQueue.splice(0)
744
+ const bySession = new Map()
745
+ for (const item of batch) {
746
+ const entry = bySession.get(item.session.id)
747
+ if (entry === undefined) bySession.set(item.session.id, [item.session, [item.event]])
748
+ else entry[1].push(item.event)
749
+ }
750
+ for (const [sessionId, [session, events]] of bySession) {
751
+ void store.projectEvents(session, events, projectionWorkspaceTitle).catch(reportProjectionFailure)
752
+ }
753
+ })
754
+ }
755
+ if (autoProjection) {
756
+ ctx.on('session/created', replaySession)
757
+ ctx.on('session/event', enqueueProjection)
758
+ for (const session of ctx.sessions.list()) replaySession(session)
759
+ }
760
+ // The DSH /api browser-trust fence does not cover /synapse routes, so this
761
+ // handler checks the Host header itself: localhost is allowed by default and
762
+ // additional authorities opt in through config.trustedHosts (mirrors the
763
+ // fence's DNS-rebinding defense).
764
+ const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(host => String(host).trim().toLowerCase()).filter(Boolean)])
765
+ const api = async (req, res) => {
766
+ try {
767
+ const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
768
+ if (!trustedHosts.has(hostname)) return sendJson(res, 403, { error: '不被信任的 Host' })
769
+ const path = new URL(req.url ?? '/', 'http://dsh.local').pathname
770
+ if (path === '/synapse/api/reset' && req.method === 'POST') return sendJson(res, 200, await store.clearLegacy(ctx.sessions.list()))
771
+ if (path === '/synapse/api/workspaces') {
772
+ if (req.method === 'GET') return sendJson(res, 200, { workspaces: await store.list() })
773
+ if (req.method === 'POST') return sendJson(res, 201, { workspace: await store.create((await readJson(req)).title) })
774
+ }
775
+ const workspace = /^\/synapse\/api\/workspaces\/([0-9a-f-]+)$/i.exec(path)
776
+ if (workspace !== null) {
777
+ if (req.method === 'GET') return sendJson(res, 200, { workspace: await store.get(workspace[1]) })
778
+ if (req.method === 'POST') return sendJson(res, 201, { thread: await store.createThread(workspace[1], await readJson(req)) })
779
+ }
780
+ const branch = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/branch$/i.exec(path)
781
+ if (branch !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.branch(branch[1], await readJson(req)) })
782
+ if (path === '/synapse/api/sessions/sync' && req.method === 'POST') { const body = await readJson(req); return sendJson(res, 200, { workspaces: await store.syncSessions(body.sessions, body.removedSessionIds) }) }
783
+ const messages = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/messages$/i.exec(path)
784
+ if (messages !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.addMessage(messages[1], (await readJson(req)).text) })
785
+ const thread = /^\/synapse\/api\/threads\/([0-9a-f-]+)$/i.exec(path)
786
+ if (thread !== null && req.method === 'PATCH') return sendJson(res, 200, { thread: await store.updateThread(thread[1], await readJson(req)) })
787
+ if (thread !== null && req.method === 'DELETE') return sendJson(res, 200, await store.removeThread(thread[1]))
788
+ return sendJson(res, 404, { error: '接口不存在' })
789
+ } catch (error) {
790
+ if (error instanceof InputError) return sendJson(res, 400, { error: error.message })
791
+ if (error instanceof NotFoundError) return sendJson(res, 404, { error: error.message })
792
+ ctx.logger.error(error instanceof Error ? error : new Error(String(error)))
793
+ return sendJson(res, 500, { error: 'Synapse 数据暂时不可用' })
794
+ }
795
+ }
796
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse', handler: (_req, res) => { res.writeHead(302, { location: '/synapse/' }); res.end() } }), 'synapse: redirect')
797
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/', handler: (_req, res) => { sendFile(res, 'text/html; charset=utf-8', page()) } }), 'synapse: page')
798
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/app.js', handler: async (_req, res) => { sendFile(res, 'text/javascript; charset=utf-8', await readFile(new URL('./app.js', import.meta.url), 'utf8')) } }), 'synapse: app')
799
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/styles.css', handler: async (_req, res) => { sendFile(res, 'text/css; charset=utf-8', await readFile(new URL('./styles.css', import.meta.url), 'utf8')) } }), 'synapse: styles')
800
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/deepseek-mark.svg', handler: async (_req, res) => { sendFile(res, 'image/svg+xml', await readFile(new URL('./deepseek-mark.svg', import.meta.url), 'utf8')) } }), 'synapse: DeepSeek mark')
801
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/synapse/api', handler: api }), 'synapse: api')
802
+ }