dsh-log-slicer 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-log-slicer contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,81 @@
1
+ # dsh-log-slicer
2
+
3
+ Slice one section out of a DeepSeek Harness session log — verbatim turns, catalog row, backups, verification hash. One command. **Dry-run by default.**
4
+
5
+ ## 30-second start
6
+
7
+ ```bash
8
+ dsh-log-slicer --list --limit 20 # 1. read-only: what is in the log
9
+ dsh-log-slicer --kind chat --title "try" \
10
+ --from "verbatim fragment" --to "verbatim fragment" # 2. dry-run, nothing written
11
+ dsh-log-slicer ... --file notes.md --index index.md --apply # 3. write after backing up
12
+ ```
13
+
14
+ Anchors are **verbatim fragments**, never line numbers. `--apply` backs the target up to `旧版本/` first, then appends the section and the catalog row, then prints a verification line.
15
+
16
+ ## What it is / is not
17
+
18
+ - It is a **data extraction** tool: it pulls a slice of the session log out verbatim and lays it out in one of two house styles.
19
+ - It is **not** a summarizer, a compressor, or a memory tool. It does not rewrite, rank, or "improve" anything.
20
+ - Because it only extracts, it is not universal: it knows the "section" format it was built for (`**you:**` / `**me:**` labels, three-line header, catalog row, verification line). That format is a house style, not a standard.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ dsh plugin add dsh-log-slicer # as a plugin: adds log_probe / log_slice tools
26
+ npx dsh-log-slicer --help # or just use the CLI
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ # read-only: what is in the log
33
+ dsh-log-slicer --list --limit 40
34
+
35
+ # dry-run: check the slice before touching files
36
+ dsh-log-slicer --kind chat --title "2026-10-31 ① ..." \
37
+ --from "start fragment" --to "end fragment" \
38
+ --file "./notes/vol-8.md" --index "./notes/index.md" --volume vol-8
39
+
40
+ # write (backs up to 旧版本/ first)
41
+ dsh-log-slicer ... --apply --tag "before adding 10-31 ①"
42
+ ```
43
+
44
+ Anchors are **verbatim fragments**, never line numbers: the log grows every day. When an anchor is missing the CLI lists nearby turns as candidates.
45
+
46
+ ## Output contract
47
+
48
+ - Body: `## title` then turns, taken verbatim from the log
49
+ - Catalog row: `| volume | title | turns |`
50
+ - Verification line: `| date | file (+N turns) | bytes | sha256-prefix-16 | equal in both places |`
51
+ - Backup: `旧版本/<name>(<before the change>).bak`
52
+
53
+ ## Environment
54
+
55
+ - Node ≥ 22.15 (`zlib.zstdDecompressSync`). The Node bundled inside DSH works.
56
+ - Logs at `$DSH_HOME/sessions/<workspace>/<sessionId>/session.v4.jsonl.zstd`; `$DSH_HOME` defaults to `~/.dsh`.
57
+ - No third-party dependencies.
58
+
59
+ ## Limitations
60
+
61
+ - **Depends on the host's log format.** `src/decode.js` is the only layer that touches it.
62
+ - Images are reported as `sha256` + media type only; copying them out is on you.
63
+ - Only `user/message` and `assistant/message` events are understood.
64
+
65
+ ## FAQ
66
+
67
+ **"Log not found."** It looks under `~/.dsh/sessions` for the newest file. Set `DSH_HOME` or pass `--log "C:\path\to\session.v4.jsonl.zstd"`.
68
+
69
+ **"Anchor not found" plus a list of candidates.** Anchors are verbatim fragments, not line numbers or titles. Copy a whole turn from `--list`.
70
+
71
+ **The section has too many / too few turns.** `--to` is inclusive; slice small and often rather than one big block.
72
+
73
+ **Windows paths fail.** Quote the path, don't escape backslashes.
74
+
75
+ **Where are the images?** Only `sha256` + media type are reported. Copying and numbering them belongs to your note-taking convention, not to this tool.
76
+
77
+ **Can I use it without DSH?** Yes — any `session.v4.jsonl.zstd` fed to `--log`. Pure Node, no deps; if the format changes, it breaks.
78
+
79
+ ## Maintenance
80
+
81
+ Maintained whenever. If it breaks, fork it or send a PR — do not expect a schedule. MIT.
package/README.md CHANGED
@@ -1,3 +1,110 @@
1
- # Temporary Holding Version
1
+ # dsh-log-slicer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 DeepSeek Harness 的会话日志,切成"一节"能直接贴进笔记的东西。
4
+
5
+ 一条命令:读日志 → 取原话 → 切一段 → 出正文 → 出目录行 → 备份旧样 → 出校验记录。**默认 dry-run,不写盘**。
6
+
7
+ ## 30 秒上手
8
+
9
+ 不用读完这篇,四步:
10
+
11
+ ```bash
12
+ # 1. 看看日志里有什么(只读,随便跑)
13
+ dsh-log-slicer --list --limit 20
14
+
15
+ # 2. 从里面挑两句原话当锚点,试切一段(还是只读)
16
+ dsh-log-slicer --kind chat --title "试一下" \
17
+ --from "把 --list 里看到的一句原话粘这里" \
18
+ --to "另一句原话粘这里"
19
+
20
+ # 3. 看着像你要的东西了,指定文件写盘
21
+ dsh-log-slicer ... --file 我的笔记.md --index 目录.md --apply
22
+
23
+ # 4. 不确定它认没认对,就一直不加 --apply:dry-run 会把要写的东西整段打出来
24
+ ```
25
+
26
+ 写盘时它先在 `旧版本/` 备份一份,再追加正文和目录行,最后打印一行校验记录;不满意就把 `.bak` 拷回去。
27
+
28
+ 想装成插件:`dsh plugin add dsh-log-slicer`,装完多出 `log_probe`、`log_slice` 两个工具,功能一样。
29
+
30
+
31
+ ## 它是什么 / 不是什么
32
+
33
+ - 是:一个**取数**工具。日志是唯一的来源,它把日志里某一段原话原封不动取出来,按你指定的体裁排版。
34
+ - 不是:摘要工具、压缩工具、记忆工具。它不总结、不改写、不替你判断哪段重要。
35
+ - 因为它只取数,所以它不通用于所有人——它认的是"一节"的写法:`**你:**`/`**我:**` 独占一行、三行头、目录行、校验行。这些格式来自某一套笔记法,不是通用标准。
36
+
37
+ ## 安装
38
+
39
+ ```bash
40
+ # 当插件装(DSH 里出现 log_probe / log_slice 两个工具)
41
+ dsh plugin add dsh-log-slicer
42
+
43
+ # 只用命令行,不装插件也行
44
+ npx dsh-log-slicer --help
45
+ ```
46
+
47
+ ## 用法
48
+
49
+ ```bash
50
+ # 先看看日志里有什么(只读、便宜)
51
+ dsh-log-slicer --list --limit 40
52
+
53
+ # 切一节,先 dry-run 核对
54
+ dsh-log-slicer --kind chat \
55
+ --title "2026-10-31 ① 榫卯与第一栋房子" \
56
+ --from "我们开始今天的工作" --to "看看对你有没有帮助" \
57
+ --file "../闲话集/闲话集-8.md" --index "../闲话集/目录.md" --volume 闲话集-8
58
+
59
+ # 核对无误再写盘(会先备份到 旧版本/)
60
+ dsh-log-slicer ... --apply --tag "加 10-31 ① 之前"
61
+ ```
62
+
63
+ `--kind work` 生成另一种体裁:三行头 + 引用块(他的话照抄)+ 我的判断标成判断。
64
+
65
+ ## 锚点
66
+
67
+ `--from` / `--to` 要的是**原话片段**,不是行号——日志每天都在长。找不到时会列出最近的几句当候选,别猜行号。
68
+
69
+ ## 输出契约
70
+
71
+ - 正文:`## 标题` + 轮次,逐字取自日志
72
+ - 目录行:`| 卷 | 标题 | 轮 |`
73
+ - 校验记录:`| 日期 | 文件(+N 轮) | 字节 | 哈希前16位 | 本体=原件,两处一致 |`
74
+ - 备份:`旧版本/<名字>(<动作之前>).bak`(不带日期)
75
+
76
+ ## 环境
77
+
78
+ - Node ≥ 22.15(要用 `zlib.zstdDecompressSync`)。DSH 自带的 Node 就够,`DSH_NODE` 或直接用它的绝对路径。
79
+ - 日志位置:`$DSH_HOME/sessions/<workspace>/<sessionId>/session.v4.jsonl.zstd`,默认 `$DSH_HOME` 是 `~/.dsh`。
80
+ - 没有第三方依赖。
81
+
82
+ ## 已知限制
83
+
84
+ - **依赖宿主的日志格式**。`src/decode.js` 是唯一贴着格式的那一层,宿主改了只改这一处。
85
+ - 图片只给出 `sha256` 与媒体类型,落盘要自己按附件仓路径复制(也可 `--apply` 后手工补「(图见 …)」)。
86
+ - 只认 `user/message` 与 `assistant/message` 两种事件。
87
+
88
+ ## 常见问题
89
+
90
+ **跑起来说找不到日志。**
91
+ 默认去 `~/.dsh/sessions` 找最新的那份。装在别处就设 `DSH_HOME`,或者直接 `--log "C:\path\to\session.v4.jsonl.zstd"`。
92
+
93
+ **锚点找不到,报错还列了一堆候选。**
94
+ 锚点是**原话片段**,不是行号,也不是标题。复制时去掉句首句尾的空格和标点差异,或者先用 `--list` 把那条轮次整句抄下来。
95
+
96
+ **切出来的节少了/多了几句。**
97
+ `--to` 那一条要不要收进来,看它是不是"最后一句"。宁可切多几次,也不要一次切一大段:这东西本来就是按节用的。
98
+
99
+ **Windows 下路径报错。**
100
+ 给路径加双引号,别用反斜杠转义(`\\` 会变成转义符)。中文路径没问题。
101
+
102
+ **图呢?**
103
+ 它只认出图片的 `sha256` 和类型,落盘要自己复制,然后在正文补一行 `(图见 …)`。不是漏做——"图放哪个文件夹、怎么编号"是笔记法的约定,不该写死进取数工具。
104
+
105
+ **我不用 DSH,能用吗?**
106
+ 能,只要能拿到 `session.v4.jsonl.zstd` 并把它喂给 `--log`。它是纯 Node,没有别的依赖;但日志格式变了它也会跟着坏。
107
+
108
+ ## 维护
109
+
110
+ 不定期维护。坏了、要改、要适配新版本:欢迎 fork 或提 PR,别指望我按时修。MIT。
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-log-slicer —— 命令行入口。
4
+ *
5
+ * 一条龙:读日志 → 取原话 → 切一节 → 出目录行 → 备份旧样 → 出校验记录。
6
+ * 默认 dry-run,只打印;加 --apply 才写盘。
7
+ *
8
+ * 用法:
9
+ * dsh-log-slicer --kind chat --file "C:\...\闲话集-8.md" --index "C:\...\目录.md" \
10
+ * --volume 闲话集-8 --title "2026-10-31 ① 标题" --from "起点片段" --to "终点片段" [--apply] [--tag "加 10-31 ① 之前"]
11
+ *
12
+ * 找不到锚点时会列出最近的几句当候选,不猜行号。
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { buildSection } from '../src/pipeline.js';
18
+ import { indexRow, insertIndexRow, backupFile, appendSection, sha256Hex16, today } from '../src/register.js';
19
+ import { AnchorError } from '../src/slice.js';
20
+
21
+ const FLAGS = new Set(['apply', 'help', 'list']);
22
+
23
+ function parseArgs(argv) {
24
+ const out = {};
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (!a.startsWith('--')) continue;
28
+ const key = a.slice(2);
29
+ if (FLAGS.has(key)) {
30
+ out[key] = true;
31
+ continue;
32
+ }
33
+ out[key] = argv[++i];
34
+ }
35
+ return out;
36
+ }
37
+
38
+ const HELP = `dsh-log-slicer — slice one section out of a DSH session log
39
+
40
+ --kind chat|work 体裁(默认 chat)
41
+ --title "..." 节标题(必填)
42
+ --from "..." 起点锚点:第一句要收进来的原话片段
43
+ --to "..." 终点锚点:最后一句;不给就取到日志末尾
44
+ --file <path> 目标文件(加 --apply 时必填)
45
+ --index <path> 目录文件,自动插一行
46
+ --volume <name> 目录行里的卷名,如 闲话集-8
47
+ --log <path> 会话日志;默认取最新
48
+ --list 列最近的对话轮次,只读(配 --limit、--width)
49
+ --limit <n> --list 时列几条,默认 40
50
+ --width <n> --list 时每条截断多少字,默认 60
51
+ --tag "..." 备份标签,默认「自动整理之前」
52
+ --apply 真写盘
53
+ --help 这份说明
54
+ `;
55
+
56
+ function stamp(ts) {
57
+ if (!ts) return '';
58
+ const d = new Date(Number(ts) || ts);
59
+ if (Number.isNaN(d.getTime())) return '';
60
+ const p = (n) => String(n).padStart(2, '0');
61
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
62
+ }
63
+
64
+ function main() {
65
+ const args = parseArgs(process.argv.slice(2));
66
+ if (args.help) {
67
+ process.stdout.write(HELP);
68
+ return;
69
+ }
70
+ if (!args.list && !args.title) {
71
+ process.stderr.write('错误:缺少 --title\n\n' + HELP);
72
+ process.exit(2);
73
+ }
74
+
75
+ if (args.list) {
76
+ const all = buildSection({ kind: 'chat', title: 'list', logPath: args.log });
77
+ const limit = Math.max(1, Math.min(1000, Number(args.limit) || 40));
78
+ const width = Math.max(10, Math.min(500, Number(args.width) || 60));
79
+ const turns = all.segment.slice(-limit);
80
+ const startNo = all.totalTurns - turns.length + 1;
81
+ const lines = turns.map((t, i) => {
82
+ const who = t.who === 'user' ? '你' : '我';
83
+ const when = stamp(t.ts);
84
+ const body = String(t.text || '').replace(/\s+/g, ' ').slice(0, width);
85
+ return `${startNo + i}. [${who}] ${when} ${body}`;
86
+ });
87
+ process.stdout.write(
88
+ `# 日志 ${all.log}\n# ${all.frames} 帧(${all.failedFrames} 帧解不开)/ ${all.totalTurns} 轮有效\n\n` +
89
+ lines.join('\n') + '\n',
90
+ );
91
+ return;
92
+ }
93
+
94
+ let built;
95
+ try {
96
+ built = buildSection({
97
+ kind: args.kind === 'work' ? 'work' : 'chat',
98
+ title: args.title,
99
+ from: args.from,
100
+ to: args.to,
101
+ logPath: args.log,
102
+ });
103
+ } catch (e) {
104
+ if (e instanceof AnchorError) {
105
+ process.stderr.write(`错误:${e.message}\n`);
106
+ if (e.candidates.length) {
107
+ process.stderr.write('最近的几句(可拿其中一段当锚点):\n' + e.candidates.map((c) => ' ' + c).join('\n') + '\n');
108
+ }
109
+ process.exit(3);
110
+ }
111
+ throw e;
112
+ }
113
+
114
+ const out = [];
115
+ out.push(`# 日志 ${built.log}`);
116
+ out.push(`# ${(built.logSize / 1048576).toFixed(1)} MB / ${built.frames} 帧(${built.failedFrames} 帧解不开)/ ${built.totalTurns} 轮有效`);
117
+ out.push(`# 切成 ${built.segment.length} 轮,其中你 ${built.counts.you} 轮、我 ${built.counts.me} 轮`);
118
+ if (built.imageCount) {
119
+ out.push(`# 图片 ${built.imageCount} 张:`);
120
+ for (const img of built.images) out.push(`# ${img.mediaType || '?'} sha256:${img.sha256}`);
121
+ }
122
+ out.push('', '===== 正文(将追加) =====', '', built.markdown);
123
+
124
+ const row = indexRow(args.volume || (args.file ? path.basename(args.file, '.md') : '卷'), args.title, built.counts.you);
125
+ out.push('===== 目录行 =====', '', row);
126
+
127
+ if (args.apply !== true) {
128
+ out.push('', '(dry-run,没有写盘。加 --apply 才动手。)');
129
+ process.stdout.write(out.join('\n') + '\n');
130
+ return;
131
+ }
132
+ if (!args.file) {
133
+ process.stderr.write('错误:--apply 需要 --file\n');
134
+ process.exit(2);
135
+ }
136
+
137
+ const target = path.resolve(args.file);
138
+ const tag = args.tag || '自动整理之前';
139
+ const backup = fs.existsSync(target) ? backupFile(target, tag) : null;
140
+ const { after } = appendSection(target, built.markdown);
141
+ let indexBackup = null;
142
+ if (args.index && fs.existsSync(args.index)) {
143
+ const indexPath = path.resolve(args.index);
144
+ indexBackup = backupFile(indexPath, tag);
145
+ const updated = insertIndexRow(fs.readFileSync(indexPath, 'utf8'), row, args.volume || path.basename(target, '.md'));
146
+ fs.writeFileSync(indexPath, updated, 'utf8');
147
+ }
148
+
149
+ out.push('');
150
+ out.push(`已写入:${target}`);
151
+ if (backup) out.push(`备份:${backup}`);
152
+ if (indexBackup) out.push(`备份:${indexBackup}`);
153
+ out.push(`字节:${after}`);
154
+ out.push(`校验记录(贴进备份说明):| ${today()} | ${path.basename(target)}(+${built.counts.you} 轮) | ${after} | \`${sha256Hex16(target)}\` | 本体=原件,两处一致 |`);
155
+ process.stdout.write(out.join('\n') + '\n');
156
+ }
157
+
158
+ main();
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-log-slicer
3
+ name: dsh-log-slicer
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "dsh-log-slicer",
3
+ "description": "Slice one section out of a DSH session log: verbatim turns, catalog row, backup and verification hash.",
4
+ "version": "0.1.0",
5
+ "entry": {
6
+ "name": "dsh-log-slicer",
7
+ "inject": [
8
+ "tools"
9
+ ]
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "dsh-log-slicer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Slice one section out of a DeepSeek Harness session log: verbatim turns, images, catalog row, backups and a verification hash — one command, dry-run by default.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "session-log",
10
+ "zstd",
11
+ "transcript",
12
+ "markdown"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "main": "./src/index.js",
17
+ "exports": {
18
+ ".": "./src/index.js",
19
+ "./pipeline": "./src/pipeline.js",
20
+ "./decode": "./src/decode.js",
21
+ "./clean": "./src/clean.js",
22
+ "./slice": "./src/slice.js",
23
+ "./render": "./src/render.js",
24
+ "./register": "./src/register.js"
25
+ },
26
+ "bin": {
27
+ "dsh-log-slicer": "./bin/log-slicer.mjs"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "bin",
32
+ "cordis.patch.yml",
33
+ "dsh.plugin.json",
34
+ "README.md",
35
+ "README.en.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=22.15.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@deepseek-ai/dsh-tools": "*"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@deepseek-ai/dsh-tools": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "scripts": {
50
+ "test": "node --test"
51
+ }
6
52
  }
