dsh-retrace 0.4.3 → 0.4.6
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 +23 -0
- package/bin/retrace.mjs +137 -0
- package/lib/archaeology-cli.js +150 -0
- package/lib/client.bundle.js +189 -51
- package/lib/client.js +287 -60
- package/lib/dynamic-client.js +189 -51
- package/lib/dynamic-host.js +14 -3
- package/lib/host-core.js +14 -3
- package/lib/http.js +24 -0
- package/lib/index.js +5 -0
- package/lib/prewrite-guard.js +50 -10
- package/lib/versioning.js +55 -1
- package/lib/watchdog.js +181 -0
- package/package.json +11 -10
package/README.md
CHANGED
|
@@ -328,3 +328,26 @@ DeepSeek Harness community.
|
|
|
328
328
|
## 📄 License
|
|
329
329
|
|
|
330
330
|
MIT
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
## 🧭 会话日志考古(retrace CLI)
|
|
334
|
+
|
|
335
|
+
DSH 会话日志持久化了每次工具调用的完整输入输出——数据资产与审计资产。
|
|
336
|
+
`retrace` CLI 提供只读考古能力(复用 dsh-log-contract 0.3.0 的契约与提取):
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
retrace index <session> # 工具调用索引(A1)
|
|
340
|
+
retrace query <session> --cmd "seed-scale" # 按命令正则查输出(A1)
|
|
341
|
+
retrace extract <session> --pattern "seed-scale" --out ./found # 导出输出(A2)
|
|
342
|
+
retrace file-history <session> <path> # 文件 write/edit 历史版本(A3)
|
|
343
|
+
retrace file-diff <session> <path> 0 5 # 两版本行级 diff(A3)
|
|
344
|
+
retrace lineage <session> # 会话 parent 链谱系(A4)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
<session> 为完整日志路径或 sessionId(自动在 ~/.dsh/sessions 查找)。全部只读。
|
|
348
|
+
|
|
349
|
+
**分叉图里的会话谱系(A4,UI)**:Fork map 视图头部展示当前会话的
|
|
350
|
+
`parentSession` 接续链(当前会话 → 父 → 根,`←` 方向)。数据来自
|
|
351
|
+
`GET /api/plugins/retrace/lineage?sessionId=`(只读 header 遍历,带环保护),
|
|
352
|
+
与 CLI `retrace lineage` 同一语义。这样"这个会话是从哪个会话接着干/分叉出来的"
|
|
353
|
+
在界面上一眼可见——也是分叉图拓扑的元数据源。
|
package/bin/retrace.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-retrace · bin/retrace.mjs
|
|
4
|
+
*
|
|
5
|
+
* 会话日志考古 CLI(任务书 A1-A4)——只读,不写任何日志。
|
|
6
|
+
*
|
|
7
|
+
* retrace index <session> [--json]
|
|
8
|
+
* 工具调用索引:调用数 / 配对率 / 孤儿数 / 命令分布(A1)
|
|
9
|
+
* retrace query <session> --cmd <regex> [--json]
|
|
10
|
+
* 按命令正则查工具输出(A1)
|
|
11
|
+
* retrace extract <session> --pattern <regex> --out <dir> [--min-size N]
|
|
12
|
+
* 导出匹配命令的工具输出到目录(A2)
|
|
13
|
+
* retrace file-history <session> <path> [--json]
|
|
14
|
+
* 某文件的所有 write/edit 历史版本(A3)
|
|
15
|
+
* retrace file-diff <session> <path> <v1> <v2>
|
|
16
|
+
* 两个历史版本的行级 diff(A3)
|
|
17
|
+
* retrace lineage <session> [--json]
|
|
18
|
+
* 会话 parent 链谱系(A4,分叉图数据源)
|
|
19
|
+
*
|
|
20
|
+
* <session> 为完整文件路径或 sessionId(自动在 ~/.dsh/sessions 查找)。
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import { loadSessionLog, extractToolOutputs, auditToolCalls } from 'dsh-log-contract';
|
|
24
|
+
import { replayFileHistory, diffLines, resolveSessionFile, lineageOf } from '../lib/archaeology-cli.js';
|
|
25
|
+
|
|
26
|
+
const USAGE = `dsh-retrace 考古 CLI —— 会话日志数据/审计资产挖掘(只读)
|
|
27
|
+
|
|
28
|
+
用法:
|
|
29
|
+
retrace index <session> [--json]
|
|
30
|
+
retrace query <session> --cmd <regex> [--json]
|
|
31
|
+
retrace extract <session> --pattern <regex> --out <dir> [--min-size N]
|
|
32
|
+
retrace file-history <session> <path> [--json]
|
|
33
|
+
retrace file-diff <session> <path> <v1> <v2>
|
|
34
|
+
retrace lineage <session> [--json]`;
|
|
35
|
+
|
|
36
|
+
function fail(msg) {
|
|
37
|
+
process.stderr.write(`❌ ${msg}\n\n${USAGE}\n`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const args = process.argv.slice(2);
|
|
42
|
+
const cmd = args[0];
|
|
43
|
+
const rest = args.slice(1);
|
|
44
|
+
const json = rest.includes('--json');
|
|
45
|
+
const positional = rest.filter((a) => !a.startsWith('-'));
|
|
46
|
+
const opt = (name, def) => {
|
|
47
|
+
const i = rest.indexOf(name);
|
|
48
|
+
return i >= 0 && rest[i + 1] ? rest[i + 1] : def;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function main() {
|
|
52
|
+
if (cmd === 'index') {
|
|
53
|
+
const file = resolveSessionFile(positional[0] ?? fail('index 需要 <session>'));
|
|
54
|
+
const log = loadSessionLog(file);
|
|
55
|
+
const report = auditToolCalls(log.events.map((e) => e.event));
|
|
56
|
+
if (json) return process.stdout.write(JSON.stringify({ file, ...report }, null, 2) + '\n');
|
|
57
|
+
process.stdout.write(`📇 retrace index —— ${file}\n`);
|
|
58
|
+
process.stdout.write(` 工具调用 ${report.calls} | 结果 ${report.results} | 孤儿 ${report.orphans} | 配对率 ${(report.pairingRate * 100).toFixed(1)}%\n`);
|
|
59
|
+
process.stdout.write(` 输出总字节 ${report.outputBytes}${report.largest ? ` | 最大 ${report.largest.size}B(${(report.largest.command || '?').slice(0, 40)})` : ''}\n`);
|
|
60
|
+
process.stdout.write(` 命令分布(前 8):\n`);
|
|
61
|
+
for (const { command, count } of report.commands.top.slice(0, 8)) {
|
|
62
|
+
process.stdout.write(` ${String(count).padStart(4)} ${(command || '(no-command)').slice(0, 70)}\n`);
|
|
63
|
+
}
|
|
64
|
+
} else if (cmd === 'query' || cmd === 'extract') {
|
|
65
|
+
const pattern = opt('--cmd', opt('--pattern', ''));
|
|
66
|
+
if (!pattern) fail(`${cmd} 需要 --cmd/--pattern <regex>`);
|
|
67
|
+
const file = resolveSessionFile(positional[0] ?? fail(`${cmd} 需要 <session>`));
|
|
68
|
+
const log = loadSessionLog(file);
|
|
69
|
+
const minSize = cmd === 'extract' ? Number(opt('--min-size', '50')) : 0;
|
|
70
|
+
const { pairs, total } = extractToolOutputs(log.events.map((e) => e.event), pattern, { minSize });
|
|
71
|
+
if (cmd === 'query') {
|
|
72
|
+
if (json) return process.stdout.write(JSON.stringify({ file, pattern, matched: pairs.length, total, pairs: pairs.map((p) => ({ callId: p.callId, command: p.command, size: p.size, text: p.text.slice(0, 500) })) }, null, 2) + '\n');
|
|
73
|
+
process.stdout.write(`🔎 retrace query —— ${file} | /${pattern}/\n`);
|
|
74
|
+
process.stdout.write(` 匹配 ${pairs.length} 个输出(共 ${total} 个调用)\n`);
|
|
75
|
+
for (const p of pairs.slice(0, 5)) process.stdout.write(` - [${p.size}B] ${p.command.slice(0, 50)}… ${p.text.slice(0, 70).replace(/\n/g, ' ')}…\n`);
|
|
76
|
+
if (pairs.length > 5) process.stdout.write(` … 其余 ${pairs.length - 5} 个(extract --out 导出全部)\n`);
|
|
77
|
+
} else {
|
|
78
|
+
const outDir = opt('--out', '');
|
|
79
|
+
if (!outDir) fail('extract 需要 --out <dir>');
|
|
80
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
81
|
+
let written = 0;
|
|
82
|
+
for (const p of pairs) {
|
|
83
|
+
fs.writeFileSync(`${outDir}/${p.callId.replace(/[^a-zA-Z0-9_-]/g, '_')}.txt`, p.text);
|
|
84
|
+
written += 1;
|
|
85
|
+
}
|
|
86
|
+
process.stdout.write(`📤 retrace extract —— ${file} | /${pattern}/ | ${written}/${pairs.length} 输出 → ${outDir}\n`);
|
|
87
|
+
}
|
|
88
|
+
} else if (cmd === 'file-history') {
|
|
89
|
+
const file = resolveSessionFile(positional[0] ?? fail('file-history 需要 <session>'));
|
|
90
|
+
const path = positional[1] ?? fail('file-history 需要 <path>');
|
|
91
|
+
const log = loadSessionLog(file);
|
|
92
|
+
const { files } = replayFileHistory(log.events.map((e) => e.event));
|
|
93
|
+
const history = files.get(path) ?? [];
|
|
94
|
+
if (json) return process.stdout.write(JSON.stringify({ file, path, versions: history.map((v) => ({ seq: v.seq, time: v.time, kind: v.kind, tool: v.tool, hash: v.hash, size: v.size })) }, null, 2) + '\n');
|
|
95
|
+
process.stdout.write(`📜 retrace file-history —— ${path}(${file})\n`);
|
|
96
|
+
if (history.length === 0) {
|
|
97
|
+
process.stdout.write(' (该文件无 write/edit 记录)\n');
|
|
98
|
+
} else {
|
|
99
|
+
history.forEach((v, i) => {
|
|
100
|
+
process.stdout.write(` v${i} [${v.kind}/${v.tool}] seq ${v.seq} ${v.size}B ${v.hash} ${(v.time ? new Date(v.time).toISOString().slice(5, 16) : '')}\n`);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
} else if (cmd === 'file-diff') {
|
|
104
|
+
const file = resolveSessionFile(positional[0] ?? fail('file-diff 需要 <session>'));
|
|
105
|
+
const path = positional[1] ?? fail('file-diff 需要 <path>');
|
|
106
|
+
const v1 = Number(positional[2]);
|
|
107
|
+
const v2 = Number(positional[3]);
|
|
108
|
+
if (!Number.isInteger(v1) || !Number.isInteger(v2)) fail('file-diff 需要 <v1> <v2>(版本索引)');
|
|
109
|
+
const log = loadSessionLog(file);
|
|
110
|
+
const { files } = replayFileHistory(log.events.map((e) => e.event));
|
|
111
|
+
const history = files.get(path) ?? [];
|
|
112
|
+
if (v1 < 0 || v2 < 0 || v1 >= history.length || v2 >= history.length) fail(`版本索引越界(共 ${history.length} 个版本)`);
|
|
113
|
+
const lines = diffLines(history[v1].content, history[v2].content);
|
|
114
|
+
process.stdout.write(`🔀 retrace file-diff —— ${path} v${v1}(${history[v1].hash}) → v${v2}(${history[v2].hash})\n`);
|
|
115
|
+
let added = 0;
|
|
116
|
+
let removed = 0;
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (line.op === 'add') added += 1;
|
|
119
|
+
if (line.op === 'del') removed += 1;
|
|
120
|
+
}
|
|
121
|
+
process.stdout.write(` +${added} / -${removed}\n`);
|
|
122
|
+
for (const line of lines.slice(0, 40)) {
|
|
123
|
+
process.stdout.write(` ${line.op === 'add' ? '+' : line.op === 'del' ? '-' : ' '} ${line.text}\n`);
|
|
124
|
+
}
|
|
125
|
+
if (lines.length > 40) process.stdout.write(` … 其余 ${lines.length - 40} 行\n`);
|
|
126
|
+
} else if (cmd === 'lineage') {
|
|
127
|
+
const file = resolveSessionFile(positional[0] ?? fail('lineage 需要 <session>'));
|
|
128
|
+
const log = loadSessionLog(file);
|
|
129
|
+
const lineage = lineageOf(log);
|
|
130
|
+
if (json) return process.stdout.write(JSON.stringify(lineage, null, 2) + '\n');
|
|
131
|
+
process.stdout.write(`🌿 retrace lineage —— ${lineage.id}\n`);
|
|
132
|
+
process.stdout.write(` parent: ${lineage.parentId ?? '(无,根会话)'}\n`);
|
|
133
|
+
} else {
|
|
134
|
+
fail(cmd ? `未知命令 "${cmd}"` : '缺少命令');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
main();
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-retrace · lib/archaeology-cli.js
|
|
3
|
+
*
|
|
4
|
+
* 会话日志考古 CLI 的核心逻辑(任务书 dsh-会话日志考古-插件任务与方法.md A1-A4)。
|
|
5
|
+
* 与 dsh-log-contract 的 archaeology.js 分工:B 侧提供 extract/audit 纯函数,
|
|
6
|
+
* 本模块提供 A 侧的文件版本考古(write/edit 重放)与谱系(parent 链)。
|
|
7
|
+
* **只读不写**(纪律 §8.1)。
|
|
8
|
+
*/
|
|
9
|
+
import { loadSessionLog, extractToolOutputs, auditToolCalls } from 'dsh-log-contract';
|
|
10
|
+
import { accessSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
|
|
14
|
+
/** 文件写工具名(与 host-core WRITE_TOOLS 同族;DSH 真实工具名为裸 write/edit)。 */
|
|
15
|
+
const WRITE_NAMES = /^(?:fs\.)?(?:write|edit|create|append|patch|apply-patch)$/i;
|
|
16
|
+
|
|
17
|
+
/** 从 tool/call 提取文件操作 { path, kind: 'write'|'edit', content? }。 */
|
|
18
|
+
export function fileOpFromCall(event) {
|
|
19
|
+
if (event?.type !== 'tool/call') return null;
|
|
20
|
+
const name = typeof event.data?.name === 'string' ? event.data.name : '';
|
|
21
|
+
if (!WRITE_NAMES.test(name)) return null;
|
|
22
|
+
let args = {};
|
|
23
|
+
try {
|
|
24
|
+
args = typeof event.data?.arguments === 'string' ? JSON.parse(event.data.arguments) : event.data?.arguments ?? {};
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const path = args.file_path ?? args.path ?? args.filePath ?? args.file ?? args.target;
|
|
29
|
+
if (typeof path !== 'string' || path === '') return null;
|
|
30
|
+
const isEdit = /edit|patch/i.test(name);
|
|
31
|
+
if (isEdit) {
|
|
32
|
+
if (typeof args.old_string !== 'string' || typeof args.new_string !== 'string') return null;
|
|
33
|
+
return { path, kind: 'edit', oldString: args.old_string, newString: args.new_string, seq: event.seq, time: event.time, name };
|
|
34
|
+
}
|
|
35
|
+
if (typeof args.content !== 'string') return null;
|
|
36
|
+
return { path, kind: 'write', content: args.content, seq: event.seq, time: event.time, name };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 重放会话内全部文件写操作 → 每文件的版本序列(A3)。
|
|
41
|
+
* write 记全量;edit 基于上一版本做 old→new 替换(重放得到全量)。
|
|
42
|
+
*
|
|
43
|
+
* @param events - 会话事件数组。
|
|
44
|
+
* @returns {{
|
|
45
|
+
* files: Map<path, Array<{ seq, time, kind, tool, hash, size, content }>>,
|
|
46
|
+
* }}
|
|
47
|
+
*/
|
|
48
|
+
export function replayFileHistory(events) {
|
|
49
|
+
const files = new Map();
|
|
50
|
+
for (const event of events) {
|
|
51
|
+
const op = fileOpFromCall(event);
|
|
52
|
+
if (!op) continue;
|
|
53
|
+
const history = files.get(op.path) ?? [];
|
|
54
|
+
let content;
|
|
55
|
+
if (op.kind === 'write') {
|
|
56
|
+
content = op.content;
|
|
57
|
+
} else {
|
|
58
|
+
const prev = history[history.length - 1]?.content ?? '';
|
|
59
|
+
content = prev.includes(op.oldString) ? prev.replace(op.oldString, op.newString) : prev;
|
|
60
|
+
}
|
|
61
|
+
history.push({
|
|
62
|
+
seq: op.seq,
|
|
63
|
+
time: op.time,
|
|
64
|
+
kind: op.kind,
|
|
65
|
+
tool: op.name,
|
|
66
|
+
hash: simpleHash(content),
|
|
67
|
+
size: content.length,
|
|
68
|
+
content,
|
|
69
|
+
});
|
|
70
|
+
files.set(op.path, history);
|
|
71
|
+
}
|
|
72
|
+
return { files };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 轻量内容哈希(考古版本标识用,非加密)。 */
|
|
76
|
+
export function simpleHash(text) {
|
|
77
|
+
let h = 2166136261;
|
|
78
|
+
for (let i = 0; i < text.length; i++) {
|
|
79
|
+
h ^= text.charCodeAt(i);
|
|
80
|
+
h = Math.imul(h, 16777619);
|
|
81
|
+
}
|
|
82
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 行级 diff:返回 { added, removed, lines: [{op:'same'|'add'|'del', text}] }。 */
|
|
86
|
+
export function diffLines(a, b) {
|
|
87
|
+
const al = a.split('\n');
|
|
88
|
+
const bl = b.split('\n');
|
|
89
|
+
const lines = [];
|
|
90
|
+
let i = 0;
|
|
91
|
+
let j = 0;
|
|
92
|
+
// 简单前缀/后缀对齐(考古 diff 够用;完整 LCS 不必要)
|
|
93
|
+
while (i < al.length && j < bl.length && al[i] === bl[j]) {
|
|
94
|
+
lines.push({ op: 'same', text: al[i] });
|
|
95
|
+
i++;
|
|
96
|
+
j++;
|
|
97
|
+
}
|
|
98
|
+
const at = al.length - 1;
|
|
99
|
+
const bt = bl.length - 1;
|
|
100
|
+
let ae = al.length;
|
|
101
|
+
let be = bl.length;
|
|
102
|
+
while (ae > i && be > j && al[ae - 1] === bl[be - 1]) {
|
|
103
|
+
ae--;
|
|
104
|
+
be--;
|
|
105
|
+
}
|
|
106
|
+
for (let k = i; k < ae; k++) lines.push({ op: 'del', text: al[k] });
|
|
107
|
+
for (let k = j; k < be; k++) lines.push({ op: 'add', text: bl[k] });
|
|
108
|
+
while (ae < al.length) {
|
|
109
|
+
lines.push({ op: 'same', text: al[ae] });
|
|
110
|
+
ae++;
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 解析会话参数:完整文件路径,或 sessionId(在 ~/.dsh/sessions 下查找)。 */
|
|
116
|
+
export function resolveSessionFile(ref) {
|
|
117
|
+
if (typeof ref !== 'string' || ref === '') throw new Error('session 参数不能为空');
|
|
118
|
+
if (ref.includes('/') || ref.endsWith('.zstd') || ref.endsWith('.jsonl')) return ref;
|
|
119
|
+
// 按 id 在所有工作区查找
|
|
120
|
+
const root = join(homedir(), '.dsh', 'sessions');
|
|
121
|
+
for (const workspace of readdirSync(root)) {
|
|
122
|
+
const candidate = join(root, workspace, ref, 'session.jsonl.zstd');
|
|
123
|
+
try {
|
|
124
|
+
accessSync(candidate);
|
|
125
|
+
return candidate;
|
|
126
|
+
} catch { /* keep looking */ }
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`session "${ref}" 未在 ~/.dsh/sessions 下找到`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 谱系(A4):从当前会话沿 header.parentSession 追溯父链。
|
|
133
|
+
* @param log - loadSessionLog 结果。
|
|
134
|
+
* @returns {{ id, parentId, ancestors: Array<{id, parentId}> }}
|
|
135
|
+
*/
|
|
136
|
+
export function lineageOf(log) {
|
|
137
|
+
const header = log?.header ?? {};
|
|
138
|
+
const ancestors = [];
|
|
139
|
+
let parentId = header.parentSession ?? null;
|
|
140
|
+
const seen = new Set([header.id]);
|
|
141
|
+
while (parentId && !seen.has(parentId)) {
|
|
142
|
+
seen.add(parentId);
|
|
143
|
+
ancestors.push({ id: parentId, parentId: null });
|
|
144
|
+
// 父会话文件在同一工作区;不递归读文件(只列 id 链,内容读取留给调用方)
|
|
145
|
+
parentId = null; // 需要父文件才能继续;单次给出直接父
|
|
146
|
+
}
|
|
147
|
+
return { id: header.id, parentId: header.parentSession ?? null, ancestors };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export { extractToolOutputs, auditToolCalls, loadSessionLog };
|