dsh-sessions-manager 3.4.1 → 3.4.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sessions-manager",
3
3
  "description": "DSH 设置面板会话管理器:归档 / 恢复 / 彻底删除 / 移动到其他工作区,带工作区标签与会话日期;统一「会话管理」面板。Session manager for the DeepSeek Harness settings panel — archive / restore / permanently delete / move sessions across workspaces, with workspace tags & session dates in one unified panel.",
4
- "version": "3.4.1",
4
+ "version": "3.4.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
package/src/index.js CHANGED
@@ -16,7 +16,8 @@ import { renderSessionMarkdown } from './markdown.js'
16
16
  import { createStarIndex } from './star-index.js'
17
17
  import { aggregateStorage } from './storage-stats.js'
18
18
  import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
19
- import { createSessionMetaCache } from './session-meta-cache.js'
19
+ import { createSessionMetaCache, fingerprintOf } from './session-meta-cache.js'
20
+ import { createTitleIndexStore } from './title-persist-index.js'
20
21
 
21
22
 
22
23
  export const name = 'dsh-sessions-manager'
@@ -128,6 +129,44 @@ export function apply(ctx) {
128
129
  // 见 src/session-meta-cache.js 的说明:列表构建原本每条会话都要整本解压日志,
129
130
  // 这个缓存让「日志没变」的会话直接跳过解码。
130
131
  const metaCache = createSessionMetaCache()
132
+ // 持久标题索引(冷启动加速):metaCache 是进程内的,重启即空——第一次列表
133
+ // 仍要全库解码。索引按同样的 (mtime, size) 指纹存解码结果,指纹没变的会话
134
+ // 重启后也直接复用。见 src/title-persist-index.js。
135
+ const titleIndex = createTitleIndexStore({ dir: TRASH_DIR, file: join(TRASH_DIR, 'title-index.json') })
136
+
137
+ // P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
138
+ // 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
139
+ async function hydrateFromPersist(ids, statsById) {
140
+ const hits = new Map()
141
+ if (!ids || !ids.length) return hits
142
+ let store
143
+ try { store = await titleIndex.entries() } catch (e) { return hits }
144
+ for (const id of ids) {
145
+ const stat = statsById.get(id)
146
+ const entry = store && store[id]
147
+ if (!stat || !entry) continue
148
+ const fp = fingerprintOf(stat)
149
+ if (fp && entry.fingerprint === fp) {
150
+ hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
151
+ }
152
+ }
153
+ return hits
154
+ }
155
+
156
+ // 把本批真正解码出的元数据异步回写持久索引(fire-and-forget:索引只是
157
+ // 加速器,写失败不影响响应,队列内部已串行化 + 原子替换)。
158
+ function persistDecoded(decoded, statsById) {
159
+ if (!decoded || !decoded.size) return
160
+ const batch = {}
161
+ const now = Date.now()
162
+ for (const [id, meta] of decoded) {
163
+ const fp = fingerprintOf(statsById.get(id))
164
+ if (!fp) continue
165
+ batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
166
+ }
167
+ if (!Object.keys(batch).length) return
168
+ titleIndex.merge(batch).catch(() => {})
169
+ }
131
170
 
132
171
  // 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
133
172
  function metaFromSnapshot(o) {
@@ -239,6 +278,8 @@ export function apply(ctx) {
239
278
  } catch (e2) { /* keep what we have */ }
240
279
  }
241
280
  metaCache.set(key, statInfo, meta)
281
+ // 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
282
+ if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
242
283
  return buildItem(key, meta, usage, opts.exposeUsage)
243
284
  }
244
285
 
