dsh-log-slicer 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.en.md +81 -0
- package/README.md +109 -2
- package/bin/log-slicer.mjs +165 -0
- package/cordis.patch.yml +3 -0
- package/dsh.plugin.json +11 -0
- package/package.json +50 -3
- package/src/clean.js +77 -0
- package/src/decode.js +104 -0
- package/src/index.js +178 -0
- package/src/pipeline.js +61 -0
- package/src/register.js +86 -0
- package/src/render.js +94 -0
- package/src/slice.js +51 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# 变更记录
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
三处都是第一次真用(拿 2026-10-31 上午那段去切)时暴露出来的。
|
|
6
|
+
|
|
7
|
+
- **修**:`--kind work` 的三行头里,「原件」默认值从 `已移走` 改成 `(待填)`。工具并不知道那份原件在哪儿,替人写一个断言是越界。
|
|
8
|
+
- **修**:目录里如果某一卷的名字正好是「卷」,它会撞上表头首格,行被插到表头与分隔行中间,表格就坏了。现在表头与分隔行不参与"这是该卷的行"的判断。
|
|
9
|
+
- **增**:指路句识别。形如「我更新了第一轮」「你可以看看那个文件」「这方面内容我们就结束了」的句子,是他往文件上指,不是他的话。dry-run 会把它们列出来,并提示去原件抄真段落;若那段属三级,就不抄,写一句"按三级不抄在这里"。
|
|
10
|
+
|
|
11
|
+
## 0.1.0
|
|
12
|
+
|
|
13
|
+
第一版。取数 → 清洗 → 切片 → 渲染 → 登记,CLI 与插件两条入口共用 `src/pipeline.js`。
|
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
|
-
#
|
|
1
|
+
# dsh-log-slicer
|
|
2
2
|
|
|
3
|
-
|
|
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,165 @@
|
|
|
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
|
+
if (built.pointerLines.length) {
|
|
123
|
+
out.push(`# 注意:有 ${built.pointerLines.length} 轮像"指路句"(往文件上指,不是原话)——`);
|
|
124
|
+
for (const p of built.pointerLines) {
|
|
125
|
+
out.push(`# 第 ${p.index + 1} 轮「${p.phrase}」:${p.text.replace(/\s+/g, ' ')}…`);
|
|
126
|
+
}
|
|
127
|
+
out.push('# 去那几份原件里抄真段落;若属三级,就不抄,写一句"按三级不抄在这里"。');
|
|
128
|
+
}
|
|
129
|
+
out.push('', '===== 正文(将追加) =====', '', built.markdown);
|
|
130
|
+
|
|
131
|
+
const row = indexRow(args.volume || (args.file ? path.basename(args.file, '.md') : '卷'), args.title, built.counts.you);
|
|
132
|
+
out.push('===== 目录行 =====', '', row);
|
|
133
|
+
|
|
134
|
+
if (args.apply !== true) {
|
|
135
|
+
out.push('', '(dry-run,没有写盘。加 --apply 才动手。)');
|
|
136
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!args.file) {
|
|
140
|
+
process.stderr.write('错误:--apply 需要 --file\n');
|
|
141
|
+
process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const target = path.resolve(args.file);
|
|
145
|
+
const tag = args.tag || '自动整理之前';
|
|
146
|
+
const backup = fs.existsSync(target) ? backupFile(target, tag) : null;
|
|
147
|
+
const { after } = appendSection(target, built.markdown);
|
|
148
|
+
let indexBackup = null;
|
|
149
|
+
if (args.index && fs.existsSync(args.index)) {
|
|
150
|
+
const indexPath = path.resolve(args.index);
|
|
151
|
+
indexBackup = backupFile(indexPath, tag);
|
|
152
|
+
const updated = insertIndexRow(fs.readFileSync(indexPath, 'utf8'), row, args.volume || path.basename(target, '.md'));
|
|
153
|
+
fs.writeFileSync(indexPath, updated, 'utf8');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
out.push('');
|
|
157
|
+
out.push(`已写入:${target}`);
|
|
158
|
+
if (backup) out.push(`备份:${backup}`);
|
|
159
|
+
if (indexBackup) out.push(`备份:${indexBackup}`);
|
|
160
|
+
out.push(`字节:${after}`);
|
|
161
|
+
out.push(`校验记录(贴进备份说明):| ${today()} | ${path.basename(target)}(+${built.counts.you} 轮) | ${after} | \`${sha256Hex16(target)}\` | 本体=原件,两处一致 |`);
|
|
162
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main();
|
package/cordis.patch.yml
ADDED
package/dsh.plugin.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-log-slicer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"CHANGELOG.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.15.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@deepseek-ai/dsh-tools": "*"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@deepseek-ai/dsh-tools": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test"
|
|
52
|
+
}
|
|
6
53
|
}
|
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
|
+
}
|
package/src/pipeline.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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, findPointerLines } 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
|
+
const pointerLines = findPointerLines(segment);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
log: log.path,
|
|
40
|
+
logSize: decoded.frames ? log.size || 0 : 0,
|
|
41
|
+
frames: decoded.frames,
|
|
42
|
+
failedFrames: decoded.failed,
|
|
43
|
+
totalTurns: turns.length,
|
|
44
|
+
segment,
|
|
45
|
+
counts,
|
|
46
|
+
imageCount: images.length,
|
|
47
|
+
images,
|
|
48
|
+
pointerLines,
|
|
49
|
+
markdown,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fsSize(p) {
|
|
54
|
+
try {
|
|
55
|
+
return fs.statSync(p).size;
|
|
56
|
+
} catch {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { applyImageNotes };
|
package/src/register.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
const cells = (line) => line.split('|').map((c) => c.trim());
|
|
32
|
+
/** 表头与分隔行不参与"这是该卷的行"的判断——卷名叫「卷」时会撞上表头。 */
|
|
33
|
+
const isStructural = (line) => {
|
|
34
|
+
const t = line.trim();
|
|
35
|
+
if (!t.startsWith('|')) return true;
|
|
36
|
+
if (t.includes('---')) return true;
|
|
37
|
+
const c = cells(t);
|
|
38
|
+
return c[1] === '卷' && (c[2] === '节' || c[2] === '标题');
|
|
39
|
+
};
|
|
40
|
+
let last = -1;
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const t = lines[i].trim();
|
|
43
|
+
if (isStructural(t)) continue;
|
|
44
|
+
if (t.startsWith(`| ${volume} |`) || t.startsWith(`|${volume}|`)) last = i;
|
|
45
|
+
}
|
|
46
|
+
if (last >= 0) {
|
|
47
|
+
lines.splice(last + 1, 0, row);
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
let headerSep = -1;
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
if (lines[i].trim().startsWith('|') && lines[i].includes('---')) headerSep = i;
|
|
53
|
+
}
|
|
54
|
+
if (headerSep < 0) throw new Error('index file has no markdown table');
|
|
55
|
+
let end = headerSep;
|
|
56
|
+
while (end + 1 < lines.length && lines[end + 1].trim().startsWith('|')) end++;
|
|
57
|
+
lines.splice(end + 1, 0, row);
|
|
58
|
+
return lines.join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 旧样名:`<名字>(<动作之前>).bak`;不写日期。 */
|
|
62
|
+
export function backupPath(target, tag, oldverDir) {
|
|
63
|
+
const base = path.basename(target, path.extname(target));
|
|
64
|
+
return path.join(oldverDir || path.join(path.dirname(target), '旧版本'), `${base}(${tag}).bak`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function backupFile(target, tag, oldverDir) {
|
|
68
|
+
const dir = oldverDir || path.join(path.dirname(target), '旧版本');
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
const dst = backupPath(target, tag, dir);
|
|
71
|
+
fs.copyFileSync(target, dst);
|
|
72
|
+
return dst;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 追加一节:先保证前面是空行,再写正文。 */
|
|
76
|
+
export function appendSection(target, markdown) {
|
|
77
|
+
const before = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
|
|
78
|
+
const needsGap = before.length > 0 && !before.endsWith('\n\n');
|
|
79
|
+
const body = (needsGap ? '\n' : '') + markdown;
|
|
80
|
+
fs.writeFileSync(target, before + body, 'utf8');
|
|
81
|
+
return { before: Buffer.byteLength(before, 'utf8'), after: fs.statSync(target).size };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function verificationLine({ date = today(), name, note, bytes, hash }) {
|
|
85
|
+
return `| ${date} | ${name}(${note}) | ${bytes} | \`${hash}\` | 本体=原件,两处一致 |`;
|
|
86
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
* 他的话里有没有"指路句"("我更新了第一轮""你可以看看那个文件")。
|
|
59
|
+
*
|
|
60
|
+
* 这类句子不是原话,是他往文件上指;录进正本会让后来的人以为那儿就是内容。
|
|
61
|
+
* 这里只负责认出来、报给调用方,怎么处理(去原件抄、或按三级不抄)由人决定。
|
|
62
|
+
*/
|
|
63
|
+
const POINTER_PATTERNS = [
|
|
64
|
+
/我(?:已经)?更新了第[一二三四五六七八九十\d]+轮/,
|
|
65
|
+
/你可以(?:看看|看一下|读一下|理解一下)/,
|
|
66
|
+
/这方面内容我们就(?:结束|到此)/,
|
|
67
|
+
/我(?:在)?工作区(?:更新|放)了/,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export function findPointerLines(segment) {
|
|
71
|
+
const hits = [];
|
|
72
|
+
segment.forEach((t, i) => {
|
|
73
|
+
if (t.who !== 'user') return;
|
|
74
|
+
const body = tidy(t.text);
|
|
75
|
+
for (const re of POINTER_PATTERNS) {
|
|
76
|
+
const m = body.match(re);
|
|
77
|
+
if (m) {
|
|
78
|
+
hits.push({ index: i, phrase: m[0], text: body.slice(0, 60) });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return hits;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 把 `(图未落 …)` 占位替换成正式图注。 */
|
|
87
|
+
export function applyImageNotes(markdown, mapping = {}) {
|
|
88
|
+
let out = markdown;
|
|
89
|
+
for (const [sha, note] of Object.entries(mapping)) {
|
|
90
|
+
const re = new RegExp(`(图未落:\`${sha}\`[^)]*)`, 'g');
|
|
91
|
+
out = out.replace(re, note);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
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
|
+
}
|