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 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 时才发现的问题。
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-log-slicer",
3
3
  "description": "Slice one section out of a DSH session log: verbatim turns, catalog row, backup and verification hash.",
4
- "version": "0.1.0",
4
+ "version": "0.1.4",
5
5
  "entry": {
6
6
  "name": "dsh-log-slicer",
7
7
  "inject": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-log-slicer",
3
- "version": "0.1.2",
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' && typeof x.text === 'string')
31
- .map((x) => x.text)
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 turnsFromText(text) {
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
- 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
- }
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
- * 在 $DSH_HOME/sessions 下找 session.v4.jsonl.zstd,按修改时间倒序。
67
- * sessions/<workspace>/<sessionId>/session.v4.jsonl.zstd
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
- const file = path.join(wsDir, s.name, 'session.v4.jsonl.zstd');
100
+ if (!s.isDirectory()) continue;
101
+ let files;
88
102
  try {
89
- const st = fs.statSync(file);
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) throw new Error('no session.v4.jsonl.zstd found under ' + (root || path.join(dshHome(), 'sessions')));
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
- * - apply(ctx) 里 ctx.tools.register(defineTool({...spec, execute}))
11
- * - apply 的异常一律吞掉:最坏是插件不工作,不能让应用起不来
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
- async function registerAll(ctx) {
92
- const defineTool = await loadDefineTool();
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
- const probe = defineTool({
95
- ...probeSpec,
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
- 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
- };
128
+ return execute(args);
113
129
  },
114
- });
130
+ };
131
+ }
115
132
 
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"');
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
- 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
- },
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
- const disposers = [ctx.tools.register(probe), ctx.tools.register(slice)];
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 { decodeFile, latestSessionLog } from './decode.js';
9
- import { turnsFromText } from './clean.js';
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 = turnsFromText(decoded.text);
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,