toolflow 3.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +31 -0
- package/README.md +106 -0
- package/README_zh.md +109 -0
- package/docs/reports/ADVANCED_EVOLUTION_REPORT.md +44 -0
- package/docs/reports/AUDIT_AND_OPTIMIZATION_REPORT.md +645 -0
- package/docs/reports/COLD_START_REVIEW_EVOLUTION.md +51 -0
- package/docs/reports/DEEP_ECOSYSTEM_EVOLUTION.md +43 -0
- package/docs/reports/MEMORY.md +18 -0
- package/docs/reports/MICHAEL_DISPATCH_RESULT.md +36 -0
- package/docs/reports/OPENSOURCE_INTEGRATION_REPORT.md +43 -0
- package/docs/reports/PHASE_1_OPTIMIZATION_REPORT.md +87 -0
- package/docs/reports/PHASE_2_OPTIMIZATION_REPORT.md +50 -0
- package/docs/reports/PHASE_3_OPTIMIZATION_REPORT.md +24 -0
- package/docs/reports/PHASE_4_OPTIMIZATION_REPORT.md +28 -0
- package/docs/reports/REPORT_TO_MICHAEL.md +101 -0
- package/docs/reports/SIGNOFF_AND_RELEASE_REPORT.md +85 -0
- package/docs/reports/STAFF_ASSIGNMENTS.md +26 -0
- package/docs/reports/TASK_ASSIGNMENTS.md +59 -0
- package/docs/reports/V1_6_0_EVOLUTION_REPORT.md +48 -0
- package/docs/reports/V1_9_0_HOTFIX_REPORT.md +30 -0
- package/docs/reports/V2_0_0_RELEASE_REPORT.md +18 -0
- package/docs/reports/V2_2_0_ZERO_SPECIALIZATION_REPORT.md +24 -0
- package/docs/reports/V2_3_0_EVOLUTION_REPORT.md +12 -0
- package/ecosystem_taxonomy.json +798 -0
- package/package.json +46 -0
- package/src/blast_radius.ts +302 -0
- package/src/deep_ecosystem.ts +523 -0
- package/src/degradation_matrix.ts +180 -0
- package/src/dehydrator.ts +532 -0
- package/src/ecosystem_taxonomy.json +803 -0
- package/src/engine.ts +1510 -0
- package/src/i18n.ts +89 -0
- package/src/index.ts +983 -0
- package/src/json_extractor.ts +57 -0
- package/src/memory.ts +151 -0
- package/src/prompts_manager.ts +262 -0
- package/src/review_isolation.ts +188 -0
- package/src/state.ts +810 -0
- package/src/taxonomy.ts +580 -0
- package/src/types.ts +341 -0
- package/src/ui.ts +1036 -0
- package/src/worker_orchestrator.ts +60 -0
- package/tests/challenger_stress_harness.ts +265 -0
- package/tests/monorepo_multilang_stress.ts +404 -0
- package/tests/sandbox_e2e.ts +167 -0
- package/tests/test_json_extractor.ts +44 -0
- package/tests/test_modules_1_to_4.ts +106 -0
- package/tests/test_suite.ts +1689 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as crypto from "node:crypto";
|
|
4
|
+
import type { ArtifactRecord } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface DehydratedStageHandoff {
|
|
7
|
+
stageId: string;
|
|
8
|
+
stageTitle: string;
|
|
9
|
+
verifiedArtifacts: ArtifactRecord[];
|
|
10
|
+
contractSummary: string;
|
|
11
|
+
rawLogFilePath: string;
|
|
12
|
+
tokenSavingsRatio: string;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
topologyHints?: {
|
|
15
|
+
importedModules?: string[];
|
|
16
|
+
exportedSymbols?: string[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReadToolInput {
|
|
21
|
+
path: string;
|
|
22
|
+
offset?: number;
|
|
23
|
+
limit?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CacheCheckResult {
|
|
27
|
+
isDuplicate: boolean;
|
|
28
|
+
notice?: string;
|
|
29
|
+
savedLines?: number;
|
|
30
|
+
savedTokens?: number;
|
|
31
|
+
bypassReason?: "targeted_window" | "recent_edit_failure" | "small_file" | "command_dirty" | "not_cached";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024; // 10MB 单阶段日志截断上限
|
|
35
|
+
const MAX_TOTAL_DISK_BYTES = 200 * 1024 * 1024; // 200MB 运行归档总配额
|
|
36
|
+
|
|
37
|
+
export class ContextDehydrator {
|
|
38
|
+
private runsDir: string;
|
|
39
|
+
private baseDir: string;
|
|
40
|
+
|
|
41
|
+
constructor(cwd: string = process.cwd(), blueprintId: string = "default") {
|
|
42
|
+
this.baseDir = path.join(cwd, ".pi", "toolflow", "runs");
|
|
43
|
+
this.runsDir = path.join(this.baseDir, blueprintId);
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(this.runsDir, { recursive: true });
|
|
46
|
+
} catch (_) {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getDirectorySizeBytes(dirPath: string): number {
|
|
50
|
+
let total = 0;
|
|
51
|
+
try {
|
|
52
|
+
if (!fs.existsSync(dirPath)) return 0;
|
|
53
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const full = path.join(dirPath, entry.name);
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
total += this.getDirectorySizeBytes(full);
|
|
58
|
+
} else if (entry.isFile()) {
|
|
59
|
+
total += fs.statSync(full).size;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (_) {}
|
|
63
|
+
return total;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 清理过期或超过容量配额的脱水归档运行目录 (LRU + 200MB 硬配额)
|
|
68
|
+
*/
|
|
69
|
+
public pruneOldRuns(
|
|
70
|
+
maxRuns: number = 10,
|
|
71
|
+
maxAgeMs: number = 7 * 24 * 60 * 60 * 1000,
|
|
72
|
+
maxDiskBytes: number = MAX_TOTAL_DISK_BYTES
|
|
73
|
+
): string[] {
|
|
74
|
+
const deletedDirs: string[] = [];
|
|
75
|
+
try {
|
|
76
|
+
if (!fs.existsSync(this.baseDir)) return deletedDirs;
|
|
77
|
+
const entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
|
|
78
|
+
const runFolders = entries
|
|
79
|
+
.filter(e => e.isDirectory())
|
|
80
|
+
.map(e => {
|
|
81
|
+
const fullPath = path.join(this.baseDir, e.name);
|
|
82
|
+
const stat = fs.statSync(fullPath);
|
|
83
|
+
const sizeBytes = this.getDirectorySizeBytes(fullPath);
|
|
84
|
+
return { name: e.name, fullPath, mtimeMs: stat.mtimeMs, sizeBytes };
|
|
85
|
+
})
|
|
86
|
+
.filter(f => f.fullPath !== this.runsDir) // 保护当前正在执行的 Run,仅对其余历史运行目录实施配额管理
|
|
87
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs); // 升序,最旧在前面 (Index 0 is Oldest)
|
|
88
|
+
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
let totalSize = runFolders.reduce((acc, f) => acc + f.sizeBytes, 0);
|
|
91
|
+
const retained = [...runFolders];
|
|
92
|
+
|
|
93
|
+
for (const folder of runFolders) {
|
|
94
|
+
const isTooOld = now - folder.mtimeMs > maxAgeMs;
|
|
95
|
+
const isExceedingCount = retained.length > maxRuns;
|
|
96
|
+
const isExceedingQuota = totalSize > maxDiskBytes;
|
|
97
|
+
|
|
98
|
+
if (isTooOld || isExceedingCount || isExceedingQuota) {
|
|
99
|
+
try {
|
|
100
|
+
fs.rmSync(folder.fullPath, { recursive: true, force: true });
|
|
101
|
+
totalSize -= folder.sizeBytes;
|
|
102
|
+
const idx = retained.findIndex(r => r.name === folder.name);
|
|
103
|
+
if (idx >= 0) retained.splice(idx, 1);
|
|
104
|
+
deletedDirs.push(folder.name);
|
|
105
|
+
} catch (_) {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (_) {}
|
|
109
|
+
return deletedDirs;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 将阶段庞大原始调试与执行日志落盘归档,返回脱水三元组,自动提取拓扑依赖提示
|
|
114
|
+
*/
|
|
115
|
+
public dehydrateStageLog(
|
|
116
|
+
stageId: string,
|
|
117
|
+
stageTitle: string,
|
|
118
|
+
rawLogs: string,
|
|
119
|
+
artifacts: ArtifactRecord[],
|
|
120
|
+
contractSummary: string
|
|
121
|
+
): DehydratedStageHandoff {
|
|
122
|
+
const logFileName = `stage_${stageId}_raw.log`;
|
|
123
|
+
const rawLogFilePath = path.join(this.runsDir, logFileName);
|
|
124
|
+
|
|
125
|
+
// V8 堆内存与超大日志落盘截断保护 (基于 UTF-8 字节长度精确计量)
|
|
126
|
+
const rawStr = typeof rawLogs === "string" ? rawLogs : String(rawLogs ?? "");
|
|
127
|
+
const byteLen = Buffer.byteLength(rawStr, "utf-8");
|
|
128
|
+
let sanitizedLogs = rawStr;
|
|
129
|
+
if (byteLen > MAX_LOG_SIZE_BYTES) {
|
|
130
|
+
const head = rawStr.slice(0, 1024 * 1024);
|
|
131
|
+
const tail = rawStr.slice(-1024 * 1024);
|
|
132
|
+
sanitizedLogs = `${head}\n\n... [TOOLFLOW LOG TRUNCATED: Exceeded 10MB safety cap, original size: ${byteLen} bytes] ...\n\n${tail}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
fs.writeFileSync(rawLogFilePath, sanitizedLogs, "utf-8");
|
|
137
|
+
} catch (_) {}
|
|
138
|
+
|
|
139
|
+
// 解析产物中涉及的导入与导出符号拓扑 (跳过二进制文件)
|
|
140
|
+
const BINARY_EXTS = new Set([
|
|
141
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svg",
|
|
142
|
+
".exe", ".dll", ".so", ".dylib", ".wasm", ".zip", ".tar", ".gz",
|
|
143
|
+
".7z", ".pdf", ".db", ".sqlite", ".bin"
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const importedModules: string[] = [];
|
|
147
|
+
const exportedSymbols: string[] = [];
|
|
148
|
+
for (const art of artifacts) {
|
|
149
|
+
const ext = path.extname(art.path).toLowerCase();
|
|
150
|
+
if (!BINARY_EXTS.has(ext) && fs.existsSync(art.path)) {
|
|
151
|
+
try {
|
|
152
|
+
const content = fs.readFileSync(art.path, "utf-8").slice(0, 32768);
|
|
153
|
+
// 匹配 ES Module 和 CommonJS 导入
|
|
154
|
+
const importMatches = content.matchAll(/(?:import\s+(?:\{[^}]+\}|\w+|\*\s+as\s+\w+)\s+from\s+["']([^"']+)["']|require\(["']([^"']+)["']\))/g);
|
|
155
|
+
for (const m of importMatches) {
|
|
156
|
+
const mod = m[1] || m[2];
|
|
157
|
+
if (mod && !importedModules.includes(mod)) importedModules.push(mod);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 匹配各类 export 声明 (function / async function / class / interface / type / enum / const / let)
|
|
161
|
+
const exportMatches = content.matchAll(/export\s+(?:async\s+)?(?:const|let|var|class|interface|type|enum|function)\s+(\w+)/g);
|
|
162
|
+
for (const m of exportMatches) {
|
|
163
|
+
if (m[1] && !exportedSymbols.includes(m[1])) exportedSymbols.push(m[1]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 匹配 export { a, b, c }
|
|
167
|
+
const namedExportBlock = content.matchAll(/export\s*\{\s*([^}]+)\s*\}/g);
|
|
168
|
+
for (const block of namedExportBlock) {
|
|
169
|
+
if (block[1]) {
|
|
170
|
+
const names = block[1].split(",").map(n => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
|
|
171
|
+
for (const n of names) {
|
|
172
|
+
if (!exportedSymbols.includes(n)) exportedSymbols.push(n);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (_) {}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
stageId,
|
|
182
|
+
stageTitle,
|
|
183
|
+
verifiedArtifacts: artifacts,
|
|
184
|
+
contractSummary,
|
|
185
|
+
rawLogFilePath,
|
|
186
|
+
tokenSavingsRatio: ">95%",
|
|
187
|
+
timestamp: Date.now(),
|
|
188
|
+
topologyHints: (importedModules.length > 0 || exportedSymbols.length > 0)
|
|
189
|
+
? { importedModules, exportedSymbols }
|
|
190
|
+
: undefined
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 格式化为注入下一个 Stage 的紧凑型上下文提示词 (<150 Token)
|
|
196
|
+
*/
|
|
197
|
+
public formatHandoffPrompt(handoff: DehydratedStageHandoff): string {
|
|
198
|
+
const artifactList = handoff.verifiedArtifacts.length > 0
|
|
199
|
+
? handoff.verifiedArtifacts
|
|
200
|
+
.map(a => ` - 产物: \`${a.path}\` (${a.sizeBytes} bytes, SHA: \`${(a.sha256 || "unknown").slice(0, 12)}\`)`)
|
|
201
|
+
.join("\n")
|
|
202
|
+
: " - 无物理文件";
|
|
203
|
+
|
|
204
|
+
const topologyInfo = handoff.topologyHints
|
|
205
|
+
? `\n[代码拓扑引用] 依赖模块: [${handoff.topologyHints.importedModules?.join(", ") || "无"}], 暴露符号: [${handoff.topologyHints.exportedSymbols?.join(", ") || "无"}]`
|
|
206
|
+
: "";
|
|
207
|
+
|
|
208
|
+
return [
|
|
209
|
+
`[上一阶段交付凭证] 阶段: ${handoff.stageTitle} (已物理放行)`,
|
|
210
|
+
`[核心接口与状态] ${handoff.contractSummary}${topologyInfo}`,
|
|
211
|
+
`[已验证物理产物]\n${artifactList}`,
|
|
212
|
+
`[脱水日志归档] 详细调试日志已落盘至: \`${handoff.rawLogFilePath}\` (必要时可通过 read 按需索引)`
|
|
213
|
+
].join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* ⚡ 终端 ANSI 控制字符清洗与进度条折叠辅助函数
|
|
218
|
+
*/
|
|
219
|
+
private sanitizeTerminalOutput(rawText: string): string {
|
|
220
|
+
if (!rawText) return "";
|
|
221
|
+
// 1. 去除 ANSI 转义控制字符 (色彩代码、光标跳跃等 \x1b[...m)
|
|
222
|
+
let text = rawText.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\([a-zA-Z]/g, "");
|
|
223
|
+
|
|
224
|
+
// 2. 压缩 \r 产生的刷屏进度条(例如 npm install / wget / docker 下载进度)
|
|
225
|
+
// 仅保留由 \r 覆盖的最后一行
|
|
226
|
+
if (text.includes("\r")) {
|
|
227
|
+
const parts = text.split("\n").map(line => {
|
|
228
|
+
if (!line.includes("\r")) return line;
|
|
229
|
+
const sub = line.split("\r").filter(s => s.trim().length > 0);
|
|
230
|
+
return sub.length > 0 ? sub[sub.length - 1] : "";
|
|
231
|
+
});
|
|
232
|
+
text = parts.join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return text;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 检查文本是否处于交互式等待中(等待用户输入,此时绝对不可脱水截断)
|
|
240
|
+
*/
|
|
241
|
+
private isInteractiveWait(text: string): boolean {
|
|
242
|
+
if (!text) return false;
|
|
243
|
+
const trimmedTail = text.slice(-300).trim();
|
|
244
|
+
const interactivePatterns = [
|
|
245
|
+
/\[y\/n\]/i,
|
|
246
|
+
/\(y\/n\)/i,
|
|
247
|
+
/\[yes\/no\]/i,
|
|
248
|
+
/are you sure/i,
|
|
249
|
+
/press any key/i,
|
|
250
|
+
/password:/i,
|
|
251
|
+
/enter pass phrase/i,
|
|
252
|
+
/\? /
|
|
253
|
+
];
|
|
254
|
+
return interactivePatterns.some(p => p.test(trimmedTail));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 提取输出中的关键报错行(保证哪怕脱水中段,编译/运行核心错误也不会被吞噬)
|
|
259
|
+
*/
|
|
260
|
+
private extractKeyErrorLines(lines: string[]): string[] {
|
|
261
|
+
const errorLines: string[] = [];
|
|
262
|
+
const errorPattern = /(error[:\s]|fatal[:\s]|failed[:\s]|exception[:\s]|panic[:\s]|traceback)/i;
|
|
263
|
+
for (let i = 0; i < lines.length; i++) {
|
|
264
|
+
if (errorPattern.test(lines[i])) {
|
|
265
|
+
// 抓取报错行及其上下文前后各 1 行
|
|
266
|
+
const start = Math.max(0, i - 1);
|
|
267
|
+
const end = Math.min(lines.length, i + 2);
|
|
268
|
+
for (let j = start; j < end; j++) {
|
|
269
|
+
if (!errorLines.includes(lines[j])) {
|
|
270
|
+
errorLines.push(lines[j]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (errorLines.length >= 6) break; // 最多优先抓取 6 行关键报错
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return errorLines;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* ⚡ 实时单次工具输出脱水 (Tool Result Dehydration)
|
|
281
|
+
* 当 bash, powershell, fetch_content 或重型 MCP 输出过长时,自动将完整原始输出落盘归档,
|
|
282
|
+
* 仅向下游返回摘要与指纹,彻底阻断数万 Token 垃圾日志污染会话上下文。
|
|
283
|
+
*/
|
|
284
|
+
public dehydrateToolOutput(toolName: string, rawText: string): { dehydrated: boolean; text: string; archivePath?: string } {
|
|
285
|
+
if (!rawText || typeof rawText !== "string") return { dehydrated: false, text: rawText };
|
|
286
|
+
|
|
287
|
+
// 1. 终端 ANSI 控制字符净化与进度条清洗
|
|
288
|
+
const sanitized = this.sanitizeTerminalOutput(rawText);
|
|
289
|
+
|
|
290
|
+
// 2. 交互式等待绝对豁免(如 [y/N], Password: 等),坚决不截断,防止打废终端交互
|
|
291
|
+
if (toolName === "bash" || toolName === "powershell") {
|
|
292
|
+
if (this.isInteractiveWait(sanitized)) {
|
|
293
|
+
return { dehydrated: false, text: sanitized };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const lines = sanitized.split("\n");
|
|
298
|
+
|
|
299
|
+
// ⚡ 针对不同类型的工具设置针对性的智能阈值与截断窗口
|
|
300
|
+
let thresholdLines = 40;
|
|
301
|
+
let thresholdBytes = 2500;
|
|
302
|
+
let headCount = 15;
|
|
303
|
+
let tailCount = 10;
|
|
304
|
+
|
|
305
|
+
if (toolName === "grep" || toolName === "find") {
|
|
306
|
+
// 搜索工具:结果超 15 行或 1000 字符就脱水,保留前 8 条和后 4 条匹配
|
|
307
|
+
thresholdLines = 15;
|
|
308
|
+
thresholdBytes = 1000;
|
|
309
|
+
headCount = 8;
|
|
310
|
+
tailCount = 4;
|
|
311
|
+
} else if (toolName === "read") {
|
|
312
|
+
// 读取工具:超 30 行或 2000 字符脱水,保留前 15 行和后 10 行
|
|
313
|
+
thresholdLines = 30;
|
|
314
|
+
thresholdBytes = 2000;
|
|
315
|
+
headCount = 15;
|
|
316
|
+
tailCount = 10;
|
|
317
|
+
} else if (toolName === "bash" || toolName === "powershell") {
|
|
318
|
+
// 终端工具:超 35 行或 2200 字符脱水,保留前 12 行和后 12 行错误/尾部
|
|
319
|
+
thresholdLines = 35;
|
|
320
|
+
thresholdBytes = 2200;
|
|
321
|
+
headCount = 12;
|
|
322
|
+
tailCount = 12;
|
|
323
|
+
} else if (toolName === "fetch_content" || toolName === "web_search" || toolName === "get_search_content") {
|
|
324
|
+
// ⚡ 网络与爬虫工具:网页与搜索内容通常包含大量无用 HTML/脚本/长文,极易撑爆上下文
|
|
325
|
+
thresholdLines = 25;
|
|
326
|
+
thresholdBytes = 1500;
|
|
327
|
+
headCount = 10;
|
|
328
|
+
tailCount = 8;
|
|
329
|
+
} else if (toolName.startsWith("computer_use_") || toolName.startsWith("cua_")) {
|
|
330
|
+
// ⚡ 视觉/桌面自动化工具:UIA 树、Accessibility Tree 和状态诊断返回海量树状节点
|
|
331
|
+
thresholdLines = 30;
|
|
332
|
+
thresholdBytes = 2000;
|
|
333
|
+
headCount = 12;
|
|
334
|
+
tailCount = 8;
|
|
335
|
+
} else if (toolName === "mcp" || toolName === "mcpScript") {
|
|
336
|
+
// ⚡ 重型外部 MCP 工具输出:截断大型 JSON 或对象转储
|
|
337
|
+
thresholdLines = 30;
|
|
338
|
+
thresholdBytes = 2000;
|
|
339
|
+
headCount = 10;
|
|
340
|
+
tailCount = 10;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (lines.length <= thresholdLines && sanitized.length < thresholdBytes) {
|
|
344
|
+
return { dehydrated: false, text: sanitized };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const safeTool = toolName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
348
|
+
const filename = `tool_${safeTool}_${Date.now()}.log`;
|
|
349
|
+
const fullPath = path.join(this.runsDir, filename);
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
fs.writeFileSync(fullPath, rawText, "utf-8");
|
|
353
|
+
} catch (_) {}
|
|
354
|
+
|
|
355
|
+
const head = lines.slice(0, headCount).join("\n");
|
|
356
|
+
const tail = lines.slice(-tailCount).join("\n");
|
|
357
|
+
const omittedCount = lines.length - (headCount + tailCount);
|
|
358
|
+
const relPath = path.relative(process.cwd(), fullPath).replace(/\\/g, "/");
|
|
359
|
+
|
|
360
|
+
// 3. 智能抓取中段关键 Error 行,避免关键编译/运行报错被粗暴掐死
|
|
361
|
+
const keyErrors = this.extractKeyErrorLines(lines.slice(headCount, Math.max(headCount, lines.length - tailCount)));
|
|
362
|
+
const errorSection = keyErrors.length > 0
|
|
363
|
+
? ["", " [⚡ 核心报错摘要提取]:", ...keyErrors.map(e => " > " + e), ""]
|
|
364
|
+
: [];
|
|
365
|
+
|
|
366
|
+
const summaryText = [
|
|
367
|
+
head,
|
|
368
|
+
...errorSection,
|
|
369
|
+
"",
|
|
370
|
+
`... [⚡ ToolFlow Token Optimizer: Dehydrated ${omittedCount} lines (~${Math.round(sanitized.length / 4)} tokens) to ${relPath}] ...`,
|
|
371
|
+
"",
|
|
372
|
+
tail
|
|
373
|
+
].join("\n");
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
dehydrated: true,
|
|
377
|
+
text: summaryText,
|
|
378
|
+
archivePath: fullPath
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* ⚡ 文件读取去重与短期缓存管理器 (ReadCacheManager)
|
|
385
|
+
* 具备 6 级穿透门禁与极速代码骨架提取 (Symbol Outline),彻底避免 Agent 致盲与死锁
|
|
386
|
+
*/
|
|
387
|
+
export class ReadCacheManager {
|
|
388
|
+
private cache: Map<string, { mtimeMs: number; hash: string; lastTurnIndex: number; linesCount: number }> = new Map();
|
|
389
|
+
private lastCommandTimestamp: number = 0;
|
|
390
|
+
private editFailures: Map<string, { failedTurn: number; timestamp: number }> = new Map();
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 记录外部命令(如 bash)执行,作废任何潜在的命令副效应
|
|
394
|
+
*/
|
|
395
|
+
public recordCommandExecution(): void {
|
|
396
|
+
this.lastCommandTimestamp = Date.now();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 记录 edit/write 失败,用于下一次强制穿透供给完整源码
|
|
401
|
+
*/
|
|
402
|
+
public recordEditFailure(filePath: string, currentTurn: number): void {
|
|
403
|
+
if (!filePath) return;
|
|
404
|
+
const normPath = path.resolve(filePath).replace(/\\/g, "/");
|
|
405
|
+
this.editFailures.set(normPath, { failedTurn: currentTurn, timestamp: Date.now() });
|
|
406
|
+
this.invalidate(normPath);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 检查近期是否发生过编辑失败
|
|
411
|
+
*/
|
|
412
|
+
public hasRecentEditFailure(filePath: string, currentTurn: number): boolean {
|
|
413
|
+
if (!filePath) return false;
|
|
414
|
+
const normPath = path.resolve(filePath).replace(/\\/g, "/");
|
|
415
|
+
const record = this.editFailures.get(normPath);
|
|
416
|
+
if (!record) return false;
|
|
417
|
+
// 3 轮内或 60 秒内只要发生过编辑失败,强制允许穿透读取供大模型纠错
|
|
418
|
+
if (currentTurn - record.failedTurn <= 3 || Date.now() - record.timestamp < 60000) {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
this.editFailures.delete(normPath);
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 检查文件读取是否可以命中去重缓存
|
|
427
|
+
* @param filePath 读取的目标文件路径
|
|
428
|
+
* @param fileContent 文件原始内容
|
|
429
|
+
* @param currentTurn 当前会话轮次
|
|
430
|
+
* @param input 工具入参(包含 offset / limit)
|
|
431
|
+
*/
|
|
432
|
+
public checkOrUpdate(
|
|
433
|
+
filePath: string,
|
|
434
|
+
fileContent: string,
|
|
435
|
+
currentTurn: number,
|
|
436
|
+
input?: ReadToolInput
|
|
437
|
+
): CacheCheckResult {
|
|
438
|
+
// 门禁 G1: 显式指定 offset/limit 的局部切片读取,绝对不拦截(提供精确代码上下文供 edit 匹配)
|
|
439
|
+
if (input?.offset !== undefined || input?.limit !== undefined) {
|
|
440
|
+
return { isDuplicate: false, bypassReason: "targeted_window" };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!filePath || !fileContent) {
|
|
444
|
+
return { isDuplicate: false };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const normPath = path.resolve(filePath).replace(/\\/g, "/");
|
|
448
|
+
|
|
449
|
+
// 门禁 G2: 最近发生过 edit/write 失败,强制穿透供给完整文本供大模型纠错
|
|
450
|
+
if (this.hasRecentEditFailure(normPath, currentTurn)) {
|
|
451
|
+
return { isDuplicate: false, bypassReason: "recent_edit_failure" };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const lines = fileContent.split("\n");
|
|
455
|
+
|
|
456
|
+
// 门禁 G3: 极小文件穿透(小于等于 5 行或字符数少于 200 的文件绝对穿透,低成本无拖拽)
|
|
457
|
+
if (lines.length <= 5 || fileContent.length < 200) {
|
|
458
|
+
return { isDuplicate: false, bypassReason: "small_file" };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 门禁 G4: 外部命令执行后的副效应保护(15 秒内穿透)
|
|
462
|
+
if (this.lastCommandTimestamp > 0 && Date.now() - this.lastCommandTimestamp < 15000) {
|
|
463
|
+
this.invalidate(normPath);
|
|
464
|
+
return { isDuplicate: false, bypassReason: "command_dirty" };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let currentMtime = 0;
|
|
468
|
+
try {
|
|
469
|
+
if (fs.existsSync(filePath)) {
|
|
470
|
+
currentMtime = fs.statSync(filePath).mtimeMs;
|
|
471
|
+
}
|
|
472
|
+
} catch (_) {}
|
|
473
|
+
|
|
474
|
+
// 1. 全量内容 SHA256 哈希计算(Node 22 原生极速哈希,耗时 <0.1ms)
|
|
475
|
+
const hash = crypto.createHash("sha256").update(fileContent).digest("hex");
|
|
476
|
+
const cached = this.cache.get(normPath);
|
|
477
|
+
|
|
478
|
+
if (cached) {
|
|
479
|
+
// 2. 真实物理 mtimeMs 变动检测:若磁盘修改时间发生变动(哪怕 1ms),一票否决
|
|
480
|
+
const mtimeMatches = currentMtime > 0 ? (cached.mtimeMs === 0 || currentMtime === cached.mtimeMs) : true;
|
|
481
|
+
const isUnchanged = mtimeMatches && cached.hash === hash;
|
|
482
|
+
const turnDistance = currentTurn - cached.lastTurnIndex;
|
|
483
|
+
|
|
484
|
+
// 仅在确认文件在磁盘和哈希上毫无任何变动,且处于近邻 15 轮次内生效
|
|
485
|
+
if (isUnchanged && turnDistance >= 1 && turnDistance <= 15) {
|
|
486
|
+
// 更新最后访问轮次
|
|
487
|
+
const lastTurn = cached.lastTurnIndex;
|
|
488
|
+
cached.lastTurnIndex = currentTurn;
|
|
489
|
+
const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, "/");
|
|
490
|
+
const approxSavedTokens = Math.max(20, Math.round(fileContent.length / 4));
|
|
491
|
+
|
|
492
|
+
const notice = [
|
|
493
|
+
`=== [⚡ ToolFlow Read Cache: "${relativePath}" (${lines.length} 行, 与第 ${lastTurn} 轮内容一致, 节约 ~${approxSavedTokens} Tokens) ] ===`,
|
|
494
|
+
`=== [⚡ 提示: 内容完全一致且未变更。若需查看或精准修改实现,请调用 read 传入 offset=<行号> limit=<行数> 按需读取] ===`
|
|
495
|
+
].join("\n");
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
isDuplicate: true,
|
|
499
|
+
savedLines: lines.length,
|
|
500
|
+
savedTokens: approxSavedTokens,
|
|
501
|
+
notice
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 记录最新真实物理指纹与内容哈希
|
|
507
|
+
this.cache.set(normPath, {
|
|
508
|
+
mtimeMs: currentMtime,
|
|
509
|
+
hash,
|
|
510
|
+
lastTurnIndex: currentTurn,
|
|
511
|
+
linesCount: lines.length
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
return { isDuplicate: false, bypassReason: "not_cached" };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* 清除特定路径缓存(当发现该文件被 write / edit 时调用,保证缓存不发霉)
|
|
519
|
+
*/
|
|
520
|
+
public invalidate(filePath: string): void {
|
|
521
|
+
const normPath = path.resolve(filePath).replace(/\\/g, "/");
|
|
522
|
+
this.cache.delete(normPath);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 重置所有读缓存
|
|
527
|
+
*/
|
|
528
|
+
public clear(): void {
|
|
529
|
+
this.cache.clear();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|