dsh-native-session-delete 1.0.7 → 1.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.
@@ -0,0 +1,251 @@
1
+ const SEARCH_LIMIT = 20
2
+ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
3
+ const MAX_REQUEST_BYTES = 8 * 1024
4
+
5
+ const assertSessionId = sessionId => {
6
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 512 || sessionId.includes('\0')) {
7
+ throw new TypeError('invalid archived session id')
8
+ }
9
+ return sessionId
10
+ }
11
+
12
+ const archiveIds = workspaceRegistry => {
13
+ const ids = workspaceRegistry?.archivedSessionIds
14
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== 'string')) {
15
+ throw new Error('unsupported workspace registry archive snapshot')
16
+ }
17
+ return [...ids]
18
+ }
19
+
20
+ const plainSnippet = (text, query) => {
21
+ const compact = text.replace(/\s+/g, ' ').trim()
22
+ if (compact.length <= 240) return compact
23
+ const matchAt = compact.toLowerCase().indexOf(query.toLowerCase())
24
+ const start = Math.max(0, (matchAt === -1 ? 0 : matchAt) - 80)
25
+ const end = Math.min(compact.length, start + 240)
26
+ return `${start > 0 ? '…' : ''}${compact.slice(start, end)}${end < compact.length ? '…' : ''}`
27
+ }
28
+
29
+ const scanArchivedEvents = async (sessionQuery, archivedSessionIds, query, signal) => {
30
+ if (typeof sessionQuery?.filterEvents !== 'function') {
31
+ throw new Error('archived history scan is unavailable')
32
+ }
33
+ const filters = [
34
+ { kind: 'type', values: ['user/message', 'assistant/message'] },
35
+ { kind: 'surface', values: ['current'] },
36
+ { kind: 'text', text: query },
37
+ ]
38
+ const items = []
39
+ for (let offset = 0; offset < archivedSessionIds.length && items.length <= SEARCH_LIMIT; offset += 4) {
40
+ signal?.throwIfAborted()
41
+ const batch = archivedSessionIds.slice(offset, offset + 4)
42
+ const matches = await Promise.all(batch.map(sessionId => sessionQuery.filterEvents(sessionId, filters)))
43
+ for (let index = 0; index < batch.length; index += 1) {
44
+ const match = Array.isArray(matches[index])
45
+ ? matches[index].find(event => typeof event?.text === 'string' && event.text.trim().length > 0)
46
+ : undefined
47
+ if (match !== undefined) items.push({ sessionId: batch[index], snippet: plainSnippet(match.text, query) })
48
+ if (items.length > SEARCH_LIMIT) break
49
+ }
50
+ }
51
+ return { items: items.slice(0, SEARCH_LIMIT), hasMore: items.length > SEARCH_LIMIT }
52
+ }
53
+
54
+ /**
55
+ * Remove one id from DSH's registry-global archive set without touching the
56
+ * session log or its workspace accounting position. DSH does not expose a
57
+ * public unarchive method yet, so every private seam is checked before use.
58
+ */
59
+ export async function restoreArchivedSession(workspaceRegistry, sessionId) {
60
+ const id = assertSessionId(sessionId)
61
+ if (
62
+ typeof workspaceRegistry?.enqueueOperation !== 'function'
63
+ || typeof workspaceRegistry?.requireState !== 'function'
64
+ || typeof workspaceRegistry?.setState !== 'function'
65
+ ) {
66
+ throw new Error('unsupported workspace registry restore seam')
67
+ }
68
+
69
+ return workspaceRegistry.enqueueOperation(async () => {
70
+ const state = workspaceRegistry.requireState()
71
+ if (!Array.isArray(state?.archivedSessionIds)) {
72
+ throw new Error('unsupported workspace registry restore state')
73
+ }
74
+ if (!state.archivedSessionIds.includes(id)) {
75
+ return { restored: false, archivedSessionIds: [...state.archivedSessionIds] }
76
+ }
77
+ const archivedSessionIds = state.archivedSessionIds.filter(candidate => candidate !== id)
78
+ await workspaceRegistry.setState({ ...state, archivedSessionIds })
79
+ return { restored: true, archivedSessionIds }
80
+ })
81
+ }
82
+
83
+ /** Keep DSH's archive registry free of ids whose storage was permanently removed. */
84
+ export async function deleteSessionAndReconcileArchive({ workspaceRegistry, deleteSession, warn = console.warn }, sessionId) {
85
+ const result = await deleteSession(sessionId)
86
+ if (result?.ok !== true) return result
87
+ try {
88
+ await restoreArchivedSession(workspaceRegistry, sessionId)
89
+ return { ok: true, value: { ...result.value, archiveReconciled: true } }
90
+ } catch (error) {
91
+ warn('session storage was deleted but its archive marker could not be reconciled:', error)
92
+ return { ok: true, value: { ...result.value, archiveReconciled: false } }
93
+ }
94
+ }
95
+
96
+ /** Search current user/assistant message history inside the archive set only. */
97
+ export async function searchArchivedSessions({ workspaceRegistry, sessionQuery }, query, signal) {
98
+ const normalized = typeof query === 'string' ? query.trim() : ''
99
+ if (normalized.length === 0 || normalized.length > 500 || normalized.includes('\0')) {
100
+ throw new TypeError('invalid archive search query')
101
+ }
102
+ if (typeof sessionQuery?.searchSessions !== 'function') {
103
+ throw new Error('archived history search is unavailable')
104
+ }
105
+
106
+ const archivedSessionIds = archiveIds(workspaceRegistry)
107
+ if (archivedSessionIds.length === 0) return { items: [], hasMore: false }
108
+
109
+ let page
110
+ try {
111
+ page = await sessionQuery.searchSessions({
112
+ query: normalized,
113
+ sessionFilters: [{ kind: 'id', values: archivedSessionIds }],
114
+ eventFilters: [
115
+ { kind: 'type', values: ['user/message', 'assistant/message'] },
116
+ { kind: 'surface', values: ['current'] },
117
+ ],
118
+ limit: SEARCH_LIMIT,
119
+ }, { signal })
120
+ } catch (error) {
121
+ if (error?.code !== 'SESSION_QUERY_SEARCH_DISABLED') throw error
122
+ return scanArchivedEvents(sessionQuery, archivedSessionIds, normalized, signal)
123
+ }
124
+
125
+ const archived = new Set(archivedSessionIds)
126
+ const items = []
127
+ const included = new Set()
128
+ for (const hit of Array.isArray(page?.items) ? page.items : []) {
129
+ const sessionId = hit?.header?.id
130
+ const match = hit?.bestMatch
131
+ if (
132
+ typeof sessionId !== 'string'
133
+ || !archived.has(sessionId)
134
+ || included.has(sessionId)
135
+ || match?.sessionId !== sessionId
136
+ || match?.surface !== 'current'
137
+ || !MESSAGE_TYPES.has(match?.type)
138
+ || typeof match?.snippet !== 'string'
139
+ ) continue
140
+ included.add(sessionId)
141
+ items.push({ sessionId, snippet: match.snippet })
142
+ if (items.length >= SEARCH_LIMIT) break
143
+ }
144
+
145
+ return { items, hasMore: page?.nextCursor !== undefined }
146
+ }
147
+
148
+ const sendJson = (res, status, payload) => {
149
+ res.writeHead(status, {
150
+ 'content-type': 'application/json; charset=utf-8',
151
+ 'cache-control': 'no-store',
152
+ 'x-content-type-options': 'nosniff',
153
+ })
154
+ res.end(JSON.stringify(payload))
155
+ }
156
+
157
+ const failure = (code, message) => ({ ok: false, error: { code, message } })
158
+
159
+ const readJsonBody = async req => {
160
+ const chunks = []
161
+ let size = 0
162
+ for await (const chunk of req) {
163
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
164
+ size += buffer.length
165
+ if (size > MAX_REQUEST_BYTES) throw new Error('request-too-large')
166
+ chunks.push(buffer)
167
+ }
168
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
169
+ }
170
+
171
+ const acceptJsonPost = (req, res) => {
172
+ if (req.method !== 'POST') {
173
+ sendJson(res, 405, failure('method-not-allowed', '只允许使用 POST 管理归档会话。'))
174
+ return false
175
+ }
176
+ const host = req.headers.host
177
+ if (typeof host !== 'string' || req.headers.origin !== `http://${host}`) {
178
+ sendJson(res, 403, failure('forbidden', '归档管理请求未通过同源校验。'))
179
+ return false
180
+ }
181
+ const contentType = req.headers['content-type']
182
+ if (typeof contentType !== 'string' || !contentType.toLowerCase().startsWith('application/json')) {
183
+ sendJson(res, 415, failure('unsupported-media-type', '归档管理请求必须使用 JSON。'))
184
+ return false
185
+ }
186
+ return true
187
+ }
188
+
189
+ const parseBody = async (req, res) => {
190
+ try {
191
+ return await readJsonBody(req)
192
+ } catch (error) {
193
+ const tooLarge = error instanceof Error && error.message === 'request-too-large'
194
+ sendJson(
195
+ res,
196
+ tooLarge ? 413 : 400,
197
+ failure(tooLarge ? 'request-too-large' : 'invalid-json', tooLarge ? '归档管理请求过大。' : '归档管理请求不是有效 JSON。'),
198
+ )
199
+ return undefined
200
+ }
201
+ }
202
+
203
+ /** Same-origin HTTP boundary consumed by the native archive manager UI. */
204
+ export function createArchiveRequestHandlers({ restore, search, warn = console.warn }) {
205
+ return {
206
+ restore: async (req, res) => {
207
+ if (!acceptJsonPost(req, res)) return
208
+ if (req.headers['x-dsh-session-manager-action'] !== 'restore-session') {
209
+ sendJson(res, 403, failure('forbidden', '恢复请求缺少明确的操作标记。'))
210
+ return
211
+ }
212
+ const body = await parseBody(req, res)
213
+ if (body === undefined) return
214
+ try {
215
+ const sessionId = assertSessionId(body?.sessionId)
216
+ sendJson(res, 200, { ok: true, value: await restore(sessionId) })
217
+ } catch (error) {
218
+ if (error instanceof TypeError) {
219
+ sendJson(res, 400, failure('invalid-session-id', '会话 ID 无效。'))
220
+ } else {
221
+ sendJson(res, 500, failure('restore-failed', '取消归档失败,归档状态未确认改变。'))
222
+ }
223
+ }
224
+ },
225
+ search: async (req, res) => {
226
+ if (!acceptJsonPost(req, res)) return
227
+ const body = await parseBody(req, res)
228
+ if (body === undefined) return
229
+ const query = typeof body?.query === 'string' ? body.query.trim() : ''
230
+ if (query.length === 0 || query.length > 500 || query.includes('\0')) {
231
+ sendJson(res, 400, failure('invalid-query', '搜索内容无效。'))
232
+ return
233
+ }
234
+ const controller = new AbortController()
235
+ const abort = () => controller.abort(new Error('archive search request closed'))
236
+ req.once('aborted', abort)
237
+ if (typeof res.once === 'function') res.once('close', abort)
238
+ try {
239
+ const value = await search(query, controller.signal)
240
+ sendJson(res, 200, { ok: true, value })
241
+ } catch (error) {
242
+ if (controller.signal.aborted) return
243
+ warn('archived history search failed:', error)
244
+ sendJson(res, 503, failure('search-unavailable', '归档内容搜索暂不可用。'))
245
+ } finally {
246
+ req.off('aborted', abort)
247
+ if (typeof res.off === 'function') res.off('close', abort)
248
+ }
249
+ },
250
+ }
251
+ }
package/src/index.js CHANGED
@@ -3,9 +3,15 @@ import {
3
3
  deleteSessionSafely,
4
4
  installAgentHandleTracker,
5
5
  } from './host/delete-session.mjs'