package/src/clean.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * clean.js —— 事件流 → 轮次。
3
+ *
4
+ * 只留人和模型真正说的话:宿主注入的环境文本、图片占位行都扔掉。
5
+ * 图片留下 sha256 与媒体类型,供上层落成文件。
6
+ */
7
+
8
+ const ENV_PREFIXES = [
9
+ 'Current runtime context.',
10
+ 'Time sampled while preparing',
11
+ 'Browser time zone for this request',
12
+ 'Elapsed since the preceding model-visible message',
13
+ '<system-reminder>',
14
+ '[Compressed conversation section]',
15
+ ];
16
+
17
+ const IMAGE_PLACEHOLDER = /^Image "[^"]+" \(sha256:[0-9a-f]{4,}/i;
18
+
19
+ export function isEnvironmentText(text) {
20
+ if (typeof text !== 'string') return true;
21
+ const t = text.trim();
22
+ if (!t) return true;
23
+ if (IMAGE_PLACEHOLDER.test(t)) return true;
24
+ return ENV_PREFIXES.some((p) => t.startsWith(p));
25
+ }
26
+
27
+ function textOf(items) {
28
+ if (!Array.isArray(items)) return '';
29
+ return items
30
+ .filter((x) => x && x.type === 'text' && typeof x.text === 'string')
31
+ .map((x) => x.text)
32
+ .join('\n');
33
+ }
34
+
35
+ function imagesOf(items) {
36
+ if (!Array.isArray(items)) return [];
37
+ const out = [];
38
+ for (const x of items) {
39
+ if (!x || x.type !== 'image') continue;
40
+ const a = x.attachment || {};
41
+ const id = String(a.attachmentId || a.id || '').replace(/^sha256:sha256:/, 'sha256:');
42
+ out.push({ id, sha256: id.replace(/^sha256:/, ''), mediaType: a.mediaType || '' });
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * @param {string} text 解压后的 JSONL 文本
49
+ * @returns {Array<{who: 'user'|'me', ts: number|null, text: string, images: Array}>}
50
+ */
51
+ export function turnsFromText(text) {
52
+ const turns = [];
53
+ for (const line of String(text).split('\n')) {
54
+ if (!line.trim()) continue;
55
+ let ev;
56
+ try {
57
+ ev = JSON.parse(line);
58
+ } catch {
59
+ continue;
60
+ }
61
+ if (!ev || typeof ev !== 'object') continue;
62
+ const ts = ev.time ?? ev.timestamp ?? null;
63
+ if (ev.type === 'user/message') {
64
+ const items = (ev.data && ev.data.content) || [];
65
+ const body = textOf(items);
66
+ const images = imagesOf(items);
67
+ if (isEnvironmentText(body) && images.length === 0) continue;
68
+ turns.push({ who: 'user', ts, text: body.trim(), images });
69
+ } else if (ev.type === 'assistant/message') {
70
+ const items = (ev.data && ev.data.message && ev.data.message.content) || [];
71
+ const body = textOf(items);
72
+ if (isEnvironmentText(body)) continue;
73
+ turns.push({ who: 'me', ts, text: body.trim(), images: [] });
74
+ }
75
+ }
76
+ return turns;
77
+ }
package/src/decode.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * decode.js —— 只做一件事:把会话日志的多帧 zstd 解成文本。
3
+ *
4
+ * 这是整个包里最容易随宿主版本失效的一层,所以单独一个文件:
5
+ * 宿主一改日志格式,只改这里。
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+ import zlib from 'node:zlib';
12
+
13
+ const MAGIC = [0x28, 0xb5, 0x2f, 0xfd];
14
+
15
+ export function dshHome() {
16
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
17
+ }
18
+
19
+ function assertZstd() {
20
+ if (typeof zlib.zstdDecompressSync !== 'function') {
21
+ throw new Error(
22
+ 'this Node build has no zstd support (needs node >= 22.15). ' +
23
+ 'Run it with the Node that ships inside DSH, or upgrade Node.',
24
+ );
25
+ }
26
+ }
27
+
28
+ /**
29
+ * 逐帧解压。帧尾可能带垃圾(非本帧字节),所以解不开时逐字节缩短再试。
30
+ * @returns {{text: string, frames: number, failed: number}}
31
+ */
32
+ export function decodeFrames(buf, { maxTrim = 32 } = {}) {
33
+ assertZstd();
34
+ const offs = [];
35
+ for (let i = 0; i + 4 <= buf.length; i++) {
36
+ if (buf[i] === MAGIC[0] && buf[i + 1] === MAGIC[1] && buf[i + 2] === MAGIC[2] && buf[i + 3] === MAGIC[3]) {
37
+ offs.push(i);
38
+ }
39
+ }
40
+ const parts = [];
41
+ let failed = 0;
42
+ for (let k = 0; k < offs.length; k++) {
43
+ const start = offs[k];
44
+ const end = k + 1 < offs.length ? offs[k + 1] : buf.length;
45
+ let ok = false;
46
+ for (let trim = 0; trim <= maxTrim && !ok; trim++) {
47
+ const stop = end - trim;
48
+ if (stop <= start) break;
49
+ try {
50
+ parts.push(zlib.zstdDecompressSync(buf.subarray(start, stop)).toString('utf8'));
51
+ ok = true;
52
+ } catch {
53
+ /* 帧尾有垃圾,缩短重试 */
54
+ }
55
+ }
56
+ if (!ok) failed++;
57
+ }
58
+ return { text: parts.join(''), frames: offs.length, failed };
59
+ }
60
+
61
+ export function decodeFile(filePath, options) {
62
+ return decodeFrames(fs.readFileSync(filePath), options);
63
+ }
64
+
65
+ /**
66
+ * 在 $DSH_HOME/sessions 下找 session.v4.jsonl.zstd,按修改时间倒序。
67
+ * sessions/<workspace>/<sessionId>/session.v4.jsonl.zstd
68
+ */
69
+ export function findSessionLogs(root = path.join(dshHome(), 'sessions')) {
70
+ const out = [];
71
+ let entries;
72
+ try {
73
+ entries = fs.readdirSync(root, { withFileTypes: true });
74
+ } catch {
75
+ return out;
76
+ }
77
+ for (const ws of entries) {
78
+ if (!ws.isDirectory()) continue;
79
+ const wsDir = path.join(root, ws.name);
80
+ let subs;
81
+ try {
82
+ subs = fs.readdirSync(wsDir, { withFileTypes: true });
83
+ } catch {
84
+ continue;
85
+ }
86
+ for (const s of subs) {
87
+ const file = path.join(wsDir, s.name, 'session.v4.jsonl.zstd');
88
+ try {
89
+ const st = fs.statSync(file);
90
+ out.push({ path: file, workspace: ws.name, session: s.name, mtime: st.mtimeMs, size: st.size });
91
+ } catch {
92
+ /* 不是会话目录 */
93
+ }
94
+ }
95
+ }
96
+ out.sort((a, b) => b.mtime - a.mtime);
97
+ return out;
98
+ }
99
+
100
+ export function latestSessionLog(root) {
101
+ const logs = findSessionLogs(root);
102
+ if (!logs.length) throw new Error('no session.v4.jsonl.zstd found under ' + (root || path.join(dshHome(), 'sessions')));
103
+ return logs[0];
104
+ }
package/src/index.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * index.js —— DSH 插件入口。
3
+ *
4
+ * 两件事:
5
+ * log_probe 看日志里有什么(轮次、时间、可用的锚点),只读、便宜
6
+ * log_slice 切一节出来;默认 dry-run,写盘要 apply === true
7
+ *
8
+ * 契约(对着 @deepseek-ai/dsh-tools 的用法):
9
+ * - export const inject = ['tools']
10
+ * - apply(ctx) 里 ctx.tools.register(defineTool({...spec, execute}))
11
+ * - apply 的异常一律吞掉:最坏是插件不工作,不能让应用起不来
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { buildSection } from './pipeline.js';
17
+ import { indexRow, insertIndexRow, backupFile, appendSection, sha256Hex16, today } from './register.js';
18
+
19
+ export const name = 'dsh-log-slicer';
20
+ export const inject = ['tools'];
21
+
22
+ async function loadDefineTool() {
23
+ try {
24
+ const mod = await import('@deepseek-ai/dsh-tools');
25
+ if (mod?.defineTool) return mod.defineTool;
26
+ } catch {
27
+ /* 落到环境变量 */
28
+ }
29
+ const override = process.env.DSH_TOOLS_PATH;
30
+ if (override) {
31
+ const { pathToFileURL } = await import('node:url');
32
+ const mod = await import(pathToFileURL(override).href);
33
+ if (mod?.defineTool) return mod.defineTool;
34
+ }
35
+ throw new Error('cannot resolve @deepseek-ai/dsh-tools (set DSH_TOOLS_PATH for local dev)');
36
+ }
37
+
38
+ export const probeSpec = {
39
+ name: 'log_probe',
40
+ description: [
41
+ 'List recent conversation turns from the DSH session log, so you can pick anchors before slicing.',
42
+ 'Read-only and cheap: it decodes the log and prints who said what (truncated), newest last.',
43
+ 'Use it when you need to know where a section starts or ends.',
44
+ ].join('\n'),
45
+ parameters: {
46
+ logPath: {
47
+ type: 'string',
48
+ description: 'Absolute path to session.v4.jsonl.zstd. Default: the newest session log under $DSH_HOME/sessions.',
49
+ },
50
+ limit: {
51
+ type: 'integer',
52
+ description: 'How many turns to print, newest last. Default 40.',
53
+ },
54
+ width: {
55
+ type: 'integer',
56
+ description: 'Characters per turn before truncating. Default 60.',
57
+ },
58
+ },
59
+ };
60
+
61
+ export const sliceSpec = {
62
+ name: 'log_slice',
63
+ description: [
64
+ 'Slice one section out of the DSH session log and render it as chat-log markdown or work-dialogue markdown.',
65
+ 'Anchors are verbatim sentence fragments (--from / --to), not line numbers: the log grows every day.',
66
+ 'Default dry-run: nothing is written unless apply is true.',
67
+ 'When apply is true it backs up the target first, appends the section, inserts the index row and prints a verification line.',
68
+ ].join('\n'),
69
+ parameters: {
70
+ kind: {
71
+ type: 'string',
72
+ enum: ['chat', 'work'],
73
+ description: 'chat = 《闲话集》 style (verbatim turns); work = three-line header plus quoted turns.',
74
+ },
75
+ title: {
76
+ type: 'string',
77
+ required: true,
78
+ description: 'Section title, e.g. "2026-10-31 ① 榫卯与第一栋房子".',
79
+ },
80
+ from: { type: 'string', description: 'Start anchor: a verbatim fragment of the first turn to include.' },
81
+ to: { type: 'string', description: 'End anchor: a verbatim fragment of the last turn to include. Omit for end of log.' },
82
+ file: { type: 'string', description: 'Target markdown file to append to (requires apply).' },
83
+ index: { type: 'string', description: 'Index (catalog) file whose table gets a new row (requires apply).' },
84
+ volume: { type: 'string', description: 'Volume name used in the index row, e.g. "闲话集-8".' },
85
+ logPath: { type: 'string', description: 'Path to the session log. Default: newest.' },
86
+ tag: { type: 'string', description: 'Backup tag, e.g. "加10-31 ① 之前". Backups go to <dir>/旧版本/.' },
87
+ apply: { type: 'boolean', description: 'Actually write. Default false.' },
88
+ },
89
+ };
90
+
91
+ async function registerAll(ctx) {
92
+ const defineTool = await loadDefineTool();
93
+
94
+ const probe = defineTool({
95
+ ...probeSpec,
96
+ async execute(args = {}) {
97
+ const limit = Math.max(1, Math.min(500, Number(args.limit) || 40));
98
+ const width = Math.max(10, Math.min(400, Number(args.width) || 60));
99
+ const built = buildSection({ kind: 'chat', title: 'probe', logPath: args.logPath });
100
+ const turns = built.segment.length ? built.segment : [];
101
+ const lines = turns.slice(-limit).map((t, i) => {
102
+ const who = t.who === 'user' ? 'you' : 'me';
103
+ const when = t.ts ? new Date(Number(t.ts) || t.ts).toLocaleString() : '';
104
+ const body = String(t.text || '').replace(/\s+/g, ' ').slice(0, width);
105
+ return `${turns.length - Math.min(limit, turns.length) + i + 1}. [${who}] ${when} ${body}`;
106
+ });
107
+ return {
108
+ text:
109
+ `# ${built.log}\n` +
110
+ `# ${built.frames} frames (${built.failedFrames} failed) / ${built.totalTurns} turns\n\n` +
111
+ lines.join('\n'),
112
+ };
113
+ },
114
+ });
115
+
116
+ const slice = defineTool({
117
+ ...sliceSpec,
118
+ async execute(args = {}) {
119
+ const built = buildSection({
120
+ kind: args.kind === 'work' ? 'work' : 'chat',
121
+ title: args.title,
122
+ from: args.from,
123
+ to: args.to,
124
+ logPath: args.logPath,
125
+ });
126
+ const head =
127
+ `# ${built.log}\n` +
128
+ `# ${built.frames} frames (${built.failedFrames} failed) / ${built.totalTurns} turns\n` +
129
+ `# sliced ${built.segment.length} turns (you ${built.counts.you} / me ${built.counts.me})\n`;
130
+ if (args.apply !== true) {
131
+ return { text: `${head}\n===== markdown (dry-run) =====\n\n${built.markdown}` };
132
+ }
133
+ if (!args.file) throw new Error('apply=true requires "file"');
134
+
135
+ const target = path.resolve(args.file);
136
+ const tag = args.tag || '自动整理之前';
137
+ const backup = fs.existsSync(target) ? backupFile(target, tag) : null;
138
+ const { after } = appendSection(target, built.markdown);
139
+ const row = indexRow(args.volume || path.basename(target, '.md'), args.title, built.counts.you);
140
+ let indexBackup = null;
141
+ let indexPath = null;
142
+ if (args.index && fs.existsSync(args.index)) {
143
+ indexPath = path.resolve(args.index);
144
+ indexBackup = backupFile(indexPath, tag);
145
+ const updated = insertIndexRow(fs.readFileSync(indexPath, 'utf8'), row, args.volume || path.basename(target, '.md'));
146
+ fs.writeFileSync(indexPath, updated, 'utf8');
147
+ }
148
+ const line = `| ${today()} | ${path.basename(target)}(+${built.counts.you} 轮) | ${after} | \`${sha256Hex16(target)}\` | 本体=原件,两处一致 |`;
149
+ return {
150
+ text:
151
+ `${head}\nwritten: ${target}\n` +
152
+ (backup ? `backup: ${backup}\n` : '') +
153
+ (indexBackup ? `backup: ${indexBackup}\n` : '') +
154
+ (indexPath ? `index: ${indexPath}\n` : '') +
155
+ `bytes: ${after}\nrow: ${row}\nverification: ${line}`,
156
+ };
157
+ },
158
+ });
159
+
160
+ const disposers = [ctx.tools.register(probe), ctx.tools.register(slice)];
161
+ return () => {
162
+ for (const d of disposers.reverse()) d?.();
163
+ };
164
+ }
165
+
166
+ export async function apply(ctx) {
167
+ try {
168
+ return await registerAll(ctx);
169
+ } catch (e) {
170
+ try {
171
+ ctx?.logger?.error?.(`[dsh-log-slicer] failed to load: ${e?.stack ?? e}`);
172
+ } catch {
173
+ /* 连日志都写不了就算了 */
174
+ }
175
+ if (process.env.DSH_LOG_SLICER_DEBUG) throw e;
176
+ return () => {};
177
+ }
178
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * pipeline.js —— 把四层串起来:取数 → 清洗 → 切片 → 渲染。
3
+ *
4
+ * CLI 和插件工具都调这里,保证两条路产出一样的东西。
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import { decodeFile, latestSessionLog } from './decode.js';
9
+ import { turnsFromText } from './clean.js';
10
+ import { sliceTurns, countTurns } from './slice.js';
11
+ import { renderChat, renderWork, applyImageNotes } from './render.js';
12
+
13
+ /**
14
+ * @param {object} options
15
+ * @param {'chat'|'work'} options.kind
16
+ * @param {string} options.title
17
+ * @param {string} [options.from]
18
+ * @param {string} [options.to]
19
+ * @param {string} [options.logPath] 不给就取最新的会话日志
20
+ * @param {object} [options.workHeader] work 体裁的三行头内容
21
+ */
22
+ export function buildSection(options = {}) {
23
+ const { kind = 'chat', title, from, to, logPath, workHeader } = options;
24
+ if (!title) throw new Error('title is required');
25
+
26
+ const log = logPath ? { path: logPath, size: fsSize(logPath), mtime: 0 } : latestSessionLog();
27
+ const decoded = decodeFile(log.path);
28
+ const turns = turnsFromText(decoded.text);
29
+ const segment = sliceTurns(turns, { from, to });
30
+ const counts = countTurns(segment);
31
+
32
+ const markdown =
33
+ kind === 'work' ? renderWork(segment, title, workHeader || {}) : renderChat(segment, title);
34
+
35
+ const images = segment.flatMap((t) => t.images || []);
36
+
37
+ return {
38
+ log: log.path,
39
+ logSize: decoded.frames ? log.size || 0 : 0,
40
+ frames: decoded.frames,
41
+ failedFrames: decoded.failed,
42
+ totalTurns: turns.length,
43
+ segment,
44
+ counts,
45
+ imageCount: images.length,
46
+ images,
47
+ markdown,
48
+ };
49
+ }
50
+
51
+ function fsSize(p) {
52
+ try {
53
+ return fs.statSync(p).size;
54
+ } catch {
55
+ return 0;
56
+ }
57
+ }
58
+
59
+ export { applyImageNotes };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * register.js —— 落盘那一层:目录行、旧样备份、追加、哈希。
3
+ *
4
+ * 这一层的格式全是"过盈"的:目录行、校验行、`.bak` 命名一旦变了,
5
+ * 下游(备份说明、目录表、人的习惯)就装不上。
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import crypto from 'node:crypto';
11
+
12
+ export function today(now = new Date()) {
13
+ const p = (n) => String(n).padStart(2, '0');
14
+ return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
15
+ }
16
+
17
+ export function sha256Hex16(filePath) {
18
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex').slice(0, 16).toUpperCase();
19
+ }
20
+
21
+ /** `| 卷 | 标题 | 轮 |` —— 列间一个空格,和两本正本一致。 */
22
+ export function indexRow(volume, title, turns) {
23
+ return `| ${volume} | ${title} | ${turns} |`;
24
+ }
25
+
26
+ /**
27
+ * 把目录行插到该卷最后一行之后;没有该卷的行就插到表格末尾。
28
+ */
29
+ export function insertIndexRow(markdown, row, volume) {
30
+ const lines = String(markdown).split('\n');
31
+ let last = -1;
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const t = lines[i].trim();
34
+ if (t.startsWith(`| ${volume} |`) || t.startsWith(`|${volume}|`)) last = i;
35
+ }
36
+ if (last >= 0) {
37
+ lines.splice(last + 1, 0, row);
38
+ return lines.join('\n');
39
+ }
40
+ let headerSep = -1;
41
+ for (let i = 0; i < lines.length; i++) {
42
+ if (lines[i].trim().startsWith('|') && lines[i].includes('---')) headerSep = i;
43
+ }
44
+ if (headerSep < 0) throw new Error('index file has no markdown table');
45
+ let end = headerSep;
46
+ while (end + 1 < lines.length && lines[end + 1].trim().startsWith('|')) end++;
47
+ lines.splice(end + 1, 0, row);
48
+ return lines.join('\n');
49
+ }
50
+
51
+ /** 旧样名:`<名字>(<动作之前>).bak`;不写日期。 */
52
+ export function backupPath(target, tag, oldverDir) {
53
+ const base = path.basename(target, path.extname(target));
54
+ return path.join(oldverDir || path.join(path.dirname(target), '旧版本'), `${base}(${tag}).bak`);
55
+ }
56
+
57
+ export function backupFile(target, tag, oldverDir) {
58
+ const dir = oldverDir || path.join(path.dirname(target), '旧版本');
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ const dst = backupPath(target, tag, dir);
61
+ fs.copyFileSync(target, dst);
62
+ return dst;
63
+ }
64
+
65
+ /** 追加一节:先保证前面是空行,再写正文。 */
66
+ export function appendSection(target, markdown) {
67
+ const before = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
68
+ const needsGap = before.length > 0 && !before.endsWith('\n\n');
69
+ const body = (needsGap ? '\n' : '') + markdown;
70
+ fs.writeFileSync(target, before + body, 'utf8');
71
+ return { before: Buffer.byteLength(before, 'utf8'), after: fs.statSync(target).size };
72
+ }
73
+
74
+ export function verificationLine({ date = today(), name, note, bytes, hash }) {
75
+ return `| ${date} | ${name}(${note}) | ${bytes} | \`${hash}\` | 本体=原件,两处一致 |`;
76
+ }
package/src/render.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * render.js —— 轮次 → Markdown。纯字符串,不碰文件。
3
+ *
4
+ * 两种体裁:
5
+ * chat:《闲话集》体——原话、`**你:**`/`**我:**` 独占一行
6
+ * work:《工作时值得记录的对话》体——三行头 + 引用块 + 标成判断
7
+ */
8
+
9
+ const LABEL = { user: '**你:**', me: '**我:**' };
10
+
11
+ function tidy(body) {
12
+ return String(body || '')
13
+ .replace(/\r\n/g, '\n')
14
+ .replace(/[ \t]+$/gm, '')
15
+ .replace(/\n{3,}/g, '\n\n')
16
+ .trim();
17
+ }
18
+
19
+ export function renderChat(segment, title) {
20
+ const blocks = [`## ${title}`, ''];
21
+ for (const t of segment) {
22
+ const body = tidy(t.text);
23
+ blocks.push(LABEL[t.who] || LABEL.me, '');
24
+ if (body) blocks.push(body, '');
25
+ if (t.who === 'user' && Array.isArray(t.images)) {
26
+ for (const img of t.images) {
27
+ // 图片落成文件后由调用方替换成「(图见 …)」;这里给一个显眼占位,别静默丢
28
+ blocks.push(`(图未落:\`${img.sha256}\` ${img.mediaType} — 落盘后改成「(图见 …)」)`, '');
29
+ }
30
+ }
31
+ }
32
+ return blocks.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
33
+ }
34
+
35
+ export function renderWork(segment, title, { what = '(待写)', line = '(待写)', why = '(待写)', origin = '已移走' } = {}) {
36
+ const out = [
37
+ `## ${title}`,
38
+ '',
39
+ `> 这段在做什么:${what}`,
40
+ `> 涉及:${line}|原件:${origin}`,
41
+ `> 为什么留着:${why}`,
42
+ '',
43
+ ];
44
+ for (const t of segment) {
45
+ const body = tidy(t.text);
46
+ if (!body) continue;
47
+ if (t.who === 'user') {
48
+ // 他的话:引用块照抄,一个字不改
49
+ out.push(...body.split('\n').map((l) => `> ${l}`.trimEnd()), '');
50
+ } else {
51
+ out.push('**我**(当时的判断,可能被推翻):', '', body, '');
52
+ }
53
+ }
54
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
55
+ }
56
+
57
+ /** 把 `(图未落 …)` 占位替换成正式图注。 */
58
+ export function applyImageNotes(markdown, mapping = {}) {
59
+ let out = markdown;
60
+ for (const [sha, note] of Object.entries(mapping)) {
61
+ const re = new RegExp(`(图未落:\`${sha}\`[^)]*)`, 'g');
62
+ out = out.replace(re, note);
63
+ }
64
+ return out;
65
+ }
package/src/slice.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * slice.js —— 按锚点切成一段,配对成"你说/我说"。
3
+ *
4
+ * 锚点是原话片段(子串匹配),不认行号:日志每天都在长,行号一定会错。
5
+ */
6
+
7
+ export class AnchorError extends Error {
8
+ constructor(message, candidates = []) {
9
+ super(message);
10
+ this.name = 'AnchorError';
11
+ this.candidates = candidates;
12
+ }
13
+ }
14
+
15
+ function preview(text, width = 40) {
16
+ return String(text || '').replace(/\s+/g, ' ').slice(0, width);
17
+ }
18
+
19
+ /** 找不到锚点时,给最近的几句当候选,别让人猜。 */
20
+ export function suggestAnchors(turns, limit = 8) {
21
+ return turns
22
+ .slice(-limit)
23
+ .map((t, i) => `${t.who === 'user' ? '你' : '我'}: ${preview(t.text)}`);
24
+ }
25
+
26
+ /**
27
+ * @param {Array} turns
28
+ * @param {{from?: string, to?: string}} range
29
+ */
30
+ export function sliceTurns(turns, range = {}) {
31
+ const { from, to } = range;
32
+ let start = 0;
33
+ if (from) {
34
+ start = turns.findIndex((t) => t.text && t.text.includes(from));
35
+ if (start < 0) throw new AnchorError(`anchor not found (from): ${from}`, suggestAnchors(turns));
36
+ }
37
+ let end = turns.length - 1;
38
+ if (to) {
39
+ const found = turns.findIndex((t, i) => i >= start && t.text && t.text.includes(to));
40
+ if (found < 0) throw new AnchorError(`anchor not found (to): ${to}`, suggestAnchors(turns));
41
+ end = found;
42
+ }
43
+ if (end < start) throw new AnchorError('anchor range is empty: "to" is before "from"', []);
44
+ return turns.slice(start, end + 1);
45
+ }
46
+
47
+ /** 统计轮数:以"你"的条数为"轮"。 */
48
+ export function countTurns(segment) {
49
+ const you = segment.filter((t) => t.who === 'user').length;
50
+ return { you, me: segment.length - you };
51
+ }