dsh-session-flow 0.1.0 → 1.1.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/lib/host.js CHANGED
@@ -7,13 +7,14 @@
7
7
  // get { sessionId } → 轻量详情(回合摘要 + 工具统计,秒开)
8
8
  // getTurn { sessionId, turn } → 展开回合时按需取完整时间线
9
9
  // searchIn { sessionId, query } → 会话内全文检索(匹配位置列表)
10
+ // searchAll { query, workspace? } → 跨会话全文检索(最近 20 会话/总 5s 预算/取消支持)
10
11
  // stats → 环境信息(dsh home、索引目录、各工作区缓存状态)
11
12
  //
12
13
  // 依赖 cordis 服务:webServer(HTTP 载体)。
13
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFile } from 'node:fs'
14
15
  import { join } from 'node:path'
15
16
  import { deflateRawSync } from 'node:zlib'
16
- import { decodeFile, listWorkspaces, looksLikePath, parseSession, summarizeParsed } from './archive.js'
17
+ import { decodeFile, listSessionDirs, listWorkspaces, looksLikePath, parseSession, summarizeParsed } from './archive.js'
17
18
  import { deriveTimeline } from './timeline.js'
18
19
  import {
19
20
  dshHome,
@@ -24,6 +25,7 @@ import {
24
25
  workspaceIndexFile,
25
26
  writeIndex,
26
27
  } from './index-store.js'
28
+ import { applyRename, MAX_RENAME_LENGTH, saveRenames, userTitleOf } from './renames.js'
27
29
 
28
30
  export const name = 'dsh-session-flow'
29
31
 
@@ -64,6 +66,43 @@ function sendJson(res, status, obj) {
64
66
  res.end(JSON.stringify(obj))
65
67
  }
66
68
 
69
+ // ── 卡死监控(stall-monitor):健康分类 ─────────────────────────────
70
+ // 「进行中但长时间无输出」≠ 卡死(模型长思考/长工具执行都静默)。
71
+ // 分类只看结构事实:运行中 + 无流式 chunk + 无未闭合工具 + 静默超阈值 → 疑似卡死。
72
+ // TODO(设置页待办):STALL_THRESHOLD_MS 待插件设置页落地后可配置化(见 docs HANDOVER §7)。
73
+ export const STALL_THRESHOLD_MS = 3 * 60 * 1000
74
+ export const HEALTH_ACTIVE_WINDOW_MS = 60 * 1000
75
+
76
+ /**
77
+ * 健康分类(纯函数,verify 可测)。
78
+ * @param {{running:boolean,lastEventTime:number|null,openTool:boolean,inflight:boolean}} facts
79
+ * @param {number} now - 调用方时间戳(client 传 Date.now(),避免 host/client 时钟偏差语义混乱)。
80
+ * @returns {{kind:'active'|'tool-wait'|'quiet'|'stalled'|'ended'|'unknown', idleMs:number|null}}
81
+ */
82
+ export function classifyHealth(facts, now) {
83
+ if (!facts || typeof facts !== 'object') return { kind: 'unknown', idleMs: null }
84
+ const idleMs = typeof facts.lastEventTime === 'number' ? Math.max(0, now - facts.lastEventTime) : null
85
+ if (facts.running !== true) return { kind: 'ended', idleMs }
86
+ // 有流式中间态或 60s 内有事件 → 确实在跑。
87
+ if (facts.inflight === true || (idleMs !== null && idleMs < HEALTH_ACTIVE_WINDOW_MS)) return { kind: 'active', idleMs }
88
+ // 有未闭合工具调用 → 静默是工具在执行(长跑命令正常),不算卡死。
89
+ if (facts.openTool === true) return { kind: 'tool-wait', idleMs }
90
+ if (idleMs === null) return { kind: 'unknown', idleMs }
91
+ if (idleMs >= STALL_THRESHOLD_MS) return { kind: 'stalled', idleMs }
92
+ return { kind: 'quiet', idleMs }
93
+ }
94
+
95
+ /**
96
+ * 对齐官方重命名(官方 rename 为 log-backed session/title user 事件,同一数据源)。
97
+ * 显示优先级:档案 user 源标题(官方唯一真源)> renames.json 遗留 overlay(自然淘汰)。
98
+ */
99
+ function effectiveUserTitle(home, entry, sessionId) {
100
+ if (entry && entry.titleSource === 'user' && typeof entry.title === 'string' && entry.title !== '') {
101
+ return entry.title
102
+ }
103
+ return userTitleOf(home, sessionId)
104
+ }
105
+
67
106
  /** 汇总输出视图(去掉 counts 细节,浏览器更轻)。 */
68
107
  function sessionView(summary) {
69
108
  if (!summary) return summary
@@ -627,7 +666,7 @@ export function apply(ctx) {
627
666
  if (requested !== null && ws.name !== requested) continue
628
667
  const result = scanWorkspaceIndex(home, ws.name, { force })
629
668
  const sessions = Object.values(result.index.sessions)
630
- .map(sessionView)
669
+ .map((s) => ({ ...sessionView(s), userTitle: effectiveUserTitle(home, s, s.id) }))
631
670
  .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
632
671
  const meta = workspaceLabelOf(ws.name, sessions)
633
672
  workspaces.push({
@@ -653,6 +692,27 @@ export function apply(ctx) {
653
692
  })
654
693
  }
655
694
 
695
+ if (method === 'rename') {
696
+ // M8a 会话重命名:私有 userTitle 显示层覆盖(不动原始存档)。
697
+ // 空标题 = 清除恢复原名;超长 400;会话不存在 404;落盘失败回滚 + 500。
698
+ const sessionId = String(body.sessionId || '')
699
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: 'sessionId required' })
700
+ const found = findWorkspaceOfSession(home, sessionId)
701
+ if (found === null) return sendJson(res, 404, { ok: false, error: `session ${sessionId} not found in archives` })
702
+ const raw = typeof body.title === 'string' ? body.title : ''
703
+ const trimmed = raw.trim()
704
+ if (trimmed.length > MAX_RENAME_LENGTH) {
705
+ return sendJson(res, 400, { ok: false, error: `title too long (max ${MAX_RENAME_LENGTH} chars)` })
706
+ }
707
+ const previous = userTitleOf(home, sessionId)
708
+ const userTitle = applyRename(home, sessionId, trimmed)
709
+ if (!saveRenames(home)) {
710
+ applyRename(home, sessionId, previous || '')
711
+ return sendJson(res, 500, { ok: false, error: 'failed to persist renames.json' })
712
+ }
713
+ return sendJson(res, 200, { ok: true, userTitle })
714
+ }
715
+
656
716
  if (method === 'get') {
657
717
  // 轻量详情(秒开):只返回回合摘要 + 工具统计;完整时间线由 getTurn 按需取。
658
718
  const sessionId = String(body.sessionId || '')
@@ -694,6 +754,7 @@ export function apply(ctx) {
694
754
  workspaceLabel: wsMeta.label,
695
755
  workspaceCwd: wsMeta.cwd,
696
756
  session: sessionView(sum),
757
+ userTitle: effectiveUserTitle(home, sum, sessionId),
697
758
  counts,
698
759
  lightTurns: entry.lightTurns,
699
760
  toolStats: entry.toolStats,
@@ -850,7 +911,11 @@ export function apply(ctx) {
850
911
  const chunks = chunkTurns(entry.turns, EXPORT_CHUNK_TARGET)
851
912
  // 概览(含分卷指引)。
852
913
  const overview = renderOverviewMd({ entry, sum, found, title, llmText, chunkCount: chunks.length })
853
- const rawTitle = title || sessionId
914
+ // 文件名用自定义标题优先(档案 user 源标题 = 官方真源优先;renames.json 遗留回退)。
915
+ const parsedTitle = entry.parsed.title
916
+ const archiveUserTitle = parsedTitle && parsedTitle.source && parsedTitle.source.kind === 'user'
917
+ ? parsedTitle.title : null
918
+ const rawTitle = archiveUserTitle || userTitleOf(home, sessionId) || title || sessionId
854
919
  const safeTitle = String(rawTitle).replace(/[\\/:*?"<>|\r\n]/g, '_').slice(0, 60)
855
920
  // 组装 ZIP 文件清单:概览 + 时间线分卷(00-概览.md / 01-时间线-回合X-Y.md …)。
856
921
  const zipFiles = [{ name: '00-概览.md', data: Buffer.from(overview, 'utf8') }]
@@ -909,6 +974,7 @@ export function apply(ctx) {
909
974
  const buildNode = (s) => ({
910
975
  id: s.id,
911
976
  title: s.title || null,
977
+ userTitle: effectiveUserTitle(home, s, s.id),
912
978
  delegationDepth: s.delegationDepth || 0,
913
979
  createdAt: s.createdAt || null,
914
980
  lastEventTime: s.lastEventTime || null,
@@ -956,12 +1022,25 @@ export function apply(ctx) {
956
1022
  }
957
1023
  const STREAM_MID_TYPES = new Set(['assistant/chunk', 'assistant/message', 'tool/call', 'step/start', 'turn/start', 'user/message', 'request/header'])
958
1024
  const running = openTurns > 0 || openSteps > 0 || openTools > 0 || STREAM_MID_TYPES.has(lastType)
1025
+ // 卡死监控:健康事实 + 分类。assumeRunning:总览探测以官方 sessions.list 的
1026
+ // running 为准(tail 窗口可能不含 turn/start,结构信号会漏判),此时结构信号只供
1027
+ // openTool/inflight 事实;now 由 client 传入(缺省 host 本地时间)。
1028
+ const now = typeof body.now === 'number' ? body.now : Date.now()
1029
+ const effectiveRunning = running || body.assumeRunning === true
1030
+ const healthFacts = {
1031
+ running: effectiveRunning,
1032
+ lastEventTime: summary.lastEventTime !== undefined ? summary.lastEventTime : null,
1033
+ lastEventType: lastType,
1034
+ openTool: openTools > 0,
1035
+ inflight: lastType === 'assistant/chunk',
1036
+ }
959
1037
  return sendJson(res, 200, {
960
1038
  ok: true,
961
1039
  session: sessionView(summary),
962
1040
  counts,
963
1041
  timeline,
964
1042
  running,
1043
+ health: { ...healthFacts, ...classifyHealth(healthFacts, now) },
965
1044
  })
966
1045
  }
967
1046
 
@@ -1036,6 +1115,122 @@ export function apply(ctx) {
1036
1115
  return sendJson(res, 200, { ok: true, query, count: matches.length, matches })
1037
1116
  }
1038
1117
 
1118
+ if (method === 'searchAll') {
1119
+ // 方向 A:跨会话全文检索(内容级召回,复用 searchIn 的扫描语义)。
1120
+ // 约束(PERF-ANALYSIS §2A):按 lastEventTime(文件 mtime)取最近 SEARCH_MAX_SESSIONS
1121
+ // 个会话;超大文件跳过;总时间预算 SEARCH_TIME_BUDGET_MS,超时返回已扫部分 + hasMore;
1122
+ // 请求中止(req aborted)即停;搜索词 ≥2 字符;workspace 可选过滤。
1123
+ // 每会话:cachedSession(缓存优先)→ 自由文本扫描(用户/助手/思考/工具名/参数/结果)
1124
+ // → matchCount 全量统计(排序依据)+ 返回前 SEARCH_MATCHES_PER_SESSION 条命中。
1125
+ const query = String(body.query || '').trim()
1126
+ if (query.length < 2) return sendJson(res, 400, { ok: false, error: 'query must be at least 2 characters' })
1127
+ const q = query.toLowerCase()
1128
+ const wsFilter = body.workspace ? String(body.workspace) : ''
1129
+ const SEARCH_MAX_SESSIONS = 20
1130
+ const SEARCH_MATCHES_PER_SESSION = 5
1131
+ const SEARCH_TIME_BUDGET_MS = 5000
1132
+ const SEARCH_MAX_FILE_MB = 50
1133
+
1134
+ // 枚举会话(mtime 降序 = 最近活动优先)。
1135
+ const candidates = []
1136
+ for (const ws of listWorkspaces(home)) {
1137
+ if (wsFilter && ws.name !== wsFilter) continue
1138
+ for (const s of listSessionDirs(ws.dir)) {
1139
+ candidates.push({ id: s.id, file: s.file, mtimeMs: s.mtimeMs, sizeBytes: s.sizeBytes, workspace: ws.name })
1140
+ }
1141
+ }
1142
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1143
+ const selected = candidates.slice(0, SEARCH_MAX_SESSIONS)
1144
+
1145
+ const started = Date.now()
1146
+ const results = []
1147
+ let scanned = 0
1148
+ let aborted = false
1149
+ req.on('aborted', () => { aborted = true })
1150
+
1151
+ for (const c of selected) {
1152
+ if (aborted) break
1153
+ if (Date.now() - started > SEARCH_TIME_BUDGET_MS) break
1154
+ // 超大文件跳过(异常会话,避免单会话拖垮总预算)。
1155
+ if (c.sizeBytes > SEARCH_MAX_FILE_MB * 1024 * 1024) continue
1156
+ let turns
1157
+ try {
1158
+ const entry = cachedSession(home, c.id, c.file, c.mtimeMs, c.sizeBytes)
1159
+ turns = entry.turns
1160
+ } catch {
1161
+ continue
1162
+ }
1163
+ if (!Array.isArray(turns) || turns.length === 0) continue
1164
+ scanned++
1165
+ const matches = []
1166
+ const snippet = (text, needle) => {
1167
+ const t = String(text || '')
1168
+ const i = t.toLowerCase().indexOf(needle)
1169
+ if (i < 0) return t.slice(0, 100)
1170
+ const start = Math.max(0, i - 30)
1171
+ return (start > 0 ? '…' : '') + t.slice(start, i + needle.length + 60) + (i + needle.length + 60 < t.length ? '…' : '')
1172
+ }
1173
+ for (const t of turns) {
1174
+ for (const u of t.userMessages) {
1175
+ if (u.text.toLowerCase().includes(q)) {
1176
+ matches.push({ kind: 'user', turn: t.turn, seq: u.seq, preview: snippet(u.text, q) })
1177
+ }
1178
+ }
1179
+ for (const a of t.assistantMessages) {
1180
+ if (a.hasThinking && a.thinking.toLowerCase().includes(q)) {
1181
+ matches.push({ kind: 'thinking', turn: t.turn, seq: a.seq, preview: snippet(a.thinking, q) })
1182
+ }
1183
+ if (a.hasText && a.text.toLowerCase().includes(q)) {
1184
+ matches.push({ kind: 'assistant', turn: t.turn, seq: a.seq, preview: snippet(a.text, q) })
1185
+ }
1186
+ }
1187
+ for (const s of t.steps) {
1188
+ for (const call of s.toolCalls) {
1189
+ const argsLower = call.argumentsText.toLowerCase()
1190
+ const resLower = call.resultText.toLowerCase()
1191
+ if (call.name.toLowerCase().includes(q) || argsLower.includes(q) || resLower.includes(q)) {
1192
+ const inArgs = argsLower.includes(q)
1193
+ matches.push({
1194
+ kind: call.isError === true ? 'error' : 'tool',
1195
+ turn: t.turn, callId: call.callId, name: call.name,
1196
+ preview: snippet(inArgs ? call.argumentsText : call.resultText, q) || call.resultPreview,
1197
+ })
1198
+ }
1199
+ }
1200
+ }
1201
+ }
1202
+ if (matches.length === 0) continue
1203
+ // 标题:索引优先({title, source} 对象取 .title),无索引时用会话 id。
1204
+ let title = c.id
1205
+ let entry = null
1206
+ try {
1207
+ const index = readIndex(home, c.workspace)
1208
+ entry = (index.sessions && index.sessions[c.id]) || null
1209
+ if (entry && entry.title) {
1210
+ title = typeof entry.title === 'object' ? String(entry.title.title || c.id) : String(entry.title)
1211
+ }
1212
+ } catch {}
1213
+ results.push({
1214
+ sessionId: c.id,
1215
+ workspace: c.workspace,
1216
+ title,
1217
+ userTitle: effectiveUserTitle(home, entry, c.id),
1218
+ matchCount: matches.length,
1219
+ matches: matches.slice(0, SEARCH_MATCHES_PER_SESSION),
1220
+ })
1221
+ }
1222
+ results.sort((a, b) => b.matchCount - a.matchCount)
1223
+ const hasMore = scanned < selected.length && !aborted
1224
+ return sendJson(res, 200, {
1225
+ ok: true,
1226
+ query,
1227
+ scanned,
1228
+ total: selected.length,
1229
+ hasMore,
1230
+ results,
1231
+ })
1232
+ }
1233
+
1039
1234
  if (method === 'cacheInfo') {
1040
1235
  // 缓存管理:统计索引与时间线缓存的体积/数量。
1041
1236
  const root = indexRoot(home)
@@ -23,7 +23,7 @@ export function workspaceIndexFile(home, wsName) {
23
23
  }
24
24
 
25
25
  /** 索引格式版本:升级时旧缓存会被自动全量重扫一次(避免字段缺失)。 */
26
- const INDEX_VERSION = 3
26
+ const INDEX_VERSION = 4
27
27
 
28
28
  export function readIndex(home, wsName) {
29
29
  try {
package/lib/renames.js ADDED
@@ -0,0 +1,80 @@
1
+ // lib/renames.js — M8a 会话重命名存储:<indexRoot>/renames.json
2
+ //
3
+ // 与索引/时间线缓存生命周期**解耦**(缓存管理「清理全部缓存」会重建索引,
4
+ // 重命名是用户资产不能丢——设计见 docs/02-design/RENAME.md)。
5
+ // 格式:{ version: 1, items: { [sessionId]: { title, updatedAt } } }
6
+ // 原子写(tmp + rename)+ 损坏容错(空表 + 警告,不阻塞任何接口)。
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { indexRoot } from './index-store.js'
10
+
11
+ export const RENAMES_VERSION = 1
12
+ export const MAX_RENAME_LENGTH = 120
13
+
14
+ const renames = new Map() // sessionId -> { title, updatedAt }
15
+ let loaded = false
16
+
17
+ export function renamesFile(home) {
18
+ return join(indexRoot(home), 'renames.json')
19
+ }
20
+
21
+ /** 惰性加载:首次访问读盘;损坏 → 空表 + 警告(不抛,不阻塞)。 */
22
+ export function loadRenames(home) {
23
+ if (loaded) return renames
24
+ loaded = true
25
+ try {
26
+ if (!existsSync(renamesFile(home))) return renames
27
+ const raw = JSON.parse(readFileSync(renamesFile(home), 'utf8'))
28
+ if (raw && typeof raw === 'object' && raw.items && typeof raw.items === 'object') {
29
+ for (const [id, item] of Object.entries(raw.items)) {
30
+ if (item && typeof item.title === 'string' && item.title) {
31
+ renames.set(id, { title: item.title, updatedAt: Number(item.updatedAt) || 0 })
32
+ }
33
+ }
34
+ }
35
+ } catch (error) {
36
+ console.warn('[dsh-session-flow] renames.json 读取失败,按空表处理:', error && error.message)
37
+ }
38
+ return renames
39
+ }
40
+
41
+ /** 原子写盘;失败仅告警(内存态保留,由调用方决定是否回滚)。 */
42
+ export function saveRenames(home) {
43
+ try {
44
+ const dir = indexRoot(home)
45
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
46
+ const file = renamesFile(home)
47
+ const tmp = `${file}.tmp`
48
+ writeFileSync(tmp, JSON.stringify({ version: RENAMES_VERSION, items: Object.fromEntries(renames) }, null, 2))
49
+ renameSync(tmp, file)
50
+ return true
51
+ } catch (error) {
52
+ console.warn('[dsh-session-flow] renames.json 写盘失败:', error && error.message)
53
+ return false
54
+ }
55
+ }
56
+
57
+ /** 设置/清除自定义标题:title 为 null/空 → 清除;返回新值(string|null)。 */
58
+ export function applyRename(home, sessionId, title) {
59
+ loadRenames(home)
60
+ const trimmed = typeof title === 'string' ? title.trim() : ''
61
+ if (trimmed === '') {
62
+ renames.delete(sessionId)
63
+ } else {
64
+ renames.set(sessionId, { title: trimmed, updatedAt: Date.now() })
65
+ }
66
+ return trimmed === '' ? null : trimmed
67
+ }
68
+
69
+ /** 取自定义标题(string|null)。 */
70
+ export function userTitleOf(home, sessionId) {
71
+ loadRenames(home)
72
+ const item = renames.get(sessionId)
73
+ return item && item.title ? item.title : null
74
+ }
75
+
76
+ /** 供 verify 直连测试:重置模块态。 */
77
+ export function _resetRenamesForTest() {
78
+ renames.clear()
79
+ loaded = false
80
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-session-flow",
3
- "description": "会话信息流重设计插件:折叠汇总 + 会话总览归档 + 深链跳转 + 子代理血缘树 + 摘要引擎(规则/LLM 双模式),实时复用原生轨迹数据",
4
- "version": "0.1.0",
3
+ "description": "DSH 会话回顾与归档插件:可折叠信息流 + 会话级汇总——总览工作台、折叠时间线、血缘树、双模式摘要、全文检索、ZIP 导出、实时跟踪、会话重命名(官方数据源对齐)、健康监控",
4
+ "version": "1.1.0",
5
5
  "type": "module",
6
6
  "main": "lib/host.js",
7
7
  "exports": {
@@ -14,7 +14,8 @@
14
14
  "lib/",
15
15
  "cordis.patch.yml",
16
16
  "README.md",
17
- "README.en.md"
17
+ "README.en.md",
18
+ "assets/screenshots/"
18
19
  ],
19
20
  "keywords": [
20
21
  "dsh",