@logic-dante/dsh-conversation-manager 1.5.0 → 1.5.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.
Files changed (3) hide show
  1. package/README.md +42 -0
  2. package/index.js +655 -492
  3. package/package.json +2 -1
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # dsh-conversation-manager
2
+
3
+ DSH(DeepSeek Harness)插件:在侧栏会话的「...」菜单里**真正删除**对话,连同磁盘上的会话数据一起清除。DSH 自带的 archive 只是把会话隐藏,本插件是真删。
4
+
5
+ ## 安装
6
+
7
+ ```sh
8
+ dsh plugin --profile web add github:lloggc-Dante/dsh-conversation-manager
9
+ ```
10
+
11
+ 装完**重启 DSH**。`--profile` 换成你实际使用的 profile 名。
12
+
13
+ 卸载:
14
+
15
+ ```sh
16
+ dsh plugin --profile web remove @logic-dante/dsh-conversation-manager
17
+ ```
18
+
19
+ ## 用法
20
+
21
+ - **删单个**:鼠标移到侧栏的会话 → 打开「...」菜单 → 点红色的「删除对话」。该行立刻从列表消失,不用刷新页面。
22
+ - **批量删**:点搜索框旁边的多选按钮 → 逐个勾选会话 → 点账户区上方的删除栏 → 二次确认后删除。
23
+
24
+ ## 删除会做什么
25
+
26
+ 删掉 `~/.dsh/sessions/<项目>/<session-id>/` 整个目录(含 `session.v4.jsonl.zstd` 日志)和 `storages/session_projcache` 里的缓存记录,并把这个 id 从所有工作区、归档与置顶列表里摘除。
27
+
28
+ **不可恢复,没有回收站。** 删除前会先停掉这个对话正在跑的工作;如果文件还被占用,会提示你先切到别的对话再删。
29
+
30
+ ## 兼容性
31
+
32
+ 需要 DSH 的 web 端(`dsh.client.platform: web`)。插件依赖 `connection.fetch` 路由和 `workspaceRegistry` / `sessionPersistence` 两个服务。
33
+
34
+ ## English
35
+
36
+ Deletes a conversation from its sidebar "..." menu — for real, not an archive. Install with:
37
+
38
+ ```sh
39
+ dsh plugin --profile web add github:lloggc-Dante/dsh-conversation-manager
40
+ ```
41
+
42
+ Restart DSH afterwards. The red "Delete conversation" row removes the session directory and log, the projection-cache record, and the id from every workspace, archive and pin list. Multi-select delete lives behind the toggle beside the search box and asks for confirmation. Deletion is permanent — there is no trash.
package/index.js CHANGED
@@ -1,492 +1,655 @@
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
- }
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`. A deleted
25
+ * id is also remembered (tombstoned) for a while: a Session that is still
26
+ * resident can flush its log back to disk after the delete, so a small sweeper
27
+ * keeps removing that copy and keeps the row off every open page.
28
+ */
29
+ import { promises as fsp } from 'node:fs';
30
+ import { homedir } from 'node:os';
31
+ import path from 'node:path';
32
+
33
+ const ROUTE = '/api/conversation-manager/delete';
34
+ const ACTION_HEADER = 'x-dsh-conversation-manager';
35
+ const BODY_LIMIT = 64 * 1024;
36
+ /** Session ids are opaque, but they are also used as a directory name. */
37
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,199}$/;
38
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]$/;
39
+
40
+ /**
41
+ * A deleted id is remembered for a while: a session that is still resident in
42
+ * the host keeps a writer alive and can flush its log back to disk, which made
43
+ * the row reappear after a "successful" delete. The tombstone lets the sweeper
44
+ * remove the recreated store again until the writer is gone.
45
+ */
46
+ const DELETED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
47
+ const ENFORCE_WINDOW_MS = 10 * 60 * 1000;
48
+ const ENFORCE_INTERVAL_MS = 2500;
49
+
50
+ const json = (status, value) =>
51
+ new Response(JSON.stringify(value), {
52
+ status,
53
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
54
+ });
55
+
56
+ const fail = (status, message) => json(status, { ok: false, message });
57
+
58
+ const textOf = (error) => (error instanceof Error ? error.message : String(error));
59
+
60
+ const warn = (...args) => {
61
+ try {
62
+ console.warn('[conversation-manager]', ...args);
63
+ } catch {
64
+ /* console may be gone during shutdown */
65
+ }
66
+ };
67
+
68
+ class RequestError extends Error {
69
+ constructor(status, message) {
70
+ super(message);
71
+ this.status = status;
72
+ }
73
+ }
74
+
75
+ /** Resolve a host service without hard-failing when the profile does not have it. */
76
+ function service(ctx, name) {
77
+ try {
78
+ const found = ctx.get?.(name);
79
+ if (found) return found;
80
+ } catch {
81
+ /* optional access */
82
+ }
83
+ try {
84
+ return ctx[name];
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ function dshHome() {
91
+ const fromEnv = process.env.DSH_HOME;
92
+ return typeof fromEnv === 'string' && fromEnv.length > 0 ? fromEnv : path.join(homedir(), '.dsh');
93
+ }
94
+
95
+ /* ------------------------------------------------------------------------- *
96
+ * Mirror of the persistence backend's path escapes.
97
+ * ------------------------------------------------------------------------- */
98
+
99
+ /** `encodeSegment()` from the jsonl persistence backend: one safe path segment. */
100
+ function encodeSegment(raw) {
101
+ if (raw.length === 0) throw new Error('cannot encode an empty path segment');
102
+ if (raw === '.') return '~002E';
103
+ if (raw === '..') return '~002E~002E';
104
+ let out = '';
105
+ for (let i = 0; i < raw.length; i += 1) {
106
+ const code = raw.charCodeAt(i);
107
+ const ch = String.fromCharCode(code);
108
+ if (ch !== '~' && SAFE_SEGMENT.test(ch)) out += ch;
109
+ else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** `projectKey()` from the jsonl persistence backend: the cwd group directory. */
115
+ function projectKey(cwd) {
116
+ if (cwd.length === 0) throw new Error('cannot encode an empty project path');
117
+ let readable = '';
118
+ let separatorRun = false;
119
+ for (let i = 0; i < cwd.length; i += 1) {
120
+ const code = cwd.charCodeAt(i);
121
+ const ch = String.fromCharCode(code);
122
+ if (ch === '/' || ch === '\\' || ch === ':') {
123
+ if (!separatorRun) readable += '-';
124
+ separatorRun = true;
125
+ } else if (ch !== '~' && SAFE_SEGMENT.test(ch)) {
126
+ readable += ch;
127
+ separatorRun = false;
128
+ } else {
129
+ readable += '~' + code.toString(16).toUpperCase().padStart(4, '0');
130
+ separatorRun = false;
131
+ }
132
+ }
133
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`;
134
+ }
135
+
136
+ /** Same-origin guard: reject cross-site / cross-origin callers. */
137
+ function originVerdict(request) {
138
+ const site = request.headers.get('sec-fetch-site');
139
+ if (site && !['same-origin', 'none'].includes(site)) return fail(403, '拒绝跨站请求');
140
+ const origin = request.headers.get('origin');
141
+ if (origin && /^https?:/i.test(origin)) {
142
+ try {
143
+ const host = request.headers.get('host') || new URL(request.url).host;
144
+ if (new URL(origin).host !== host) return fail(403, '拒绝跨源请求');
145
+ } catch {
146
+ return fail(403, '无效来源');
147
+ }
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ async function readJson(request) {
153
+ const reader = request.body?.getReader();
154
+ if (!reader) throw new RequestError(400, '缺少请求内容');
155
+ const chunks = [];
156
+ let size = 0;
157
+ try {
158
+ for (;;) {
159
+ const { value, done } = await reader.read();
160
+ if (done) break;
161
+ size += value.byteLength;
162
+ if (size > BODY_LIMIT) {
163
+ await reader.cancel();
164
+ throw new RequestError(413, '请求内容过大');
165
+ }
166
+ chunks.push(value);
167
+ }
168
+ } finally {
169
+ try {
170
+ reader.releaseLock();
171
+ } catch {
172
+ /* already released */
173
+ }
174
+ }
175
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
176
+ if (!raw) throw new RequestError(400, '请求内容为空');
177
+ try {
178
+ return JSON.parse(raw);
179
+ } catch {
180
+ throw new RequestError(400, '请求内容不是合法 JSON');
181
+ }
182
+ }
183
+
184
+ const isDirectory = async (candidate) => {
185
+ try {
186
+ return (await fsp.stat(candidate)).isDirectory();
187
+ } catch {
188
+ return false;
189
+ }
190
+ };
191
+
192
+ /**
193
+ * Every directory that may hold this session: the exact one derived from the
194
+ * header cwd, plus any group that happens to contain the escaped id.
195
+ */
196
+ async function locateSessionDirs(ctx, sessionId) {
197
+ const home = dshHome();
198
+ const sessionsRoot = path.join(home, 'sessions');
199
+ const found = new Set();
200
+
201
+ const persistence = service(ctx, 'sessionPersistence');
202
+ if (persistence && typeof persistence.stat === 'function') {
203
+ try {
204
+ const snapshot = await persistence.stat(sessionId);
205
+ const cwd = snapshot?.header?.cwd;
206
+ if (typeof cwd === 'string' && cwd.length > 0) {
207
+ const group = cwd === void 0 ? '_no-cwd' : projectKey(cwd);
208
+ const exact = path.join(sessionsRoot, group, encodeSegment(sessionId));
209
+ if (await isDirectory(exact)) found.add(exact);
210
+ } else if (snapshot) {
211
+ const noCwd = path.join(sessionsRoot, '_no-cwd', encodeSegment(sessionId));
212
+ if (await isDirectory(noCwd)) found.add(noCwd);
213
+ }
214
+ } catch (error) {
215
+ warn('sessionPersistence.stat failed', sessionId, textOf(error));
216
+ }
217
+ }
218
+
219
+ let groups = [];
220
+ try {
221
+ groups = await fsp.readdir(sessionsRoot, { withFileTypes: true });
222
+ } catch (error) {
223
+ if (error?.code !== 'ENOENT') throw error;
224
+ }
225
+ const escaped = encodeSegment(sessionId);
226
+ for (const group of groups) {
227
+ if (!group.isDirectory()) continue;
228
+ for (const name of new Set([escaped, sessionId])) {
229
+ const candidate = path.join(sessionsRoot, group.name, name);
230
+ if (await isDirectory(candidate)) found.add(candidate);
231
+ }
232
+ }
233
+ return [...found];
234
+ }
235
+
236
+ /** Remove the projection-cache record that mirrors the session summary. */
237
+ async function removeProjectionCache(sessionId) {
238
+ const cacheDir = path.join(dshHome(), 'storages', 'session_projcache', 'sessions');
239
+ for (const name of new Set([`${sessionId}.json`, `${encodeSegment(sessionId)}.json`])) {
240
+ try {
241
+ await fsp.rm(path.join(cacheDir, name), { force: true });
242
+ } catch {
243
+ /* a stale projection cache is harmless */
244
+ }
245
+ }
246
+ }
247
+
248
+ /** Remove the durable store plus its projection-cache record. */
249
+ async function removeStorage(ctx, sessionId, targets) {
250
+ const removed = [];
251
+ for (const dir of targets) {
252
+ try {
253
+ await fsp.rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
254
+ removed.push(dir);
255
+ } catch (error) {
256
+ const busy = ['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(error?.code);
257
+ throw new RequestError(
258
+ 409,
259
+ busy
260
+ ? '会话数据仍被占用,请先停止这个对话(换一个对话再删)后重试。'
261
+ : '删除会话数据失败:' + textOf(error),
262
+ );
263
+ }
264
+ }
265
+ await removeProjectionCache(sessionId);
266
+ return { targets, removed };
267
+ }
268
+
269
+ /**
270
+ * Hide the row immediately. Deleting the log makes the session disappear on the
271
+ * next list refresh, but an already-open page keeps its last snapshot; marking
272
+ * the id archived makes the sidebar drop that row at once, and the journal below
273
+ * clears the marker again once the store is gone.
274
+ */
275
+ async function hideSession(ctx, sessionId) {
276
+ const registry = service(ctx, 'workspaceRegistry');
277
+ if (!registry || typeof registry.archiveSession !== 'function') return false;
278
+ try {
279
+ await registry.archiveSession(sessionId, { stopActivity: true });
280
+ return true;
281
+ } catch (error) {
282
+ warn('archiveSession failed', sessionId, textOf(error));
283
+ return false;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Tell every open page that this Session is gone. `api-session/removed` is a
289
+ * forwarded Host event (`dsh-api-remotes` allowlist) and the session client
290
+ * applies it as a row removal, so the sidebar drops the row without a reload and
291
+ * without leaving an "archived" marker behind.
292
+ */
293
+ function announceRemoval(ctx, sessionId) {
294
+ try {
295
+ if (typeof ctx.emit !== 'function') return false;
296
+ ctx.emit('api-session/removed', sessionId);
297
+ return true;
298
+ } catch (error) {
299
+ warn('announceRemoval failed', sessionId, textOf(error));
300
+ return false;
301
+ }
302
+ }
303
+
304
+ /** `api-session/removed` relay bookkeeping for the boot sweep. */
305
+ const journalPath = () => path.join(dshHome(), 'storages', 'conversation-manager', 'archived.json');
306
+
307
+ async function readJournal() {
308
+ try {
309
+ const parsed = JSON.parse(await fsp.readFile(journalPath(), 'utf8'));
310
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
311
+ } catch {
312
+ return [];
313
+ }
314
+ }
315
+
316
+ async function writeJournal(ids) {
317
+ try {
318
+ const file = journalPath();
319
+ await fsp.mkdir(path.dirname(file), { recursive: true });
320
+ await fsp.writeFile(file, JSON.stringify([...new Set(ids)], null, 2), 'utf8');
321
+ } catch (error) {
322
+ warn('journal write failed', textOf(error));
323
+ }
324
+ }
325
+
326
+ /* -------------------------------------------------------------------------- *
327
+ * Deleted-session tombstones.
328
+ *
329
+ * Deleting the store is not enough while the session is still resident: the
330
+ * host keeps a writer for an open conversation and flushes it again, recreating
331
+ * the exact directory we just removed. The row then reappears and the next
332
+ * delete either races the writer or finds nothing on disk. Remembering the id
333
+ * (and the paths we removed) lets a small sweeper keep the store deleted and
334
+ * keep the row off every open page until the writer is gone.
335
+ * -------------------------------------------------------------------------- */
336
+
337
+ const deletedPath = () => path.join(dshHome(), 'storages', 'conversation-manager', 'deleted.json');
338
+ /** sessionId -> { at, dirs } */
339
+ const deletedIds = new Map();
340
+ let deletedLoaded = false;
341
+ let enforceTimer;
342
+ let enforceCtx;
343
+
344
+ async function loadDeleted() {
345
+ if (deletedLoaded) return;
346
+ deletedLoaded = true;
347
+ let parsed;
348
+ try {
349
+ parsed = JSON.parse(await fsp.readFile(deletedPath(), 'utf8'));
350
+ } catch {
351
+ return;
352
+ }
353
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
354
+ const now = Date.now();
355
+ for (const [id, entry] of Object.entries(parsed)) {
356
+ if (typeof id !== 'string' || !SAFE_ID.test(id)) continue;
357
+ const at = typeof entry === 'number' ? entry : typeof entry?.at === 'number' ? entry.at : now;
358
+ if (now - at > DELETED_TTL_MS) continue;
359
+ const dirs = Array.isArray(entry?.dirs) ? entry.dirs.filter((value) => typeof value === 'string') : [];
360
+ deletedIds.set(id, { at, dirs });
361
+ }
362
+ }
363
+
364
+ async function saveDeleted() {
365
+ try {
366
+ const file = deletedPath();
367
+ await fsp.mkdir(path.dirname(file), { recursive: true });
368
+ const now = Date.now();
369
+ const payload = {};
370
+ for (const [id, entry] of [...deletedIds]) {
371
+ if (now - entry.at > DELETED_TTL_MS) {
372
+ deletedIds.delete(id);
373
+ continue;
374
+ }
375
+ payload[id] = { at: entry.at, dirs: entry.dirs };
376
+ }
377
+ await fsp.writeFile(file, JSON.stringify(payload, null, 2), 'utf8');
378
+ } catch (error) {
379
+ warn('deleted journal write failed', textOf(error));
380
+ }
381
+ }
382
+
383
+ /** Remember a deleted id and start the sweeper that keeps it deleted. */
384
+ async function rememberDeleted(ctx, sessionId, dirs) {
385
+ await loadDeleted();
386
+ const previous = deletedIds.get(sessionId);
387
+ const kept = new Set([...(previous?.dirs ?? []), ...(dirs ?? [])]);
388
+ deletedIds.set(sessionId, { at: Date.now(), dirs: [...kept] });
389
+ await saveDeleted();
390
+ startEnforcement(ctx);
391
+ }
392
+
393
+ function startEnforcement(ctx) {
394
+ if (!enforceCtx) enforceCtx = ctx;
395
+ if (enforceTimer) return;
396
+ enforceTimer = setInterval(() => {
397
+ enforceDeleted(enforceCtx).catch((error) => warn('deleted sweep failed', textOf(error)));
398
+ }, ENFORCE_INTERVAL_MS);
399
+ enforceTimer?.unref?.();
400
+ }
401
+
402
+ /** Remove recreated stores for tombstoned ids and keep their rows out of the list. */
403
+ async function enforceDeleted(ctx) {
404
+ await loadDeleted();
405
+ if (deletedIds.size === 0) return;
406
+ if (enforceTimer === undefined) startEnforcement(ctx);
407
+ const now = Date.now();
408
+ let changed = false;
409
+ let budget = 50;
410
+ for (const [sessionId, entry] of [...deletedIds]) {
411
+ if (budget-- <= 0) break;
412
+ if (now - entry.at > DELETED_TTL_MS) {
413
+ deletedIds.delete(sessionId);
414
+ changed = true;
415
+ continue;
416
+ }
417
+ let removed = 0;
418
+ if (entry.dirs.length === 0) {
419
+ // The store was already gone at delete time; look for a recreated one.
420
+ try {
421
+ entry.dirs = await locateSessionDirs(ctx, sessionId);
422
+ changed = entry.dirs.length > 0 || changed;
423
+ } catch (error) {
424
+ warn('deleted lookup failed', sessionId, textOf(error));
425
+ }
426
+ }
427
+ for (const dir of [...entry.dirs]) {
428
+ try {
429
+ if (!(await isDirectory(dir))) continue;
430
+ await fsp.rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
431
+ removed += 1;
432
+ } catch (error) {
433
+ warn('deleted removal failed', sessionId, textOf(error));
434
+ }
435
+ }
436
+ if (removed > 0) {
437
+ await removeProjectionCache(sessionId);
438
+ try {
439
+ await forgetInRegistry(ctx, sessionId);
440
+ } catch (error) {
441
+ warn('deleted detach failed', sessionId, textOf(error));
442
+ }
443
+ announceRemoval(ctx, sessionId);
444
+ changed = true;
445
+ } else if (now - entry.at < ENFORCE_WINDOW_MS) {
446
+ // The row can be re-announced by the host while the session is resident;
447
+ // keep telling the pages it is gone until that window closes.
448
+ announceRemoval(ctx, sessionId);
449
+ }
450
+ }
451
+ if (changed) await saveDeleted();
452
+ }
453
+
454
+ /**
455
+ * Startup sweep: an id we archived while deleting may sit in the archive list
456
+ * after its store is gone. Unarchive those so the archived view does not keep a
457
+ * ghost; ids whose store still exists stay archived (the delete failed).
458
+ */
459
+ async function pruneArchivedGhosts(ctx) {
460
+ const journal = await readJournal();
461
+ if (journal.length === 0) return;
462
+ const registry = service(ctx, 'workspaceRegistry');
463
+ const persistence = service(ctx, 'sessionPersistence');
464
+ if (!registry || typeof registry.unarchiveSession !== 'function') return;
465
+ const keep = [];
466
+ for (const id of journal) {
467
+ let exists = true;
468
+ try {
469
+ exists = Boolean(await persistence?.stat?.(id));
470
+ } catch {
471
+ exists = true;
472
+ }
473
+ if (exists) {
474
+ keep.push(id);
475
+ continue;
476
+ }
477
+ try {
478
+ await registry.unarchiveSession(id);
479
+ } catch (error) {
480
+ warn('unarchive ghost failed', id, textOf(error));
481
+ keep.push(id);
482
+ }
483
+ }
484
+ await writeJournal(keep);
485
+ }
486
+
487
+ /** Drop the id from every workspace, and from the archived / pinned id lists. */
488
+ async function forgetInRegistry(ctx, sessionId) {
489
+ const registry = service(ctx, 'workspaceRegistry');
490
+ if (!registry || typeof registry.list !== 'function') return { detached: false, reason: 'registry-unavailable' };
491
+ let detached = false;
492
+ for (const workspace of registry.list()) {
493
+ const ids = workspace?.sessionIds;
494
+ if (!Array.isArray(ids) || !ids.includes(sessionId)) continue;
495
+ try {
496
+ await workspace.detachSession(sessionId);
497
+ detached = true;
498
+ } catch (error) {
499
+ warn('detachSession failed', textOf(error));
500
+ }
501
+ }
502
+ if (typeof registry.unpinSession === 'function') {
503
+ try {
504
+ await registry.unpinSession(sessionId);
505
+ } catch {
506
+ /* not pinned */
507
+ }
508
+ }
509
+ return { detached };
510
+ }
511
+
512
+ async function handleDelete(ctx, request) {
513
+ if (request.method !== 'POST') return fail(405, '请使用 POST');
514
+ const contentType = (request.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
515
+ if (contentType !== 'application/json' || request.headers.get(ACTION_HEADER) !== '1') {
516
+ return fail(403, '请从侧栏会话菜单删除对话');
517
+ }
518
+ const denied = originVerdict(request);
519
+ if (denied) return denied;
520
+ let body;
521
+ try {
522
+ body = await readJson(request);
523
+ } catch (error) {
524
+ return fail(error?.status || 400, textOf(error));
525
+ }
526
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
527
+ if (!SAFE_ID.test(sessionId) || sessionId.includes('..')) return fail(400, '无效的会话 ID');
528
+ try {
529
+ const targets = await locateSessionDirs(ctx, sessionId);
530
+ if (targets.length === 0) {
531
+ // Nothing on disk. The row may still be listed from the live session or a
532
+ // stale projection; forget it in the registry, keep the tombstone so a
533
+ // late flush cannot bring it back, and report success so the phantom row
534
+ // can be dismissed instead of erroring forever.
535
+ const cleanup = await forgetInRegistry(ctx, sessionId);
536
+ await removeProjectionCache(sessionId);
537
+ await rememberDeleted(ctx, sessionId, []);
538
+ const announced = announceRemoval(ctx, sessionId);
539
+ return json(200, {
540
+ ok: true,
541
+ sessionId,
542
+ missing: true,
543
+ removedGroups: 0,
544
+ removedPaths: 0,
545
+ swept: 0,
546
+ detached: cleanup.detached,
547
+ hidden: false,
548
+ archived: false,
549
+ announced,
550
+ });
551
+ }
552
+ // Stop any running work and hide the row before the store goes away; a live
553
+ // agent would otherwise keep writing to the directory being removed.
554
+ const hidden = await hideSession(ctx, sessionId);
555
+ let storage;
556
+ try {
557
+ storage = await removeStorage(ctx, sessionId, targets);
558
+ } catch (error) {
559
+ if (hidden) {
560
+ try {
561
+ await service(ctx, 'workspaceRegistry')?.unarchiveSession?.(sessionId);
562
+ } catch {
563
+ /* keep the row hidden rather than resurrect a half-deleted row */
564
+ }
565
+ }
566
+ throw error;
567
+ }
568
+ const cleanup = await forgetInRegistry(ctx, sessionId);
569
+ // Remember the deletion: a resident session can flush its log back to disk
570
+ // after this point, and the sweeper removes that copy again.
571
+ await rememberDeleted(ctx, sessionId, storage.targets);
572
+ // A still-live container can recreate the store right after removal; sweep
573
+ // once more so "deleted" is not undone by a late flush.
574
+ let swept = 0;
575
+ for (let attempt = 0; attempt < 3; attempt += 1) {
576
+ await new Promise((resolve) => setTimeout(resolve, 150));
577
+ const again = await locateSessionDirs(ctx, sessionId);
578
+ if (again.length === 0) break;
579
+ try {
580
+ await removeStorage(ctx, sessionId, again);
581
+ swept += again.length;
582
+ } catch (error) {
583
+ warn('sweep failed', sessionId, textOf(error));
584
+ break;
585
+ }
586
+ }
587
+ // Drop the row from every open page, then clear the hiding marker so the
588
+ // conversation is not left sitting in the archived view.
589
+ const announced = announceRemoval(ctx, sessionId);
590
+ let archived = hidden;
591
+ if (hidden && announced) {
592
+ // Let the removal reach the page before the archive set changes again.
593
+ await new Promise((resolve) => setTimeout(resolve, 120));
594
+ try {
595
+ await service(ctx, 'workspaceRegistry')?.unarchiveSession?.(sessionId);
596
+ archived = false;
597
+ } catch (error) {
598
+ warn('clearing archive marker failed', sessionId, textOf(error));
599
+ }
600
+ }
601
+ if (archived) await writeJournal([...(await readJournal()), sessionId]);
602
+ return json(200, {
603
+ ok: true,
604
+ sessionId,
605
+ removedGroups: storage.targets.length,
606
+ removedPaths: storage.removed.length,
607
+ swept,
608
+ detached: cleanup.detached,
609
+ hidden,
610
+ archived,
611
+ announced,
612
+ });
613
+ } catch (error) {
614
+ warn('delete failed', sessionId, textOf(error));
615
+ return fail(error?.status || 500, textOf(error));
616
+ }
617
+ }
618
+
619
+ export function apply(ctx) {
620
+ const install = (host) => {
621
+ const carrier = host?.connection?.fetch;
622
+ if (typeof carrier?.register !== 'function') {
623
+ warn('connection.fetch unavailable; delete route not registered');
624
+ return;
625
+ }
626
+ try {
627
+ const dispose = carrier.register({
628
+ path: ROUTE,
629
+ methods: ['POST'],
630
+ requestBody: 'buffered',
631
+ fetch: (request) => handleDelete(ctx, request),
632
+ });
633
+ if (typeof dispose?.catch === 'function') dispose.catch((error) => warn('route registration failed', textOf(error)));
634
+ } catch (error) {
635
+ warn('route registration failed', textOf(error));
636
+ }
637
+ };
638
+ if (typeof ctx.inject === 'function') ctx.inject(['connection'], install);
639
+ else install(ctx);
640
+ // Startup: unarchive ghosts left by an interrupted delete, then enforce the
641
+ // tombstones once (a resident session may have recreated its store while the
642
+ // app was closed) and keep the sweeper running.
643
+ try {
644
+ const timer = setTimeout(() => {
645
+ loadDeleted()
646
+ .then(() => enforceDeleted(ctx))
647
+ .then(() => startEnforcement(ctx))
648
+ .then(() => pruneArchivedGhosts(ctx))
649
+ .catch((error) => warn('startup sweep failed', textOf(error)));
650
+ }, 3000);
651
+ timer?.unref?.();
652
+ } catch (error) {
653
+ warn('prune scheduling failed', textOf(error));
654
+ }
655
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logic-dante/dsh-conversation-manager",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Delete unwanted conversations straight from the sidebar session menu, including their on-disk session store.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -48,3 +48,4 @@
48
48
 
49
49
 
50
50
 
51
+