@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.
@@ -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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Conversation Manager",
4
+ "description": "Delete unwanted conversations straight from the session menu, removing their on-disk session store too."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "对话管理插件",
4
+ "description": "在会话的「...」菜单里直接删除不需要的对话,连同磁盘上的会话数据一起清除。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
- {
2
- "name": "@logic-dante/dsh-conversation-manager",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -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.