dsh-sessions-manager 3.2.2 → 3.3.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.
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useEffect, useMemo, useRef, useState } from 'react'
14
- import { canDropOnWorkspace, dotStateFor, sessionForNodes, workspaceForNodes } from './logic.js'
14
+ import { canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
15
15
 
16
16
  export const inject = ['slots']
17
17
 
@@ -55,6 +55,11 @@ const CSS = `
55
55
  .archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
56
56
  .archv-dot{color:var(--dsw-alias-border-l3);flex:none}
57
57
  .archv-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);flex:none;cursor:pointer}
58
+ .archv-star{appearance:none;width:24px;height:24px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:none;background:0 0;border-radius:7px;cursor:pointer;color:var(--dsw-alias-label-tertiary);transition:color .15s ease,background-color .15s ease}
59
+ .archv-star:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
60
+ .archv-star svg{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:round}
61
+ .archv-star-on,.archv-star-on:hover{color:var(--dsw-alias-state-business-primary)}
62
+ .archv-star-on svg{fill:currentColor}
58
63
  .archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
59
64
  .archv-actions{display:flex;gap:8px;flex:none;flex-wrap:nowrap;justify-content:flex-end}
60
65
  .archv-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:9px;font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:6px;text-align:center;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
@@ -100,6 +105,9 @@ const CSS = `
100
105
  .dtl-k{font-size:11px;color:var(--dsw-alias-label-tertiary)}
101
106
  .dtl-v{font-size:12px;color:var(--dsw-alias-label-primary);word-break:break-all}
102
107
  .dtl-sec{margin-top:12px}
108
+ .dtl-export{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
109
+ .dtl-export .archv-btn{text-decoration:none}
110
+ .dtl-note{margin-top:8px;font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5}
103
111
  .dtl-sec-t{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px}
104
112
  .dtl-tags{display:flex;flex-wrap:wrap;gap:6px}
105
113
  .dtl-tag{display:inline-flex;font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);padding:2px 8px}
@@ -212,7 +220,7 @@ function SessionPanel({ workspacesSvc }) {
212
220
  const [sessions, setSessions] = useState(null)
213
221
  const [workspaces, setWorkspaces] = useState([])
214
222
  const initialPrefs = useRef(loadPanelPrefs()).current
215
- const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
223
+ const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'starred', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
216
224
  const [query, setQuery] = useState('')
217
225
  const [workspaceFilter, setWorkspaceFilter] = useState(() => initialPrefs.workspaceFilter || 'all')
218
226
  const [sortBy, setSortBy] = useState(() => ['newest', 'oldest', 'title'].includes(initialPrefs.sortBy) ? initialPrefs.sortBy : 'newest')
@@ -235,6 +243,9 @@ function SessionPanel({ workspacesSvc }) {
235
243
  const [details, setDetails] = useState({})
236
244
  const [openDetails, setOpenDetails] = useState(null)
237
245
  const [detailsLoading, setDetailsLoading] = useState(null)
246
+ const [mdBusy, setMdBusy] = useState(null)
247
+ const [zipOk, setZipOk] = useState(true)
248
+ const zipChecked = useRef(false)
238
249
  const [openMenu, setOpenMenu] = useState(null)
239
250
  const timer = useRef(null)
240
251
  const menuRef = useRef(null)
@@ -278,6 +289,53 @@ function SessionPanel({ workspacesSvc }) {
278
289
  try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy })) } catch (e) {}
279
290
  }, [filter, workspaceFilter, sortBy])
280
291
 