6
+ import {
7
+ createArchiveRequestHandlers,
8
+ deleteSessionAndReconcileArchive,
9
+ restoreArchivedSession,
10
+ searchArchivedSessions,
11
+ } from './host/archive-manager.mjs'
6
12
 
7
13
  export const name = 'dsh-session-delete'
8
- export const inject = ['webServer', 'sessionPersistence', 'sessions', 'agents']
14
+ export const inject = ['webServer', 'sessionPersistence', 'sessions', 'agents', 'workspaceRegistry']
9
15
 
10
16
  export function apply(ctx) {
11
17
  const sessionRoot = ctx.sessionPersistence?.root
@@ -16,13 +22,24 @@ export function apply(ctx) {
16
22
  const agentHandles = installAgentHandleTracker(ctx.agents, ctx.sessions)
17
23
  ctx.effect(() => () => agentHandles.release(), 'dsh-session-delete: agent lifecycle tracking')
18
24
 
25
+ const deleteSession = sessionId => deleteSessionSafely({
26
+ sessions: ctx.sessions,
27
+ agents: ctx.agents,
28
+ agentHandles,
29
+ sessionPersistence: ctx.sessionPersistence,
30
+ }, { sessionRoot, sessionId })
19
31
  const handler = createDeleteRequestHandler({
20
- deleteSession: sessionId => deleteSessionSafely({
21
- sessions: ctx.sessions,
22
- agents: ctx.agents,
23
- agentHandles,
24
- sessionPersistence: ctx.sessionPersistence,
25
- }, { sessionRoot, sessionId }),
32
+ deleteSession: sessionId => deleteSessionAndReconcileArchive({
33
+ workspaceRegistry: ctx.workspaceRegistry,
34
+ deleteSession,
35
+ }, sessionId),
36
+ })
37
+ const archiveHandlers = createArchiveRequestHandlers({
38
+ restore: sessionId => restoreArchivedSession(ctx.workspaceRegistry, sessionId),
39
+ search: (query, signal) => searchArchivedSessions({
40
+ workspaceRegistry: ctx.workspaceRegistry,
41
+ sessionQuery: ctx.get('sessionQuery'),
42
+ }, query, signal),
26
43
  })
27
44
 
28
45
  ctx.effect(
@@ -33,6 +50,22 @@ export function apply(ctx) {
33
50
  }),
34
51
  'dsh-session-delete: confirmed permanent deletion route',
35
52
  )
53
+ ctx.effect(
54
+ () => ctx.webServer.register({
55
+ kind: 'exact',
56
+ path: '/plugins/dsh-session-delete/restore',
57
+ handler: archiveHandlers.restore,
58
+ }),
59
+ 'dsh-session-delete: archived session restore route',
60
+ )
61
+ ctx.effect(
62
+ () => ctx.webServer.register({
63
+ kind: 'exact',
64
+ path: '/plugins/dsh-session-delete/archive-search',
65
+ handler: archiveHandlers.search,
66
+ }),
67
+ 'dsh-session-delete: archived history search route',
68
+ )
36
69
  }
