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/README.en.md +49 -99
- package/README.md +47 -97
- package/assets/screenshots/dock-right.png +0 -0
- package/assets/screenshots/folded-detail.png +0 -0
- package/assets/screenshots/fulltext-search.png +0 -0
- package/assets/screenshots/overview.png +0 -0
- package/assets/screenshots/session-flow-tab.png +0 -0
- package/assets/screenshots/turn-rail.png +0 -0
- package/lib/archive.js +40 -2
- package/lib/client.js +1528 -17
- package/lib/host.js +198 -3
- package/lib/index-store.js +1 -1
- package/lib/renames.js +80 -0
- package/package.json +4 -3
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
|
-
|
|
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)
|
package/lib/index-store.js
CHANGED
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": "
|
|
4
|
-
"version": "
|
|
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",
|