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/lib/index.js +193 -24
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/index.js +80 -8
- package/src/title-persist-index.js +137 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|