dsh-synapse 0.5.1 → 0.5.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.
Files changed (4) hide show
  1. package/app.js +19 -2
  2. package/client.js +13 -4
  3. package/index.js +109 -4
  4. package/package.json +1 -1
package/app.js CHANGED
@@ -268,6 +268,24 @@ function openNewSession() {
268
268
  }
269
269
 
270
270
  async function archiveThread(thread) {
271
+ // A DSH-backed thread is archived in DSH itself, so the sidebar and this map
272
+ // share one archive set (issue #21 / #19). Keeping a second local delete is
273
+ // what made the map claim "archived" while DSH still listed the session.
274
+ if (thread.dshSessionId !== null) {
275
+ if (!window.confirm(`在 DSH 中归档「${thread.title}」及其分支?DSH 侧会话也会被归档。`)) return
276
+ try {
277
+ await dshRpc('synapse:archive-session', { sessionId: thread.dshSessionId })
278
+ } catch (error) {
279
+ setError(error instanceof Error ? error.message : '归档失败')
280
+ return
281
+ }
282
+ state.archivedSessionIds.add(thread.dshSessionId)
283
+ state.activeId = state.activeId === thread.id ? (state.workspace?.threads.find(item => item.id !== thread.id)?.id ?? null) : state.activeId
284
+ if (state.inspectorCardId !== null && state.canvasCardsById?.get(state.inspectorCardId)?.dshThreadId === thread.id) state.inspectorCardId = null
285
+ render()
286
+ await refreshSummaries()
287
+ return
288
+ }
271
289
  if (!window.confirm(`归档画布中的「${thread.title}」及其分支?DSH 原会话会保留,可在 DSH 内继续查看。`)) return
272
290
  await api(`/synapse/api/threads/${thread.id}`, { method: 'DELETE' })
273
291
  state.historyBySession.delete(thread.dshSessionId)
@@ -303,8 +321,6 @@ async function archiveThread(thread) {
303
321
  } else {
304
322
  state.activeId = null
305
323
  }
306
- // Archive in DSH as well, so both sides agree on one archive set (#21 / #19).
307
- if (thread.dshSessionId !== null) post('synapse:archive-session', { sessionId: thread.dshSessionId })
308
324
  await refreshSummaries()
309
325
  }
310
326
 
@@ -1872,6 +1888,7 @@ window.addEventListener('message', event => {
1872
1888
  }
1873
1889
  }
1874
1890
  if (data.type === 'synapse:forked-session' || data.type === 'synapse:created-session' || data.type === 'synapse:message-sent') settleRpc(data.requestId, data.session ?? data)
1891
+ if (data.type === 'synapse:archived-session') settleRpc(data.requestId, data)
1875
1892
  if (data.type === 'synapse:bridge-error') { settleRpc(data.requestId, undefined, new Error(data.message)); if (data.requestId === undefined) setError(data.message) }
1876
1893
  })
1877
1894
 
package/client.js CHANGED
@@ -316,14 +316,23 @@ window.__ModuleLoader__.load({
316
316
  }
317
317
  if (event.data.type === 'synapse:archive-session') {
318
318
  // One archive set only: archive through the Workspace action so DSH's
319
- // sidebar and this map agree, then report the refreshed set.
319
+ // sidebar and this map agree, then acknowledge the request so the map
320
+ // can hide the card instead of silently diverging (#21 / #19).
320
321
  const sessionId = event.data.sessionId
322
+ const requestId = event.data.requestId
321
323
  if (typeof sessionId !== 'string' || sessionId === '') return
322
- if (typeof ctx.workspaces.archiveSession !== 'function') return send('synapse:bridge-error', { message: '当前 DSH 版本不支持归档接口' })
324
+ if (typeof ctx.workspaces.archiveSession !== 'function') {
325
+ return send('synapse:bridge-error', { requestId, message: '当前 DSH 版本不支持归档接口' })
326
+ }
323
327
  Promise.resolve()
324
328
  .then(() => ctx.workspaces.archiveSession(sessionId))
325
- .then(() => send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) }))
326
- .catch(() => send('synapse:bridge-error', { message: 'DSH 归档失败,请稍后重试' }))
329
+ .then(() => {
330
+ send('synapse:archived-session', { requestId, sessionId })
331
+ send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) })
332
+ })
333
+ .catch(error => {
334
+ send('synapse:bridge-error', { requestId, message: `DSH 归档失败:${error instanceof Error ? error.message : String(error)}` })
335
+ })
327
336
  return
328
337
  }
