dsh-synapse 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,11 +22,24 @@
22
22
 
23
23
  ## 快速安装
24
24
 
25
- 需要 DeepSeek Harness `>= 0.1.7-rc.2`(本插件依赖该版本的 Host 会话与 Client 服务接口)、Node.js `>= 22.19.0`,以及 `web` profile。
25
+ 需要 DeepSeek Harness `>= 0.1.7-rc.2`(本插件依赖该版本的 Host 会话与 Client 服务接口)和 Node.js `>= 22.19.0`。
26
26
 
27
27
  > **兼容性提示:** 本版本包含破坏性变更,仅支持 DeepSeek Harness `>= 0.1.7-rc.2`,不再兼容更早版本。
28
28
 
29
+ **先确认插件要装进哪一套 profile**。DSH 按 profile 分套安装插件,桌面 App 和命令行各用一套,互不影响:
30
+
31
+ | 你平时怎么用 DSH | 要装的 profile | 安装命令 |
32
+ |---|---|---|
33
+ | 桌面 App(DeepSeek Harness 客户端) | `desktop` | `corepack pnpm dsh plugin --profile desktop add dsh-synapse` |
34
+ | 命令行 `dsh web` / 浏览器访问 | `web` | `corepack pnpm dsh plugin --profile web add dsh-synapse` |
35
+ | 两种都用 | 两套各装一次 | 两条命令都执行 |
36
+
37
+ - 从 App 内的插件页面安装时,App 会自动装进它自己用的那套,不用管 profile。
38
+ - **自检口诀:装完顶部看不到「会话地图」开关,就是装到另一套去了**,换成另一条命令再装一次即可。
39
+ - 桌面 App 装完需要**重启 App**(Host 侧插件在启动时加载);命令行装的,重启 `dsh web` 并刷新页面。
40
+
29
41
  ```powershell
42
+ # 命令行方式:装好后启动
30
43
  corepack pnpm dsh plugin --profile web add dsh-synapse
31
44
  corepack pnpm dsh web
32
45
  ```
@@ -60,9 +73,9 @@ corepack pnpm dsh web
60
73
  ## 与 DSH 的边界
61
74
 
62
75
  - DSH session log 保存真实会话内容;Synapse 只投影已提交的事件。
63
- - Synapse 的画布布局数据保存在 `$DSH_HOME/synapse/`,删除它不会删除 DSH 会话。
76
+ - Synapse 只保存画布布局和每轮对话的短预览,消息正文始终从 DSH 会话记录按需读取;`$DSH_HOME/synapse/` 删除后不会丢会话,只会丢布局。
64
77
  - 插件不修改 prompt、模型请求、工具 schema、provider 路由或可复用 KV-cache 前缀。
65
- - 内置 patch 仅支持 DSH 的 `web` profile。
78
+ - 内置 patch 由 profile 的 bundle 列表引入,桌面 App(`desktop`)与命令行(`web`)都适用。
66
79
 
67
80
  ## 开发
68
81
 
package/app.js CHANGED
@@ -57,6 +57,10 @@ const state = {
57
57
  summaries: [], workspace: null, activeId: null, selectedCardId: null, mode: 'canvas', zoom: 1, currentDsh: null, sidebarCollapsed: false,
58
58
  dshWorkspaces: [], selectedDshWorkspaceId: null,
59
59
  historyBySession: new Map(), historyRequests: new Map(), pendingReplies: new Map(), pendingRpc: new Map(), liveReplies: new Map(),
60
+ // Message bodies are not part of the workspace payload; they are fetched per
61
+ // screenful and cached here (issue #42).
62
+ bodies: new Map(), bodyRequests: new Set(), bodyFailures: new Set(), bodiesLoaded: new Set(),
63
+ archivedSessionIds: new Set(),
60
64
  draft: null, error: '', workspaceLoad: 0, branchAnchors: new Map(savedBranchAnchors), cardPositions: new Map(savedCardPositions), collapsedCardIds: new Set(savedCollapsedCards), quickPhrases: savedQuickPhrases, quickPhraseEditorOpen: false,
61
65
  dragging: false, canvasGesture: false, canvasRefreshAfter: 0, canvasViewInitialized: false, canvasCamera: { x: 0, y: 0 }, mapCardSessionSwitches: new Set(),
62
66
  expandedMessageIds: new Set(),
@@ -299,6 +303,8 @@ async function archiveThread(thread) {
299
303
  } else {
300
304
  state.activeId = null
301
305
  }
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 })
302
308
  await refreshSummaries()
303
309
  }
304
310
 
@@ -392,10 +398,25 @@ async function submitDraft() {
392
398
  }
393
399
 
394
400
  function threadsById() { return new Map((state.workspace?.threads ?? []).map(thread => [thread.id, thread])) }
401
+
402
+ /**
403
+ * A session archived in DSH must not keep drawing cards here: the canvas and
404
+ * the sidebar both read this filtered list (issue #21 / #19). Threads without a
405
+ * DSH session (canvas-only notes) are never hidden by DSH's state.
406
+ */
407
+ function visibleThreads(threads) {
408
+ if (state.archivedSessionIds.size === 0) return threads
409
+ return threads.filter(thread => thread.dshSessionId === null || !state.archivedSessionIds.has(thread.dshSessionId))
410
+ }
395
411
  function persistedMessagesFor(thread) { return state.historyBySession.get(thread.dshSessionId) ?? thread.messages ?? [] }
396
412
 
397
413
  function pendingUserIndex(messages, pending) {
398
- return messages.findLastIndex(message => message.kind === 'user' && message.text === pending.text && new Date(message.at).getTime() >= pending.at - 2_000)
414
+ // The stored copy may be a preview, so match by full length and by the
415
+ // preview prefix instead of by exact text equality.
416
+ return messages.findLastIndex(message => message.kind === 'user'
417
+ && new Date(message.at).getTime() >= pending.at - 2_000
418
+ && (message.text === pending.text
419
+ || (typeof message.text === 'string' && pending.text.startsWith(message.text.replace(/…$/, '')))))
399
420
  }
