dsh-synapse 0.4.1 → 0.4.2

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 CHANGED
@@ -1,465 +1,469 @@
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 },
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
+ // `snapshotEvents()` replaces the removed `session.events` getter. It is
204
+ // marked deprecated because DSH is moving complete logs out of memory, but
205
+ // rebuilding this canvas from history needs the full sequence and the
206
+ // reader is the only complete-log access a plugin still has.
207
+ for (const event of session.snapshotEvents()) {
208
+ if (event.seq >= replayFrom) this.projectEventInto(workspace, thread, event)
209
+ }
210
+ return structuredClone(thread)
211
+ }, { deferred: true })
212
+ }
213
+
214
+ /** Project one committed DSH session event. Repeated sequence numbers are ignored. */
215
+ async projectEvent(session, event, workspaceTitle = 'DSH 任务') {
216
+ return this.mutate(() => {
217
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
218
+ const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
219
+ const thread = this.dshThread(workspace, session)
220
+ this.projectEventInto(workspace, thread, event)
221
+ return structuredClone(thread)
222
+ }, { deferred: true })
223
+ }
224
+
225
+ /** Project a batch of committed events for one session in a single write. */
226
+ async projectEvents(session, events, workspaceTitle = 'DSH 任务') {
227
+ if (events.length === 0) return null
228
+ return this.mutate(() => {
229
+ if (this.state.hiddenSessionIds.includes(session.id)) return null
230
+ const workspace = this.dshWorkspace(sessionCwd(session), workspaceTitle)
231
+ const thread = this.dshThread(workspace, session)
232
+ for (const event of events) this.projectEventInto(workspace, thread, event)
233
+ return structuredClone(thread)
234
+ }, { deferred: true })
235
+ }
236
+
237
+ async load() {
238
+ await mkdir(dirname(this.dataFile), { recursive: true })
239
+ try {
240
+ const parsed = JSON.parse(await readFile(this.dataFile, 'utf8'))
241
+ const { state, migrated } = normalizeState(parsed)
242
+ this.state = state
243
+ if (migrated) await this.save()
244
+ } catch (error) {
245
+ if (error?.code !== 'ENOENT') throw new Error(`synapse: cannot read ${this.dataFile}: ${error.message}`)
246
+ this.state = { version: 4, hiddenSessionIds: [], workspaces: [] }
247
+ await this.save()
248
+ }
249
+ }
250
+
251
+ async mutate(action, { deferred = false } = {}) {
252
+ await this.ready
253
+ const task = this.serial.then(async () => {
254
+ const result = action()
255
+ if (deferred) this.markDirty()
256
+ else await this.save()
257
+ return result
258
+ })
259
+ this.serial = task.catch(() => undefined)
260
+ return task
261
+ }
262
+
263
+ /** Mark the state dirty and schedule one trailing flush for the window. */
264
+ markDirty() {
265
+ this.dirty = true
266
+ if (this.flushTimer !== null) return
267
+ this.flushTimer = setTimeout(() => {
268
+ this.flushTimer = null
269
+ void this.flush()
270
+ }, SAVE_DEBOUNCE_MS)
271
+ }
272
+
273
+ /** Persist the current state when dirty, ordered after in-flight mutations. */
274
+ flush() {
275
+ if (!this.dirty) return Promise.resolve()
276
+ this.dirty = false
277
+ const task = this.serial.then(() => this.save())
278
+ this.serial = task.catch(() => undefined)
279
+ return task
280
+ }
281
+
282
+ async save() {
283
+ // Two dsh web instances sharing one profile clobber each other's canvas
284
+ // state. Warn loudly instead of silently losing work; a live lock held by
285
+ // another process or a file mtime that moved since our last write both
286
+ // indicate a second writer.
287
+ const before = await this.fileMtime()
288
+ if (this.lastKnownMtime !== null && before !== null && before !== this.lastKnownMtime) {
289
+ this.lastKnownMtime = before
290
+ if (!this.externalModWarned) {
291
+ this.externalModWarned = true
292
+ process.stderr.write('synapse: workspaces.json 已被另一个 dsh web 实例修改,本实例的写入可能覆盖其更改——请只运行一个实例\n')
293
+ }
294
+ }
295
+ await this.acquireLock()
296
+ try {
297
+ const temporaryFile = `${this.dataFile}.${process.pid}.tmp`
298
+ await writeFile(temporaryFile, `${JSON.stringify(this.state)}\n`, 'utf8')
299
+ await rename(temporaryFile, this.dataFile)
300
+ this.lastKnownMtime = (await stat(this.dataFile)).mtimeMs
301
+ } finally {
302
+ await this.releaseLock()
303
+ }
304
+ }
305
+
306
+ async fileMtime() {
307
+ try { return (await stat(this.dataFile)).mtimeMs } catch { return null }
308
+ }
309
+
310
+ /** Take an exclusive cross-process lock, breaking a stale one; warn when a live process holds it. */
311
+ async acquireLock() {
312
+ const lockFile = `${this.dataFile}.lock`
313
+ if (await this.tryAcquire(lockFile)) return
314
+ if (await this.lockIsStale(lockFile)) {
315
+ await unlink(lockFile).catch(() => {})
316
+ if (await this.tryAcquire(lockFile)) return
317
+ }
318
+ if (!this.lockWarned) {
319
+ this.lockWarned = true
320
+ process.stderr.write('synapse: 另一个 dsh web 实例正在写入 workspaces.json——请只运行一个实例,否则画布数据可能互相覆盖\n')
321
+ }
322
+ }
323
+
324
+ async tryAcquire(lockFile) {
325
+ try {
326
+ await writeFile(lockFile, `${process.pid}\n`, { flag: 'wx' })
327
+ return true
328
+ } catch {
329
+ return false
330
+ }
331
+ }
332
+
333
+ /** A lock is stale when its owner PID is gone or the lock file is older than the stale window. */
334
+ async lockIsStale(lockFile) {
335
+ try {
336
+ const [content, stats] = await Promise.all([readFile(lockFile, 'utf8'), stat(lockFile)])
337
+ const tooOld = Date.now() - stats.mtimeMs > LOCK_STALE_MS
338
+ const pid = Number.parseInt(content, 10)
339
+ if (!Number.isInteger(pid)) return tooOld
340
+ if (pid === process.pid) return false
341
+ try {
342
+ process.kill(pid, 0)
343
+ return tooOld
344
+ } catch {
345
+ return true
346
+ }
347
+ } catch {
348
+ return false
349
+ }
350
+ }
351
+
352
+ async releaseLock() {
353
+ await unlink(`${this.dataFile}.lock`).catch(() => {})
354
+ }
355
+
356
+ workspace(workspaceId) {
357
+ const workspace = this.state.workspaces.find(item => item.id === workspaceId)
358
+ if (workspace === undefined) throw new NotFoundError('工作空间不存在')
359
+ return workspace
360
+ }
361
+
362
+ locateThread(threadId) {
363
+ for (const workspace of this.state.workspaces) {
364
+ const thread = workspace.threads.find(item => item.id === threadId)
365
+ if (thread !== undefined) return { workspace, thread }
366
+ }
367
+ throw new NotFoundError('节点不存在')
368
+ }
369
+
370
+ dshWorkspace(cwd, fallbackTitle) {
371
+ let workspace = this.state.workspaces.find(item => item.kind === 'dsh' && item.cwd === cwd)
372
+ if (workspace !== undefined) return workspace
373
+ const now = new Date().toISOString()
374
+ workspace = { id: randomUUID(), kind: 'dsh', cwd, title: workspaceTitle(cwd, fallbackTitle), createdAt: now, updatedAt: now, threads: [] }
375
+ this.state.workspaces.unshift(workspace)
376
+ return workspace
377
+ }
378
+
379
+ dshThread(workspace, session) {
380
+ let thread = workspace.threads.find(item => item.dshSessionId === session.id)
381
+ if (thread !== undefined) {
382
+ if (typeof session.title === 'string' && session.title.trim() !== '') {
383
+ const title = session.title.slice(0, MAX_TITLE_LENGTH)
384
+ thread.title = title
385
+ thread.dshSessionTitle = title
386
+ }
387
+ // `inheritedEventCount` is DSH's durable fork cut, the successor of the
388
+ // removed `header.seedLength`. It survives restore, unlike `firstLiveSeq`.
389
+ const seedLength = session.inheritedEventCount
390
+ if (Number.isSafeInteger(seedLength) && seedLength >= 0) thread.sourceSeedLength = seedLength
391
+ return thread
392
+ }
393
+ const parentSessionId = typeof session.header?.parentSession === 'string' ? session.header.parentSession : null
394
+ const parent = parentSessionId === null ? undefined : workspace.threads.find(item => item.dshSessionId === parentSessionId)
395
+ const siblings = workspace.threads.filter(item => item.sourceParentSessionId === parentSessionId)
396
+ const now = new Date().toISOString()
397
+ thread = {
398
+ id: randomUUID(),
399
+ title: typeof session.title === 'string' && session.title.trim() !== '' ? session.title.slice(0, MAX_TITLE_LENGTH) : (parent === undefined ? 'DSH 会话' : `${parent.title} 分支`),
400
+ parentId: parent?.id ?? null,
401
+ sourceParentSessionId: parentSessionId,
402
+ sourceSeedLength: Number.isSafeInteger(session.inheritedEventCount) && session.inheritedEventCount >= 0 ? session.inheritedEventCount : null,
403
+ dshSessionId: session.id,
404
+ dshSessionTitle: typeof session.title === 'string' ? session.title.slice(0, MAX_TITLE_LENGTH) : null,
405
+ color: TOPIC_COLORS[workspace.threads.length % TOPIC_COLORS.length],
406
+ // DSH projection stores only a neutral semantic anchor. The visual map
407
+ // lays out visible cards from the current conversation graph each render,
408
+ // so old/archived session counts must never leak into future coordinates.
409
+ position: parent === undefined ? { x: 86, y: 82 } : { x: parent.position.x + 400, y: parent.position.y },
406
410
  createdAt: now,
407
411
  updatedAt: now,
408
412
  messages: [],
409
413
  pendingProcess: [],
410
- }
411
- workspace.threads.push(thread)
412
- // A child may arrive before its parent during startup replay. Repair that
413
- // relation when the missing parent later reaches the projection.
414
- for (const child of workspace.threads) {
415
- if (child.sourceParentSessionId === session.id && child.parentId === null) child.parentId = thread.id
416
- }
417
- workspace.updatedAt = now
418
- return thread
419
- }
420
-
421
- projectEventInto(workspace, thread, event) {
422
- if (event.type === 'session/title' && typeof event.data?.title === 'string') {
423
- thread.title = event.data.title.slice(0, MAX_TITLE_LENGTH)
424
- thread.dshSessionTitle = thread.title
425
- thread.updatedAt = new Date(event.time).toISOString()
426
- workspace.updatedAt = thread.updatedAt
427
- return
428
- }
429
- if (event.type === 'tool/call' || event.type === 'tool/result') {
430
- this.foldToolProcess(thread, event)
431
- workspace.updatedAt = thread.updatedAt
432
- return
433
- }
434
- const projection = projectableEvent(event)
435
- if (projection === null || thread.messages.some(message => message.sourceSeq === event.seq)) return
436
- const at = new Date(event.time).toISOString()
437
- const message = {
438
- id: randomUUID(),
439
- text: projection.text,
440
- kind: projection.kind,
441
- sourceSeq: event.seq,
442
- at,
414
+ }
415
+ workspace.threads.push(thread)
416
+ // A child may arrive before its parent during startup replay. Repair that
417
+ // relation when the missing parent later reaches the projection.
418
+ for (const child of workspace.threads) {
419
+ if (child.sourceParentSessionId === session.id && child.parentId === null) child.parentId = thread.id
420
+ }
421
+ workspace.updatedAt = now
422
+ return thread
423
+ }
424
+
425
+ projectEventInto(workspace, thread, event) {
426
+ if (event.type === 'session/title' && typeof event.data?.title === 'string') {
427
+ thread.title = event.data.title.slice(0, MAX_TITLE_LENGTH)
428
+ thread.dshSessionTitle = thread.title
429
+ thread.updatedAt = new Date(event.time).toISOString()
430
+ workspace.updatedAt = thread.updatedAt
431
+ return
432
+ }
433
+ if (event.type === 'tool/call' || event.type === 'tool/result') {
434
+ this.foldToolProcess(thread, event)
435
+ workspace.updatedAt = thread.updatedAt
436
+ return
437
+ }
438
+ const projection = projectableEvent(event)
439
+ if (projection === null || thread.messages.some(message => message.sourceSeq === event.seq)) return
440
+ const at = new Date(event.time).toISOString()
441
+ const message = {
442
+ id: randomUUID(),
443
+ text: projection.text,
444
+ kind: projection.kind,
445
+ sourceSeq: event.seq,
446
+ at,
443
447
  ...(projection.kind === 'assistant' || projection.kind === 'error'
444
448
  ? { turn: event.data?.turn, step: event.data?.step, process: [] }
445
449
  : {}),
446
450
  }
447
451
  this.attachPendingProcess(thread, message)
448
- thread.messages.push(message)
449
- thread.updatedAt = at
450
- workspace.updatedAt = at
451
- if (thread.dshSessionTitle === null && projection.kind === 'user') {
452
- thread.title = titleFromText(projection.text)
453
- thread.dshSessionTitle = thread.title
454
- }
455
- }
456
-
457
- /**
458
- * Fold one tool call or result into the assistant message of its own
452
+ thread.messages.push(message)
453
+ thread.updatedAt = at
454
+ workspace.updatedAt = at
455
+ if (thread.dshSessionTitle === null && projection.kind === 'user') {
456
+ thread.title = titleFromText(projection.text)
457
+ thread.dshSessionTitle = thread.title
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Fold one tool call or result into the assistant message of its own
459
463
  * turn/step, keyed by `callId`, so a tool invocation never becomes a
460
464
  * separate canvas card. If a tool result arrives before its associated
461
465
  * assistant/error message, retain it on the thread until that turn appears.
462
- */
466
+ */
463
467
  foldToolProcess(thread, event) {
464
468
  const at = new Date(event.time).toISOString()
465
469
  const data = event.data ?? {}
@@ -482,11 +486,11 @@ export class WorkspaceStore {
482
486
  const error = errorText(data.error)
483
487
  if (entry === undefined) {
484
488
  process.push({ callId, turn: data.turn, step: data.step, name: '工具调用', arguments: null, result: outcome, error })
485
- } else {
486
- entry.result = outcome
487
- entry.error = error
488
- }
489
- }
489
+ } else {
490
+ entry.result = outcome
491
+ entry.error = error
492
+ }
493
+ }
490
494
  thread.updatedAt = at
