local-context-manager 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/examples/local-context-manager.json +13 -0
- package/package.json +45 -0
- package/src/checkpoint-reset.ts +757 -0
- package/src/config.ts +235 -0
- package/src/continuation.ts +116 -0
- package/src/handoff.ts +171 -0
- package/src/index.ts +941 -0
- package/src/policy.ts +81 -0
- package/src/telemetry.ts +211 -0
- package/src/tool-output.ts +403 -0
package/src/policy.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const MIN_COMPACTION_TURN_GAP = 2;
|
|
2
|
+
|
|
3
|
+
export interface CompactionGateOptions {
|
|
4
|
+
rearmTokens: number;
|
|
5
|
+
minimumTurnGap?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keeps threshold compaction one-shot until the active epoch has actually shrunk.
|
|
10
|
+
* Explicit phase-boundary requests still honor the turn cooldown but can bypass the
|
|
11
|
+
* threshold gate when the caller has deliberately asked for a new epoch.
|
|
12
|
+
*/
|
|
13
|
+
export class CompactionGate {
|
|
14
|
+
private readonly rearmTokens: number;
|
|
15
|
+
private readonly minimumTurnGap: number;
|
|
16
|
+
private armed = true;
|
|
17
|
+
private inFlight = false;
|
|
18
|
+
private lastRequestTurn: number | null = null;
|
|
19
|
+
|
|
20
|
+
constructor(options: CompactionGateOptions) {
|
|
21
|
+
this.rearmTokens = Number.isFinite(options.rearmTokens) ? Math.max(1, options.rearmTokens) : 1;
|
|
22
|
+
const minimumTurnGap = options.minimumTurnGap ?? MIN_COMPACTION_TURN_GAP;
|
|
23
|
+
this.minimumTurnGap = Number.isFinite(minimumTurnGap) ? Math.max(0, Math.floor(minimumTurnGap)) : MIN_COMPACTION_TURN_GAP;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
observe(tokens: number | null): void {
|
|
27
|
+
if (tokens !== null && Number.isFinite(tokens) && tokens <= this.rearmTokens) {
|
|
28
|
+
this.armed = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
canRequest(turn: number, explicit: boolean): boolean {
|
|
33
|
+
if (this.inFlight) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
this.lastRequestTurn !== null &&
|
|
38
|
+
Number.isFinite(turn) &&
|
|
39
|
+
turn - this.lastRequestTurn < this.minimumTurnGap
|
|
40
|
+
) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return explicit || this.armed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
request(turn: number): boolean {
|
|
47
|
+
if (this.inFlight) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
this.inFlight = true;
|
|
51
|
+
this.armed = false;
|
|
52
|
+
this.lastRequestTurn = Number.isFinite(turn) ? turn : this.lastRequestTurn;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
complete(postTokens: number | null, turn?: number): void {
|
|
57
|
+
this.inFlight = false;
|
|
58
|
+
this.observe(postTokens);
|
|
59
|
+
if (turn !== undefined && Number.isFinite(turn) && turn >= 0) {
|
|
60
|
+
this.lastRequestTurn = turn;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fail(): void {
|
|
65
|
+
this.inFlight = false;
|
|
66
|
+
this.armed = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get isInFlight(): boolean {
|
|
70
|
+
return this.inFlight;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function shouldTriggerThresholdCompaction(tokens: number | null, thresholdTokens: number): boolean {
|
|
75
|
+
return tokens !== null && Number.isFinite(tokens) && tokens >= thresholdTokens;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getRearmTokens(softWarningTokens: number, compactThresholdTokens: number): number {
|
|
79
|
+
const threeQuarterThreshold = Math.floor(compactThresholdTokens * 0.75);
|
|
80
|
+
return Math.max(1, Math.min(softWarningTokens, threeQuarterThreshold));
|
|
81
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export interface ContextUsageLike {
|
|
2
|
+
tokens: number | null;
|
|
3
|
+
contextWindow: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface TelemetrySnapshot {
|
|
7
|
+
contextTokens: number | null;
|
|
8
|
+
contextWindow: number | null;
|
|
9
|
+
compactThresholdTokens: number;
|
|
10
|
+
percentOfThreshold: number | null;
|
|
11
|
+
tokensAddedSinceCompaction: number | null;
|
|
12
|
+
approximateToolOutputTokens: number;
|
|
13
|
+
toolOutputTokensRemoved: number;
|
|
14
|
+
toolOutputsReduced: number;
|
|
15
|
+
compactions: number;
|
|
16
|
+
lastCompactionAt: number | null;
|
|
17
|
+
lastCompactionTurn: number | null;
|
|
18
|
+
checkpointResets: number;
|
|
19
|
+
lastCheckpointResetAt: number | null;
|
|
20
|
+
lastCheckpointPath: string | null;
|
|
21
|
+
currentTurn: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ContextTelemetry {
|
|
25
|
+
private contextTokens: number | null = null;
|
|
26
|
+
private contextWindow: number | null = null;
|
|
27
|
+
private baselineTokens: number | null = null;
|
|
28
|
+
private tokensAddedSinceCompaction: number | null = null;
|
|
29
|
+
private approximateToolOutputTokens = 0;
|
|
30
|
+
private toolOutputTokensRemoved = 0;
|
|
31
|
+
private toolOutputsReduced = 0;
|
|
32
|
+
private compactions: number;
|
|
33
|
+
private lastCompactionAt: number | null;
|
|
34
|
+
private lastCompactionTurn: number | null;
|
|
35
|
+
private checkpointResets: number;
|
|
36
|
+
private lastCheckpointResetAt: number | null;
|
|
37
|
+
private lastCheckpointPath: string | null;
|
|
38
|
+
private currentTurn = 0;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
compactions = 0,
|
|
42
|
+
lastCompactionAt: number | null = null,
|
|
43
|
+
checkpointResets = 0,
|
|
44
|
+
lastCheckpointResetAt: number | null = null,
|
|
45
|
+
lastCheckpointPath: string | null = null,
|
|
46
|
+
) {
|
|
47
|
+
this.compactions = compactions;
|
|
48
|
+
this.lastCompactionAt = lastCompactionAt;
|
|
49
|
+
this.lastCompactionTurn = null;
|
|
50
|
+
this.checkpointResets = checkpointResets;
|
|
51
|
+
this.lastCheckpointResetAt = lastCheckpointResetAt;
|
|
52
|
+
this.lastCheckpointPath = lastCheckpointPath;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
observe(usage: ContextUsageLike | undefined): void {
|
|
56
|
+
if (!usage) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.contextWindow = Number.isFinite(usage.contextWindow) && usage.contextWindow > 0 ? usage.contextWindow : null;
|
|
61
|
+
if (usage.tokens === null || !Number.isFinite(usage.tokens) || usage.tokens < 0) {
|
|
62
|
+
this.contextTokens = null;
|
|
63
|
+
this.tokensAddedSinceCompaction = null;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.setObservedTokens(usage.tokens);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
observeEstimate(tokens: number, contextWindow?: number): void {
|
|
71
|
+
if (Number.isFinite(contextWindow) && contextWindow !== undefined && contextWindow > 0) {
|
|
72
|
+
this.contextWindow = contextWindow;
|
|
73
|
+
}
|
|
74
|
+
if (!Number.isFinite(tokens) || tokens < 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.setObservedTokens(tokens);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private setObservedTokens(tokens: number): void {
|
|
81
|
+
this.contextTokens = tokens;
|
|
82
|
+
if (this.baselineTokens === null) {
|
|
83
|
+
this.baselineTokens = tokens;
|
|
84
|
+
}
|
|
85
|
+
this.tokensAddedSinceCompaction = Math.max(0, tokens - this.baselineTokens);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
markTurn(turn: number): void {
|
|
89
|
+
if (Number.isFinite(turn) && turn >= 0) {
|
|
90
|
+
this.currentTurn = turn;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setCompactionBaseline(tokens: number): void {
|
|
95
|
+
if (!Number.isFinite(tokens) || tokens < 0) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.baselineTokens = tokens;
|
|
99
|
+
if (this.contextTokens !== null) {
|
|
100
|
+
this.tokensAddedSinceCompaction = Math.max(0, this.contextTokens - tokens);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
recordToolOutput(tokens: number): void {
|
|
105
|
+
if (Number.isFinite(tokens) && tokens > 0) {
|
|
106
|
+
this.approximateToolOutputTokens += Math.floor(tokens);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
recordToolReduction(originalTokens: number, retainedTokens: number): void {
|
|
111
|
+
const original = Math.max(0, originalTokens);
|
|
112
|
+
const retained = Math.max(0, Math.min(original, retainedTokens));
|
|
113
|
+
this.recordToolOutput(retained);
|
|
114
|
+
this.toolOutputTokensRemoved += original - retained;
|
|
115
|
+
this.toolOutputsReduced += 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setActiveToolOutputTokens(tokens: number): void {
|
|
119
|
+
this.approximateToolOutputTokens = Number.isFinite(tokens) ? Math.max(0, Math.floor(tokens)) : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
markCompaction(timestamp: number, turn: number, postTokens: number | null, activeToolOutputTokens: number): void {
|
|
123
|
+
this.compactions += 1;
|
|
124
|
+
this.lastCompactionAt = Number.isFinite(timestamp) ? timestamp : Date.now();
|
|
125
|
+
this.lastCompactionTurn = turn;
|
|
126
|
+
this.baselineTokens = postTokens !== null && Number.isFinite(postTokens) ? postTokens : null;
|
|
127
|
+
this.contextTokens = postTokens !== null && Number.isFinite(postTokens) ? postTokens : null;
|
|
128
|
+
this.tokensAddedSinceCompaction = postTokens !== null && Number.isFinite(postTokens) ? 0 : null;
|
|
129
|
+
this.setActiveToolOutputTokens(activeToolOutputTokens);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
markCheckpointReset(timestamp: number, path: string, lineageCount?: number): void {
|
|
133
|
+
if (lineageCount === undefined) {
|
|
134
|
+
this.checkpointResets += 1;
|
|
135
|
+
} else if (Number.isSafeInteger(lineageCount) && lineageCount >= 0) {
|
|
136
|
+
this.checkpointResets = lineageCount;
|
|
137
|
+
}
|
|
138
|
+
this.lastCheckpointResetAt = Number.isFinite(timestamp) ? timestamp : Date.now();
|
|
139
|
+
this.lastCheckpointPath = path;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
snapshot(compactThresholdTokens: number): TelemetrySnapshot {
|
|
143
|
+
const percentOfThreshold =
|
|
144
|
+
this.contextTokens !== null && compactThresholdTokens > 0
|
|
145
|
+
? (this.contextTokens / compactThresholdTokens) * 100
|
|
146
|
+
: null;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
contextTokens: this.contextTokens,
|
|
150
|
+
contextWindow: this.contextWindow,
|
|
151
|
+
compactThresholdTokens,
|
|
152
|
+
percentOfThreshold,
|
|
153
|
+
tokensAddedSinceCompaction: this.tokensAddedSinceCompaction,
|
|
154
|
+
approximateToolOutputTokens: this.approximateToolOutputTokens,
|
|
155
|
+
toolOutputTokensRemoved: this.toolOutputTokensRemoved,
|
|
156
|
+
toolOutputsReduced: this.toolOutputsReduced,
|
|
157
|
+
compactions: this.compactions,
|
|
158
|
+
lastCompactionAt: this.lastCompactionAt,
|
|
159
|
+
lastCompactionTurn: this.lastCompactionTurn,
|
|
160
|
+
checkpointResets: this.checkpointResets,
|
|
161
|
+
lastCheckpointResetAt: this.lastCheckpointResetAt,
|
|
162
|
+
lastCheckpointPath: this.lastCheckpointPath,
|
|
163
|
+
currentTurn: this.currentTurn,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatTokenCount(tokens: number | null): string {
|
|
169
|
+
if (tokens === null) {
|
|
170
|
+
return "?";
|
|
171
|
+
}
|
|
172
|
+
if (tokens < 1_000) {
|
|
173
|
+
return `${Math.round(tokens)}`;
|
|
174
|
+
}
|
|
175
|
+
if (tokens < 10_000) {
|
|
176
|
+
return `${(tokens / 1_000).toFixed(1)}k`;
|
|
177
|
+
}
|
|
178
|
+
return `${Math.round(tokens / 1_000)}k`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatTelemetryStatus(snapshot: TelemetrySnapshot): string {
|
|
182
|
+
const context = formatTokenCount(snapshot.contextTokens);
|
|
183
|
+
const threshold = formatTokenCount(snapshot.compactThresholdTokens);
|
|
184
|
+
const percent = snapshot.percentOfThreshold === null ? "?" : `${Math.round(snapshot.percentOfThreshold)}%`;
|
|
185
|
+
const added = formatTokenCount(snapshot.tokensAddedSinceCompaction);
|
|
186
|
+
const tools = formatTokenCount(snapshot.approximateToolOutputTokens);
|
|
187
|
+
return `ctx ${context}/${threshold} (${percent}) · +${added} · tool≈${tools} · c${snapshot.compactions}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function formatTelemetryDetails(snapshot: TelemetrySnapshot): string {
|
|
191
|
+
const lines = [
|
|
192
|
+
`Context: ${formatTokenCount(snapshot.contextTokens)} tokens`,
|
|
193
|
+
`Context window: ${formatTokenCount(snapshot.contextWindow)}`,
|
|
194
|
+
`Compact threshold: ${formatTokenCount(snapshot.compactThresholdTokens)} tokens`,
|
|
195
|
+
`Threshold consumed: ${snapshot.percentOfThreshold === null ? "unknown" : `${snapshot.percentOfThreshold.toFixed(1)}%`}`,
|
|
196
|
+
`Added since compaction: ${formatTokenCount(snapshot.tokensAddedSinceCompaction)} tokens`,
|
|
197
|
+
`Active tool output: approximately ${formatTokenCount(snapshot.approximateToolOutputTokens)} tokens`,
|
|
198
|
+
`Tool output reduced: ${snapshot.toolOutputsReduced} result(s), approximately ${formatTokenCount(snapshot.toolOutputTokensRemoved)} tokens removed`,
|
|
199
|
+
`Compactions in session: ${snapshot.compactions}`,
|
|
200
|
+
`Last compaction: ${snapshot.lastCompactionAt === null ? "never" : new Date(snapshot.lastCompactionAt).toISOString()}`,
|
|
201
|
+
`Checkpoint resets in session lineage: ${snapshot.checkpointResets}`,
|
|
202
|
+
`Last checkpoint reset: ${snapshot.lastCheckpointResetAt === null ? "never" : new Date(snapshot.lastCheckpointResetAt).toISOString()}`,
|
|
203
|
+
];
|
|
204
|
+
if (snapshot.lastCheckpointPath !== null) {
|
|
205
|
+
lines.push(`Last checkpoint path: ${snapshot.lastCheckpointPath}`);
|
|
206
|
+
}
|
|
207
|
+
if (snapshot.lastCompactionTurn !== null) {
|
|
208
|
+
lines.push(`Last compaction turn: ${snapshot.lastCompactionTurn}`);
|
|
209
|
+
}
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
export interface TextContentBlock {
|
|
2
|
+
type: "text";
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ImageContentBlock {
|
|
7
|
+
type: "image";
|
|
8
|
+
data: string;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ToolContentBlock = TextContentBlock | ImageContentBlock;
|
|
13
|
+
|
|
14
|
+
export interface ToolOutputInput {
|
|
15
|
+
toolName: string;
|
|
16
|
+
input: Record<string, unknown>;
|
|
17
|
+
content: ReadonlyArray<ToolContentBlock>;
|
|
18
|
+
details?: unknown;
|
|
19
|
+
isError: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToolReduction {
|
|
23
|
+
changed: boolean;
|
|
24
|
+
content: ToolContentBlock[];
|
|
25
|
+
originalText: string;
|
|
26
|
+
compactedText: string;
|
|
27
|
+
originalTokens: number;
|
|
28
|
+
retainedTokens: number;
|
|
29
|
+
removedTokens: number;
|
|
30
|
+
category: "build" | "failure" | "search" | "diff" | "generic" | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const MAX_RETAINED_OUTPUT_CHARS = 10_000;
|
|
34
|
+
export const MAX_RETAINED_OUTPUT_LINES = 120;
|
|
35
|
+
|
|
36
|
+
const MAX_LINE_CHARS = 2_000;
|
|
37
|
+
const SUCCESSFUL_COMMAND_RE =
|
|
38
|
+
/\b(?:build|test|check|lint|typecheck|compile|make|cargo|clippy|pytest|jest|vitest|mocha|npm|pnpm|yarn|bun|gradle|mvn|dotnet|xcodebuild|swift|go\s+(?:test|build)|mix\s+(?:test|compile)|maturin)\b/i;
|
|
39
|
+
const SOURCE_COMMAND_RE =
|
|
40
|
+
/(?:^|[;&|])(\s*)(?:cat|sed|head|tail|less|more|type|Get-Content|git\s+show)\b|\b(?:cat|sed|head|tail|less|more|type|Get-Content|git\s+show)\s+/i;
|
|
41
|
+
const SEARCH_COMMAND_RE = /(?:^|[;&|\s])(?:rg|ripgrep|grep|git\s+grep|find)\b/i;
|
|
42
|
+
const GIT_DIFF_COMMAND_RE = /(?:^|[;&|\s])git\s+(?:-[^\s]+\s+)*diff\b/i;
|
|
43
|
+
const HIGH_PRIORITY_RE =
|
|
44
|
+
/\b(?:error|errors|failed|failure|exception|traceback|panic|fatal|undefined|cannot|could not|command exited|exit code)\b|(?:^|\s)(?:at\s+)?[^\s:]+:\d+(?::\d+)?/i;
|
|
45
|
+
const MEDIUM_PRIORITY_RE =
|
|
46
|
+
/\b(?:warning|warnings|warn|passed|passing|failed|skipped|tests?|suites?|summary|assert(?:ion)?s?)\b/i;
|
|
47
|
+
|
|
48
|
+
function estimateTextTokens(text: string): number {
|
|
49
|
+
return Math.ceil(text.length / 4);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getText(content: ReadonlyArray<ToolContentBlock>): string {
|
|
53
|
+
return content
|
|
54
|
+
.filter((block): block is TextContentBlock => block.type === "text")
|
|
55
|
+
.map((block) => block.text)
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getCommand(input: Record<string, unknown>): string | undefined {
|
|
60
|
+
return typeof input.command === "string" && input.command.trim() ? input.command.trim() : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function quote(value: string): string {
|
|
64
|
+
return JSON.stringify(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clipLine(line: string): string {
|
|
68
|
+
if (line.length <= MAX_LINE_CHARS) {
|
|
69
|
+
return line;
|
|
70
|
+
}
|
|
71
|
+
const head = Math.max(1_000, MAX_LINE_CHARS - 450);
|
|
72
|
+
return `${line.slice(0, head)} ... ${line.slice(-400)} [line clipped]`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lineScore(line: string): number {
|
|
76
|
+
if (HIGH_PRIORITY_RE.test(line)) {
|
|
77
|
+
return 3;
|
|
78
|
+
}
|
|
79
|
+
if (MEDIUM_PRIORITY_RE.test(line)) {
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function selectExcerpt(lines: string[], maxLines = MAX_RETAINED_OUTPUT_LINES): string[] {
|
|
86
|
+
if (lines.length <= maxLines) {
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const mandatory = new Set<number>();
|
|
91
|
+
const selected = new Set<number>();
|
|
92
|
+
const add = (index: number, required = false) => {
|
|
93
|
+
if (index < 0 || index >= lines.length) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
selected.add(index);
|
|
97
|
+
if (required) {
|
|
98
|
+
mandatory.add(index);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (let index = 0; index < Math.min(4, lines.length); index++) {
|
|
103
|
+
add(index, true);
|
|
104
|
+
}
|
|
105
|
+
for (let index = Math.max(0, lines.length - 16); index < lines.length; index++) {
|
|
106
|
+
add(index, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const highPriority = lines
|
|
110
|
+
.map((line, index) => ({ index, score: lineScore(line) }))
|
|
111
|
+
.filter((item) => item.score === 3)
|
|
112
|
+
.map((item) => item.index);
|
|
113
|
+
const mediumPriority = lines
|
|
114
|
+
.map((line, index) => ({ index, score: lineScore(line) }))
|
|
115
|
+
.filter((item) => item.score === 2)
|
|
116
|
+
.map((item) => item.index);
|
|
117
|
+
|
|
118
|
+
// Keep the head and tail no matter how many diagnostics a noisy tool emits;
|
|
119
|
+
// fill the remaining budget by diagnostic priority, then by source order.
|
|
120
|
+
selected.clear();
|
|
121
|
+
for (const index of mandatory) {
|
|
122
|
+
selected.add(index);
|
|
123
|
+
}
|
|
124
|
+
for (const index of highPriority.flatMap((value) => [value - 1, value, value + 1])) {
|
|
125
|
+
if (selected.size >= maxLines) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
add(index);
|
|
129
|
+
}
|
|
130
|
+
for (const index of mediumPriority) {
|
|
131
|
+
if (selected.size >= maxLines) {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
add(index);
|
|
135
|
+
}
|
|
136
|
+
for (let index = 0; index < lines.length && selected.size < maxLines; index++) {
|
|
137
|
+
add(index);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [...selected]
|
|
141
|
+
.sort((a, b) => a - b)
|
|
142
|
+
.map((index) => lines[index]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fitExcerpt(lines: string[], maxChars = MAX_RETAINED_OUTPUT_CHARS): string {
|
|
146
|
+
const result: string[] = [];
|
|
147
|
+
let length = 0;
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
const clipped = clipLine(line);
|
|
150
|
+
const separatorLength = result.length === 0 ? 0 : 1;
|
|
151
|
+
if (length + separatorLength + clipped.length > maxChars) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
result.push(clipped);
|
|
155
|
+
length += separatorLength + clipped.length;
|
|
156
|
+
}
|
|
157
|
+
return result.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function compactedHeader(
|
|
161
|
+
category: Exclude<ToolReduction["category"], null>,
|
|
162
|
+
originalText: string,
|
|
163
|
+
input: Record<string, unknown>,
|
|
164
|
+
isError: boolean,
|
|
165
|
+
): string {
|
|
166
|
+
const lines = originalText ? originalText.split("\n").length : 0;
|
|
167
|
+
const command = getCommand(input);
|
|
168
|
+
const status = isError ? parseExitStatus(originalText) : "0";
|
|
169
|
+
const label = category === "failure" ? "failed command" : `${category} output`;
|
|
170
|
+
const metadata = [
|
|
171
|
+
`[local-context-manager] Reduced ${label}.`,
|
|
172
|
+
command ? `Command: ${command}` : undefined,
|
|
173
|
+
`Exit status: ${status}`,
|
|
174
|
+
`Original size: ${lines} lines, ${originalText.length} characters.`,
|
|
175
|
+
].filter((line): line is string => line !== undefined);
|
|
176
|
+
return metadata.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseExitStatus(text: string): string {
|
|
180
|
+
const match = text.match(/(?:exit(?:ed)?|status|code)\s*(?:code\s*)?[:=]?\s*(-?\d+)/i);
|
|
181
|
+
return match?.[1] ?? "non-zero";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildExcerptText(
|
|
185
|
+
category: Exclude<ToolReduction["category"], null>,
|
|
186
|
+
originalText: string,
|
|
187
|
+
input: Record<string, unknown>,
|
|
188
|
+
isError: boolean,
|
|
189
|
+
): string {
|
|
190
|
+
const lines = originalText.split("\n");
|
|
191
|
+
const header = compactedHeader(category, originalText, input, isError);
|
|
192
|
+
let body: string;
|
|
193
|
+
|
|
194
|
+
if (category === "failure") {
|
|
195
|
+
const excerpt = fitExcerpt(selectExcerpt(lines));
|
|
196
|
+
body = excerpt ? `Key diagnostics and recent output:\n${excerpt}` : "No textual diagnostic was available.";
|
|
197
|
+
} else if (category === "search") {
|
|
198
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : typeof input.query === "string" ? input.query : undefined;
|
|
199
|
+
const excerpt = fitExcerpt(selectExcerpt(lines));
|
|
200
|
+
body = [
|
|
201
|
+
pattern ? `Search query: ${quote(pattern)}` : undefined,
|
|
202
|
+
`Matching lines shown: ${lines.filter((line) => line.trim()).length}`,
|
|
203
|
+
excerpt ? `Relevant matches:\n${excerpt}` : "No matching lines were returned.",
|
|
204
|
+
]
|
|
205
|
+
.filter((line): line is string => line !== undefined)
|
|
206
|
+
.join("\n");
|
|
207
|
+
} else if (category === "diff") {
|
|
208
|
+
body = formatDiffSummary(lines);
|
|
209
|
+
} else {
|
|
210
|
+
const important = lines.filter((line) => lineScore(line) >= 2);
|
|
211
|
+
const excerpt = fitExcerpt(selectExcerpt(important.length > 0 ? important : lines));
|
|
212
|
+
body = excerpt ? `Relevant output:\n${excerpt}` : "No textual output was returned.";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return `${header}\n${body}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function diffPathFromHeader(line: string): string | undefined {
|
|
219
|
+
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
220
|
+
return match?.[2] ?? match?.[1];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeDiffPath(line: string): string | undefined {
|
|
224
|
+
const match = line.match(/^\+\+\+ b\/(.+)$/);
|
|
225
|
+
return match?.[1];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatDiffSummary(lines: string[]): string {
|
|
229
|
+
const files = new Map<string, { added: number; deleted: number; hunks: string[] }>();
|
|
230
|
+
let currentFile: string | undefined;
|
|
231
|
+
const changedLines: string[] = [];
|
|
232
|
+
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
const headerPath = diffPathFromHeader(line);
|
|
235
|
+
if (headerPath) {
|
|
236
|
+
currentFile = headerPath;
|
|
237
|
+
files.set(currentFile, files.get(currentFile) ?? { added: 0, deleted: 0, hunks: [] });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const plusPath = normalizeDiffPath(line);
|
|
242
|
+
if (plusPath) {
|
|
243
|
+
currentFile = plusPath;
|
|
244
|
+
files.set(currentFile, files.get(currentFile) ?? { added: 0, deleted: 0, hunks: [] });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!currentFile) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const file = files.get(currentFile);
|
|
253
|
+
if (!file) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (line.startsWith("@@")) {
|
|
257
|
+
file.hunks.push(line);
|
|
258
|
+
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
259
|
+
file.added += 1;
|
|
260
|
+
changedLines.push(`${currentFile}: ${line}`);
|
|
261
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
262
|
+
file.deleted += 1;
|
|
263
|
+
changedLines.push(`${currentFile}: ${line}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const fileLines = [...files].map(([path, stats]) => `- ${path} (+${stats.added}/-${stats.deleted})`);
|
|
268
|
+
const hunkLines = [...files].flatMap(([path, stats]) => stats.hunks.slice(0, 12).map((hunk) => `- ${path}: ${hunk}`));
|
|
269
|
+
const changeExcerpt = fitExcerpt(selectExcerpt(changedLines, 80), 7_000);
|
|
270
|
+
const sections = [
|
|
271
|
+
`Files changed: ${files.size}`,
|
|
272
|
+
fileLines.length > 0 ? fileLines.join("\n") : undefined,
|
|
273
|
+
hunkLines.length > 0 ? `Hunks:\n${hunkLines.join("\n")}` : undefined,
|
|
274
|
+
changeExcerpt ? `Changed-line excerpt:\n${changeExcerpt}` : undefined,
|
|
275
|
+
].filter((section): section is string => section !== undefined);
|
|
276
|
+
return sections.join("\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function replaceTextBlocks(content: ReadonlyArray<ToolContentBlock>, text: string): ToolContentBlock[] {
|
|
280
|
+
const firstTextIndex = content.findIndex((block) => block.type === "text");
|
|
281
|
+
if (firstTextIndex < 0) {
|
|
282
|
+
return [...content];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result: ToolContentBlock[] = [];
|
|
286
|
+
for (let index = 0; index < content.length; index++) {
|
|
287
|
+
const block = content[index];
|
|
288
|
+
if (block.type !== "text") {
|
|
289
|
+
result.push(block);
|
|
290
|
+
} else if (index === firstTextIndex) {
|
|
291
|
+
result.push({ type: "text", text });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getCategory(toolName: string, input: Record<string, unknown>, isError: boolean, text: string): ToolReduction["category"] {
|
|
298
|
+
const normalizedToolName = toolName.toLowerCase();
|
|
299
|
+
if (normalizedToolName === "read") {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
if (normalizedToolName === "grep" || normalizedToolName === "find") {
|
|
303
|
+
return text.length > MAX_RETAINED_OUTPUT_CHARS ? "search" : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const command = getCommand(input);
|
|
307
|
+
if (!command) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
if (GIT_DIFF_COMMAND_RE.test(command) && text.length > MAX_RETAINED_OUTPUT_CHARS) {
|
|
311
|
+
return "diff";
|
|
312
|
+
}
|
|
313
|
+
if (isError && text.length > MAX_RETAINED_OUTPUT_CHARS) {
|
|
314
|
+
return "failure";
|
|
315
|
+
}
|
|
316
|
+
if (SEARCH_COMMAND_RE.test(command) && text.length > MAX_RETAINED_OUTPUT_CHARS) {
|
|
317
|
+
return "search";
|
|
318
|
+
}
|
|
319
|
+
if (text.length <= MAX_RETAINED_OUTPUT_CHARS) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
if (SOURCE_COMMAND_RE.test(command)) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
if (SUCCESSFUL_COMMAND_RE.test(command)) {
|
|
326
|
+
return "build";
|
|
327
|
+
}
|
|
328
|
+
if (text.length > MAX_RETAINED_OUTPUT_CHARS * 2) {
|
|
329
|
+
return "generic";
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function reduceToolOutput(result: ToolOutputInput): ToolReduction {
|
|
335
|
+
const originalText = getText(result.content);
|
|
336
|
+
const category = getCategory(result.toolName, result.input, result.isError, originalText);
|
|
337
|
+
if (!category || !originalText) {
|
|
338
|
+
return {
|
|
339
|
+
changed: false,
|
|
340
|
+
content: [...result.content],
|
|
341
|
+
originalText,
|
|
342
|
+
compactedText: originalText,
|
|
343
|
+
originalTokens: estimateTextTokens(originalText),
|
|
344
|
+
retainedTokens: estimateTextTokens(originalText),
|
|
345
|
+
removedTokens: 0,
|
|
346
|
+
category: null,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const compactedText = buildExcerptText(category, originalText, result.input, result.isError);
|
|
351
|
+
if (compactedText.length >= originalText.length) {
|
|
352
|
+
return {
|
|
353
|
+
changed: false,
|
|
354
|
+
content: [...result.content],
|
|
355
|
+
originalText,
|
|
356
|
+
compactedText: originalText,
|
|
357
|
+
originalTokens: estimateTextTokens(originalText),
|
|
358
|
+
retainedTokens: estimateTextTokens(originalText),
|
|
359
|
+
removedTokens: 0,
|
|
360
|
+
category: null,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const originalTokens = estimateTextTokens(originalText);
|
|
365
|
+
const retainedTokens = estimateTextTokens(compactedText);
|
|
366
|
+
return {
|
|
367
|
+
changed: true,
|
|
368
|
+
content: replaceTextBlocks(result.content, compactedText),
|
|
369
|
+
originalText,
|
|
370
|
+
compactedText,
|
|
371
|
+
originalTokens,
|
|
372
|
+
retainedTokens,
|
|
373
|
+
removedTokens: Math.max(0, originalTokens - retainedTokens),
|
|
374
|
+
category,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function extractFullOutputPath(details: unknown, _text: string): string | undefined {
|
|
379
|
+
// Only trust structured tool metadata. A command's stdout can contain arbitrary
|
|
380
|
+
// text that impersonates a recovery path, including a path to sensitive data.
|
|
381
|
+
if (typeof details !== "object" || details === null || Array.isArray(details)) {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
const path = (details as { fullOutputPath?: unknown }).fullOutputPath;
|
|
385
|
+
return typeof path === "string" && path.trim() ? path.trim() : undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function appendFullOutputNotice(content: ReadonlyArray<ToolContentBlock>, path: string): ToolContentBlock[] {
|
|
389
|
+
const textIndex = content.findIndex((block) => block.type === "text");
|
|
390
|
+
if (textIndex < 0) {
|
|
391
|
+
return [...content];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return content.map((block, index) => {
|
|
395
|
+
if (index !== textIndex || block.type !== "text") {
|
|
396
|
+
return block;
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
type: "text",
|
|
400
|
+
text: `${block.text}\nFull output saved to: ${path}`,
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
}
|