400
421
 
401
422
  function settlePendingReply(thread, messages) {
@@ -411,7 +432,15 @@ function messagesFor(thread) {
411
432
  // A runtime-context snapshot is internal DSH state, never a user turn.
412
433
  // Filter here as well as during persistence so existing saved workspaces
413
434
  // immediately render one question and its answer as one card.
414
- const messages = persistedMessagesFor(thread).filter(message => !(message.kind === 'user' && typeof message.text === 'string' && message.text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')))
435
+ const stored = persistedMessagesFor(thread).filter(message => !(message.kind === 'user' && typeof message.text === 'string' && message.text.trimStart().startsWith('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.')))
436
+ // Swap in hydrated bodies where present: the stored text is only a preview.
437
+ const bodies = state.bodies.get(thread.id)
438
+ const messages = bodies === undefined || bodies.size === 0
439
+ ? stored
440
+ : stored.map(message => {
441
+ const body = bodies.get(message.sourceSeq)
442
+ return body === undefined ? message : { ...message, text: body.text, process: Array.isArray(body.process) ? body.process : message.process, truncated: body.truncated === true, hydrated: true }
443
+ })
415
444
  const pending = state.pendingReplies.get(thread.dshSessionId)
416
445
  if (pending === undefined) return messages
417
446
  if (settlePendingReply(thread, messages)) {
@@ -700,6 +729,7 @@ function conversationCards(threads) {
700
729
  position,
701
730
  positionLocked,
702
731
  question: question.text,
732
+ questionMessage: question,
703
733
  answer,
704
734
  error,
705
735
  processCount,
@@ -1034,7 +1064,7 @@ function syncCanvasViewport() {
1034
1064
  }
1035
1065
 
1036
1066
  function renderCanvas() {
1037
- const threads = state.workspace?.threads ?? []
1067
+ const threads = visibleThreads(state.workspace?.threads ?? [])
1038
1068
  if (threads.length === 0 && state.draft?.kind !== 'new') return `<section class="empty-canvas"><strong>当前工作目录还没有 DSH 对话。</strong><p>点击新会话,在画布中输入第一条消息。</p><div><button class="primary" type="button" data-action="create-session">新建会话</button></div></section>`
1039
1069
  const allCards = conversationCards(threads)
1040
1070
  const graph = conversationGraphView(allCards)
@@ -1194,7 +1224,7 @@ function render() {
1194
1224
  }
1195
1225
  }
1196
1226
  const workspace = state.workspace
1197
- const threads = workspace?.threads ?? []
1227
+ const threads = visibleThreads(workspace?.threads ?? [])
1198
1228
  const view = state.mode === 'thread' ? renderThread() : renderCanvas()
1199
1229
  const choices = workspaceChoices()
1200
1230
  const selectedWorkspaceId = state.selectedDshWorkspaceId ?? workspace?.id
@@ -1239,6 +1269,74 @@ function render() {
1239
1269
  if (target instanceof HTMLElement) target.scrollIntoView({ block: 'start' })
1240
1270
  })
1241
1271
  }
1272
+ // Card faces and the detail view only hold previews: pull the real bodies for
1273
+ // whatever is on screen right now (issue #42).
1274
+ void hydrateBodies()
1275
+ }
1276
+
1277
+ /**
1278
+ * Ask the Host for the bodies of the messages currently on screen. The
1279
+ * workspace payload carries previews only, so each newly mounted card and each
1280
+ * opened detail view hydrates from the DSH session log.
1281
+ */
1282
+ async function hydrateBodies() {
1283
+ const wanted = new Map()
1284
+ const add = (threadId, message) => {
1285
+ if (threadId === undefined || message === null || message === undefined) return
1286
+ if (!Number.isInteger(message.sourceSeq)) return
1287
+ if (state.bodies.get(threadId)?.has(message.sourceSeq) === true) return
1288
+ const key = `${threadId}:${message.sourceSeq}`
1289
+ if (state.bodyRequests.has(key) || state.bodyFailures.has(key)) return
1290
+ const seqs = wanted.get(threadId) ?? new Set()
1291
+ seqs.add(message.sourceSeq)
1292
+ wanted.set(threadId, seqs)
1293
+ }
1294
+ const addCard = card => {
1295
+ if (card === undefined) return
1296
+ add(card.dshThreadId, card.questionMessage)
1297
+ add(card.dshThreadId, card.answer)
1298
+ add(card.dshThreadId, card.error)
1299
+ }
1300
+ if (state.mode === 'thread' && state.activeId !== null) {
1301
+ const thread = currentThread()
1302
+ if (thread !== null && !state.bodiesLoaded.has(thread.id)) {
1303
+ for (const message of persistedMessagesFor(thread)) add(thread.id, message)
1304
+ }
1305
+ } else if (state.mode === 'canvas') {
1306
+ for (const cardId of state.mountedCardIds) addCard(state.canvasCardsById?.get(cardId))
1307
+ }
1308
+ if (state.inspectorCardId !== null) addCard(state.canvasCardsById?.get(state.inspectorCardId))
1309
+ for (const [threadId, seqs] of wanted) void fetchBodies(threadId, [...seqs])
1310
+ }
1311
+
1312
+ async function fetchBodies(threadId, seqs) {
1313
+ if (seqs.length === 0) return
1314
+ const keys = seqs.map(seq => `${threadId}:${seq}`)
1315
+ for (const key of keys) state.bodyRequests.add(key)
1316
+ try {
1317
+ const body = await api(`/synapse/api/threads/${threadId}/full?seqs=${seqs.join(',')}`)
1318
+ const cached = state.bodies.get(threadId) ?? new Map()
1319
+ for (const [seq, payload] of Object.entries(body.messages ?? {})) {
1320
+ if (payload === null || typeof payload !== 'object') continue
1321
+ cached.set(Number(seq), payload)
1322
+ }
1323
+ state.bodies.set(threadId, cached)
1324
+ if (state.mode === 'thread' && state.activeId === threadId) state.bodiesLoaded.add(threadId)
1325
+ if (body.unavailable === true) {
1326
+ // Archived or already removed from DSH: keep the previews, stop asking.
1327
+ for (const message of persistedMessagesFor(currentThread() ?? { messages: [] })) {
1328
+ if (Number.isInteger(message.sourceSeq)) state.bodyFailures.add(`${threadId}:${message.sourceSeq}`)
1329
+ }
1330
+ state.bodiesLoaded.add(threadId)
1331
+ }
1332
+ if (state.mode === 'thread' || state.inspectorCardId !== null) render()
1333
+ else if (canReplaceView()) render()
1334
+ else deferCanvasRefresh(400)
1335
+ } catch {
1336
+ for (const key of keys) state.bodyFailures.add(key)
1337
+ } finally {
1338
+ for (const key of keys) state.bodyRequests.delete(key)
1339
+ }
1242
1340
  }
1243
1341
 
1244
1342
  function renderPreservingDetailScroll() {
@@ -1340,7 +1438,8 @@ function canvasViewport(target) {
1340
1438
  }
1341
1439
 
1342
1440
  function zoomCanvas(viewport, nextZoom, clientX, clientY) {
1343
- const zoom = Math.min(4, Math.max(.6, Math.round(nextZoom * 100) / 100))
1441
+ // 20% keeps a whole map readable as an overview; 400% is the detail ceiling.
1442
+ const zoom = Math.min(4, Math.max(.2, Math.round(nextZoom * 100) / 100))
1344
1443
  if (zoom === state.zoom) return
1345
1444
  const bounds = viewport.getBoundingClientRect()
1346
1445
  const localX = clientX - bounds.left
@@ -1698,6 +1797,9 @@ window.addEventListener('message', event => {
1698
1797
  }
1699
1798
  if (data.type === 'synapse:workspaces') {
1700
1799
  state.dshWorkspaces = Array.isArray(data.workspaces) ? data.workspaces.filter(workspace => typeof workspace?.id === 'string' && typeof workspace.title === 'string' && Array.isArray(workspace.sessionIds)) : []
1800
+ // DSH owns the archive set; mirror it so an archived session stops drawing
1801
+ // cards here too (issue #21 / #19).
1802
+ state.archivedSessionIds = new Set(Array.isArray(data.archivedSessionIds) ? data.archivedSessionIds.filter(id => typeof id === 'string') : [])
1701
1803
  const current = currentDshWorkspace()
1702
1804
  if (current !== undefined && current.id !== state.selectedDshWorkspaceId) void openDshWorkspace(current.id).catch(setError)
1703
1805
  else if (state.selectedDshWorkspaceId !== null) void openDshWorkspace(state.selectedDshWorkspaceId).catch(setError)
package/client.js CHANGED
@@ -31,6 +31,13 @@ window.__ModuleLoader__.load({
31
31
  { id: 'dsh-ungrouped', title: '未分组', path: null, sessionIds: sessions.ids.filter(id => !accounted.has(id)) },
32
32
  ]
33
33
  }
34
+ // DSH owns which Sessions are archived. The map mirrors that set so an
35
+ // archived Session stops drawing cards, and archives through the same
36
+ // Workspace action instead of keeping a second hidden list (#21 / #19).
37
+ const archivedSessionIds = ctx => {
38
+ const snapshot = ctx.workspaces.list.getSnapshot()
39
+ return Array.isArray(snapshot.archivedSessionIds) ? snapshot.archivedSessionIds.map(String) : []
40
+ }
34
41
 
35
42
  module.exports.inject = ['sessions', 'workspaces', 'uiWorkspace', 'uiConversation']
36
43
  module.exports.apply = ctx => {
@@ -225,7 +232,7 @@ window.__ModuleLoader__.load({
225
232
  publishLiveSessions()
226
233
  syncTheme()
227
234
  if (!overlay.hidden) {
228
- send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
235
+ send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) })
229
236
  send('synapse:current-session', { session: currentSession(ctx) })
230
237
  }
231
238
  }
@@ -261,7 +268,7 @@ window.__ModuleLoader__.load({
261
268
  if (event.data.type === 'synapse:close') return close()
262
269
  if (event.data.type === 'synapse:map-ready') return showMapOverlay()
263
270
  if (event.data.type === 'synapse:request-current') {
264
- send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
271
+ send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) })
265
272
  return send('synapse:current-session', { session: currentSession(ctx) })
266
273
  }
267
274
  if (event.data.type === 'synapse:open-session') {
@@ -307,6 +314,18 @@ window.__ModuleLoader__.load({
307
314
  try { ctx.uiWorkspace.openSession(event.data.sessionId) } catch { send('synapse:bridge-error', { message: NO_SESSION }) }
308
315
  return
309
316
  }
317
+ if (event.data.type === 'synapse:archive-session') {
318
+ // One archive set only: archive through the Workspace action so DSH's
319
+ // sidebar and this map agree, then report the refreshed set.
320
+ const sessionId = event.data.sessionId
321
+ if (typeof sessionId !== 'string' || sessionId === '') return
322
+ if (typeof ctx.workspaces.archiveSession !== 'function') return send('synapse:bridge-error', { message: '当前 DSH 版本不支持归档接口' })
323
+ Promise.resolve()
324
+ .then(() => ctx.workspaces.archiveSession(sessionId))
325
+ .then(() => send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx), archivedSessionIds: archivedSessionIds(ctx) }))
326
+ .catch(() => send('synapse:bridge-error', { message: 'DSH 归档失败,请稍后重试' }))
327
+ return
328
+ }
310
329
  if (event.data.type === 'synapse:fork-session') {
311
330
  const atSeq = Number.isInteger(event.data.atSeq) ? event.data.atSeq : undefined
312
331
  ctx.sessions.fork({ sessionId: event.data.sessionId, atSeq, increaseTitle: true }).then(id => {
@@ -4,16 +4,16 @@
4
4
 
5
5
  `dsh-synapse` is a presentation and organization layer for DeepSeek Harness conversations. It turns existing DSH sessions, turns, and forks into a visual map without replacing the systems that own those conversations.
6
6
 
7
- ## Web profile integration
7
+ ## Profile integration
8
8
 
9
- The package contributes `cordis.patch.yml`, which inserts the `dsh-synapse` service into the DSH `web` profile. It reuses the existing DSH Web server and client runtime.
9
+ The package contributes `cordis.patch.yml`, which inserts the `dsh-synapse` service into whichever DSH profile lists the package as a bundle — `desktop` for the desktop app, `web` for the `dsh web` command. It reuses the existing DSH Web server and client runtime.
10
10
 
11
11
  The plugin:
12
12
 
13
13
  - does not start a second HTTP server;
14
14
  - does not create a second model or agent runtime;
15
15
  - does not replace DSH authentication or permission checks;
16
- - does not support non-Web profiles unless those profiles explicitly add the plugin.
16
+ - stays inert in a profile that does not list it, so installing it into the wrong profile only means the map never appears (see the installation table in the user guide).
17
17
 
18
18
  ## Conversation ownership
19
19
 
@@ -78,10 +78,14 @@ Each user question becomes a conversation card. The following assistant messages
78
78
 
79
79
  Only a turn a person typed becomes a card. The harness also writes its own `user/message` rows — the runtime-context snapshot, the skill catalogue `<system-reminder>` — and those carry a structured `data.source.kind`; injected kinds (`skill-catalog`, plus the runtime snapshot's text marker) are not projected, so they neither occupy a card nor steal the answer from the real question. Existing projections are pruned the same way when the store loads.
80
80
 
81
- Projected card text is capped at 8000 characters. Longer messages receive a truncation marker in the card, while their complete content remains available from the conversation detail view.
81
+ Projected messages keep only a preview (280 characters), not the body. The body already lives in the DSH session log, and a workspace file full of copies is what made one workspace reach several megabytes and stall the canvas (issue #42). The canvas asks the Host for the bodies of the cards it actually mounts (`GET /synapse/api/threads/:id/full`), and the detail view asks for a whole thread at once; when a session is archived or gone from DSH the previews stay and the card is marked unavailable instead of rendering blank.
82
+
83
+ Projected card text is capped at 8000 characters on the detail fetch, while the preview stays short.
82
84
 
83
85
  Projection writes are coalesced during event bursts, and live updates reuse cached Markdown and patch the active card instead of rebuilding the complete canvas. Card coordinates remain visual metadata only and never determine conversation lineage.
84
86
 
87
+ Archiving is owned by DSH. The map mirrors `archivedSessionIds` from the Workspace snapshot to stop drawing cards for archived sessions, and its own archive action calls the Workspace `archiveSession` so both sides share one archive set (issue #21 / #19).
88
+
85
89
  ## Tool process folding
86
90
 
87
91
  Live events pair tool calls and results by `callId` and render them inside the related assistant reply instead of as standalone conversation cards.
@@ -115,7 +119,7 @@ As a result, the plugin has no direct model-experience effect and does not inval
115
119
 
116
120
  ## Operational limitations
117
121
 
118
- - Only the `web` profile is supported by the bundled patch.
122
+ - The bundled patch applies to any profile that lists the package as a bundle (`desktop`, `web`); a profile that does not list it stays untouched.
119
123
  - Canvas metadata and session content have different owners and backup requirements.
120
124
  - A single shared metadata file is not a multi-writer database.
121
125
  - Browser state can be cleared independently from DSH Home data.
package/docs/en/README.md CHANGED
@@ -8,13 +8,31 @@ The plugin does not replace DSH models, tools, sessions, permissions, or the Web
8
8
 
9
9
  - DeepSeek Harness `>= 0.1.7-rc.2`; this revision targets that release's Host session and Client service interfaces.
10
10
  - Node.js `>= 22.19.0`.
11
- - The `web` profile; other profiles are not currently supported.
11
+ - The right profile: the desktop app uses `desktop`, the `dsh web` command uses `web` (see below).
12
+
13
+ ## Which profile to install into
14
+
15
+ DSH installs plugins per profile. Each profile keeps its own plugin list and `node_modules`, so the desktop app and the command line do not affect each other.
16
+
17
+ | How you run DSH | Profile to use |
18
+ |---|---|
19
+ | Desktop app (DeepSeek Harness client) | `desktop` |
20
+ | `dsh web` on the command line | `web` |
21
+ | Both | Install once per profile |
22
+
23
+ - Installing from the app's plugin page already targets the profile the app uses, so you do not pick one there.
24
+ - **Quick self-check: if the "会话地图 / Session map" switch never appears, the plugin went into the other profile** — run the other command and install again.
25
+ - A desktop install takes effect after **restarting the app**; a command-line install takes effect after restarting `dsh web` and refreshing the page.
12
26
 
13
27
  ## Installation
14
28
 
15
29
  ### Install from npm
16
30
 
17
31
  ```powershell
32
+ # Desktop app
33
+ corepack pnpm dsh plugin --profile desktop add dsh-synapse
34
+
35
+ # Command line (dsh web)
18
36
  corepack pnpm dsh plugin --profile web add dsh-synapse
19
37
  ```
20
38
 
@@ -30,7 +48,7 @@ GitHub installs run the package `prepare` script, which validates JavaScript syn
30
48
 
31
49
  ### pnpm 10+ allowBuilds
32
50
 
33
- pnpm 10 and later may block build scripts for Git dependencies by default. If installation is blocked, copy the **complete key printed by pnpm** into the DSH Web profile's `pnpm-workspace.yaml`:
51
+ pnpm 10 and later may block build scripts for Git dependencies by default. If installation is blocked, copy the **complete key printed by pnpm** into the matching profile's `pnpm-workspace.yaml`:
34
52
 
35
53
  ```yaml
36
54
  allowBuilds:
@@ -45,7 +63,7 @@ Use the complete key containing the tarball URL and commit, not the bare package
45
63
  corepack pnpm dsh plugin --profile web add link:E:\path\to\dsh-synapse
46
64
  ```
47
65
 
48
- The `link:` form references the local checkout directly and is recommended for development. In a normal run, restart `dsh web` and refresh the page after editing the plugin.
66
+ The `link:` form references the local checkout directly and is recommended for development. In a normal run, restart the host (the desktop app or `dsh web`) and refresh the page after editing the plugin.
49
67
 
50
68
  ## Start DSH Web
51
69
 
@@ -150,7 +168,7 @@ Does not invalidate. The plugin never changes request headers, system prompts, o
150
168
 
151
169
  ## Known limitations
152
170
 
153
- - Only the `web` profile is supported.
171
+ - Both the `desktop` (desktop app) and `web` (`dsh web`) profiles are supported; any other profile has to add the package to its own bundle list. Installing into the wrong profile does not error — the Session map switch simply never appears.
154
172
  - Two DSH Web instances sharing one profile write the same `workspaces.json`. A cross-process write lock and external-modification warnings exist, but last-writer-wins replacement remains possible; run one instance.
155
173
  - During v3 migration, legacy tool cards pair each call with the next result by order. Live events pair by `callId`.
156
174
 
@@ -8,13 +8,31 @@
8
8
 
9
9
  - DeepSeek Harness `>= 0.1.7-rc.2`(本插件依赖该版本的 Host 会话与 Client 服务接口)。
10
10
  - Node.js `>= 22.19.0`。
11
- - 使用 `web` profile;其他 profile 暂不支持。
11
+ - 选对 profile:桌面 App 用 `desktop`,命令行 `dsh web` 用 `web`(见下节)。
12
+
13
+ ## 装到哪一套 profile
14
+
15
+ DSH 按 profile 分套安装插件:每套 profile 有自己的插件清单和 `node_modules`,桌面 App 和命令行各用一套,互不影响。
16
+
17
+ | 你平时怎么用 DSH | 要装的 profile |
18
+ |---|---|
19
+ | 桌面 App(DeepSeek Harness 客户端) | `desktop` |
20
+ | 命令行 `dsh web` / 浏览器访问 | `web` |
21
+ | 两种都用 | 两套各装一次 |
22
+
23
+ - 从 App 内的插件页面安装时,App 会自动装进它自己用的那套,无需关心 profile。
24
+ - **自检口诀:装完顶部看不到「会话地图」开关,就是装到另一套去了**,换成另一条命令再装一次即可。
25
+ - 桌面 App 装完需要**重启 App**;命令行装的,重启 `dsh web` 并刷新页面。
12
26
 
13
27
  ## 安装
14
28
 
15
29
  ### 从 npm 安装
16
30
 
17
31
  ```powershell
32
+ # 桌面 App
33
+ corepack pnpm dsh plugin --profile desktop add dsh-synapse
34
+
35
+ # 命令行 dsh web
18
36
  corepack pnpm dsh plugin --profile web add dsh-synapse
19
37
  ```
20
38
 
@@ -30,7 +48,7 @@ GitHub 安装会运行本包的 `prepare` 脚本,通过 `node --check` 验证
30
48
 
31
49
  ### pnpm 10+ 的 allowBuilds
32
50
 
33
- pnpm 10 及之后版本默认可能阻止 Git 依赖执行构建脚本。如果安装被拦截,请将 pnpm 输出的**完整键**复制到 DSH Web profile 的 `pnpm-workspace.yaml`:
51
+ pnpm 10 及之后版本默认可能阻止 Git 依赖执行构建脚本。如果安装被拦截,请将 pnpm 输出的**完整键**复制到对应 profile 的 `pnpm-workspace.yaml`:
34
52
 
35
53
  ```yaml
36
54
  allowBuilds:
@@ -45,7 +63,7 @@ allowBuilds:
45
63
  corepack pnpm dsh plugin --profile web add link:E:\path\to\dsh-synapse
46
64
  ```
47
65
 
48
- `link:` 会直接引用本地 checkout,适合开发和调试。普通运行模式下修改代码后,建议重启 `dsh web` 并刷新页面。
66
+ `link:` 会直接引用本地 checkout,适合开发和调试。普通运行模式下修改代码后,建议重启宿主(桌面 App 或 `dsh web`)并刷新页面。
49
67
 
50
68
  ## 启动
51
69
 
@@ -143,7 +161,7 @@ $DSH_HOME/synapse/
143
161
 
144
162
  ## 已知限制
145
163
 
146
- - 仅支持 `web` profile。
164
+ - 支持 `desktop`(桌面 App)与 `web`(命令行 `dsh web`)两套 profile;其他自定义 profile 需要自己把插件加入 bundle 列表。装错 profile 时插件不会报错,只是顶部不会出现「会话地图」。
147
165
  - 两个 DSH Web 实例共享同一个 profile 时会写入同一个 `workspaces.json`。虽然存在跨进程写锁和外部修改警告,仍可能出现最后写入覆盖,请只运行一个实例。
148
166
  - v3 数据迁移时,旧工具卡片按顺序将每次调用与下一条结果配对;实时事件使用 `callId`。
149
167
 
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
1
+ import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import { dirname } from 'node:path'
4
4
 
@@ -10,8 +10,16 @@ const MAX_TITLE_LENGTH = 120
10
10
  const MAX_NOTE_LENGTH = 4_000
11
11
  // Projected message text cap: longer replies truncate with a marker pointing
12
12
  // at the detail view instead of silently cutting mid-sentence.
13
- const MAX_PROJECTION_LENGTH = 8_000
13
+ // Projected messages keep only a short preview: the body already lives in the
14
+ // DSH session log, and a canvas full of copies is what made one workspace file
15
+ // grow to megabytes (issue #42). Bodies are streamed back on demand by
16
+ // `/synapse/api/threads/:id/full`.
17
+ const EXCERPT_CHARS = 280
18
+ // On-demand bodies are capped so a single enormous tool result cannot stall the
19
+ // browser while its card is rendered.
20
+ const MAX_BODY_CHARS = 200_000
14
21
  const PROJECTION_TRUNCATED_SUFFIX = '\n——…(详情查看全文)'
22
+ const BODY_TRUNCATED_SUFFIX = '\n——…(正文过长,其余请在 DSH 中查看)'
15
23
  const TOPIC_COLORS = ['#0f766e', '#2563eb', '#be123c', '#7c3aed', '#b45309']
16
24
  // The harness writes `user/message` rows of its own (runtime snapshots, skill
17
25
  // catalogues). A canvas card must stand for a turn a person actually typed, so
@@ -55,6 +63,20 @@ export class WorkspaceStore {
55
63
  return structuredClone(workspace)
56
64
  }
57
65
 
66
+ /**
67
+ * Read-only lookup used by on-demand body loading: returns the live thread
68
+ * (no clone) together with its workspace, or null when the id is unknown.
69
+ */
70
+ async locateThread(threadId) {
71
+ await this.ready
72
+ for (const workspace of this.state.workspaces) {
73
+ for (const thread of workspace.threads ?? []) {
74
+ if (thread.id === threadId) return { workspace, thread }
75
+ }
76
+ }
77
+ return null
78
+ }
79
+
58
80
  async create(title) {
59
81
  return this.mutate(() => {
60
82
  const now = new Date().toISOString()
@@ -250,10 +272,15 @@ export class WorkspaceStore {
250
272
  const parsed = JSON.parse(await readFile(this.dataFile, 'utf8'))
251
273
  const { state, migrated } = normalizeState(parsed)
252
274
  this.state = state
253
- if (migrated) await this.save()
275
+ if (migrated) {
276
+ // Dropping stored bodies is the one migration that cannot be undone by
277
+ // re-projection alone, so keep the pre-v5 file next to the new one.
278
+ if (Number(parsed?.version) < 5) await copyFile(this.dataFile, `${this.dataFile}.v4-backup`).catch(() => {})
279
+ await this.save()
280
+ }
254
281
  } catch (error) {
255
282
  if (error?.code !== 'ENOENT') throw new Error(`synapse: cannot read ${this.dataFile}: ${error.message}`)
256
- this.state = { version: 4, hiddenSessionIds: [], workspaces: [] }
283
+ this.state = { version: 5, hiddenSessionIds: [], workspaces: [] }
257
284
  await this.save()
258
285
  }
259
286
  }
@@ -451,6 +478,8 @@ export class WorkspaceStore {
451
478
  const message = {
452
479
  id: randomUUID(),
453
480
  text: projection.text,
481
+ length: projection.length,
482
+ truncated: projection.truncated,
454
483
  kind: projection.kind,
455
484
  sourceSeq: event.seq,
456
485
  at,
@@ -484,21 +513,21 @@ export class WorkspaceStore {
484
513
  const process = target === undefined ? (thread.pendingProcess ??= []) : (target.process ??= [])
485
514
  const callId = String(event.type === 'tool/call' ? data.callId : data.message?.source?.callId ?? '')
486
515
  const entry = process.find(item => item.callId === callId)
516
+ // Process records keep only identity and status: tool arguments and results
517
+ // are the bulk of the old file (issue #42) and are reloaded on demand.
487
518
  if (event.type === 'tool/call') {
488
519
  if (entry === undefined) {
489
- process.push({ callId, turn: data.turn, step: data.step, name: data.name, arguments: data.arguments, result: null, error: null })
520
+ process.push({ callId, turn: data.turn, step: data.step, name: data.name, status: 'pending' })
490
521
  } else {
491
522
  entry.name = data.name
492
- entry.arguments = data.arguments
493
523
  }
494
524
  } else {
495
- const outcome = contentText(data.message?.content)
496
- const error = errorText(data.error)
525
+ const failed = errorText(data.error) !== null
497
526
  if (entry === undefined) {
498
- process.push({ callId, turn: data.turn, step: data.step, name: '工具调用', arguments: null, result: outcome, error })
527
+ process.push({ callId, turn: data.turn, step: data.step, name: '工具调用', status: failed ? 'failed' : 'done' })
499
528
  } else {
500
- entry.result = outcome
501
- entry.error = error
529
+ entry.status = failed ? 'failed' : 'done'
530
+ if (typeof entry.name !== 'string' || entry.name === '') entry.name = '工具调用'
502
531
  }
503
532
  }
504
533
  thread.updatedAt = at
@@ -539,7 +568,7 @@ class NotFoundError extends Error {}
539
568
  function normalizeState(value) {
540
569
  let migrated = false
541
570
  let state
542
- if ((value?.version === 2 || value?.version === 3 || value?.version === 4) && Array.isArray(value.workspaces)) {
571
+ if ((value?.version === 2 || value?.version === 3 || value?.version === 4 || value?.version === 5) && Array.isArray(value.workspaces)) {
543
572
  const hiddenSessionIds = Array.isArray(value.hiddenSessionIds) ? value.hiddenSessionIds.filter(item => typeof item === 'string') : []
544
573
  migrated = value.version < 3 || !Array.isArray(value.hiddenSessionIds)
545
574
  const workspaces = value.workspaces.map(workspace => ({
@@ -580,16 +609,54 @@ function normalizeState(value) {
580
609
  }
581
610
  migrated = true
582
611
  } else {
583
- throw new Error('expected Synapse data version 1, 2, 3, or 4')
612
+ throw new Error('expected Synapse data version 1, 2, 3, 4, or 5')
584
613
  }
585
- if (state.version !== 4) {
614
+ if (state.version < 4) {
586
615
  if (foldLegacyToolCards(state.workspaces)) migrated = true
587
616
  state.version = 4
588
617
  migrated = true
589
618
  }
619
+ // v5 drops message bodies and tool payloads from the workspace file: they are
620
+ // re-read from the DSH session log on demand. Existing bodies become previews.
621
+ if (state.version < 5) {
622
+ for (const workspace of state.workspaces) {
623
+ for (const thread of workspace.threads ?? []) {
624
+ if (Array.isArray(thread.messages)) thread.messages = thread.messages.map(toStoredMessage)
625
+ if (Array.isArray(thread.pendingProcess)) thread.pendingProcess = thread.pendingProcess.map(toStoredProcess)
626
+ }
627
+ }
628
+ state.version = 5
629
+ migrated = true
630
+ }
590
631
  return { state, migrated }
591
632
  }
592
633
 
634
+ /** v4 message → v5 message: keep identity and a preview, drop the body. */
635
+ function toStoredMessage(message) {
636
+ const normalized = typeof message?.text === 'string' ? message.text.trim() : ''
637
+ const text = normalized === '' ? '' : excerptOf(normalized)
638
+ const process = Array.isArray(message?.process) ? message.process.map(toStoredProcess) : message?.process
639
+ return {
640
+ ...message,
641
+ text,
642
+ length: normalized.length,
643
+ truncated: text.length < normalized.length,
644
+ ...(process === undefined ? {} : { process }),
645
+ }
646
+ }
647
+
648
+ /** v4 process record → v5 record: keep identity and status, drop the payload. */
649
+ function toStoredProcess(entry) {
650
+ if (entry === null || typeof entry !== 'object') return entry
651
+ const { arguments: _arguments, result, error, ...rest } = entry
652
+ const status = error !== null && error !== undefined && error !== ''
653
+ ? 'failed'
654
+ : result === null || result === undefined
655
+ ? 'pending'
656
+ : 'done'
657
+ return { ...rest, status: typeof entry.status === 'string' ? entry.status : status }
658
+ }
659
+
593
660
  /**
594
661
  * Fold v3-era standalone tool cards (kinds `tool` / `tool-result`) into the
595
662
  * preceding assistant message's `process` list, pairing each call with the
@@ -656,30 +723,88 @@ function requiredText(value, maxLength, field) {
656
723
  return text
657
724
  }
658
725
 
659
- function projectableEvent(event) {
660
- switch (event.type) {
661
- case 'user/message': {
662
- if (isInjectedUserSource(event.data?.source)) return null
663
- const text = contentText(event.data.content)
664
- return isRuntimeContextText(text) ? null : noteProjection('user', text)
665
- }
666
- case 'assistant/message':
667
- return noteProjection('assistant', contentText(event.data?.message?.content))
668
- case 'todo/write':
669
- return noteProjection('todo', Array.isArray(event.data?.todos) ? event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n') : '')
726
+ /** Event → canvas message kind, or null when the event is not a message. */
727
+ function eventKind(event) {
728
+ switch (event?.type) {
729
+ case 'user/message': return 'user'
730
+ case 'assistant/message': return 'assistant'
731
+ case 'todo/write': return 'todo'
670
732
  case 'turn/end': {
671
733
  const reason = event.data?.reason
672
- if (reason?.kind === 'error') return noteProjection('error', errorText(reason.error) ?? '本轮执行失败')
673
- if (reason?.kind === 'cancelled' || reason?.kind === 'canceled' || reason?.kind === 'aborted') return noteProjection('error', '本轮已取消')
734
+ if (reason?.kind === 'error') return 'error'
735
+ if (reason?.kind === 'cancelled' || reason?.kind === 'canceled' || reason?.kind === 'aborted') return 'error'
674
736
  return null
675
737
  }
676
738
  default:
677
- return /(?:error|failed|failure|cancel(?:led)?|abort)/i.test(event.type)
678
- ? noteProjection('error', errorText(event.data?.error ?? event.data?.reason ?? event.data) ?? 'Harness 运行失败')
679
- : null
739
+ return /(?:error|failed|failure|cancel(?:led)?|abort)/i.test(event?.type ?? '') ? 'error' : null
680
740
  }
681
741
  }
682
742
 
743
+ /**
744
+ * Full body of one event. Shared by the projection (which stores a preview) and
745
+ * the on-demand loader (which returns the whole text), so both read the same
746
+ * fields instead of keeping two parsers in step.
747
+ */
748
+ function eventBody(event) {
749
+ switch (event?.type) {
750
+ case 'user/message': return contentText(event.data?.content)
751
+ case 'assistant/message': return contentText(event.data?.message?.content)
752
+ case 'todo/write': return Array.isArray(event.data?.todos) ? event.data.todos.map(todo => `[${todo.status}] ${todo.content}`).join('\n') : ''
753
+ case 'turn/end': {
754
+ const reason = event.data?.reason
755
+ if (reason?.kind === 'error') return errorText(reason.error) ?? '本轮执行失败'
756
+ return '本轮已取消'
757
+ }
758
+ default:
759
+ return eventKind(event) === 'error' ? errorText(event.data?.error ?? event.data?.reason ?? event.data) ?? 'Harness 运行失败' : ''
760
+ }
761
+ }
762
+
763
+ function projectableEvent(event) {
764
+ const kind = eventKind(event)
765
+ if (kind === null) return null
766
+ const text = eventBody(event)
767
+ if (kind === 'user' && (isInjectedUserSource(event.data?.source) || isRuntimeContextText(text))) return null
768
+ return noteProjection(kind, text)
769
+ }
770
+
771
+ /**
772
+ * Full bodies for the requested stored messages, assembled from one session's
773
+ * event log. Exported so the on-demand loader is testable without a Host.
774
+ * `seqs === null` returns every projected message of the thread.
775
+ */
776
+ export function bodiesFromEvents(thread, events, seqs) {
777
+ const wanted = seqs === null ? null : new Set(seqs)
778
+ const messages = {}
779
+ const eventsBySeq = new Map(events.map(event => [event?.seq, event]))
780
+ const calls = new Map()
781
+ for (const event of events) {
782
+ if (event?.type !== 'tool/call' && event?.type !== 'tool/result') continue
783
+ const callId = String(event.type === 'tool/call' ? event.data?.callId : event.data?.message?.source?.callId ?? '')
784
+ if (callId === '') continue
785
+ const record = calls.get(callId) ?? {}
786
+ if (event.type === 'tool/call') {
787
+ record.arguments = typeof event.data?.arguments === 'string' ? event.data.arguments : null
788
+ } else {
789
+ record.result = contentText(event.data?.message?.content) || null
790
+ record.error = errorText(event.data?.error)
791
+ }
792
+ calls.set(callId, record)
793
+ }
794
+ for (const message of thread.messages ?? []) {
795
+ if (wanted !== null && !wanted.has(message.sourceSeq)) continue
796
+ const event = eventsBySeq.get(message.sourceSeq)
797
+ if (event === undefined) continue
798
+ const body = eventBody(event).trim()
799
+ const capped = body.length > MAX_BODY_CHARS ? `${body.slice(0, MAX_BODY_CHARS)}${BODY_TRUNCATED_SUFFIX}` : body
800
+ const process = Array.isArray(message.process)
801
+ ? message.process.map(entry => ({ ...entry, ...(calls.get(String(entry.callId)) ?? {}) }))
802
+ : []
803
+ messages[String(message.sourceSeq)] = { text: capped, truncated: capped.length < body.length, process }
804
+ }
805
+ return messages
806
+ }
807
+
683
808
  function errorText(value) {
684
809
  if (typeof value === 'string') return value.trim() || null
685
810
  if (value === null || value === undefined || typeof value !== 'object') return null
@@ -693,8 +818,14 @@ function errorText(value) {
693
818
  function noteProjection(kind, text) {
694
819
  const normalized = text.trim()
695
820
  if (normalized === '') return null
696
- if (normalized.length <= MAX_PROJECTION_LENGTH) return { kind, text: normalized }
697
- return { kind, text: `${normalized.slice(0, MAX_PROJECTION_LENGTH)}${PROJECTION_TRUNCATED_SUFFIX}` }
821
+ const excerpt = excerptOf(normalized)
822
+ return { kind, text: excerpt, length: normalized.length, truncated: excerpt.length < normalized.length }
823
+ }
824
+
825
+ /** Short preview kept in the workspace file, with an ellipsis when cut. */
826
+ function excerptOf(text) {
827
+ if (text.length <= EXCERPT_CHARS) return text
828
+ return `${text.slice(0, EXCERPT_CHARS).trimEnd()}…`
698
829
  }
699
830
 
700
831
  function isRuntimeContextText(text) {
@@ -825,6 +956,27 @@ export function apply(ctx, config) {
825
956
  ctx.on('session/event', enqueueProjection)
826
957
  for (const session of ctx.sessions.list()) replaySession(session)
827
958
  }
959
+ // Bodies live in the session log, not in the workspace file (issue #42). The
960
+ // canvas pulls them for the cards it actually renders; the detail view pulls a
961
+ // whole thread at once. An archived or already-removed session cannot be
962
+ // re-read, so the caller is told instead of receiving silently empty text.
963
+ const threadBodies = async (threadId, seqs) => {
964
+ const located = await store.locateThread(threadId)
965
+ if (located === null) throw new NotFoundError('会话不存在')
966
+ const { thread } = located
967
+ const wanted = seqs === null ? null : new Set(seqs)
968
+ if (thread.dshSessionId === null) {
969
+ const messages = {}
970
+ for (const message of thread.messages ?? []) {
971
+ if (wanted !== null && !wanted.has(message.sourceSeq)) continue
972
+ messages[String(message.sourceSeq)] = { text: message.text, truncated: message.truncated === true, process: message.process ?? [], local: true }
973
+ }
974
+ return { messages }
975
+ }
976
+ 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) }
979
+ }
828
980
  // The DSH /api browser-trust fence does not cover /synapse routes, so this
829
981
  // handler checks the Host header itself: localhost is allowed by default and
830
982
  // additional authorities opt in through config.trustedHosts (mirrors the
@@ -850,6 +1002,14 @@ export function apply(ctx, config) {
850
1002
  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) }) }
851
1003
  const messages = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/messages$/i.exec(path)
852
1004
  if (messages !== null && req.method === 'POST') return sendJson(res, 201, { thread: await store.addMessage(messages[1], (await readJson(req)).text) })
1005
+ const full = /^\/synapse\/api\/threads\/([0-9a-f-]+)\/full$/i.exec(path)
1006
+ if (full !== null && req.method === 'GET') {
1007
+ const raw = new URL(req.url ?? '/', 'http://dsh.local').searchParams.get('seqs')
1008
+ const seqs = raw === null || raw.trim() === ''
1009
+ ? null
1010
+ : raw.split(',').map(value => Number.parseInt(value, 10)).filter(value => Number.isInteger(value) && value >= 0)
1011
+ return sendJson(res, 200, await threadBodies(full[1], seqs))
1012
+ }
853
1013
  const thread = /^\/synapse\/api\/threads\/([0-9a-f-]+)$/i.exec(path)
854
1014
  if (thread !== null && req.method === 'PATCH') return sendJson(res, 200, { thread: await store.updateThread(thread[1], await readJson(req)) })
855
1015
  if (thread !== null && req.method === 'DELETE') return sendJson(res, 200, await store.removeThread(thread[1]))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-synapse",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "A visual, non-linear conversation workspace plugin for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "index.js",