dsh-session-flow 1.0.0 → 1.2.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
@@ -10,10 +10,11 @@
10
10
  // searchAll { query, workspace? } → 跨会话全文检索(最近 20 会话/总 5s 预算/取消支持)
11
11
  // stats → 环境信息(dsh home、索引目录、各工作区缓存状态)
12
12
  //
13
- // 依赖 cordis 服务:webServer(HTTP 载体)。
13
+ // 依赖 cordis 服务:webServer(HTTP 载体);settings(插件设置命名空间,软依赖)。
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFile } from 'node:fs'
15
15
  import { join } from 'node:path'
16
16
  import { deflateRawSync } from 'node:zlib'
17
+ import z from 'schemastery'
17
18
  import { decodeFile, listSessionDirs, listWorkspaces, looksLikePath, parseSession, summarizeParsed } from './archive.js'
18
19
  import { deriveTimeline } from './timeline.js'
19
20
  import {
@@ -66,6 +67,97 @@ function sendJson(res, status, obj) {
66
67
  res.end(JSON.stringify(obj))
67
68
  }
68
69
 
70
+ // ── 卡死监控(stall-monitor):健康分类 ─────────────────────────────
71
+ // 「进行中但长时间无输出」≠ 卡死(模型长思考/长工具执行都静默)。
72
+ // 分类只看结构事实:运行中 + 无流式 chunk + 无未闭合工具 + 静默超阈值 → 疑似卡死。
73
+ // 阈值可在设置页配置(settings 命名空间 session-flow / stallThresholdMin,分钟);
74
+ // STALL_THRESHOLD_MS 保留为默认值(verify 引用)。
75
+ export const STALL_THRESHOLD_MS = 3 * 60 * 1000
76
+ export const HEALTH_ACTIVE_WINDOW_MS = 60 * 1000
77
+
78
+ // ── 插件设置(settings 命名空间 'session-flow')────────────────────
79
+ // 默认值必须与 client.js 的 SETTINGS_DEFAULTS 完全一致(无用户设置时行为零变化)。
80
+ export const SETTINGS_DEFAULTS = {
81
+ pianoWindow: 12, // 轮次悬浮条可见行数
82
+ pianoWheelSpeed: 0.012, // 滚轮灵敏度(行/px)
83
+ pianoSnapMs: 170, // 静止吸附延迟(ms)
84
+ livePollMs: 3000, // 实时轮询间隔(ms)
85
+ liveFollowPx: 40, // 实时吸底阈值(px)
86
+ liveHistoryTurns: 3, // 详情页实时模式保留的历史回合数
87
+ stallThresholdMin: 3, // 疑似卡死阈值(分钟)
88
+ }
89
+
90
+ /** schemastery schema:settings 服务 resolve 时调用 schema(merged),并需要 toJSON()(describe 用)。 */
91
+ const SETTINGS_SCHEMA = z.object({
92
+ pianoWindow: z.number().step(1).min(6).max(18).default(SETTINGS_DEFAULTS.pianoWindow),
93
+ pianoWheelSpeed: z.number().min(0.005).max(0.03).default(SETTINGS_DEFAULTS.pianoWheelSpeed),
94
+ pianoSnapMs: z.number().step(1).min(100).max(400).default(SETTINGS_DEFAULTS.pianoSnapMs),
95
+ livePollMs: z.number().step(1).min(1500).max(10000).default(SETTINGS_DEFAULTS.livePollMs),
96
+ liveFollowPx: z.number().step(1).min(20).max(120).default(SETTINGS_DEFAULTS.liveFollowPx),
97
+ liveHistoryTurns: z.number().step(1).min(0).max(10).default(SETTINGS_DEFAULTS.liveHistoryTurns),
98
+ stallThresholdMin: z.number().step(1).min(1).max(10).default(SETTINGS_DEFAULTS.stallThresholdMin),
99
+ })
100
+
101
+ /** 解析后的当前设置值(base + user 层);host 侧消费点(卡死阈值)从这里读。 */
102
+ const liveSettings = { ...SETTINGS_DEFAULTS }
103
+
104
+ /**
105
+ * 注册 settings 命名空间(软依赖:宿主无 settings 服务时跳过,行为回退默认值)。
106
+ * 模式参照 aionui-panel installSettingsSection:ctx.inject(['settings'], ...) +
107
+ * scope.watch 同步当前值。客户端经 settingsScope 直接读写(pet 同款),无需额外路由。
108
+ */
109
+ function installSettings(ctx) {
110
+ try {
111
+ if (typeof ctx.inject !== 'function') return
112
+ ctx.inject(['settings'], (sctx) => {
113
+ try {
114
+ const scope = sctx.settings.register('session-flow', SETTINGS_SCHEMA, { base: SETTINGS_DEFAULTS })
115
+ const sync = () => {
116
+ const value = scope.get()
117
+ if (value && typeof value === 'object') Object.assign(liveSettings, SETTINGS_DEFAULTS, value)
118
+ }
119
+ sync()
120
+ scope.watch(sync)
121
+ } catch (error) {
122
+ console.error('[dsh-session-flow] settings registration failed:', error)
123
+ }
124
+ })
125
+ } catch (error) {
126
+ console.error('[dsh-session-flow] settings service unavailable:', error)
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 健康分类(纯函数,verify 可测)。
132
+ * @param {{running:boolean,lastEventTime:number|null,openTool:boolean,inflight:boolean}} facts
133
+ * @param {number} now - 调用方时间戳(client 传 Date.now(),避免 host/client 时钟偏差语义混乱)。
134
+ * @param {number} [stallMs] - 卡死阈值(缺省 STALL_THRESHOLD_MS;设置页 stallThresholdMin 下发)。
135
+ * @returns {{kind:'active'|'tool-wait'|'quiet'|'stalled'|'ended'|'unknown', idleMs:number|null}}
136
+ */
137
+ export function classifyHealth(facts, now, stallMs = STALL_THRESHOLD_MS) {
138
+ if (!facts || typeof facts !== 'object') return { kind: 'unknown', idleMs: null }
139
+ const idleMs = typeof facts.lastEventTime === 'number' ? Math.max(0, now - facts.lastEventTime) : null
140
+ if (facts.running !== true) return { kind: 'ended', idleMs }
141
+ // 有流式中间态或 60s 内有事件 → 确实在跑。
142
+ if (facts.inflight === true || (idleMs !== null && idleMs < HEALTH_ACTIVE_WINDOW_MS)) return { kind: 'active', idleMs }
143
+ // 有未闭合工具调用 → 静默是工具在执行(长跑命令正常),不算卡死。
144
+ if (facts.openTool === true) return { kind: 'tool-wait', idleMs }
145
+ if (idleMs === null) return { kind: 'unknown', idleMs }
146
+ if (idleMs >= stallMs) return { kind: 'stalled', idleMs }
147
+ return { kind: 'quiet', idleMs }
148
+ }
149
+
150
+ /**
151
+ * 对齐官方重命名(官方 rename 为 log-backed session/title user 事件,同一数据源)。
152
+ * 显示优先级:档案 user 源标题(官方唯一真源)> renames.json 遗留 overlay(自然淘汰)。
153
+ */
154
+ function effectiveUserTitle(home, entry, sessionId) {
155
+ if (entry && entry.titleSource === 'user' && typeof entry.title === 'string' && entry.title !== '') {
156
+ return entry.title
157
+ }
158
+ return userTitleOf(home, sessionId)
159
+ }
160
+
69
161
  /** 汇总输出视图(去掉 counts 细节,浏览器更轻)。 */
70
162
  function sessionView(summary) {
71
163
  if (!summary) return summary
@@ -593,6 +685,9 @@ export function apply(ctx) {
593
685
  return
594
686
  }
595
687
 
688
+ // 插件设置命名空间(软依赖;卡死阈值等 host 侧参数经 liveSettings 下发)。
689
+ installSettings(ctx)
690
+
596
691
  webServer.register({
597
692
  kind: 'exact',
598
693
  path: '/api/session-flow',
@@ -629,7 +724,7 @@ export function apply(ctx) {
629
724
  if (requested !== null && ws.name !== requested) continue
630
725
  const result = scanWorkspaceIndex(home, ws.name, { force })
631
726
  const sessions = Object.values(result.index.sessions)
632
- .map((s) => ({ ...sessionView(s), userTitle: userTitleOf(home, s.id) }))
727
+ .map((s) => ({ ...sessionView(s), userTitle: effectiveUserTitle(home, s, s.id) }))
633
728
  .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
634
729
  const meta = workspaceLabelOf(ws.name, sessions)
635
730
  workspaces.push({
@@ -717,7 +812,7 @@ export function apply(ctx) {
717
812
  workspaceLabel: wsMeta.label,
718
813
  workspaceCwd: wsMeta.cwd,
719
814
  session: sessionView(sum),
720
- userTitle: userTitleOf(home, sessionId),
815
+ userTitle: effectiveUserTitle(home, sum, sessionId),
721
816
  counts,
722
817
  lightTurns: entry.lightTurns,
723
818
  toolStats: entry.toolStats,
@@ -874,8 +969,11 @@ export function apply(ctx) {
874
969
  const chunks = chunkTurns(entry.turns, EXPORT_CHUNK_TARGET)
875
970
  // 概览(含分卷指引)。
876
971
  const overview = renderOverviewMd({ entry, sum, found, title, llmText, chunkCount: chunks.length })
877
- // 文件名用自定义标题优先(M8a:导出在 host 端合并,前端无法覆盖)。
878
- const rawTitle = userTitleOf(home, sessionId) || title || sessionId
972
+ // 文件名用自定义标题优先(档案 user 源标题 = 官方真源优先;renames.json 遗留回退)。
973
+ const parsedTitle = entry.parsed.title
974
+ const archiveUserTitle = parsedTitle && parsedTitle.source && parsedTitle.source.kind === 'user'
975
+ ? parsedTitle.title : null
976
+ const rawTitle = archiveUserTitle || userTitleOf(home, sessionId) || title || sessionId
879
977
  const safeTitle = String(rawTitle).replace(/[\\/:*?"<>|\r\n]/g, '_').slice(0, 60)
880
978
  // 组装 ZIP 文件清单:概览 + 时间线分卷(00-概览.md / 01-时间线-回合X-Y.md …)。
881
979
  const zipFiles = [{ name: '00-概览.md', data: Buffer.from(overview, 'utf8') }]
@@ -934,7 +1032,7 @@ export function apply(ctx) {
934
1032
  const buildNode = (s) => ({
935
1033
  id: s.id,
936
1034
  title: s.title || null,
937
- userTitle: userTitleOf(home, s.id),
1035
+ userTitle: effectiveUserTitle(home, s, s.id),
938
1036
  delegationDepth: s.delegationDepth || 0,
939
1037
  createdAt: s.createdAt || null,
940
1038
  lastEventTime: s.lastEventTime || null,
@@ -982,12 +1080,25 @@ export function apply(ctx) {
982
1080
  }
983
1081
  const STREAM_MID_TYPES = new Set(['assistant/chunk', 'assistant/message', 'tool/call', 'step/start', 'turn/start', 'user/message', 'request/header'])
984
1082
  const running = openTurns > 0 || openSteps > 0 || openTools > 0 || STREAM_MID_TYPES.has(lastType)
1083
+ // 卡死监控:健康事实 + 分类。assumeRunning:总览探测以官方 sessions.list 的
1084
+ // running 为准(tail 窗口可能不含 turn/start,结构信号会漏判),此时结构信号只供
1085
+ // openTool/inflight 事实;now 由 client 传入(缺省 host 本地时间)。
1086
+ const now = typeof body.now === 'number' ? body.now : Date.now()
1087
+ const effectiveRunning = running || body.assumeRunning === true
1088
+ const healthFacts = {
1089
+ running: effectiveRunning,
1090
+ lastEventTime: summary.lastEventTime !== undefined ? summary.lastEventTime : null,
1091
+ lastEventType: lastType,
1092
+ openTool: openTools > 0,
1093
+ inflight: lastType === 'assistant/chunk',
1094
+ }
985
1095
  return sendJson(res, 200, {
986
1096
  ok: true,
987
1097
  session: sessionView(summary),
988
1098
  counts,
989
1099
  timeline,
990
1100
  running,
1101
+ health: { ...healthFacts, ...classifyHealth(healthFacts, now, liveSettings.stallThresholdMin * 60000) },
991
1102
  })
992
1103
  }
993
1104
 
@@ -1149,18 +1260,19 @@ export function apply(ctx) {
1149
1260
  if (matches.length === 0) continue
1150
1261
  // 标题:索引优先({title, source} 对象取 .title),无索引时用会话 id。
1151
1262
  let title = c.id
1263
+ let entry = null
1152
1264
  try {
1153
1265
  const index = readIndex(home, c.workspace)
1154
- const ent = index.sessions && index.sessions[c.id]
1155
- if (ent && ent.title) {
1156
- title = typeof ent.title === 'object' ? String(ent.title.title || c.id) : String(ent.title)
1266
+ entry = (index.sessions && index.sessions[c.id]) || null
1267
+ if (entry && entry.title) {
1268
+ title = typeof entry.title === 'object' ? String(entry.title.title || c.id) : String(entry.title)
1157
1269
  }
1158
1270
  } catch {}
1159
1271
  results.push({
1160
1272
  sessionId: c.id,
1161
1273
  workspace: c.workspace,
1162
1274
  title,
1163
- userTitle: userTitleOf(home, c.id),
1275
+ userTitle: effectiveUserTitle(home, entry, c.id),
1164
1276
  matchCount: matches.length,
1165
1277
  matches: matches.slice(0, SEARCH_MATCHES_PER_SESSION),
1166
1278
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-session-flow",
3
- "description": "DSH 会话回顾与归档插件:可折叠信息流 + 会话级汇总——总览工作台、折叠时间线、血缘树、双模式摘要、全文检索、ZIP 导出、实时跟踪、会话重命名",
4
- "version": "1.0.0",
3
+ "description": "DSH 会话回顾与归档插件:可折叠信息流 + 会话级汇总——总览工作台、折叠时间线、血缘树、双模式摘要、全文检索、ZIP 导出、实时跟踪、会话重命名(官方数据源对齐)、健康监控",
4
+ "version": "1.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/host.js",
7
7
  "exports": {
@@ -42,6 +42,9 @@
42
42
  "platform": "web"
43
43
  }
44
44
  },
45
+ "dependencies": {
46
+ "schemastery": "^3.18.0"
47
+ },
45
48
  "peerDependencies": {
46
49
  "react": "^18.2.0",
47
50
  "react-dom": "^18.2.0",