dsh-log-contract 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/bin/dsh-log-contract.mjs +73 -2
- package/lib/archaeology.js +159 -0
- package/lib/checks.js +130 -0
- package/lib/contracts.js +24 -0
- package/lib/index.js +3 -1
- package/lib/prewrite.js +21 -1
- package/lib/repair.js +168 -2
- package/lib/validate.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -183,3 +183,20 @@ node scripts/check-local-fossils.mjs # 扫描 ../ 下 backup-session-*.jsonl.z
|
|
|
183
183
|
## 许可
|
|
184
184
|
|
|
185
185
|
MIT © OfferKuai Team
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
## 🧭 会话考古(extract / audit-report)
|
|
189
|
+
|
|
190
|
+
DSH 会话日志持久化了每次工具调用的完整输入输出——数据资产与审计资产。
|
|
191
|
+
本工具提供只读考古能力:
|
|
192
|
+
|
|
193
|
+
```sh
|
|
194
|
+
# 按命令正则导出工具输出(保留原始文本)
|
|
195
|
+
dsh-log-contract extract <session-log> --pattern "seed-scale" --min-size 50 --out ./found
|
|
196
|
+
|
|
197
|
+
# 考古审计报告:调用数 / 配对率 / 孤儿数 / 命令分布
|
|
198
|
+
dsh-log-contract audit-report <session-log>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
契约规则 P3(tool/call↔tool/result 配对完整性)与 P4(输出结构可解析)
|
|
202
|
+
守护"挖得动":孤儿调用、text 字段异常在 check 中告警。
|
package/bin/dsh-log-contract.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* contracts 列出内置契约规则目录
|
|
14
14
|
*/
|
|
15
15
|
import fs from 'node:fs';
|
|
16
|
-
import { loadSessionLog, validateSessionLog, createPreWriter, repairSession, CONTRACT_RULES, ruleById } from '../lib/index.js';
|
|
16
|
+
import { loadSessionLog, validateSessionLog, createPreWriter, repairSession, CONTRACT_RULES, ruleById, extractToolOutputs, auditToolCalls } from '../lib/index.js';
|
|
17
17
|
|
|
18
18
|
const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log contract guard)
|
|
19
19
|
|
|
@@ -39,6 +39,13 @@ const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log cont
|
|
|
39
39
|
{ "edit": [ ...事件列表... ] } 帧级手术后的完整事件列表
|
|
40
40
|
判定通过/拒绝并列出全部违规(三层契约:持久化/引擎/插件)。
|
|
41
41
|
|
|
42
|
+
dsh-log-contract extract <session-log> --pattern <regex> [--out DIR] [--min-size N] [--json]
|
|
43
|
+
考古提取:按命令正则导出工具输出(只读)。--out 写到目录(保留原始文本),
|
|
44
|
+
否则打印前 3 条摘要。--min-size 过滤小输出(默认 50,任务书口径)。
|
|
45
|
+
|
|
46
|
+
dsh-log-contract audit-report <session-log> [--json]
|
|
47
|
+
考古审计报告:调用数 / 配对率 / 孤儿数 / 命令分布。
|
|
48
|
+
|
|
42
49
|
dsh-log-contract contracts
|
|
43
50
|
列出内置契约规则目录(含官方源码出处)。
|
|
44
51
|
|
|
@@ -164,13 +171,15 @@ function cmdFix(args) {
|
|
|
164
171
|
const dropFailedTurns = args.includes('--drop-failed-turns');
|
|
165
172
|
const trimIdx = args.indexOf('--trim-last');
|
|
166
173
|
const trimLast = trimIdx >= 0 && args[trimIdx + 1] ? Number(args[trimIdx + 1]) : undefined;
|
|
174
|
+
const compactIdx = args.indexOf('--compact-last');
|
|
175
|
+
const compactLast = compactIdx >= 0 && args[compactIdx + 1] ? Number(args[compactIdx + 1]) : undefined;
|
|
167
176
|
const apply = args.includes('--apply');
|
|
168
177
|
const backupDirIdx = args.indexOf('--backup-dir');
|
|
169
178
|
const backupDir = backupDirIdx >= 0 && args[backupDirIdx + 1] ? args[backupDirIdx + 1] : undefined;
|
|
170
179
|
const file = args.find((a) => !a.startsWith('-'));
|
|
171
180
|
if (!file) fail(USAGE);
|
|
172
181
|
|
|
173
|
-
const result = repairSession(file, { removeMarkers, dropFailedTurns, trimLast, apply, backupDir });
|
|
182
|
+
const result = repairSession(file, { removeMarkers, dropFailedTurns, trimLast, compactLast, apply, backupDir });
|
|
174
183
|
if (json) {
|
|
175
184
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
176
185
|
process.exit(result.ok ? 0 : 1);
|
|
@@ -193,6 +202,66 @@ function cmdFix(args) {
|
|
|
193
202
|
process.exit(result.ok ? 0 : 1);
|
|
194
203
|
}
|
|
195
204
|
|
|
205
|
+
function cmdExtract(args) {
|
|
206
|
+
const json = args.includes('--json');
|
|
207
|
+
const outIdx = args.indexOf('--out');
|
|
208
|
+
const outDir = outIdx >= 0 && args[outIdx + 1] ? args[outIdx + 1] : undefined;
|
|
209
|
+
const minIdx = args.indexOf('--min-size');
|
|
210
|
+
const minSize = minIdx >= 0 && args[minIdx + 1] ? Number(args[minIdx + 1]) : 50;
|
|
211
|
+
const patternIdx = args.indexOf('--pattern');
|
|
212
|
+
const pattern = patternIdx >= 0 && args[patternIdx + 1] ? args[patternIdx + 1] : '';
|
|
213
|
+
const file = args.find((a) => !a.startsWith('-'));
|
|
214
|
+
if (!file || pattern === '') fail('extract 需要 <session-log> 与 --pattern <regex>');
|
|
215
|
+
|
|
216
|
+
const log = loadSessionLog(file);
|
|
217
|
+
const { pairs, total } = extractToolOutputs(log.events.map((e) => e.event), pattern, { minSize });
|
|
218
|
+
if (json) {
|
|
219
|
+
process.stdout.write(JSON.stringify({ file, pattern, matched: pairs.length, total, pairs: pairs.map((p) => ({ callId: p.callId, command: p.command, size: p.size })) }, null, 2) + '\n');
|
|
220
|
+
process.exit(0);
|
|
221
|
+
}
|
|
222
|
+
process.stdout.write(`\n🔍 dsh-log-contract extract —— ${file}\n`);
|
|
223
|
+
process.stdout.write(` 命令正则:/${pattern}/ | 匹配 ${pairs.length} 个输出(共 ${total} 个工具调用,min-size ${minSize})\n`);
|
|
224
|
+
if (outDir) {
|
|
225
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
226
|
+
let written = 0;
|
|
227
|
+
for (const p of pairs) {
|
|
228
|
+
const safe = p.callId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
229
|
+
fs.writeFileSync(`${outDir}/${safe}.txt`, p.text);
|
|
230
|
+
written += 1;
|
|
231
|
+
}
|
|
232
|
+
process.stdout.write(` 已导出 ${written} 个输出到 ${outDir}\n`);
|
|
233
|
+
} else {
|
|
234
|
+
for (const p of pairs.slice(0, 3)) {
|
|
235
|
+
process.stdout.write(` - [${p.size}B] ${p.command.slice(0, 60)}… ${p.text.slice(0, 80).replace(/\n/g, ' ')}…\n`);
|
|
236
|
+
}
|
|
237
|
+
if (pairs.length > 3) process.stdout.write(` … 其余 ${pairs.length - 3} 个(加 --out DIR 全部导出)\n`);
|
|
238
|
+
}
|
|
239
|
+
process.stdout.write('\n');
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function cmdAuditReport(args) {
|
|
244
|
+
const json = args.includes('--json');
|
|
245
|
+
const file = args.find((a) => !a.startsWith('-'));
|
|
246
|
+
if (!file) fail(USAGE);
|
|
247
|
+
const log = loadSessionLog(file);
|
|
248
|
+
const report = auditToolCalls(log.events.map((e) => e.event));
|
|
249
|
+
if (json) {
|
|
250
|
+
process.stdout.write(JSON.stringify({ file, ...report }, null, 2) + '\n');
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
process.stdout.write(`\n📊 dsh-log-contract audit-report —— ${file}\n`);
|
|
254
|
+
process.stdout.write(` 工具调用 ${report.calls} | 结果 ${report.results} | 孤儿 ${report.orphans} | 配对率 ${(report.pairingRate * 100).toFixed(1)}%\n`);
|
|
255
|
+
process.stdout.write(` 输出总字节 ${report.outputBytes}`);
|
|
256
|
+
if (report.largest) process.stdout.write(` | 最大 ${report.largest.size}B(${(report.largest.command || '?').slice(0, 40)})`);
|
|
257
|
+
process.stdout.write(`\n 命令分布(前 ${report.commands.top.length} 个去重):\n`);
|
|
258
|
+
for (const { command, count } of report.commands.top.slice(0, 8)) {
|
|
259
|
+
process.stdout.write(` ${String(count).padStart(4)} ${(command || '(no-command)').slice(0, 70)}\n`);
|
|
260
|
+
}
|
|
261
|
+
process.stdout.write('\n');
|
|
262
|
+
process.exit(0);
|
|
263
|
+
}
|
|
264
|
+
|
|
196
265
|
const args = process.argv.slice(2);
|
|
197
266
|
const cmd = args[0];
|
|
198
267
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
@@ -205,6 +274,8 @@ if (cmd === '--version' || cmd === '-v') {
|
|
|
205
274
|
process.exit(0);
|
|
206
275
|
}
|
|
207
276
|
if (cmd === 'check') cmdCheck(args.slice(1));
|
|
277
|
+
else if (cmd === 'extract') cmdExtract(args.slice(1));
|
|
278
|
+
else if (cmd === 'audit-report') cmdAuditReport(args.slice(1));
|
|
208
279
|
else if (cmd === 'prewrite') cmdPrewrite(args.slice(1));
|
|
209
280
|
else if (cmd === 'fix') cmdFix(args.slice(1));
|
|
210
281
|
else if (cmd === 'contracts') cmdContracts();
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/archaeology.js
|
|
3
|
+
*
|
|
4
|
+
* 会话日志考古(任务书 dsh-会话日志考古-插件任务与方法.md)——两插件共享:
|
|
5
|
+
* retrace 的考古界面/导出(A1-A4)与 log-contract 的 extract/audit-report
|
|
6
|
+
* (B3/B4)都消费这里的纯函数。**只读不写**(纪律 §8.1)。
|
|
7
|
+
*
|
|
8
|
+
* 核心事实(§2):
|
|
9
|
+
* - tool/call 的 `data.callId` ↔ tool/result 的 `data.message.source.callId`
|
|
10
|
+
* 配对(不可用"上一个 call"推断);
|
|
11
|
+
* - tool/call 的 `data.arguments`(JSON 串)含 `command`(命令考古);
|
|
12
|
+
* - tool/result 的 `data.message.content` 是嵌套 text 结构(输出考古)。
|
|
13
|
+
*/
|
|
14
|
+
/** 元数据字段:不参与考古文本提取。 */
|
|
15
|
+
const META_KEYS = new Set([
|
|
16
|
+
'type', 'toolCallId', 'isError', 'id', 'role', 'source', 'name', 'status',
|
|
17
|
+
'usage', 'provider', 'model', 'callId', 'kind', 'version', 'error',
|
|
18
|
+
'title', 'timestamp', 'threadId', 'messageId', 'sessionId', 'plugin',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 递归提取 content 中的全部 text(保留顺序,换行连接)。
|
|
23
|
+
* 只收集 `{type:'text', text}` 块与裸字符串;`tool-result` 之类的类型标签、
|
|
24
|
+
* toolCallId/isError 等元数据字段一律跳过——避免提取到类型名而非内容。
|
|
25
|
+
*/
|
|
26
|
+
export function extractText(content) {
|
|
27
|
+
const parts = [];
|
|
28
|
+
const walk = (node) => {
|
|
29
|
+
if (typeof node === 'string') {
|
|
30
|
+
parts.push(node);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(node)) {
|
|
34
|
+
for (const item of node) walk(item);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (node !== null && typeof node === 'object') {
|
|
38
|
+
if (node.type === 'text' && typeof node.text === 'string') {
|
|
39
|
+
parts.push(node.text);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const key of Object.keys(node)) {
|
|
43
|
+
if (META_KEYS.has(key)) continue;
|
|
44
|
+
if (key === 'text' && typeof node[key] === 'string') {
|
|
45
|
+
parts.push(node[key]);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
walk(node[key]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(content);
|
|
53
|
+
return parts.join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 解析 tool/call 的 arguments(JSON 串容错)。 */
|
|
57
|
+
export function toolCommandOf(event) {
|
|
58
|
+
if (event?.type !== 'tool/call') return '';
|
|
59
|
+
try {
|
|
60
|
+
const args = typeof event.data?.arguments === 'string' ? JSON.parse(event.data.arguments) : event.data?.arguments;
|
|
61
|
+
return typeof args?.command === 'string' ? args.command : '';
|
|
62
|
+
} catch {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 提取会话内工具调用 → 输出 的配对表(考古 A1/A2/B3 的基础)。
|
|
69
|
+
*
|
|
70
|
+
* @param events - 会话事件数组(按日志顺序)。
|
|
71
|
+
* @returns {{
|
|
72
|
+
* calls: Map<callId, { command: string, callSeq: number }>,
|
|
73
|
+
* outputs: Map<callId, { text: string, size: number, resultSeq: number }>,
|
|
74
|
+
* orphans: string[], // 无 result 的 callId(P3 同源)
|
|
75
|
+
* }}
|
|
76
|
+
*/
|
|
77
|
+
export function indexToolCalls(events) {
|
|
78
|
+
const calls = new Map();
|
|
79
|
+
const outputs = new Map();
|
|
80
|
+
for (const event of events) {
|
|
81
|
+
if (event.type === 'tool/call') {
|
|
82
|
+
const callId = event.data?.callId;
|
|
83
|
+
if (typeof callId === 'string' && callId !== '') {
|
|
84
|
+
calls.set(callId, { command: toolCommandOf(event), callSeq: event.seq });
|
|
85
|
+
}
|
|
86
|
+
} else if (event.type === 'tool/result') {
|
|
87
|
+
const callId = event.data?.message?.source?.callId;
|
|
88
|
+
if (typeof callId === 'string' && callId !== '') {
|
|
89
|
+
const text = extractText(event.data?.message?.content);
|
|
90
|
+
outputs.set(callId, { text, size: text.length, resultSeq: event.seq });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const orphans = [...calls.keys()].filter((callId) => !outputs.has(callId));
|
|
95
|
+
return { calls, outputs, orphans };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 按命令正则导出工具输出(考古 A2/B3)。
|
|
100
|
+
*
|
|
101
|
+
* @param events - 会话事件数组。
|
|
102
|
+
* @param pattern - 命令正则(字符串或 RegExp;字符串按子串匹配,空则全量)。
|
|
103
|
+
* @param opts.minSize - 输出最小字节数过滤(默认 0;任务书用 50 过滤噪声)。
|
|
104
|
+
* @returns {{ pairs: Array<{ callId, command, text, size }>, matched: number, total: number }}
|
|
105
|
+
*/
|
|
106
|
+
export function extractToolOutputs(events, pattern = '', { minSize = 0 } = {}) {
|
|
107
|
+
const { calls, outputs } = indexToolCalls(events);
|
|
108
|
+
const re = pattern instanceof RegExp ? pattern : (pattern ? new RegExp(pattern) : null);
|
|
109
|
+
const pairs = [];
|
|
110
|
+
for (const [callId, call] of calls) {
|
|
111
|
+
const matched = re === null || re.test(call.command);
|
|
112
|
+
if (!matched) continue;
|
|
113
|
+
const output = outputs.get(callId);
|
|
114
|
+
if (!output || output.size < minSize) continue;
|
|
115
|
+
pairs.push({ callId, command: call.command, text: output.text, size: output.size });
|
|
116
|
+
}
|
|
117
|
+
return { pairs, matched: pairs.length, total: calls.size };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 会话考古审计报告(B4):调用数 / 配对率 / 孤儿数 / 命令分布。
|
|
122
|
+
*
|
|
123
|
+
* @param events - 会话事件数组。
|
|
124
|
+
* @returns {{
|
|
125
|
+
* calls, results, paired, orphans, pairingRate,
|
|
126
|
+
* commands: { top: Array<{ command, count }>, distinct },
|
|
127
|
+
* outputBytes, largest: { callId, command, size } | null,
|
|
128
|
+
* }}
|
|
129
|
+
*/
|
|
130
|
+
export function auditToolCalls(events) {
|
|
131
|
+
const { calls, outputs, orphans } = indexToolCalls(events);
|
|
132
|
+
const commandCounts = new Map();
|
|
133
|
+
for (const call of calls.values()) {
|
|
134
|
+
const key = call.command || '(no-command)';
|
|
135
|
+
commandCounts.set(key, (commandCounts.get(key) ?? 0) + 1);
|
|
136
|
+
}
|
|
137
|
+
const top = [...commandCounts.entries()]
|
|
138
|
+
.map(([command, count]) => ({ command, count }))
|
|
139
|
+
.sort((a, b) => b.count - a.count);
|
|
140
|
+
let outputBytes = 0;
|
|
141
|
+
let largest = null;
|
|
142
|
+
for (const [callId, output] of outputs) {
|
|
143
|
+
outputBytes += output.size;
|
|
144
|
+
if (!largest || output.size > largest.size) {
|
|
145
|
+
largest = { callId, command: calls.get(callId)?.command ?? '', size: output.size };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const paired = outputs.size;
|
|
149
|
+
return {
|
|
150
|
+
calls: calls.size,
|
|
151
|
+
results: outputs.size,
|
|
152
|
+
paired,
|
|
153
|
+
orphans: orphans.length,
|
|
154
|
+
pairingRate: calls.size > 0 ? paired / calls.size : 0,
|
|
155
|
+
commands: { top: top.slice(0, 15), distinct: top.length },
|
|
156
|
+
outputBytes,
|
|
157
|
+
largest,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/lib/checks.js
CHANGED
|
@@ -265,6 +265,136 @@ export function finalFold(events) {
|
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* P3 —— tool/call ↔ tool/result 配对完整性(考古任务书 B1)。
|
|
270
|
+
* 每个 tool/call 的 `data.callId` 必须能在 tool/result 的
|
|
271
|
+
* `data.message.source.callId` 中找到配对;孤儿 call(无 result)告警——
|
|
272
|
+
* 中断/失败轮次可能产生孤儿(合法但要审计)。warning 级:不破坏日志。
|
|
273
|
+
*/
|
|
274
|
+
export function toolPairingViolations(events) {
|
|
275
|
+
const out = [];
|
|
276
|
+
const calls = new Map(); // callId → { command, loc }
|
|
277
|
+
const results = new Set();
|
|
278
|
+
for (const { event, lineNo } of events) {
|
|
279
|
+
if (event.type === 'tool/call') {
|
|
280
|
+
const callId = event.data?.callId;
|
|
281
|
+
if (typeof callId === 'string' && callId !== '') {
|
|
282
|
+
let command = '';
|
|
283
|
+
try {
|
|
284
|
+
const args = typeof event.data?.arguments === 'string' ? JSON.parse(event.data.arguments) : event.data?.arguments;
|
|
285
|
+
command = typeof args?.command === 'string' ? args.command.slice(0, 120) : '';
|
|
286
|
+
} catch { /* 参数解析失败不阻断配对检查 */ }
|
|
287
|
+
calls.set(callId, { command, loc: { seq: event.seq, lineNo, eventType: event.type } });
|
|
288
|
+
}
|
|
289
|
+
} else if (event.type === 'tool/result') {
|
|
290
|
+
const callId = event.data?.message?.source?.callId;
|
|
291
|
+
if (typeof callId === 'string' && callId !== '') results.add(callId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const [callId, { command, loc }] of calls) {
|
|
295
|
+
if (!results.has(callId)) {
|
|
296
|
+
out.push(violation('P3', loc, `tool/call ${callId}(命令 ${command || '(未知)'})没有配对的 tool/result——孤儿调用(中断/失败未落结果),考古提取将缺该输出`));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 递归检查 content 的可提取性:任意标量(string/number/boolean/null)都是合法
|
|
304
|
+
* 元数据(如 isError、toolCallId)——只有 "text 字段存在但值非 string" 才是
|
|
305
|
+
* 考古提取会漏数据的结构异常(extractText 依赖 text 为 string)。
|
|
306
|
+
*/
|
|
307
|
+
function findUnparsableContent(node, path) {
|
|
308
|
+
if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean' || node === null) return null;
|
|
309
|
+
if (Array.isArray(node)) {
|
|
310
|
+
for (let i = 0; i < node.length; i++) {
|
|
311
|
+
const r = findUnparsableContent(node[i], `${path}[${i}]`);
|
|
312
|
+
if (r !== null) return r;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
if (typeof node === 'object') {
|
|
317
|
+
if (Object.prototype.hasOwnProperty.call(node, 'text') && typeof node.text !== 'string') {
|
|
318
|
+
return `${path}.text(${String(node.text)},非 string)`;
|
|
319
|
+
}
|
|
320
|
+
for (const key of Object.keys(node)) {
|
|
321
|
+
if (key === 'text') continue;
|
|
322
|
+
const r = findUnparsableContent(node[key], `${path}.${key}`);
|
|
323
|
+
if (r !== null) return r;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
return `${path}(${String(node)})`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* P4 —— tool/result 输出结构契约(考古任务书 B2)。
|
|
332
|
+
* `data.message.content` 必须可递归解析(list[dict{type:text,text}] 或等价);
|
|
333
|
+
* 不可解析片段 = 考古提取将漏数据。空 content(失败/无输出)合法。warning 级。
|
|
334
|
+
*/
|
|
335
|
+
export function toolResultStructureViolations(events) {
|
|
336
|
+
const out = [];
|
|
337
|
+
for (const { event, lineNo } of events) {
|
|
338
|
+
if (event.type !== 'tool/result') continue;
|
|
339
|
+
const content = event.data?.message?.content;
|
|
340
|
+
if (!Array.isArray(content) || content.length === 0) continue;
|
|
341
|
+
const callId = event.data?.message?.source?.callId;
|
|
342
|
+
const loc = { seq: event.seq, lineNo, eventType: event.type };
|
|
343
|
+
const bad = findUnparsableContent(content, 'content');
|
|
344
|
+
if (bad !== null) {
|
|
345
|
+
out.push(violation('P4', loc, `tool/result ${callId ?? '?'} 的 content 含不可解析片段(${bad})——考古提取将漏数据`));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* T1 —— token-meter 配对(复刻 @deepseek-ai/dsh-token-meter 的 _foldEvent 状态机,
|
|
353
|
+
* 2026-08-28 事故根因 3 固化):
|
|
354
|
+
* - `step/start` 打开一个 step(记录 turn/step);
|
|
355
|
+
* - `step/end` 必须匹配当前打开的 step/start,否则抛错;
|
|
356
|
+
* - `assistant/message` 必须匹配当前打开的 step/start(turn/step 完全一致),否则抛错;
|
|
357
|
+
* - `user/message` / `tool/result` 不检查(token meter 不配对)。
|
|
358
|
+
*
|
|
359
|
+
* 违反 = token meter 折叠抛错 → 该会话 `/compact` 与压力测量永久失败。
|
|
360
|
+
* 已知命中:retrace 的 turn/step=null 编辑/撤回 marker(空 assistant/message replace)——
|
|
361
|
+
* foldSurface 认可其合法性(M1 只约束 append 形态),但 token meter 崩溃。这是
|
|
362
|
+
* M1 规则的盲区:M1 没约束"replace 也必须过 token meter"。
|
|
363
|
+
*
|
|
364
|
+
* @param events - 行序事件流(`{event, lineNo}`)。
|
|
365
|
+
* @returns T1 违规列表。
|
|
366
|
+
*/
|
|
367
|
+
export function tokenMeterViolations(events) {
|
|
368
|
+
const out = [];
|
|
369
|
+
// 无任何 step/start 的日志:现代 DSH 每个 assistant 回合必有 step/start,
|
|
370
|
+
// 完全没有说明是极早期格式或简化日志——token-meter 的 step 配对兼容性未
|
|
371
|
+
// 定义,不做配对检查(避免对旧结构误报;真实事故会话都是现代结构)。
|
|
372
|
+
if (!events.some(({ event }) => event.type === 'step/start')) return out;
|
|
373
|
+
let stepStart = undefined;
|
|
374
|
+
for (const { event, lineNo } of events) {
|
|
375
|
+
const loc = { seq: event.seq, lineNo, eventType: event.type };
|
|
376
|
+
if (event.type === 'step/start') {
|
|
377
|
+
if (stepStart !== undefined) {
|
|
378
|
+
out.push(violation('T1', loc, `step/start at seq ${event.seq} arrived before turn ${stepStart.turn}/step ${stepStart.step} ended——token meter 折叠会抛错`));
|
|
379
|
+
}
|
|
380
|
+
stepStart = { turn: event.data?.turn, step: event.data?.step };
|
|
381
|
+
} else if (event.type === 'step/end') {
|
|
382
|
+
if (stepStart === undefined || stepStart.turn !== event.data?.turn || stepStart.step !== event.data?.step) {
|
|
383
|
+
out.push(violation('T1', loc, `step/end at seq ${event.seq} has no matching step/start event(turn=${String(event.data?.turn)}, step=${String(event.data?.step)})——token meter 折叠会抛错`));
|
|
384
|
+
}
|
|
385
|
+
stepStart = undefined;
|
|
386
|
+
} else if (event.type === 'assistant/message') {
|
|
387
|
+
const turn = event.data?.turn;
|
|
388
|
+
const step = event.data?.step;
|
|
389
|
+
if (stepStart === undefined || stepStart.turn !== turn || stepStart.step !== step) {
|
|
390
|
+
const open = stepStart === undefined ? '无打开的 step' : `打开的 step 为 turn ${stepStart.turn}/step ${stepStart.step}`;
|
|
391
|
+
out.push(violation('T1', loc, `assistant/message at seq ${event.seq} has no matching step/start event(turn=${String(turn)}, step=${String(step)};${open})——token meter 折叠会抛错,/compact 与压力测量永久失败(retrace 的 turn-null 编辑/撤回 marker 即命中此条)`));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
|
|
268
398
|
/** 从事件推导 wire 消息(与 dsh-session deriveEventMessage 同语义)。 */
|
|
269
399
|
export function deriveWireMessage(event) {
|
|
270
400
|
if (event.type === 'user/message') {
|
package/lib/contracts.js
CHANGED
|
@@ -197,6 +197,30 @@ export const CONTRACT_RULES = [
|
|
|
197
197
|
description: '终验:把全部事件按序喂给官方 foldSurface,不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。',
|
|
198
198
|
},
|
|
199
199
|
|
|
200
|
+
{
|
|
201
|
+
id: 'T1',
|
|
202
|
+
title: 'token-meter 配对:assistant/message 与 step/end 必须匹配当前打开的 step/start',
|
|
203
|
+
layer: LAYER.ENGINE,
|
|
204
|
+
severity: SEVERITY.ERROR,
|
|
205
|
+
source: '@deepseek-ai/dsh-token-meter lib/index.js:566-625 (_foldEvent)',
|
|
206
|
+
description: 'token meter 折叠要求 assistant/message 与 step/end 与打开的 step/start(turn/step 完全一致)匹配;违反即 /compact 与压力测量永久失败。retrace 的 turn-null 编辑/撤回 marker(空 assistant/message replace)命中此条——foldSurface 认可其合法性但 token meter 崩溃(M1 只约束 append 形态的盲区),压缩前需清理。',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: 'P3',
|
|
210
|
+
title: 'tool/call ↔ tool/result 配对完整性(考古 B1)',
|
|
211
|
+
layer: LAYER.PLUGIN,
|
|
212
|
+
severity: SEVERITY.WARNING,
|
|
213
|
+
source: 'dsh-会话日志考古-插件任务与方法.md §2/§4.2(callId 配对,不可用"上一个 call"推断)',
|
|
214
|
+
description: '每个 tool/call 的 data.callId 必须能在 tool/result 的 data.message.source.callId 中找到配对;孤儿 call(无 result)告警——中断/失败轮次可能产生孤儿(合法但要审计),考古提取将缺该输出。',
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: 'P4',
|
|
218
|
+
title: 'tool/result 输出结构可解析(考古 B2)',
|
|
219
|
+
layer: LAYER.PLUGIN,
|
|
220
|
+
severity: SEVERITY.WARNING,
|
|
221
|
+
source: 'dsh-会话日志考古-插件任务与方法.md §2/§4.2(content 递归 text 结构)',
|
|
222
|
+
description: 'tool/result 的 data.message.content 必须可递归解析(list[dict{type:text,text}] 或等价);不可解析片段 = 考古提取将漏数据。空 content(失败/无输出)合法。',
|
|
223
|
+
},
|
|
200
224
|
// ── M · 客户端引擎层 ────────────────────────────────────────────────────
|
|
201
225
|
{
|
|
202
226
|
id: 'M1',
|
package/lib/index.js
CHANGED
|
@@ -7,5 +7,7 @@
|
|
|
7
7
|
export { loadSessionLog } from './log-reader.js';
|
|
8
8
|
export { validateSessionLog } from './validate.js';
|
|
9
9
|
export { createPreWriter, preWriterFromLog } from './prewrite.js';
|
|
10
|
-
export { repairSession, strictScanText, removeMarkersText, dropFailedTurnsText, trimLastMessagesText, rebuildZstdText } from './repair.js';
|
|
10
|
+
export { repairSession, strictScanText, removeMarkersText, dropFailedTurnsText, trimLastMessagesText, compactLastMessagesText, rebuildZstdText } from './repair.js';
|
|
11
11
|
export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
|
|
12
|
+
export { tokenMeterViolations } from './checks.js';
|
|
13
|
+
export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
|
package/lib/prewrite.js
CHANGED
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
* 所有判定复用 `lib/checks.js`(与离线体检同一套逻辑),
|
|
18
18
|
* 保证"体检看到的问题 = 写入前拦下的问题"。
|
|
19
19
|
*/
|
|
20
|
-
import { envelopeViolations, engineViolations, finalFold, isSafeInt, pluginViolations, replaySurface, violation } from './checks.js';
|
|
20
|
+
import { envelopeViolations, engineViolations, finalFold, isSafeInt, pluginViolations, replaySurface, tokenMeterViolations, violation } from './checks.js';
|
|
21
|
+
|
|
22
|
+
/** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
|
|
23
|
+
function isKnownMarkerCandidate(event) {
|
|
24
|
+
return Boolean(event) && event.type === 'assistant/message' && event.surfaceOp && event.surfaceOp !== 'append' && event.data?.editor !== undefined;
|
|
25
|
+
}
|
|
21
26
|
|
|
22
27
|
/** 把一个"拟写事件"规整为带 seq 的事件;seq 未携带时按追加位置赋值。 */
|
|
23
28
|
function normalizeCandidate(candidate, nextSeq) {
|
|
@@ -78,6 +83,21 @@ export function createPreWriter(input = {}) {
|
|
|
78
83
|
if (folded.error) {
|
|
79
84
|
violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
|
|
80
85
|
}
|
|
86
|
+
// T1 —— token-meter 配对(事故根因 3 固化)。写前校验只判定**拟写事件自身**
|
|
87
|
+
// 的 step 配对:retrace 的 turn-null 编辑/撤回 marker 必然命中(空
|
|
88
|
+
// assistant/message replace 无 step 可配对),但编辑功能必须可用——白名单
|
|
89
|
+
// 降级为 warning(已知设计债,压缩前需 doctor 清理);非 marker 的
|
|
90
|
+
// assistant/message 配对失败保持 error。历史已有事件的 T1 归属离线体检
|
|
91
|
+
// (check),不在这里重复拦截(否则历史 marker 会让后续编辑全部被拒)。
|
|
92
|
+
const lastCandidate = candidateEvents[candidateEvents.length - 1];
|
|
93
|
+
for (const t1 of tokenMeterViolations(candidateEvents.map((event) => ({ event })))) {
|
|
94
|
+
if (t1.id !== 'T1' || t1.seq !== lastCandidate?.seq) continue;
|
|
95
|
+
if (isKnownMarkerCandidate(lastCandidate)) {
|
|
96
|
+
violations.push({ ...t1, severity: 'warning', message: `${t1.message}(已知 retrace marker 设计债:压缩前需 doctor 清理)` });
|
|
97
|
+
} else {
|
|
98
|
+
violations.push(t1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
81
101
|
const bySeverity = { error: 0, warning: 0, info: 0 };
|
|
82
102
|
for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
|
|
83
103
|
return {
|
package/lib/repair.js
CHANGED
|
@@ -31,7 +31,7 @@ import path from 'node:path';
|
|
|
31
31
|
import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib';
|
|
32
32
|
import { decompressZstd, loadSessionLog } from './log-reader.js';
|
|
33
33
|
import { validateSessionLog } from './validate.js';
|
|
34
|
-
import { CHUNK_ROW_TYPES, MARKER_PREFIXES } from './checks.js';
|
|
34
|
+
import { CHUNK_ROW_TYPES, MARKER_PREFIXES, SURFACE_TYPES } from './checks.js';
|
|
35
35
|
|
|
36
36
|
/** 复刻 dsh-session expandRow:chunk 行展开为完整事件(含 data/turn/step)。 */
|
|
37
37
|
function expandChunkRow(row) {
|
|
@@ -370,6 +370,165 @@ export function trimLastMessagesText(text, keepMessages) {
|
|
|
370
370
|
return { ...r, kept: keepMessages, cutoff };
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/** 从被遮蔽的 user/assistant 消息做提取式摘要(跨范围均匀采样,不依赖模型)。 */
|
|
374
|
+
function extractiveSummary(msgEvents) {
|
|
375
|
+
const lines = [];
|
|
376
|
+
const step = Math.max(1, Math.ceil(msgEvents.length / 18));
|
|
377
|
+
const sampled = [];
|
|
378
|
+
for (let i = 0; i < msgEvents.length; i += step) sampled.push(msgEvents[i]);
|
|
379
|
+
const last = msgEvents[msgEvents.length - 1];
|
|
380
|
+
if (sampled[sampled.length - 1] !== last) sampled.push(last);
|
|
381
|
+
for (const ev of sampled) {
|
|
382
|
+
let text = '';
|
|
383
|
+
if (ev.type === 'user/message') {
|
|
384
|
+
text = (ev.data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
385
|
+
} else if (ev.type === 'assistant/message') {
|
|
386
|
+
text = (ev.data?.message?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
|
|
387
|
+
}
|
|
388
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
389
|
+
if (!text) continue;
|
|
390
|
+
const chunk = text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
|
391
|
+
lines.push(`${ev.type === 'user/message' ? '问' : '答'} ${chunk}`);
|
|
392
|
+
if (lines.join('\n').length > 1800) break;
|
|
393
|
+
}
|
|
394
|
+
if (lines.length === 0) return '(早期对话无文本内容)';
|
|
395
|
+
return `【早期对话提取式摘要 · 完整原文保留在会话日志与备份中】\n${lines.join('\n')}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* DSH 官方压缩(compaction):不删除任何事件——在日志尾部追加
|
|
400
|
+
* compaction/start → compaction/summary → checkpoint(user/message, replace
|
|
401
|
+
* [start..end]) → compaction/end,把 [start..end] 从模型表面遮蔽,替换为
|
|
402
|
+
* 提取式摘要。旧事件全部保留(append-only 审计),日志一行不删。
|
|
403
|
+
* @param {string} text - JSONL 全文(严格连续)。
|
|
404
|
+
* @param {number} keepMessages - 保留的最近 append 消息数。
|
|
405
|
+
* @returns {{ text: string, compacted: boolean, kept: number, shadowed: number, summary: string }}
|
|
406
|
+
*/
|
|
407
|
+
export function compactLastMessagesText(text, keepMessages) {
|
|
408
|
+
const parts = text.split('\n');
|
|
409
|
+
const bySeq = new Map();
|
|
410
|
+
const surfaceNodes = [];
|
|
411
|
+
for (let i = 1; i < parts.length; i++) {
|
|
412
|
+
const raw = parts[i].trim();
|
|
413
|
+
if (!raw) continue;
|
|
414
|
+
const decoded = decodeLine(raw);
|
|
415
|
+
if (decoded === null) continue;
|
|
416
|
+
for (const ev of decoded) {
|
|
417
|
+
bySeq.set(ev.seq, ev);
|
|
418
|
+
if (SURFACE_TYPES.has(ev.type)) {
|
|
419
|
+
const op = ev.surfaceOp;
|
|
420
|
+
if (op === 'append') surfaceNodes.push(ev.seq);
|
|
421
|
+
else if (op && op.op === 'replace') {
|
|
422
|
+
const s = surfaceNodes.indexOf(op.start);
|
|
423
|
+
const e = surfaceNodes.indexOf(op.end);
|
|
424
|
+
if (s !== -1 && e !== -1 && s <= e) surfaceNodes.splice(s, e - s + 1);
|
|
425
|
+
surfaceNodes.push(ev.seq);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const appendMsgs = surfaceNodes.filter((seq) => {
|
|
431
|
+
const ev = bySeq.get(seq);
|
|
432
|
+
return ev && (ev.type === 'user/message' || (ev.type === 'assistant/message' && ev.surfaceOp === 'append'));
|
|
433
|
+
});
|
|
434
|
+
const total = appendMsgs.length;
|
|
435
|
+
if (total <= keepMessages) return { text, compacted: false, kept: total, shadowed: 0, summary: '' };
|
|
436
|
+
const target = appendMsgs[total - keepMessages];
|
|
437
|
+
let boundary = target;
|
|
438
|
+
for (const seq of appendMsgs) {
|
|
439
|
+
const ev = bySeq.get(seq);
|
|
440
|
+
if (ev.type === 'user/message' && seq <= target) boundary = seq;
|
|
441
|
+
}
|
|
442
|
+
const shadowedSeqs = surfaceNodes.filter((seq) => seq < boundary);
|
|
443
|
+
const start = shadowedSeqs[0];
|
|
444
|
+
const end = shadowedSeqs[shadowedSeqs.length - 1];
|
|
445
|
+
const summary = extractiveSummary(shadowedSeqs.map((seq) => bySeq.get(seq)).filter(Boolean));
|
|
446
|
+
// 压缩事件插入到"第一个保留事件"之前(seq = boundary..boundary+3),
|
|
447
|
+
// 之后的事件整体 +4 重编号——保证表面顺序为 [checkpoint, 近期轮次…]。
|
|
448
|
+
const boundarySeq = boundary;
|
|
449
|
+
const compactionId = `dsh-fix-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
450
|
+
const startSeq = boundarySeq;
|
|
451
|
+
const summarySeq = boundarySeq + 1;
|
|
452
|
+
const checkpointSeq = boundarySeq + 2;
|
|
453
|
+
const endSeq = boundarySeq + 3;
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
const appended = [
|
|
456
|
+
{ type: 'compaction/start', seq: startSeq, time: now, data: { compactionId, turn: null } },
|
|
457
|
+
{
|
|
458
|
+
type: 'compaction/summary',
|
|
459
|
+
seq: summarySeq,
|
|
460
|
+
time: now,
|
|
461
|
+
data: {
|
|
462
|
+
compactionId,
|
|
463
|
+
summary,
|
|
464
|
+
shadowedRange: { start, end },
|
|
465
|
+
shadowedSeqs,
|
|
466
|
+
shadowedTokenCount: Math.round(summary.length / 4),
|
|
467
|
+
provider: 'dsh-log-contract',
|
|
468
|
+
model: 'extractive',
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
type: 'user/message',
|
|
473
|
+
seq: checkpointSeq,
|
|
474
|
+
time: now,
|
|
475
|
+
surfaceOp: { op: 'replace', start, end },
|
|
476
|
+
sourceEventSeqs: [startSeq, summarySeq, ...shadowedSeqs],
|
|
477
|
+
data: {
|
|
478
|
+
id: `checkpoint-${compactionId}`,
|
|
479
|
+
role: 'user',
|
|
480
|
+
source: { kind: 'user' },
|
|
481
|
+
content: [{ type: 'text', text: summary }],
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
{ type: 'compaction/end', seq: endSeq, time: now, data: { compactionId, turn: null } },
|
|
485
|
+
];
|
|
486
|
+
// 找到第一个事件 seq >= boundary 的行(保留区起点),在其前插入压缩事件
|
|
487
|
+
let insertIdx = parts.length - 1;
|
|
488
|
+
for (let i = 1; i < parts.length; i++) {
|
|
489
|
+
const raw = parts[i].trim();
|
|
490
|
+
if (!raw) continue;
|
|
491
|
+
const decoded = decodeLine(raw);
|
|
492
|
+
if (decoded === null || decoded.length === 0) continue;
|
|
493
|
+
if (decoded[0].seq >= boundarySeq) {
|
|
494
|
+
insertIdx = i;
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const shiftFields = (v) => {
|
|
499
|
+
if (typeof v.seq === 'number') v.seq += 4;
|
|
500
|
+
if (CHUNK_ROW_TYPES.has(v.type) && typeof v.seq0 === 'number') v.seq0 += 4;
|
|
501
|
+
if (Array.isArray(v.sourceEventSeqs)) v.sourceEventSeqs = v.sourceEventSeqs.map((x) => x + 4);
|
|
502
|
+
if (v.surfaceOp && v.surfaceOp.op === 'replace') {
|
|
503
|
+
v.surfaceOp.start += 4;
|
|
504
|
+
v.surfaceOp.end += 4;
|
|
505
|
+
}
|
|
506
|
+
return v;
|
|
507
|
+
};
|
|
508
|
+
const out = [];
|
|
509
|
+
for (let i = 0; i < parts.length; i++) {
|
|
510
|
+
if (i === insertIdx) for (const e of appended) out.push(JSON.stringify(e));
|
|
511
|
+
const raw = parts[i];
|
|
512
|
+
if (i > 0 && i >= insertIdx && raw.trim()) {
|
|
513
|
+
let v;
|
|
514
|
+
try {
|
|
515
|
+
v = JSON.parse(raw);
|
|
516
|
+
} catch {
|
|
517
|
+
out.push(raw);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (typeof v.seq === 'number') {
|
|
521
|
+
out.push(JSON.stringify(shiftFields(v)));
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
out.push(raw);
|
|
526
|
+
}
|
|
527
|
+
const newText = out.join('\n');
|
|
528
|
+
if (!newText.endsWith('\n')) return { text: newText + '\n', compacted: true, kept: keepMessages, shadowed: shadowedSeqs.length, summary };
|
|
529
|
+
return { text: newText, compacted: true, kept: keepMessages, shadowed: shadowedSeqs.length, summary };
|
|
530
|
+
}
|
|
531
|
+
|
|
373
532
|
/**
|
|
374
533
|
* 按官方 writer 帧格式重建 .jsonl.zstd(帧1=header,帧2=其余,均带 checksum)。
|
|
375
534
|
* @param {string} text - JSONL 全文(以 "\n" 结尾)。
|
|
@@ -395,7 +554,7 @@ export function rebuildZstdText(text) {
|
|
|
395
554
|
* 对单个会话日志执行诊断 +(可选)修复。
|
|
396
555
|
* @param {string} file - .jsonl 或 .jsonl.zstd 路径。
|
|
397
556
|
* @param {{
|
|
398
|
-
* removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number,
|
|
557
|
+
* removeMarkers?: boolean, dropFailedTurns?: boolean, trimLast?: number, compactLast?: number,
|
|
399
558
|
* apply?: boolean, backupDir?: string
|
|
400
559
|
* }} opts
|
|
401
560
|
* @returns {{
|
|
@@ -455,6 +614,13 @@ export function repairSession(file, opts = {}) {
|
|
|
455
614
|
const r = trimLastMessagesText(plain, opts.trimLast);
|
|
456
615
|
applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
|
|
457
616
|
}
|
|
617
|
+
if (typeof opts.compactLast === 'number') {
|
|
618
|
+
const r = compactLastMessagesText(plain, opts.compactLast);
|
|
619
|
+
if (r.compacted) {
|
|
620
|
+
issues.push({ kind: 'compact', detail: `官方压缩:遮蔽 ${r.shadowed} 个 surface 节点,保留最近 ${r.kept} 条消息;旧事件全部保留(日志零删除)` });
|
|
621
|
+
plain = r.text;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
458
624
|
// 全部修复完成后做一次终检(中间态的临时违规不阻塞——后续修复可能已消除)
|
|
459
625
|
const scanFinal = strictScanText(plain);
|
|
460
626
|
const checkFinal = validateSessionLog(loadSessionLogFromText(plain));
|
package/lib/validate.js
CHANGED
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
pluginViolations,
|
|
18
18
|
replaySurface,
|
|
19
19
|
violation,
|
|
20
|
+
tokenMeterViolations,
|
|
21
|
+
toolPairingViolations,
|
|
22
|
+
toolResultStructureViolations,
|
|
20
23
|
wireViolations,
|
|
21
24
|
} from './checks.js';
|
|
22
25
|
|
|
@@ -101,6 +104,13 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
101
104
|
violations.push(...replay.violations);
|
|
102
105
|
|
|
103
106
|
const folded = finalFold(events.map((e) => e.event));
|
|
107
|
+
|
|
108
|
+
// ── T · token meter 配对(事故根因 3)──
|
|
109
|
+
violations.push(...tokenMeterViolations(events));
|
|
110
|
+
|
|
111
|
+
// ── P3/P4 · 考古契约(工具配对 + 输出结构)──
|
|
112
|
+
violations.push(...toolPairingViolations(events));
|
|
113
|
+
violations.push(...toolResultStructureViolations(events));
|
|
104
114
|
if (folded.error) {
|
|
105
115
|
violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
|
|
106
116
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-log-contract",
|
|
3
3
|
"description": "日志契约守护 — DSH session log contract guard: offline health check (CLI) + pre-write validation for DeepSeek Harness session logs",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"packageManager": "pnpm@11.7.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|