@logic-dante/dsh-conversation-manager 0.0.0-stage → 1.5.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.
- package/client.js +1161 -0
- package/cordis.patch.yml +11 -0
- package/icon.svg +7 -0
- package/index.js +492 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -6
- package/README.md +0 -3
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-conversation-manager bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# One dual-face plugin row: the node half (exports ".") runs in the host process
|
|
4
|
+
# and registers POST /api/conversation-manager/delete on the authenticated
|
|
5
|
+
# connection fetch channel; the `dsh.client` manifest field makes the browser
|
|
6
|
+
# half (exports "./client", served at /plugins/<id>/client.js) add the red
|
|
7
|
+
# "delete conversation" row to the sidebar session menu.
|
|
8
|
+
- insert:
|
|
9
|
+
- id: conversation-manager
|
|
10
|
+
name: '@logic-dante/dsh-conversation-manager'
|
|
11
|
+
|
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="Conversation Manager">
|
|
2
|
+
<rect x="2" y="2" width="28" height="28" rx="9" fill="#e5484d"/>
|
|
3
|
+
<path d="M10.5 11.5h11" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round"/>
|
|
4
|
+
<path d="M13.6 11.5v-1.2c0-.6.5-1.1 1.1-1.1h2.6c.6 0 1.1.5 1.1 1.1v1.2" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round"/>
|
|
5
|
+
<path d="M12.2 11.5l.55 8.1c.05.9.8 1.6 1.7 1.6h3.1c.9 0 1.65-.7 1.7-1.6l.55-8.1" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linejoin="round"/>
|
|
6
|
+
<path d="M14.6 14.2v4.2M17.4 14.2v4.2" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-conversation-manager — host half.
|
|
3
|
+
*
|
|
4
|
+
* The sidebar gives a session only "archive", which keeps the conversation
|
|
5
|
+
* stored and merely hides it from the list. This plugin adds a real delete: one
|
|
6
|
+
* red row in a session's "..." menu (see client.js) calls the authenticated
|
|
7
|
+
* route below, and the host half removes the durable session store.
|
|
8
|
+
*
|
|
9
|
+
* POST /api/conversation-manager/delete { "sessionId": "session-…" }
|
|
10
|
+
*
|
|
11
|
+
* Effects of one call:
|
|
12
|
+
* 1. the session's own directory `<DSH_HOME>/sessions/<cwd group>/<id>/` is
|
|
13
|
+
* removed together with its `session.v4.jsonl.zstd` log;
|
|
14
|
+
* 2. its projection-cache record is removed;
|
|
15
|
+
* 3. the id is detached from every workspace and dropped from the archived /
|
|
16
|
+
* pinned id lists, so the sidebar stops listing it.
|
|
17
|
+
*
|
|
18
|
+
* Both on-disk names are escaped: the cwd group by `projectKey()` and the
|
|
19
|
+
* session directory by `encodeSegment()`. Those two escapes are mirrored below
|
|
20
|
+
* from `@deepseek-ai/dsh-session-persistence-jsonl`; the session directory is
|
|
21
|
+
* also discovered by scanning the group directories, and the exact group is
|
|
22
|
+
* derived from the session header cwd through `sessionPersistence.stat()`.
|
|
23
|
+
*
|
|
24
|
+
* Deletion is irreversible: there is no trash, exactly like `rm -rf`.
|
|
25
|
+
*/
|
|
26
|
+
import { promises as fsp } from 'node:fs';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
const ROUTE = '/api/conversation-manager/delete';
|
|
31
|
+
const ACTION_HEADER = 'x-dsh-conversation-manager';
|
|
32
|
+
const BODY_LIMIT = 64 * 1024;
|
|
33
|
+
/** Session ids are opaque, but they are also used as a directory name. */
|
|
34
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,199}$/;
|
|
35
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9._-]$/;
|
|
36
|
+
|
|
37
|
+
const json = (status, value) =>
|
|
38
|
+
new Response(JSON.stringify(value), {
|
|
39
|
+
status,
|
|
40
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const fail = (status, message) => json(status, { ok: false, message });
|
|
44
|
+
|
|
45
|
+
const textOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
46
|
+
|
|
47
|
+
const warn = (...args) => {
|
|
48
|
+
try {
|
|
49
|
+
console.warn('[conversation-manager]', ...args);
|
|
50
|
+
} catch {
|
|
51
|
+
/* console may be gone during shutdown */
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
class RequestError extends Error {
|
|
56
|
+
constructor(status, message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.status = status;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve a host service without hard-failing when the profile does not have it. */
|
|
63
|
+
function service(ctx, name) {
|
|
64
|
+
try {
|
|
65
|
+
const found = ctx.get?.(name);
|
|
66
|
+
if (found) return found;
|
|
67
|
+
} catch {
|
|
68
|
+
/* optional access */
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return ctx[name];
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function dshHome() {
|
|
78
|
+
const fromEnv = process.env.DSH_HOME;
|
|
79
|
+
return typeof fromEnv === 'string' && fromEnv.length > 0 ? fromEnv : path.join(homedir(), '.dsh');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ------------------------------------------------------------------------- *
|
|
83
|
+
* Mirror of the persistence backend's path escapes.
|
|
84
|
+
* ------------------------------------------------------------------------- */
|
|
85
|
+
|
|
86
|
+
/** `encodeSegment()` from the jsonl persistence backend: one safe path segment. */
|
|
87
|
+
function encodeSegment(raw) {
|
|
88
|
+
if (raw.length === 0) throw new Error('cannot encode an empty path segment');
|
|
89
|
+
if (raw === '.') return '~002E';
|
|
90
|
+
if (raw === '..') return '~002E~002E';
|
|
91
|
+
let out = '';
|
|
92
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
93
|
+
const code = raw.charCodeAt(i);
|
|
94
|
+
const ch = String.fromCharCode(code);
|
|
95
|
+
if (ch !== '~' && SAFE_SEGMENT.test(ch)) out += ch;
|
|
96
|
+
else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `projectKey()` from the jsonl persistence backend: the cwd group directory. */
|
|
102
|
+
function projectKey(cwd) {
|
|
103
|
+
if (cwd.length === 0) throw new Error('cannot encode an empty project path');
|
|
104
|
+
let readable = '';
|
|
105
|
+
let separatorRun = false;
|
|
106
|
+
for (let i = 0; i < cwd.length; i += 1) {
|
|
107
|
+
const code = cwd.charCodeAt(i);
|
|
108
|
+
const ch = String.fromCharCode(code);
|
|
109
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
110
|
+
if (!separatorRun) readable += '-';
|
|
111
|
+
separatorRun = true;
|
|
112
|
+
} else if (ch !== '~' && SAFE_SEGMENT.test(ch)) {
|
|
113
|
+
readable += ch;
|
|
114
|
+
separatorRun = false;
|
|
115
|
+
} else {
|
|
116
|
+
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0');
|
|
117
|
+
separatorRun = false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Same-origin guard: reject cross-site / cross-origin callers. */
|
|
124
|
+
function originVerdict(request) {
|
|
125
|
+
const site = request.headers.get('sec-fetch-site');
|
|
126
|
+
if (site && !['same-origin', 'none'].includes(site)) return fail(403, '拒绝跨站请求');
|
|
127
|
+
const origin = request.headers.get('origin');
|
|
128
|
+
if (origin && /^https?:/i.test(origin)) {
|
|
129
|
+
try {
|
|
130
|
+
const host = request.headers.get('host') || new URL(request.url).host;
|
|
131
|
+
if (new URL(origin).host !== host) return fail(403, '拒绝跨源请求');
|
|
132
|
+
} catch {
|
|
133
|
+
return fail(403, '无效来源');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function readJson(request) {
|
|
140
|
+
const reader = request.body?.getReader();
|
|
141
|
+
if (!reader) throw new RequestError(400, '缺少请求内容');
|
|
142
|
+
const chunks = [];
|
|
143
|
+
let size = 0;
|
|
144
|
+
try {
|
|
145
|
+
for (;;) {
|
|
146
|
+
const { value, done } = await reader.read();
|
|
147
|
+
if (done) break;
|
|
148
|
+
size += value.byteLength;
|
|
149
|
+
if (size > BODY_LIMIT) {
|
|
150
|
+
await reader.cancel();
|
|
151
|
+
throw new RequestError(413, '请求内容过大');
|
|
152
|
+
}
|
|
153
|
+
chunks.push(value);
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
try {
|
|
157
|
+
reader.releaseLock();
|
|
158
|
+
} catch {
|
|
159
|
+
/* already released */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
163
|
+
if (!raw) throw new RequestError(400, '请求内容为空');
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(raw);
|
|
166
|
+
} catch {
|
|
167
|
+
throw new RequestError(400, '请求内容不是合法 JSON');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const isDirectory = async (candidate) => {
|
|
172
|
+
try {
|
|
173
|
+
return (await fsp.stat(candidate)).isDirectory();
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Every directory that may hold this session: the exact one derived from the
|
|
181
|
+
* header cwd, plus any group that happens to contain the escaped id.
|
|
182
|
+
*/
|
|
183
|
+
async function locateSessionDirs(ctx, sessionId) {
|
|
184
|
+
const home = dshHome();
|
|
185
|
+
const sessionsRoot = path.join(home, 'sessions');
|
|
186
|
+
const found = new Set();
|
|
187
|
+
|
|
188
|
+
const persistence = service(ctx, 'sessionPersistence');
|
|
189
|
+
if (persistence && typeof persistence.stat === 'function') {
|
|
190
|
+
try {
|
|
191
|
+
const snapshot = await persistence.stat(sessionId);
|
|
192
|
+
const cwd = snapshot?.header?.cwd;
|
|
193
|
+
if (typeof cwd === 'string' && cwd.length > 0) {
|
|
194
|
+
const group = cwd === void 0 ? '_no-cwd' : projectKey(cwd);
|
|
195
|
+
const exact = path.join(sessionsRoot, group, encodeSegment(sessionId));
|
|
196
|
+
if (await isDirectory(exact)) found.add(exact);
|
|
197
|
+
} else if (snapshot) {
|
|
198
|
+
const noCwd = path.join(sessionsRoot, '_no-cwd', encodeSegment(sessionId));
|
|
199
|
+
if (await isDirectory(noCwd)) found.add(noCwd);
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
warn('sessionPersistence.stat failed', sessionId, textOf(error));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let groups = [];
|
|
207
|
+
try {
|
|
208
|
+
groups = await fsp.readdir(sessionsRoot, { withFileTypes: true });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
211
|
+
}
|
|
212
|
+
const escaped = encodeSegment(sessionId);
|
|
213
|
+
for (const group of groups) {
|
|
214
|
+
if (!group.isDirectory()) continue;
|
|
215
|
+
for (const name of new Set([escaped, sessionId])) {
|
|
216
|
+
const candidate = path.join(sessionsRoot, group.name, name);
|
|
217
|
+
if (await isDirectory(candidate)) found.add(candidate);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return [...found];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Remove the durable store plus its projection-cache record. */
|
|
224
|
+
async function removeStorage(ctx, sessionId, targets) {
|
|
225
|
+
const home = dshHome();
|
|
226
|
+
const removed = [];
|
|
227
|
+
for (const dir of targets) {
|
|
228
|
+
try {
|
|
229
|
+
await fsp.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 120 });
|
|
230
|
+
removed.push(dir);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const busy = ['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(error?.code);
|
|
233
|
+
throw new RequestError(
|
|
234
|
+
409,
|
|
235
|
+
busy
|
|
236
|
+
? '会话数据仍被占用,请先停止这个对话(换一个对话再删)后重试。'
|
|
237
|
+
: '删除会话数据失败:' + textOf(error),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const cacheDir = path.join(home, 'storages', 'session_projcache', 'sessions');
|
|
242
|
+
for (const name of new Set([`${sessionId}.json`, `${encodeSegment(sessionId)}.json`])) {
|
|
243
|
+
const cache = path.join(cacheDir, name);
|
|
244
|
+
try {
|
|
245
|
+
await fsp.rm(cache, { force: true });
|
|
246
|
+
removed.push(cache);
|
|
247
|
+
} catch {
|
|
248
|
+
/* a stale projection cache is harmless */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { targets, removed };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Hide the row immediately. Deleting the log makes the session disappear on the
|
|
256
|
+
* next list refresh, but an already-open page keeps its last snapshot; marking
|
|
257
|
+
* the id archived makes the sidebar drop that row at once, and the journal below
|
|
258
|
+
* clears the marker again once the store is gone.
|
|
259
|
+
*/
|
|
260
|
+
async function hideSession(ctx, sessionId) {
|
|
261
|
+
const registry = service(ctx, 'workspaceRegistry');
|
|
262
|
+
if (!registry || typeof registry.archiveSession !== 'function') return false;
|
|
263
|
+
try {
|
|
264
|
+
await registry.archiveSession(sessionId, { stopActivity: true });
|
|
265
|
+
return true;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
warn('archiveSession failed', sessionId, textOf(error));
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Tell every open page that this Session is gone. `api-session/removed` is a
|
|
274
|
+
* forwarded Host event (`dsh-api-remotes` allowlist) and the session client
|
|
275
|
+
* applies it as a row removal, so the sidebar drops the row without a reload and
|
|
276
|
+
* without leaving an "archived" marker behind.
|
|
277
|
+
*/
|
|
278
|
+
function announceRemoval(ctx, sessionId) {
|
|
279
|
+
try {
|
|
280
|
+
if (typeof ctx.emit !== 'function') return false;
|
|
281
|
+
ctx.emit('api-session/removed', sessionId);
|
|
282
|
+
return true;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
warn('announceRemoval failed', sessionId, textOf(error));
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `api-session/removed` relay bookkeeping for the boot sweep. */
|
|
290
|
+
const journalPath = () => path.join(dshHome(), 'storages', 'conversation-manager', 'archived.json');
|
|
291
|
+
|
|
292
|
+
async function readJournal() {
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(await fsp.readFile(journalPath(), 'utf8'));
|
|
295
|
+
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
|
|
296
|
+
} catch {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function writeJournal(ids) {
|
|
302
|
+
try {
|
|
303
|
+
const file = journalPath();
|
|
304
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
305
|
+
await fsp.writeFile(file, JSON.stringify([...new Set(ids)], null, 2), 'utf8');
|
|
306
|
+
} catch (error) {
|
|
307
|
+
warn('journal write failed', textOf(error));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Startup sweep: an id we archived while deleting may sit in the archive list
|
|
313
|
+
* after its store is gone. Unarchive those so the archived view does not keep a
|
|
314
|
+
* ghost; ids whose store still exists stay archived (the delete failed).
|
|
315
|
+
*/
|
|
316
|
+
async function pruneArchivedGhosts(ctx) {
|
|
317
|
+
const journal = await readJournal();
|
|
318
|
+
if (journal.length === 0) return;
|
|
319
|
+
const registry = service(ctx, 'workspaceRegistry');
|
|
320
|
+
const persistence = service(ctx, 'sessionPersistence');
|
|
321
|
+
if (!registry || typeof registry.unarchiveSession !== 'function') return;
|
|
322
|
+
const keep = [];
|
|
323
|
+
for (const id of journal) {
|
|
324
|
+
let exists = true;
|
|
325
|
+
try {
|
|
326
|
+
exists = Boolean(await persistence?.stat?.(id));
|
|
327
|
+
} catch {
|
|
328
|
+
exists = true;
|
|
329
|
+
}
|
|
330
|
+
if (exists) {
|
|
331
|
+
keep.push(id);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
await registry.unarchiveSession(id);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
warn('unarchive ghost failed', id, textOf(error));
|
|
338
|
+
keep.push(id);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
await writeJournal(keep);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Drop the id from every workspace, and from the archived / pinned id lists. */
|
|
345
|
+
async function forgetInRegistry(ctx, sessionId) {
|
|
346
|
+
const registry = service(ctx, 'workspaceRegistry');
|
|
347
|
+
if (!registry || typeof registry.list !== 'function') return { detached: false, reason: 'registry-unavailable' };
|
|
348
|
+
let detached = false;
|
|
349
|
+
for (const workspace of registry.list()) {
|
|
350
|
+
const ids = workspace?.sessionIds;
|
|
351
|
+
if (!Array.isArray(ids) || !ids.includes(sessionId)) continue;
|
|
352
|
+
try {
|
|
353
|
+
await workspace.detachSession(sessionId);
|
|
354
|
+
detached = true;
|
|
355
|
+
} catch (error) {
|
|
356
|
+
warn('detachSession failed', textOf(error));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (typeof registry.unpinSession === 'function') {
|
|
360
|
+
try {
|
|
361
|
+
await registry.unpinSession(sessionId);
|
|
362
|
+
} catch {
|
|
363
|
+
/* not pinned */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { detached };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function handleDelete(ctx, request) {
|
|
370
|
+
if (request.method !== 'POST') return fail(405, '请使用 POST');
|
|
371
|
+
const contentType = (request.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
372
|
+
if (contentType !== 'application/json' || request.headers.get(ACTION_HEADER) !== '1') {
|
|
373
|
+
return fail(403, '请从侧栏会话菜单删除对话');
|
|
374
|
+
}
|
|
375
|
+
const denied = originVerdict(request);
|
|
376
|
+
if (denied) return denied;
|
|
377
|
+
let body;
|
|
378
|
+
try {
|
|
379
|
+
body = await readJson(request);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
return fail(error?.status || 400, textOf(error));
|
|
382
|
+
}
|
|
383
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
384
|
+
if (!SAFE_ID.test(sessionId) || sessionId.includes('..')) return fail(400, '无效的会话 ID');
|
|
385
|
+
try {
|
|
386
|
+
const targets = await locateSessionDirs(ctx, sessionId);
|
|
387
|
+
if (targets.length === 0) {
|
|
388
|
+
// Nothing on disk: report the truth instead of pretending success, so a
|
|
389
|
+
// conversation that still shows up can be diagnosed instead of silently
|
|
390
|
+
// "deleted" while it keeps living in the sidebar.
|
|
391
|
+
return json(404, {
|
|
392
|
+
ok: false,
|
|
393
|
+
message: '没有找到这个对话的存储文件(可能已经被删除了)。',
|
|
394
|
+
sessionId,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Stop any running work and hide the row before the store goes away; a live
|
|
398
|
+
// agent would otherwise keep writing to the directory being removed.
|
|
399
|
+
const hidden = await hideSession(ctx, sessionId);
|
|
400
|
+
let storage;
|
|
401
|
+
try {
|
|
402
|
+
storage = await removeStorage(ctx, sessionId, targets);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (hidden) {
|
|
405
|
+
try {
|
|
406
|
+
await service(ctx, 'workspaceRegistry')?.unarchiveSession?.(sessionId);
|
|
407
|
+
} catch {
|
|
408
|
+
/* keep the row hidden rather than resurrect a half-deleted row */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
const cleanup = await forgetInRegistry(ctx, sessionId);
|
|
414
|
+
// A still-live container can recreate the store right after removal; sweep
|
|
415
|
+
// once more so "deleted" is not undone by a late flush.
|
|
416
|
+
let swept = 0;
|
|
417
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
418
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
419
|
+
const again = await locateSessionDirs(ctx, sessionId);
|
|
420
|
+
if (again.length === 0) break;
|
|
421
|
+
try {
|
|
422
|
+
await removeStorage(ctx, sessionId, again);
|
|
423
|
+
swept += again.length;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
warn('sweep failed', sessionId, textOf(error));
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Drop the row from every open page, then clear the hiding marker so the
|
|
430
|
+
// conversation is not left sitting in the archived view.
|
|
431
|
+
const announced = announceRemoval(ctx, sessionId);
|
|
432
|
+
let archived = hidden;
|
|
433
|
+
if (hidden && announced) {
|
|
434
|
+
// Let the removal reach the page before the archive set changes again.
|
|
435
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
436
|
+
try {
|
|
437
|
+
await service(ctx, 'workspaceRegistry')?.unarchiveSession?.(sessionId);
|
|
438
|
+
archived = false;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
warn('clearing archive marker failed', sessionId, textOf(error));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (archived) await writeJournal([...(await readJournal()), sessionId]);
|
|
444
|
+
return json(200, {
|
|
445
|
+
ok: true,
|
|
446
|
+
sessionId,
|
|
447
|
+
removedGroups: storage.targets.length,
|
|
448
|
+
removedPaths: storage.removed.length,
|
|
449
|
+
swept,
|
|
450
|
+
detached: cleanup.detached,
|
|
451
|
+
hidden,
|
|
452
|
+
archived,
|
|
453
|
+
announced,
|
|
454
|
+
});
|
|
455
|
+
} catch (error) {
|
|
456
|
+
warn('delete failed', sessionId, textOf(error));
|
|
457
|
+
return fail(error?.status || 500, textOf(error));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function apply(ctx) {
|
|
462
|
+
const install = (host) => {
|
|
463
|
+
const carrier = host?.connection?.fetch;
|
|
464
|
+
if (typeof carrier?.register !== 'function') {
|
|
465
|
+
warn('connection.fetch unavailable; delete route not registered');
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
const dispose = carrier.register({
|
|
470
|
+
path: ROUTE,
|
|
471
|
+
methods: ['POST'],
|
|
472
|
+
requestBody: 'buffered',
|
|
473
|
+
fetch: (request) => handleDelete(ctx, request),
|
|
474
|
+
});
|
|
475
|
+
if (typeof dispose?.catch === 'function') dispose.catch((error) => warn('route registration failed', textOf(error)));
|
|
476
|
+
} catch (error) {
|
|
477
|
+
warn('route registration failed', textOf(error));
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
if (typeof ctx.inject === 'function') ctx.inject(['connection'], install);
|
|
481
|
+
else install(ctx);
|
|
482
|
+
// A delete archives the row to hide it at once, then clears that marker on a
|
|
483
|
+
// later boot, once the store it referred to is gone.
|
|
484
|
+
try {
|
|
485
|
+
const timer = setTimeout(() => {
|
|
486
|
+
pruneArchivedGhosts(ctx).catch((error) => warn('prune failed', textOf(error)));
|
|
487
|
+
}, 4000);
|
|
488
|
+
timer?.unref?.();
|
|
489
|
+
} catch (error) {
|
|
490
|
+
warn('prune scheduling failed', textOf(error));
|
|
491
|
+
}
|
|
492
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@logic-dante/dsh-conversation-manager",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@logic-dante/dsh-conversation-manager",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Delete unwanted conversations straight from the sidebar session menu, including their on-disk session store.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./locale/en.json": "./locale/en.json",
|
|
11
|
+
"./locale/zh.json": "./locale/zh.json",
|
|
12
|
+
"./icon": "./icon.svg",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"client.js",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"locale/en.json",
|
|
20
|
+
"locale/zh.json",
|
|
21
|
+
"icon.svg"
|
|
22
|
+
],
|
|
23
|
+
"icon": "icon.svg",
|
|
24
|
+
"dsh": {
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-locale",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-workspace"
|
|
32
|
+
],
|
|
33
|
+
"platform": "web"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
package/README.md
DELETED