37
70
 
38
71
  export {
@@ -41,3 +74,10 @@ export {
41
74
  deleteSessionSafely,
42
75
  installAgentHandleTracker,
43
76
  } from './host/delete-session.mjs'
77
+
78
+ export {
79
+ createArchiveRequestHandlers,
80
+ deleteSessionAndReconcileArchive,
81
+ restoreArchivedSession,
82
+ searchArchivedSessions,
83
+ } from './host/archive-manager.mjs'
package/README.en.md DELETED
@@ -1,134 +0,0 @@
1
- <div align="center">
2
-
3
- # DSH Native Session Delete
4
-
5
- **Bring permanent session deletion to the native DeepSeek Harness menu.**
6
-
7
- Native dark menu · Second confirmation · Permanent delete · In-place list update
8
-
9
- [![Release](https://img.shields.io/github/v/release/WSL043/dsh-native-session-delete?display_name=tag&style=flat-square)](https://github.com/WSL043/dsh-native-session-delete/releases/latest)
10
- [![Checks](https://img.shields.io/github/actions/workflow/status/WSL043/dsh-native-session-delete/ci.yml?branch=main&label=checks&style=flat-square)](https://github.com/WSL043/dsh-native-session-delete/actions/workflows/ci.yml)
11
- [![npm](https://img.shields.io/npm/v/dsh-native-session-delete?style=flat-square)](https://www.npmjs.com/package/dsh-native-session-delete)
12
- [![DSH](https://img.shields.io/badge/DSH-compatible-2f81f7?style=flat-square)](#compatibility)
13
- [![License](https://img.shields.io/github/license/WSL043/dsh-native-session-delete?style=flat-square)](LICENSE)
14
-
15
- [中文](README.md) · [Install](#install) · [Use](#use) · [Safety boundary](#safety-boundary)
16
-
17
- </div>
18
-
19
- <p align="center">
20
- <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/docs/assets/hero.en.png" alt="Red Delete session action in the native DeepSeek Harness dark-mode session menu">
21
- </p>
22
-
23
- | Native | Direct | Smooth |
24
- | --- | --- | --- |
25
- | The action lives in the native session menu and keeps Archive available | A second confirmation permanently deletes the target; running work is stopped first | The list updates in place without reloading the whole DSH page |
26
-
27
- ## Install
28
-
29
- ### Windows quick install (recommended)
30
-
31
- Open PowerShell and paste one line:
32
-
33
- ```powershell
34
- irm 'https://github.com/WSL043/dsh-native-session-delete/releases/download/v1.0.7/install.ps1' | iex
35
- ```
36
-
37
- The helper checks the current directory, PATH, `DSH_PORTABLE_ROOT`, Downloads/Desktop/Documents, and up to
38
- three nested levels below those folders and `LocalAppData\Temp`, then calls the official DSH `plugin add`
39
- command once. It does not recursively scan disks, install a package manager, snapshot profiles, create a
40
- resident command, or download the plugin twice. It supports regular DSH and both the portable edition and
41
- Windows installer edition of [DSH-Portable](https://github.com/WSL043/DSH-Portable) as targets. If it finds one durable installation plus disposable
42
- copies under Temp, it chooses the durable installation automatically. If several durable installations exist,
43
- the helper displays their real paths and asks for a number; no command editing or placeholder path is needed.
44
- If it still finds nothing, enter the actual DSH-Portable folder and rerun the same one-line command.
45
-
46
- ### Official CLI (macOS, Linux, or direct review)
47
-
48
- ```sh
49
- dsh plugin --profile web add dsh-native-session-delete@1.0.7
50
- ```
51
-
52
- The helper and direct command use the same standard bundle mechanism. The helper is only a Windows entry
53
- point; DSH still owns the installation transaction.
54
-
55
- When the command finishes, save your work and restart DSH once through its normal workflow so the new
56
- bundle configuration becomes active.
57
-
58
- ### Agent installation
59
-
60
- Use the fixed-version [AGENTS.md](https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/AGENTS.md).
61
- It defines installation, update, acceptance, uninstall, and safety boundaries. Do not use the `main`
62
- branch document as an installation contract.
63
-
64
- ## Use
65
-
66
- 1. Open the native actions menu beside the target session in the sidebar.
67
- 2. Choose the red **Delete session…** action.
68
- 3. Check the session name and select **Delete permanently** in the confirmation dialog, or select
69
- **Cancel** to leave it unchanged.
70
-
71
- <p align="center">
72
- <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/docs/assets/confirm-delete.en.png" width="560" alt="English dark-mode permanent deletion confirmation dialog">
73
- <br><sub>Permanent deletion cannot be undone; the dialog identifies the target session.</sub>
74
- </p>
75
-
76
- Once active, the plugin reuses DSH lifecycle and session-storage capabilities. If work is still running,
77
- it is stopped and allowed to settle before the target session is deleted. The session list then
78
- updates in place without reloading the whole DSH page.
79
-
80
- ## Safety boundary
81
-
82
- > [!WARNING]
83
- > Permanent deletion cannot be undone. Check the session name before confirming and make a separate backup when needed.
84
-
85
- The plugin is responsible for validating and removing only the explicitly confirmed session's dedicated
86
- directory within DSH's default per-session JSONL store and host lifecycle boundary. DSH currently exposes
87
- no public session-deletion API. The second confirmation is mandatory; cancelling sends no deletion request.
88
-
89
- The following are outside the plugin's deletion scope and are not guaranteed to be removed:
90
-
91
- - Other sessions, other plugin data, external attachments, caches, indexes, logs, backups, or cloud/sync copies;
92
- - Non-JSONL storage or hosts without a safe stop capability; these are refused instead of force-deleted;
93
- - Additional copies created by the operating system, filesystem, host updates, or third-party sync services.
94
-
95
- If the operating system refuses cleanup, the plugin reports that deletion could not be confirmed rather
96
- than misreporting partial completion as success. You are responsible for having authority to delete the
97
- target data and for meeting applicable retention, audit, and privacy requirements. This is an unofficial
98
- community plugin, not affiliated with or endorsed by DeepSeek. It is provided under the [MIT License](LICENSE),
99
- without warranty.
100
-
101
- ## Compatibility
102
-
103
- <!-- dsh-compatibility -->
104
- Supports DeepSeek Harness: `0.1.0-rc.6`, `0.1.0-rc.7`, `0.1.0-rc.8`, `0.1.1-rc.1`, `0.1.1-rc.2`.
105
- <!-- /dsh-compatibility -->
106
-
107
- The plugin supports DSH's default per-session JSONL storage. It adds deletion to the native session menu;
108
- uninstalling restores the original DSH menu.
109
-
110
- ## Update and uninstall
111
-
112
- Update by rerunning the quick installer or installing the new npm version. For v1.0.7:
113
-
114
- ```sh
115
- dsh plugin --profile web add dsh-native-session-delete@1.0.7
116
- ```
117
-
118
- Uninstall removes only this plugin's bundle layer and never deletes sessions:
119
-
120
- ```sh
121
- dsh plugin --profile web remove dsh-native-session-delete
122
- ```
123
-
124
- For DSH-Portable, use the corresponding `.\dsh.exe plugin --profile web add ...` or
125
- `.\dsh.exe plugin --profile web remove dsh-native-session-delete`. Restart DSH through its normal
126
- workflow after installing, updating, or uninstalling so the configuration is recomposed.
127
-
128
- ## Support and license
129
-
130
- Open a [GitHub Issue](https://github.com/WSL043/dsh-native-session-delete/issues) for ordinary problems. Report
131
- security issues privately as described in [SECURITY.md](SECURITY.md).
132
-
133
- MIT. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for the modified upstream client and its license
134
- notice.