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/README.en.md +5 -2
- package/README.md +5 -2
- package/lib/client.js +1140 -213
- package/lib/host.js +122 -10
- package/package.json +5 -2
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:
|
|
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:
|
|
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
|
-
//
|
|
878
|
-
const
|
|
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:
|
|
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
|
-
|
|
1155
|
-
if (
|
|
1156
|
-
title = typeof
|
|
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:
|
|
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.
|
|
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",
|