@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.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.
Files changed (116) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6227 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2090 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1747 -0
  19. package/backend/lib/feishu_cfg.js +262 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +2077 -0
  23. package/backend/lib/feishu_provision.js +886 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +426 -0
  33. package/backend/lib/proc.js +361 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4546 -0
  40. package/backend/lib/seed_hint.js +40 -0
  41. package/backend/lib/servercaps.js +182 -0
  42. package/backend/lib/serverstate.js +227 -0
  43. package/backend/lib/sessparse.js +1176 -0
  44. package/backend/lib/sse.js +207 -0
  45. package/backend/lib/static.js +84 -0
  46. package/backend/lib/taskjson.js +302 -0
  47. package/backend/lib/waitq.js +2114 -0
  48. package/backend/lib/worktree.js +588 -0
  49. package/backend/lib/zstd-frames.js +218 -0
  50. package/backend/routes/admin.js +553 -0
  51. package/backend/routes/agents.js +149 -0
  52. package/backend/routes/assets.js +145 -0
  53. package/backend/routes/board.js +1228 -0
  54. package/backend/routes/feishu.js +269 -0
  55. package/backend/routes/feishu_app.js +249 -0
  56. package/backend/routes/feishu_hook.js +358 -0
  57. package/backend/routes/index.js +298 -0
  58. package/backend/routes/projects.js +1138 -0
  59. package/backend/routes/sessions.js +1003 -0
  60. package/backend/routes/tasks.js +1530 -0
  61. package/backend/schema/fixtures/ancient.sql +189 -0
  62. package/backend/schema/fixtures/pre_alter.sql +177 -0
  63. package/backend/schema/migrations.json +154 -0
  64. package/backend/schema/schema.json +1451 -0
  65. package/backend/schema/schema.sql +216 -0
  66. package/backend/server.js +505 -0
  67. package/board.py +96 -13
  68. package/builtin_assets.py +0 -0
  69. package/builtin_prompts/free_style/flow.md +0 -0
  70. package/builtin_prompts/free_style/load_test.md +0 -0
  71. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  78. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  79. package/chat.py +33 -0
  80. package/db.py +294 -129
  81. package/dsh-plugin/cordis.patch.yml +0 -0
  82. package/dsh-plugin/install.sh +22 -8
  83. package/dsh-plugin/lib/client.bundle.js +111 -2
  84. package/dsh-plugin/lib/index.js +340 -29
  85. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  86. package/dsh-plugin/scripts/build.mjs +0 -0
  87. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  88. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  89. package/dsh-plugin/src/client.js +111 -2
  90. package/dshevents.py +0 -0
  91. package/export_cases.py +0 -0
  92. package/extensions/dsh-autocommit/asset.json +0 -0
  93. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  94. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  95. package/feishu.py +417 -3
  96. package/feishu_conv.py +0 -0
  97. package/lib.py +0 -0
  98. package/lifecycle.py +116 -13
  99. package/loadcase.py +0 -0
  100. package/loadgen.py +0 -0
  101. package/localbus.py +0 -0
  102. package/migrate_p7b.py +0 -0
  103. package/package.json +6 -1
  104. package/platcompat.py +0 -0
  105. package/prompts.py +0 -0
  106. package/rag.py +0 -0
  107. package/requirements.txt +0 -0
  108. package/server.py +155 -11
  109. package/touchstone.py +0 -0
  110. package/waitq.py +0 -0
  111. package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
  112. package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
  113. package/webui/dist-plugin/index.html +2 -2
  114. package/worktree.py +0 -0
  115. package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
  116. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,1176 @@
