@device1999/dsh-session-archive-delete 0.0.0-stage → 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/lib/paths.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * dsh-session-archive-delete · 路径与命名工具
3
+ *
4
+ * 三个原则,与 delete-archived-sessions.ps1 保持一致:
5
+ * 1. 会话目录名原样保留(session-<uuid>),备份里不改名,恢复时直接复制回去
6
+ * 2. 备份文件夹名 <id 前 8 位>-<标题>,重名加 -2 / -3
7
+ * 3. 任何写操作前先确认目标路径确实在预期根目录之下,绝不越界
8
+ */
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import os from 'node:os'
12
+
13
+ /** DSH 家目录:与宿主其它插件一致,优先 DSH_HOME,其次 ~/.dsh */
14
+ export function dshHome() {
15
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
16
+ }
17
+
18
+ /**
19
+ * 标题转安全文件名:去掉 Windows 非法与易混淆字符,压缩空白,按字节裁剪。
20
+ * 默认 60 字节(约 20 个汉字),与 ps1 一致。
21
+ */
22
+ export function safeFileName(text, maxBytes = 60) {
23
+ if (!text) return ''
24
+ let s = String(text)
25
+ .replace(/[\x00-\x1F\x7F]/g, ' ')
26
+ .replace(/[<>:"/\\|?*]/g, ' ')
27
+ .replace(/[\[\]{}#%&~`^$@+=;,!]/g, ' ')
28
+ .replace(/\s+/g, ' ')
29
+ .trim()
30
+ .replace(/^[-_. ]+|[-_. ]+$/g, '')
31
+ const buf = Buffer.from(s, 'utf8')
32
+ if (buf.length > maxBytes) {
33
+ let cut = maxBytes
34
+ while (cut > 0 && (buf[cut] & 0xc0) === 0x80) cut--
35
+ s = buf.subarray(0, cut).toString('utf8').replace(/^[-_. ]+|[-_. ]+$/g, '')
36
+ }
37
+ return s
38
+ }
39
+
40
+ /** session UUID 前 8 位小写 */
41
+ export function idPrefix(sessionId) {
42
+ const leaf = String(sessionId || '').replace(/^session-/, '')
43
+ const m = leaf.match(/^([0-9a-fA-F]{8})/)
44
+ if (m) return m[1].toLowerCase()
45
+ return (safeFileName(leaf, 8) || 'unknown').toLowerCase()
46
+ }
47
+
48
+ /** 备份文件夹名:<前8位>-<标题> */
49
+ export function backupName(sessionId, title) {
50
+ const t = safeFileName(title) || '未命名'
51
+ return idPrefix(sessionId) + '-' + t
52
+ }
53
+
54
+ /**
55
+ * 在 root 下为 name 找一个不冲突的目录名(已存在就 -2 / -3)。
56
+ * @returns {string} 绝对路径
57
+ */
58
+ export function uniqueDir(root, name) {
59
+ let candidate = name
60
+ let n = 2
61
+ while (fs.existsSync(path.join(root, candidate))) {
62
+ candidate = name + '-' + n
63
+ n++
64
+ }
65
+ return path.join(root, candidate)
66
+ }
67
+
68
+ /** 确认 child 解析后确实在 parent 之下(防止路径穿越与大小写/短名绕行) */
69
+ export function isInside(parent, child) {
70
+ const p = path.resolve(parent)
71
+ const c = path.resolve(child)
72
+ if (c === p) return false
73
+ return c.startsWith(p.endsWith(path.sep) ? p : p + path.sep)
74
+ }
75
+
76
+ /** 递归列出目录下所有文件(返回绝对路径数组) */
77
+ export function listFiles(root) {
78
+ const out = []
79
+ const walk = (dir) => {
80
+ let entries = []
81
+ try {
82
+ entries = fs.readdirSync(dir, { withFileTypes: true })
83
+ } catch {
84
+ return
85
+ }
86
+ for (const e of entries) {
87
+ const full = path.join(dir, e.name)
88
+ if (e.isDirectory()) walk(full)
89
+ else if (e.isFile()) out.push(full)
90
+ }
91
+ }
92
+ walk(root)
93
+ return out
94
+ }
95
+
96
+ /** 递归计算目录大小(字节) */
97
+ export function dirSize(root) {
98
+ let total = 0
99
+ for (const f of listFiles(root)) {
100
+ try {
101
+ total += fs.statSync(f).size
102
+ } catch {}
103
+ }
104
+ return total
105
+ }
package/lib/store.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * dsh-session-archive-delete · 设置持久化
3
+ *
4
+ * 存到 ~/.dsh/dsh-session-archive-delete.json(不写进 profile,不污染插件目录)。
5
+ * 读取失败一律回落到默认值;写入用「临时文件 + 改名」,避免半截 JSON。
6
+ *
7
+ * 设置项:
8
+ * mode 'backup-then-delete'(默认)| 'delete-only'
9
+ * backupRoot 备份根目录;空字符串 = <工作区根目录>\DSH-归档会话
10
+ * confirm 是否弹确认框(默认 true)
11
+ */
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { dshHome } from './paths.js'
15
+
16
+ export const DEFAULTS = Object.freeze({
17
+ mode: 'both', // 'both'(同时显示两个菜单项,默认)| 'backup-then-delete' | 'delete-only'
18
+ backupRoot: '',
19
+ confirm: true,
20
+ dateStyle: 'default', // 'default'(日期+主题)| 'id-title' | 'title' | 'id-title-date'
21
+ })
22
+
23
+ let cache = null
24
+
25
+ export function settingsFile() {
26
+ return path.join(dshHome(), 'dsh-session-archive-delete.json')
27
+ }
28
+
29
+ /** 读设置(带内存缓存,写入后失效) */
30
+ export function readSettings() {
31
+ if (cache) return { ...cache }
32
+ let loaded = {}
33
+ try {
34
+ loaded = JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) || {}
35
+ } catch {
36
+ loaded = {}
37
+ }
38
+ cache = normalize(loaded)
39
+ return { ...cache }
40
+ }
41
+
42
+ /** 全量覆盖写设置,返回写入后的值 */
43
+ export function writeSettings(patch) {
44
+ const next = normalize({ ...readSettings(), ...(patch || {}) })
45
+ const file = settingsFile()
46
+ const tmp = file + '.tmp'
47
+ try {
48
+ fs.mkdirSync(path.dirname(file), { recursive: true })
49
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8')
50
+ fs.renameSync(tmp, file)
51
+ } catch (err) {
52
+ try {
53
+ fs.rmSync(tmp, { force: true })
54
+ } catch {}
55
+ throw new Error('settings write failed: ' + String((err && err.message) || err))
56
+ }
57
+ cache = next
58
+ return { ...next }
59
+ }
60
+
61
+ /** 备份文件夹命名方式(dateStyle)合法值与默认 */
62
+ const DATE_STYLES = ['default', 'id-title', 'title', 'id-title-date']
63
+
64
+ /** 把任意输入收敛成合法设置。mode 默认 both:菜单同时显示两个选项 */
65
+ export function normalize(input) {
66
+ const src = input && typeof input === 'object' ? input : {}
67
+ const mode = src.mode === 'delete-only' || src.mode === 'backup-then-delete' ? src.mode : 'both'
68
+ const backupRoot = typeof src.backupRoot === 'string' ? src.backupRoot.trim() : ''
69
+ const confirm = src.confirm === false ? false : true
70
+ const dateStyle = DATE_STYLES.indexOf(src.dateStyle) >= 0 ? src.dateStyle : 'default'
71
+ return { mode, backupRoot, confirm, dateStyle }
72
+ }
73
+
74
+ /** 测试或热更新后清掉缓存 */
75
+ export function resetCache() {
76
+ cache = null
77
+ }
package/lib/subtree.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * dsh-session-archive-delete · 子代理会话收集
3
+ *
4
+ * 子代理会话是主会话的「兄弟目录」:
5
+ * ~/.dsh/sessions/<工作区>/session-<主会话id>/
6
+ * ~/.dsh/sessions/<工作区>/session-<子代理id>/ ← 目录名里看不出父子关系
7
+ *
8
+ * 关系记录在会话 header 的 parentSession 字段里。删主会话时如果不处理它们:
9
+ * - 磁盘上留孤儿目录(占空间)
10
+ * - 归档账本仍引用它们,侧栏可能出现空壳条目
11
+ * - 对话框 @ 提及仍可能列出这些死掉的子会话
12
+ *
13
+ * 所以删除前先按 parentSession 广度优先收集整个子树(含跨工作区的子会话),
14
+ * 由调用方一起备份、一起删除。
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+
19
+ /** 读取会话日志里第一个含 header 的行,拿到 header 对象 */
20
+ function readHeaderFromJsonl(file) {
21
+ try {
22
+ const fd = fs.openSync(file, 'r')
23
+ const buf = Buffer.alloc(64 * 1024)
24
+ const n = fs.readSync(fd, buf, 0, buf.length, 0)
25
+ fs.closeSync(fd)
26
+ const text = buf.subarray(0, n).toString('utf8')
27
+ // zstd 压缩文件按 utf8 解出来是乱码,直接放弃
28
+ if (text.indexOf('\uFFFD') >= 0) return null
29
+ for (const line of text.split(/\r?\n/)) {
30
+ if (!line || line.indexOf('header') < 0) continue
31
+ try {
32
+ const o = JSON.parse(line)
33
+ if (o && o.header && typeof o.header === 'object') return o.header
34
+ if (o && o.type === 'session/header' && o.data && typeof o.data === 'object') return o.data
35
+ } catch {}
36
+ }
37
+ } catch {}
38
+ return null
39
+ }
40
+
41
+ /**
42
+ * 读出某个会话目录的 header(不依赖宿主服务,纯读盘)。
43
+ * 注意:真实 DSH 的会话文件是 session.v4.jsonl.zstd(zstd 压缩),
44
+ * 读不出 header 属正常情况——此时完全依赖宿主的 sessionQuery 服务。
45
+ */
46
+ function readHeaderFromDir(sessionDir) {
47
+ if (!sessionDir || !fs.existsSync(sessionDir)) return null
48
+ try {
49
+ for (const e of fs.readdirSync(sessionDir, { withFileTypes: true })) {
50
+ if (!e.isFile()) continue
51
+ if (!/\.(jsonl|json)$/i.test(e.name)) continue
52
+ const h = readHeaderFromJsonl(path.join(sessionDir, e.name))
53
+ if (h) return h
54
+ }
55
+ } catch {}
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * 收集主会话及其所有子孙会话(广度优先)。
61
+ *
62
+ * 数据来源优先级:宿主 sessionQuery.listSessions() → 扫描磁盘读 header。
63
+ *
64
+ * @param {object} p
65
+ * @param {object} p.ctx 插件上下文
66
+ * @param {string} p.sessionId 主会话 id
67
+ * @param {string} p.sessionsRoot sessions 根目录
68
+ * @returns {Promise<Array<{id:string, dir:string, workspace:string}>>}
69
+ * 第一个元素恒为主会话自身
70
+ */
71
+ export async function collectSubtree({ ctx, sessionId, sessionsRoot }) {
72
+ const out = [{ id: sessionId, dir: null, workspace: null }]
73
+ const seen = new Set([sessionId, sessionId.replace(/^session-/, ''), 'session-' + sessionId.replace(/^session-/, '')])
74
+
75
+ // ---- 建立 parent -> children 索引 ----
76
+ const childrenOf = new Map()
77
+ const addChild = (parent, child, dir, workspace) => {
78
+ if (!parent || !child) return
79
+ const list = childrenOf.get(parent) || []
80
+ if (!list.some((x) => x.id === child)) list.push({ id: child, dir, workspace })
81
+ childrenOf.set(parent, list)
82
+ }
83
+
84
+ let fromHost = false
85
+ try {
86
+ const sq = ctx && ctx.get ? ctx.get('sessionQuery') : null
87
+ if (sq && typeof sq.listSessions === 'function') {
88
+ const records = await sq.listSessions()
89
+ for (const r of records || []) {
90
+ const h = r && r.header
91
+ if (!h || typeof h.id !== 'string') continue
92
+ if (typeof h.parentSession !== 'string' || !h.parentSession) continue
93
+ addChild(h.parentSession, h.id, null, null)
94
+ }
95
+ fromHost = true
96
+ }
97
+ } catch {}
98
+
99
+ // ---- 宿主服务不可用时,扫盘补全索引 ----
100
+ if (!fromHost) {
101
+ let wsDirs = []
102
+ try {
103
+ wsDirs = fs.readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory())
104
+ } catch {}
105
+ for (const ws of wsDirs) {
106
+ const wsPath = path.join(sessionsRoot, ws.name)
107
+ let sessDirs = []
108
+ try {
109
+ sessDirs = fs.readdirSync(wsPath, { withFileTypes: true }).filter((e) => e.isDirectory())
110
+ } catch {}
111
+ for (const sd of sessDirs) {
112
+ const dir = path.join(wsPath, sd.name)
113
+ const h = readHeaderFromDir(dir)
114
+ if (!h) continue
115
+ if (typeof h.parentSession !== 'string' || !h.parentSession) continue
116
+ addChild(h.parentSession, h.id, dir, ws.name)
117
+ }
118
+ }
119
+ }
120
+
121
+ // ---- 广度优先展开 ----
122
+ const queue = [sessionId]
123
+ let guard = 0
124
+ while (queue.length > 0 && guard++ < 5000) {
125
+ const parent = queue.shift()
126
+ const kids = childrenOf.get(parent) || []
127
+ for (const k of kids) {
128
+ if (seen.has(k.id)) continue
129
+ seen.add(k.id)
130
+ seen.add('session-' + k.id.replace(/^session-/, ''))
131
+ seen.add(k.id.replace(/^session-/, ''))
132
+ out.push({ id: k.id, dir: k.dir, workspace: k.workspace })
133
+ queue.push(k.id)
134
+ }
135
+ }
136
+
137
+ return out
138
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
- {
2
- "name": "@device1999/dsh-session-archive-delete",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
1
+ {
2
+ "name": "@device1999/dsh-session-archive-delete",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "给 DeepSeek Harness 的会话列表加一个安全的删除入口:先完整备份到本地、校验通过后再删除,支持子代理会话级联处理与自定义备份位置。",
6
+ "license": "MIT",
7
+ "author": "",
8
+ "main": "lib/index.js",
9
+ "exports": {
10
+ ".": "./lib/index.js",
11
+ "./client": "./assets/client.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "assets",
17
+ "cordis.patch.yml",
18
+ "README.md",
19
+ "CHANGELOG.md",
20
+ "LICENSE"
21
+ ],
22
+ "keywords": [
23
+ "deepseek-harness",
24
+ "dsh",
25
+ "dsh-plugin",
26
+ "session",
27
+ "delete",
28
+ "backup",
29
+ "archive",
30
+ "cleanup"
31
+ ],
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "inject": [
39
+ "@deepseek-ai/dsh-client-runtime",
40
+ "@deepseek-ai/dsh-client-locale",
41
+ "@deepseek-ai/dsh-client-ui-primitives"
42
+ ]
43
+ }
44
+ },
45
+ "engines": {
46
+ "node": "^20.0.0 || >=22.0.0"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/device1999/dsh-session-archive-delete.git"
51
+ },
52
+ "homepage": "https://github.com/device1999/dsh-session-archive-delete#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/device1999/dsh-session-archive-delete/issues"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
6
59
  }