@@ -805,7 +846,13 @@ export function apply(ctx) {
805
846
  // 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
806
847
  const statsById = new Map(visibleIds.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
807
848
  const { cached, missing } = metaCache.partition(visibleIds, statsById)
808
- const snapshotById = await projectTitles(missing)
849
+ // P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
850
+ const persisted = await hydrateFromPersist(missing, statsById)
851
+ for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
852
+ const stillMissing = missing.filter((id) => !persisted.has(id))
853
+ const snapshotById = await projectTitles(stillMissing)
854
+ const decoded = new Map()
855
+ const collectDecoded = (id, meta) => { decoded.set(id, meta) }
809
856
  const CHUNK = 6
810
857
  for (let i = 0; i < visibleIds.length; i += CHUNK) {
811
858
  // Arrow wrapper on purpose: Array#map passes (value, index, array), and
@@ -813,9 +860,11 @@ export function apply(ctx) {
813
860
  const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => resolveOne(id, usage, {
814
861
  exposeUsage: !!(opts && opts.usage),
815
862
  preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
863
+ collectDecoded,
816
864
  })))
817
865
  for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
818
866
  }
867
+ persistDecoded(decoded, statsById)
819
868
  // Annotate stars; GC only when we have a trustworthy id baseline, so a
820
869
  // failing sp.list() can never wipe the whole index.
821
870
  let starredSet = new Set()
@@ -889,19 +938,27 @@ export function apply(ctx) {
889
938
  const usage = await collectUsage(headers)
890
939
  const statsById = new Map(ids.map((id) => [id, { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) }]))
891
940
  const { cached, missing } = metaCache.partition(ids, statsById)
892
- const snapshotById = await projectTitles(missing)
941
+ // P4:与列表构建共用持久标题索引,冷启动零解码。
942
+ const persisted = await hydrateFromPersist(missing, statsById)
943
+ for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
944
+ const rest = missing.filter((id) => !persisted.has(id))
945
+ const snapshotById = await projectTitles(rest)
946
+ const decoded = new Map()
947
+ const collectDecoded = (id, meta) => { decoded.set(id, meta) }
893
948
  for (const id of ids) {
894
- let meta = cached.get(id) || null
949
+ let meta = cached.get(id) || persisted.get(id) || null
895
950
  if (!meta) {
896
951
  const snapshot = snapshotById.has(id)
897
952
  ? snapshotById.get(id)
898
953
  : (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
899
954
  const next = metaFromSnapshot(snapshot)
900
955
  metaCache.set(id, statsById.get(id), next)
956
+ if (statsById.get(id)) collectDecoded(id, next)
901
957
  meta = next
902
958
  }
903
959
  if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
904
960
  }
961
+ persistDecoded(decoded, statsById)
905
962
  }
906
963
  return {
907
964
  titles: Object.fromEntries(authorityTitleCache),
@@ -1226,6 +1283,8 @@ export function apply(ctx) {
1226
1283
  if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
1227
1284
  const out = await purgeFromTrash(sid)
1228
1285
  metaCache.invalidate(sid)
1286
+ // 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
1287
+ titleIndex.remove([sid]).catch(() => {})
1229
1288
  json(res, out)
1230
1289
  } catch (e) {
1231
1290
  json(res, { ok: false, error: String((e && e.message) || e) }, 500)
@@ -1243,7 +1302,7 @@ export function apply(ctx) {
1243
1302
  if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
1244
1303
  const results = []
1245
1304
  for (const sid of ids) {
1246
- try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid) }
1305
+ try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); titleIndex.remove([sid]).catch(() => {}) }
1247
1306
  catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
1248
1307
  }
1249
1308
  json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