329
338
  if (event.data.type === 'synapse:fork-session') {
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
1
+ import { copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
2
2
  import { randomUUID } from 'node:crypto'
3
- import { dirname } from 'node:path'
3
+ import { dirname, join } from 'node:path'
4
+ import { zstdDecompressSync } from 'node:zlib'
4
5
 
5
6
  export const name = 'synapse'
6
7
  export const inject = ['webServer', 'sessions']
@@ -805,6 +806,101 @@ export function bodiesFromEvents(thread, events, seqs) {
805
806
  return messages
806
807
  }
807
808
 
809
+ // ---------------------------------------------------------------------------
810
+ // Session logs on disk
811
+ //
812
+ // `ctx.sessions` only exposes sessions this process currently holds, which is
813
+ // one or two out of a workspace's history. Everything else is read from the DSH
814
+ // session log: a run of concatenated zstd frames holding JSONL events.
815
+ // ---------------------------------------------------------------------------
816
+
817
+ const SESSION_LOG_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
818
+ const SESSION_LOG_PATTERN = /^session(?:\.[\w-]+)*\.jsonl\.zstd$/
819
+
820
+ /** Decode the concatenated zstd frames of one session log into JSONL text. */
821
+ export function decodeSessionLog(buffer) {
822
+ if (typeof zstdDecompressSync !== 'function') return null
823
+ const offsets = []
824
+ let index = buffer.indexOf(SESSION_LOG_MAGIC, 0)
825
+ while (index !== -1) {
826
+ offsets.push(index)
827
+ index = buffer.indexOf(SESSION_LOG_MAGIC, index + 4)
828
+ }
829
+ if (offsets.length === 0) return null
830
+ const parts = []
831
+ for (let i = 0; i < offsets.length; i += 1) {
832
+ const end = i + 1 < offsets.length ? offsets[i + 1] : buffer.length
833
+ try {
834
+ parts.push(zstdDecompressSync(buffer.subarray(offsets[i], end)))
835
+ } catch {
836
+ // A log that is being appended to can end mid-frame; decode what is whole.
837
+ break
838
+ }
839
+ }
840
+ if (parts.length === 0) return null
841
+ return Buffer.concat(parts).toString('utf8')
842
+ }
843
+
844
+ /** Reads session events from `$DSH_HOME/sessions`, caching by file mtime. */
845
+ export class SessionLogReader {
846
+ constructor(directory) {
847
+ this.directory = directory
848
+ this.cache = new Map()
849
+ }
850
+
851
+ /** Events of one session, or null when its log is missing or unreadable. */
852
+ async events(sessionId) {
853
+ if (typeof this.directory !== 'string' || this.directory === '' || typeof sessionId !== 'string' || sessionId === '') return null
854
+ const file = await this.findLog(sessionId)
855
+ if (file === null) {
856
+ this.cache.delete(sessionId)
857
+ return null
858
+ }
859
+ const info = await stat(file).catch(() => null)
860
+ if (info === null) return null
861
+ const cached = this.cache.get(sessionId)
862
+ if (cached !== undefined && cached.mtimeMs === info.mtimeMs && cached.size === info.size) return cached.events
863
+ const text = decodeSessionLog(await readFile(file))
864
+ if (text === null) return null
865
+ const events = []
866
+ for (const line of text.split('\n')) {
867
+ if (line.trim() === '') continue
868
+ try {
869
+ const event = JSON.parse(line)
870
+ if (typeof event?.seq === 'number') events.push(event)
871
+ } catch {
872
+ // Trailing partial line while the session is still being written.
873
+ }
874
+ }
875
+ // Keep a few decoded logs; one screenful of cards touches few threads.
876
+ if (this.cache.size >= 4) this.cache.delete(this.cache.keys().next().value)
877
+ this.cache.set(sessionId, { mtimeMs: info.mtimeMs, size: info.size, events })
878
+ return events
879
+ }
880
+
881
+ async findLog(sessionId) {
882
+ let workspaces
883
+ try {
884
+ workspaces = await readdir(this.directory, { withFileTypes: true })
885
+ } catch {
886
+ return null
887
+ }
888
+ for (const workspace of workspaces) {
889
+ if (!workspace.isDirectory()) continue
890
+ const sessionDirectory = join(this.directory, workspace.name, sessionId)
891
+ let files
892
+ try {
893
+ files = await readdir(sessionDirectory)
894
+ } catch {
895
+ continue
896
+ }
897
+ const log = files.find(name => SESSION_LOG_PATTERN.test(name))
898
+ if (log !== undefined) return join(sessionDirectory, log)
899
+ }
900
+ return null
901
+ }
902
+ }
903
+
808
904
  function errorText(value) {
809
905
  if (typeof value === 'string') return value.trim() || null
810
906
  if (value === null || value === undefined || typeof value !== 'object') return null
@@ -915,6 +1011,12 @@ function page() {
915
1011
  /** Mount Synapse routes on the existing DSH Web Server. */
916
1012
  export function apply(ctx, config) {
917
1013
  const store = new WorkspaceStore(config?.dataFile)
1014
+ // A profile can point the plugin at a different home; by default the session
1015
+ // logs sit beside the workspace file ($DSH_HOME/sessions).
1016
+ const sessionsDirectory = typeof config?.sessionsDir === 'string' && config.sessionsDir !== ''
1017
+ ? config.sessionsDir
1018
+ : join(dirname(dirname(config?.dataFile ?? '')), 'sessions')
1019
+ const sessionLog = new SessionLogReader(sessionsDirectory)
918
1020
  const autoProjection = config?.autoProjection !== false
919
1021
  const projectionWorkspaceTitle = typeof config?.projectionWorkspaceTitle === 'string' && config.projectionWorkspaceTitle.trim() !== ''
920
1022
  ? config.projectionWorkspaceTitle.trim().slice(0, MAX_TITLE_LENGTH)
@@ -974,8 +1076,11 @@ export function apply(ctx, config) {
974
1076
  return { messages }
975
1077
  }
976
1078
  const session = ctx.sessions.list().find(candidate => candidate.id === thread.dshSessionId)
977
- if (session === undefined) return { messages: {}, unavailable: true }
978
- return { messages: bodiesFromEvents(thread, session.snapshotEvents(), seqs) }
1079
+ if (session !== undefined) return { messages: bodiesFromEvents(thread, session.snapshotEvents(), seqs) }
1080
+ // Closed session: read its log from disk, which is where the body lives.
1081
+ const events = await sessionLog.events(thread.dshSessionId)
1082
+ if (events === null) return { messages: {}, unavailable: true }
1083
+ return { messages: bodiesFromEvents(thread, events, seqs) }
979
1084
  }
980
1085
  // The DSH /api browser-trust fence does not cover /synapse routes, so this
981
1086
  // handler checks the Host header itself: localhost is allowed by default and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-synapse",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "A visual, non-linear conversation workspace plugin for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "index.js",