dsh-log-slicer 0.1.3 → 0.1.5
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 +12 -0
- package/bin/log-slicer.mjs +2 -2
- package/dsh.plugin.json +1 -1
- package/package.json +6 -6
- package/src/clean.js +107 -21
- package/src/decode.js +36 -7
- package/src/index.js +12 -4
- package/src/pipeline.js +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.1.5
|
|
4
|
+
|
|
5
|
+
(0.1.4 被 registry 的一次半途 PUT 占了号,发不出去;内容原样并入本版。)
|
|
6
|
+
|
|
7
|
+
这一版防的不是"宿主换个界面",是"宿主换套日志"。不加新功能,只把最容易断的几处改成认表;宿主真改了格式,第一眼能看见报错,而不是切出一节空文。
|
|
8
|
+
|
|
9
|
+
- **加宽**:日志文件名认 `session[.v<N>].jsonl.zstd` 与 `.zst`,不再写死 `session.v4`;`--list` 与两个工具的输出都带上认到的 schema 号。
|
|
10
|
+
- **加宽**:事件名认一张表(`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`。
|
|
11
|
+
- **改成大声失败**:一条轮次都认不出来时报 `LogSchemaError`,消息里带"读了多少事件、见过的事件类型各几条"——宿主升级之后,这一句就是诊断书。
|
|
12
|
+
- **加宽**:注册口先认 `ctx.tools.register`,认不到试 `ctx.tool()`,都没有才抛;`apply` 照旧吞异常,不带崩应用。
|
|
13
|
+
- **修**:`dsh.plugin.json` 的版本号一直停在 0.1.0,补到与包一致。
|
|
14
|
+
|
|
3
15
|
## 0.1.3
|
|
4
16
|
|
|
5
17
|
0.1.2 装进 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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-log-slicer",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Slice one section out of a DeepSeek Harness session log: verbatim turns, images, catalog row, backups and a verification hash
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
|
7
7
|
"dsh-plugin",
|
|
@@ -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
|
@@ -162,7 +162,7 @@ async function probe({ logPath, limit, width } = {}) {
|
|
|
162
162
|
});
|
|
163
163
|
return [
|
|
164
164
|
`# ${built.log}`,
|
|
165
|
-
`# ${built.frames} frames (${built.failedFrames} failed) / ${built.totalTurns} turns`,
|
|
165
|
+
`# schema ${built.schema ?? '?'} / ${built.frames} frames (${built.failedFrames} failed) / ${built.totalTurns} turns`,
|
|
166
166
|
'',
|
|
167
167
|
lines.join('\n'),
|
|
168
168
|
].join('\n');
|
|
@@ -178,7 +178,7 @@ async function slice(args = {}) {
|
|
|
178
178
|
});
|
|
179
179
|
const head = [
|
|
180
180
|
`# ${built.log}`,
|
|
181
|
-
`# ${built.frames} frames (${built.failedFrames} failed) / ${built.totalTurns} turns`,
|
|
181
|
+
`# schema ${built.schema ?? '?'} / ${built.frames} frames (${built.failedFrames} failed) / ${built.totalTurns} turns`,
|
|
182
182
|
`# sliced ${built.segment.length} turns (you ${built.counts.you} / me ${built.counts.me})`,
|
|
183
183
|
].join('\n');
|
|
184
184
|
|
|
@@ -226,9 +226,17 @@ async function slice(args = {}) {
|
|
|
226
226
|
|
|
227
227
|
async function registerAll(ctx) {
|
|
228
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');
|
|
229
237
|
const disposers = [
|
|
230
|
-
|
|
231
|
-
|
|
238
|
+
register(define(defineTool, probeSpec, probe)),
|
|
239
|
+
register(define(defineTool, sliceSpec, slice)),
|
|
232
240
|
];
|
|
233
241
|
return () => {
|
|
234
242
|
for (const d of disposers.reverse()) d?.();
|
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,
|