1
+ /**
2
+ * Touchstone session 解析层(Node 版):`sessparse.py`(838 行)的逐字等价移植。
3
+ *
4
+ * 职责与 Python 侧完全一致:定位并解析 dsh(deepseek harness)会话存储
5
+ * (`~/.dsh/sessions/<bucket>/<sid>/session[.v<N>].jsonl.zstd`,多帧 zstd 事件流),
6
+ * 统一输出 entry 模型供 session 对话窗口渲染;另提供标题 / 首问 / 会话枚举 / 存在性
7
+ * / 运行中会话探测 / 回退锚点等读口。
8
+ *
9
+ * **移植口径(为什么逐条对齐而不是「写个等价的」)**
10
+ * - 这是双轨共存期(方案 §五)的读路径:Python 与 Node 两版同时跑,对拍器逐字段比。
11
+ * 任何「顺手优化」都会变成对拍差异,故本文件**函数名、边界条件、缓存键、字段序**
12
+ * 一律照抄 Python,连 `_trunc` 的码点口径、`_obj` 的坏 JSON 容错都一样。
13
+ * - 与 Python 的**语义差异只有三处**(都在下方各自位置注明):
14
+ * ① JS 字符串按 UTF-16 码元索引/计数,Python 按码点 ⇒ 截断与长度用码点扫描实现;
15
+ * ② Python 的 `str.strip()` 空白集 ≠ JS `trim()`(BOM / \x1c-\x1f / \x85)⇒ 自实现 `pyStrip`;
16
+ * ③ Python 的 `dict.get` 在非 dict 上抛 AttributeError(读口的 `except (OSError, ValueError)`
17
+ * **不吞**)⇒ `dget`/`orDict` 在非 dict 上抛 `PyTypeError`,由调用方按同一口径决定吞不吞。
18
+ * - **zstd 解压**用已交付的 `backend/lib/zstd-frames.js`(多帧扫描 + 逐帧解压,`node:zlib`
19
+ * 只解单帧)——那正是本模块的地基,不在这里重复实现(方案 R1b)。
20
+ * - Python 侧 `_DEC_TLS`(每线程一个 `ZstdDecompressor`)是 SIGSEGV 的历史修复:Node 的
21
+ * `zstdDecompressSync` 是同步单线程调用,**没有**共享可变解压器对象,故本版无对应物。
22
+ *
23
+ * 已知且**有意保留**的边界差异(真会话文件里不会出现,仅记录以免被当成 bug 改):
24
+ * - JSON 数值:Python 区分 int/float(`1.0` 不算整数 token 计数),JS 统一是 number;
25
+ * 超出 2^53 的整数(`seq`/`time` 不会这么大)会丢精度。
26
+ * - `json.loads` 接受裸 `NaN`/`Infinity`,`JSON.parse` 拒绝(→ 该行按坏行跳过)。
27
+ * - skippable zstd 帧:**两侧同口径跳过**(Python `decompressobj` 与 Node 扫描器都按 RFC 8878
28
+ * 跳过;`tools/parity/zstd-run.mjs` 的 `skippable_middle` 变体实测同为 3 帧 / 67 B)。
29
+ * 真机 464 个会话文件实测**零** skippable 帧。
30
+ * - 路径/字符串比较:`pyStrCompare` 按码点序实现 Python 的 str 比较(仅非 BMP 字符上有别于 JS 码元序)。
31
+ *
32
+ * **半帧内容(曾与 Python 不一致,已在本文件内对齐,未改 `zstd-frames.js`)**:
33
+ * 文件在某个完整帧内部被截断时,Python 的 `decompressobj().decompress()` 会把半帧里
34
+ * **已完整解出的块内容**一并返回;`zstd-frames.decompressSessionFile` 的口径是「扫帧失败即停」,
35
+ * 整个半帧(含已解出部分)都丢。该收尾逻辑已于 2026-10-11 **上移到 `zstd-frames.js`**
36
+ * (`decompressFramePartial`,`finishFlush: Z_NO_FLUSH`),本文件直接调
37
+ * `decompressSessionFile()` —— 六类坏样本实测与 Python 逐字节相等(对拍报告第四节)。
38
+ *
39
+ * **半截帧(头行)**:`_dsh_header` 与 `_dsh_decompressed` 一致地调用 `decompressFramePartial`
40
+ * ——Python 的 `decompressobj()` 在截断处返回部分内容,本版同(真机头行都在第 1 帧第 1 行,
41
+ * 该分支只在「首帧正被写」的窗口里起作用)。
42
+ *
43
+ * 导出面:与 Python 同名(含下划线的内部函数也**照名导出**,供单测与 `tools/parity` 探针
44
+ * 直接调用;业务代码只用公开读口:`load` / `session_title` / `list_sessions` / `session_exists`
45
+ * / `is_subagent` / `live_session_id` / `dsh_first_prompt` / `dsh_fork_boundary` 等)。
46
+ */
47
+ import fs from 'node:fs';
48
+ import { homedir } from 'node:os';
49
+ import path from 'node:path';
50
+ import { zstdDecompressSync } from 'node:zlib';
51
+
52
+ import { decompressFramePartial, decompressSessionFile, frameEnd } from './zstd-frames.js';
53
+
54
+ // ---------------------------------------------------------------- 常量与基础口径
55
+
56
+ /**
57
+ * dsh 会话存储根(Python:`os.path.expanduser("~/.dsh/sessions")`)。
58
+ * Python 侧是可 monkeypatch 的模块常量;Node 的 ESM 导出绑定不可从外部赋值,
59
+ * 故这里用「内部 let + 读/写函数」:`dshSessionsRoot()` 读、`setSessionsRoot()` 写
60
+ * (仅供单测与 `tools/parity/sess-run.mjs` 的快照目录使用,等价于 Python 测试里的
61
+ * `monkeypatch.setattr(sessparse, "DSH_SESSIONS", …)`)。
62
+ */
63
+ let DSH_SESSIONS = path.join(homedir(), '.dsh', 'sessions');
64
+
65
+ /** 当前会话存储根(默认 `~/.dsh/sessions`)。 */
66
+ export function dshSessionsRoot() {
67
+ return DSH_SESSIONS;
68
+ }
69
+
70
+ /** 改写会话存储根(测试/对拍专用,绝对值化避免 cwd 参与解析)。 */
71
+ export function setSessionsRoot(root) {
72
+ DSH_SESSIONS = path.resolve(String(root));
73
+ }
74
+
75
+ // session id / media id 白名单(防路径穿越)
76
+ // dsh 会话 id 为 session-<uuid>(新)或裸 <uuid>(早期/桌面端;目录名即 id)。
77
+ // 正则尾部 `\n?$`:Python `re` 的 `$` 匹配「串尾或末尾换行之前」,JS 的 `$` 只匹配串尾,
78
+ // 故显式补一个可选换行,保持两版接受集合一致(对齐 `SID_DSH_RE`/`MEDIA_DSH_RE` 的原始写法)。
79
+ export const SID_DSH_RE = /^(?:session-)?[0-9a-fA-F-]{8,36}\n?$/;
80
+ // dsh 附件 id(宿主 attachment 服务的不透明存储 id;平台只用它向驱动要字节)
81
+ export const MEDIA_DSH_RE = /^sha256:[a-f0-9]{64}\n?$/;
82
+
83
+ export const ARGS_MAX = 2000; // tool_call args JSON 截断长度
84
+ export const OUT_MAX = 4000; // tool_result 输出截断长度
85
+
86
+ /** 可解析的会话族白名单(Python 侧是 tuple,此处冻结数组;退场族传入即被拒)。 */
87
+ export const FAMILIES = Object.freeze(['dsh']);
88
+
89
+ /** 解析缓存:Map<sid, Map<agent, {stamp, entries, totals, ctx}>>(Python:`{(sid, agent): …}`)。 */
90
+ const _CACHE = new Map();
91
+
92
+ /** 模拟 Python ValueError(读口的 `except (OSError, ValueError)` 会吞掉它)。 */
93
+ class PyValueError extends Error {}
94
+
95
+ /** 模拟 Python TypeError/AttributeError(读口**不吞**它,与 Python 的 except 子句一致)。 */
96
+ class PyTypeError extends Error {}
97
+
98
+ /**
99
+ * 「预期内的降级原因」白名单(P8 评审):文件读不到 / 数据坏 / zstd 坏帧 —— 这些在
100
+ * Python 侧本来就走 `except (OSError, ValueError)` 静默降级,Node 侧同样静默。
101
+ */
102
+ function isExpectedDegrade(error) {
103
+ if (error instanceof PyValueError) return true;
104
+ // 驱动错误(DshDriverError):Python 的 `except (…, dshdriver.DshDriverError)` 面,
105
+ // 「附件不存在(404)」是正常路径。鸭子类型判定,避免 sessparse 反向依赖 dshdriver
106
+ // (M3 接线是调用方 `setMediaFetcher(dshdriver.media)` 注入,模块间不 import)
107
+ if (error && error.name === 'DshDriverError') return true;
108
+ const code = error && error.code;
109
+ return code === 'ENOENT' || code === 'EACCES' || code === 'EPERM' || code === 'EISDIR'
110
+ || code === 'ENOTDIR' || code === 'ELOOP' || code === 'ENAMETOOLONG'
111
+ || code === 'ZSTD_error_prefix_unknown' || code === 'ZSTD_error_checksum_wrong'
112
+ || code === 'ZSTD_error_corruption_detected' || code === 'Z_BUF_ERROR';
113
+ }
114
+
115
+ /**
116
+ * 非预期异常先**留痕**再按读口语义降级(P8 评审:`load` 只重抛 PyTypeError、`resolve_media`
117
+ * 裸 catch,会让「接线错了」(如把 async fetcher 塞给同步实现)静默变成 missing/404)。
118
+ * 降级行为不变——只是不再无声:意外异常一律 `console.error('[sessparse] …')`。
119
+ */
120
+ function noteDegrade(where, error) {
121
+ if (isExpectedDegrade(error)) return;
122
+ const name = (error && error.name) || 'Error';
123
+ const msg = (error && error.message) || String(error);
124
+ console.error(`[sessparse] ${where} 非预期异常,按读口语义降级:${name}: ${msg}`);
125
+ }
126
+
127
+ /**
128
+ * Python 真值语义:`""`/`0`/`None`/`False`/空容器为假。
129
+ * JS 的 `[]` 与 `{}` 是 truthy,直接用 `Boolean()` 会与 Python 分歧(如 `bool(msg.get("isError"))`)。
130
+ */
131
+ function pyTruthy(v) {
132
+ if (Array.isArray(v)) return v.length > 0;
133
+ if (v !== null && typeof v === 'object') return Object.keys(v).length > 0;
134
+ return Boolean(v);
135
+ }
136
+
137
+ /** Python `x or y`(真值口径见 `pyTruthy`)。 */
138
+ function pyOr(v, fallback) {
139
+ return pyTruthy(v) ? v : fallback;
140
+ }
141
+
142
+ /** Python 的空白字符集(`str.isspace()` 为真的码点;与 JS `trim()` 的差异见模块头)。 */
143
+ const PY_WS = '\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000';
144
+ const PY_STRIP_RE = new RegExp(`^[${PY_WS}]+|[${PY_WS}]+$`, 'g');
145
+
146
+ /**
147
+ * Python `str.strip()`(无参):只按 Python 的空白集剥两端。
148
+ * 导出仅供单测/对拍(Node 独有助手,非 Python 同名函数):差集是 `\x1c-\x1f`/`\x85`
149
+ * 只 Python 剥、`\ufeff` 只 JS `trim()` 剥 —— 会话分析全靠它(`_parse_dsh`/`_dsh_header`/首问)。
150
+ */
151
+ export function pyStrip(text) {
152
+ return String(text).replace(PY_STRIP_RE, '');
153
+ }
154
+
155
+ /** 该 JSON 值是不是「普通对象」(Python 的 dict;数组/字符串/null/数字都不是)。 */
156
+ function isPlainObject(v) {
157
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
158
+ }
159
+
160
+ /**
161
+ * Python `dict.get(key, default)`:obj 必须是 dict,否则抛异常。
162
+ *
163
+ * 为什么较真:会话存储是**外部输入**,`data.get("source")` 的 `source` 若是个字符串,
164
+ * Python 会抛 AttributeError 一路冒到读口(`load` 的 except 只吞 OSError/ValueError),
165
+ * 而 JS 里 `"abc".source` 只是 undefined ⇒ 静默继续。让 `dget` 在非 dict 上抛
166
+ * `PyTypeError`,就把「坏输入」的表现对齐了。JSON 解析结果里不存在 undefined 值,
167
+ * 故「键缺失」⟺ Python 的 None。
168
+ */
169
+ function dget(obj, key, fallback = null) {
170
+ if (!isPlainObject(obj)) throw new PyTypeError(`对非 dict 取键 "${key}"`);
171
+ const v = obj[key];
172
+ return v === undefined ? fallback : v;
173
+ }
174
+
175
+ /** Python `(x or {})`:假值→`{}`;真值但非 dict → 与随后 `.get` 同样炸。 */
176
+ function orDict(v) {
177
+ if (!pyTruthy(v)) return {};
178
+ if (!isPlainObject(v)) throw new PyTypeError('期望 dict,实际是 ' + (Array.isArray(v) ? 'list' : typeof v));
179
+ return v;
180
+ }
181
+
182
+ /**
183
+ * Python `str()`(仅覆盖 JSON 里会出现的类型):
184
+ * str→原样;bool→`True`/`False`;null→`None`;数字→十进制文本;容器→JSON 文本兜底
185
+ * (Python 的 `str(dict)` 是 repr 形态,这里不逐字复刻——真会话里标题/媒体类型都是字符串)。
186
+ */
187
+ function pyStr(v) {
188
+ if (typeof v === 'string') return v;
189
+ if (v === null || v === undefined) return 'None';
190
+ if (typeof v === 'boolean') return v ? 'True' : 'False';
191
+ if (typeof v === 'number') return String(v);
192
+ return JSON.stringify(v);
193
+ }
194
+
195
+ /** Python `int()` 的字符串分支:允许两端空白、正负号、下划线分隔(`int("1_0")==10`)。 */
196
+ const PY_INT_STR_RE = /^[+-]?\d(?:_?\d)*$/;
197
+
198
+ /**
199
+ * Python `int(v)`:
200
+ * number → 截断取整(`int(1.9)==1`、`int(-1.9)==-1`);bool → 0/1;
201
+ * str → 按整数解析(非法串抛 PyValueError,对应 Python 的 ValueError);
202
+ * 容器 → PyTypeError(对应 Python 的 TypeError)。
203
+ */
204
+ function pyInt(v) {
205
+ if (typeof v === 'number') {
206
+ if (!Number.isFinite(v)) throw new PyValueError(`int() 不接受 ${v}`);
207
+ return Math.trunc(v);
208
+ }
209
+ if (typeof v === 'boolean') return v ? 1 : 0;
210
+ if (typeof v === 'string') {
211
+ const s = pyStrip(v);
212
+ if (!PY_INT_STR_RE.test(s)) throw new PyValueError(`invalid literal for int(): ${JSON.stringify(v)}`);
213
+ return Number(s.replaceAll('_', ''));
214
+ }
215
+ throw new PyTypeError(`int() 不接受 ${isPlainObject(v) || Array.isArray(v) ? 'dict/list' : typeof v}`);
216
+ }
217
+
218
+ /**
219
+ * Python `for x in v` 的迭代口径(JSON 值):
220
+ * 数组→元素;字符串→逐字符;对象→键(字符串);数字/布尔/null → TypeError(不可迭代)。
221
+ * JS 直接 `for…of` 一个普通对象会抛「not iterable」,而 Python 会静默迭代键,故显式对齐。
222
+ */
223
+ function pyIter(v) {
224
+ if (Array.isArray(v)) return v;
225
+ if (typeof v === 'string') return v;
226
+ if (isPlainObject(v)) return Object.keys(v);
227
+ throw new PyTypeError(`${typeof v} 不可迭代`);
228
+ }
229
+
230
+ /** Python `"\n".join(x for x in parts if x)`:只保留真值元素;非字符串元素抛 TypeError。 */
231
+ function joinTruthy(parts, sep = '\n') {
232
+ const kept = [];
233
+ for (const p of parts) {
234
+ if (!pyTruthy(p)) continue;
235
+ if (typeof p !== 'string') throw new PyTypeError('join 的元素不是 str');
236
+ kept.push(p);
237
+ }
238
+ return kept.join(sep);
239
+ }
240
+
241
+ /** Python `len(str)`:**码点**数(JS `.length` 是 UTF-16 码元数,遇代理对会多算)。 */
242
+ function cpLen(text) {
243
+ if (typeof text !== 'string') throw new PyTypeError('len() 只接受 str');
244
+ let n = 0;
245
+ for (let i = 0; i < text.length; i += 1) {
246
+ const code = text.charCodeAt(i);
247
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length
248
+ && (text.charCodeAt(i + 1) & 0xfc00) === 0xdc00) i += 1;
249
+ n += 1;
250
+ }
251
+ return n;
252
+ }
253
+
254
+ /** Python str 比较(码点序;JS 的 `<` 是码元序,仅非 BMP 字符上有别)。 */
255
+ function pyStrCompare(a, b) {
256
+ const ia = a[Symbol.iterator]();
257
+ const ib = b[Symbol.iterator]();
258
+ for (;;) {
259
+ const x = ia.next();
260
+ const y = ib.next();
261
+ if (x.done && y.done) return 0;
262
+ if (x.done) return -1;
263
+ if (y.done) return 1;
264
+ const ca = x.value.codePointAt(0);
265
+ const cb = y.value.codePointAt(0);
266
+ if (ca !== cb) return ca < cb ? -1 : 1;
267
+ }
268
+ }
269
+
270
+ /** Python `os.stat` 的 stamp 载体:`(st_mtime_ns, st_size)`(bigint,避免 2^53 精度问题)。 */
271
+ function statStamp(p) {
272
+ const st = fs.statSync(p, { bigint: true });
273
+ return { ns: st.mtimeNs, size: st.size };
274
+ }
275
+
276
+ /** 两个 stamp 是否相等(Python:元组 `==`)。 */
277
+ function sameStamp(a, b) {
278
+ return a.ns === b.ns && a.size === b.size;
279
+ }
280
+
281
+ /**
282
+ * Python `os.path.getmtime()` 的**逐位等价**实现:照 CPython `_PyTime_AsSecondsDouble` 的
283
+ * 口径 `double(秒) + double(纳秒) * 1e-9`。
284
+ *
285
+ * 为什么不用更短的 `stat.mtimeMs / 1000`(2026-10-11 实测,Node 22.19 与 24.20 同结论):
286
+ * 400 个**静止**真会话文件上,本公式与 Python `st_mtime` **400/400 逐位相等**,而
287
+ * `mtimeMs / 1000` 是 **0/400**(mtimeMs 是整毫秒,亚毫秒部分直接丢)。`mtime` 会经
288
+ * `list_sessions` 下发给看板并**参与跨语言对拍**,故保留位级口径。
289
+ *
290
+ * 边界语义只要求**同一侧自洽**:`live_session_id` 的 `mtime > since`、缓存 stamp 的
291
+ * 「变没变」判定都不依赖跨语言位级相等;需要跨语言比边界时留 ≥1ms 余量即可。
292
+ *
293
+ * ⚠️ 造样本时的坑:**Node 22.19 的 `fs.utimesSync` 只到微秒**(`utimesSync(p, 1.000000001)`
294
+ * 落盘成 `1000000000n`,Node 24 保留 `1000000001n`)——纳秒样本要用自然写入(内核给 ns)。
295
+ */
296
+ function statMtime(p) {
297
+ const st = fs.statSync(p, { bigint: true });
298
+ return Number(st.mtimeNs / 1000000000n) + Number(st.mtimeNs % 1000000000n) * 1e-9;
299
+ }
300
+
301
+ // ---------------------------------------------------------------- 小工具(同 Python 段)
302
+
303
+ /** 超长文本截断(返回截断后的文本)。 */
304
+ export function _trunc(text, limit) {
305
+ if (typeof text !== 'string') throw new PyTypeError('_trunc 只接受 str');
306
+ if (text.length <= limit) return text; // UTF-16 长度 ≤ limit ⇒ 码点数必然 ≤ limit
307
+ // 按**码点**数走到 limit(Python `text[:limit]` 是码点切片;JS slice 是码元切片)
308
+ let count = 0;
309
+ let i = 0;
310
+ while (i < text.length && count < limit) {
311
+ const code = text.charCodeAt(i);
312
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length
313
+ && (text.charCodeAt(i + 1) & 0xfc00) === 0xdc00) i += 1;
314
+ i += 1;
315
+ count += 1;
316
+ }
317
+ return i >= text.length ? text : text.slice(0, i);
318
+ }
319
+
320
+ /**
321
+ * JSON 文本 → 对象或 null(非法 JSON / 非对象一律 null)。
322
+ *
323
+ * JSONL 行容错统一入口:会话存储里可能出现合法 JSON 但非对象的行(数字/字符串/数组),
324
+ * 直接取键会炸掉整次解析。
325
+ */
326
+ export function _obj(raw) {
327
+ let d;
328
+ try {
329
+ d = JSON.parse(raw);
330
+ } catch {
331
+ return null;
332
+ }
333
+ return isPlainObject(d) ? d : null;
334
+ }
335
+
336
+ /**
337
+ * JSON 值 → 非负整数,其他(bool / 字符串 / 负数 / None / 小数)一律 null。
338
+ *
339
+ * token 计数与窗口大小都必须是干净的整数。注:Python 侧 `1.0` 是 float 不算整数,
340
+ * JS 无法区分 `1.0`/`1`(模块头已记录的已知差异)。
341
+ */
342
+ export function _nonneg_int(v) {
343
+ return typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null;
344
+ }
345
+
346
+ /**
347
+ * dsh `TokenUsage` → 本次请求的 **prompt 侧 token 数**(上下文占用分子)。
348
+ *
349
+ * 口径照抄 dsh `dsh-token-meter` 的 `pressureFrom`:
350
+ * `inputTokens + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0)`=「这一次请求送进模型的
351
+ * 上下文有多大」。**不能**用 totalTokens:它含本次回复的输出 token,环会随回复长度越画越大。
352
+ * inputTokens 缺失(老格式)退回 `totalTokens - outputTokens`;再取不到返回 null
353
+ * (调用方保留上一槽位值)。
354
+ */
355
+ export function _dsh_prompt_tokens(usage) {
356
+ if (!isPlainObject(usage)) return null;
357
+ const inp = _nonneg_int(usage.inputTokens);
358
+ if (inp !== null) {
359
+ return inp + (_nonneg_int(usage.cacheReadTokens) || 0) + (_nonneg_int(usage.cacheWriteTokens) || 0);
360
+ }
361
+ const total = _nonneg_int(usage.totalTokens);
362
+ const out = _nonneg_int(usage.outputTokens);
363
+ if (total !== null && out !== null && total >= out) return total - out;
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * entry 收集器:自动分配 seq 并累计 totals(Python `class _Entries`)。
369
+ * `ctx` 两槽位与 `dshAnchors` 的语义见 Python 同名类。
370
+ */
371
+ export function _Entries() {
372
+ return {
373
+ entries: [],
374
+ // token/耗时汇总(API 契约字段):dsh 事件流无 usage 汇总事件,恒为 0
375
+ totals: { input: 0, output: 0, cache_read: 0, duration_ms: 0 },
376
+ // 上下文占用两槽位(两个**独立 last-wins** 槽位):used=最新一次 usage 的 prompt 侧
377
+ // token 数,max=最新 `request/context` 的 contextWindow;null=该槽位尚未出现过。
378
+ ctx: { used: null, max: null },
379
+ // dsh 回退锚点(仅 _parse_dsh 填充):[(mid, eseq, text), …]
380
+ dshAnchors: [],
381
+ };
382
+ }
383
+
384
+ /** Python `_Entries.add()`:seq 自增 + time 取整 + 追加自定义字段。 */
385
+ function addEntry(col, kind, timeMs = 0, kw = {}) {
386
+ col.entries.push({ seq: col.entries.length, kind, time: pyInt(pyOr(timeMs, 0)), ...kw });
387
+ }
388
+
389
+ // ---------------------------------------------------------------- dsh 解析
390
+
391
+ /**
392
+ * 解压 dsh 会话文件(多帧 zstd),返回拼接的 JSONL 文本;完全不可读返回 ''。
393
+ *
394
+ * 容错分两跳,合起来与 Python `_dsh_decompressed` 的逐帧 `decompressobj()` **逐字节等价**:
395
+ * 半帧收尾(「坏帧里已完整解出的块内容」)由 `zstd-frames.js` 统一负责:
396
+ * `decompressSessionFile()` = 逐帧解压 + 坏帧处 `decompressFramePartial`
397
+ * (`finishFlush: Z_NO_FLUSH`,与 Python `decompressobj()` 的部分输出逐字节同值)。
398
+ * 纯垃圾 / 坏校验和取不到内容 ⇒ 与 Python 抛 `ZstdError` 时「本次结果整段丢弃」同效。
399
+ *
400
+ * 真机与手工样本实测(tools/parity/sess-run.mjs 第四节):
401
+ * 尾部垃圾 / 尾部半截帧 / 帧内截断 / 坏校验和帧 / 中间帧损坏 / skippable 帧 —— 六类样本
402
+ * 的 sha256 与 Python 全部相等。
403
+ */
404
+ export function _dsh_decompressed(p) {
405
+ let buf;
406
+ try {
407
+ buf = fs.readFileSync(p);
408
+ } catch {
409
+ return '';
410
+ }
411
+ // 坏帧处理(含「半帧里已解出的块内容」)已上移到 `zstd-frames.decompressSessionFile`
412
+ // ——Python 语义属于「解压循环」那一层,放这里会让每个调用方各写一跳(2026-10-11 收口)。
413
+ return decompressSessionFile(buf);
414
+ }
415
+
416
+ /** 会话事件流文件名(glob `session*.jsonl.zstd`;`[\s\S]` 对应 fnmatch 的 `.` 可跨行)。 */
417
+ const SESSION_FILE_RE = /^session[\s\S]*\.jsonl\.zstd$/;
418
+
419
+ /** 在一个会话目录里挑事件流文件:`session*.jsonl.zstd` 中版本号最大者,无则 null。 */
420
+ export function _dsh_pick_file(sdir) {
421
+ let names;
422
+ try {
423
+ names = fs.readdirSync(sdir); // Python glob:目录不存在/不是目录 → 空
424
+ } catch {
425
+ return null;
426
+ }
427
+ const hits = [];
428
+ for (const name of names) {
429
+ if (name.startsWith('.')) continue; // Python glob 的 `*` 不匹配隐藏项
430
+ if (!SESSION_FILE_RE.test(name)) continue;
431
+ const p = path.join(sdir, name);
432
+ hits.push([_dsh_format_version(p), p]);
433
+ }
434
+ if (!hits.length) return null;
435
+ // Python `max([(版本, 路径)])`:先比版本号,再比路径(str 比较=码点序)
436
+ let best = hits[0];
437
+ for (const hit of hits) {
438
+ if (hit[0] > best[0] || (hit[0] === best[0] && pyStrCompare(hit[1], best[1]) > 0)) best = hit;
439
+ }
440
+ return best[1]; // 版本号大者优先(同目录一般只有一个)
441
+ }
442
+
443
+ /**
444
+ * 按会话 id 跨工作区 bucket 定位 zstd 会话文件(返回路径或 null)。
445
+ *
446
+ * id 形态两代并存:`session-<uuid>` 与裸 `<uuid>`(绝大多数是子代理会话)。
447
+ * 文件名也带格式版本(`session.v4.jsonl.zstd` / `.v3` / 无版本)——**只认无版本名会让
448
+ * v3/v4 会话全部 found=false**,故按通配匹配、版本号最大者优先。
449
+ */
450
+ export function _dsh_session_file(sid) {
451
+ if (!SID_DSH_RE.test(sid || '')) return null;
452
+ let buckets;
453
+ try {
454
+ buckets = fs.readdirSync(DSH_SESSIONS); // Python glob:根不存在 → 空
455
+ } catch {
456
+ return null;
457
+ }
458
+ const hits = [];
459
+ for (const bucket of buckets) {
460
+ if (bucket.startsWith('.')) continue;
461
+ const sdir = path.join(DSH_SESSIONS, bucket, sid);
462
+ let names;
463
+ try {
464
+ names = fs.readdirSync(sdir);
465
+ } catch {
466
+ continue; // 不是目录/不存在:glob 不产出该分支
467
+ }
468
+ for (const name of names) {
469
+ if (name.startsWith('.')) continue;
470
+ if (!SESSION_FILE_RE.test(name)) continue;
471
+ const p = path.join(sdir, name);
472
+ hits.push([_dsh_format_version(p), p]);
473
+ }
474
+ }
475
+ if (!hits.length) return null;
476
+ let best = hits[0];
477
+ for (const hit of hits) {
478
+ if (hit[0] > best[0] || (hit[0] === best[0] && pyStrCompare(hit[1], best[1]) > 0)) best = hit;
479
+ }
480
+ return best[1];
481
+ }
482
+
483
+ /** 从会话文件名解析格式版本(无版本号记 0,保证「有版本 > 无版本」)。 */
484
+ export function _dsh_format_version(p) {
485
+ const m = /session\.v(\d+)\.jsonl\.zstd$/.exec(path.basename(p));
486
+ return m ? Number(m[1]) : 0;
487
+ }
488
+
489
+ // dsh 会话**头行**缓存:Map<path, {stamp, header}>。头行承载 origin/delegationDepth/cwd,
490
+ // 是判「子代理会话」的唯一可靠依据(目录名不是判据,见 is_subagent)。
491
+ const _DSH_HEADER_CACHE = new Map();
492
+ const _DSH_HEADER_FRAMES = 4; // 头行最多向前找几帧(真机 304 个会话全在第 1 帧第 1 行)
493
+
494
+ /**
495
+ * 读 dsh 会话**头行**(`{"type":"session","id":…,"origin":…,"delegationDepth":…}`),
496
+ * 读不到/无头行返回 {}(老样本与异常一律降级为空)。
497
+ *
498
+ * 只解前 `_DSH_HEADER_FRAMES` 帧即停(头行就是第 1 帧第 1 行)——枚举 300+ 会话时
499
+ * 不该付整段解压的代价。这里直接调 `zstd-frames.frameEnd` 定位单帧后逐帧解压,
500
+ * 与 Python 的 `decompressobj()` 逐帧语义一致(不能用 `decompressFrames`:那会解全量)。
501
+ * 按 (mtime_ns, size) 缓存:会话只追加,stamp 变即失效重读。
502
+ */
503
+ export function _dsh_header(p) {
504
+ let stamp;
505
+ try {
506
+ stamp = statStamp(p);
507
+ } catch {
508
+ return {};
509
+ }
510
+ const hit = _DSH_HEADER_CACHE.get(p);
511
+ if (hit && sameStamp(hit.stamp, stamp)) return hit.header;
512
+ let rest;
513
+ try {
514
+ rest = fs.readFileSync(p);
515
+ } catch {
516
+ return {};
517
+ }
518
+ let header = {};
519
+ for (let i = 0; i < _DSH_HEADER_FRAMES; i += 1) {
520
+ if (!rest.length) break;
521
+ let text = '';
522
+ let end = null;
523
+ try {
524
+ end = frameEnd(rest, 0);
525
+ text = zstdDecompressSync(rest.subarray(0, end)).toString('utf8');
526
+ } catch {
527
+ // 半截帧/坏帧:Python 的 `decompressobj().decompress()` 在截断处**不抛错**,返回已解出的
528
+ // 部分内容(头行若在其中就能读到)。故这里也尽力取一部分;真取不到才算「未知」(=主会话)。
529
+ text = decompressFramePartial(end === null ? rest : rest.subarray(0, end)).toString('utf8');
530
+ if (!text) break;
531
+ }
532
+ if (end === null) {
533
+ // 半帧之后没有「下一帧」可续:解析完这一跳即收工
534
+ for (const rawLine of text.split('\n')) {
535
+ const line = pyStrip(rawLine);
536
+ if (!line) continue;
537
+ const d = _obj(line);
538
+ if (d !== null && dget(d, 'type') === 'session') { header = d; break; }
539
+ }
540
+ break;
541
+ }
542
+ rest = rest.subarray(end); // 余量即下一帧(逐帧解压,同 _dsh_decompressed)
543
+ for (const rawLine of text.split('\n')) {
544
+ const line = pyStrip(rawLine);
545
+ if (!line) continue;
546
+ const d = _obj(line);
547
+ if (d !== null && dget(d, 'type') === 'session') {
548
+ header = d;
549
+ break;
550
+ }
551
+ }
552
+ if (pyTruthy(header)) break;
553
+ }
554
+ _DSH_HEADER_CACHE.set(p, { stamp, header });
555
+ return header;
556
+ }
557
+
558
+ /**
559
+ * 头行 → 是否子代理会话:`origin=subagent` 为主判据,`delegationDepth>0` 兜底。
560
+ *
561
+ * 两个判据都留,是防 dsh 后续只写其一(真机实测两集合严格互斥)。
562
+ */
563
+ export function _header_subagent(header) {
564
+ if (pyStr(pyOr(dget(header, 'origin'), '')) === 'subagent') return true;
565
+ try {
566
+ return pyInt(pyOr(dget(header, 'delegationDepth'), 0)) > 0;
567
+ } catch (error) {
568
+ if (error instanceof PyTypeError || error instanceof PyValueError) return false;
569
+ throw error;
570
+ }
571
+ }
572
+
573
+ /**
574
+ * 该会话是否 dsh **子代理会话**(主会话派生的调研/审计子代理,非用户会话)。
575
+ *
576
+ * 子代理会话与主会话同 bucket、同格式,只能靠头行区分(**目录名不是判据**——真机裸 uuid
577
+ * 目录 82 个里 73 个是子代理,另 9 个是老主会话)。族不在白名单 / 存储不存在 / 头行缺失
578
+ * 一律 false(未知按主会话处理——占用判定宁多等不可误放行)。
579
+ */
580
+ export function is_subagent(family, sid) {
581
+ if (!FAMILIES.includes(family)) return false;
582
+ let p;
583
+ try {
584
+ p = _dsh_session_file(sid);
585
+ } catch (error) {
586
+ noteDegrade('is_subagent', error);
587
+ return false;
588
+ }
589
+ return p ? _header_subagent(_dsh_header(p)) : false;
590
+ }
591
+
592
+ /**
593
+ * 工作区路径 → dsh 会话 bucket 目录。
594
+ * 实测命名:路径斜杠换 '-',首尾各补 '-',如 /srv/myproj → --srv-myproj--。
595
+ */
596
+ export function dsh_bucket(cwd) {
597
+ return path.join(DSH_SESSIONS, `-${(cwd || '').replace(/\/+$/, '').replaceAll('/', '-')}--`);
598
+ }
599
+
600
+ /**
601
+ * 取工作区 bucket 下最新的会话 id(按事件流文件 mtime 的启发式)。
602
+ * 目录名两代并存(session-<uuid> / 裸 <uuid>),故按 `*` 通配 + 事件流文件判存在。
603
+ */
604
+ export function dsh_latest_session(cwd) {
605
+ let names;
606
+ try {
607
+ names = fs.readdirSync(dsh_bucket(cwd));
608
+ } catch {
609
+ return '';
610
+ }
611
+ let best = '';
612
+ let bestMt = 0.0;
613
+ for (const name of names) {
614
+ if (name.startsWith('.')) continue;
615
+ const zfile = _dsh_pick_file(path.join(dsh_bucket(cwd), name));
616
+ if (!zfile) continue;
617
+ let mt;
618
+ try {
619
+ mt = statMtime(zfile);
620
+ } catch {
621
+ continue;
622
+ }
623
+ if (mt > bestMt) {
624
+ best = name;
625
+ bestMt = mt;
626
+ }
627
+ }
628
+ return best;
629
+ }
630
+
631
+ // path -> {stamp, title, prompt, titles}:当前标题、首问与全部标题事件同源同缓存——
632
+ // 三者都要全量解压会话文件,一次扫描一起取(30s 同步节拍里同一文件只解压一次)。
633
+ const _DSH_TITLE_CACHE = new Map();
634
+
635
+ /**
636
+ * 取 text 中 idx 所在的那一行(不含换行符)。
637
+ *
638
+ * find 定位 + 切一行,避免为找一处事件把上千行文本 split 成全量列表。
639
+ */
640
+ export function _line_at(text, idx) {
641
+ const start = text.lastIndexOf('\n', idx - 1) + 1; // Python rfind("\n", 0, idx)
642
+ const end = text.indexOf('\n', idx);
643
+ return end < 0 ? text.slice(start) : text.slice(start, end);
644
+ }
645
+
646
+ /**
647
+ * 全部 `session/title` 事件的标题,按文件顺序(无事件返回 [])。
648
+ *
649
+ * **末枚即 DSH 当前标题**(dsh 对同一会话会写多枚:先 fallback 截断句、再由 LLM 标题覆盖、
650
+ * 用户显式改名再追加;GUI 侧栏显示 last-wins 的那一枚)。平台侧一律取同一口径;
651
+ * `list_sessions` 另把整串下发给看板,用于判定「卡面标题是否仍是平台自动写入的形态」。
652
+ *
653
+ * 逐次 find + **整行 JSON 校验**:非事件行里出现同名字符串(坏行/别处文本)一律跳过;
654
+ * 空标题事件跳过(dsh 不会写空标题,真出现时按「不表态」处理)。
655
+ */
656
+ export function _dsh_scan_titles(text) {
657
+ const out = [];
658
+ let pos = 0;
659
+ for (;;) {
660
+ const idx = text.indexOf('"session/title"', pos);
661
+ if (idx < 0) return out;
662
+ pos = idx + 1;
663
+ const d = _obj(_line_at(text, idx));
664
+ if (d === null || dget(d, 'type') !== 'session/title') continue;
665
+ const title = pyStr(pyOr(dget(orDict(dget(d, 'data')), 'title'), ''));
666
+ if (title) out.push(title);
667
+ }
668
+ }
669
+
670
+ /**
671
+ * 主会话第一条真实用户提问的原文(可多行;无返回 '')。
672
+ *
673
+ * 判定与会话窗口的 user entry 同口径:`user/message` 且 `source.kind == "user"`
674
+ * (系统注入的 plugin / agent-instructions 消息被过滤);文本块按换行拼接、**原样保留
675
+ * 内部换行**。只有图片没有文本的提问返回 `[图片]`。「第一条」按**文件顺序**取。
676
+ */
677
+ export function _dsh_scan_prompt(text) {
678
+ let pos = 0;
679
+ for (;;) {
680
+ const idx = text.indexOf('"user/message"', pos);
681
+ if (idx < 0) return '';
682
+ pos = idx + 1;
683
+ const d = _obj(_line_at(text, idx));
684
+ if (d === null || dget(d, 'type') !== 'user/message') continue; // 别处出现同名字符串:跳过
685
+ const data = orDict(dget(d, 'data'));
686
+ if (dget(orDict(dget(data, 'source')), 'kind', '') !== 'user') continue; // 系统注入消息
687
+ const blocks = [];
688
+ for (const b of pyIter(pyOr(dget(data, 'content'), []))) {
689
+ if (isPlainObject(b)) blocks.push(b);
690
+ }
691
+ const texts = [];
692
+ for (const b of blocks) {
693
+ if (dget(b, 'type') === 'text') texts.push(dget(b, 'text', ''));
694
+ }
695
+ const body = pyStrip(joinTruthy(texts, '\n'));
696
+ if (body) return body;
697
+ if (blocks.some((b) => dget(b, 'type') === 'image')) return '[图片]';
698
+ // 既无文本也无图片:不是有效提问
699
+ }
700
+ }
701
+
702
+ /**
703
+ * 一次解压取出 {title, prompt, titles},按文件 stamp 缓存。
704
+ *
705
+ * - title = `titles` 的**末枚**(last-wins = dsh GUI 侧栏显示的标题);
706
+ * - prompt = 看板 sync 卡描述(首问全文)的数据源;
707
+ * - titles(全部标题事件)= 看板 sync 卡据此判定「卡面标题是否仍是平台自动写入的形态」。
708
+ */
709
+ export function _dsh_meta(p) {
710
+ let stamp;
711
+ try {
712
+ stamp = statStamp(p);
713
+ } catch {
714
+ return { title: '', prompt: '', titles: [] };
715
+ }
716
+ const hit = _DSH_TITLE_CACHE.get(p);
717
+ if (hit && sameStamp(hit.stamp, stamp)) {
718
+ return { title: hit.title, prompt: hit.prompt, titles: hit.titles };
719
+ }
720
+ const text = _dsh_decompressed(p);
721
+ const titles = _dsh_scan_titles(text);
722
+ const title = titles.length ? titles[titles.length - 1] : '';
723
+ const prompt = _dsh_scan_prompt(text);
724
+ _DSH_TITLE_CACHE.set(p, { stamp, title, prompt, titles });
725
+ return { title, prompt, titles };
726
+ }
727
+
728
+ /** 读取 dsh 会话当前标题(最后一枚 session/title 事件),无/不可读返回 ''。 */
729
+ export function dsh_title(sid) {
730
+ const p = _dsh_session_file(sid);
731
+ return p ? _dsh_meta(p).title : '';
732
+ }
733
+
734
+ /** 读取主会话第一条真实用户提问的原文(可多行),无/不可读返回 ''。 */
735
+ export function dsh_first_prompt(sid) {
736
+ const p = _dsh_session_file(sid);
737
+ return p ? _dsh_meta(p).prompt : '';
738
+ }
739
+
740
+ /**
741
+ * 解析 dsh 会话(解压后的 JSONL 事件流)。
742
+ *
743
+ * 关注事件:user/message(仅 source.kind=user 的真实输入,过滤系统注入)、
744
+ * assistant/message(content 块 reasoning/text)、tool/call、tool/result。
745
+ * 无 usage 汇总事件,totals 记 0;ctx 两槽位见 `_dsh_prompt_tokens` 与 `request/context`。
746
+ */
747
+ export function _parse_dsh(p) {
748
+ const col = _Entries();
749
+ const toolNames = new Map(); // callId -> 工具名(回填 tool_result)
750
+ for (const rawLine of _dsh_decompressed(p).split('\n')) {
751
+ const line = pyStrip(rawLine);
752
+ if (!line) continue;
753
+ const d = _obj(line);
754
+ if (d === null) continue;
755
+ const t = dget(d, 'type');
756
+ const tm = pyInt(pyOr(dget(d, 'time'), 0));
757
+ const data = orDict(dget(d, 'data'));
758
+ if (t === 'user/message') {
759
+ if (dget(orDict(dget(data, 'source')), 'kind', '') !== 'user') continue;
760
+ const texts = [];
761
+ const images = [];
762
+ for (const b of pyIter(pyOr(dget(data, 'content'), []))) {
763
+ if (!isPlainObject(b)) continue;
764
+ if (dget(b, 'type') === 'text') {
765
+ texts.push(dget(b, 'text', ''));
766
+ } else if (dget(b, 'type') === 'image') {
767
+ // 图片附件:只记 attachmentId(字节由宿主 attachment 服务持有,平台经驱动 /media 取)
768
+ const aid = pyStr(pyOr(dget(orDict(dget(b, 'attachment')), 'attachmentId'), ''));
769
+ if (aid) images.push({ media: aid });
770
+ }
771
+ }
772
+ const text = pyStrip(joinTruthy(texts, '\n'));
773
+ if (text || images.length) {
774
+ // ese=会话事件 seq(dsh 每条事件自带单调 seq),mid 用 `e<ese>` 合成:
775
+ // 会话窗的「回退」按钮需要 mid 才显示,回退时再用 mid 换算 fork 边界
776
+ const ese = pyInt(pyOr(dget(d, 'seq'), 0));
777
+ const mid = `e${ese}`;
778
+ col.dshAnchors.push([mid, ese, text || '[图片]']);
779
+ addEntry(col, 'user', tm, { text, images, mid, eseq: ese });
780
+ }
781
+ } else if (t === 'assistant/message') {
782
+ for (const b of pyIter(pyOr(dget(orDict(dget(data, 'message')), 'content'), []))) {
783
+ if (!isPlainObject(b)) continue;
784
+ if (dget(b, 'type') === 'reasoning') addEntry(col, 'think', tm, { text: dget(b, 'text', '') });
785
+ else if (dget(b, 'type') === 'text') addEntry(col, 'assistant', tm, { text: dget(b, 'text', '') });
786
+ }
787
+ // 上下文占用分子:每次模型调用结算的 prompt 侧 token 数,last-wins
788
+ // (无 usage 的中断消息不参与,槽位保留上一次可用的值)
789
+ const used = _dsh_prompt_tokens(dget(data, 'usage'));
790
+ if (used !== null) col.ctx.used = used;
791
+ } else if (t === 'request/context') {
792
+ // 上下文占用分母:宿主为该路由解析出的上下文窗口(last-wins)。
793
+ // 0/缺失一律忽略——窗口必须为正才有意义。
794
+ const win = _nonneg_int(dget(data, 'contextWindow'));
795
+ if (win) col.ctx.max = win;
796
+ } else if (t === 'tool/call') {
797
+ const cid = dget(data, 'callId', '');
798
+ toolNames.set(cid, dget(data, 'name', ''));
799
+ addEntry(col, 'tool_call', tm, {
800
+ name: dget(data, 'name', ''),
801
+ args: _trunc(pyOr(dget(data, 'arguments', ''), ''), ARGS_MAX),
802
+ call_id: cid,
803
+ });
804
+ } else if (t === 'tool/result') {
805
+ const msg = orDict(dget(data, 'message'));
806
+ let cid = pyOr(dget(orDict(dget(msg, 'source')), 'callId', ''), dget(msg, 'toolCallId', ''));
807
+ const texts = [];
808
+ for (const b of pyIter(pyOr(dget(msg, 'content'), []))) {
809
+ if (!isPlainObject(b)) continue;
810
+ // v4 实况:message.content 直接是 [{type:"text",text:…}];
811
+ // 另兼容早期/迁移形态:再嵌一层 {type:"tool-result",content:[{text}]}
812
+ if (dget(b, 'type') === 'text') {
813
+ texts.push(dget(b, 'text', ''));
814
+ } else if (dget(b, 'type') === 'tool-result') {
815
+ cid = cid || dget(b, 'toolCallId', '');
816
+ for (const c of pyIter(pyOr(dget(b, 'content'), []))) {
817
+ if (isPlainObject(c) && dget(c, 'type') === 'text') texts.push(dget(c, 'text', ''));
818
+ }
819
+ }
820
+ }
821
+ const out = joinTruthy(texts, '\n');
822
+ addEntry(col, 'tool_result', tm, {
823
+ call_id: cid,
824
+ name: toolNames.has(cid) ? toolNames.get(cid) : '',
825
+ text: _trunc(out, OUT_MAX),
826
+ is_error: pyTruthy(dget(msg, 'isError')),
827
+ truncated: cpLen(out) > OUT_MAX,
828
+ });
829
+ }
830
+ }
831
+ return col;
832
+ }
833
+
834
+ // ------------------------------------------------ 回退边界 / 运行中会话探测
835
+
836
+ /**
837
+ * 把「回退到某条提问之前」换算成宿主 `session.fork` 的 `atSeq`。
838
+ *
839
+ * dsh **没有原地 undo**:等价实现是把该提问**之前**的历史复制成新会话。`atSeq` 是
840
+ * inclusive 的,故取该提问事件 seq 的**前一个**(`max(0, ese - 1)`)。
841
+ * 提问不在会话里/会话不可读/mid 为空 → null。
842
+ */
843
+ export function dsh_fork_boundary(sid, mid) {
844
+ if (!pyTruthy(mid)) return null;
845
+ const p = _dsh_session_file(sid);
846
+ if (p === null) return null;
847
+ let col;
848
+ try {
849
+ col = _parse_dsh(p);
850
+ } catch (error) {
851
+ if (error instanceof PyTypeError) throw error; // Python 只吞 OSError/ValueError
852
+ noteDegrade('dsh_fork_boundary', error);
853
+ return null;
854
+ }
855
+ for (const [anchorMid, ese] of col.dshAnchors) {
856
+ if (anchorMid === mid) return Math.max(0, pyInt(ese) - 1);
857
+ }
858
+ return null;
859
+ }
860
+
861
+ /** 该 dsh 会话的真实用户提问锚点 `[{mid, eseq, text}]`(测试与诊断用)。 */
862
+ export function dsh_user_anchors(sid) {
863
+ const p = _dsh_session_file(sid);
864
+ if (p === null) return [];
865
+ let col;
866
+ try {
867
+ col = _parse_dsh(p);
868
+ } catch (error) {
869
+ if (error instanceof PyTypeError) throw error;
870
+ noteDegrade('dsh_user_anchors', error);
871
+ return [];
872
+ }
873
+ return col.dshAnchors.map(([m, e, t]) => ({ mid: m, eseq: e, text: t }));
874
+ }
875
+
876
+ /**
877
+ * dsh 运行中探测:工作区 bucket 下 mtime 晚于 since 的最新会话目录。
878
+ * mtime 取事件流文件(目录 mtime 不随写入变化,且文件名带格式版本)。
879
+ */
880
+ export function _live_dsh(cwd, since) {
881
+ let names;
882
+ try {
883
+ names = fs.readdirSync(dsh_bucket(cwd));
884
+ } catch {
885
+ return '';
886
+ }
887
+ let best = '';
888
+ let bestMt = 0.0;
889
+ for (const name of names) {
890
+ if (name.startsWith('.')) continue;
891
+ const zfile = _dsh_pick_file(path.join(dsh_bucket(cwd), name));
892
+ if (!zfile) continue;
893
+ let mt;
894
+ try {
895
+ mt = statMtime(zfile);
896
+ } catch {
897
+ continue;
898
+ }
899
+ if (mt > since && mt > bestMt) {
900
+ best = name;
901
+ bestMt = mt;
902
+ }
903
+ }
904
+ return best;
905
+ }
906
+
907
+ /**
908
+ * 运行中会话探测:任务的 session_id 尚未入库时,按「工作区匹配 + 活动时间晚于 since」
909
+ * 猜出正在写的会话 id,供 session 窗口在任务运行期间实时展示对话。
910
+ *
911
+ * 结果只用于展示,绝不写回任务行。无匹配 / 存储不存在 / 参数无效 / 族不在白名单返回 ''。
912
+ */
913
+ export function live_session_id(family, cwd, since) {
914
+ cwd = (cwd || '').replace(/\/+$/, '');
915
+ if (!cwd || since <= 0 || !FAMILIES.includes(family)) return '';
916
+ try {
917
+ return _live_dsh(cwd, since);
918
+ } catch (error) {
919
+ if (error instanceof PyTypeError) throw error;
920
+ noteDegrade('live_session_id', error);
921
+ return '';
922
+ }
923
+ }
924
+
925
+ // ---------------------------------------------------------------- 统一入口
926
+
927
+ /**
928
+ * 加载并解析 dsh 会话,返回 messages 响应主体。
929
+ *
930
+ * 返回 `{found:true, agents, agent, entries, total, totals}` 或
931
+ * `{found:false, reason:"missing"|"unsupported"}`。entries 为 seq >= after 的增量;
932
+ * total 为全量条数(变小时客户端应重置重拉)。dsh 为单线会话(固定 main)。
933
+ * 另在**分母(上下文窗口)已知**时附带 `ctx`(会话窗底栏占用圈的数据源);窗口未知时**不带**该键。
934
+ */
935
+ export function load(family, sid, agent, after = 0) {
936
+ if (!FAMILIES.includes(family)) return { found: false, reason: 'unsupported' };
937
+ const agentName = pyOr(agent, 'main');
938
+ let zpath;
939
+ let agents;
940
+ let cache;
941
+ try {
942
+ zpath = _dsh_session_file(sid);
943
+ if (zpath === null) return { found: false, reason: 'missing' };
944
+ // dsh 为单线会话(无子 agent):agents 形状与旧单线族逐字段一致
945
+ agents = [{ id: 'main', type: 'main', parent: '' }];
946
+ const stamp = statStamp(zpath);
947
+ const byAgent = _CACHE.get(sid);
948
+ cache = byAgent ? byAgent.get(agentName) : undefined;
949
+ if (cache === undefined || !sameStamp(cache.stamp, stamp)) {
950
+ const col = _parse_dsh(zpath);
951
+ cache = { stamp, entries: col.entries, totals: col.totals, ctx: col.ctx };
952
+ if (byAgent) byAgent.set(agentName, cache);
953
+ else _CACHE.set(sid, new Map([[agentName, cache]]));
954
+ }
955
+ } catch (error) {
956
+ if (error instanceof PyTypeError) throw error; // Python 只吞 OSError/ValueError
957
+ noteDegrade('load', error);
958
+ return { found: false, reason: 'missing' };
959
+ }
960
+ const entries = cache.entries;
961
+ after = Math.max(0, pyInt(pyOr(after, 0)));
962
+ const out = {
963
+ found: true,
964
+ agents,
965
+ agent: agentName,
966
+ entries: entries.slice(after),
967
+ total: entries.length,
968
+ totals: cache.totals,
969
+ };
970
+ // 上下文占用:只在**分母已知**时下发;分子缺省 0(窗口已知但没有模型调用=空上下文)
971
+ if (cache.ctx.max) out.ctx = { used: cache.ctx.used || 0, max: cache.ctx.max };
972
+ return out;
973
+ }
974
+
975
+ /**
976
+ * 驱动 `/media` 取字节的函数(Node 侧注入口)。
977
+ *
978
+ * 注入的是 `backend/lib/dshdriver.js` 的 `media(attachmentId)` —— 它是 **async** 函数,
979
+ * 返回 `{content_type, bytes, data(base64)}`(`DshDriverError(404)` 表示附件不存在)。
980
+ * 本闸口接受同步或异步 fetcher:`resolve_media` 内部 `await`,两种都能接。
981
+ */
982
+ let mediaFetcher = null;
983
+
984
+ /** 注入媒体取字节函数(M3 接线时 `setMediaFetcher(dshdriver.media)`;单测可注入桩)。 */
985
+ export function setMediaFetcher(fetcher) {
986
+ mediaFetcher = fetcher;
987
+ }
988
+
989
+ /**
990
+ * base64 解码(Python `base64.b64decode(s)` 的容错口径:先丢弃字母表外字符,再查 padding)。
991
+ * JS `Buffer.from(s, 'base64')` 对坏串静默截断,会让「坏数据」看起来是「有数据」,
992
+ * 故这里显式把关,把 binascii.Error 折算成 PyValueError(同被 resolve_media 吞掉)。
993
+ */
994
+ function pyB64Decode(text) {
995
+ const cleaned = String(text).replace(/[^A-Za-z0-9+/=]/g, '');
996
+ if (cleaned.length % 4 !== 0) throw new PyValueError('Invalid base64-encoded string');
997
+ return Buffer.from(cleaned, 'base64');
998
+ }
999
+
1000
+ /**
1001
+ * 解析会话图片为 `[bytes, content_type]`;不存在或校验失败返回 null。**async**。
1002
+ *
1003
+ * dsh: media_id = sha256:<64hex>(宿主 attachment id)→ 驱动 `/media` 取字节;
1004
+ * 族不在白名单 / id 形态不符 / 驱动无数据 / 附件不存在一律 null,路由层据此落 404。
1005
+ *
1006
+ * ⚠️ **为什么是 async(M2 评审 P1,2026-10-11)**:Node 侧 `dshdriver.media()` 是 async,
1007
+ * 早期同步实现会把 Promise 当响应对象 ⇒ 每个附件**静默 404**,且被拒的 Promise 变成
1008
+ * unhandled rejection(Node 默认直接崩进程)。调用方必须 `await`;Python 侧同名函数是同步的,
1009
+ * 接线/对拍处 `await` 一层即可,判据不变。
1010
+ * 注:Python 返回 tuple,本版返回 `[Buffer, string]`(同一解构口径)。
1011
+ */
1012
+ export async function resolve_media(family, sid, agent, mediaId) {
1013
+ if (!FAMILIES.includes(family)) return null;
1014
+ try {
1015
+ if (!MEDIA_DSH_RE.test(mediaId || '')) return null;
1016
+ if (mediaFetcher === null) return null;
1017
+ const resp = await mediaFetcher(mediaId); // 同步 fetcher 直接返回对象;异步 fetcher 在此 await
1018
+ const data = pyB64Decode(pyOr(dget(resp, 'data'), ''));
1019
+ const ctype = pyStr(pyOr(dget(resp, 'content_type'), ''));
1020
+ return data.length && ctype ? [data, ctype] : null;
1021
+ } catch (error) {
1022
+ noteDegrade('resolve_media', error); // 附件不存在/坏 base64 静默;其它留痕
1023
+ return null;
1024
+ }
1025
+ }
1026
+
1027
+ /** 清空解析缓存(测试用)。注:与 Python 一致,只清 `_CACHE`,不动头行/标题/归档缓存。 */
1028
+ export function cache_clear() {
1029
+ _CACHE.clear();
1030
+ }
1031
+
1032
+ // -------------------------------------- 会话标题 / 按工作区枚举 / 存在性检查
1033
+ // 供看板「绑定已有会话」下拉与「session 自动同步」共用;均为增强能力,
1034
+ // 异常一律静默返回空('' / [] / false),不打断主流程。
1035
+
1036
+ export const LIST_SESSIONS_LIMIT = 50; // 按工作区枚举会话的条数上限(绑定下拉/自动同步共用)
1037
+
1038
+ // dsh 归档集读侧缓存:{key: {path, ns, size} | null, ids: Set}。
1039
+ // 按 (path, mtime_ns, size) 失效——宿主每次归档/取消归档都整表重写 workspace.json。
1040
+ const _ARCHIVE_CACHE = { key: null, ids: new Set() };
1041
+
1042
+ /**
1043
+ * 读 dsh 宿主归档集(`<dsh home>/storages/workspace.json` → `global.archivedSessionIds`)。
1044
+ *
1045
+ * 为什么读文件而不是问驱动:sessparse 是纯只读解析层,归档标记只服务展示/建卡归类,
1046
+ * 不该引入 HTTP 依赖与调用频次。文件缺失 / 坏 JSON / 无该键 / 无 global 段 → 空集
1047
+ * (= 全部未归档,即历史行为);解析结果按 (mtime_ns, size) 缓存。
1048
+ */
1049
+ export function _dsh_archived_ids() {
1050
+ const p = path.join(path.dirname(DSH_SESSIONS), 'storages', 'workspace.json');
1051
+ let st;
1052
+ try {
1053
+ st = fs.statSync(p, { bigint: true });
1054
+ } catch {
1055
+ return new Set();
1056
+ }
1057
+ const key = { path: p, ns: st.mtimeNs, size: st.size };
1058
+ if (_ARCHIVE_CACHE.key !== null && _ARCHIVE_CACHE.key.path === key.path
1059
+ && _ARCHIVE_CACHE.key.ns === key.ns && _ARCHIVE_CACHE.key.size === key.size) {
1060
+ return _ARCHIVE_CACHE.ids;
1061
+ }
1062
+ let ids = new Set();
1063
+ try {
1064
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
1065
+ let raw = null;
1066
+ if (isPlainObject(data)) {
1067
+ const glob = data.global;
1068
+ if (isPlainObject(glob)) raw = glob.archivedSessionIds === undefined ? null : glob.archivedSessionIds;
1069
+ if (raw === null) raw = data.archivedSessionIds === undefined ? null : data.archivedSessionIds; // 兼容顶层直挂
1070
+ }
1071
+ if (Array.isArray(raw)) {
1072
+ ids = new Set();
1073
+ for (const s of raw) {
1074
+ if (pyTruthy(s)) ids.add(pyStr(s));
1075
+ }
1076
+ }
1077
+ } catch {
1078
+ ids = new Set(); // 读不到/坏文件:按未归档降级(绝不上抛)
1079
+ }
1080
+ _ARCHIVE_CACHE.key = key;
1081
+ _ARCHIVE_CACHE.ids = ids;
1082
+ return ids;
1083
+ }
1084
+
1085
+ /**
1086
+ * 统一会话标题读取(异常静默返回 '',标题只是展示增强,不打断主流程)。
1087
+ * dsh 标题取会话**当前**标题(末枚 `session/title` 事件,与 dsh GUI 侧栏一致)。
1088
+ */
1089
+ export function session_title(family, sid) {
1090
+ if (!FAMILIES.includes(family)) return '';
1091
+ try {
1092
+ return dsh_title(sid);
1093
+ } catch (error) {
1094
+ noteDegrade('session_title', error);
1095
+ return '';
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * dsh:bucket 目录下枚举会话目录,mtime 取事件流文件;`archived` 取宿主归档集。
1101
+ *
1102
+ * 目录名两代并存、文件名带格式版本,故目录匹配与文件匹配都用通配。
1103
+ * **子代理会话不进列表**:判据取会话头行的 origin/delegationDepth(先判头行再解全量,
1104
+ * 省一次整段解压)。裸 uuid 目录**不代表**是子代理(真机 82 个里 73 个是子代理、
1105
+ * 9 个是老主会话)。归档只影响归档标记:会话目录仍在 bucket 里,列表照常枚举到它。
1106
+ */
1107
+ export function _list_dsh(cwd) {
1108
+ const archived = _dsh_archived_ids();
1109
+ const out = [];
1110
+ const bucket = dsh_bucket(cwd);
1111
+ let names;
1112
+ try {
1113
+ names = fs.readdirSync(bucket);
1114
+ } catch {
1115
+ return out; // Python glob:bucket 不存在 → 空列表
1116
+ }
1117
+ for (const sid of names) {
1118
+ if (sid.startsWith('.')) continue;
1119
+ const sdir = path.join(bucket, sid);
1120
+ const zfile = _dsh_pick_file(sdir);
1121
+ if (!zfile) continue;
1122
+ let mtime;
1123
+ try {
1124
+ mtime = statMtime(zfile);
1125
+ } catch {
1126
+ continue;
1127
+ }
1128
+ if (_header_subagent(_dsh_header(zfile))) continue; // 子代理会话
1129
+ // 一次解压同时取当前标题、首问与全部标题事件(同 stamp 缓存)
1130
+ const meta = _dsh_meta(zfile);
1131
+ out.push({
1132
+ sid,
1133
+ title: meta.title,
1134
+ titles: meta.titles.slice(), // Python `list(titles)`:下发副本,不暴露缓存内数组
1135
+ first_prompt: meta.prompt,
1136
+ mtime,
1137
+ archived: archived.has(sid),
1138
+ });
1139
+ }
1140
+ return out;
1141
+ }
1142
+
1143
+ /**
1144
+ * 按工作区枚举 dsh 会话 `[{sid, title, titles, first_prompt, mtime秒, archived}]`,
1145
+ * mtime 降序、上限 `LIST_SESSIONS_LIMIT`。
1146
+ *
1147
+ * 族不在白名单 / cwd 为空 / 异常一律静默返回 [](列表是增强能力)。
1148
+ */
1149
+ export function list_sessions(family, cwd) {
1150
+ if (!FAMILIES.includes(family)) return [];
1151
+ cwd = (cwd || '').replace(/\/+$/, '');
1152
+ if (!cwd) return [];
1153
+ let items;
1154
+ try {
1155
+ items = _list_dsh(cwd);
1156
+ } catch (error) {
1157
+ noteDegrade('list_sessions', error);
1158
+ return [];
1159
+ }
1160
+ items.sort((a, b) => b.mtime - a.mtime); // Python sort(reverse=True) 稳定:等值保持原序
1161
+ return items.slice(0, LIST_SESSIONS_LIMIT);
1162
+ }
1163
+
1164
+ /**
1165
+ * 会话存储是否还在(sync 卡片「存储被删→done」判定用;不做内容解析,纯路径存在性)。
1166
+ * 族不在白名单返回 false。
1167
+ */
1168
+ export function session_exists(family, sid) {
1169
+ if (!FAMILIES.includes(family)) return false;
1170
+ try {
1171
+ return Boolean(_dsh_session_file(sid));
1172
+ } catch (error) {
1173
+ noteDegrade('session_exists', error);
1174
+ return false;
1175
+ }
1176
+ }