dsh-retrace 0.4.5 → 0.4.7
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 +55 -2
- package/lib/client.js +58 -0
- package/lib/dynamic-client.js +55 -2
- package/lib/dynamic-host.js +38 -5
- package/lib/host-core.js +38 -5
- package/lib/http.js +24 -0
- package/lib/index.js +5 -0
- package/lib/prewrite-guard.js +50 -10
- package/lib/versioning.js +50 -0
- 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 };
|
package/lib/client.bundle.js
CHANGED
|
@@ -65,6 +65,7 @@ var zh = {
|
|
|
65
65
|
"marker.referenceHint": "\u70B9\u51FB\u5C55\u5F00\u67E5\u770B\u539F\u63D0\u95EE\uFF08\u4EC5\u4F5C\u5BF9\u7167\uFF0C\u4E0D\u4F1A\u8FDB\u5165\u6A21\u578B\u4E0A\u4E0B\u6587\uFF09",
|
|
66
66
|
"marker.degradedHint": "\u6B64\u64CD\u4F5C\u6D89\u53CA\u5927\u8303\u56F4\u5BF9\u8BDD\uFF0C\u4E3A\u4FDD\u62A4\u5386\u53F2\u672A\u9690\u85CF\u5185\u5BB9\uFF08\u65E5\u5FD7\u5B8C\u597D\uFF09\u3002",
|
|
67
67
|
"marker.unionHint": "\u5DF2\u7D2F\u79EF\u9690\u85CF\u7EA6 {count}% \u7684\u5386\u53F2\u6D88\u606F\uFF1B\u53EF\u5728 \u8BBE\u7F6E\u2192\u901A\u7528 \u5173\u95ED\u300C\u6309\u6807\u8BB0\u9690\u85CF\u300D\u67E5\u770B\u5B8C\u6574\u5386\u53F2\u3002",
|
|
68
|
+
"marker.t1Broken": "\u7F16\u8F91\u5DF2\u751F\u6548\uFF1B\u6B64\u6807\u8BB0\u4F1A\u4F7F\u672C\u4F1A\u8BDD\u7684 /compact \u5931\u6548\u3002\u79BB\u7EBF\u6E05\u7406\uFF1A\u5173\u95ED\u4F1A\u8BDD\u540E\u8FD0\u884C dsh-log-contract fix --drop-turnnull\uFF08\u7F16\u8F91\u5916\u89C2\u4F1A\u56DE\u9000\u4E3A\u539F\u59CB\u5185\u5BB9\uFF09\u3002",
|
|
68
69
|
"options.title": "\u6D88\u606F\u7F16\u8F91\u63D2\u4EF6",
|
|
69
70
|
"options.showOriginalInput": "\u7F16\u8F91\u540E\u663E\u793A\u539F\u63D0\u95EE\u5BF9\u7167",
|
|
70
71
|
"options.editFromScratch": "\u7F16\u8F91\u540E\u4ECE\u65B0\u5BF9\u8BDD\u5F00\u59CB\uFF08\u9690\u85CF\u6B64\u524D\u7684\u6D88\u606F\uFF0C\u9ED8\u8BA4\u5173\uFF09",
|
|
@@ -115,6 +116,7 @@ var zh = {
|
|
|
115
116
|
"timeline.trajectory": "\u8F68\u8FF9\u53F0\u8D26",
|
|
116
117
|
"timeline.jump": "\u8DF3\u8F6C",
|
|
117
118
|
"timeline.jumpFailed": "\u8BE5\u7248\u672C\u5728\u8F83\u8FDC\u7684\u8FC7\u53BB\uFF08\u8D85\u51FA\u81EA\u52A8\u52A0\u8F7D\u9884\u7B97\uFF09\uFF0C\u65E0\u6CD5\u76F4\u63A5\u5B9A\u4F4D\u3002\u8BF7\u5411\u4E0A\u6EDA\u52A8\u52A0\u8F7D\u66F4\u65E9\u6D88\u606F\u540E\u91CD\u8BD5\uFF1B\u6216\u7528\u300C\u8BE6\u60C5\u300D\u67E5\u770B\u8BE5\u7248\u672C\u5F53\u65F6\u7684\u4E8B\u4EF6\u539F\u6587\u3002",
|
|
119
|
+
"timeline.doctorWarn": "\u8BE5\u4F1A\u8BDD\u542B {count} \u4E2A\u7F16\u8F91/\u64A4\u56DE\u6807\u8BB0\uFF0C\u538B\u7F29\uFF08/compact\uFF09\u524D\u8BF7\u5148\u6E05\u7406\uFF08token meter \u517C\u5BB9\uFF09\u3002",
|
|
118
120
|
"timeline.gitRepo": "git \u4ED3\u5E93",
|
|
119
121
|
"timeline.gitHead": "HEAD {hash}",
|
|
120
122
|
"timeline.gitDirty": "\u5DE5\u4F5C\u533A\u6709\u672A\u63D0\u4EA4\u6539\u52A8",
|
|
@@ -132,7 +134,12 @@ var zh = {
|
|
|
132
134
|
"fork.node.user": "\u7528\u6237\u6D88\u606F",
|
|
133
135
|
"fork.node.assistant": "\u52A9\u624B\u56DE\u590D",
|
|
134
136
|
"fork.node.tool": "\u5DE5\u5177\u7ED3\u679C",
|
|
135
|
-
"fork.histTitle": "\u5386\u53F2\u5206\u53C9\u70B9"
|
|
137
|
+
"fork.histTitle": "\u5386\u53F2\u5206\u53C9\u70B9",
|
|
138
|
+
"fork.lineage": "\u4F1A\u8BDD\u8C31\u7CFB",
|
|
139
|
+
"fork.lineageThis": "\u5F53\u524D\u4F1A\u8BDD",
|
|
140
|
+
"fork.lineageParent": "\u7236\u4F1A\u8BDD",
|
|
141
|
+
"fork.lineageRoot": "\u6839\u4F1A\u8BDD",
|
|
142
|
+
"fork.lineageEmpty": "\u672C\u4F1A\u8BDD\u6CA1\u6709\u7236\u4F1A\u8BDD\uFF08\u72EC\u7ACB\u6839\u4F1A\u8BDD\uFF09\u3002"
|
|
136
143
|
};
|
|
137
144
|
var en = {
|
|
138
145
|
"action.edit": "Edit",
|
|
@@ -152,6 +159,7 @@ var en = {
|
|
|
152
159
|
"marker.referenceHint": "Click to expand the original input (reference only, never sent to the model)",
|
|
153
160
|
"marker.degradedHint": "This operation spans a large part of the conversation; content stays visible to protect your history (the log is intact).",
|
|
154
161
|
"marker.unionHint": 'About {count}% of the history is hidden in total; disable "Hide shadowed messages" in Settings \u2192 General to review the full history.',
|
|
162
|
+
"marker.t1Broken": "Edit applied; this marker will break /compact for this session. Offline clean-up: close the session and run dsh-log-contract fix --drop-turnnull (the edit reverts to the original content).",
|
|
155
163
|
"options.title": "Message editor plugin",
|
|
156
164
|
"options.showOriginalInput": "Show the original input after editing",
|
|
157
165
|
"options.editFromScratch": "Start a fresh conversation after editing (hide earlier messages, default off)",
|
|
@@ -202,6 +210,7 @@ var en = {
|
|
|
202
210
|
"timeline.trajectory": "Trajectory",
|
|
203
211
|
"timeline.jump": "Jump",
|
|
204
212
|
"timeline.jumpFailed": "This version lies too far back (beyond the auto-load budget) to locate directly. Scroll up to load earlier messages, or use Details to read the original event text of this version.",
|
|
213
|
+
"timeline.doctorWarn": "This session has {count} edit/recall markers; clean them before /compact (token-meter compatibility).",
|
|
205
214
|
"timeline.gitRepo": "git repository",
|
|
206
215
|
"timeline.gitHead": "HEAD {hash}",
|
|
207
216
|
"timeline.gitDirty": "working tree has uncommitted changes",
|
|
@@ -219,7 +228,12 @@ var en = {
|
|
|
219
228
|
"fork.node.user": "User message",
|
|
220
229
|
"fork.node.assistant": "Assistant reply",
|
|
221
230
|
"fork.node.tool": "Tool result",
|
|
222
|
-
"fork.histTitle": "Historical fork points"
|
|
231
|
+
"fork.histTitle": "Historical fork points",
|
|
232
|
+
"fork.lineage": "Session lineage",
|
|
233
|
+
"fork.lineageThis": "This session",
|
|
234
|
+
"fork.lineageParent": "Parent session",
|
|
235
|
+
"fork.lineageRoot": "Root session",
|
|
236
|
+
"fork.lineageEmpty": "This session has no parent (standalone root)."
|
|
223
237
|
};
|
|
224
238
|
var SURFACE_TYPES = /* @__PURE__ */ new Set(["user/message", "assistant/message", "tool/result"]);
|
|
225
239
|
function isReplacementSurfaceEvent(event) {
|
|
@@ -659,6 +673,9 @@ function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
|
|
|
659
673
|
setFailure(code === "agent-busy" ? t("error.busy") : result?.error?.message ?? t("error.generic"));
|
|
660
674
|
return;
|
|
661
675
|
}
|
|
676
|
+
if (result.value?.markerT1Broken === true) {
|
|
677
|
+
setFailure(t("marker.t1Broken"));
|
|
678
|
+
}
|
|
662
679
|
if (op === "recall") {
|
|
663
680
|
const echoed = typeof result.value?.text === "string" && result.value.text.length > 0 ? result.value.text : textOf(content);
|
|
664
681
|
if (echoed && inputActions && typeof inputActions.setDraft === "function") {
|
|
@@ -855,6 +872,8 @@ var CSS = `
|
|
|
855
872
|
.dsh-rt-timeline-head{display:flex;align-items:center;gap:8px;flex:none}
|
|
856
873
|
.dsh-rt-timeline-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px;flex:1}
|
|
857
874
|
.dsh-rt-timeline-git{display:flex;align-items:center;gap:6px;flex:none;border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;padding:4px 8px}
|
|
875
|
+
.dsh-rt-doctor{border-color:var(--dsw-alias-state-warning-primary);background:var(--dsw-alias-state-warn-tertiary)}
|
|
876
|
+
.dsh-rt-doctor .dsh-rt-timeline-git-text{color:var(--dsw-alias-state-warning-primary)}
|
|
858
877
|
.dsh-rt-timeline-git-text{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px}
|
|
859
878
|
.dsh-rt-timeline-list{overflow-y:auto;flex:1;min-height:0;position:relative;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
|
|
860
879
|
.dsh-rt-timeline-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:12px 4px;text-align:center}
|
|
@@ -904,6 +923,14 @@ var CSS = `
|
|
|
904
923
|
.dsh-rt-fork-hist{display:flex;flex-direction:column;gap:4px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px;overflow-y:auto;flex:none}
|
|
905
924
|
.dsh-rt-fork-hist-row{display:flex;align-items:flex-start;gap:8px;padding:4px 8px;border-radius:8px}
|
|
906
925
|
.dsh-rt-fork-hist-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
926
|
+
.dsh-rt-fork-lineage{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:8px;padding:8px;flex:none}
|
|
927
|
+
.dsh-rt-fork-lineage-hop{display:flex;align-items:baseline;gap:8px;min-width:0}
|
|
928
|
+
.dsh-rt-fork-lineage-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
|
|
929
|
+
.dsh-rt-lineage-this .dsh-rt-fork-lineage-tag{color:var(--dsw-alias-state-info-primary)}
|
|
930
|
+
.dsh-rt-lineage-root .dsh-rt-fork-lineage-tag{color:var(--dsw-alias-state-success-primary)}
|
|
931
|
+
.dsh-rt-fork-lineage-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
932
|
+
.dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
|
|
933
|
+
.dsh-rt-fork-lineage-empty{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;flex:none}
|
|
907
934
|
`;
|
|
908
935
|
var JUMP_PAGE_BUDGET = 24;
|
|
909
936
|
function switchToViewTab(viewId) {
|
|
@@ -1055,6 +1082,7 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1055
1082
|
const [previewScope, setPreviewScope] = (0, import_react.useState)("both");
|
|
1056
1083
|
const [rollbackBusy, setRollbackBusy] = (0, import_react.useState)(false);
|
|
1057
1084
|
const [scrollTop, setScrollTop] = (0, import_react.useState)(0);
|
|
1085
|
+
const [doctor, setDoctor] = (0, import_react.useState)(null);
|
|
1058
1086
|
const projected = typeof useProjection === "function" ? useProjection("retrace/versions") : void 0;
|
|
1059
1087
|
(0, import_react.useEffect)(() => {
|
|
1060
1088
|
if (projected && Array.isArray(projected.versions)) setVersions(projected.versions);
|
|
@@ -1074,6 +1102,7 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1074
1102
|
(0, import_react.useEffect)(() => {
|
|
1075
1103
|
if (projected === void 0) refresh();
|
|
1076
1104
|
refreshGit();
|
|
1105
|
+
timelineGet(`/doctor?sessionId=${encodeURIComponent(sessionId)}`).then((result) => setDoctor(result?.ok === true ? result.value : null)).catch(() => setDoctor(null));
|
|
1077
1106
|
}, []);
|
|
1078
1107
|
const jump = (boundarySeq) => jumpToAnchor(store, boundarySeq);
|
|
1079
1108
|
const requestPreview = (record) => {
|
|
@@ -1122,6 +1151,9 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1122
1151
|
onClick: refresh
|
|
1123
1152
|
}, t("timeline.refresh"))
|
|
1124
1153
|
]),
|
|
1154
|
+
doctor && doctor.enabled && doctor.markerCount > 0 && (0, import_react.createElement)("div", { key: "doctor", className: "dsh-rt-timeline-git dsh-rt-doctor" }, [
|
|
1155
|
+
(0, import_react.createElement)("span", { key: "text", className: "dsh-rt-timeline-git-text" }, t("timeline.doctorWarn", { count: doctor.markerCount }))
|
|
1156
|
+
]),
|
|
1125
1157
|
git !== null && git !== void 0 && (0, import_react.createElement)("div", { key: "git", className: "dsh-rt-timeline-git" }, [
|
|
1126
1158
|
git.headHash ? [
|
|
1127
1159
|
(0, import_react.createElement)("span", { key: "r", className: "dsh-rt-timeline-git-text" }, `${t("timeline.gitRepo")} \xB7 ${t("timeline.gitHead", { hash: git.headHash.slice(0, 8) })}${git.dirty ? ` \xB7 ${t("timeline.gitDirty")}` : ""}`)
|
|
@@ -1237,6 +1269,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1237
1269
|
const [loading, setLoading] = (0, import_react.useState)(false);
|
|
1238
1270
|
const [error, setError] = (0, import_react.useState)(null);
|
|
1239
1271
|
const [scrollTop, setScrollTop] = (0, import_react.useState)(0);
|
|
1272
|
+
const [lineage, setLineage] = (0, import_react.useState)(null);
|
|
1240
1273
|
const projected = typeof useProjection === "function" ? useProjection("retrace/forkmap") : void 0;
|
|
1241
1274
|
const versions = typeof useProjection === "function" ? useProjection("retrace/versions") : void 0;
|
|
1242
1275
|
const markerBySeq = new Map((versions?.versions ?? []).map((v) => [v.boundarySeq, v.markerText]));
|
|
@@ -1253,6 +1286,10 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1253
1286
|
};
|
|
1254
1287
|
(0, import_react.useEffect)(() => {
|
|
1255
1288
|
if (projected === void 0) refresh();
|
|
1289
|
+
timelineGet(`/lineage?sessionId=${encodeURIComponent(sessionId)}`).then((result) => {
|
|
1290
|
+
if (result && result.ok === true && Array.isArray(result.value)) setLineage(result.value);
|
|
1291
|
+
}).catch(() => {
|
|
1292
|
+
});
|
|
1256
1293
|
}, []);
|
|
1257
1294
|
const nodes = fork?.nodes ?? [];
|
|
1258
1295
|
const boundaries = fork?.boundaries ?? [];
|
|
@@ -1278,6 +1315,22 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1278
1315
|
onClick: refresh
|
|
1279
1316
|
}, t("fork.refresh"))
|
|
1280
1317
|
]),
|
|
1318
|
+
Array.isArray(lineage) && lineage.length > 1 && (0, import_react.createElement)("div", { key: "lineage", className: "dsh-rt-fork-lineage" }, [
|
|
1319
|
+
(0, import_react.createElement)("div", { key: "lt", className: "dsh-rt-fork-spine-label" }, t("fork.lineage")),
|
|
1320
|
+
lineage.map((hop, i) => (0, import_react.createElement)("div", {
|
|
1321
|
+
key: hop.id,
|
|
1322
|
+
className: `dsh-rt-fork-lineage-hop${i === 0 ? " dsh-rt-lineage-this" : ""}${i === lineage.length - 1 ? " dsh-rt-lineage-root" : ""}`
|
|
1323
|
+
}, [
|
|
1324
|
+
(0, import_react.createElement)(
|
|
1325
|
+
"span",
|
|
1326
|
+
{ key: "tag", className: "dsh-rt-fork-lineage-tag" },
|
|
1327
|
+
i === 0 ? t("fork.lineageThis") : i === lineage.length - 1 ? t("fork.lineageRoot") : t("fork.lineageParent")
|
|
1328
|
+
),
|
|
1329
|
+
(0, import_react.createElement)("span", { key: "id", className: "dsh-rt-fork-lineage-id" }, hop.id),
|
|
1330
|
+
i < lineage.length - 1 && (0, import_react.createElement)("span", { key: "arrow", className: "dsh-rt-fork-lineage-arrow" }, "\u2190")
|
|
1331
|
+
]))
|
|
1332
|
+
]),
|
|
1333
|
+
Array.isArray(lineage) && lineage.length <= 1 && (0, import_react.createElement)("div", { key: "lineage-empty", className: "dsh-rt-fork-lineage-empty" }, t("fork.lineageEmpty")),
|
|
1281
1334
|
error !== null && (0, import_react.createElement)("div", { key: "error", className: "dsh-rt-error" }, error),
|
|
1282
1335
|
loading && (0, import_react.createElement)("div", { key: "loading", className: "dsh-rt-timeline-empty" }, t("timeline.loading")),
|
|
1283
1336
|
!loading && nodes.length === 0 && (0, import_react.createElement)("div", { key: "empty", className: "dsh-rt-timeline-empty" }, t("fork.empty")),
|
package/lib/client.js
CHANGED
|
@@ -73,6 +73,7 @@ const zh = {
|
|
|
73
73
|
'marker.referenceHint': '点击展开查看原提问(仅作对照,不会进入模型上下文)',
|
|
74
74
|
'marker.degradedHint': '此操作涉及大范围对话,为保护历史未隐藏内容(日志完好)。',
|
|
75
75
|
'marker.unionHint': '已累积隐藏约 {count}% 的历史消息;可在 设置→通用 关闭「按标记隐藏」查看完整历史。',
|
|
76
|
+
'marker.t1Broken': '编辑已生效;此标记会使本会话的 /compact 失效。离线清理:关闭会话后运行 dsh-log-contract fix --drop-turnnull(编辑外观会回退为原始内容)。',
|
|
76
77
|
'options.title': '消息编辑插件',
|
|
77
78
|
'options.showOriginalInput': '编辑后显示原提问对照',
|
|
78
79
|
'options.editFromScratch': '编辑后从新对话开始(隐藏此前的消息,默认关)',
|
|
@@ -123,6 +124,7 @@ const zh = {
|
|
|
123
124
|
'timeline.trajectory': '轨迹台账',
|
|
124
125
|
'timeline.jump': '跳转',
|
|
125
126
|
'timeline.jumpFailed': '该版本在较远的过去(超出自动加载预算),无法直接定位。请向上滚动加载更早消息后重试;或用「详情」查看该版本当时的事件原文。',
|
|
127
|
+
'timeline.doctorWarn': '该会话含 {count} 个编辑/撤回标记,压缩(/compact)前请先清理(token meter 兼容)。',
|
|
126
128
|
'timeline.gitRepo': 'git 仓库',
|
|
127
129
|
'timeline.gitHead': 'HEAD {hash}',
|
|
128
130
|
'timeline.gitDirty': '工作区有未提交改动',
|
|
@@ -141,6 +143,11 @@ const zh = {
|
|
|
141
143
|
'fork.node.assistant': '助手回复',
|
|
142
144
|
'fork.node.tool': '工具结果',
|
|
143
145
|
'fork.histTitle': '历史分叉点',
|
|
146
|
+
'fork.lineage': '会话谱系',
|
|
147
|
+
'fork.lineageThis': '当前会话',
|
|
148
|
+
'fork.lineageParent': '父会话',
|
|
149
|
+
'fork.lineageRoot': '根会话',
|
|
150
|
+
'fork.lineageEmpty': '本会话没有父会话(独立根会话)。',
|
|
144
151
|
}
|
|
145
152
|
/** English dictionary, checked complete against the zh key set. */
|
|
146
153
|
const en = {
|
|
@@ -161,6 +168,7 @@ const en = {
|
|
|
161
168
|
'marker.referenceHint': 'Click to expand the original input (reference only, never sent to the model)',
|
|
162
169
|
'marker.degradedHint': 'This operation spans a large part of the conversation; content stays visible to protect your history (the log is intact).',
|
|
163
170
|
'marker.unionHint': 'About {count}% of the history is hidden in total; disable "Hide shadowed messages" in Settings → General to review the full history.',
|
|
171
|
+
'marker.t1Broken': 'Edit applied; this marker will break /compact for this session. Offline clean-up: close the session and run dsh-log-contract fix --drop-turnnull (the edit reverts to the original content).',
|
|
164
172
|
'options.title': 'Message editor plugin',
|
|
165
173
|
'options.showOriginalInput': 'Show the original input after editing',
|
|
166
174
|
'options.editFromScratch': 'Start a fresh conversation after editing (hide earlier messages, default off)',
|
|
@@ -211,6 +219,7 @@ const en = {
|
|
|
211
219
|
'timeline.trajectory': 'Trajectory',
|
|
212
220
|
'timeline.jump': 'Jump',
|
|
213
221
|
'timeline.jumpFailed': 'This version lies too far back (beyond the auto-load budget) to locate directly. Scroll up to load earlier messages, or use Details to read the original event text of this version.',
|
|
222
|
+
'timeline.doctorWarn': 'This session has {count} edit/recall markers; clean them before /compact (token-meter compatibility).',
|
|
214
223
|
'timeline.gitRepo': 'git repository',
|
|
215
224
|
'timeline.gitHead': 'HEAD {hash}',
|
|
216
225
|
'timeline.gitDirty': 'working tree has uncommitted changes',
|
|
@@ -229,6 +238,11 @@ const en = {
|
|
|
229
238
|
'fork.node.assistant': 'Assistant reply',
|
|
230
239
|
'fork.node.tool': 'Tool result',
|
|
231
240
|
'fork.histTitle': 'Historical fork points',
|
|
241
|
+
'fork.lineage': 'Session lineage',
|
|
242
|
+
'fork.lineageThis': 'This session',
|
|
243
|
+
'fork.lineageParent': 'Parent session',
|
|
244
|
+
'fork.lineageRoot': 'Root session',
|
|
245
|
+
'fork.lineageEmpty': 'This session has no parent (standalone root).',
|
|
232
246
|
}
|
|
233
247
|
|
|
234
248
|
// ---------------------------------------------------------------------------
|
|
@@ -864,6 +878,10 @@ function UserActionsRow({ node, sessionId, useSession, inputActions, t }) {
|
|
|
864
878
|
setFailure(code === 'agent-busy' ? t('error.busy') : (result?.error?.message ?? t('error.generic')))
|
|
865
879
|
return
|
|
866
880
|
}
|
|
881
|
+
if (result.value?.markerT1Broken === true) {
|
|
882
|
+
// R2:该 marker 会使 /compact 失效——编辑已生效,但提示离线清理。
|
|
883
|
+
setFailure(t('marker.t1Broken'))
|
|
884
|
+
}
|
|
867
885
|
if (op === 'recall') {
|
|
868
886
|
const echoed = typeof result.value?.text === 'string' && result.value.text.length > 0
|
|
869
887
|
? result.value.text
|
|
@@ -1087,6 +1105,8 @@ const CSS = `
|
|
|
1087
1105
|
.dsh-rt-timeline-head{display:flex;align-items:center;gap:8px;flex:none}
|
|
1088
1106
|
.dsh-rt-timeline-title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px;flex:1}
|
|
1089
1107
|
.dsh-rt-timeline-git{display:flex;align-items:center;gap:6px;flex:none;border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;padding:4px 8px}
|
|
1108
|
+
.dsh-rt-doctor{border-color:var(--dsw-alias-state-warning-primary);background:var(--dsw-alias-state-warn-tertiary)}
|
|
1109
|
+
.dsh-rt-doctor .dsh-rt-timeline-git-text{color:var(--dsw-alias-state-warning-primary)}
|
|
1090
1110
|
.dsh-rt-timeline-git-text{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px}
|
|
1091
1111
|
.dsh-rt-timeline-list{overflow-y:auto;flex:1;min-height:0;position:relative;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
|
|
1092
1112
|
.dsh-rt-timeline-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:12px 4px;text-align:center}
|
|
@@ -1136,6 +1156,14 @@ const CSS = `
|
|
|
1136
1156
|
.dsh-rt-fork-hist{display:flex;flex-direction:column;gap:4px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px;overflow-y:auto;flex:none}
|
|
1137
1157
|
.dsh-rt-fork-hist-row{display:flex;align-items:flex-start;gap:8px;padding:4px 8px;border-radius:8px}
|
|
1138
1158
|
.dsh-rt-fork-hist-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
1159
|
+
.dsh-rt-fork-lineage{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);border-radius:8px;padding:8px;flex:none}
|
|
1160
|
+
.dsh-rt-fork-lineage-hop{display:flex;align-items:baseline;gap:8px;min-width:0}
|
|
1161
|
+
.dsh-rt-fork-lineage-tag{font-size:11px;font-weight:500;line-height:16px;color:var(--dsw-alias-label-secondary);flex:none}
|
|
1162
|
+
.dsh-rt-lineage-this .dsh-rt-fork-lineage-tag{color:var(--dsw-alias-state-info-primary)}
|
|
1163
|
+
.dsh-rt-lineage-root .dsh-rt-fork-lineage-tag{color:var(--dsw-alias-state-success-primary)}
|
|
1164
|
+
.dsh-rt-fork-lineage-id{font-family:var(--dsw-font-mono);font-size:11px;line-height:16px;color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1165
|
+
.dsh-rt-fork-lineage-arrow{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);flex:none}
|
|
1166
|
+
.dsh-rt-fork-lineage-empty{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;flex:none}
|
|
1139
1167
|
`
|
|
1140
1168
|
|
|
1141
1169
|
const JUMP_PAGE_BUDGET = 24
|
|
@@ -1374,6 +1402,7 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1374
1402
|
const [previewScope, setPreviewScope] = useState('both')
|
|
1375
1403
|
const [rollbackBusy, setRollbackBusy] = useState(false)
|
|
1376
1404
|
const [scrollTop, setScrollTop] = useState(0)
|
|
1405
|
+
const [doctor, setDoctor] = useState(null)
|
|
1377
1406
|
|
|
1378
1407
|
// Live push-frame path (projection standard kit) — falls back to HTTP.
|
|
1379
1408
|
const projected = typeof useProjection === 'function' ? useProjection('retrace/versions') : undefined
|
|
@@ -1406,6 +1435,10 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1406
1435
|
useEffect(() => {
|
|
1407
1436
|
if (projected === undefined) refresh()
|
|
1408
1437
|
refreshGit()
|
|
1438
|
+
// Compression pre-check: flag turn-null markers that would break /compact.
|
|
1439
|
+
timelineGet(`/doctor?sessionId=${encodeURIComponent(sessionId)}`)
|
|
1440
|
+
.then((result) => setDoctor(result?.ok === true ? result.value : null))
|
|
1441
|
+
.catch(() => setDoctor(null))
|
|
1409
1442
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1410
1443
|
}, [])
|
|
1411
1444
|
|
|
@@ -1472,6 +1505,10 @@ function RetraceView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1472
1505
|
}, t('timeline.refresh')),
|
|
1473
1506
|
]),
|
|
1474
1507
|
|
|
1508
|
+
doctor && doctor.enabled && doctor.markerCount > 0 && createElement('div', { key: 'doctor', className: 'dsh-rt-timeline-git dsh-rt-doctor' }, [
|
|
1509
|
+
createElement('span', { key: 'text', className: 'dsh-rt-timeline-git-text' }, t('timeline.doctorWarn', { count: doctor.markerCount })),
|
|
1510
|
+
]),
|
|
1511
|
+
|
|
1475
1512
|
git !== null && git !== undefined && createElement('div', { key: 'git', className: 'dsh-rt-timeline-git' }, [
|
|
1476
1513
|
git.headHash
|
|
1477
1514
|
? [
|
|
@@ -1609,6 +1646,7 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1609
1646
|
const [loading, setLoading] = useState(false)
|
|
1610
1647
|
const [error, setError] = useState(null)
|
|
1611
1648
|
const [scrollTop, setScrollTop] = useState(0)
|
|
1649
|
+
const [lineage, setLineage] = useState(null)
|
|
1612
1650
|
|
|
1613
1651
|
// Live push-frame path (projection standard kit) — falls back to HTTP.
|
|
1614
1652
|
const projected = typeof useProjection === 'function' ? useProjection('retrace/forkmap') : undefined
|
|
@@ -1636,6 +1674,12 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1636
1674
|
// registry; HTTP is the on-demand fallback for minimal compositions.
|
|
1637
1675
|
useEffect(() => {
|
|
1638
1676
|
if (projected === undefined) refresh()
|
|
1677
|
+
// Lineage chain (A4): read-only parent walk; shown as a header card.
|
|
1678
|
+
timelineGet(`/lineage?sessionId=${encodeURIComponent(sessionId)}`)
|
|
1679
|
+
.then((result) => {
|
|
1680
|
+
if (result && result.ok === true && Array.isArray(result.value)) setLineage(result.value)
|
|
1681
|
+
})
|
|
1682
|
+
.catch(() => { /* lineage is a best-effort enrichment */ })
|
|
1639
1683
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1640
1684
|
}, [])
|
|
1641
1685
|
|
|
@@ -1675,6 +1719,20 @@ function ForkView({ sessionId, useProjection, t, actions, store }) {
|
|
|
1675
1719
|
}, t('fork.refresh')),
|
|
1676
1720
|
]),
|
|
1677
1721
|
|
|
1722
|
+
Array.isArray(lineage) && lineage.length > 1 && createElement('div', { key: 'lineage', className: 'dsh-rt-fork-lineage' }, [
|
|
1723
|
+
createElement('div', { key: 'lt', className: 'dsh-rt-fork-spine-label' }, t('fork.lineage')),
|
|
1724
|
+
lineage.map((hop, i) => createElement('div', {
|
|
1725
|
+
key: hop.id,
|
|
1726
|
+
className: `dsh-rt-fork-lineage-hop${i === 0 ? ' dsh-rt-lineage-this' : ''}${i === lineage.length - 1 ? ' dsh-rt-lineage-root' : ''}`,
|
|
1727
|
+
}, [
|
|
1728
|
+
createElement('span', { key: 'tag', className: 'dsh-rt-fork-lineage-tag' },
|
|
1729
|
+
i === 0 ? t('fork.lineageThis') : (i === lineage.length - 1 ? t('fork.lineageRoot') : t('fork.lineageParent'))),
|
|
1730
|
+
createElement('span', { key: 'id', className: 'dsh-rt-fork-lineage-id' }, hop.id),
|
|
1731
|
+
i < lineage.length - 1 && createElement('span', { key: 'arrow', className: 'dsh-rt-fork-lineage-arrow' }, '←'),
|
|
1732
|
+
])),
|
|
1733
|
+
]),
|
|
1734
|
+
Array.isArray(lineage) && lineage.length <= 1 && createElement('div', { key: 'lineage-empty', className: 'dsh-rt-fork-lineage-empty' }, t('fork.lineageEmpty')),
|
|
1735
|
+
|
|
1678
1736
|
error !== null && createElement('div', { key: 'error', className: 'dsh-rt-error' }, error),
|
|
1679
1737
|
loading && createElement('div', { key: 'loading', className: 'dsh-rt-timeline-empty' }, t('timeline.loading')),
|
|
1680
1738
|
!loading && nodes.length === 0 && createElement('div', { key: 'empty', className: 'dsh-rt-timeline-empty' }, t('fork.empty')),
|