dsh-log-slicer 0.1.2 → 0.1.4
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 +19 -0
- package/bin/log-slicer.mjs +2 -2
- package/dsh.plugin.json +1 -1
- package/package.json +5 -5
- package/src/clean.js +107 -21
- package/src/decode.js +36 -7
- package/src/index.js +162 -82
- package/src/pipeline.js +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
这一版防的不是"宿主换个界面",是"宿主换套日志"。不加新功能,只把最容易断的几处改成认表;宿主真改了格式,第一眼能看见报错,而不是切出一节空文。
|
|
6
|
+
|
|
7
|
+
- **加宽**:日志文件名认 `session[.v<N>].jsonl.zstd` 与 `.zst`,不再写死 `session.v4`;`--list` 与两个工具的输出都带上认到的 schema 号。
|
|
8
|
+
- **加宽**:事件名认一张表(`user/message`、`message/user`、`assistant/message`、`message/assistant`、`user`、`assistant`、`model/message`、`message/model`);正文位置认 `data.content`、`data.message.content`、`data.message`、`content`、`message`、`text`、`data.text`,以及 `{content:[…]}`/`{parts:[…]}` 两种包装;文本块认 `{text}`、`{text:{value}}`、`input_text`、`output_text`。
|
|
9
|
+
- **改成大声失败**:一条轮次都认不出来时报 `LogSchemaError`,消息里带"读了多少事件、见过的事件类型各几条"——宿主升级之后,这一句就是诊断书。
|
|
10
|
+
- **加宽**:注册口先认 `ctx.tools.register`,认不到试 `ctx.tool()`,都没有才抛;`apply` 照旧吞异常,不带崩应用。
|
|
11
|
+
- **修**:`dsh.plugin.json` 的版本号一直停在 0.1.0,补到与包一致。
|
|
12
|
+
|
|
13
|
+
## 0.1.3
|
|
14
|
+
|
|
15
|
+
0.1.2 装进 profile 之后,重启没有任何反应——两个工具都没出现。查下来两处:
|
|
16
|
+
|
|
17
|
+
- **修**:注册的 definition 少了 `output`。宿主 `register()` 的硬要求是 `output = { schema, render }`,`render` 返回 ContentBlock[];只有 `name/description/parameters/execute` 会被直接拒。
|
|
18
|
+
- **修**:`@deepseek-ai/dsh-tools` 只是 peerDependency,装进 profile 后未必解析得到(实测 `ERR_MODULE_NOT_FOUND`)。原来解析不到就抛错,而 `apply` 又把异常吞掉了,于是整个插件静默失效。现在解析不到就自己构造一份同形状的 definition:少了参数校验,注册与调用照常。
|
|
19
|
+
|
|
20
|
+
两条一起解释了"CLI 能跑、装成插件是哑的"。
|
|
21
|
+
|
|
3
22
|
## 0.1.2
|
|
4
23
|
|
|
5
24
|
- **修**:`package.json` 里补上 `dsh.bundle.patch`,指向 `./cordis.patch.yml`。少了这一行,装成插件时宿主既不会读那个 patch,也就不会把两个工具注册进会话——CLI 照样能用,装成插件却是哑的。这是装进 profile 时才发现的问题。
|
package/bin/log-slicer.mjs
CHANGED
|
@@ -85,7 +85,7 @@ function main() {
|
|
|
85
85
|
return `${startNo + i}. [${who}] ${when} ${body}`;
|
|
86
86
|
});
|
|
87
87
|
process.stdout.write(
|
|
88
|
-
`# 日志 ${all.log}\n# ${all.frames} 帧(${all.failedFrames} 帧解不开)/ ${all.totalTurns} 轮有效\n\n` +
|
|
88
|
+
`# 日志 ${all.log}\n# schema ${all.schema ?? '?'} / ${all.frames} 帧(${all.failedFrames} 帧解不开)/ ${all.totalTurns} 轮有效\n\n` +
|
|
89
89
|
lines.join('\n') + '\n',
|
|
90
90
|
);
|
|
91
91
|
return;
|
|
@@ -113,7 +113,7 @@ function main() {
|
|
|
113
113
|
|
|
114
114
|
const out = [];
|
|
115
115
|
out.push(`# 日志 ${built.log}`);
|
|
116
|
-
out.push(`# ${(built.logSize / 1048576).toFixed(1)} MB / ${built.frames} 帧(${built.failedFrames} 帧解不开)/ ${built.totalTurns} 轮有效`);
|
|
116
|
+
out.push(`# schema ${built.schema ?? '?'} / ${(built.logSize / 1048576).toFixed(1)} MB / ${built.frames} 帧(${built.failedFrames} 帧解不开)/ ${built.totalTurns} 轮有效`);
|
|
117
117
|
out.push(`# 切成 ${built.segment.length} 轮,其中你 ${built.counts.you} 轮、我 ${built.counts.me} 轮`);
|
|
118
118
|
if (built.imageCount) {
|
|
119
119
|
out.push(`# 图片 ${built.imageCount} 张:`);
|
package/dsh.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-log-slicer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"engines": {
|
|
40
40
|
"node": ">=22.15.0"
|
|
41
41
|
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test"
|
|
44
|
+
},
|
|
42
45
|
"peerDependencies": {
|
|
43
46
|
"@deepseek-ai/dsh-tools": "*"
|
|
44
47
|
},
|
|
@@ -51,8 +54,5 @@
|
|
|
51
54
|
"bundle": {
|
|
52
55
|
"patch": "./cordis.patch.yml"
|
|
53
56
|
}
|
|
54
|
-
},
|
|
55
|
-
"scripts": {
|
|
56
|
-
"test": "node --test"
|
|
57
57
|
}
|
|
58
|
-
}
|
|
58
|
+
}
|
package/src/clean.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 只留人和模型真正说的话:宿主注入的环境文本、图片占位行都扔掉。
|
|
5
5
|
* 图片留下 sha256 与媒体类型,供上层落成文件。
|
|
6
|
+
*
|
|
7
|
+
* 这一层是"宿主换版本最先崩的地方":事件名、正文容器的形状都可能变。
|
|
8
|
+
* 所以这里不认死一个名字,认一张表;表里没见过的形状先跳过,
|
|
9
|
+
* 最后如果一条轮次都没认出来,就报错并列出见过的事件类型——宁可当场停下,
|
|
10
|
+
* 也不要静悄悄切出一节空文。
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
const ENV_PREFIXES = [
|
|
@@ -16,6 +21,37 @@ const ENV_PREFIXES = [
|
|
|
16
21
|
|
|
17
22
|
const IMAGE_PLACEHOLDER = /^Image "[^"]+" \(sha256:[0-9a-f]{4,}/i;
|
|
18
23
|
|
|
24
|
+
/** 事件名 → 说话的人。见过的名字都收在这里;宿主改名时往这里加一行就行。 */
|
|
25
|
+
const EVENT_ROLES = {
|
|
26
|
+
'user/message': 'user',
|
|
27
|
+
'message/user': 'user',
|
|
28
|
+
'user': 'user',
|
|
29
|
+
'assistant/message': 'me',
|
|
30
|
+
'message/assistant': 'me',
|
|
31
|
+
'assistant': 'me',
|
|
32
|
+
'model/message': 'me',
|
|
33
|
+
'message/model': 'me',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** 正文可能待在的几个位置,按先后试。 */
|
|
37
|
+
const CONTENT_PATHS = [
|
|
38
|
+
(ev) => ev.data && ev.data.content,
|
|
39
|
+
(ev) => ev.data && ev.data.message && ev.data.message.content,
|
|
40
|
+
(ev) => ev.data && ev.data.message,
|
|
41
|
+
(ev) => ev.content,
|
|
42
|
+
(ev) => ev.message,
|
|
43
|
+
(ev) => ev.text,
|
|
44
|
+
(ev) => ev.data && ev.data.text,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export class LogSchemaError extends Error {
|
|
48
|
+
constructor(message, eventTypes = []) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'LogSchemaError';
|
|
51
|
+
this.eventTypes = eventTypes;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
19
55
|
export function isEnvironmentText(text) {
|
|
20
56
|
if (typeof text !== 'string') return true;
|
|
21
57
|
const t = text.trim();
|
|
@@ -24,11 +60,43 @@ export function isEnvironmentText(text) {
|
|
|
24
60
|
return ENV_PREFIXES.some((p) => t.startsWith(p));
|
|
25
61
|
}
|
|
26
62
|
|
|
63
|
+
/** 正文块可能是字符串、`{text}`、`{text:{value}}`;都收。 */
|
|
64
|
+
function blockText(x) {
|
|
65
|
+
if (typeof x === 'string') return x;
|
|
66
|
+
if (!x || typeof x !== 'object') return '';
|
|
67
|
+
if (typeof x.text === 'string') return x.text;
|
|
68
|
+
if (x.text && typeof x.text === 'object' && typeof x.text.value === 'string') return x.text.value;
|
|
69
|
+
if (typeof x.value === 'string') return x.value;
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function itemsOf(ev) {
|
|
74
|
+
for (const pick of CONTENT_PATHS) {
|
|
75
|
+
let v;
|
|
76
|
+
try {
|
|
77
|
+
v = pick(ev);
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (v == null) continue;
|
|
82
|
+
if (typeof v === 'string') return v ? [{ type: 'text', text: v }] : [];
|
|
83
|
+
if (Array.isArray(v)) return v;
|
|
84
|
+
if (typeof v === 'object') {
|
|
85
|
+
// 有些版本把正文包成 {content:[...]} 或 {parts:[...]}
|
|
86
|
+
if (Array.isArray(v.content)) return v.content;
|
|
87
|
+
if (Array.isArray(v.parts)) return v.parts;
|
|
88
|
+
if (typeof v.text === 'string') return [{ type: 'text', text: v.text }];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
27
94
|
function textOf(items) {
|
|
28
95
|
if (!Array.isArray(items)) return '';
|
|
29
96
|
return items
|
|
30
|
-
.filter((x) => x && x.type === 'text'
|
|
31
|
-
.map(
|
|
97
|
+
.filter((x) => x && (x.type === 'text' || x.type === 'input_text' || x.type === 'output_text'))
|
|
98
|
+
.map(blockText)
|
|
99
|
+
.filter(Boolean)
|
|
32
100
|
.join('\n');
|
|
33
101
|
}
|
|
34
102
|
|
|
@@ -37,19 +105,21 @@ function imagesOf(items) {
|
|
|
37
105
|
const out = [];
|
|
38
106
|
for (const x of items) {
|
|
39
107
|
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 || '' });
|
|
108
|
+
const a = x.attachment || x.source || {};
|
|
109
|
+
const id = String(a.attachmentId || a.id || a.sha256 || '').replace(/^sha256:sha256:/, 'sha256:');
|
|
110
|
+
out.push({ id, sha256: id.replace(/^sha256:/, ''), mediaType: a.mediaType || a.media_type || '' });
|
|
43
111
|
}
|
|
44
112
|
return out;
|
|
45
113
|
}
|
|
46
114
|
|
|
47
115
|
/**
|
|
48
116
|
* @param {string} text 解压后的 JSONL 文本
|
|
49
|
-
* @returns {Array<{who: 'user'|'me', ts: number|null, text: string, images: Array}>}
|
|
117
|
+
* @returns {{turns: Array<{who: 'user'|'me', ts: number|null, text: string, images: Array}>, eventTypes: Record<string, number>}}
|
|
50
118
|
*/
|
|
51
|
-
export function
|
|
119
|
+
export function parseTurnLog(text) {
|
|
52
120
|
const turns = [];
|
|
121
|
+
const eventTypes = {};
|
|
122
|
+
let events = 0;
|
|
53
123
|
for (const line of String(text).split('\n')) {
|
|
54
124
|
if (!line.trim()) continue;
|
|
55
125
|
let ev;
|
|
@@ -59,19 +129,35 @@ export function turnsFromText(text) {
|
|
|
59
129
|
continue;
|
|
60
130
|
}
|
|
61
131
|
if (!ev || typeof ev !== 'object') continue;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
132
|
+
events++;
|
|
133
|
+
const type = String(ev.type ?? '');
|
|
134
|
+
eventTypes[type] = (eventTypes[type] || 0) + 1;
|
|
135
|
+
const role = EVENT_ROLES[type];
|
|
136
|
+
if (!role) continue;
|
|
137
|
+
const ts = ev.time ?? ev.timestamp ?? (ev.data && ev.data.time) ?? null;
|
|
138
|
+
const items = itemsOf(ev);
|
|
139
|
+
const body = textOf(items);
|
|
140
|
+
const images = role === 'user' ? imagesOf(items) : [];
|
|
141
|
+
if (isEnvironmentText(body) && images.length === 0) continue;
|
|
142
|
+
turns.push({ who: role, ts, text: body.trim(), images });
|
|
143
|
+
}
|
|
144
|
+
if (turns.length === 0 && events > 0) {
|
|
145
|
+
const top = Object.entries(eventTypes)
|
|
146
|
+
.sort((a, b) => b[1] - a[1])
|
|
147
|
+
.slice(0, 8)
|
|
148
|
+
.map(([k, v]) => `${k || '(空)'}×${v}`)
|
|
149
|
+
.join('、');
|
|
150
|
+
throw new LogSchemaError(
|
|
151
|
+
`read ${events} events but recognised no turns (event types: ${top}). ` +
|
|
152
|
+
'The host log format probably changed — add the new names to EVENT_ROLES in src/clean.js, ' +
|
|
153
|
+
'or check CONTENT_PATHS if the names are the same but the body moved.',
|
|
154
|
+
Object.keys(eventTypes),
|
|
155
|
+
);
|
|
75
156
|
}
|
|
76
|
-
return turns;
|
|
157
|
+
return { turns, eventTypes };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 兼容旧调用:只要轮次。 */
|
|
161
|
+
export function turnsFromText(text) {
|
|
162
|
+
return parseTurnLog(text).turns;
|
|
77
163
|
}
|
package/src/decode.js
CHANGED
|
@@ -63,8 +63,21 @@ export function decodeFile(filePath, options) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
66
|
+
* 日志文件的形状:session.v4.jsonl.zstd、session.v5.jsonl.zstd、session.jsonl.zst……
|
|
67
|
+
* 版本号只用来排序与记录;不拿它当"认不认这份日志"的门槛——宿主改版本时,
|
|
68
|
+
* 这里多认一个文件名,比让整条链路失明强。
|
|
69
|
+
*/
|
|
70
|
+
const SESSION_FILE = /^session(?:\.v(\d+))?\.jsonl\.(?:zstd|zst)$/i;
|
|
71
|
+
|
|
72
|
+
export function schemaOf(fileName) {
|
|
73
|
+
const m = SESSION_FILE.exec(path.basename(fileName));
|
|
74
|
+
if (!m) return null;
|
|
75
|
+
return m[1] ? Number(m[1]) : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 在 $DSH_HOME/sessions 下找会话日志,按修改时间倒序。
|
|
80
|
+
* sessions/<workspace>/<sessionId>/session.v<N>.jsonl.zstd
|
|
68
81
|
*/
|
|
69
82
|
export function findSessionLogs(root = path.join(dshHome(), 'sessions')) {
|
|
70
83
|
const out = [];
|
|
@@ -84,12 +97,24 @@ export function findSessionLogs(root = path.join(dshHome(), 'sessions')) {
|
|
|
84
97
|
continue;
|
|
85
98
|
}
|
|
86
99
|
for (const s of subs) {
|
|
87
|
-
|
|
100
|
+
if (!s.isDirectory()) continue;
|
|
101
|
+
let files;
|
|
88
102
|
try {
|
|
89
|
-
|
|
90
|
-
out.push({ path: file, workspace: ws.name, session: s.name, mtime: st.mtimeMs, size: st.size });
|
|
103
|
+
files = fs.readdirSync(path.join(wsDir, s.name), { withFileTypes: true });
|
|
91
104
|
} catch {
|
|
92
|
-
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
if (!f.isFile()) continue;
|
|
109
|
+
const schema = schemaOf(f.name);
|
|
110
|
+
if (schema == null) continue;
|
|
111
|
+
const file = path.join(wsDir, s.name, f.name);
|
|
112
|
+
try {
|
|
113
|
+
const st = fs.statSync(file);
|
|
114
|
+
out.push({ path: file, workspace: ws.name, session: s.name, schema, mtime: st.mtimeMs, size: st.size });
|
|
115
|
+
} catch {
|
|
116
|
+
/* 读不到就算了 */
|
|
117
|
+
}
|
|
93
118
|
}
|
|
94
119
|
}
|
|
95
120
|
}
|
|
@@ -99,6 +124,10 @@ export function findSessionLogs(root = path.join(dshHome(), 'sessions')) {
|
|
|
99
124
|
|
|
100
125
|
export function latestSessionLog(root) {
|
|
101
126
|
const logs = findSessionLogs(root);
|
|
102
|
-
if (!logs.length)
|
|
127
|
+
if (!logs.length) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
'no session log found under ' + (root || path.join(dshHome(), 'sessions')) + ' — looked for session[.v<N>].jsonl.zstd/.zst',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
103
132
|
return logs[0];
|
|
104
133
|
}
|
package/src/index.js
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
* log_probe 看日志里有什么(轮次、时间、可用的锚点),只读、便宜
|
|
6
6
|
* log_slice 切一节出来;默认 dry-run,写盘要 apply === true
|
|
7
7
|
*
|
|
8
|
-
* 契约(对着 @deepseek-ai/dsh-tools
|
|
8
|
+
* 契约(对着 @deepseek-ai/dsh-tools 的 README 与 register() 的实现核对过):
|
|
9
9
|
* - export const inject = ['tools']
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
10
|
+
* - 注册的 definition 必须带 output = { schema, render },缺了会被 register() 直接拒
|
|
11
|
+
* - execute 返回的是"规范值"(JSON),render(args, value) 把它渲染成 ContentBlock[]
|
|
12
|
+
*
|
|
13
|
+
* 这里有一个实际的坑:装进 profile 之后,`@deepseek-ai/dsh-tools` 未必能从插件目录
|
|
14
|
+
* 解析到(它只是 peerDependency,宿主也没有把它放进 profile 的 node_modules)。
|
|
15
|
+
* 所以这里两手准备:能拿到 defineTool 就用它(带参数校验),拿不到就自己构造一份
|
|
16
|
+
* 同样形状的 definition —— 少了参数校验,但注册与调用都正常。
|
|
17
|
+
*
|
|
18
|
+
* apply 的异常一律吞掉:最坏是插件不工作,不能让应用起不来。
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
21
|
import fs from 'node:fs';
|
|
@@ -19,22 +26,6 @@ import { indexRow, insertIndexRow, backupFile, appendSection, sha256Hex16, today
|
|
|
19
26
|
export const name = 'dsh-log-slicer';
|
|
20
27
|
export const inject = ['tools'];
|
|
21
28
|
|
|
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
29
|
export const probeSpec = {
|
|
39
30
|
name: 'log_probe',
|
|
40
31
|
description: [
|
|
@@ -88,76 +79,165 @@ export const sliceSpec = {
|
|
|
88
79
|
},
|
|
89
80
|
};
|
|
90
81
|
|
|
91
|
-
|
|
92
|
-
|
|
82
|
+
/** 把本文件的简写参数表转成 JSON Schema(defineTool 那条路会自己转,这条路自己转)。 */
|
|
83
|
+
export function toJsonSchema(parameters = {}) {
|
|
84
|
+
const properties = {};
|
|
85
|
+
const required = [];
|
|
86
|
+
for (const [key, raw] of Object.entries(parameters)) {
|
|
87
|
+
const { required: isRequired, ...rest } = raw;
|
|
88
|
+
properties[key] = { ...rest };
|
|
89
|
+
if (isRequired) required.push(key);
|
|
90
|
+
}
|
|
91
|
+
const schema = { type: 'object', properties, additionalProperties: false };
|
|
92
|
+
if (required.length) schema.required = required;
|
|
93
|
+
return schema;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 拿 defineTool;拿不到返回 null(装进 profile 时很可能拿不到)。 */
|
|
97
|
+
async function loadDefineTool() {
|
|
98
|
+
try {
|
|
99
|
+
const mod = await import('@deepseek-ai/dsh-tools');
|
|
100
|
+
if (typeof mod?.defineTool === 'function') return mod.defineTool;
|
|
101
|
+
} catch {
|
|
102
|
+
/* 落到下面的自造 */
|
|
103
|
+
}
|
|
104
|
+
const override = process.env.DSH_TOOLS_PATH;
|
|
105
|
+
if (override) {
|
|
106
|
+
try {
|
|
107
|
+
const { pathToFileURL } = await import('node:url');
|
|
108
|
+
const mod = await import(pathToFileURL(override).href);
|
|
109
|
+
if (typeof mod?.defineTool === 'function') return mod.defineTool;
|
|
110
|
+
} catch {
|
|
111
|
+
/* 一样落到自造 */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
93
116
|
|
|
94
|
-
|
|
95
|
-
|
|
117
|
+
/** 与 defineTool 产出同形状的 definition:register() 只要求 output.schema + output.render。 */
|
|
118
|
+
export function plainTool({ name: toolName, description, parameters, execute }) {
|
|
119
|
+
return {
|
|
120
|
+
name: toolName,
|
|
121
|
+
description,
|
|
122
|
+
parameters: toJsonSchema(parameters),
|
|
123
|
+
output: {
|
|
124
|
+
schema: { type: 'string' },
|
|
125
|
+
render: (_args, value) => [{ type: 'text', text: String(value ?? '') }],
|
|
126
|
+
},
|
|
96
127
|
async execute(args = {}) {
|
|
97
|
-
|
|
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
|
-
};
|
|
128
|
+
return execute(args);
|
|
113
129
|
},
|
|
114
|
-
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
115
132
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!args.file) throw new Error('apply=true requires "file"');
|
|
133
|
+
function define(defineTool, spec, execute) {
|
|
134
|
+
if (defineTool) {
|
|
135
|
+
return defineTool({
|
|
136
|
+
name: spec.name,
|
|
137
|
+
description: spec.description,
|
|
138
|
+
parameters: spec.parameters,
|
|
139
|
+
output: {
|
|
140
|
+
schema: { type: 'string' },
|
|
141
|
+
render: (_args, value) => [{ type: 'text', text: String(value ?? '') }],
|
|
142
|
+
},
|
|
143
|
+
async execute(args) {
|
|
144
|
+
return execute(args);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return plainTool({ ...spec, execute });
|
|
149
|
+
}
|
|
134
150
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
151
|
+
async function probe({ logPath, limit, width } = {}) {
|
|
152
|
+
const n = Math.max(1, Math.min(500, Number(limit) || 40));
|
|
153
|
+
const w = Math.max(10, Math.min(400, Number(width) || 60));
|
|
154
|
+
const built = buildSection({ kind: 'chat', title: 'probe', logPath });
|
|
155
|
+
const turns = built.segment.length ? built.segment : [];
|
|
156
|
+
const start = turns.length - Math.min(n, turns.length);
|
|
157
|
+
const lines = turns.slice(-n).map((t, i) => {
|
|
158
|
+
const who = t.who === 'user' ? 'you' : 'me';
|
|
159
|
+
const when = t.ts ? new Date(Number(t.ts) || t.ts).toLocaleString() : '';
|
|
160
|
+
const body = String(t.text || '').replace(/\s+/g, ' ').slice(0, w);
|
|
161
|
+
return `${start + i + 1}. [${who}] ${when} ${body}`;
|
|
162
|
+
});
|
|
163
|
+
return [
|
|
164
|
+
`# ${built.log}`,
|
|
165
|
+
`# schema ${built.schema ?? '?'} / ${built.frames} frames (${built.failedFrames} failed) / ${built.totalTurns} turns`,
|
|
166
|
+
'',
|
|
167
|
+
lines.join('\n'),
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function slice(args = {}) {
|
|
172
|
+
const built = buildSection({
|
|
173
|
+
kind: args.kind === 'work' ? 'work' : 'chat',
|
|
174
|
+
title: args.title,
|
|
175
|
+
from: args.from,
|
|
176
|
+
to: args.to,
|
|
177
|
+
logPath: args.logPath,
|
|
158
178
|
});
|
|
179
|
+
const head = [
|
|
180
|
+
`# ${built.log}`,
|
|
181
|
+
`# schema ${built.schema ?? '?'} / ${built.frames} frames (${built.failedFrames} failed) / ${built.totalTurns} turns`,
|
|
182
|
+
`# sliced ${built.segment.length} turns (you ${built.counts.you} / me ${built.counts.me})`,
|
|
183
|
+
].join('\n');
|
|
184
|
+
|
|
185
|
+
const pointerNote = built.pointerLines?.length
|
|
186
|
+
? `\n# note: ${built.pointerLines.length} turn(s) look like pointers to a file, not the words themselves —` +
|
|
187
|
+
'\n# copy the real paragraphs from that file; if they are grading-3, do not quote them at all.' +
|
|
188
|
+
'\n# ' + built.pointerLines.map((l) => l.text).join('\n# ')
|
|
189
|
+
: '';
|
|
190
|
+
|
|
191
|
+
if (args.apply !== true) {
|
|
192
|
+
return `${head}${pointerNote}\n\n===== markdown (dry-run) =====\n\n${built.markdown}`;
|
|
193
|
+
}
|
|
194
|
+
if (!args.file) throw new Error('apply=true requires "file"');
|
|
195
|
+
|
|
196
|
+
const target = path.resolve(args.file);
|
|
197
|
+
const tag = args.tag || '自动整理之前';
|
|
198
|
+
const backup = fs.existsSync(target) ? backupFile(target, tag) : null;
|
|
199
|
+
const { after } = appendSection(target, built.markdown);
|
|
200
|
+
const volume = args.volume || path.basename(target, '.md');
|
|
201
|
+
const row = indexRow(volume, args.title, built.counts.you);
|
|
202
|
+
let indexBackup = null;
|
|
203
|
+
let indexPath = null;
|
|
204
|
+
if (args.index && fs.existsSync(args.index)) {
|
|
205
|
+
indexPath = path.resolve(args.index);
|
|
206
|
+
indexBackup = backupFile(indexPath, tag);
|
|
207
|
+
const updated = insertIndexRow(fs.readFileSync(indexPath, 'utf8'), row, volume);
|
|
208
|
+
fs.writeFileSync(indexPath, updated, 'utf8');
|
|
209
|
+
}
|
|
210
|
+
const line = `| ${today()} | ${path.basename(target)}(+${built.counts.you} 轮) | ${after} | \`${sha256Hex16(target)}\` | 本体=原件,两处一致 |`;
|
|
211
|
+
return [
|
|
212
|
+
head,
|
|
213
|
+
pointerNote,
|
|
214
|
+
'',
|
|
215
|
+
`written: ${target}`,
|
|
216
|
+
backup ? `backup: ${backup}` : '',
|
|
217
|
+
indexBackup ? `backup: ${indexBackup}` : '',
|
|
218
|
+
indexPath ? `index: ${indexPath}` : '',
|
|
219
|
+
`bytes: ${after}`,
|
|
220
|
+
`row: ${row}`,
|
|
221
|
+
`verification: ${line}`,
|
|
222
|
+
]
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
.join('\n');
|
|
225
|
+
}
|
|
159
226
|
|
|
160
|
-
|
|
227
|
+
async function registerAll(ctx) {
|
|
228
|
+
const defineTool = await loadDefineTool();
|
|
229
|
+
// 宿主的注册口可能在版本间挪位置:先认 ctx.tools.register,认不到就试 ctx.tool(),
|
|
230
|
+
// 都没有才抛——抛了会在 apply 里被记一行日志,不会带崩应用。
|
|
231
|
+
const register = typeof ctx?.tools?.register === 'function'
|
|
232
|
+
? ctx.tools.register.bind(ctx.tools)
|
|
233
|
+
: typeof ctx?.tool === 'function'
|
|
234
|
+
? ctx.tool.bind(ctx)
|
|
235
|
+
: null;
|
|
236
|
+
if (!register) throw new Error('host exposes neither ctx.tools.register nor ctx.tool');
|
|
237
|
+
const disposers = [
|
|
238
|
+
register(define(defineTool, probeSpec, probe)),
|
|
239
|
+
register(define(defineTool, sliceSpec, slice)),
|
|
240
|
+
];
|
|
161
241
|
return () => {
|
|
162
242
|
for (const d of disposers.reverse()) d?.();
|
|
163
243
|
};
|
package/src/pipeline.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { decodeFile, latestSessionLog, schemaOf } from './decode.js';
|
|
10
|
+
import { parseTurnLog } from './clean.js';
|
|
10
11
|
import { sliceTurns, countTurns } from './slice.js';
|
|
11
12
|
import { renderChat, renderWork, applyImageNotes, findPointerLines } from './render.js';
|
|
12
13
|
|
|
@@ -23,9 +24,9 @@ export function buildSection(options = {}) {
|
|
|
23
24
|
const { kind = 'chat', title, from, to, logPath, workHeader } = options;
|
|
24
25
|
if (!title) throw new Error('title is required');
|
|
25
26
|
|
|
26
|
-
const log = logPath ? { path: logPath, size: fsSize(logPath), mtime: 0 } : latestSessionLog();
|
|
27
|
+
const log = logPath ? { path: logPath, size: fsSize(logPath), mtime: 0, schema: schemaOf(logPath) } : latestSessionLog();
|
|
27
28
|
const decoded = decodeFile(log.path);
|
|
28
|
-
const turns =
|
|
29
|
+
const { turns, eventTypes } = parseTurnLog(decoded.text);
|
|
29
30
|
const segment = sliceTurns(turns, { from, to });
|
|
30
31
|
const counts = countTurns(segment);
|
|
31
32
|
|
|
@@ -37,9 +38,12 @@ export function buildSection(options = {}) {
|
|
|
37
38
|
|
|
38
39
|
return {
|
|
39
40
|
log: log.path,
|
|
41
|
+
logFile: path.basename(log.path),
|
|
42
|
+
schema: log.schema ?? null,
|
|
40
43
|
logSize: decoded.frames ? log.size || 0 : 0,
|
|
41
44
|
frames: decoded.frames,
|
|
42
45
|
failedFrames: decoded.failed,
|
|
46
|
+
eventTypes,
|
|
43
47
|
totalTurns: turns.length,
|
|
44
48
|
segment,
|
|
45
49
|
counts,
|