@xxgxiaoyu/dsh-session-sync 0.0.0-stage → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Session-store bridge.
3
+ *
4
+ * Two ways to reach the local session store:
5
+ * 1. through the harness services (`ctx.sessionQuery`, `ctx.sessionPersistence`)
6
+ * which is what the GUI itself uses — preferred, because an imported
7
+ * session then shows up exactly like a locally created one;
8
+ * 2. straight from `$DSH_HOME/sessions` when the services are unavailable
9
+ * (standalone harness, older build). Reading still works; importing needs
10
+ * the persistence service, so that path reports a clear error.
11
+ *
12
+ * Wire format for one session (`exportSession`):
13
+ * { format: 'dsh-session-sync/1', session: <header>, events: [<...>] }
14
+ * `events` is the replay-validated raw log exactly as `readSession` returns it.
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import crypto from 'node:crypto'
19
+ import { zstdDecompressSync } from 'node:zlib'
20
+ import { dshHome } from './state.js'
21
+
22
+ export const WIRE_FORMAT = 'dsh-session-sync/1'
23
+
24
+ export class SessionSyncError extends Error {
25
+ constructor(code, message, status = 400) {
26
+ super(message)
27
+ this.name = 'SessionSyncError'
28
+ this.code = code
29
+ this.status = status
30
+ }
31
+ }
32
+
33
+
34
+ /**
35
+ * Drop the inherited prefix a forked/summarised source carries.
36
+ *
37
+ * A seeded session's log ends its inherited block with
38
+ * `{ type: 'session/end-seed', data: { inherited: true } }`. Copying those events
39
+ * into a fresh, unseeded log makes DSH reject the copy with
40
+ * "released v2 unseeded Session contains an inherited end-seed marker", so an
41
+ * import keeps only the events the source session authored itself.
42
+ *
43
+ * @param {object[]} events - source event list, in seq order.
44
+ * @returns {object[]} the source's own events (possibly the same array).
45
+ */
46
+ export function stripInheritedPrefix(events) {
47
+ let cut = -1
48
+ for (const [index, event] of events.entries()) {
49
+ if (event?.type === 'session/end-seed' && isPlainObject(event.data) && event.data.inherited === true) cut = index
50
+ }
51
+ if (cut < 0) return events
52
+ return events.slice(cut + 1).filter(
53
+ (event) => !(event?.type === 'session/end-seed' && isPlainObject(event.data) && event.data.inherited === true),
54
+ )
55
+ }
56
+
57
+ /**
58
+ * Pick the working directory an imported session should claim: an explicit
59
+ * override, then the source directory when it also exists on this machine, then
60
+ * the current process directory. A v4 header needs an absolute path that the
61
+ * local UI can reopen.
62
+ */
63
+ function importCwd(source, options) {
64
+ if (typeof options.cwd === 'string' && path.isAbsolute(options.cwd)) return options.cwd
65
+ if (typeof source.cwd === 'string' && path.isAbsolute(source.cwd) && fs.existsSync(source.cwd)) return source.cwd
66
+ return process.cwd()
67
+ }
68
+
69
+ function isPlainObject(value) {
70
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
71
+ }
72
+
73
+ /** Map session id -> {file, dir, mtime, size} for every stored session. */
74
+ function scanStore(storeRoot) {
75
+ const index = new Map()
76
+ let slugs
77
+ try {
78
+ slugs = fs.readdirSync(storeRoot, { withFileTypes: true })
79
+ } catch {
80
+ return index
81
+ }
82
+ for (const slug of slugs) {
83
+ if (!slug.isDirectory()) continue
84
+ const slugDir = path.join(storeRoot, slug.name)
85
+ let sessions
86
+ try {
87
+ sessions = fs.readdirSync(slugDir, { withFileTypes: true })
88
+ } catch {
89
+ continue
90
+ }
91
+ for (const entry of sessions) {
92
+ if (!entry.isDirectory()) continue
93
+ const dir = path.join(slugDir, entry.name)
94
+ let files
95
+ try {
96
+ files = fs.readdirSync(dir)
97
+ } catch {
98
+ continue
99
+ }
100
+ const file = files.find((name) => name.endsWith('.jsonl.zstd'))
101
+ if (file === undefined) continue
102
+ const full = path.join(dir, file)
103
+ let stat
104
+ try {
105
+ stat = fs.statSync(full)
106
+ } catch {
107
+ continue
108
+ }
109
+ index.set(entry.name, { file: full, dir, mtime: stat.mtimeMs, size: stat.size, slug: slug.name })
110
+ }
111
+ }
112
+ return index
113
+ }
114
+
115
+ /** Read the header line (first zstd frame) without decompressing the whole log. */
116
+ function readHeader(file) {
117
+ let fd
118
+ try {
119
+ fd = fs.openSync(file, 'r')
120
+ const chunk = Buffer.allocUnsafe(Math.min(256 * 1024, Math.max(4096, fs.fstatSync(fd).size)))
121
+ const read = fs.readSync(fd, chunk, 0, chunk.length, 0)
122
+ const text = zstdDecompressSync(chunk.subarray(0, read)).toString('utf8')
123
+ const line = text.split('\n', 1)[0]
124
+ const header = JSON.parse(line)
125
+ return isPlainObject(header) ? header : undefined
126
+ } catch {
127
+ return undefined
128
+ } finally {
129
+ if (fd !== undefined) {
130
+ try {
131
+ fs.closeSync(fd)
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ /** Titles recorded in the projection cache, which is cheap and always current. */
140
+ function cachedTitles(storeRoot) {
141
+ const out = new Map()
142
+ const dir = path.join(path.dirname(storeRoot), 'storages', 'session_projcache', 'sessions')
143
+ let names
144
+ try {
145
+ names = fs.readdirSync(dir)
146
+ } catch {
147
+ return out
148
+ }
149
+ for (const name of names) {
150
+ if (!name.endsWith('.json')) continue
151
+ try {
152
+ const doc = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))
153
+ const title = doc?.record?.rows?.title?.val
154
+ if (typeof title === 'string' && title.trim().length > 0) out.set(name.slice(0, -'.json'.length), title.trim())
155
+ } catch {
156
+ /* a broken cache file only costs us a title */
157
+ }
158
+ }
159
+ return out
160
+ }
161
+
162
+ function titleFromEvents(events) {
163
+ for (const event of events) {
164
+ if (event?.type !== 'message' || !isPlainObject(event.data)) continue
165
+ const role = event.data.role
166
+ if (role !== 'user') continue
167
+ const content = event.data.content
168
+ const text = typeof content === 'string'
169
+ ? content
170
+ : Array.isArray(content)
171
+ ? content.map((part) => (typeof part === 'string' ? part : typeof part?.text === 'string' ? part.text : '')).join(' ')
172
+ : ''
173
+ const flat = text.replace(/\s+/g, ' ').trim()
174
+ if (flat.length > 0) return flat.slice(0, 80)
175
+ }
176
+ return undefined
177
+ }
178
+
179
+ function sourceCreatedAt(source) {
180
+ const value = source?.createdAt
181
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : Date.now()
182
+ }
183
+
184
+ /**
185
+ * Content fingerprint of a session, stable across the id rewrite every import
186
+ * performs.
187
+ *
188
+ * Two devices with auto-pull enabled would otherwise bounce the same
189
+ * conversation back and forth: A pushes to B, B's auto-pull imports it under a
190
+ * fresh id, A's auto-pull imports that copy under another fresh id, forever.
191
+ * Fingerprinting the payload lets the importer notice "I already have this
192
+ * conversation" without trusting ids.
193
+ *
194
+ * @param {object} session - wire session header.
195
+ * @param {object[]} events - wire events.
196
+ * @returns {string} short hex digest.
197
+ */
198
+ export function sessionSignature(session, events) {
199
+ const list = Array.isArray(events) ? events : []
200
+ const first = list[0]
201
+ const last = list[list.length - 1]
202
+ const shape = [
203
+ typeof session?.createdAt === 'number' ? session.createdAt : null,
204
+ typeof session?.agentPreset === 'string' ? session.agentPreset : null,
205
+ list.length,
206
+ typeof first?.seq === 'number' ? first.seq : 0,
207
+ typeof first?.time === 'number' ? first.time : null,
208
+ typeof first?.type === 'string' ? first.type : null,
209
+ preview(first),
210
+ typeof last?.time === 'number' ? last.time : null,
211
+ typeof last?.type === 'string' ? last.type : null,
212
+ ]
213
+ return crypto.createHash('sha1').update(JSON.stringify(shape)).digest('hex').slice(0, 20)
214
+ }
215
+
216
+ function preview(event) {
217
+ const data = event?.data
218
+ if (typeof data === 'string') return data.slice(0, 160)
219
+ if (isPlainObject(data)) {
220
+ const content = data.content ?? data.text ?? data.message ?? null
221
+ if (typeof content === 'string') return content.slice(0, 160)
222
+ if (Array.isArray(content)) {
223
+ const text = content
224
+ .map((part) => (typeof part === 'string' ? part : typeof part?.text === 'string' ? part.text : ''))
225
+ .join('')
226
+ return text.slice(0, 160)
227
+ }
228
+ }
229
+ return null
230
+ }
231
+
232
+ /**
233
+ * SHA-256 of one wire payload, so the receiver can prove it got the bytes the
234
+ * sender meant to send. Hashed over exactly what travels on the wire
235
+ * (`{format, session, events}`); `JSON.stringify` of a parsed object keeps the
236
+ * sender's key order, so both sides hash the same string.
237
+ *
238
+ * @param {object} session - wire session header.
239
+ * @param {object[]} events - wire events.
240
+ * @returns {string} 64-char lowercase hex digest.
241
+ */
242
+ export function sessionDigest(session, events) {
243
+ return crypto
244
+ .createHash('sha256')
245
+ .update(JSON.stringify({ format: WIRE_FORMAT, session, events }))
246
+ .digest('hex')
247
+ }
248
+
249
+ function checksumMatches(payload) {
250
+ const expected = typeof payload?.sha256 === 'string' ? payload.sha256.toLowerCase() : null
251
+ if (expected === null || expected.length === 0) return { verified: false, digest: null, ok: true }
252
+ const actual = sessionDigest(payload.session, payload.events)
253
+ return { verified: true, digest: actual, ok: actual === expected }
254
+ }
255
+
256
+ export class SessionBridge {
257
+ /**
258
+ * @param {object} ctx - host plugin context.
259
+ * @param {{ home?: string, onImported?: (signature: string, localId: string) => void, onExported?: (signature: string, localId: string) => void }} [options]
260
+ */
261
+ constructor(ctx, options = {}) {
262
+ const home = dshHome(options)
263
+ this.ctx = ctx
264
+ this.onImported = typeof options.onImported === 'function' ? options.onImported : null
265
+ this.onExported = typeof options.onExported === 'function' ? options.onExported : null
266
+ this.home = home
267
+ this.storeRoot = path.join(home, 'sessions')
268
+ this._titleCache = new Map()
269
+ this._titleCacheAt = 0
270
+ }
271
+
272
+ get query() {
273
+ const service = this.ctx?.get?.('sessionQuery') ?? this.ctx?.sessionQuery
274
+ return service
275
+ }
276
+
277
+ get persistence() {
278
+ const service = this.ctx?.get?.('sessionPersistence') ?? this.ctx?.sessionPersistence
279
+ return service
280
+ }
281
+
282
+ get hasPersistence() {
283
+ return this.persistence !== undefined && this.persistence !== null
284
+ }
285
+
286
+ /** Number of stored sessions, without reading any log or title. */
287
+ async count() {
288
+ const persistence = this.persistence
289
+ if (persistence !== undefined && persistence !== null) {
290
+ try {
291
+ const all = await persistence.list()
292
+ return Array.isArray(all) ? all.length : 0
293
+ } catch {
294
+ /* fall through to the directory scan */
295
+ }
296
+ }
297
+ return scanStore(this.storeRoot).size
298
+ }
299
+
300
+ /** Header + event log for one session, replay-validated when services exist. */
301
+ async read(id) {
302
+ const query = this.query
303
+ if (query !== undefined && query !== null) {
304
+ const snapshot = await query.readSession(id)
305
+ return {
306
+ session: snapshot.session ?? snapshot.header,
307
+ events: snapshot.events ?? [],
308
+ inheritedEventCount: snapshot.inheritedEventCount ?? 0,
309
+ }
310
+ }
311
+ const scanned = scanStore(this.storeRoot).get(id)
312
+ if (scanned === undefined) throw new SessionSyncError('session-not-found', `会话不存在: ${id}`, 404)
313
+ const header = readHeader(scanned.file)
314
+ if (header === undefined) throw new SessionSyncError('session-unreadable', `无法解析会话头: ${id}`, 500)
315
+ return { session: header, events: [], inheritedEventCount: 0 }
316
+ }
317
+
318
+ /**
319
+ * List sessions newest-first with the metadata the UI needs.
320
+ * @param {{ limit?: number }} [options]
321
+ */
322
+ async list(options = {}) {
323
+ const limit = options.limit ?? 200
324
+ const query = this.query
325
+ if (query !== undefined && query !== null) {
326
+ const records = await query.listSessions()
327
+ const sliced = records.slice(0, limit)
328
+ const ids = sliced.map((record) => record.header.id)
329
+ const titles = await this._titles(ids)
330
+ const scanned = scanStore(this.storeRoot)
331
+ return sliced.map((record) => {
332
+ const header = record.header
333
+ const disk = scanned.get(header.id)
334
+ return {
335
+ id: header.id,
336
+ title: titles.get(header.id) ?? null,
337
+ cwd: header.cwd ?? null,
338
+ createdAt: header.createdAt ?? null,
339
+ updatedAt: disk?.mtime ?? null,
340
+ sizeBytes: disk?.size ?? null,
341
+ live: record.live === true,
342
+ persisted: record.persisted === true,
343
+ parentSession: header.parentSession ?? null,
344
+ }
345
+ })
346
+ }
347
+ return this._fallbackList(limit)
348
+ }
349
+
350
+ async _fallbackList(limit) {
351
+ const scanned = scanStore(this.storeRoot)
352
+ const titles = cachedTitles(this.storeRoot)
353
+ const rows = []
354
+ for (const [id, disk] of scanned) {
355
+ const header = readHeader(disk.file)
356
+ rows.push({
357
+ id,
358
+ title: titles.get(id) ?? null,
359
+ cwd: header?.cwd ?? null,
360
+ createdAt: header?.createdAt ?? null,
361
+ updatedAt: disk.mtime,
362
+ sizeBytes: disk.size,
363
+ live: false,
364
+ persisted: true,
365
+ parentSession: header?.parentSession ?? null,
366
+ })
367
+ }
368
+ rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
369
+ return rows.slice(0, limit)
370
+ }
371
+
372
+ async _titles(ids) {
373
+ const now = Date.now()
374
+ const cache = this._titleCache
375
+ if (now - this._titleCacheAt > 60_000) {
376
+ cache.clear()
377
+ this._titleCacheAt = now
378
+ }
379
+ // Most sessions never write a `session/title` event; DSH keeps their names
380
+ // in its projection cache. Fold that in so the list stops looking untitled.
381
+ const projected = cachedTitles(this.storeRoot)
382
+ const fallback = (id) => projected.get(id) ?? null
383
+ const missing = ids.filter((id) => !cache.has(id))
384
+ const query = this.query
385
+ if (query !== undefined && query !== null && missing.length > 0) {
386
+ try {
387
+ for (let offset = 0; offset < missing.length; offset += 100) {
388
+ const batch = missing.slice(offset, offset + 100)
389
+ const snapshots = await query.readTitleSnapshots(batch)
390
+ for (const result of snapshots) {
391
+ // readTitleSnapshots returns settled results, not bare snapshots:
392
+ // {status:'fulfilled', value:{session,title}} | {status:'rejected'}.
393
+ const value = result !== null && typeof result === 'object' && 'status' in result
394
+ ? (result.status === 'fulfilled' ? result.value : null)
395
+ : result
396
+ const id = value?.session?.id
397
+ if (typeof id !== 'string') continue
398
+ const title = value?.title
399
+ cache.set(id, typeof title === 'string' && title.trim().length > 0 ? title.trim() : fallback(id))
400
+ }
401
+ for (const id of batch) if (!cache.has(id)) cache.set(id, fallback(id))
402
+ }
403
+ } catch {
404
+ for (const id of missing) if (!cache.has(id)) cache.set(id, fallback(id))
405
+ }
406
+ }
407
+ return new Map(ids.map((id) => [id, cache.get(id) ?? fallback(id)]))
408
+ }
409
+
410
+ /** Latest title for one session, or null when the log never named it. */
411
+ async titleOf(id) {
412
+ const titles = await this._titles([id])
413
+ return titles.get(id) ?? null
414
+ }
415
+
416
+ /** Export one session in the wire format used between devices. */
417
+ async exportSession(id) {
418
+ const snapshot = await this.read(id)
419
+ const header = snapshot.session
420
+ if (!isPlainObject(header)) throw new SessionSyncError('session-unreadable', `会话头无效: ${id}`, 500)
421
+ // Exporting is the moment this conversation leaves the machine: fingerprint
422
+ // it so a copy coming back later is recognised as "already mine".
423
+ this.onExported?.(sessionSignature(header, snapshot.events), id)
424
+ return {
425
+ format: WIRE_FORMAT,
426
+ session: header,
427
+ events: snapshot.events,
428
+ sha256: sessionDigest(header, snapshot.events),
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Store a session received from another device as a normal local session.
434
+ *
435
+ * A new id is minted so two devices can each keep their own copy without
436
+ * fighting over the same log; parent links are dropped because the parent
437
+ * may not exist here.
438
+ *
439
+ * @param {{ session: object, events: object[] }} payload - wire payload.
440
+ * @param {{ cwd?: string }} [options]
441
+ * @returns {Promise<{ id: string, events: number }>}
442
+ */
443
+ async importSession(payload, options = {}) {
444
+ if (!isPlainObject(payload?.session)) throw new SessionSyncError('bad-payload', '缺少会话头', 400)
445
+ if (!Array.isArray(payload?.events)) throw new SessionSyncError('bad-payload', '缺少事件列表', 400)
446
+ const persistence = this.persistence
447
+ if (persistence === undefined || persistence === null) {
448
+ throw new SessionSyncError('no-persistence', '当前运行环境没有会话存储服务,无法导入', 501)
449
+ }
450
+ const checksum = checksumMatches(payload)
451
+ if (!checksum.ok) {
452
+ throw new SessionSyncError(
453
+ 'checksum-mismatch',
454
+ `会话校验失败:sha256 不匹配(期望 ${String(payload.sha256).slice(0, 12)}…,实际 ${checksum.digest.slice(0, 12)}…)`,
455
+ 409,
456
+ )
457
+ }
458
+ const source = payload.session
459
+ const id = `session-${crypto.randomUUID()}`
460
+ // The v4 header accepts exactly these keys — `type` is storage-owned
461
+ // framing that dsh-session-persistence-jsonl writes for us, and one stray
462
+ // key makes the whole import fail with "unexpected field".
463
+ const header = {
464
+ version: 4,
465
+ id,
466
+ // Keep the source creation time: it is what makes a copied session
467
+ // recognisable, and it keeps the timeline honest in the local list.
468
+ createdAt: sourceCreatedAt(source),
469
+ cwd: importCwd(source, options),
470
+ isSeeded: false,
471
+ delegationDepth: 0,
472
+ agentPreset: typeof source.agentPreset === 'string' && source.agentPreset.length > 0
473
+ ? source.agentPreset
474
+ : 'standard',
475
+ }
476
+ // An imported copy is always unseeded; keep only what the source authored
477
+ // itself, otherwise the inherited end-seed marker makes the log unreadable.
478
+ const own = stripInheritedPrefix(payload.events.filter((event) => isPlainObject(event)))
479
+ const events = own.map((event, index) => ({ ...event, seq: index }))
480
+ const handle = await persistence.create(header)
481
+ let closed = false
482
+ try {
483
+ if (events.length > 0) await handle.append(events)
484
+ await handle.flush()
485
+ } finally {
486
+ if (!closed) {
487
+ closed = true
488
+ await handle.close().catch(() => undefined)
489
+ }
490
+ }
491
+ const signature = sessionSignature(source, payload.events)
492
+ this.onImported?.(signature, id)
493
+ return {
494
+ id,
495
+ events: events.length,
496
+ signature,
497
+ sha256: checksum.digest ?? sessionDigest(source, payload.events),
498
+ verified: checksum.verified,
499
+ }
500
+ }
501
+ }