dsh-conversation-recycle-bin 0.3.11 → 0.3.13

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/host/paths.js CHANGED
@@ -9,8 +9,12 @@
9
9
  * Session directories are discovered by scanning `<DSH_HOME>/sessions/<slug>/`
10
10
  * instead of re-implementing the slug encoding: the slug is a Host implementation
11
11
  * detail, while the session id directory name is part of the durable identity.
12
+ * The same encoding is reconstructed only by crash recovery (`recoverSessionLog`
13
+ * in `host/api.js`), where the moved log header is the sole remaining source of
14
+ * its original cwd and there is no directory left to scan.
12
15
  */
13
- import { cp, mkdir, readdir, readFile, rename, rm, stat } from 'node:fs/promises'
16
+ import { createReadStream } from 'node:fs'
17
+ import { cp, mkdir, open, readdir, readFile, rename, rm, stat } from 'node:fs/promises'
14
18
  import { homedir } from 'node:os'
15
19
  import { dirname, join, resolve } from 'node:path'
16
20
 
@@ -62,6 +66,137 @@ export function decodeSessionSegment(name) {
62
66
  return String(name).replace(/~([0-9A-Fa-f]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
63
67
  }
64
68
 
69
+ /**
70
+ * The Host's project-directory key for a session cwd.
71
+ *
72
+ * Recovery needs the exact directory a moved log came from, and the log itself
73
+ * is the only place its cwd still exists. This mirrors
74
+ * `@deepseek-ai/dsh-session-persistence-jsonl`'s `projectKey` (separators and
75
+ * drive colons become `-`, unsafe code units escape as `~XXXX`, bounded to
76
+ * 251 chars) so a reconstructed path is byte-identical to the original one.
77
+ */
78
+ export function projectKey(cwd) {
79
+ if (typeof cwd !== 'string' || cwd.length === 0) throw new Error('cannot encode an empty project path')
80
+ let readable = ''
81
+ let separatorRun = false
82
+ for (let i = 0; i < cwd.length; i += 1) {
83
+ const code = cwd.charCodeAt(i)
84
+ const ch = String.fromCharCode(code)
85
+ if (ch === '/' || ch === '\\' || ch === ':') {
86
+ if (!separatorRun) readable += '-'
87
+ separatorRun = true
88
+ } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
89
+ readable += ch
90
+ separatorRun = false
91
+ } else {
92
+ readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
93
+ separatorRun = false
94
+ }
95
+ }
96
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
97
+ }
98
+
99
+ /** The Host's path encoding for one session id; ids normally need no escaping. */
100
+ export function encodeSessionSegment(sessionId) {
101
+ const raw = String(sessionId ?? '')
102
+ if (raw.length === 0) throw new Error('cannot encode an empty session id')
103
+ if (raw === '.') return '~002E'
104
+ if (raw === '..') return '~002E~002E'
105
+ let out = ''
106
+ for (let i = 0; i < raw.length; i += 1) {
107
+ const code = raw.charCodeAt(i)
108
+ const ch = String.fromCharCode(code)
109
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch
110
+ else out += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
111
+ }
112
+ return out
113
+ }
114
+
115
+ const SESSION_HEADER_BYTE_LIMIT = 256 * 1024
116
+
117
+ /** Read the first line of a plain `.jsonl` log without loading the whole file. */
118
+ async function readFirstLine(file) {
119
+ let handle
120
+ try {
121
+ handle = await open(file, 'r')
122
+ const buffer = Buffer.alloc(64 * 1024)
123
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
124
+ const text = buffer.subarray(0, bytesRead).toString('utf8')
125
+ const newline = text.indexOf('\n')
126
+ return newline === -1 ? text : text.slice(0, newline)
127
+ } finally {
128
+ await handle?.close().catch(() => {})
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Read the first line of a zstd-compressed log, stopping after the header.
134
+ *
135
+ * `node:zlib` only gained zstd in Node 22.15; this package declares Node >=20,
136
+ * so the codec is loaded lazily and an older runtime simply fails recovery
137
+ * (the caller keeps refusing) instead of failing to import the plugin.
138
+ */
139
+ async function readZstdFirstLine(file) {
140
+ const zlib = await import('node:zlib')
141
+ const createDecoder = zlib.createZstdDecompress
142
+ if (typeof createDecoder !== 'function') throw new Error('zstd decompression is unavailable on this Node runtime')
143
+ return new Promise((resolve, reject) => {
144
+ const decoder = createDecoder()
145
+ const input = createReadStream(file)
146
+ let text = ''
147
+ let settled = false
148
+ const finish = (error, value) => {
149
+ if (settled) return
150
+ settled = true
151
+ input.destroy()
152
+ decoder.destroy()
153
+ if (error !== undefined) reject(error)
154
+ else resolve(value)
155
+ }
156
+ input.on('error', (error) => finish(error))
157
+ decoder.on('error', (error) => finish(error))
158
+ decoder.on('data', (chunk) => {
159
+ text += chunk.toString('utf8')
160
+ const newline = text.indexOf('\n')
161
+ if (newline !== -1) finish(undefined, text.slice(0, newline))
162
+ else if (text.length > SESSION_HEADER_BYTE_LIMIT) finish(new Error('session header is too large'))
163
+ })
164
+ decoder.on('end', () => finish(undefined, text.split('\n')[0]))
165
+ input.pipe(decoder)
166
+ })
167
+ }
168
+
169
+ /**
170
+ * The `cwd` recorded in a session log's first header line, or null when the
171
+ * directory has no readable log. Used only to reconstruct the original path of
172
+ * a log a crashed hard delete already moved into the recycle bin.
173
+ */
174
+ export async function readSessionLogCwd(sessionDir) {
175
+ let names = []
176
+ try {
177
+ names = (await readdir(sessionDir)).filter((name) => /\.jsonl(?:\.zstd)?$/u.test(name))
178
+ } catch {
179
+ return null
180
+ }
181
+ const ranked = names.sort((left, right) => {
182
+ const rank = (name) => (name.includes('v4') ? 0 : name.endsWith('.zstd') ? 1 : 2)
183
+ return rank(left) - rank(right)
184
+ })
185
+ for (const name of ranked) {
186
+ try {
187
+ const file = join(sessionDir, name)
188
+ const line = name.endsWith('.zstd') ? await readZstdFirstLine(file) : await readFirstLine(file)
189
+ const parsed = JSON.parse(line)
190
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.cwd === 'string' && parsed.cwd !== '') {
191
+ return parsed.cwd
192
+ }
193
+ } catch {
194
+ // Try the next generation/log file.
195
+ }
196
+ }
197
+ return null
198
+ }
199
+
65
200
  /** Find `<sessionsRoot>/<slug>/<sessionId>`; null when the session has no log. */
66
201
  export async function findSessionDir(sessionsRoot, sessionId) {
67
202
  let dirents
package/lib/version.js CHANGED
@@ -1,8 +1,8 @@
1
- /**
2
- * Single source of truth for the Host-reported plugin version.
3
- *
4
- * `package.json` stays authoritative for the package itself; this module is
5
- * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
- * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
- */
8
- export const PLUGIN_VERSION = '0.3.11'
1
+ /**
2
+ * Single source of truth for the Host-reported plugin version.
3
+ *
4
+ * `package.json` stays authoritative for the package itself; this module is
5
+ * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
+ * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
+ */
8
+ export const PLUGIN_VERSION = '0.3.13'
package/package.json CHANGED
@@ -1,81 +1,81 @@
1
- {
2
- "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.11",
4
- "description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
5
- "repository": {
6
- "type": "git",
7
- "url": "git+https://github.com/HERO476/dsh-conversation-recycle-bin.git"
8
- },
9
- "homepage": "https://github.com/HERO476/dsh-conversation-recycle-bin#readme",
10
- "bugs": {
11
- "url": "https://github.com/HERO476/dsh-conversation-recycle-bin/issues"
12
- },
13
- "author": "HERO476",
14
- "type": "module",
15
- "main": "lib/index.js",
16
- "exports": {
17
- ".": "./lib/index.js",
18
- "./client": "./client.js",
19
- "./cordis.patch.yml": "./cordis.patch.yml",
20
- "./package.json": "./package.json",
21
- "./icon": "./icon.svg"
22
- },
23
- "files": [
24
- "lib",
25
- "client.js",
26
- "cordis.patch.yml",
27
- "config.schema.json",
28
- "icon.svg",
29
- "README.md",
30
- "INSTALL.md",
31
- "CHANGELOG.md",
32
- "AUDIT-2026-10-17-desktop-web.md",
33
- "LICENSE"
34
- ],
35
- "engines": {
36
- "node": ">=20",
37
- "dsh": ">=0.2.1-rc.2"
38
- },
39
- "optionalDependencies": {
40
- "nodemailer": "^10.1.2"
41
- },
42
- "license": "MIT",
43
- "keywords": [
44
- "dsh",
45
- "dsh-plugin",
46
- "deepseek-harness",
47
- "conversation",
48
- "delete",
49
- "session",
50
- "memory",
51
- "recycle-bin",
52
- "audit"
53
- ],
54
- "icon": "./icon.svg",
55
- "meta": {
56
- "title": "删除对话",
57
- "description": "删除对话并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计、批量限流与失败回滚。"
58
- },
59
- "scripts": {
60
- "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/retention.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
61
- "check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs"
62
- },
63
- "dsh": {
64
- "manifestVersion": 1,
65
- "bundle": {
66
- "patch": "./cordis.patch.yml"
67
- },
68
- "client": {
69
- "platform": "web",
70
- "immediately": true,
71
- "inject": [
72
- "@deepseek-ai/dsh-client-locale",
73
- "@deepseek-ai/dsh-client-connection",
74
- "@deepseek-ai/dsh-client-ui-renderer",
75
- "@deepseek-ai/dsh-client-ui-workspace",
76
- "@deepseek-ai/dsh-client-ui-conversation",
77
- "@deepseek-ai/dsh-client-ui-settings"
78
- ]
79
- }
80
- }
81
- }
1
+ {
2
+ "name": "dsh-conversation-recycle-bin",
3
+ "version": "0.3.13",
4
+ "description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/HERO476/dsh-conversation-recycle-bin.git"
8
+ },
9
+ "homepage": "https://github.com/HERO476/dsh-conversation-recycle-bin#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/HERO476/dsh-conversation-recycle-bin/issues"
12
+ },
13
+ "author": "HERO476",
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./client.js",
19
+ "./cordis.patch.yml": "./cordis.patch.yml",
20
+ "./package.json": "./package.json",
21
+ "./icon": "./icon.svg"
22
+ },
23
+ "files": [
24
+ "lib",
25
+ "client.js",
26
+ "cordis.patch.yml",
27
+ "config.schema.json",
28
+ "icon.svg",
29
+ "README.md",
30
+ "INSTALL.md",
31
+ "CHANGELOG.md",
32
+ "AUDIT-2026-10-17-desktop-web.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20",
37
+ "dsh": ">=0.2.1-rc.2"
38
+ },
39
+ "optionalDependencies": {
40
+ "nodemailer": "^10.1.2"
41
+ },
42
+ "license": "MIT",
43
+ "keywords": [
44
+ "dsh",
45
+ "dsh-plugin",
46
+ "deepseek-harness",
47
+ "conversation",
48
+ "delete",
49
+ "session",
50
+ "memory",
51
+ "recycle-bin",
52
+ "audit"
53
+ ],
54
+ "icon": "./icon.svg",
55
+ "meta": {
56
+ "title": "删除对话",
57
+ "description": "删除对话并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计、批量限流与失败回滚。"
58
+ },
59
+ "scripts": {
60
+ "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/retention.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
61
+ "check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs"
62
+ },
63
+ "dsh": {
64
+ "manifestVersion": 1,
65
+ "bundle": {
66
+ "patch": "./cordis.patch.yml"
67
+ },
68
+ "client": {
69
+ "platform": "web",
70
+ "immediately": true,
71
+ "inject": [
72
+ "@deepseek-ai/dsh-client-locale",
73
+ "@deepseek-ai/dsh-client-connection",
74
+ "@deepseek-ai/dsh-client-ui-renderer",
75
+ "@deepseek-ai/dsh-client-ui-workspace",
76
+ "@deepseek-ai/dsh-client-ui-conversation",
77
+ "@deepseek-ai/dsh-client-ui-settings"
78
+ ]
79
+ }
80
+ }
81
+ }