292
+ // 收藏切换:乐观更新 + 失败回滚(star 是高频轻操作,不等网络往返)。
293
+ const toggleStar = async (it) => {
294
+ const next = !it.starred
295
+ setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, starred: next } : x)))
296
+ try {
297
+ await postJSON('/archived-sessions/star/set', { sessionId: it.sessionId, starred: next })
298
+ } catch (e) {
299
+ setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, starred: !next } : x)))
300
+ showToast('收藏失败:' + String((e && e.message) || e))
301
+ }
302
+ }
303
+
304
+ // Markdown 导出:自有路由(/archived-sessions/export-md),blob 触发下载。
305
+ const exportMarkdown = async (it) => {
306
+ if (mdBusy) return
307
+ setMdBusy(it.sessionId)
308
+ try {
309
+ const res = await fetch('/archived-sessions/export-md?sessionId=' + encodeURIComponent(it.sessionId))
310
+ if (!res.ok) throw new Error('HTTP ' + res.status)
311
+ const blob = await res.blob()
312
+ const url = URL.createObjectURL(blob)
313
+ const a = document.createElement('a')
314
+ a.href = url
315
+ a.download = 'dsh-session-' + it.sessionId + '.md'
316
+ document.body.appendChild(a)
317
+ a.click()
318
+ a.remove()
319
+ URL.revokeObjectURL(url)
320
+ showToast('已导出 Markdown')
321
+ } catch (e) {
322
+ showToast('导出失败:' + String((e && e.message) || e))
323
+ } finally {
324
+ setMdBusy(null)
325
+ }
326
+ }
327
+
328
+ // 官方 ZIP 导出预检(一次性):后端不支持 raw artifacts 时返回 501,
329
+ // 此时隐藏 ZIP 入口只留 Markdown(全局缓存,后端能力不会中途变)。
330
+ // 探针用非法 id 走 HEAD:命中 501 = 不支持;404/400 = 路由活着且支持。
331
+ useEffect(() => {
332
+ if (openDetails === null || zipChecked.current) return
333
+ zipChecked.current = true
334
+ fetch('/api/session.export?sessionId=probe&includeDescendants=false', { method: 'HEAD' })
335
+ .then((res) => setZipOk(res.status !== 501))
336
+ .catch(() => setZipOk(true))
337
+ }, [openDetails])
338
+
281
339
  // Close the ⋯ menu on outside click / Escape (no full-screen backdrop).
