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/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
- const rawTitle = title || sessionId
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)
@@ -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.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",