@zhushanwen/pi-context-engineering 0.1.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.
@@ -0,0 +1,798 @@
1
+ // compressor.ts — L0/L1/L2 compression engine with tool pairing validation
2
+
3
+ import type { L0Config, L1Config, L2Config, McConfig, BudgetConfig, ContextEngineeringConfig } from "./config.ts";
4
+ import type { RecallStore } from "./recall-store.ts";
5
+ import type { FrozenFreshState } from "./frozen-fresh.ts";
6
+
7
+ // chars→tokens 估算因子和 fallback 上下文窗口大小
8
+ const CHARS_PER_TOKEN = 4;
9
+ const DEFAULT_CONTEXT_WINDOW = 200_000;
10
+
11
+ // ── Message types (structural subset of pi-ai + Pi coding agent) ──
12
+
13
+ export interface TextContent {
14
+ type: "text";
15
+ text: string;
16
+ }
17
+
18
+ export interface ThinkingContent {
19
+ type: "thinking";
20
+ thinking: string;
21
+ thinkingSignature?: string;
22
+ redacted?: boolean;
23
+ }
24
+
25
+ export interface ImageContent {
26
+ type: "image";
27
+ data: string;
28
+ mimeType: string;
29
+ }
30
+
31
+ export interface ToolCall {
32
+ type: "toolCall";
33
+ id: string;
34
+ name: string;
35
+ arguments: Record<string, unknown>;
36
+ }
37
+
38
+ export interface UserMessage {
39
+ role: "user";
40
+ content: string | (TextContent | ImageContent)[];
41
+ timestamp: number;
42
+ }
43
+
44
+ export interface AssistantMessage {
45
+ role: "assistant";
46
+ content: (TextContent | ThinkingContent | ToolCall)[];
47
+ api: string;
48
+ provider: string;
49
+ model: string;
50
+ usage: {
51
+ input: number;
52
+ output: number;
53
+ cacheRead: number;
54
+ cacheWrite: number;
55
+ totalTokens: number;
56
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
57
+ };
58
+ stopReason: string;
59
+ timestamp: number;
60
+ }
61
+
62
+ export interface ToolResultMessage {
63
+ role: "toolResult";
64
+ toolCallId: string;
65
+ toolName: string;
66
+ content: (TextContent | ImageContent)[];
67
+ details?: unknown;
68
+ isError: boolean;
69
+ timestamp: number;
70
+ }
71
+
72
+ export interface BashExecutionMessage {
73
+ role: "bashExecution";
74
+ command: string;
75
+ output: string;
76
+ exitCode: number | undefined;
77
+ cancelled: boolean;
78
+ truncated: boolean;
79
+ fullOutputPath?: string;
80
+ timestamp: number;
81
+ excludeFromContext?: boolean;
82
+ }
83
+
84
+ export interface CompactionSummaryMessage {
85
+ role: "compactionSummary";
86
+ summary: string;
87
+ tokensBefore: number;
88
+ timestamp: number;
89
+ }
90
+
91
+ export type AgentMessage =
92
+ | UserMessage
93
+ | AssistantMessage
94
+ | ToolResultMessage
95
+ | BashExecutionMessage
96
+ | CompactionSummaryMessage;
97
+
98
+ export interface ContextUsage {
99
+ tokens: number | null;
100
+ contextWindow: number;
101
+ percent: number | null;
102
+ }
103
+
104
+ // ── Exported data types ──
105
+
106
+ export interface TurnBoundary {
107
+ startIndex: number;
108
+ endIndex: number; // 不含
109
+ timestamp: number;
110
+ }
111
+
112
+ export interface L0Stats {
113
+ expired: number;
114
+ truncated: number;
115
+ thinkingCleared: number;
116
+ }
117
+
118
+ export interface CompressionStats {
119
+ l0Expired: number;
120
+ l0Truncated: number;
121
+ l0ThinkingCleared: number;
122
+ l1Condensed: number;
123
+ l2Triggered: boolean;
124
+ validationFailed: boolean;
125
+ mcTriggered: boolean;
126
+ mcCleared: number;
127
+ budgetPersisted: number;
128
+ }
129
+
130
+ // ── Turn boundary detection ──
131
+
132
+ // MC: 可被 Microcompact 清理的工具集
133
+ export const COMPACTABLE_TOOLS = new Set([
134
+ "read", "bash", "bash_background", "grep", "glob",
135
+ "web_search", "web_fetch", "edit", "write",
136
+ ]);
137
+
138
+ export interface McStats {
139
+ triggered: boolean;
140
+ cleared: number;
141
+ }
142
+
143
+ export interface BudgetStats {
144
+ persisted: number;
145
+ }
146
+
147
+ export function findTurnBoundaries(messages: AgentMessage[]): TurnBoundary[] {
148
+ if (messages.length === 0) return [];
149
+
150
+ const boundaries: TurnBoundary[] = [];
151
+ let turnStart = 0;
152
+
153
+ for (let i = 1; i < messages.length; i++) {
154
+ const role = messages[i].role;
155
+ if (role === "user" || role === "bashExecution") {
156
+ boundaries.push({
157
+ startIndex: turnStart,
158
+ endIndex: i,
159
+ timestamp: messages[turnStart].timestamp,
160
+ });
161
+ turnStart = i;
162
+ }
163
+ }
164
+
165
+ // 最后一个 turn
166
+ boundaries.push({
167
+ startIndex: turnStart,
168
+ endIndex: messages.length,
169
+ timestamp: messages[turnStart].timestamp,
170
+ });
171
+
172
+ return boundaries;
173
+ }
174
+
175
+ export function isInProtectedTurn(
176
+ msgIndex: number,
177
+ boundaries: TurnBoundary[],
178
+ protectCount: number,
179
+ ): boolean {
180
+ if (protectCount <= 0 || boundaries.length === 0) return false;
181
+ const protectedStart = Math.max(0, boundaries.length - protectCount);
182
+ for (let i = protectedStart; i < boundaries.length; i++) {
183
+ if (msgIndex >= boundaries[i].startIndex && msgIndex < boundaries[i].endIndex) {
184
+ return true;
185
+ }
186
+ }
187
+ return false;
188
+ }
189
+
190
+ // ── Message field accessors ──
191
+
192
+ export function getMessageTimestamp(msg: AgentMessage): number {
193
+ return msg.timestamp;
194
+ }
195
+
196
+ export function getToolResultText(msg: ToolResultMessage): string {
197
+ return msg.content
198
+ .filter((c): c is TextContent => c.type === "text")
199
+ .map((c) => c.text)
200
+ .join("");
201
+ }
202
+
203
+ // ── L0 replacement formatters ──
204
+
205
+ export function expireToolResult(_originalText: string, id: string): string {
206
+ return `[Tool result expired. ID: ${id}. Use recall_context(${id}) to retrieve the original content.]`;
207
+ }
208
+
209
+ export function truncateBashOutput(
210
+ output: string,
211
+ maxChars: number,
212
+ id: string,
213
+ ): string {
214
+ if (output.length <= maxChars) return output;
215
+ // Tail retention: bash output is tail-heavy (errors, final results).
216
+ // Mirrors Pi's truncateTail in bash-executor.ts.
217
+ const tailChars = maxChars;
218
+ return (
219
+ `... [truncated. ID: ${id}. Use recall_context(${id}) to retrieve full output. Total: ${output.length} chars]\n\n` +
220
+ output.slice(-tailChars)
221
+ );
222
+ }
223
+
224
+ export function expireThinking(): string {
225
+ return "[thinking expired]";
226
+ }
227
+
228
+ // ── L1 condensation ──
229
+
230
+ const IMPORT_EXPORT_RE = /^(import|export)\s/;
231
+ const DEFINITION_RE = /(function|class|interface|type|const|let|var)\s+\w+/;
232
+
233
+ const FALLBACK_KEEP_RATIO = 0.4;
234
+ const MAX_CONDENSE_RATIO = 0.4;
235
+ const MS_PER_MINUTE = 60_000;
236
+
237
+ function fallbackTruncate(content: string): string {
238
+ // Head retention: for non-code content (JSON, YAML, logs),
239
+ // the beginning usually contains structure/headers.
240
+ // Mirrors Pi's truncateHead for read tool output.
241
+ const budget = Math.floor(content.length * FALLBACK_KEEP_RATIO);
242
+ return (
243
+ content.slice(0, budget) +
244
+ "\n[... truncated for space]"
245
+ );
246
+ }
247
+
248
+ export function condenseToolResult(
249
+ content: string,
250
+ keepHeadLines: number,
251
+ keepTailLines: number,
252
+ ): string {
253
+ const lines = content.split("\n");
254
+
255
+ // 行数不足以分 head/middle/tail → 直接 fallback
256
+ if (lines.length <= keepHeadLines + keepTailLines) {
257
+ return fallbackTruncate(content);
258
+ }
259
+
260
+ const head = lines.slice(0, keepHeadLines);
261
+ const tail = lines.slice(-keepTailLines);
262
+ const middle = lines.slice(keepHeadLines, lines.length - keepTailLines);
263
+
264
+ const keptMiddle: string[] = [];
265
+ let omitCount = 0;
266
+
267
+ for (const line of middle) {
268
+ if (IMPORT_EXPORT_RE.test(line) || DEFINITION_RE.test(line)) {
269
+ if (omitCount > 0) {
270
+ keptMiddle.push(`[... ${omitCount} lines omitted]`);
271
+ omitCount = 0;
272
+ }
273
+ keptMiddle.push(line);
274
+ } else {
275
+ omitCount++;
276
+ }
277
+ }
278
+ if (omitCount > 0) {
279
+ keptMiddle.push(`[... ${omitCount} lines omitted]`);
280
+ }
281
+
282
+ const result = [...head, ...keptMiddle, ...tail].join("\n");
283
+
284
+ // 压缩不够 → fallback 截断
285
+ if (result.length > content.length * MAX_CONDENSE_RATIO) {
286
+ return fallbackTruncate(content);
287
+ }
288
+
289
+ return result;
290
+ }
291
+
292
+ // ── Tool pairing validation ──
293
+
294
+ export function validateToolPairing(messages: AgentMessage[]): boolean {
295
+ const pendingToolCalls = new Set<string>();
296
+
297
+ for (const msg of messages) {
298
+ if (msg.role === "assistant") {
299
+ for (const block of msg.content) {
300
+ if (block.type === "toolCall") {
301
+ pendingToolCalls.add(block.id);
302
+ }
303
+ }
304
+ } else if (msg.role === "toolResult") {
305
+ if (!pendingToolCalls.has(msg.toolCallId)) {
306
+ return false;
307
+ }
308
+ pendingToolCalls.delete(msg.toolCallId);
309
+ }
310
+ }
311
+
312
+ return pendingToolCalls.size === 0;
313
+ }
314
+
315
+ // ── Compact boundary detection ──
316
+
317
+ export function findCompactBoundary(messages: AgentMessage[]): number | null {
318
+ let lastIdx: number | null = null;
319
+ for (let i = 0; i < messages.length; i++) {
320
+ if (messages[i].role === "compactionSummary") {
321
+ lastIdx = i;
322
+ }
323
+ }
324
+ return lastIdx;
325
+ }
326
+
327
+ // ── Microcompact: time-based cleanup ──
328
+
329
+ export function processMicrocompact(
330
+ messages: AgentMessage[],
331
+ config: McConfig,
332
+ store: RecallStore,
333
+ now: number,
334
+ compactBoundaryIdx: number | null,
335
+ ): { messages: AgentMessage[]; stats: McStats } {
336
+ if (!config.enabled) {
337
+ return { messages, stats: { triggered: false, cleared: 0 } };
338
+ }
339
+
340
+ // 找最后一个 assistant 消息的 timestamp
341
+ let lastAssistantTs = 0;
342
+ for (const msg of messages) {
343
+ if (msg.role === "assistant") {
344
+ lastAssistantTs = Math.max(lastAssistantTs, msg.timestamp);
345
+ }
346
+ }
347
+
348
+ // 间隔不够,不触发
349
+ if (lastAssistantTs === 0 || now - lastAssistantTs <= config.gapThresholdMinutes * MS_PER_MINUTE) {
350
+ return { messages, stats: { triggered: false, cleared: 0 } };
351
+ }
352
+
353
+ // 收集所有 compactable toolResult 索引
354
+ const candidateIdxs: number[] = [];
355
+ for (let i = 0; i < messages.length; i++) {
356
+ const msg = messages[i];
357
+ if (msg.role !== "toolResult") continue;
358
+ if (!COMPACTABLE_TOOLS.has(msg.toolName)) continue;
359
+ // 已过期的不再处理
360
+ const text = getToolResultText(msg);
361
+ if (text.startsWith("[Tool result expired")) continue;
362
+ // 只处理边界之后的
363
+ if (compactBoundaryIdx != null && i <= compactBoundaryIdx) continue;
364
+ candidateIdxs.push(i);
365
+ }
366
+
367
+ if (candidateIdxs.length <= config.keepRecent) {
368
+ return { messages, stats: { triggered: true, cleared: 0 } };
369
+ }
370
+
371
+ // 保留最近 keepRecent 个,清理前面的
372
+ const keepFrom = candidateIdxs.length - config.keepRecent;
373
+ const toClear = candidateIdxs.slice(0, keepFrom);
374
+
375
+ const result = [...messages];
376
+ for (const idx of toClear) {
377
+ const msg = result[idx] as ToolResultMessage;
378
+ const originalText = getToolResultText(msg);
379
+ const id = store.store(originalText, "mc-cleared");
380
+ result[idx] = {
381
+ ...msg,
382
+ content: [{ type: "text" as const, text: `[Old tool result expired. ID: ${id}. Use recall_context(${id}) to retrieve the original content.]` }],
383
+ };
384
+ }
385
+
386
+ return { messages: result, stats: { triggered: true, cleared: toClear.length } };
387
+ }
388
+
389
+ // ── Budget: tool result budget management ──
390
+
391
+ export function processBudget(
392
+ messages: AgentMessage[],
393
+ config: BudgetConfig,
394
+ store: RecallStore,
395
+ ffState: FrozenFreshState,
396
+ compactBoundaryIdx: number | null,
397
+ ): { messages: AgentMessage[]; stats: BudgetStats } {
398
+ if (!config.enabled) {
399
+ return { messages, stats: { persisted: 0 } };
400
+ }
401
+
402
+ const result = [...messages];
403
+ let persisted = 0;
404
+
405
+ // 按 user 消息分段
406
+ let groupStart = 0;
407
+ for (let i = 0; i <= messages.length; i++) {
408
+ const isGroupEnd = i === messages.length || messages[i].role === "user";
409
+ if (!isGroupEnd) continue;
410
+
411
+ // 处理 [groupStart, i) 范围内的 toolResult
412
+ const freshEntries: { idx: number; toolCallId: string; chars: number }[] = [];
413
+ let totalFreshChars = 0;
414
+
415
+ for (let j = groupStart; j < i; j++) {
416
+ const msg = messages[j];
417
+ if (msg.role !== "toolResult") continue;
418
+ if (compactBoundaryIdx != null && j < compactBoundaryIdx) continue;
419
+
420
+ // frozen 的用 replacement 替换
421
+ if (ffState.isFrozen(msg.toolCallId)) {
422
+ const replacement = ffState.getReplacement(msg.toolCallId)!;
423
+ result[j] = {
424
+ ...msg,
425
+ content: [{ type: "text" as const, text: replacement }],
426
+ } as ToolResultMessage;
427
+ continue;
428
+ }
429
+
430
+ const text = getToolResultText(msg);
431
+ freshEntries.push({ idx: j, toolCallId: msg.toolCallId, chars: text.length });
432
+ totalFreshChars += text.length;
433
+ }
434
+
435
+ // 超过预算 → 循环持久化最大 fresh toolResult 直到在预算内
436
+ while (totalFreshChars > config.maxToolResultCharsPerMessage && freshEntries.length > 0) {
437
+ let maxEntry = freshEntries[0];
438
+ for (const entry of freshEntries) {
439
+ if (entry.chars > maxEntry.chars) maxEntry = entry;
440
+ }
441
+
442
+ const msg = messages[maxEntry.idx] as ToolResultMessage;
443
+ const text = getToolResultText(msg);
444
+ const id = store.store(text, "budget-persisted");
445
+ const replacement =
446
+ `[Persisted output (ID: ${id}). Preview: ${text.slice(0, config.previewSize)}... Total: ${text.length} chars]`;
447
+ ffState.markFrozen(maxEntry.toolCallId, replacement);
448
+ result[maxEntry.idx] = {
449
+ ...msg,
450
+ content: [{ type: "text" as const, text: replacement }],
451
+ } as ToolResultMessage;
452
+ totalFreshChars -= maxEntry.chars;
453
+ freshEntries.splice(freshEntries.indexOf(maxEntry), 1);
454
+ persisted++;
455
+ // Guard: if replacement would not reduce total size, stop to avoid over-persisting small results
456
+ if (replacement.length >= maxEntry.chars) break;
457
+ totalFreshChars += replacement.length;
458
+ }
459
+
460
+ groupStart = i;
461
+ }
462
+
463
+ return { messages: result, stats: { persisted } };
464
+ }
465
+
466
+ // ── Private helpers ──
467
+
468
+ function estimateMessageChars(msg: AgentMessage): number {
469
+ switch (msg.role) {
470
+ case "user": {
471
+ if (typeof msg.content === "string") return msg.content.length;
472
+ return msg.content
473
+ .filter((c): c is TextContent => c.type === "text")
474
+ .reduce((s, c) => s + c.text.length, 0);
475
+ }
476
+ case "assistant": {
477
+ return msg.content.reduce((s, c) => {
478
+ if (c.type === "text") return s + c.text.length;
479
+ if (c.type === "thinking") return s + c.thinking.length;
480
+ if (c.type === "toolCall") {
481
+ return s + c.name.length + JSON.stringify(c.arguments).length;
482
+ }
483
+ return s;
484
+ }, 0);
485
+ }
486
+ case "toolResult": {
487
+ return msg.content.reduce((s, c) => {
488
+ if (c.type === "text") return s + c.text.length;
489
+ if (c.type === "image") return s + c.data.length;
490
+ return s;
491
+ }, 0);
492
+ }
493
+ case "bashExecution": {
494
+ return msg.output.length;
495
+ }
496
+ default:
497
+ return 0;
498
+ }
499
+ }
500
+
501
+ function isToolResultExpired(msg: ToolResultMessage): boolean {
502
+ return getToolResultText(msg).includes("[Tool result expired");
503
+ }
504
+
505
+ function isAlreadyProcessed(msg: ToolResultMessage): boolean {
506
+ const text = getToolResultText(msg);
507
+ return text.startsWith("[Tool result expired") ||
508
+ text.startsWith("[Old tool result") ||
509
+ text.startsWith("[Condensed") ||
510
+ text.startsWith("[Persisted output");
511
+ }
512
+
513
+ // ── L0: 基础过期/截断/思考清理 ──
514
+
515
+ export function processL0(
516
+ messages: AgentMessage[],
517
+ config: L0Config,
518
+ store: RecallStore,
519
+ now: number,
520
+ turnBoundaries: TurnBoundary[],
521
+ ): { messages: AgentMessage[]; stats: L0Stats } {
522
+ const stats: L0Stats = { expired: 0, truncated: 0, thinkingCleared: 0 };
523
+ const result: AgentMessage[] = [];
524
+
525
+ // 预计算:每个位置之后是否有 user 消息
526
+ const hasUserAfter = new Array<boolean>(messages.length).fill(false);
527
+ let seenUser = false;
528
+ for (let i = messages.length - 1; i >= 0; i--) {
529
+ hasUserAfter[i] = seenUser;
530
+ if (messages[i].role === "user") seenUser = true;
531
+ }
532
+
533
+ // keepRecent: 收集所有 compactable toolResult 索引,保留最近 N 个
534
+ const keepRecentProtected = new Set<number>();
535
+ if (config.keepRecent > 0) {
536
+ const compactableIdxs: number[] = [];
537
+ for (let i = 0; i < messages.length; i++) {
538
+ if (messages[i].role === "toolResult") {
539
+ compactableIdxs.push(i);
540
+ }
541
+ }
542
+ const keepFrom = Math.max(0, compactableIdxs.length - config.keepRecent);
543
+ for (let i = keepFrom; i < compactableIdxs.length; i++) {
544
+ keepRecentProtected.add(compactableIdxs[i]);
545
+ }
546
+ }
547
+
548
+ for (let i = 0; i < messages.length; i++) {
549
+ const msg = messages[i];
550
+
551
+ if (msg.role === "toolResult") {
552
+ const age = now - msg.timestamp;
553
+ const expired = age > config.expireMinutes * MS_PER_MINUTE;
554
+ const turnProtected = isInProtectedTurn(i, turnBoundaries, config.protectRecentTurns);
555
+ const recentProtected = keepRecentProtected.has(i);
556
+
557
+ if (expired && !turnProtected && !recentProtected && !isAlreadyProcessed(msg)) {
558
+ const originalText = getToolResultText(msg);
559
+ const id = store.store(originalText, "l0-expired");
560
+ const expiredText = expireToolResult(originalText, id);
561
+ result.push({
562
+ ...msg,
563
+ content: [{ type: "text" as const, text: expiredText }],
564
+ });
565
+ stats.expired++;
566
+ } else {
567
+ result.push(msg);
568
+ }
569
+ } else if (msg.role === "bashExecution") {
570
+ if (msg.output.length > config.bashTruncateChars) {
571
+ const id = store.store(msg.output, "l0-truncated");
572
+ const truncatedOutput = truncateBashOutput(msg.output, config.bashTruncateChars, id);
573
+ result.push({ ...msg, output: truncatedOutput });
574
+ stats.truncated++;
575
+ } else {
576
+ result.push(msg);
577
+ }
578
+ } else if (msg.role === "assistant") {
579
+ const age = now - msg.timestamp;
580
+ const thinkingExpired = age > config.thinkingExpireMinutes * MS_PER_MINUTE;
581
+
582
+ if (thinkingExpired && !hasUserAfter[i]) {
583
+ const hasThinking = msg.content.some((c) => c.type === "thinking");
584
+ if (hasThinking) {
585
+ const newContent = msg.content.map((c) =>
586
+ c.type === "thinking"
587
+ ? ({ ...c, thinking: expireThinking() } as ThinkingContent)
588
+ : c,
589
+ );
590
+ result.push({ ...msg, content: newContent });
591
+ stats.thinkingCleared++;
592
+ continue;
593
+ }
594
+ }
595
+ result.push(msg);
596
+ } else {
597
+ result.push(msg);
598
+ }
599
+ }
600
+
601
+ return { messages: result, stats };
602
+ }
603
+
604
+ // ── L1: 结构化摘要 ──
605
+
606
+ export function processL1(
607
+ messages: AgentMessage[],
608
+ config: L1Config,
609
+ store: RecallStore,
610
+ turnBoundaries: TurnBoundary[],
611
+ compactBoundaryIdx: number | null,
612
+ ): { messages: AgentMessage[]; stats: { condensed: number } } {
613
+ const stats = { condensed: 0 };
614
+ const result: AgentMessage[] = [];
615
+
616
+ for (let i = 0; i < messages.length; i++) {
617
+ const msg = messages[i];
618
+
619
+ if (msg.role === "toolResult") {
620
+ if (isToolResultExpired(msg) || isAlreadyProcessed(msg)) {
621
+ result.push(msg);
622
+ continue;
623
+ }
624
+
625
+ // Compact boundary: 边界前的消息不处理
626
+ if (compactBoundaryIdx !== null && i < compactBoundaryIdx) {
627
+ result.push(msg);
628
+ continue;
629
+ }
630
+
631
+ // Protected turn check
632
+ if (isInProtectedTurn(i, turnBoundaries, config.protectRecentTurns)) {
633
+ result.push(msg);
634
+ continue;
635
+ }
636
+
637
+ const text = getToolResultText(msg);
638
+ if (text.length > config.summaryThresholdChars) {
639
+ const summary = condenseToolResult(text, config.keepHeadLines, config.keepTailLines);
640
+ const id = store.store(text, "l1-condensed");
641
+ const condensedText = `[Condensed (ID: ${id}): ${summary}]`;
642
+ result.push({
643
+ ...msg,
644
+ content: [{ type: "text" as const, text: condensedText }],
645
+ });
646
+ stats.condensed++;
647
+ } else {
648
+ result.push(msg);
649
+ }
650
+ } else {
651
+ result.push(msg);
652
+ }
653
+ }
654
+
655
+ return { messages: result, stats };
656
+ }
657
+
658
+ // ── L2: 紧急压缩 ──
659
+
660
+ export function processL2(
661
+ messages: AgentMessage[],
662
+ config: L2Config,
663
+ store: RecallStore,
664
+ contextUsage: ContextUsage | undefined,
665
+ turnBoundaries: TurnBoundary[],
666
+ compactBoundaryIdx: number | null,
667
+ ): { messages: AgentMessage[]; stats: { triggered: boolean } } {
668
+ // 计算上下文使用率
669
+ let usagePercent: number;
670
+ if (contextUsage && contextUsage.percent != null) {
671
+ usagePercent = contextUsage.percent;
672
+ } else {
673
+ let totalChars = 0;
674
+ for (const msg of messages) {
675
+ totalChars += estimateMessageChars(msg);
676
+ }
677
+ usagePercent = (totalChars / CHARS_PER_TOKEN) / DEFAULT_CONTEXT_WINDOW;
678
+ }
679
+
680
+ if (usagePercent < config.emergencyThreshold) {
681
+ return { messages, stats: { triggered: false } };
682
+ }
683
+
684
+ // L2 emergency: force-expire toolResults outside protected turns when context usage is critical.
685
+ // `triggered` means "at least one toolResult was force-expired", not "usage threshold was crossed".
686
+ // This distinguishes "L2 activated but had nothing to expire" from "L2 didn't activate".
687
+
688
+ const result: AgentMessage[] = [];
689
+ let anyForceExpired = false;
690
+
691
+ for (let i = 0; i < messages.length; i++) {
692
+ const msg = messages[i];
693
+
694
+ // Compact boundary: 边界前的消息不处理
695
+ if (compactBoundaryIdx !== null && i < compactBoundaryIdx) {
696
+ result.push(msg);
697
+ continue;
698
+ }
699
+
700
+ if (
701
+ msg.role === "toolResult" &&
702
+ !isToolResultExpired(msg) &&
703
+ !isAlreadyProcessed(msg) &&
704
+ !isInProtectedTurn(i, turnBoundaries, config.protectRecentTurns)
705
+ ) {
706
+ const originalText = getToolResultText(msg);
707
+ const id = store.store(originalText, "l2-emergency");
708
+ const expiredText = expireToolResult(originalText, id);
709
+ result.push({
710
+ ...msg,
711
+ content: [{ type: "text" as const, text: expiredText }],
712
+ });
713
+ anyForceExpired = true;
714
+ } else {
715
+ result.push(msg);
716
+ }
717
+ }
718
+
719
+ return { messages: result, stats: { triggered: anyForceExpired } };
720
+ }
721
+
722
+ // ── 主入口 ──
723
+
724
+ export function compressContext(
725
+ messages: AgentMessage[],
726
+ config: ContextEngineeringConfig,
727
+ store: RecallStore,
728
+ contextUsage: ContextUsage | undefined,
729
+ ffState: FrozenFreshState,
730
+ ): { messages: AgentMessage[]; stats: CompressionStats } {
731
+ const zeroStats: CompressionStats = {
732
+ l0Expired: 0,
733
+ l0Truncated: 0,
734
+ l0ThinkingCleared: 0,
735
+ l1Condensed: 0,
736
+ l2Triggered: false,
737
+ validationFailed: false,
738
+ mcTriggered: false,
739
+ mcCleared: 0,
740
+ budgetPersisted: 0,
741
+ };
742
+
743
+ if (!config.enabled) {
744
+ return { messages, stats: zeroStats };
745
+ }
746
+
747
+ const now = Date.now();
748
+ const boundaries = findTurnBoundaries(messages);
749
+ const compactBoundaryIdx = findCompactBoundary(messages);
750
+
751
+ const stats: CompressionStats = { ...zeroStats };
752
+ let current = messages;
753
+
754
+ // MC (Microcompact)
755
+ if (config.mc.enabled) {
756
+ const mc = processMicrocompact(current, config.mc, store, now, compactBoundaryIdx);
757
+ current = mc.messages;
758
+ stats.mcTriggered = mc.stats.triggered;
759
+ stats.mcCleared = mc.stats.cleared;
760
+ }
761
+
762
+ // Budget (ffState 由调用方持有,保证跨 turn 冻结状态持久化)
763
+ if (config.budget.enabled) {
764
+ const budget = processBudget(current, config.budget, store, ffState, compactBoundaryIdx);
765
+ current = budget.messages;
766
+ stats.budgetPersisted = budget.stats.persisted;
767
+ }
768
+
769
+ // L0
770
+ if (config.l0.enabled) {
771
+ const l0 = processL0(current, config.l0, store, now, boundaries);
772
+ current = l0.messages;
773
+ stats.l0Expired = l0.stats.expired;
774
+ stats.l0Truncated = l0.stats.truncated;
775
+ stats.l0ThinkingCleared = l0.stats.thinkingCleared;
776
+ }
777
+
778
+ // L1
779
+ if (config.l1.enabled) {
780
+ const l1 = processL1(current, config.l1, store, boundaries, compactBoundaryIdx);
781
+ current = l1.messages;
782
+ stats.l1Condensed = l1.stats.condensed;
783
+ }
784
+
785
+ // L2
786
+ if (config.l2.enabled) {
787
+ const l2 = processL2(current, config.l2, store, contextUsage, boundaries, compactBoundaryIdx);
788
+ current = l2.messages;
789
+ stats.l2Triggered = l2.stats.triggered;
790
+ }
791
+
792
+ // 配对校验
793
+ if (!validateToolPairing(current)) {
794
+ return { messages, stats: { ...stats, validationFailed: true } };
795
+ }
796
+
797
+ return { messages: current, stats };
798
+ }