282
340
  useEffect(() => {
283
341
  if (openMenu === null) return
@@ -314,9 +372,10 @@ function SessionPanel({ workspacesSvc }) {
314
372
 
315
373
  const archivedList = sessions ? sessions.filter((x) => x.archived) : []
316
374
  const activeList = sessions ? sessions.filter((x) => !x.archived) : []
375
+ const starredList = starredOf(sessions)
317
376
  const list = useMemo(() => {
318
377
  if (filter === 'trash') return []
319
- const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : sessions || []
378
+ const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : filter === 'starred' ? starredList : sessions || []
320
379
  const needle = query.trim().toLocaleLowerCase()
321
380
  const filtered = base.filter((item) => {
322
381
  if (workspaceFilter !== 'all' && (item.workspacePath || '') !== workspaceFilter) return false
@@ -569,6 +628,7 @@ function SessionPanel({ workspacesSvc }) {
569
628
  <button type="button" role="tab" aria-selected={filter === 'all'} className={'sess-fbtn' + (filter === 'all' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('all'); clearSel(); setConfirmBatch(false) }}>全部 ({sessions.length})</button>
570
629
  <button type="button" role="tab" aria-selected={filter === 'active'} className={'sess-fbtn' + (filter === 'active' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('active'); clearSel(); setConfirmBatch(false) }}>活动 ({activeList.length})</button>
571
630
  <button type="button" role="tab" aria-selected={filter === 'archived'} className={'sess-fbtn' + (filter === 'archived' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('archived'); clearSel(); setConfirmBatch(false) }}>已归档 ({archivedList.length})</button>
631
+ <button type="button" role="tab" aria-selected={filter === 'starred'} className={'sess-fbtn' + (filter === 'starred' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('starred'); clearSel(); setConfirmBatch(false) }}>已收藏 ({starredList.length})</button>
572
632
  <button type="button" role="tab" aria-selected={filter === 'trash'} className={'sess-fbtn' + (filter === 'trash' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('trash'); clearSel(); setConfirmBatch(false) }}>回收站 ({trash.length})</button>
573
633
  </div>
574
634
 
@@ -593,13 +653,13 @@ function SessionPanel({ workspacesSvc }) {
593
653
  </select>
594
654
  </div>
595
655
  </div>
596
- <div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : sessions.length} 个` : ''}</div>
656
+ <div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : sessions.length} 个` : ''}</div>
597
657
  </>
598
658
  )}
599
659
 
600
660
  {filter !== 'trash' && list.length > 0 && (
601
661
  <div className="sess-batch">
602
- <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
662
+ <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : filter === 'starred' ? `共 ${starredList.length} 个收藏会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
603
663
  <button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
604
664
  {selIds.length > 0 && (
605
665
  <>
@@ -619,7 +679,7 @@ function SessionPanel({ workspacesSvc }) {
619
679
  )}
620
680
 
621
681
  {filter !== 'trash' && list.length === 0 ? (
622
- <div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : '暂无可管理的会话。'}</div>
682
+ <div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : '暂无可管理的会话。'}</div>
623
683
  ) : filter !== 'trash' ? (
624
684
  <div className="archv-list" role="list">
625
685
  {list.map((it) => {
@@ -635,6 +695,16 @@ function SessionPanel({ workspacesSvc }) {
635
695
  onChange={() => toggle(it.sessionId)}
636
696
  aria-label={'选择 ' + (it.title || it.sessionId)}
637
697
  />
698
+ <button
699
+ type="button"
700
+ className={'archv-star' + (it.starred ? ' archv-star-on' : '')}
701
+ aria-pressed={!!it.starred}
702
+ aria-label={(it.starred ? '取消收藏 ' : '收藏 ') + (it.title || it.sessionId)}
703
+ title={it.starred ? '取消收藏' : '收藏'}
704
+ onClick={(e) => { e.stopPropagation(); toggleStar(it) }}
705
+ >
706
+ <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false"><path d="M12 2.5l2.9 5.9 6.6.9-4.8 4.6 1.2 6.5-5.9-3.1-5.9 3.1 1.2-6.5L2.5 9.3l6.6-.9z" /></svg>
707
+ </button>
638
708
  <div className="archv-body">
639
709
  <div className="archv-main">
640
710
  <div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
@@ -746,6 +816,20 @@ function SessionPanel({ workspacesSvc }) {
746
816
  </div>
747
817
  </div>
748
818
  )}
819
+ <div className="dtl-sec">
820
+ <div className="dtl-sec-t">导出</div>
821
+ <div className="dtl-export">
822
+ <a
823
+ className="archv-btn"
824
+ href={`/api/session.export?sessionId=${encodeURIComponent(it.sessionId)}&includeDescendants=true`}
825
+ onClick={(e) => e.stopPropagation()}
826
+ style={zipOk ? undefined : { pointerEvents: 'none', opacity: 0.45 }}
827
+ title={zipOk ? '含子会话与附件,由 DSH 提供' : '当前持久化后端不支持原始日志导出'}
828
+ >下载原始日志 (ZIP)</a>
829
+ <button type="button" className="archv-btn" disabled={mdBusy === it.sessionId} onClick={() => exportMarkdown(it)}>{mdBusy === it.sessionId ? '生成中…' : '导出 Markdown'}</button>
830
+ </div>
831
+ <div className="dtl-note">ZIP 含子会话与附件,由 DSH 提供 · Markdown 为本插件生成的可读对话记录</div>
832
+ </div>
749
833
  </div>
750
834
  )
751
835
  })()}
@@ -51,3 +51,9 @@ export function workspaceForNodes(nodes) {
51
51
  }
52
52
  return null
53
53
  }
54
+
55
+ // 收藏过滤:返回已收藏子集。star 是用户标记,与 DSH 的活动/归档状态正交
56
+ // (可叠加),所以这里不做任何状态联合判断,只认 starred 字段。
57
+ export function starredOf(items) {
58
+ return (items || []).filter((item) => item && item.starred)
59
+ }
package/src/index.js CHANGED
@@ -12,6 +12,8 @@ import { basename, dirname, isAbsolute, join } from 'node:path'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import { homedir } from 'node:os'
14
14
  import { rewriteFrame0Cwd } from './zstd-frame.js'
15
+ import { renderSessionMarkdown } from './markdown.js'
16
+ import { createStarIndex } from './star-index.js'
15
17
 
16
18
  export const name = 'dsh-sessions-manager'
17
19
  export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
@@ -226,6 +228,21 @@ export function apply(ctx) {
226
228
  return operation
227
229
  }
228
230
 
231
+ // ---- Starred sessions (收藏, schema v3) -----------------------------------
232
+ // User marks, kept in the plugin's own index (never touches DSH logs). Stars
233
+ // survive archive & soft-delete — both are reversible — and are dropped only
234
+ // when the session is really gone (purge, or externally removed; the latter
235
+ // is caught by gcStars during list builds).
236
+ const stars = createStarIndex()
237
+ async function gcStars(validIds) {
238
+ try {
239
+ const store = await stars.read()
240
+ const valid = new Set(validIds.map(String))
241
+ const gone = store.starredSessionIds.filter((id) => !valid.has(id))
242
+ if (gone.length) await stars.removeIds(gone)
243
+ } catch (e) { /* best-effort */ }
244
+ }
245
+
229
246
  // Soft-delete one session: record it in the recycle-bin index but KEEP its
230
247
  // log in the original workspace directory. Moving the file out (and detaching
231
248
  // it from the workspace) orphaned the session into DSH's "未分组" group and
@@ -364,6 +381,7 @@ export function apply(ctx) {
364
381
  purged = true
365
382
  })
366
383
  if (!purged) throw new Error('彻底删除失败')
384
+ stars.removeIds([sid]).catch(() => {})
367
385
  return { ok: true, purged: true }
368
386
  }
369
387
 
@@ -644,9 +662,11 @@ export function apply(ctx) {
644
662
  async function allSessionItems() {
645
663
  let materialized = new Set()
646
664
  let live = ctx.get('sessions')
665
+ let headersOk = false
647
666
  try {
648
667
  const headers = await sp.list()
649
668
  materialized = new Set(headers.map((h) => String(h.id)))
669
+ headersOk = true
650
670
  } catch (e) { /* best-effort */ }
651
671
  const ids = []
652
672
  try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) { /* ignore */ }
@@ -668,6 +688,12 @@ export function apply(ctx) {
668
688
  const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map(resolveOne))
669
689
  for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
670
690
  }
691
+ // Annotate stars; GC only when we have a trustworthy id baseline, so a
692
+ // failing sp.list() can never wipe the whole index.
693
+ let starredSet = new Set()
694
+ try { starredSet = new Set((await stars.read()).starredSessionIds) } catch (e) {}
695
+ for (const it of items) it.starred = starredSet.has(String(it.sessionId))
696
+ if (headersOk) await gcStars(ids)
671
697
  return items
672
698
  }
673
699
 
@@ -1049,6 +1075,57 @@ export function apply(ctx) {
1049
1075
  },
1050
1076
  }))
1051
1077
 
1078
+ // Star / unstar one or many sessions (收藏, schema v3).
1079
+ disposers.push(ctx.webServer.register({
1080
+ kind: 'exact',
1081
+ path: '/archived-sessions/star/set',
1082
+ handler: async (req, res) => {
1083
+ try {
1084
+ const body = await readJsonBody(req)
1085
+ const starred = !!(body && body.starred)
1086
+ let ids = parseIds(body)
1087
+ if ((!ids || ids.length === 0) && body && typeof body.sessionId === 'string') {
1088
+ ids = isSafeSessionId(body.sessionId) ? [body.sessionId] : null
1089
+ }
1090
+ if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1091
+ const starredSessionIds = await stars.setStarred(ids, starred)
1092
+ json(res, { ok: true, starredSessionIds })
1093
+ } catch (e) {
1094
+ json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
1095
+ }
1096
+ },
1097
+ }))
1098
+
1099
+ // Human-readable Markdown export (one session). Raw-log ZIP export is
1100
+ // dsh's own GET /api/session.export — we deliberately do not duplicate it
1101
+ // (see reports/HANDOFF-dsh-sessions-manager-roadmap.md §2.4).
1102
+ disposers.push(ctx.webServer.register({
1103
+ kind: 'exact',
1104
+ path: '/archived-sessions/export-md',
1105
+ handler: async (req, res) => {
1106
+ try {
1107
+ const url = new URL(req.url, 'http://localhost')
1108
+ const sid = url.searchParams.get('sessionId')
1109
+ requireSessionId(sid)
1110
+ const r = await sp.readFrom(sid, 0)
1111
+ if (!r || !r.meta) {
1112
+ const error = new Error('无法读取该会话的日志')
1113
+ error.status = 404
1114
+ throw error
1115
+ }
1116
+ const md = renderSessionMarkdown({ ...r.meta, id: sid }, r.events || [])
1117
+ res.writeHead(200, {
1118
+ 'content-type': 'text/markdown; charset=utf-8',
1119
+ 'content-disposition': `attachment; filename="dsh-session-${sid}.md"`,
1120
+ 'cache-control': 'no-store',
1121
+ })
1122
+ res.end(md)
1123
+ } catch (e) {
1124
+ json(res, { error: String((e && e.message) || e) }, errorStatus(e))
1125
+ }
1126
+ },
1127
+ }))
1128
+
1052
1129
  disposers.push(ctx.webServer.register({
1053
1130
  kind: 'exact',
1054
1131
  path: '/archived-sessions/sidebar-state',
@@ -0,0 +1,175 @@
1
+ // Render a session log as human-readable Markdown.
2
+ //
3
+ // Pure: no DOM, no I/O, no dsh imports. Everything it needs arrives as
4
+ // arguments, so the renderer is unit-testable without a running host.
5
+ //
6
+ // Field shapes below were read off real session logs (2026-09-01), not guessed:
7
+ // user/message data.content = [{ type:'text', text } | { type:'image', ... }]
8
+ // assistant/message data.message.content = [{ type:'text'|'reasoning'|'tool-call', ... }]
9
+ // tool/call data.{name, arguments} (arguments is a JSON *string*)
10
+ // tool/result data.message.content = [{ type:'tool-result', content:[{type:'text',text}] }]
11
+ // Note the asymmetry: user text lives at data.content, assistant text one level
12
+ // deeper at data.message.content. Streaming deltas (`assistant/chunk`,
13
+ // `text-chunks`, `reasoning-chunks`) are never rendered — `assistant/message`
14
+ // already carries the final text for each step.
15
+
16
+ const MAX_TOOL_ARG = 200
17
+
18
+ function isoTime(value) {
19
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
20
+ try { return new Date(value).toISOString() } catch { return null }
21
+ }
22
+
23
+ function yamlString(value) {
24
+ return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
25
+ }
26
+
27
+ function blocksOf(value) {
28
+ return Array.isArray(value) ? value.filter((b) => b && typeof b === 'object') : []
29
+ }
30
+
31
+ // Join the text blocks of a content array; image blocks are counted separately.
32
+ function textFromBlocks(blocks) {
33
+ const parts = []
34
+ for (const block of blocks) {
35
+ if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
36
+ }
37
+ return parts.join('\n\n').trim()
38
+ }
39
+
40
+ function imageCountOf(blocks) {
41
+ let count = 0
42
+ for (const block of blocks) if (block.type === 'image') count++
43
+ return count
44
+ }
45
+
46
+ function reasoningFromBlocks(blocks) {
47
+ const parts = []
48
+ for (const block of blocks) {
49
+ if (block.type === 'reasoning' && typeof block.text === 'string' && block.text.trim()) parts.push(block.text.trim())
50
+ }
51
+ return parts.join('\n\n')
52
+ }
53
+
54
+ // A short, human-usable summary of one tool call's arguments.
55
+ export function summarizeToolArguments(name, rawArguments) {
56
+ let parsed = null
57
+ if (typeof rawArguments === 'string') {
58
+ try { parsed = JSON.parse(rawArguments) } catch { parsed = null }
59
+ } else if (rawArguments && typeof rawArguments === 'object') {
60
+ parsed = rawArguments
61
+ }
62
+ if (parsed === null) return typeof rawArguments === 'string' ? rawArguments.slice(0, MAX_TOOL_ARG) : ''
63
+ if (typeof parsed !== 'object') return String(parsed).slice(0, MAX_TOOL_ARG)
64
+ const preferred = ['command', 'file_path', 'path', 'query', 'url', 'pattern']
65
+ for (const key of preferred) {
66
+ if (typeof parsed[key] === 'string' && parsed[key].trim()) return parsed[key]
67
+ }
68
+ const keys = Object.keys(parsed)
69
+ if (keys.length === 0) return ''
70
+ const rest = {}
71
+ for (const key of keys.slice(0, 6)) {
72
+ const value = parsed[key]
73
+ rest[key] = typeof value === 'string' ? value : JSON.stringify(value)
74
+ }
75
+ return JSON.stringify(rest).slice(0, MAX_TOOL_ARG)
76
+ }
77
+
78
+ /**
79
+ * Render one session as Markdown.
80
+ * @param {object} meta - Session header (`{ id, cwd, createdAt, title? }`).
81
+ * @param {Array<object>} events - Session events as stored in the log.
82
+ * @param {object} [options]
83
+ * @param {boolean} [options.includeReasoning=false] - Emit assistant reasoning blocks.
84
+ * @param {boolean} [options.includeToolResults=false] - Emit tool results.
85
+ * @param {number} [options.exportedAt] - Override the export timestamp (tests).
86
+ * @returns {string} Markdown document.
87
+ */
88
+ export function renderSessionMarkdown(meta, events, options = {}) {
89
+ const includeReasoning = options.includeReasoning === true
90
+ const includeToolResults = options.includeToolResults === true
91
+ const header = meta && typeof meta === 'object' ? meta : {}
92
+ const list = Array.isArray(events) ? events : []
93
+
94
+ // The last session/title event wins — DSH may retitle a session later on.
95
+ let title = typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null
96
+ for (const ev of list) {
97
+ const data = ev && ev.data
98
+ if (ev && ev.type === 'session/title' && data && typeof data.title === 'string' && data.title.trim()) {
99
+ title = data.title.trim()
100
+ }
101
+ }
102
+
103
+ const front = ['---']
104
+ if (title) front.push(`title: ${yamlString(title)}`)
105
+ if (typeof header.id === 'string' && header.id) front.push(`sessionId: ${yamlString(header.id)}`)
106
+ if (typeof header.cwd === 'string' && header.cwd) front.push(`cwd: ${yamlString(header.cwd)}`)
107
+ const created = isoTime(header.createdAt)
108
+ if (created) front.push(`createdAt: ${created}`)
109
+ const exported = isoTime(options.exportedAt)
110
+ if (exported) front.push(`exportedAt: ${exported}`)
111
+ front.push('---')
112
+
113
+ const out = [front.join('\n')]
114
+ if (title) out.push('', `# ${title}`)
115
+
116
+ let turn = null
117
+ for (const ev of list) {
118
+ if (!ev || typeof ev !== 'object') continue
119
+ const data = ev.data && typeof ev.data === 'object' ? ev.data : {}
120
+ const type = ev.type
121
+
122
+ if (type === 'turn/start') {
123
+ const next = Number.isInteger(data.turn) ? data.turn : null
124
+ if (next !== null && next !== turn) {
125
+ turn = next
126
+ out.push('', `## 第 ${turn} 轮`)
127
+ }
128
+ continue
129
+ }
130
+
131
+ if (type === 'user/message') {
132
+ const blocks = blocksOf(data.content)
133
+ const text = textFromBlocks(blocks)
134
+ const images = imageCountOf(blocks)
135
+ if (!text && images === 0) continue
136
+ out.push('', '### 用户', '')
137
+ if (text) out.push(text)
138
+ for (let i = 0; i < images; i++) out.push('', `![图片 ${i + 1}](attachment)`)
139
+ continue
140
+ }
141
+
142
+ if (type === 'assistant/message') {
143
+ const message = data.message && typeof data.message === 'object' ? data.message : {}
144
+ const blocks = blocksOf(message.content)
145
+ const text = textFromBlocks(blocks)
146
+ const reasoning = includeReasoning ? reasoningFromBlocks(blocks) : ''
147
+ if (!text && !reasoning) continue
148
+ out.push('', '### 助手', '')
149
+ if (reasoning) out.push('> 思考:' + reasoning.split('\n').join('\n> '), '')
150
+ if (text) out.push(text)
151
+ continue
152
+ }
153
+
154
+ if (type === 'tool/call') {
155
+ const name = typeof data.name === 'string' && data.name ? data.name : 'tool'
156
+ const summary = summarizeToolArguments(name, data.arguments)
157
+ out.push('', `### 工具调用:\`${name}\``, '')
158
+ out.push(summary ? '```\n' + summary + '\n```' : '(无参数)')
159
+ continue
160
+ }
161
+
162
+ if (type === 'tool/result' && includeToolResults) {
163
+ const message = data.message && typeof data.message === 'object' ? data.message : {}
164
+ const blocks = blocksOf(message.content)
165
+ let text = ''
166
+ for (const block of blocks) {
167
+ if (block.type === 'tool-result') text = textFromBlocks(blocksOf(block.content))
168
+ }
169
+ if (text) out.push('', '<details><summary>工具结果</summary>', '', '```\n' + text.slice(0, 2000) + '\n```', '', '</details>')
170
+ }
171
+ }
172
+
173
+ out.push('')
174
+ return out.join('\n')
175
+ }
@@ -0,0 +1,109 @@
1
+ // Durable "starred sessions" index (schema v3).
2
+ //
3
+ // Deliberately mirrors the recycle-bin index in src/index.js: version field,
4
+ // automatic upgrade of older shapes, atomic write (tmp + rename) and a single
5
+ // chained mutation queue so two concurrent requests can never clobber each
6
+ // other. Extracted from the host bundle so it can be unit-tested directly —
7
+ // pass `dir` to point the index at a temp directory.
8
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
9
+ import { readFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+
13
+ // v3 is the first star schema; it starts at 3 so it can never be confused with
14
+ // the recycle bin's v1/v2 documents even if a file is copied between them.
15
+ export const STAR_SCHEMA_VERSION = 3
16
+
17
+ const DEFAULT_STAR_DIR = join(homedir(), '.dsh', 'sessions-manager')
18
+
19
+ function isSafeSessionId(value) {
20
+ return typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\\/\0]/.test(value) && value !== '.' && value !== '..'
21
+ }
22
+
23
+ /**
24
+ * Coerce anything on disk (or nothing at all) into a valid v3 store.
25
+ * Accepts a bare array of ids (the pre-schema shape) and upgrades it.
26
+ */
27
+ export function normalizeStarStore(raw) {
28
+ const legacy = Array.isArray(raw) ? raw : null
29
+ const source = legacy || (raw && typeof raw === 'object' ? raw : null)
30
+ const ids = source && Array.isArray(source.starredSessionIds) ? source.starredSessionIds : (legacy || [])
31
+ const clean = []
32
+ const seen = new Set()
33
+ for (const id of ids) {
34
+ // Strings only: silently coercing a number into an id would let junk into
35
+ // the index and mask a caller bug.
36
+ if (!isSafeSessionId(id)) continue
37
+ if (seen.has(id)) continue
38
+ seen.add(id)
39
+ clean.push(id)
40
+ }
41
+ return { schemaVersion: STAR_SCHEMA_VERSION, starredSessionIds: clean }
42
+ }
43
+
44
+ /**
45
+ * Open the star index.
46
+ * @param {object} [options]
47
+ * @param {string} [options.dir] - Directory holding the index (tests inject a temp dir).
48
+ * @param {string} [options.indexPath] - Full index path, overriding `dir`.
49
+ */
50
+ export function createStarIndex(options = {}) {
51
+ const dir = options.dir || process.env.DSH_SESSIONS_MANAGER_STAR_DIR || DEFAULT_STAR_DIR
52
+ const indexPath = options.indexPath || join(dir, 'star.json')
53
+ let mutation = Promise.resolve()
54
+
55
+ async function read() {
56
+ try {
57
+ return normalizeStarStore(JSON.parse(readFileSync(indexPath, 'utf8')))
58
+ } catch {
59
+ return normalizeStarStore(null)
60
+ }
61
+ }
62
+
63
+ async function write(store) {
64
+ await mkdir(dir, { recursive: true })
65
+ const tmp = join(dir, `.star-${process.pid}-${Date.now()}.tmp`)
66
+ await writeFile(tmp, JSON.stringify(normalizeStarStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
67
+ await rename(tmp, indexPath)
68
+ }
69
+
70
+ // Serialize read-modify-write cycles: every mutator sees the store as left by
71
+ // the previous one, and a rejected mutator still keeps the chain alive.
72
+ function mutate(mutator) {
73
+ const operation = mutation.then(async () => {
74
+ const store = await read()
75
+ const result = await mutator(store)
76
+ await write(store)
77
+ return result
78
+ })
79
+ mutation = operation.catch(() => {})
80
+ return operation
81
+ }
82
+
83
+ /**
84
+ * Star or unstar sessions.
85
+ * @param {string[]} ids - Session ids to change.
86
+ * @param {boolean} starred - true to star, false to unstar.
87
+ * @returns {Promise<string[]>} The full starred set after the change.
88
+ */
89
+ function setStarred(ids, starred) {
90
+ const wanted = (Array.isArray(ids) ? ids : []).filter(isSafeSessionId).map(String)
91
+ return mutate((store) => {
92
+ const set = new Set(store.starredSessionIds)
93
+ for (const id of wanted) {
94
+ if (starred) set.add(id)
95
+ else set.delete(id)
96
+ }
97
+ store.starredSessionIds = [...set]
98
+ return store.starredSessionIds
99
+ })
100
+ }
101
+
102
+ // Drop ids once their session is gone (purged / deleted), otherwise the index
103
+ // would grow forever with ids that can never be listed again.
104
+ function removeIds(ids) {
105
+ return setStarred(ids, false)
106
+ }
107
+
108
+ return { read, write, mutate, setStarred, removeIds, indexPath, dir }
109
+ }