dsh-log-contract 0.2.2 → 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 +70 -1
- package/lib/archaeology.js +159 -0
- package/lib/checks.js +83 -0
- package/lib/contracts.js +16 -0
- package/lib/index.js +1 -0
- package/lib/validate.js +6 -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
|
|
|
@@ -195,6 +202,66 @@ function cmdFix(args) {
|
|
|
195
202
|
process.exit(result.ok ? 0 : 1);
|
|
196
203
|
}
|
|
197
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
|
+
|
|
198
265
|
const args = process.argv.slice(2);
|
|
199
266
|
const cmd = args[0];
|
|
200
267
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
@@ -207,6 +274,8 @@ if (cmd === '--version' || cmd === '-v') {
|
|
|
207
274
|
process.exit(0);
|
|
208
275
|
}
|
|
209
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));
|
|
210
279
|
else if (cmd === 'prewrite') cmdPrewrite(args.slice(1));
|
|
211
280
|
else if (cmd === 'fix') cmdFix(args.slice(1));
|
|
212
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,89 @@ 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
|
+
|
|
268
351
|
/**
|
|
269
352
|
* T1 —— token-meter 配对(复刻 @deepseek-ai/dsh-token-meter 的 _foldEvent 状态机,
|
|
270
353
|
* 2026-08-28 事故根因 3 固化):
|
package/lib/contracts.js
CHANGED
|
@@ -205,6 +205,22 @@ export const CONTRACT_RULES = [
|
|
|
205
205
|
source: '@deepseek-ai/dsh-token-meter lib/index.js:566-625 (_foldEvent)',
|
|
206
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
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
|
+
},
|
|
208
224
|
// ── M · 客户端引擎层 ────────────────────────────────────────────────────
|
|
209
225
|
{
|
|
210
226
|
id: 'M1',
|
package/lib/index.js
CHANGED
|
@@ -10,3 +10,4 @@ export { createPreWriter, preWriterFromLog } from './prewrite.js';
|
|
|
10
10
|
export { repairSession, strictScanText, removeMarkersText, dropFailedTurnsText, trimLastMessagesText, compactLastMessagesText, rebuildZstdText } from './repair.js';
|
|
11
11
|
export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
|
|
12
12
|
export { tokenMeterViolations } from './checks.js';
|
|
13
|
+
export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
|
package/lib/validate.js
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
replaySurface,
|
|
19
19
|
violation,
|
|
20
20
|
tokenMeterViolations,
|
|
21
|
+
toolPairingViolations,
|
|
22
|
+
toolResultStructureViolations,
|
|
21
23
|
wireViolations,
|
|
22
24
|
} from './checks.js';
|
|
23
25
|
|
|
@@ -105,6 +107,10 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
105
107
|
|
|
106
108
|
// ── T · token meter 配对(事故根因 3)──
|
|
107
109
|
violations.push(...tokenMeterViolations(events));
|
|
110
|
+
|
|
111
|
+
// ── P3/P4 · 考古契约(工具配对 + 输出结构)──
|
|
112
|
+
violations.push(...toolPairingViolations(events));
|
|
113
|
+
violations.push(...toolResultStructureViolations(events));
|
|
108
114
|
if (folded.error) {
|
|
109
115
|
violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
|
|
110
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",
|