dsh-session-autarchive 0.1.0 → 0.1.1
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 +1 -1
- package/README.md +2 -2
- package/index.js +140 -8
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -17,7 +17,7 @@ Archives sessions that have gone unused for longer than a configurable number of
|
|
|
17
17
|
|
|
18
18
|
## How it decides and runs
|
|
19
19
|
|
|
20
|
-
- **Decision**: scan `$DSH_HOME/sessions/<workspace>/session-*` directories
|
|
20
|
+
- **Decision**: scan `$DSH_HOME/sessions/<workspace>/session-*` directories and read the time of the last `turn/end` (end of a conversation turn) event inside each session log as "last activity". Sessions older than the threshold (5 days by default), not yet archived, not on the protected list, and not pinned (pinning is exempt by default) become pending sessions. Merely opening or resuming a session does not count: resuming appends the opening prompt to the log, so the file write time is not used; when no event can be read, the file write time is the fallback. Sessions written to within the last hour are skipped for that round.
|
|
21
21
|
- **Execution**: the host runs a round every 10 minutes (the first one 60 seconds after start, so it keeps running even with no window open); the browser half polls the pending list every 5 minutes and archives through the official channel when it is available, so the sidebar updates immediately.
|
|
22
22
|
- Archiving goes through the official `archiveSession` call. It does not delete data: archived sessions stay in the archive list and can be restored.
|
|
23
23
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**中文** | [English](README.en.md)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
把长期没动过的会话自动归档。按会话内最后一条对话结束的时间算天数,默认 5 天;已归档、置顶(可关)、标记「不自动归档」的都跳过。侧栏会话右键菜单给单个会话加标记,通用设置里改天数。
|
|
8
8
|
|
|
9
9
|
## 界面上的三处
|
|
10
10
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
## 判定与执行
|
|
19
19
|
|
|
20
|
-
- **判定**:扫 `$DSH_HOME/sessions/<工作区>/session-*`
|
|
20
|
+
- **判定**:扫 `$DSH_HOME/sessions/<工作区>/session-*` 目录,解出会话档里最后一条 `turn/end`(对话结束)事件的时间当「最后活动」;超过阈值(默认 5 天)、未归档、不在保护名单、非置顶(置顶豁免默认开)的就是待归档的会话。只是被打开/恢复过不算活动——恢复会话会往档里追写开场提示,所以文件写入时间不作判据;解不出事件时退回文件最后写入时间。刚写过不到 1 小时的会话本轮跳过。
|
|
21
21
|
- **执行**:宿主每 10 分钟跑一轮(启动 60 秒后第一次,窗口没开也照常跑);浏览器半边每 5 分钟拉一次待归档清单,拿到官方归档通道就当场归档,侧栏立即刷新。
|
|
22
22
|
- 归档走官方 `archiveSession`,不是删数据。归档后的会话在侧栏归档列表里,可以恢复。
|
|
23
23
|
|
package/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
16
16
|
import { dirname, join } from 'node:path'
|
|
17
|
+
import * as zlib from 'node:zlib'
|
|
17
18
|
|
|
18
19
|
/** Cordis 插件名。 */
|
|
19
20
|
export const name = 'dsh-session-autarchive'
|
|
@@ -24,6 +25,17 @@ export const inject = ['tools']
|
|
|
24
25
|
const API_PREFIX = '/dsh-session-autarchive/api'
|
|
25
26
|
const DAY_MS = 86400000
|
|
26
27
|
|
|
28
|
+
/** zstd 帧头魔数(小端读出的值)。 */
|
|
29
|
+
const ZSTD_MAGIC = 0xfd2fb528
|
|
30
|
+
/** 刚被写过的会话留一小时宽限:正开着的那个不该在同一次 tick 里被收走。 */
|
|
31
|
+
const GRACE_MS = 60 * 60 * 1000
|
|
32
|
+
/** 一轮里最多解多少个档(解压是整份读进来,防止一次把几百兆都展开)。 */
|
|
33
|
+
const MAX_DECODE = 400
|
|
34
|
+
/** 会话档「最后一条对话结束时间」的缓存:同一个 文件+大小+写入时间 只解一次。 */
|
|
35
|
+
const turnCache = new Map()
|
|
36
|
+
/** 本轮还剩多少次解码额度(每次 plan 重置)。 */
|
|
37
|
+
let decodeBudget = MAX_DECODE
|
|
38
|
+
|
|
27
39
|
const DEFAULT_STATE = {
|
|
28
40
|
enabled: true,
|
|
29
41
|
days: 5,
|
|
@@ -177,12 +189,130 @@ function scanSessions() {
|
|
|
177
189
|
if (mtime === 0) {
|
|
178
190
|
try { mtime = statSync(dir).mtimeMs } catch { /* 换成目录时间也算 */ }
|
|
179
191
|
}
|
|
180
|
-
rows.push({ id: entry.name, mtime, workspace: ws.name })
|
|
192
|
+
rows.push({ id: entry.name, mtime, workspace: ws.name, dir })
|
|
181
193
|
}
|
|
182
194
|
}
|
|
183
195
|
return rows
|
|
184
196
|
}
|
|
185
197
|
|
|
198
|
+
/**
|
|
199
|
+
* 定位会话档里所有完整 zstd 帧的字节范围(会话档是多帧拼接:一帧一条追加)。
|
|
200
|
+
* 算法与 DSH 自己的 scanZstdFrames 一致;尾部撕裂帧直接当结束。
|
|
201
|
+
*/
|
|
202
|
+
function scanZstdFrames(buffer) {
|
|
203
|
+
const frames = []
|
|
204
|
+
let offset = 0
|
|
205
|
+
while (offset < buffer.length) {
|
|
206
|
+
const start = offset
|
|
207
|
+
if (buffer.length - offset < 4) return frames
|
|
208
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return frames
|
|
209
|
+
offset += 4
|
|
210
|
+
if (offset === buffer.length) return frames
|
|
211
|
+
const descriptor = buffer.readUInt8(offset)
|
|
212
|
+
offset += 1
|
|
213
|
+
if ((descriptor & 0x18) !== 0) return frames
|
|
214
|
+
const contentSizeFlag = descriptor >>> 6
|
|
215
|
+
const singleSegment = (descriptor & 0x20) !== 0
|
|
216
|
+
const checksum = (descriptor & 0x04) !== 0
|
|
217
|
+
const dictionaryFlag = descriptor & 0x03
|
|
218
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
|
|
219
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
|
|
220
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
|
|
221
|
+
if (buffer.length - offset < remainingHeaderBytes) return frames
|
|
222
|
+
offset += remainingHeaderBytes
|
|
223
|
+
for (;;) {
|
|
224
|
+
if (buffer.length - offset < 3) return frames
|
|
225
|
+
const blockHeader = buffer.readUIntLE(offset, 3)
|
|
226
|
+
offset += 3
|
|
227
|
+
const lastBlock = (blockHeader & 1) !== 0
|
|
228
|
+
const blockType = (blockHeader >>> 1) & 0x03
|
|
229
|
+
const blockSize = blockHeader >>> 3
|
|
230
|
+
if (blockType === 0x03) return frames
|
|
231
|
+
const payloadBytes = blockType === 0x01 ? 1 : blockSize
|
|
232
|
+
if (buffer.length - offset < payloadBytes) return frames
|
|
233
|
+
offset += payloadBytes
|
|
234
|
+
if (lastBlock) break
|
|
235
|
+
}
|
|
236
|
+
if (checksum) {
|
|
237
|
+
if (buffer.length - offset < 4) return frames
|
|
238
|
+
offset += 4
|
|
239
|
+
}
|
|
240
|
+
frames.push({ start, end: offset })
|
|
241
|
+
}
|
|
242
|
+
return frames
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 逐帧解压拼回整份文本;坏帧跳过,全坏返回空串。 */
|
|
246
|
+
function decodeSessionText(buffer) {
|
|
247
|
+
const parts = []
|
|
248
|
+
for (const frame of scanZstdFrames(buffer)) {
|
|
249
|
+
try {
|
|
250
|
+
parts.push(zlib.zstdDecompressSync(buffer.subarray(frame.start, frame.end)).toString('utf8'))
|
|
251
|
+
} catch { /* 单帧坏了不影响别的帧 */ }
|
|
252
|
+
}
|
|
253
|
+
return parts.join('\n')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 会话目录里当前那一代的存档(多代并存时取最大的那份)。 */
|
|
257
|
+
function sessionFileOf(dir) {
|
|
258
|
+
let best = ''
|
|
259
|
+
let bestSize = -1
|
|
260
|
+
let names
|
|
261
|
+
try { names = readdirSync(dir) } catch { return '' }
|
|
262
|
+
for (const name of names) {
|
|
263
|
+
if (!name.endsWith('.zstd')) continue
|
|
264
|
+
const full = join(dir, name)
|
|
265
|
+
let size = 0
|
|
266
|
+
try { size = statSync(full).size } catch { continue }
|
|
267
|
+
if (size > bestSize) { best = full; bestSize = size }
|
|
268
|
+
}
|
|
269
|
+
return best
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 会话档里最后一条「对话结束」事件(turn/end)的时间。
|
|
274
|
+
*
|
|
275
|
+
* 为什么不用文件写入时间:会话被**打开/恢复**时,DSH 也会往档里追写
|
|
276
|
+
* 开场提示(session/end-seed、agent/inbox/spliced),文件时间被顶新,
|
|
277
|
+
* 于是一个 8 天没聊的会话看着像「4 天前还活着」,永远轮不到清理。
|
|
278
|
+
* 打开会话不算活动,所以只认 turn/end。
|
|
279
|
+
*/
|
|
280
|
+
function lastTurnEndMs(file) {
|
|
281
|
+
if (!file) return 0
|
|
282
|
+
if (decodeBudget <= 0) return 0
|
|
283
|
+
let st
|
|
284
|
+
try { st = statSync(file) } catch { return 0 }
|
|
285
|
+
const key = file + '|' + st.size + '|' + Math.floor(st.mtimeMs)
|
|
286
|
+
const hit = turnCache.get(key)
|
|
287
|
+
if (hit !== undefined) return hit
|
|
288
|
+
decodeBudget -= 1
|
|
289
|
+
let value = 0
|
|
290
|
+
try {
|
|
291
|
+
if (typeof zlib.zstdDecompressSync === 'function') {
|
|
292
|
+
const text = decodeSessionText(readFileSync(file))
|
|
293
|
+
for (const line of text.split('\n')) {
|
|
294
|
+
if (line.length < 20 || line.indexOf('turn/end') === -1) continue
|
|
295
|
+
try {
|
|
296
|
+
const event = JSON.parse(line)
|
|
297
|
+
if (event.type === 'turn/end' && typeof event.time === 'number' && event.time > value) value = event.time
|
|
298
|
+
} catch { /* 坏行跳过 */ }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch { value = 0 }
|
|
302
|
+
if (turnCache.size > 4000) turnCache.clear()
|
|
303
|
+
turnCache.set(key, value)
|
|
304
|
+
return value
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 这一行该按什么时间判超期:文件太新就先问对话最后结束在什么时候。 */
|
|
308
|
+
function activityMs(row, now, cutoff) {
|
|
309
|
+
if (row.mtime <= 0) return 0
|
|
310
|
+
if (row.mtime < cutoff) return row.mtime
|
|
311
|
+
if (now - row.mtime < GRACE_MS) return row.mtime
|
|
312
|
+
const last = lastTurnEndMs(sessionFileOf(row.dir))
|
|
313
|
+
return last > 0 ? last : row.mtime
|
|
314
|
+
}
|
|
315
|
+
|
|
186
316
|
/** 汇总一侧的事实:归档集、置顶集、保护名单、可见会话集。 */
|
|
187
317
|
function readFacts(ctx, state) {
|
|
188
318
|
const registry = typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : undefined
|
|
@@ -199,14 +329,16 @@ function readFacts(ctx, state) {
|
|
|
199
329
|
|
|
200
330
|
/** 算出这一轮该归档谁。 */
|
|
201
331
|
function plan(ctx, state, now = Date.now()) {
|
|
332
|
+
decodeBudget = MAX_DECODE
|
|
202
333
|
const facts = readFacts(ctx, state)
|
|
203
334
|
const cutoff = now - Number(state.days) * DAY_MS
|
|
204
335
|
const candidates = scanSessions()
|
|
205
|
-
.
|
|
336
|
+
.map((row) => ({ ...row, last: activityMs(row, now, cutoff) }))
|
|
337
|
+
.filter((row) => row.last > 0 && row.last < cutoff)
|
|
206
338
|
.filter((row) => !facts.archived.has(row.id) && !facts.guarded.has(row.id))
|
|
207
339
|
.filter((row) => !(state.exemptPinned && facts.pinned.has(row.id)))
|
|
208
340
|
.filter((row) => facts.visible === null || facts.visible.has(row.id))
|
|
209
|
-
.sort((a, b) => a.
|
|
341
|
+
.sort((a, b) => a.last - b.last)
|
|
210
342
|
return { ...facts, candidates }
|
|
211
343
|
}
|
|
212
344
|
|
|
@@ -266,10 +398,10 @@ function describe(state) {
|
|
|
266
398
|
/** 状态文本(工具输出,也给路由复用)。 */
|
|
267
399
|
function statusText(ctx, state, now = Date.now()) {
|
|
268
400
|
const { candidates, archived, pinned, guarded } = plan(ctx, state, now)
|
|
269
|
-
const lines = [describe(state), '']
|
|
401
|
+
const lines = [describe(state), '判据:会话内最后一条对话结束(turn/end);只是被打开/恢复过不算活动,读不到事件时退回文件写入时间', '']
|
|
270
402
|
lines.push('当前候选(超期且未受保护):' + candidates.length + ' 个')
|
|
271
403
|
for (const c of candidates.slice(0, 30)) {
|
|
272
|
-
lines.push(' - ' + c.id.slice(8, 16) + '
|
|
404
|
+
lines.push(' - ' + c.id.slice(8, 16) + ' 最后对话 ' + stamp(c.last) + '(' + ago(c.last, now) + ')')
|
|
273
405
|
}
|
|
274
406
|
if (candidates.length > 30) lines.push(' …(其余 ' + (candidates.length - 30) + ' 个)')
|
|
275
407
|
lines.push('')
|
|
@@ -322,7 +454,7 @@ function registerRoutes(ctx) {
|
|
|
322
454
|
days: state.days,
|
|
323
455
|
exemptPinned: state.exemptPinned,
|
|
324
456
|
protected: state.protected,
|
|
325
|
-
candidates: candidates.map((c) => ({ id: c.id, mtime: c.
|
|
457
|
+
candidates: candidates.map((c) => ({ id: c.id, mtime: c.last })),
|
|
326
458
|
now,
|
|
327
459
|
})
|
|
328
460
|
} catch (error) {
|
|
@@ -419,7 +551,7 @@ function registerRoutes(ctx) {
|
|
|
419
551
|
export function apply(ctx) {
|
|
420
552
|
ctx.tools.register({
|
|
421
553
|
name: 'dsh-session-autarchive',
|
|
422
|
-
description: '超期会话自动归档的开关台:status 看配置、保护名单与当前候选;run 立刻归档候选;protect/unprotect 标记单个会话「不自动归档」;config
|
|
554
|
+
description: '超期会话自动归档的开关台:status 看配置、保护名单与当前候选;run 立刻归档候选;protect/unprotect 标记单个会话「不自动归档」;config 改阈值与开关。判定口径=会话内最后一条对话结束事件(turn/end)的时间——只是被打开/恢复过(会追写开场提示)不算活动;读不到事件时退回文件最后写入时间。已归档、保护名单内、置顶(可关)都跳过。浏览器半边每 5 分钟按同一份清单自动执行,侧栏右键菜单的「不自动归档」也写这份名单。',
|
|
423
555
|
parameters: {
|
|
424
556
|
type: 'object',
|
|
425
557
|
properties: {
|
|
@@ -459,7 +591,7 @@ export function apply(ctx) {
|
|
|
459
591
|
for (const id of result.archived) lines.push(' ✓ ' + id.slice(8, 16))
|
|
460
592
|
for (const line of result.failed) lines.push(' ✗ ' + line)
|
|
461
593
|
if (args && args.dryRun === true) {
|
|
462
|
-
for (const c of result.candidates) lines.push(' · ' + c.id.slice(8, 16) + '
|
|
594
|
+
for (const c of result.candidates) lines.push(' · ' + c.id.slice(8, 16) + ' 最后对话 ' + stamp(c.last))
|
|
463
595
|
}
|
|
464
596
|
return { ok: true, text: lines.join('\n') }
|
|
465
597
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-autarchive",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "自动归档超期未用的会话:按会话内最后一条对话结束时间算候选(只是被打开/恢复过不算活动),已归档、置顶与「不自动归档」名单都跳过;侧栏会话右键菜单可给单个会话加标记,通用设置里改天数。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|