@@ -1440,7 +1499,10 @@ export function apply(ctx) {
1440
1499
 
1441
1500
  // Auto-archive settings. A plain read (no patch keys) doubles as the lazy
1442
1501
  // sweep trigger — that is how the once-a-day cleanup gets a chance to run
1443
- // without a background timer.
1502
+ // without a background timer. The sweep runs in the background so opening
1503
+ // the panel never waits on it (P5, issue #1): the sweep itself already
1504
+ // reuses the metadata caches, and this keeps the settings read latency
1505
+ // independent of library size.
1444
1506
  disposers.push(ctx.webServer.register({
1445
1507
  kind: 'exact',
1446
1508
  path: '/archived-sessions/auto-archive/settings',
@@ -1450,10 +1512,20 @@ export function apply(ctx) {
1450
1512
  const patch = {}
1451
1513
  if (body && Object.prototype.hasOwnProperty.call(body, 'inactiveDays')) patch.inactiveDays = body.inactiveDays
1452
1514
  if (body && Object.prototype.hasOwnProperty.call(body, 'skipStarred')) patch.skipStarred = body.skipStarred
1453
- const settings = Object.keys(patch).length
1515
+ const isPatch = Object.keys(patch).length > 0
1516
+ const settings = isPatch
1454
1517
  ? await autoArchive.update(patch)
1455
1518
  : (await autoArchive.read()).settings
1456
- const sweep = await autoArchiveSweep()
1519
+ let sweep
1520
+ if (isPatch) {
1521
+ // 显式保存设置:保持「保存即生效」的同步 sweep(含刚启用时的首次归档)。
1522
+ sweep = await autoArchiveSweep()
1523
+ } else {
1524
+ // 面板打开的纯读取:sweep 转后台执行,打开延迟与库大小解耦
1525
+ //(P5,issue #1)。sweep 本身已复用元数据缓存 + 持久标题索引。
1526
+ void autoArchiveSweep().catch(() => {})
1527
+ sweep = { triggered: true }
1528
+ }
1457
1529
  const store = await autoArchive.read()
1458
1530
  json(res, {
1459
1531
  ok: true,
@@ -0,0 +1,137 @@
1
+ // title-persist-index.js — 会话标题/元数据的**磁盘**小索引(P4,issue #1)。
2
+ //
3
+ // metaCache(session-meta-cache.js)解决的是「进程内重复解码」;本模块解决
4
+ // 的是冷启动:插件重启后内存缓存为空,第一次列表构建仍要全库解码一次。
5
+ // 把解码出的元数据连同文件指纹原子写进一个 JSON 索引,重启后列表只需
6
+ // 一次索引读 + 指纹比对,指纹没变的会话零解码。
7
+ //
8
+ // 结构仿 trash 的原子写索引:{ schemaVersion, entries: { [sessionId]: entry } }
9
+ // entry = { title, cwd, createdAt, fingerprint, updatedAt }
10
+ // fingerprint 即 session-meta-cache.js 的 fingerprintOf(stat) 产出
11
+ // ("<mtimeMs>:<size>"),比对一致即可信任条目内容。
12
+ //
13
+ // 写入时机由调用方决定(列表构建收尾批量回写、purge 时清理),本模块只
14
+ // 提供:读取缓存、合并写入(串行化 + 原子替换)、按 id 删除。任何文件
15
+ // 损坏都按空索引处理,绝不阻塞列表构建。
16
+ //
17
+ // 单实例假设:一个进程内只 apply 一个插件实例(生产即如此),实例间的
18
+ // 内存副本不互相同步——跨「重启」以落盘内容为准(测试亦按此断言)。
19
+
20
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
21
+ import { dirname, join } from 'node:path'
22
+
23
+ export const TITLE_INDEX_SCHEMA_VERSION = 1
24
+
25
+ const MAX_ENTRIES = 20000
26
+
27
+ // 条目只保留可序列化且对列表有用的字段;指纹缺失的条目无法校验,直接丢弃——
28
+ // 宁可下次重解码,也不能把无法失效的数据当真。
29
+ export function normalizeEntry(raw) {
30
+ if (!raw || typeof raw !== 'object') return null
31
+ const title = typeof raw.title === 'string' ? raw.title : null
32
+ const cwd = typeof raw.cwd === 'string' ? raw.cwd : null
33
+ const createdAt = typeof raw.createdAt === 'number' ? raw.createdAt : null
34
+ const fingerprint = typeof raw.fingerprint === 'string' && raw.fingerprint ? raw.fingerprint : null
35
+ const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
36
+ if (!fingerprint || (!title && !cwd)) return null
37
+ return { title, cwd, createdAt, fingerprint, updatedAt }
38
+ }
39
+
40
+ export function normalizeTitleIndex(raw) {
41
+ const entries = {}
42
+ if (raw && typeof raw === 'object' && raw.entries && typeof raw.entries === 'object') {
43
+ for (const [id, entry] of Object.entries(raw.entries)) {
44
+ if (typeof id !== 'string' || !id || id.length > 200) continue
45
+ const normalized = normalizeEntry(entry)
46
+ if (normalized) entries[id] = normalized
47
+ }
48
+ }
49
+ return { schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries }
50
+ }
51
+
52
+ // 纯合并:right 覆盖 left 同 id 条目;截断到 MAX_ENTRIES(保留 updatedAt 新的)。
53
+ export function mergeEntries(left, right) {
54
+ const merged = { ...left }
55
+ for (const [id, entry] of Object.entries(right)) merged[id] = entry
56
+ const ids = Object.keys(merged)
57
+ if (ids.length > MAX_ENTRIES) {
58
+ ids.sort((a, b) => (merged[a].updatedAt || 0) - (merged[b].updatedAt || 0))
59
+ for (const id of ids.slice(0, ids.length - MAX_ENTRIES)) delete merged[id]
60
+ }
61
+ return merged
62
+ }
63
+
64
+ export function createTitleIndexStore({ dir, file }) {
65
+ let cache = null
66
+ let chain = Promise.resolve()
67
+ const path = file || join(dir, 'title-index.json')
68
+
69
+ async function readRaw() {
70
+ try {
71
+ return normalizeTitleIndex(JSON.parse(await readFile(path, 'utf8')))
72
+ } catch (e) {
73
+ return normalizeTitleIndex(null)
74
+ }
75
+ }
76
+
77
+ // 所有写操作串行化(仿 trash 的 mutate 队列),避免并发 merge 互相覆盖。
78
+ function enqueue(mutator) {
79
+ const operation = chain.then(async () => {
80
+ const store = cache || (cache = (await readRaw()).entries)
81
+ await mutator(store)
82
+ return store
83
+ })
84
+ chain = operation.catch(() => {})
85
+ return operation
86
+ }
87
+
88
+ return {
89
+ // 只读:内存优先,未加载过才落盘一次。绝不抛错。
90
+ async entries() {
91
+ if (cache) return cache
92
+ cache = (await readRaw()).entries
93
+ return cache
94
+ },
95
+ // 批量合并写入(原子替换)。失败静默:索引只是加速器,坏了下次重解码。
96
+ async merge(batch) {
97
+ const right = {}
98
+ for (const [id, entry] of Object.entries(batch || {})) {
99
+ const normalized = normalizeEntry(entry)
100
+ if (normalized) right[String(id)] = normalized
101
+ }
102
+ if (!Object.keys(right).length) return false
103
+ await enqueue(async (store) => {
104
+ const next = mergeEntries(store, right)
105
+ await mkdir(dirname(path), { recursive: true })
106
+ const tmp = join(dirname(path), `.title-index-${process.pid}-${Date.now()}.tmp`)
107
+ await writeFile(tmp, JSON.stringify({ schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries: next }), { encoding: 'utf8', mode: 0o600 })
108
+ await rename(tmp, path)
109
+ cache = next
110
+ })
111
+ return true
112
+ },
113
+ async remove(ids) {
114
+ const wanted = new Set((ids || []).map(String))
115
+ if (!wanted.size) return false
116
+ await enqueue(async (store) => {
117
+ let changed = false
118
+ for (const id of wanted) {
119
+ if (id in store) { delete store[id]; changed = true }
120
+ }
121
+ if (!changed) return
122
+ await mkdir(dirname(path), { recursive: true })
123
+ const tmp = join(dirname(path), `.title-index-${process.pid}-${Date.now()}.tmp`)
124
+ await writeFile(tmp, JSON.stringify({ schemaVersion: TITLE_INDEX_SCHEMA_VERSION, entries: store }), { encoding: 'utf8', mode: 0o600 })
125
+ await rename(tmp, path)
126
+ })
127
+ return true
128
+ },
129
+ }
130
+ }
131
+
132
+ // 判定持久条目能否当作当前日志的解码结果:指纹一致即可(与内存缓存同一标准)。
133
+ export function persistEntryUsable(entry, stat) {
134
+ const normalized = normalizeEntry(entry)
135
+ if (!normalized || !stat) return null
136
+ return normalized.fingerprint === stat.fingerprint ? normalized : null
137
+ }