@xrn1997/dsh-session-delete 0.0.1 → 0.1.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/lib/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export declare const name = "@xrn1997/dsh-session-delete";
43
43
  * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
44
44
  *
45
45
  * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
46
- * 前缀路由,浏览器半同源 fetch 那四个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
46
+ * 前缀路由,浏览器半同源 fetch 那五个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
47
47
  * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
48
48
  * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
49
49
  */
package/lib/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, resolve } from "node:path";
3
+ import zlib from "node:zlib";
3
4
  import { existsSync } from "node:fs";
4
- import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
5
+ import { mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
5
6
  //#region src/shared/wire.ts
6
7
  /**
7
- * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、四个端点段、请求字段名**——
8
+ * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、五个端点段、请求字段名**——
8
9
  * Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
9
10
  * 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
10
11
  *
@@ -27,12 +28,14 @@ import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
27
28
  * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
28
29
  */
29
30
  const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
30
- /** 四个端点的段名(相对前缀;`GET list` 读、其余三个 `POST` 写)。 */
31
+ /** 五个端点的段名(相对前缀;`GET list` 读清单,其余四个 `POST` 按 id 取数或写)。 */
31
32
  const ROUTES = {
32
33
  list: "/list",
33
34
  delete: "/delete",
34
35
  restore: "/restore",
35
- purge: "/purge"
36
+ purge: "/purge",
37
+ /** 单条详情(读回收站目录里那条会话自己的日志)。用 POST 表达一个读:见设计稿 2026-10-15 §3。 */
38
+ detail: "/detail"
36
39
  };
37
40
  /** 请求体字段名:浏览器半发、Node 半读,同名同义(`purge` 的 `entryId` 缺席 = 清空)。 */
38
41
  const PARAMS = {
@@ -156,6 +159,299 @@ function trashDirOf(entryId, home) {
156
159
  return `${home}/session-trash/${entryId}`;
157
160
  }
158
161
  //#endregion
162
+ //#region src/log/zstd-frames.ts
163
+ const MAGIC = [
164
+ 40,
165
+ 181,
166
+ 47,
167
+ 253
168
+ ];
169
+ /** magic(4) + Frame_Header_Descriptor(1) 之后第一个字节的偏移。 */
170
+ const AFTER_DESCRIPTOR = 5;
171
+ const BLOCK_HEADER_BYTES = 3;
172
+ const BLOCK_LAST = 1;
173
+ const BLOCK_TYPE_SHIFT = 1;
174
+ const BLOCK_SIZE_SHIFT = 3;
175
+ const BLOCK_SIZE_MASK = 2097151;
176
+ const BLOCK_RAW = 0;
177
+ const BLOCK_RLE = 1;
178
+ const BLOCK_COMRESSED = 2;
179
+ /** 规范(RFC 8878 表 4)里 3 是 **Reserved**,不是"未压缩块":见到就抛,别照 Raw 吞块体。 */
180
+ const BLOCK_RESERVED = 3;
181
+ /** descriptor 各字段的取值 → 字段字节数(zstd 帧头的两张尺寸表)。 */
182
+ const DID_SIZES = [
183
+ 0,
184
+ 1,
185
+ 2,
186
+ 4
187
+ ];
188
+ const FCS_SIZES_SINGLE = [
189
+ 1,
190
+ 2,
191
+ 4,
192
+ 8
193
+ ];
194
+ const FCS_SIZES_MULTI = [
195
+ 0,
196
+ 2,
197
+ 4,
198
+ 8
199
+ ];
200
+ /** 实测的帧尾长度(校验和位置;bit5 没置位也照样带,见文件头注)。 */
201
+ const FRAME_TAIL_BYTES = 4;
202
+ function isMagicAt(raw, at) {
203
+ return MAGIC.every((byte, i) => raw[at + i] === byte);
204
+ }
205
+ function readUint24LE(raw, at) {
206
+ return raw[at] | raw[at + 1] << 8 | raw[at + 2] << 16;
207
+ }
208
+ /** 块体之后有没有那 4 字节尾巴:只有"跳过它之后仍是帧头或到 EOF"才认。 */
209
+ function withFrameTail(raw, at) {
210
+ const afterTail = at + FRAME_TAIL_BYTES;
211
+ if (afterTail <= raw.length && (afterTail === raw.length || isMagicAt(raw, afterTail))) return afterTail;
212
+ return at;
213
+ }
214
+ /**
215
+ * 把 `raw` 切成帧区间。
216
+ * @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
217
+ * 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
218
+ */
219
+ function frameRanges(raw) {
220
+ const ranges = [];
221
+ let at = 0;
222
+ while (at < raw.length) {
223
+ if (!isMagicAt(raw, at)) throw new Error(`zstd 帧头缺席:偏移 ${at} 处不是 magic(前面第 ${ranges.length} 帧的边界算歪了?)`);
224
+ const descriptor = raw[at + 4];
225
+ if (descriptor === void 0) throw new Error(`zstd 帧头不完整:偏移 ${at} 之后不足 ${AFTER_DESCRIPTOR} 字节`);
226
+ const fcsFlag = descriptor & 3;
227
+ const singleSegment = descriptor >> 2 & 1;
228
+ const didFlag = descriptor >> 6 & 3;
229
+ let cursor = at + AFTER_DESCRIPTOR;
230
+ if (!singleSegment) cursor += 1;
231
+ cursor += DID_SIZES[didFlag];
232
+ cursor += (singleSegment ? FCS_SIZES_SINGLE : FCS_SIZES_MULTI)[fcsFlag];
233
+ for (;;) {
234
+ const blockHeader = readUint24LE(raw, cursor);
235
+ cursor += BLOCK_HEADER_BYTES;
236
+ if (cursor > raw.length) throw new Error(`zstd 块头越界:帧起于 ${at},读到偏移 ${cursor}(文件长 ${raw.length})`);
237
+ const blockType = blockHeader >> BLOCK_TYPE_SHIFT & 3;
238
+ const blockSize = blockHeader >> BLOCK_SIZE_SHIFT & BLOCK_SIZE_MASK;
239
+ if (blockType === BLOCK_RESERVED) throw new Error(`zstd 保留块类型:帧起于 ${at},偏移 ${cursor - BLOCK_HEADER_BYTES} 的块头 type=3(规范 Reserved,对面解不开)`);
240
+ if (blockType === BLOCK_RAW || blockType === BLOCK_COMRESSED) cursor += blockSize;
241
+ else if (blockType === BLOCK_RLE) cursor += 1;
242
+ if (blockHeader & BLOCK_LAST) break;
243
+ }
244
+ cursor = withFrameTail(raw, cursor);
245
+ if (cursor !== raw.length && !isMagicAt(raw, cursor)) throw new Error(`zstd 帧边界算歪了:帧起于 ${at},算到 ${cursor},那里既不是下一帧帧头也不是 EOF`);
246
+ ranges.push({
247
+ start: at,
248
+ end: cursor
249
+ });
250
+ at = cursor;
251
+ }
252
+ return ranges;
253
+ }
254
+ //#endregion
255
+ //#region src/log/session-log.ts
256
+ /**
257
+ * 会话日志(`session.vN.jsonl.zstd`)的**逐帧读法**:帧边界交给 `zstd-frames.ts`,这一层只做
258
+ * 「帧 → 明文 → 一行一条 JSON」的解码,并把每条记录原样交给回调。
259
+ *
260
+ * **策略不在这里**(哪些 `type` 算正文、截断到多少字、算不算失败)——那是 `src/trash/detail.ts` 的事。
261
+ * 这一层只懂编解码,所以它能被单独验穿。
262
+ *
263
+ * 两条实测决定的口径:
264
+ * ① **必须逐帧**:Node 的 `zstdDecompressSync` 与 `createZstdDecompress()` 都只给第一帧(见
265
+ * `zstd-frames.ts` 头注);明文留在手里会一次装下整条日志(本机 125 条合计 280.7 MB),
266
+ * 所以**一帧一帧解、解完就撒手**,内存口径是"最大那一帧的明文"(实测 8.99 MB)而不是整条。
267
+ * ② **一行的余量跨帧带走**(2026-10-16 改,原来靠"跳过解不开的行"兜):写作侧是一条条 append,
268
+ * 实测本机 686 个 `.jsonl.zstd` / 92 421 帧**没有一帧切在行中间**(连最后一帧都整行收尾),
269
+ * 所以旧口径当时并不丢东西;但"逐帧 `split('\n')`"一旦遇上切行就是**静默少一条记录**(半个头
270
+ * 加半个身,两半都解不开 JSON),而少掉的那条正是页面上要跟账的正文。带上余量之后这条不变量
271
+ * 不必再成立,顺带把**跨帧的多字节字符**也解对了——拼接在字节层做,`toString('utf8')` 只看整行。
272
+ * 真被切断的尾巴(写一半)仍然**跳过并计数**:绝不因为一行就抛整个读数,前面几百条是真的。
273
+ */
274
+ const NEWLINE = 10;
275
+ function walkSessionLog(raw, onRecord) {
276
+ const stats = {
277
+ frames: 0,
278
+ records: 0,
279
+ unparsedLines: 0,
280
+ truncatedTailBytes: 0
281
+ };
282
+ const feed = (line) => {
283
+ if (line === "") return;
284
+ let parsed;
285
+ try {
286
+ parsed = JSON.parse(line);
287
+ } catch {
288
+ stats.unparsedLines += 1;
289
+ return;
290
+ }
291
+ stats.records += 1;
292
+ onRecord(parsed !== null && typeof parsed === "object" ? parsed : {});
293
+ };
294
+ /** 上一帧没闭合的那一行。**存字节不存字符**:切在多字节字符中间的帧要拼回完整才解得对。 */
295
+ let carry = Buffer.alloc(0);
296
+ for (const { start, end } of frameRanges(raw)) {
297
+ stats.frames += 1;
298
+ let plain;
299
+ try {
300
+ plain = zlib.zstdDecompressSync(raw.subarray(start, end));
301
+ } catch (error) {
302
+ throw new Error(`回收站日志第 ${stats.frames} 帧解压失败:${String(error)}`);
303
+ }
304
+ const merged = carry.length === 0 ? plain : Buffer.concat([carry, plain]);
305
+ const cut = merged.lastIndexOf(NEWLINE);
306
+ if (cut === -1) {
307
+ carry = merged;
308
+ continue;
309
+ }
310
+ carry = merged.subarray(cut + 1);
311
+ for (const line of merged.subarray(0, cut).toString("utf8").split("\n")) feed(line);
312
+ }
313
+ stats.truncatedTailBytes = carry.length;
314
+ if (carry.length > 0) feed(carry.toString("utf8"));
315
+ return stats;
316
+ }
317
+ //#endregion
318
+ //#region src/trash/detail.ts
319
+ /**
320
+ * 回收站**单条详情**:从回收站目录里那条会话自己的日志读出元信息与正文(设计稿 2026-10-15 §3)。
321
+ *
322
+ * 三层里的策略层——编解码在 `src/log/*`,这里只回答"给谁看、给多少、算不算失败"。
323
+ *
324
+ * 三条硬口径,都不是口味:
325
+ * ① **寻址只认清册**:`entryId` 先在清单里查,目录一律由 `trashDirOf(record.id, home)` 推 ⇒ 外部
326
+ * 输入进不了文件系统寻址(不给"传个路径就读任意目录"留缝)。
327
+ * ② **正文只认 `source.kind === "user"`**:`user/message` 里绝大多数是宿主注入的上下文
328
+ * (本机实测一条会话:真实提问 21 条、注入 241 条),不过滤就被淹掉;注入条数照样上线
329
+ * (`injectedCount`),让"为什么少了这么多条"能与页面上的数字对账。
330
+ * ③ **两个闸是载荷闸,不是读盘闸**:每条 3 000 字符、累计 50 万字符(CJK 最坏 ≈ 1.5 MB UTF-8)。
331
+ * 按 §1 的读数本机一条都触发不了(最大那条正文 806 KB ≈ 27 万字符、442 条消息)⇒ 它们是防异常
332
+ * 膨胀的;**不设"文件太大就拒绝"**,那种阈值只会把真读得出来的正文挡在外面。
333
+ *
334
+ * 计数与正文**同源同判据**:`userMessageCount` 就是进了正文的提问条数、`assistantMessageCount` 就是
335
+ * 有文字的回复条数——不是一套判据算两遍,否则页面上会出现"计数 421、正文 200"这种对不上账的话。
336
+ */
337
+ /** 每条文字的上限(字符)。超出即截断并置 `truncated`。 */
338
+ const PER_MESSAGE_CHAR_CAP = 3e3;
339
+ /** 一次回信的正文总量上限(字符)。 */
340
+ const TOTAL_CHAR_CAP = 5e5;
341
+ const LOG_SUFFIX = ".jsonl.zstd";
342
+ const DEFAULT_LIMITS = {
343
+ perMessageChars: PER_MESSAGE_CHAR_CAP,
344
+ totalChars: TOTAL_CHAR_CAP
345
+ };
346
+ /** 一条记录里那些 `content[]` 段的文字拼起来(`\n` 分隔;非 text 段一律不参与)。 */
347
+ function textOf(content) {
348
+ if (!Array.isArray(content)) return "";
349
+ return content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
350
+ }
351
+ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
352
+ return async function readTrashDetail(entryId) {
353
+ const record = (await deps.manifest.list()).find((e) => e.id === entryId);
354
+ if (record === void 0) throw verbError("trash-empty", `回收站里没有这一条:${entryId}`);
355
+ const dir = trashDirOf(record.id, deps.home);
356
+ if (!await deps.fs.exists(dir)) throw verbError("io", `回收站目录不在:${dir}`);
357
+ const names = (await deps.fs.listFiles(dir)).sort();
358
+ const logName = names.find((n) => n.endsWith(LOG_SUFFIX));
359
+ if (logName === void 0) throw verbError("io", `回收站目录里没有会话日志:${dir}(里面有:${names.join(", ") || "空目录"})`);
360
+ let raw;
361
+ try {
362
+ raw = await deps.fs.readFile(`${dir}/${logName}`);
363
+ } catch (error) {
364
+ throw verbError("io", `读会话日志失败:${String(error)}`, error);
365
+ }
366
+ const detail = {
367
+ entryId: record.id,
368
+ cwd: "",
369
+ createdAt: 0,
370
+ lastActivity: 0,
371
+ logTitle: "",
372
+ models: [],
373
+ turnCount: 0,
374
+ userMessageCount: 0,
375
+ assistantMessageCount: 0,
376
+ toolCallCount: 0,
377
+ injectedCount: 0,
378
+ messages: [],
379
+ omittedByPayloadCap: 0
380
+ };
381
+ const models = /* @__PURE__ */ new Set();
382
+ let usedChars = 0;
383
+ let capped = false;
384
+ /** 收进正文,顺带管两个闸:截到单条上限,累计到总量就停。 */
385
+ function push(role, time, text) {
386
+ if (capped) {
387
+ detail.omittedByPayloadCap += 1;
388
+ return;
389
+ }
390
+ const truncated = text.length > limits.perMessageChars;
391
+ const kept = truncated ? text.slice(0, limits.perMessageChars) : text;
392
+ if (usedChars + kept.length > limits.totalChars) {
393
+ capped = true;
394
+ detail.omittedByPayloadCap += 1;
395
+ return;
396
+ }
397
+ usedChars += kept.length;
398
+ detail.messages.push({
399
+ role,
400
+ time,
401
+ text: kept,
402
+ truncated
403
+ });
404
+ }
405
+ try {
406
+ walkSessionLog(raw, (r) => {
407
+ if (typeof r.time === "number" && r.time > detail.lastActivity) detail.lastActivity = r.time;
408
+ const data = r.data ?? {};
409
+ switch (r.type) {
410
+ case "session":
411
+ if (typeof r.cwd === "string") detail.cwd = r.cwd;
412
+ if (typeof r.createdAt === "number") detail.createdAt = r.createdAt;
413
+ return;
414
+ case "session/title":
415
+ if (typeof data.title === "string") detail.logTitle = data.title;
416
+ return;
417
+ case "turn/start":
418
+ detail.turnCount += 1;
419
+ return;
420
+ case "tool/call":
421
+ detail.toolCallCount += 1;
422
+ return;
423
+ case "user/message": {
424
+ if ((data.source ?? {}).kind !== "user") {
425
+ detail.injectedCount += 1;
426
+ return;
427
+ }
428
+ const text = textOf(data.content);
429
+ if (text === "") return;
430
+ detail.userMessageCount += 1;
431
+ push("user", r.time ?? 0, text);
432
+ return;
433
+ }
434
+ case "assistant/message": {
435
+ const message = data.message ?? {};
436
+ const source = message.source ?? {};
437
+ if (typeof source.model === "string") models.add(source.model);
438
+ const text = textOf(message.content);
439
+ if (text === "") return;
440
+ detail.assistantMessageCount += 1;
441
+ push("assistant", r.time ?? 0, text);
442
+ return;
443
+ }
444
+ default: return;
445
+ }
446
+ });
447
+ } catch (error) {
448
+ throw verbError("io", error instanceof Error ? `读回收站日志失败:${error.message}` : `读回收站日志失败:${String(error)}`, error);
449
+ }
450
+ detail.models = [...models];
451
+ return detail;
452
+ };
453
+ }
454
+ //#endregion
159
455
  //#region src/verbs/delete-session.ts
160
456
  function deleteSession(deps) {
161
457
  return async function deleteSession(sessionId) {
@@ -423,6 +719,10 @@ async function dispatch(method, rest, req, deps) {
423
719
  requireMethod(method, "POST", ROUTES.purge);
424
720
  return await purgeTrash(deps)(optionalString(await readJsonBody(req), PARAMS.entryId));
425
721
  }
722
+ if (rest === ROUTES.detail) {
723
+ requireMethod(method, "POST", ROUTES.detail);
724
+ return await readTrashDetail(deps)(requireString(await readJsonBody(req), PARAMS.entryId));
725
+ }
426
726
  throw new Error(`未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
427
727
  }
428
728
  /** 宿主 `webServer.register({kind:'prefix', path: SESSION_DELETE_API_PREFIX, handler})` 的那个 handler。 */
@@ -470,7 +770,13 @@ function createFsPort() {
470
770
  force: true
471
771
  });
472
772
  },
473
- sizeOfDir: (p) => sizeOfDir(p)
773
+ sizeOfDir: (p) => sizeOfDir(p),
774
+ async listFiles(p) {
775
+ return readdir(p);
776
+ },
777
+ async readFile(p) {
778
+ return readFile(p);
779
+ }
474
780
  };
475
781
  }
476
782
  //#endregion
@@ -732,7 +1038,7 @@ const name = "@xrn1997/dsh-session-delete";
732
1038
  * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
733
1039
  *
734
1040
  * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
735
- * 前缀路由,浏览器半同源 fetch 那四个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
1041
+ * 前缀路由,浏览器半同源 fetch 那五个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
736
1042
  * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
737
1043
  * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
738
1044
  */
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "meta": {
3
- "title": "Session Delete",
4
- "description": "Delete sessions: delete moves them to a recoverable trash, with an explicit permanent delete."
5
- }
6
- }
1
+ {
2
+ "meta": {
3
+ "title": "Session Delete",
4
+ "description": "Delete sessions: delete moves them to a recoverable trash, with an explicit permanent delete."
5
+ }
6
+ }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "meta": {
3
- "title": "会话删除",
4
- "description": "删除会话:删除 = 移入回收站(可恢复),另设显式的彻底删除。"
5
- }
6
- }
1
+ {
2
+ "meta": {
3
+ "title": "会话删除",
4
+ "description": "删除会话:删除 = 移入回收站(可恢复),另设显式的彻底删除。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xrn1997/dsh-session-delete",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "在 DeepSeek Harness 里删除会话:删除=移入回收站(可恢复),另设显式彻底删除",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -87,5 +87,9 @@
87
87
  "delete"
88
88
  ],
89
89
  "author": "xrn1997",
90
- "license": "Apache-2.0"
90
+ "license": "Apache-2.0",
91
+ "dependencies": {
92
+ "react-markdown": "^10.1.0",
93
+ "remark-gfm": "^4.0.1"
94
+ }
91
95
  }