dsh-session-flow 0.1.0 → 1.0.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 +46 -99
- package/README.md +44 -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 +1256 -13
- package/lib/host.js +144 -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
|
|
|
@@ -627,7 +629,7 @@ export function apply(ctx) {
|
|
|
627
629
|
if (requested !== null && ws.name !== requested) continue
|
|
628
630
|
const result = scanWorkspaceIndex(home, ws.name, { force })
|
|
629
631
|
const sessions = Object.values(result.index.sessions)
|
|
630
|
-
.map(sessionView)
|
|
632
|
+
.map((s) => ({ ...sessionView(s), userTitle: userTitleOf(home, s.id) }))
|
|
631
633
|
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
|
|
632
634
|
const meta = workspaceLabelOf(ws.name, sessions)
|
|
633
635
|
workspaces.push({
|
|
@@ -653,6 +655,27 @@ export function apply(ctx) {
|
|
|
653
655
|
})
|
|
654
656
|
}
|
|
655
657
|
|
|
658
|
+
if (method === 'rename') {
|
|
659
|
+
// M8a 会话重命名:私有 userTitle 显示层覆盖(不动原始存档)。
|
|
660
|
+
// 空标题 = 清除恢复原名;超长 400;会话不存在 404;落盘失败回滚 + 500。
|
|
661
|
+
const sessionId = String(body.sessionId || '')
|
|
662
|
+
if (!sessionId) return sendJson(res, 400, { ok: false, error: 'sessionId required' })
|
|
663
|
+
const found = findWorkspaceOfSession(home, sessionId)
|
|
664
|
+
if (found === null) return sendJson(res, 404, { ok: false, error: `session ${sessionId} not found in archives` })
|
|
665
|
+
const raw = typeof body.title === 'string' ? body.title : ''
|
|
666
|
+
const trimmed = raw.trim()
|
|
667
|
+
if (trimmed.length > MAX_RENAME_LENGTH) {
|
|
668
|
+
return sendJson(res, 400, { ok: false, error: `title too long (max ${MAX_RENAME_LENGTH} chars)` })
|
|
669
|
+
}
|
|
670
|
+
const previous = userTitleOf(home, sessionId)
|
|
671
|
+
const userTitle = applyRename(home, sessionId, trimmed)
|
|
672
|
+
if (!saveRenames(home)) {
|
|
673
|
+
applyRename(home, sessionId, previous || '')
|
|
674
|
+
return sendJson(res, 500, { ok: false, error: 'failed to persist renames.json' })
|
|
675
|
+
}
|
|
676
|
+
return sendJson(res, 200, { ok: true, userTitle })
|
|
677
|
+
}
|
|
678
|
+
|
|
656
679
|
if (method === 'get') {
|
|
657
680
|
// 轻量详情(秒开):只返回回合摘要 + 工具统计;完整时间线由 getTurn 按需取。
|
|
658
681
|
const sessionId = String(body.sessionId || '')
|
|
@@ -694,6 +717,7 @@ export function apply(ctx) {
|
|
|
694
717
|
workspaceLabel: wsMeta.label,
|
|
695
718
|
workspaceCwd: wsMeta.cwd,
|
|
696
719
|
session: sessionView(sum),
|
|
720
|
+
userTitle: userTitleOf(home, sessionId),
|
|
697
721
|
counts,
|
|
698
722
|
lightTurns: entry.lightTurns,
|
|
699
723
|
toolStats: entry.toolStats,
|
|
@@ -850,7 +874,8 @@ export function apply(ctx) {
|
|
|
850
874
|
const chunks = chunkTurns(entry.turns, EXPORT_CHUNK_TARGET)
|
|
851
875
|
// 概览(含分卷指引)。
|
|
852
876
|
const overview = renderOverviewMd({ entry, sum, found, title, llmText, chunkCount: chunks.length })
|
|
853
|
-
|
|
877
|
+
// 文件名用自定义标题优先(M8a:导出在 host 端合并,前端无法覆盖)。
|
|
878
|
+
const rawTitle = userTitleOf(home, sessionId) || title || sessionId
|
|
854
879
|
const safeTitle = String(rawTitle).replace(/[\\/:*?"<>|\r\n]/g, '_').slice(0, 60)
|
|
855
880
|
// 组装 ZIP 文件清单:概览 + 时间线分卷(00-概览.md / 01-时间线-回合X-Y.md …)。
|
|
856
881
|
const zipFiles = [{ name: '00-概览.md', data: Buffer.from(overview, 'utf8') }]
|
|
@@ -909,6 +934,7 @@ export function apply(ctx) {
|
|
|
909
934
|
const buildNode = (s) => ({
|
|
910
935
|
id: s.id,
|
|
911
936
|
title: s.title || null,
|
|
937
|
+
userTitle: userTitleOf(home, s.id),
|
|
912
938
|
delegationDepth: s.delegationDepth || 0,
|
|
913
939
|
createdAt: s.createdAt || null,
|
|
914
940
|
lastEventTime: s.lastEventTime || null,
|
|
@@ -1036,6 +1062,121 @@ export function apply(ctx) {
|
|
|
1036
1062
|
return sendJson(res, 200, { ok: true, query, count: matches.length, matches })
|
|
1037
1063
|
}
|
|
1038
1064
|
|
|
1065
|
+
if (method === 'searchAll') {
|
|
1066
|
+
// 方向 A:跨会话全文检索(内容级召回,复用 searchIn 的扫描语义)。
|
|
1067
|
+
// 约束(PERF-ANALYSIS §2A):按 lastEventTime(文件 mtime)取最近 SEARCH_MAX_SESSIONS
|
|
1068
|
+
// 个会话;超大文件跳过;总时间预算 SEARCH_TIME_BUDGET_MS,超时返回已扫部分 + hasMore;
|
|
1069
|
+
// 请求中止(req aborted)即停;搜索词 ≥2 字符;workspace 可选过滤。
|
|
1070
|
+
// 每会话:cachedSession(缓存优先)→ 自由文本扫描(用户/助手/思考/工具名/参数/结果)
|
|
1071
|
+
// → matchCount 全量统计(排序依据)+ 返回前 SEARCH_MATCHES_PER_SESSION 条命中。
|
|
1072
|
+
const query = String(body.query || '').trim()
|
|
1073
|
+
if (query.length < 2) return sendJson(res, 400, { ok: false, error: 'query must be at least 2 characters' })
|
|
1074
|
+
const q = query.toLowerCase()
|
|
1075
|
+
const wsFilter = body.workspace ? String(body.workspace) : ''
|
|
1076
|
+
const SEARCH_MAX_SESSIONS = 20
|
|
1077
|
+
const SEARCH_MATCHES_PER_SESSION = 5
|
|
1078
|
+
const SEARCH_TIME_BUDGET_MS = 5000
|
|
1079
|
+
const SEARCH_MAX_FILE_MB = 50
|
|
1080
|
+
|
|
1081
|
+
// 枚举会话(mtime 降序 = 最近活动优先)。
|
|
1082
|
+
const candidates = []
|
|
1083
|
+
for (const ws of listWorkspaces(home)) {
|
|
1084
|
+
if (wsFilter && ws.name !== wsFilter) continue
|
|
1085
|
+
for (const s of listSessionDirs(ws.dir)) {
|
|
1086
|
+
candidates.push({ id: s.id, file: s.file, mtimeMs: s.mtimeMs, sizeBytes: s.sizeBytes, workspace: ws.name })
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
1090
|
+
const selected = candidates.slice(0, SEARCH_MAX_SESSIONS)
|
|
1091
|
+
|
|
1092
|
+
const started = Date.now()
|
|
1093
|
+
const results = []
|
|
1094
|
+
let scanned = 0
|
|
1095
|
+
let aborted = false
|
|
1096
|
+
req.on('aborted', () => { aborted = true })
|
|
1097
|
+
|
|
1098
|
+
for (const c of selected) {
|
|
1099
|
+
if (aborted) break
|
|
1100
|
+
if (Date.now() - started > SEARCH_TIME_BUDGET_MS) break
|
|
1101
|
+
// 超大文件跳过(异常会话,避免单会话拖垮总预算)。
|
|
1102
|
+
if (c.sizeBytes > SEARCH_MAX_FILE_MB * 1024 * 1024) continue
|
|
1103
|
+
let turns
|
|
1104
|
+
try {
|
|
1105
|
+
const entry = cachedSession(home, c.id, c.file, c.mtimeMs, c.sizeBytes)
|
|
1106
|
+
turns = entry.turns
|
|
1107
|
+
} catch {
|
|
1108
|
+
continue
|
|
1109
|
+
}
|
|
1110
|
+
if (!Array.isArray(turns) || turns.length === 0) continue
|
|
1111
|
+
scanned++
|
|
1112
|
+
const matches = []
|
|
1113
|
+
const snippet = (text, needle) => {
|
|
1114
|
+
const t = String(text || '')
|
|
1115
|
+
const i = t.toLowerCase().indexOf(needle)
|
|
1116
|
+
if (i < 0) return t.slice(0, 100)
|
|
1117
|
+
const start = Math.max(0, i - 30)
|
|
1118
|
+
return (start > 0 ? '…' : '') + t.slice(start, i + needle.length + 60) + (i + needle.length + 60 < t.length ? '…' : '')
|
|
1119
|
+
}
|
|
1120
|
+
for (const t of turns) {
|
|
1121
|
+
for (const u of t.userMessages) {
|
|
1122
|
+
if (u.text.toLowerCase().includes(q)) {
|
|
1123
|
+
matches.push({ kind: 'user', turn: t.turn, seq: u.seq, preview: snippet(u.text, q) })
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
for (const a of t.assistantMessages) {
|
|
1127
|
+
if (a.hasThinking && a.thinking.toLowerCase().includes(q)) {
|
|
1128
|
+
matches.push({ kind: 'thinking', turn: t.turn, seq: a.seq, preview: snippet(a.thinking, q) })
|
|
1129
|
+
}
|
|
1130
|
+
if (a.hasText && a.text.toLowerCase().includes(q)) {
|
|
1131
|
+
matches.push({ kind: 'assistant', turn: t.turn, seq: a.seq, preview: snippet(a.text, q) })
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
for (const s of t.steps) {
|
|
1135
|
+
for (const call of s.toolCalls) {
|
|
1136
|
+
const argsLower = call.argumentsText.toLowerCase()
|
|
1137
|
+
const resLower = call.resultText.toLowerCase()
|
|
1138
|
+
if (call.name.toLowerCase().includes(q) || argsLower.includes(q) || resLower.includes(q)) {
|
|
1139
|
+
const inArgs = argsLower.includes(q)
|
|
1140
|
+
matches.push({
|
|
1141
|
+
kind: call.isError === true ? 'error' : 'tool',
|
|
1142
|
+
turn: t.turn, callId: call.callId, name: call.name,
|
|
1143
|
+
preview: snippet(inArgs ? call.argumentsText : call.resultText, q) || call.resultPreview,
|
|
1144
|
+
})
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (matches.length === 0) continue
|
|
1150
|
+
// 标题:索引优先({title, source} 对象取 .title),无索引时用会话 id。
|
|
1151
|
+
let title = c.id
|
|
1152
|
+
try {
|
|
1153
|
+
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)
|
|
1157
|
+
}
|
|
1158
|
+
} catch {}
|
|
1159
|
+
results.push({
|
|
1160
|
+
sessionId: c.id,
|
|
1161
|
+
workspace: c.workspace,
|
|
1162
|
+
title,
|
|
1163
|
+
userTitle: userTitleOf(home, c.id),
|
|
1164
|
+
matchCount: matches.length,
|
|
1165
|
+
matches: matches.slice(0, SEARCH_MATCHES_PER_SESSION),
|
|
1166
|
+
})
|
|
1167
|
+
}
|
|
1168
|
+
results.sort((a, b) => b.matchCount - a.matchCount)
|
|
1169
|
+
const hasMore = scanned < selected.length && !aborted
|
|
1170
|
+
return sendJson(res, 200, {
|
|
1171
|
+
ok: true,
|
|
1172
|
+
query,
|
|
1173
|
+
scanned,
|
|
1174
|
+
total: selected.length,
|
|
1175
|
+
hasMore,
|
|
1176
|
+
results,
|
|
1177
|
+
})
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1039
1180
|
if (method === 'cacheInfo') {
|
|
1040
1181
|
// 缓存管理:统计索引与时间线缓存的体积/数量。
|
|
1041
1182
|
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.0.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",
|