@linxunxr/dsh-auto-archive 0.0.0-stage → 0.1.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.
@@ -0,0 +1,22 @@
1
+ # Host 插件行:定时扫描不活跃会话并归档。
2
+ # id 同时是设置命名空间键,保持全局唯一。
3
+ - insert:
4
+ - id: auto-archive
5
+ name: '@linxunxr/dsh-auto-archive'
6
+ config:
7
+ # 不活跃判定阈值(天):会话最后一个事件距今超过该天数才归档。
8
+ idleDays: 7
9
+ # 扫描间隔(小时)。
10
+ intervalHours: 6
11
+ # 首次扫描前的延迟(分钟),避开启动高峰。
12
+ startupDelayMinutes: 5
13
+ # 每轮最多归档的会话数(0 = 不限)。
14
+ maxPerRun: 20
15
+ # 归档时若仍有运行中的工作是否强制停止(stopActivity)。
16
+ stopActivity: false
17
+ # 置顶会话是否豁免(建议 true)。
18
+ excludePinned: true
19
+ # 空白会话(无任何事件)是否按创建时间判定。
20
+ countBlankByCreation: true
21
+ # 只输出日志不实际归档(试运行开关)。
22
+ dryRun: false
package/docs/DETAIL.md ADDED
@@ -0,0 +1,35 @@
1
+ # dsh-auto-archive
2
+
3
+ 定期扫描会话历史,把超过配置天数没有任何事件的会话自动归档,保持会话列表清爽。
4
+
5
+ ## 设置界面
6
+
7
+ 打开 **设置 → auto-archive** 即可直接修改全部配置项(阈值、间隔、dryRun 等),保存后立即生效并持久化,无需改文件或重启。也可以按下面的方式手动编辑 `cordis.patch.yml`,两种途径等价。
8
+
9
+ ## 工作方式
10
+
11
+ - 通过 `ctx.sessionQuery.listSessions()` 枚举全部会话,`listEvents(id)` 取每个会话最后一个事件的时间作为"最后活跃时间"。
12
+ - 空白会话(无任何事件)默认按 `createdAt` 判定(可关掉:`countBlankByCreation: false` 时空白会话永不归档)。
13
+ - 已归档会话自动跳过;置顶会话默认豁免(`excludePinned: true`),因为置顶与归档互斥。
14
+ - 归档调用官方 `ctx.workspaceRegistry.archiveSession(id, { stopActivity })`:仍有运行中工作的会话会被框架拒绝并记录跳过,不会误伤;只有显式设置 `stopActivity: true` 才会先归档再停止。
15
+ - 归档是可逆的:在会话列表"已归档"里取消归档即可恢复原位置。
16
+
17
+ ## 配置(cordis.patch.yml)
18
+
19
+ | 键 | 默认 | 说明 |
20
+ |---|---|---|
21
+ | `idleDays` | `7` | 不活跃判定阈值(天) |
22
+ | `intervalHours` | `6` | 扫描间隔(小时) |
23
+ | `startupDelayMinutes` | `5` | 启动后首次扫描延迟(分钟) |
24
+ | `maxPerRun` | `20` | 每轮最多归档数(`0` 不限) |
25
+ | `stopActivity` | `false` | 归档时是否强制停止运行中的工作 |
26
+ | `excludePinned` | `true` | 置顶会话豁免 |
27
+ | `countBlankByCreation` | `true` | 空白会话按创建时间判定 |
28
+ | `dryRun` | `false` | 试运行:只记日志不实际归档 |
29
+
30
+ 建议首次启用时先开 `dryRun: true` 观察一轮日志,确认候选无误后再关闭。
31
+
32
+ ## 限制
33
+
34
+ - "不活跃"以会话日志最后一个事件的时间为准;标题生成等后台小请求若写入事件,也会刷新该时间戳。
35
+ - 只处理本机 profile 中 `sessionQuery` 可见的会话。
package/icon.svg ADDED
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
2
+ <!-- 归档箱 -->
3
+ <rect x="3" y="4" width="18" height="5" rx="1.2"/>
4
+ <path d="M5 9v10a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 19V9"/>
5
+ <path d="M10 12.5h4"/>
6
+ <!-- 循环箭头(定时自动) -->
7
+ <path d="M20.2 6.5a8.5 8.5 0 0 1 .8 3.5" opacity="0" />
8
+ <path d="M12 1.8v2.6M9.2 2.6l.9 2.2M14.8 2.6l-.9 2.2" stroke-width="1.5"/>
9
+ </svg>
package/lib/index.js ADDED
@@ -0,0 +1,164 @@
1
+ // dsh-auto-archive — Host 插件:定期扫描不活跃会话并自动归档。
2
+ // npm 包名 @linxunxr/dsh-auto-archive(无作用域名 dsh-auto-archive 已被他人占用)。
3
+ //
4
+ // 判据(对照 0.2.2-rc.3 源码验证):
5
+ // - ctx.sessionQuery.listSessions() → SessionRecord[];header 只有 createdAt
6
+ // (epoch ms),无 lastActivity 字段。
7
+ // - ctx.sessionQuery.listEvents(id) → SessionEventRecord[](升序,带 time),
8
+ // 取末条 time 作“最后活跃时间”;空日志视为空白会话。
9
+ // - ctx.workspaceRegistry.archiveSession(id, { stopActivity }):
10
+ // 活动中的会话抛 WorkspaceActiveSessionError(安全网),
11
+ // 不存在的会话抛 WorkspaceUnknownSessionError。
12
+ // - ctx.workspaceRegistry.pinnedSessionIds / archivedSessionIds:
13
+ // 公开只读 getter(src/index.ts:360/439),用于置顶豁免与去重。
14
+ // - ctx.interval/ctx.timeout 来自 @deepseek-ai/cordis-plugin-timer,需注入 'timer'。
15
+ //
16
+ // 设置界面:DSH 0.2.x 的 Settings 页只显示“loader entry id 对应的插件所导出
17
+ // Config 中带 volatile 标记的字段”(规则与 qq-mode-console 的注释一致)。
18
+ // 本插件的 entry id 是 auto-archive,所以下方 Config 的全部字段都标了
19
+ // volatile:在设置界面直接改 idleDays 等值,写入会持久化到 profile 的
20
+ // cordis.patch.yml,插件即时生效、无需重装。普通(非 volatile)字段改配置
21
+ // 文件才生效——这里全部做成 volatile,两种途径等价。
22
+ import z from '@deepseek-ai/schemastery'
23
+
24
+ export const name = 'dsh-auto-archive'
25
+
26
+ export const inject = ['workspaceRegistry', 'sessionQuery', 'timer']
27
+
28
+ const DAY_MS = 24 * 60 * 60 * 1000
29
+ const HOUR_MS = 60 * 60 * 1000
30
+ const MINUTE_MS = 60 * 1000
31
+
32
+ // .extra('volatile', true) 与 .volatile() 等价(后者只是 DSH 自带 schemastery
33
+ // 的语法糖);用 .extra 写法兼容任何 schemastery 版本(qq-mode-console 的教训)。
34
+ export const Config = z.object({
35
+ idleDays: z.number().min(1).max(365).step(1).default(7)
36
+ .description('不活跃判定阈值(天):会话最后一个事件距今超过该天数才归档')
37
+ .extra('volatile', true),
38
+ intervalHours: z.number().min(1).max(168).step(1).default(6)
39
+ .description('扫描间隔(小时)')
40
+ .extra('volatile', true),
41
+ startupDelayMinutes: z.number().min(0).max(720).step(1).default(5)
42
+ .description('启动后首次扫描延迟(分钟)')
43
+ .extra('volatile', true),
44
+ maxPerRun: z.number().min(0).max(1000).step(1).default(20)
45
+ .description('每轮最多归档数(0 = 不限)')
46
+ .extra('volatile', true),
47
+ stopActivity: z.boolean().default(false)
48
+ .description('归档时强制停止运行中的工作(默认拒绝并跳过)')
49
+ .extra('volatile', true),
50
+ excludePinned: z.boolean().default(true)
51
+ .description('置顶会话豁免')
52
+ .extra('volatile', true),
53
+ countBlankByCreation: z.boolean().default(true)
54
+ .description('空白会话(无任何事件)按创建时间判定')
55
+ .extra('volatile', true),
56
+ dryRun: z.boolean().default(false)
57
+ .description('试运行:只记日志不实际归档')
58
+ .extra('volatile', true),
59
+ })
60
+
61
+ export function apply(ctx, config) {
62
+ const logger = ctx.logger('dsh-auto-archive')
63
+ let running = false
64
+
65
+ function errorName(error) {
66
+ return error && typeof error === 'object' && typeof error.constructor?.name === 'string'
67
+ ? error.constructor.name
68
+ : ''
69
+ }
70
+
71
+ async function runOnce() {
72
+ if (running) return
73
+ running = true
74
+ try {
75
+ // volatile 字段每次扫描时读当前值(settings 写入即时生效)。
76
+ const idleDays = config.idleDays.get()
77
+ const idleMs = idleDays * DAY_MS
78
+ const now = Date.now()
79
+ const registry = ctx.workspaceRegistry
80
+
81
+ const records = await ctx.sessionQuery.listSessions()
82
+ const archived = new Set(registry.archivedSessionIds.map(String))
83
+ const excludePinned = config.excludePinned.get()
84
+ const pinned = excludePinned
85
+ ? new Set(registry.pinnedSessionIds.map(String))
86
+ : new Set()
87
+
88
+ const candidates = []
89
+ for (const record of records) {
90
+ const id = String(record.header.id)
91
+ if (archived.has(id) || pinned.has(id)) continue
92
+
93
+ let last = null
94
+ try {
95
+ const events = await ctx.sessionQuery.listEvents(id)
96
+ last = events.length > 0 ? events[events.length - 1].time : null
97
+ } catch (error) {
98
+ logger.warn(`读取会话 ${id} 事件失败,跳过:${error instanceof Error ? error.message : String(error)}`)
99
+ continue
100
+ }
101
+
102
+ const anchor = last ?? (config.countBlankByCreation.get() ? record.header.createdAt : null)
103
+ if (anchor === null) continue
104
+ const idleFor = now - anchor
105
+ if (idleFor >= idleMs) candidates.push({ id, idleFor })
106
+ }
107
+
108
+ if (candidates.length === 0) {
109
+ logger.info(`扫描完成:${records.length} 个会话中没有满足 ${idleDays} 天不活跃的候选`)
110
+ return
111
+ }
112
+
113
+ const maxPerRun = config.maxPerRun.get()
114
+ const limit = maxPerRun > 0 ? maxPerRun : candidates.length
115
+ let archivedCount = 0
116
+ let skipped = 0
117
+ for (const candidate of candidates.slice(0, limit)) {
118
+ const days = Math.floor(candidate.idleFor / DAY_MS)
119
+ if (config.dryRun.get()) {
120
+ logger.info(`[dry-run] 将归档会话 ${candidate.id}(不活跃 ${days} 天)`)
121
+ archivedCount++
122
+ continue
123
+ }
124
+ try {
125
+ await registry.archiveSession(candidate.id, { stopActivity: config.stopPropagation.get() })
126
+ archivedCount++
127
+ logger.info(`已归档会话 ${candidate.id}(不活跃 ${days} 天)`)
128
+ } catch (error) {
129
+ skipped++
130
+ const kind = errorName(error)
131
+ if (kind === 'WorkspaceActiveSessionError') {
132
+ logger.info(`会话 ${candidate.id} 仍有运行中的工作,本轮跳过`)
133
+ } else if (kind === 'WorkspaceUnknownSessionError') {
134
+ logger.info(`会话 ${candidate.id} 已不存在,跳过`)
135
+ } else {
136
+ logger.warn(`归档会话 ${candidate.id} 失败:${error instanceof Error ? error.message : String(error)}`)
137
+ }
138
+ }
139
+ }
140
+ logger.info(`扫描完成:共 ${records.length} 个会话,归档 ${archivedCount} 个,跳过 ${skipped} 个`)
141
+ } catch (error) {
142
+ logger.error(`自动归档扫描失败:${error instanceof Error ? error.message : String(error)}`)
143
+ } finally {
144
+ running = false
145
+ }
146
+ }
147
+
148
+ // ctx.interval/ctx.timeout(cordis-plugin-timer)注册在当前 fiber 上,
149
+ // 插件卸载时自动清理;返回值同时是 disposer,稳妥起见仍显式保存。
150
+ const disposeInterval = ctx.interval(() => { void runOnce() }, config.intervalHours.get() * HOUR_MS)
151
+ const delayMinutes = config.startupDelayMinutes.get()
152
+ const disposeInitial = delayMinutes > 0
153
+ ? ctx.timeout(() => { void runOnce() }, delayMinutes * MINUTE_MS)
154
+ : null
155
+
156
+ logger.info(
157
+ `dsh-auto-archive 已加载:idleDays=${config.idleDays.get()} intervalHours=${config.intervalHours.get()} maxPerRun=${config.maxPerRun.get()} dryRun=${config.dryRun.get()}`,
158
+ )
159
+
160
+ return () => {
161
+ disposeInterval()
162
+ disposeInitial?.()
163
+ }
164
+ }
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Auto-archive idle sessions",
4
+ "description": "Periodically archives sessions whose last event is older than a configured number of days. Pinned and already-archived sessions are skipped, and sessions with running work are never force-stopped unless enabled.",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "不活跃会话自动归档",
4
+ "description": "定期扫描并归档超过设定天数没有任何事件的会话。置顶与已归档会话自动豁免;仍有运行中工作的会话默认不会被强制停止(可在配置中开启)。",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@linxunxr/dsh-auto-archive",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Auto-archive idle sessions: periodically archives unarchived sessions with no events for a configured number of days",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./locale/*.json": "./locale/*.json",
10
+ "./icon": "./icon.svg",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "dsh": {
14
+ "bundle": {
15
+ "patch": "./cordis.patch.yml"
16
+ }
17
+ },
18
+ "dependencies": {
19
+ "@deepseek-ai/schemastery": "^3.18.1"
20
+ },
21
+ "engines": {
22
+ "dsh": ">=0.2.2-rc.1 <0.3.0"
23
+ },
24
+ "files": [
25
+ "lib",
26
+ "locale",
27
+ "docs",
28
+ "icon.svg",
29
+ "cordis.patch.yml"
30
+ ],
31
+ "license": "MIT"
32
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.