491
495
  }
492
496
 
@@ -497,157 +501,157 @@ export class WorkspaceStore {
497
501
  message.process.push(...matching.map(({ turn, step, ...entry }) => entry))
498
502
  thread.pendingProcess = thread.pendingProcess.filter(entry => entry.turn !== message.turn || entry.step !== message.step)
499
503
  }
500
-
501
- thread({ title, parentId, dshSessionId, dshSessionTitle, position, color, now, order }) {
502
- return {
503
- id: randomUUID(),
504
- title: requiredText(title, MAX_TITLE_LENGTH, 'title'),
505
- parentId: typeof parentId === 'string' && parentId.length > 0 ? parentId : null,
506
- dshSessionId: typeof dshSessionId === 'string' && dshSessionId.length > 0 ? dshSessionId : null,
507
- dshSessionTitle: typeof dshSessionTitle === 'string' ? dshSessionTitle.slice(0, MAX_TITLE_LENGTH) : null,
508
- color: TOPIC_COLORS.includes(color) ? color : TOPIC_COLORS[order % TOPIC_COLORS.length],
509
- position: positionOf(position ?? { x: 86 + (order % 3) * 410, y: 82 + Math.floor(order / 3) * 260 }),
510
- createdAt: now,
504
+
505
+ thread({ title, parentId, dshSessionId, dshSessionTitle, position, color, now, order }) {
506
+ return {
507
+ id: randomUUID(),
508
+ title: requiredText(title, MAX_TITLE_LENGTH, 'title'),
509
+ parentId: typeof parentId === 'string' && parentId.length > 0 ? parentId : null,
510
+ dshSessionId: typeof dshSessionId === 'string' && dshSessionId.length > 0 ? dshSessionId : null,
511
+ dshSessionTitle: typeof dshSessionTitle === 'string' ? dshSessionTitle.slice(0, MAX_TITLE_LENGTH) : null,
512
+ color: TOPIC_COLORS.includes(color) ? color : TOPIC_COLORS[order % TOPIC_COLORS.length],
513
+ position: positionOf(position ?? { x: 86 + (order % 3) * 410, y: 82 + Math.floor(order / 3) * 260 }),
514
+ createdAt: now,
511
515
  updatedAt: now,
512
516
  messages: [],
513
517
  pendingProcess: [],
514
- }
515
- }
516
-
517
- summary(workspace) {
518
- 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 }
519
- }
520
- }
521
-
522
- class InputError extends Error {}
523
- class NotFoundError extends Error {}
524
-
525
- function normalizeState(value) {
526
- let migrated = false
527
- let state
528
- if ((value?.version === 2 || value?.version === 3 || value?.version === 4) && Array.isArray(value.workspaces)) {
529
- const hiddenSessionIds = Array.isArray(value.hiddenSessionIds) ? value.hiddenSessionIds.filter(item => typeof item === 'string') : []
530
- migrated = value.version < 3 || !Array.isArray(value.hiddenSessionIds)
531
- const workspaces = value.workspaces.map(workspace => ({
532
- ...workspace,
533
- threads: Array.isArray(workspace.threads) ? workspace.threads.map(thread => {
534
- if (Array.isArray(thread.messages)) {
535
- const messages = thread.messages.filter(message => !isRuntimeContextMessage(message))
536
- if (messages.length !== thread.messages.length) migrated = true
518
+ }
519
+ }
520
+
521
+ summary(workspace) {
522
+ 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 }
523
+ }
524
+ }
525
+
526
+ class InputError extends Error {}
527
+ class NotFoundError extends Error {}
528
+
529
+ function normalizeState(value) {
530
+ let migrated = false
531
+ let state
532
+ if ((value?.version === 2 || value?.version === 3 || value?.version === 4) && Array.isArray(value.workspaces)) {
533
+ const hiddenSessionIds = Array.isArray(value.hiddenSessionIds) ? value.hiddenSessionIds.filter(item => typeof item === 'string') : []
534
+ migrated = value.version < 3 || !Array.isArray(value.hiddenSessionIds)
535
+ const workspaces = value.workspaces.map(workspace => ({
536
+ ...workspace,
537
+ threads: Array.isArray(workspace.threads) ? workspace.threads.map(thread => {
538
+ if (Array.isArray(thread.messages)) {
539
+ const messages = thread.messages.filter(message => !isRuntimeContextMessage(message))
540
+ if (messages.length !== thread.messages.length) migrated = true
537
541
  return { ...thread, messages }
538
- }
539
- migrated = true
540
- const notes = Array.isArray(thread.notes) ? thread.notes : []
541
- const { notes: _notes, ...rest } = thread
542
+ }
543
+ migrated = true
544
+ const notes = Array.isArray(thread.notes) ? thread.notes : []
545
+ const { notes: _notes, ...rest } = thread
542
546
  return { ...rest, messages: notes, pendingProcess: [] }
543
- }) : [],
544
- }))
545
- state = { ...value, version: value.version, hiddenSessionIds, workspaces }
546
- } else if (value?.version === 1 && Array.isArray(value.workspaces)) {
547
- const now = typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString()
548
- state = {
549
- version: 3,
550
- hiddenSessionIds: [],
551
- workspaces: value.workspaces.map((workspace, index) => {
552
- const events = Array.isArray(workspace.events) ? workspace.events : []
553
- const workspaceNow = typeof workspace.updatedAt === 'string' ? workspace.updatedAt : now
554
- return {
555
- id: typeof workspace.id === 'string' ? workspace.id : randomUUID(),
556
- title: typeof workspace.title === 'string' && workspace.title.trim() ? workspace.title : '未命名工作空间',
557
- createdAt: typeof workspace.createdAt === 'string' ? workspace.createdAt : workspaceNow,
558
- updatedAt: workspaceNow,
559
- threads: events.length === 0 ? [] : [{
560
- id: randomUUID(), title: workspace.title || '历史记录', parentId: null, dshSessionId: null, dshSessionTitle: null,
561
- color: TOPIC_COLORS[index % TOPIC_COLORS.length], position: { x: 86, y: 82 }, createdAt: workspaceNow, updatedAt: workspaceNow,
562
- messages: events.map(event => ({ id: typeof event.id === 'string' ? event.id : randomUUID(), text: String(event.text ?? ''), at: typeof event.at === 'string' ? event.at : workspaceNow })),
563
- }],
564
- }
565
- }),
566
- }
567
- migrated = true
568
- } else {
569
- throw new Error('expected Synapse data version 1, 2, 3, or 4')
570
- }
571
- if (state.version !== 4) {
572
- if (foldLegacyToolCards(state.workspaces)) migrated = true
573
- state.version = 4
574
- migrated = true
575
- }
576
- return { state, migrated }
577
- }
578
-
579
- /**
580
- * Fold v3-era standalone tool cards (kinds `tool` / `tool-result`) into the
581
- * preceding assistant message's `process` list, pairing each call with the
582
- * result that follows it in order, so every tool invocation lives in one
583
- * home: the assistant turn card.
584
- */
585
- function foldLegacyToolCards(workspaces) {
586
- let changed = false
587
- for (const workspace of workspaces) {
588
- for (const thread of workspace.threads ?? []) {
589
- if (!Array.isArray(thread.messages)) continue
590
- const folded = []
591
- let assistant = null
592
- let pending = []
593
- for (const message of thread.messages) {
594
- if (message.kind === 'assistant') {
595
- assistant = message
596
- assistant.process ??= []
597
- pending = []
598
- folded.push(message)
599
- continue
600
- }
601
- if (message.kind !== 'tool' && message.kind !== 'tool-result') {
602
- folded.push(message)
603
- continue
604
- }
605
- if (assistant === null) {
606
- folded.push(message)
607
- continue
608
- }
609
- changed = true
610
- if (message.kind === 'tool') {
611
- const [name = '工具调用', ...argumentLines] = message.text.split('\n')
612
- const entry = { callId: `legacy-${assistant.process.length}`, name, arguments: argumentLines.join('\n'), result: null, error: null }
613
- pending.push(entry)
614
- assistant.process.push(entry)
615
- } else {
616
- const entry = pending.shift() ?? (() => {
617
- const orphan = { callId: `legacy-orphan-${assistant.process.length}`, name: '工具调用', arguments: null, result: null, error: null }
618
- assistant.process.push(orphan)
619
- return orphan
620
- })()
621
- entry.result = message.text
622
- }
623
- }
624
- thread.messages = folded
625
- }
626
- }
627
- return changed
628
- }
629
-
630
- function positionOf(value) {
631
- const x = Number(value?.x)
632
- const y = Number(value?.y)
633
- if (!Number.isFinite(x) || !Number.isFinite(y)) throw new InputError('position 必须包含有效坐标')
634
- return { x: Math.round(Math.max(-2000, Math.min(5000, x))), y: Math.round(Math.max(-2000, Math.min(5000, y))) }
635
- }
636
-
637
- function requiredText(value, maxLength, field) {
638
- if (typeof value !== 'string') throw new InputError(`${field} 必须是文本`)
639
- const text = value.trim()
640
- if (text.length === 0) throw new InputError(`${field} 不能为空`)
641
- if (text.length > maxLength) throw new InputError(`${field} 超过长度限制`)
642
- return text
643
- }
644
-
645
- function projectableEvent(event) {
646
- switch (event.type) {
647
- case 'user/message': {
648
- const text = contentText(event.data.content)
649
- return isRuntimeContextText(text) ? null : noteProjection('user', text)
650
- }
547
+ }) : [],
548
+ }))
549
+ state = { ...value, version: value.version, hiddenSessionIds, workspaces }
550
+ } else if (value?.version === 1 && Array.isArray(value.workspaces)) {
551
+ const now = typeof value.updatedAt === 'string' ? value.updatedAt : new Date().toISOString()
552
+ state = {
553
+ version: 3,
554
+ hiddenSessionIds: [],
555
+ workspaces: value.workspaces.map((workspace, index) => {
556
+ const events = Array.isArray(workspace.events) ? workspace.events : []
557
+ const workspaceNow = typeof workspace.updatedAt === 'string' ? workspace.updatedAt : now
558
+ return {
559
+ id: typeof workspace.id === 'string' ? workspace.id : randomUUID(),
560
+ title: typeof workspace.title === 'string' && workspace.title.trim() ? workspace.title : '未命名工作空间',
561
+ createdAt: typeof workspace.createdAt === 'string' ? workspace.createdAt : workspaceNow,
562
+ updatedAt: workspaceNow,
563
+ threads: events.length === 0 ? [] : [{
564
+ id: randomUUID(), title: workspace.title || '历史记录', parentId: null, dshSessionId: null, dshSessionTitle: null,
565
+ color: TOPIC_COLORS[index % TOPIC_COLORS.length], position: { x: 86, y: 82 }, createdAt: workspaceNow, updatedAt: workspaceNow,
566
+ messages: events.map(event => ({ id: typeof event.id === 'string' ? event.id : randomUUID(), text: String(event.text ?? ''), at: typeof event.at === 'string' ? event.at : workspaceNow })),
567
+ }],
568
+ }
569
+ }),
570
+ }
571
+ migrated = true
572
+ } else {
573
+ throw new Error('expected Synapse data version 1, 2, 3, or 4')
574
+ }
575
+ if (state.version !== 4) {
576
+ if (foldLegacyToolCards(state.workspaces)) migrated = true
577
+ state.version = 4
578
+ migrated = true
579
+ }
580
+ return { state, migrated }
581
+ }
582
+
583
+ /**
584
+ * Fold v3-era standalone tool cards (kinds `tool` / `tool-result`) into the
585
+ * preceding assistant message's `process` list, pairing each call with the
586
+ * result that follows it in order, so every tool invocation lives in one
587
+ * home: the assistant turn card.
588
+ */
589
+ function foldLegacyToolCards(workspaces) {
590
+ let changed = false
591
+ for (const workspace of workspaces) {
592
+ for (const thread of workspace.threads ?? []) {
593
+ if (!Array.isArray(thread.messages)) continue
594
+ const folded = []
595
+ let assistant = null
596
+ let pending = []
597
+ for (const message of thread.messages) {
598
+ if (message.kind === 'assistant') {
599
+ assistant = message
600
+ assistant.process ??= []
601
+ pending = []
602
+ folded.push(message)
603
+ continue
604
+ }
605
+ if (message.kind !== 'tool' && message.kind !== 'tool-result') {
606
+ folded.push(message)
607
+ continue
608
+ }
609
+ if (assistant === null) {
610
+ folded.push(message)
611
+ continue
612
+ }
613
+ changed = true
614
+ if (message.kind === 'tool') {
615
+ const [name = '工具调用', ...argumentLines] = message.text.split('\n')
616
+ const entry = { callId: `legacy-${assistant.process.length}`, name, arguments: argumentLines.join('\n'), result: null, error: null }
617
+ pending.push(entry)
618
+ assistant.process.push(entry)
619
+ } else {
620
+ const entry = pending.shift() ?? (() => {
621
+ const orphan = { callId: `legacy-orphan-${assistant.process.length}`, name: '工具调用', arguments: null, result: null, error: null }
622
+ assistant.process.push(orphan)
623
+ return orphan
624
+ })()
625
+ entry.result = message.text
626
+ }
627
+ }
628
+ thread.messages = folded
629
+ }
630
+ }
631
+ return changed
632
+ }
633
+
634
+ function positionOf(value) {
635
+ const x = Number(value?.x)
636
+ const y = Number(value?.y)
637
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new InputError('position 必须包含有效坐标')
638
+ return { x: Math.round(Math.max(-2000, Math.min(5000, x))), y: Math.round(Math.max(-2000, Math.min(5000, y))) }
639
+ }
640
+
641
+ function requiredText(value, maxLength, field) {
642
+ if (typeof value !== 'string') throw new InputError(`${field} 必须是文本`)
643
+ const text = value.trim()
644
+ if (text.length === 0) throw new InputError(`${field} 不能为空`)
645
+ if (text.length > maxLength) throw new InputError(`${field} 超过长度限制`)
646
+ return text
647
+ }
648
+
649
+ function projectableEvent(event) {
650
+ switch (event.type) {
651
+ case 'user/message': {
652
+ const text = contentText(event.data.content)
653
+ return isRuntimeContextText(text) ? null : noteProjection('user', text)
654
+ }
651
655
  case 'assistant/message':
652
656
  return noteProjection('assistant', contentText(event.data?.message?.content))
653
657
  case 'todo/write':
@@ -674,156 +678,159 @@ function errorText(value) {
674
678
  if (message !== '') return [name, code].filter(Boolean).concat(message).join(': ')
675
679
  return [name, code].filter(Boolean).join(': ') || null
676
680
  }
677
-
678
- function noteProjection(kind, text) {
679
- const normalized = text.trim()
680
- if (normalized === '') return null
681
- if (normalized.length <= MAX_PROJECTION_LENGTH) return { kind, text: normalized }
682
- return { kind, text: `${normalized.slice(0, MAX_PROJECTION_LENGTH)}${PROJECTION_TRUNCATED_SUFFIX}` }
683
- }
684
-
685
- function isRuntimeContextText(text) {
686
- return typeof text === 'string' && text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')
687
- }
688
-
689
- function isRuntimeContextMessage(message) {
690
- return message?.kind === 'user' && isRuntimeContextText(message.text)
691
- }
692
-
693
- function contentText(content) {
694
- if (!Array.isArray(content)) return ''
695
- return content.flatMap(block => {
696
- if (block?.type === 'text') return [block.text]
697
- if (block?.type === 'tool-call') return [block.name, block.arguments]
698
- if (block?.type === 'tool-result') return contentText(block.content)
699
- return []
700
- }).filter(value => typeof value === 'string' && value.trim() !== '').join('\n')
701
- }
702
-
703
- function titleFromText(text) {
704
- const line = text.replaceAll(/\s+/g, ' ').trim()
705
- return (line.length > 42 ? `${line.slice(0, 42)}...` : line) || 'DSH 会话'
706
- }
707
-
708
- function sessionCwd(session) {
709
- const cwd = session.header?.meta?.cwd ?? session.header?.cwd
710
- return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : '未指定工作目录'
711
- }
712
-
713
- function workspaceTitle(cwd, fallbackTitle) {
714
- if (cwd === '未指定工作目录') return fallbackTitle
715
- const segment = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1)
716
- return segment && segment.trim() !== '' ? segment : fallbackTitle
717
- }
718
-
719
- async function readJson(req) {
720
- const chunks = []
721
- let length = 0
722
- for await (const chunk of req) {
723
- length += chunk.length
724
- if (length > MAX_BODY_BYTES) throw new InputError('请求内容过大')
725
- chunks.push(chunk)
726
- }
727
- try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new InputError('请求不是有效 JSON') }
728
- }
729
-
730
- function sendJson(res, status, body) {
731
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
732
- res.end(JSON.stringify(body))
733
- }
734
-
735
- function sendFile(res, contentType, body) {
736
- res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
737
- res.end(body)
738
- }
739
-
740
- function page() {
741
- 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>`
742
- }
743
-
744
- /** Mount Synapse routes on the existing DSH Web Server. */
745
- export function apply(ctx, config) {
746
- const store = new WorkspaceStore(config?.dataFile)
747
- const autoProjection = config?.autoProjection !== false
748
- const projectionWorkspaceTitle = typeof config?.projectionWorkspaceTitle === 'string' && config.projectionWorkspaceTitle.trim() !== ''
749
- ? config.projectionWorkspaceTitle.trim().slice(0, MAX_TITLE_LENGTH)
750
- : 'DSH 任务'
751
- const reportProjectionFailure = error => {
752
- ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
753
- }
754
- const replaySession = session => {
755
- // Forks inherit their parent's log. The canvas already represents that
756
- // history through the parent node, so only project the child's live tail.
757
- const replayFrom = session.header?.parentSession === undefined ? 0 : session.firstLiveSeq
758
- void store.projectSession(session, replayFrom, projectionWorkspaceTitle).catch(reportProjectionFailure)
759
- }
760
- // Buffer live events per session and flush them in one write per microtask,
761
- // so a burst of turn events coalesces into a single save instead of N.
762
- const projectionQueue = []
763
- let projectionScheduled = false
764
- const enqueueProjection = (session, event) => {
765
- projectionQueue.push({ session, event })
766
- if (projectionScheduled) return
767
- projectionScheduled = true
768
- queueMicrotask(() => {
769
- projectionScheduled = false
770
- const batch = projectionQueue.splice(0)
771
- const bySession = new Map()
772
- for (const item of batch) {
773
- const entry = bySession.get(item.session.id)
774
- if (entry === undefined) bySession.set(item.session.id, [item.session, [item.event]])
775
- else entry[1].push(item.event)
776
- }
777
- for (const [sessionId, [session, events]] of bySession) {
778
- void store.projectEvents(session, events, projectionWorkspaceTitle).catch(reportProjectionFailure)
779
- }
780
- })
781
- }
782
- if (autoProjection) {
783
- ctx.on('session/created', replaySession)
784
- ctx.on('session/event', enqueueProjection)
785
- for (const session of ctx.sessions.list()) replaySession(session)
786
- }
787
- // The DSH /api browser-trust fence does not cover /synapse routes, so this
788
- // handler checks the Host header itself: localhost is allowed by default and
789
- // additional authorities opt in through config.trustedHosts (mirrors the
790
- // fence's DNS-rebinding defense).
791
- const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(host => String(host).trim().toLowerCase()).filter(Boolean)])
792
- const api = async (req, res) => {
793
- try {
794
- const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
795
- if (!trustedHosts.has(hostname)) return sendJson(res, 403, { error: '不被信任的 Host' })
796
- const path = new URL(req.url ?? '/', 'http://dsh.local').pathname
797
- if (path === '/synapse/api/reset' && req.method === 'POST') return sendJson(res, 200, await store.clearLegacy(ctx.sessions.list()))
798
- if (path === '/synapse/api/workspaces') {
799
- if (req.method === 'GET') return sendJson(res, 200, { workspaces: await store.list() })
800
- if (req.method === 'POST') return sendJson(res, 201, { workspace: await store.create((await readJson(req)).title) })
801
- }
802
- const workspace = /^\/synapse\/api\/workspaces\/([0-9a-f-]+)$/i.exec(path)
803
- if (workspace !== null) {
804
- if (req.method === 'GET') return sendJson(res, 200, { workspace: await store.get(workspace[1]) })
805
- if (req.method === 'POST') return sendJson(res, 201, { thread: await store.createThread(workspace[1], await readJson(req)) })
806
- }
807
- const branch = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/branch$/i.exec(path)
808
- if (branch !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.branch(branch[1], await readJson(req)) })
809
- 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) }) }
810
- const messages = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/messages$/i.exec(path)
811
- if (messages !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.addMessage(messages[1], (await readJson(req)).text) })
812
- const thread = /^\/synapse\/api\/threads\/([0-9a-f-]+)$/i.exec(path)
813
- if (thread !== null && req.method === 'PATCH') return sendJson(res, 200, { thread: await store.updateThread(thread[1], await readJson(req)) })
814
- if (thread !== null && req.method === 'DELETE') return sendJson(res, 200, await store.removeThread(thread[1]))
815
- return sendJson(res, 404, { error: '接口不存在' })
816
- } catch (error) {
817
- if (error instanceof InputError) return sendJson(res, 400, { error: error.message })
818
- if (error instanceof NotFoundError) return sendJson(res, 404, { error: error.message })
819
- ctx.logger.error(error instanceof Error ? error : new Error(String(error)))
820
- return sendJson(res, 500, { error: 'Synapse 数据暂时不可用' })
821
- }
822
- }
823
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse', handler: (_req, res) => { res.writeHead(302, { location: '/synapse/' }); res.end() } }), 'synapse: redirect')
824
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/', handler: (_req, res) => { sendFile(res, 'text/html; charset=utf-8', page()) } }), 'synapse: page')
825
- 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')
826
- 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')
827
- 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')
828
- ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/synapse/api', handler: api }), 'synapse: api')
829
- }
681
+
682
+ function noteProjection(kind, text) {
683
+ const normalized = text.trim()
684
+ if (normalized === '') return null
685
+ if (normalized.length <= MAX_PROJECTION_LENGTH) return { kind, text: normalized }
686
+ return { kind, text: `${normalized.slice(0, MAX_PROJECTION_LENGTH)}${PROJECTION_TRUNCATED_SUFFIX}` }
687
+ }
688
+
689
+ function isRuntimeContextText(text) {
690
+ return typeof text === 'string' && text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')
691
+ }
692
+
693
+ function isRuntimeContextMessage(message) {
694
+ return message?.kind === 'user' && isRuntimeContextText(message.text)
695
+ }
696
+
697
+ function contentText(content) {
698
+ if (!Array.isArray(content)) return ''
699
+ return content.flatMap(block => {
700
+ if (block?.type === 'text') return [block.text]
701
+ if (block?.type === 'tool-call') return [block.name, block.arguments]
702
+ if (block?.type === 'tool-result') return contentText(block.content)
703
+ return []
704
+ }).filter(value => typeof value === 'string' && value.trim() !== '').join('\n')
705
+ }
706
+
707
+ function titleFromText(text) {
708
+ const line = text.replaceAll(/\s+/g, ' ').trim()
709
+ return (line.length > 42 ? `${line.slice(0, 42)}...` : line) || 'DSH 会话'
710
+ }
711
+
712
+ function sessionCwd(session) {
713
+ // The Session header defines `cwd` as its only working-directory carrier; the
714
+ // old `header.meta.cwd` fallback never matched a real header field.
715
+ const cwd = session.header?.cwd
716
+ return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : '未指定工作目录'
717
+ }
718
+
719
+ function workspaceTitle(cwd, fallbackTitle) {
720
+ if (cwd === '未指定工作目录') return fallbackTitle
721
+ const segment = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1)
722
+ return segment && segment.trim() !== '' ? segment : fallbackTitle
723
+ }
724
+
725
+ async function readJson(req) {
726
+ const chunks = []
727
+ let length = 0
728
+ for await (const chunk of req) {
729
+ length += chunk.length
730
+ if (length > MAX_BODY_BYTES) throw new InputError('请求内容过大')
731
+ chunks.push(chunk)
732
+ }
733
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new InputError('请求不是有效 JSON') }
734
+ }
735
+
736
+ function sendJson(res, status, body) {
737
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
738
+ res.end(JSON.stringify(body))
739
+ }
740
+
741
+ function sendFile(res, contentType, body) {
742
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
743
+ res.end(body)
744
+ }
745
+
746
+ function page() {
747
+ 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>`
748
+ }
749
+
750
+ /** Mount Synapse routes on the existing DSH Web Server. */
751
+ export function apply(ctx, config) {
752
+ const store = new WorkspaceStore(config?.dataFile)
753
+ const autoProjection = config?.autoProjection !== false
754
+ const projectionWorkspaceTitle = typeof config?.projectionWorkspaceTitle === 'string' && config.projectionWorkspaceTitle.trim() !== ''
755
+ ? config.projectionWorkspaceTitle.trim().slice(0, MAX_TITLE_LENGTH)
756
+ : 'DSH 任务'
757
+ const reportProjectionFailure = error => {
758
+ ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
759
+ }
760
+ const replaySession = session => {
761
+ // Forks inherit their parent's log. The canvas already represents that
762
+ // history through the parent node, so only project the child's live tail:
763
+ // `inheritedEventCount` is 0 without a fork and the durable cut with one.
764
+ const replayFrom = session.inheritedEventCount ?? 0
765
+ void store.projectSession(session, replayFrom, projectionWorkspaceTitle).catch(reportProjectionFailure)
766
+ }
767
+ // Buffer live events per session and flush them in one write per microtask,
768
+ // so a burst of turn events coalesces into a single save instead of N.
769
+ const projectionQueue = []
770
+ let projectionScheduled = false
771
+ const enqueueProjection = (session, event) => {
772
+ projectionQueue.push({ session, event })
773
+ if (projectionScheduled) return
774
+ projectionScheduled = true
775
+ queueMicrotask(() => {
776
+ projectionScheduled = false
777
+ const batch = projectionQueue.splice(0)
778
+ const bySession = new Map()
779
+ for (const item of batch) {
780
+ const entry = bySession.get(item.session.id)
781
+ if (entry === undefined) bySession.set(item.session.id, [item.session, [item.event]])
782
+ else entry[1].push(item.event)
783
+ }
784
+ for (const [sessionId, [session, events]] of bySession) {
785
+ void store.projectEvents(session, events, projectionWorkspaceTitle).catch(reportProjectionFailure)
786
+ }
787
+ })
788
+ }
789
+ if (autoProjection) {
790
+ ctx.on('session/created', replaySession)
791
+ ctx.on('session/event', enqueueProjection)
792
+ for (const session of ctx.sessions.list()) replaySession(session)
793
+ }
794
+ // The DSH /api browser-trust fence does not cover /synapse routes, so this
795
+ // handler checks the Host header itself: localhost is allowed by default and
796
+ // additional authorities opt in through config.trustedHosts (mirrors the
797
+ // fence's DNS-rebinding defense).
798
+ const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(host => String(host).trim().toLowerCase()).filter(Boolean)])
799
+ const api = async (req, res) => {
800
+ try {
801
+ const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
802
+ if (!trustedHosts.has(hostname)) return sendJson(res, 403, { error: '不被信任的 Host' })
803
+ const path = new URL(req.url ?? '/', 'http://dsh.local').pathname
804
+ if (path === '/synapse/api/reset' && req.method === 'POST') return sendJson(res, 200, await store.clearLegacy(ctx.sessions.list()))
805
+ if (path === '/synapse/api/workspaces') {
806
+ if (req.method === 'GET') return sendJson(res, 200, { workspaces: await store.list() })
807
+ if (req.method === 'POST') return sendJson(res, 201, { workspace: await store.create((await readJson(req)).title) })
808
+ }
809
+ const workspace = /^\/synapse\/api\/workspaces\/([0-9a-f-]+)$/i.exec(path)
810
+ if (workspace !== null) {
811
+ if (req.method === 'GET') return sendJson(res, 200, { workspace: await store.get(workspace[1]) })
812
+ if (req.method === 'POST') return sendJson(res, 201, { thread: await store.createThread(workspace[1], await readJson(req)) })
813
+ }
814
+ const branch = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/branch$/i.exec(path)
815
+ if (branch !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.branch(branch[1], await readJson(req)) })
816
+ 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) }) }
817
+ const messages = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/messages$/i.exec(path)
818
+ if (messages !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.addMessage(messages[1], (await readJson(req)).text) })
819
+ const thread = /^\/synapse\/api\/threads\/([0-9a-f-]+)$/i.exec(path)
820
+ if (thread !== null && req.method === 'PATCH') return sendJson(res, 200, { thread: await store.updateThread(thread[1], await readJson(req)) })
821
+ if (thread !== null && req.method === 'DELETE') return sendJson(res, 200, await store.removeThread(thread[1]))
822
+ return sendJson(res, 404, { error: '接口不存在' })
823
+ } catch (error) {
824
+ if (error instanceof InputError) return sendJson(res, 400, { error: error.message })
825
+ if (error instanceof NotFoundError) return sendJson(res, 404, { error: error.message })
826
+ ctx.logger.error(error instanceof Error ? error : new Error(String(error)))
827
+ return sendJson(res, 500, { error: 'Synapse 数据暂时不可用' })
828
+ }
829
+ }
830
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse', handler: (_req, res) => { res.writeHead(302, { location: '/synapse/' }); res.end() } }), 'synapse: redirect')
831
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/synapse/', handler: (_req, res) => { sendFile(res, 'text/html; charset=utf-8', page()) } }), 'synapse: page')
832
+ 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')
833
+ 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')
834
+ 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')
835
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/synapse/api', handler: api }), 'synapse: api')
836
+ }