@herbertgao/pi-extensions 2026.9.9 → 2026.9.11
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 +1 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/node_modules/@herbertgao/pi-bark/package.json +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -2
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +12 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +427 -120
- package/node_modules/@herbertgao/pi-subagents/docs/rpc.md +184 -0
- package/node_modules/@herbertgao/pi-subagents/docs/workflows.md +466 -0
- package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +6 -6
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/compose.js +52 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/fan-out-audit.js +56 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/gated-fix.js +60 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/lib/count-child.js +30 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/review-panel.js +68 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/structured-findings.js +81 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +12 -10
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +52 -12
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +837 -146
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +213 -39
- package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +73 -14
- package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +101 -47
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +2249 -914
- package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +13 -0
- package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +215 -0
- package/node_modules/@herbertgao/pi-subagents/src/mention.ts +147 -0
- package/node_modules/@herbertgao/pi-subagents/src/model-resolver.ts +9 -1
- package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +40 -26
- package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +18 -8
- package/node_modules/@herbertgao/pi-subagents/src/prompts.ts +46 -9
- package/node_modules/@herbertgao/pi-subagents/src/schedule.ts +21 -16
- package/node_modules/@herbertgao/pi-subagents/src/settings.ts +137 -7
- package/node_modules/@herbertgao/pi-subagents/src/structured-output.ts +136 -0
- package/node_modules/@herbertgao/pi-subagents/src/types.ts +126 -8
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-mention.ts +274 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +20 -5
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +10 -4
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +167 -22
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-card.ts +555 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-dialog.ts +1304 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-menu.ts +226 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/collisions.ts +122 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/entry.ts +47 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/host.ts +463 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/journal.ts +164 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/json-schema.ts +142 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/meta.ts +401 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/progress.ts +622 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/runtime.ts +1399 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/saved.ts +230 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/task.ts +333 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/tool-description.ts +200 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/worker-source.ts +781 -0
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +97 -95
- package/node_modules/@herbertgao/pi-subagents/src/xml.ts +13 -0
- package/node_modules/@herbertgao/resume-from/package.json +1 -1
- package/node_modules/@herbertgao/sol-pi/README.md +3 -3
- package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
- package/node_modules/@herbertgao/sol-pi/agents-install.md +4 -4
- package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +6 -6
- package/node_modules/@herbertgao/sol-pi/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/dist/index.ts +39 -89
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
- package/node_modules/@narumitw/pi-btw/package.json +4 -4
- package/node_modules/@narumitw/pi-btw/src/btw.ts +28 -87
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +8 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +40 -37
- package/node_modules/@narumitw/pi-caffeinate/README.md +21 -66
- package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +10 -41
- package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +2 -2
- package/node_modules/@narumitw/pi-caffeinate/package.json +50 -51
- package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +637 -663
- package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +114 -120
- package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +29 -29
- package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +108 -126
- package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +124 -128
- package/node_modules/pi-multi-account/CHANGELOG.md +1209 -0
- package/node_modules/pi-multi-account/CONTRIBUTING.md +61 -0
- package/node_modules/pi-multi-account/LICENSE +21 -0
- package/node_modules/pi-multi-account/README.md +197 -0
- package/node_modules/pi-multi-account/SECURITY.md +27 -0
- package/node_modules/pi-multi-account/auth-file-transaction.ts +56 -0
- package/node_modules/pi-multi-account/child-usability.ts +233 -0
- package/node_modules/pi-multi-account/compaction-summary.ts +32 -0
- package/node_modules/pi-multi-account/completion-route-planner.ts +224 -0
- package/node_modules/pi-multi-account/context-guard.ts +420 -0
- package/node_modules/pi-multi-account/cursor/LICENSE +21 -0
- package/node_modules/pi-multi-account/cursor/NOTICE +2 -0
- package/node_modules/pi-multi-account/cursor/auth.ts +165 -0
- package/node_modules/pi-multi-account/cursor/bridge-handle.ts +155 -0
- package/node_modules/pi-multi-account/cursor/conversation-registry.ts +104 -0
- package/node_modules/pi-multi-account/cursor/cursor-models-raw.json +611 -0
- package/node_modules/pi-multi-account/cursor/cursor-shared.ts +192 -0
- package/node_modules/pi-multi-account/cursor/h2-bridge.mjs +175 -0
- package/node_modules/pi-multi-account/cursor/index.ts +572 -0
- package/node_modules/pi-multi-account/cursor/message-parsing.ts +323 -0
- package/node_modules/pi-multi-account/cursor/prompt-usage.ts +53 -0
- package/node_modules/pi-multi-account/cursor/proto/agent_pb.ts +15294 -0
- package/node_modules/pi-multi-account/cursor/proxy.ts +2510 -0
- package/node_modules/pi-multi-account/cursor/session-lifecycle.ts +40 -0
- package/node_modules/pi-multi-account/cursor/sse-keepalive.ts +24 -0
- package/node_modules/pi-multi-account/cursor/stream-lifecycle.ts +193 -0
- package/node_modules/pi-multi-account/cursor/upstream-watchdog.ts +88 -0
- package/node_modules/pi-multi-account/cursor-bridge.ts +240 -0
- package/node_modules/pi-multi-account/cursor-model-name.ts +12 -0
- package/node_modules/pi-multi-account/index.ts +11825 -0
- package/node_modules/pi-multi-account/model-catalog.ts +354 -0
- package/node_modules/pi-multi-account/package.json +101 -0
- package/node_modules/pi-multi-account/pi-contract.ts +281 -0
- package/node_modules/pi-multi-account/provider-payload-stream.ts +44 -0
- package/node_modules/pi-multi-account/provider-priority.ts +189 -0
- package/node_modules/pi-multi-account/slot-proxy-auth.ts +167 -0
- package/node_modules/pi-multi-account/slot-proxy.ts +344 -0
- package/node_modules/pi-multi-account/state-file-transaction.ts +67 -0
- package/node_modules/pi-multi-account/usage.ts +1099 -0
- package/node_modules/pi-typesafe/README.md +6 -2
- package/node_modules/pi-typesafe/dist/client.d.ts +11 -0
- package/node_modules/pi-typesafe/dist/client.js +45 -10
- package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
- package/node_modules/pi-typesafe/dist/index.js +1 -1
- package/node_modules/pi-typesafe/package.json +2 -2
- package/node_modules/pi-web-access/CHANGELOG.md +36 -0
- package/node_modules/pi-web-access/README.md +75 -18
- package/node_modules/pi-web-access/anysearch.ts +4 -15
- package/node_modules/pi-web-access/bocha.ts +3 -22
- package/node_modules/pi-web-access/brave.ts +3 -21
- package/node_modules/pi-web-access/brightdata.ts +5 -32
- package/node_modules/pi-web-access/content-find.ts +168 -53
- package/node_modules/pi-web-access/curator-page.ts +4 -1
- package/node_modules/pi-web-access/curator-run.ts +2 -1
- package/node_modules/pi-web-access/curator-server.ts +1 -0
- package/node_modules/pi-web-access/dist/index.js +24620 -0
- package/node_modules/pi-web-access/domain-filter-normalization.ts +14 -0
- package/node_modules/pi-web-access/duckduckgo.ts +3 -21
- package/node_modules/pi-web-access/extract.ts +3 -1
- package/node_modules/pi-web-access/firecrawl.ts +5 -29
- package/node_modules/pi-web-access/gemini-search.ts +81 -32
- package/node_modules/pi-web-access/index.ts +149 -148
- package/node_modules/pi-web-access/jina-search.ts +4 -15
- package/node_modules/pi-web-access/kagi.ts +4 -13
- package/node_modules/pi-web-access/kimi-search.ts +5 -30
- package/node_modules/pi-web-access/mistral-search.ts +1 -15
- package/node_modules/pi-web-access/ollama.ts +2 -7
- package/node_modules/pi-web-access/openai-search.ts +174 -36
- package/node_modules/pi-web-access/opencode-session-headers.ts +24 -0
- package/node_modules/pi-web-access/package.json +10 -4
- package/node_modules/pi-web-access/page-query.ts +10 -2
- package/node_modules/pi-web-access/parallel.ts +1 -15
- package/node_modules/pi-web-access/pdf-extract.ts +3 -0
- package/node_modules/pi-web-access/querit.ts +5 -29
- package/node_modules/pi-web-access/search-answer-formatting.ts +11 -0
- package/node_modules/pi-web-access/search-result-count-normalization.ts +4 -0
- package/node_modules/pi-web-access/search1api.ts +5 -29
- package/node_modules/pi-web-access/searchinfinity.ts +5 -29
- package/node_modules/pi-web-access/searxng.ts +3 -21
- package/node_modules/pi-web-access/serpapi.ts +5 -28
- package/node_modules/pi-web-access/serpbase.ts +3 -22
- package/node_modules/pi-web-access/serpdive.ts +3 -21
- package/node_modules/pi-web-access/serper.ts +5 -28
- package/node_modules/pi-web-access/serply.ts +197 -0
- package/node_modules/pi-web-access/source-check.ts +11 -47
- package/node_modules/pi-web-access/summary-review.ts +7 -3
- package/node_modules/pi-web-access/tavily.ts +3 -21
- package/node_modules/pi-web-access/tinyfish.ts +5 -29
- package/node_modules/pi-web-access/utils.ts +9 -1
- package/node_modules/pi-web-access/valyu.ts +5 -28
- package/node_modules/pi-web-access/xai-search.ts +1 -15
- package/node_modules/pi-web-access/xcrawl.ts +5 -32
- package/package.json +17 -11
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { comparePriority } from "./provider-priority.ts";
|
|
2
|
+
|
|
3
|
+
export interface RouteModelRef {
|
|
4
|
+
provider: string;
|
|
5
|
+
id: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RouteAttemptFact {
|
|
9
|
+
operationId: string;
|
|
10
|
+
purpose: string;
|
|
11
|
+
attempt: number;
|
|
12
|
+
model: RouteModelRef;
|
|
13
|
+
dispatched: boolean;
|
|
14
|
+
stopReason: string;
|
|
15
|
+
errorMessage?: string;
|
|
16
|
+
response?: {
|
|
17
|
+
status: number;
|
|
18
|
+
retryAfterMs?: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RouteRequestFact {
|
|
23
|
+
operationId: string;
|
|
24
|
+
purpose: string;
|
|
25
|
+
attempt: number;
|
|
26
|
+
maxAttempts: number;
|
|
27
|
+
deadlineAt: number;
|
|
28
|
+
sessionModel?: RouteModelRef;
|
|
29
|
+
preferredModels: readonly RouteModelRef[];
|
|
30
|
+
attempts: readonly RouteAttemptFact[];
|
|
31
|
+
previous?: RouteAttemptFact;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RouteDecision =
|
|
35
|
+
| { action: "route"; model: RouteModelRef; delayMs?: number }
|
|
36
|
+
| { action: "stop"; reason?: string };
|
|
37
|
+
|
|
38
|
+
export interface RouteFailurePatterns {
|
|
39
|
+
ignore: readonly string[];
|
|
40
|
+
auth: readonly string[];
|
|
41
|
+
limit: readonly string[];
|
|
42
|
+
transient: readonly string[];
|
|
43
|
+
model: readonly string[];
|
|
44
|
+
contextOverflow: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type RouteFailureKind =
|
|
48
|
+
| "success"
|
|
49
|
+
| "aborted"
|
|
50
|
+
| "route_unavailable"
|
|
51
|
+
| "context_overflow"
|
|
52
|
+
| "auth"
|
|
53
|
+
| "limit"
|
|
54
|
+
| "model"
|
|
55
|
+
| "cursor_stall"
|
|
56
|
+
| "transient"
|
|
57
|
+
| "unhandled";
|
|
58
|
+
|
|
59
|
+
export interface ScoredRouteCandidate<T> {
|
|
60
|
+
model: T;
|
|
61
|
+
remaining: number;
|
|
62
|
+
rotIndex: number;
|
|
63
|
+
rank: number;
|
|
64
|
+
lastRefusalAt: number;
|
|
65
|
+
predictedBusy: boolean;
|
|
66
|
+
confirmed: boolean;
|
|
67
|
+
group: string;
|
|
68
|
+
sameFamily: boolean;
|
|
69
|
+
sameModel: boolean;
|
|
70
|
+
provider: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RankAutomaticRouteOptions {
|
|
74
|
+
preferLatestModel: boolean;
|
|
75
|
+
preferSameIdentity: boolean;
|
|
76
|
+
providerPriority: readonly string[];
|
|
77
|
+
availableNowOnly: boolean;
|
|
78
|
+
lastLeftProvider?: string;
|
|
79
|
+
lastLeftAt?: number;
|
|
80
|
+
antiPingPongMs: number;
|
|
81
|
+
now: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function matches(text: string, patterns: readonly string[]): boolean {
|
|
85
|
+
const lower = text.toLowerCase();
|
|
86
|
+
return patterns.some((pattern) => pattern.length > 0 && lower.includes(pattern.toLowerCase()));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sameRoute(left: RouteModelRef, right: RouteModelRef): boolean {
|
|
90
|
+
return left.provider === right.provider && left.id === right.id;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function routeKey(route: RouteModelRef): string {
|
|
94
|
+
return `${route.provider}/${route.id}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isCursorStall(provider: string, text: string): boolean {
|
|
98
|
+
if (!provider.startsWith("cursor")) return false;
|
|
99
|
+
const lower = text.toLowerCase();
|
|
100
|
+
return (
|
|
101
|
+
(lower.includes("cursor run stalled") && lower.includes("timed out")) ||
|
|
102
|
+
(lower.includes("produced no output") && lower.includes("timed out"))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function classifyRouteAttempt(
|
|
107
|
+
attempt: RouteAttemptFact,
|
|
108
|
+
patterns: RouteFailurePatterns,
|
|
109
|
+
): RouteFailureKind {
|
|
110
|
+
if (attempt.stopReason === "route_unavailable") return "route_unavailable";
|
|
111
|
+
if (attempt.stopReason === "aborted") return "aborted";
|
|
112
|
+
if (attempt.stopReason !== "error") return "success";
|
|
113
|
+
const text = attempt.errorMessage?.trim() ?? "";
|
|
114
|
+
if (matches(text, patterns.contextOverflow)) return "context_overflow";
|
|
115
|
+
if (text && matches(text, patterns.ignore)) return "unhandled";
|
|
116
|
+
if (attempt.response?.status === 401 || matches(text, patterns.auth)) return "auth";
|
|
117
|
+
if (
|
|
118
|
+
attempt.response?.status === 402 ||
|
|
119
|
+
attempt.response?.status === 403 ||
|
|
120
|
+
attempt.response?.status === 429 ||
|
|
121
|
+
matches(text, patterns.limit)
|
|
122
|
+
) {
|
|
123
|
+
return "limit";
|
|
124
|
+
}
|
|
125
|
+
if (matches(text, patterns.model)) return "model";
|
|
126
|
+
if (isCursorStall(attempt.model.provider, text)) return "cursor_stall";
|
|
127
|
+
if (attempt.response && attempt.response.status >= 500) return "transient";
|
|
128
|
+
if (matches(text, patterns.transient)) return "transient";
|
|
129
|
+
return "unhandled";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function selectOperationRoute(options: {
|
|
133
|
+
request: RouteRequestFact;
|
|
134
|
+
orderedCandidates: readonly RouteModelRef[];
|
|
135
|
+
patterns: RouteFailurePatterns;
|
|
136
|
+
maxSameRouteTransientRetries: number;
|
|
137
|
+
transientRetryDelayMs: number;
|
|
138
|
+
}): RouteDecision {
|
|
139
|
+
const { request } = options;
|
|
140
|
+
const ordered = [...request.preferredModels, ...(request.sessionModel ? [request.sessionModel] : []), ...options.orderedCandidates];
|
|
141
|
+
const candidates = ordered.filter(
|
|
142
|
+
(candidate, index) => ordered.findIndex((other) => sameRoute(candidate, other)) === index,
|
|
143
|
+
);
|
|
144
|
+
const unavailable = new Set(
|
|
145
|
+
request.attempts
|
|
146
|
+
.filter((attempt) => attempt.stopReason === "route_unavailable")
|
|
147
|
+
.map((attempt) => routeKey(attempt.model)),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (!request.previous) {
|
|
151
|
+
const initial = candidates.find((candidate) => !unavailable.has(routeKey(candidate)));
|
|
152
|
+
return initial
|
|
153
|
+
? { action: "route", model: initial }
|
|
154
|
+
: { action: "stop", reason: "No completion route is available" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const failureKind = classifyRouteAttempt(request.previous, options.patterns);
|
|
158
|
+
if (failureKind === "success") {
|
|
159
|
+
return { action: "stop", reason: "Completion already succeeded" };
|
|
160
|
+
}
|
|
161
|
+
if (failureKind === "aborted") {
|
|
162
|
+
return { action: "stop", reason: "Completion was cancelled" };
|
|
163
|
+
}
|
|
164
|
+
if (failureKind === "context_overflow" || failureKind === "unhandled") {
|
|
165
|
+
return {
|
|
166
|
+
action: "stop",
|
|
167
|
+
reason: `Completion failure is not provider-route evidence (${failureKind})`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (failureKind === "transient" || failureKind === "cursor_stall") {
|
|
172
|
+
const sameRouteTransientFailures = request.attempts.filter(
|
|
173
|
+
(attempt) =>
|
|
174
|
+
sameRoute(attempt.model, request.previous!.model) &&
|
|
175
|
+
["transient", "cursor_stall"].includes(classifyRouteAttempt(attempt, options.patterns)),
|
|
176
|
+
).length;
|
|
177
|
+
if (sameRouteTransientFailures <= options.maxSameRouteTransientRetries) {
|
|
178
|
+
return {
|
|
179
|
+
action: "route",
|
|
180
|
+
model: request.previous.model,
|
|
181
|
+
delayMs: request.previous.response?.retryAfterMs ?? options.transientRetryDelayMs,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const attempted = new Set(request.attempts.map((attempt) => routeKey(attempt.model)));
|
|
187
|
+
const fallback = candidates.find((candidate) => !attempted.has(routeKey(candidate)));
|
|
188
|
+
return fallback
|
|
189
|
+
? { action: "route", model: fallback }
|
|
190
|
+
: { action: "stop", reason: `No healthy fallback after ${failureKind}` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function rankAutomaticRouteCandidates<T>(
|
|
194
|
+
scored: readonly ScoredRouteCandidate<T>[],
|
|
195
|
+
options: RankAutomaticRouteOptions,
|
|
196
|
+
): T[] {
|
|
197
|
+
const byRankThenRotation = (left: ScoredRouteCandidate<T>, right: ScoredRouteCandidate<T>) =>
|
|
198
|
+
(options.preferLatestModel ? left.rank - right.rank : 0) || left.rotIndex - right.rotIndex;
|
|
199
|
+
const byPolicy = (left: ScoredRouteCandidate<T>, right: ScoredRouteCandidate<T>) =>
|
|
200
|
+
(options.preferSameIdentity
|
|
201
|
+
? Number(right.sameModel) - Number(left.sameModel) || Number(right.sameFamily) - Number(left.sameFamily)
|
|
202
|
+
: 0) ||
|
|
203
|
+
Number(right.confirmed) - Number(left.confirmed) ||
|
|
204
|
+
Number(left.predictedBusy) - Number(right.predictedBusy) ||
|
|
205
|
+
comparePriority(left.group, right.group, options.providerPriority) ||
|
|
206
|
+
left.lastRefusalAt - right.lastRefusalAt ||
|
|
207
|
+
byRankThenRotation(left, right);
|
|
208
|
+
|
|
209
|
+
let available = scored.filter((candidate) => candidate.remaining === 0).sort(byPolicy);
|
|
210
|
+
if (
|
|
211
|
+
options.lastLeftProvider &&
|
|
212
|
+
options.lastLeftAt !== undefined &&
|
|
213
|
+
options.now - options.lastLeftAt < options.antiPingPongMs &&
|
|
214
|
+
available.length > 1
|
|
215
|
+
) {
|
|
216
|
+
const alternatives = available.filter((candidate) => candidate.provider !== options.lastLeftProvider);
|
|
217
|
+
if (alternatives.length > 0) available = alternatives;
|
|
218
|
+
}
|
|
219
|
+
if (available.length > 0) return available.map((candidate) => candidate.model);
|
|
220
|
+
if (options.availableNowOnly) return [];
|
|
221
|
+
return [...scored]
|
|
222
|
+
.sort((left, right) => left.remaining - right.remaining || byPolicy(left, right))
|
|
223
|
+
.map((candidate) => candidate.model);
|
|
224
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-run context guard: keep a request inside the model's window WHILE the agent is working.
|
|
3
|
+
*
|
|
4
|
+
* ## The hole this fills
|
|
5
|
+
*
|
|
6
|
+
* Pi checks whether it should compact in exactly two places: after a whole agent run has ended,
|
|
7
|
+
* and just before a new user prompt is submitted. Nothing checks in between. A single autonomous
|
|
8
|
+
* run ("продовжуй") is one agent run no matter how many tool calls it makes, so for the entire
|
|
9
|
+
* length of that run the context is unmeasured and unbounded.
|
|
10
|
+
*
|
|
11
|
+
* Measured across 18 real coding sessions (~96 MB of transcripts):
|
|
12
|
+
*
|
|
13
|
+
* - ONE agent run between two user messages ran for 78 minutes (586 records, 220 assistant turns,
|
|
14
|
+
* 360 tool results). Reported context went 85 663 → 542 529 against a 272 000 window. No
|
|
15
|
+
* compaction check fired during it; compaction only ran once the run died.
|
|
16
|
+
* - On the main working model (gpt-5.6-sol, window 272 000): 48 % of all requests were sent above
|
|
17
|
+
* 80 % of the window, 30 % above 100 %, 149 above 150 %. Same figures whether you count
|
|
18
|
+
* `totalTokens` or prompt-only (`input + cacheRead + cacheWrite`).
|
|
19
|
+
* - Auto-compaction, when it did fire, fired at 94 %–199 % of the window. Measured trigger points:
|
|
20
|
+
* 107, 121, 122, 125, 132, 134, 136, 137, 141, 145, 153, 199 %.
|
|
21
|
+
* - Pi's own headroom is `contextWindow - reserveTokens`, reserve 16 384 by default. Excluding
|
|
22
|
+
* Cursor (whose counter is its own server's), the growth between two consecutive assistant
|
|
23
|
+
* replies is p99 = 18 950 and p99.9 = 46 731 tokens. The safety margin is smaller than a normal
|
|
24
|
+
* step, never mind a big one.
|
|
25
|
+
* - A retryable provider error consumes the one checkpoint there is: `_handlePostAgentRun()`
|
|
26
|
+
* takes the retry branch and returns BEFORE `_checkCompaction()`. In the run above a Codex 500
|
|
27
|
+
* partway through was retried and the run carried on to 542 k unchecked.
|
|
28
|
+
* - Advertised windows are not usable windows. claude-opus-5 and kimi k3 declare 1 000 000+, so
|
|
29
|
+
* Pi's threshold lands at ~1 032 192 and auto-compaction can never fire on them; those sessions
|
|
30
|
+
* reached 717 813 and 547 203 tokens.
|
|
31
|
+
*
|
|
32
|
+
* ## What this module does about it
|
|
33
|
+
*
|
|
34
|
+
* It is the emergency valve, not a replacement for compaction:
|
|
35
|
+
*
|
|
36
|
+
* 1. Estimate the size of the request Pi is about to send, from Pi's own message list plus the
|
|
37
|
+
* system prompt — never from the provider's reported number. Cursor and openai-codex both keep
|
|
38
|
+
* the conversation on their side (Cursor via a checkpoint, Codex via WebSocket deltas and
|
|
39
|
+
* `previous_response_id`), so what they report back is their bookkeeping, not ours.
|
|
40
|
+
* 2. Above `softPercent` of the usable window, elide the OLDEST large tool results out of the
|
|
41
|
+
* outgoing request — down to `targetPercent` — leaving the most recent `keepVerbatimTokens`
|
|
42
|
+
* untouched. The session transcript is never modified; only what goes over the wire shrinks.
|
|
43
|
+
* 3. Above `compactPercent`, ask for a real compaction at the next safe boundary. Real compaction
|
|
44
|
+
* must not be triggered mid-run: `ctx.compact()` starts with `await this.abort()` and would
|
|
45
|
+
* kill the agent's work.
|
|
46
|
+
*
|
|
47
|
+
* Two properties this design has to have, and does:
|
|
48
|
+
*
|
|
49
|
+
* - **Prefix stability.** Once a message is elided it stays elided (`elidedKeys`), so the request
|
|
50
|
+
* prefix does not churn from turn to turn and the provider's prompt cache re-warms once instead
|
|
51
|
+
* of on every request.
|
|
52
|
+
* - **Self-honesty.** Trimming the outgoing request lowers what the provider reports, which would
|
|
53
|
+
* hide the growth from Pi's own threshold and stop real compaction from ever happening. So the
|
|
54
|
+
* guard keeps its own untrimmed accounting and drives compaction itself.
|
|
55
|
+
*
|
|
56
|
+
* Nothing here imports Pi at runtime: the module is pure so `node --test` can load it (the
|
|
57
|
+
* protobuf-backed modules cannot be imported under Node's type stripping — see
|
|
58
|
+
* cursor/conversation-registry.ts for the same reasoning).
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** Pi's own image allowance in `estimateTokens`, mirrored so our numbers are comparable to its. */
|
|
62
|
+
const ESTIMATED_IMAGE_CHARS = 4800;
|
|
63
|
+
|
|
64
|
+
/** Prefix every stub carries, so an already-elided result is never counted as elidable again. */
|
|
65
|
+
export const ELISION_MARKER = "[pi-multi-account context-guard]";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fixed multiplicative safety margin on the chars/4 estimate. chars/4 under-counts code (which
|
|
69
|
+
* tokenizes closer to 3 chars/token), so the guard is deliberately never allowed to believe the
|
|
70
|
+
* context is smaller than the raw estimate.
|
|
71
|
+
*/
|
|
72
|
+
export const SAFETY_SLOPE = 1.1;
|
|
73
|
+
|
|
74
|
+
/** Ceiling on the learned additive overhead, so one bad observation cannot wedge the guard. */
|
|
75
|
+
const MAX_LEARNED_OVERHEAD = 80_000;
|
|
76
|
+
|
|
77
|
+
/** Starting guess for system prompt + tool schemas before any response has been observed. */
|
|
78
|
+
const INITIAL_OVERHEAD = 12_000;
|
|
79
|
+
|
|
80
|
+
export interface GuardMessage {
|
|
81
|
+
role: string;
|
|
82
|
+
[key: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ContentBlock {
|
|
86
|
+
type?: string;
|
|
87
|
+
text?: string;
|
|
88
|
+
thinking?: string;
|
|
89
|
+
name?: string;
|
|
90
|
+
arguments?: unknown;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function contentChars(content: unknown): number {
|
|
94
|
+
if (typeof content === "string") return content.length;
|
|
95
|
+
if (!Array.isArray(content)) return 0;
|
|
96
|
+
let chars = 0;
|
|
97
|
+
for (const block of content as ContentBlock[]) {
|
|
98
|
+
if (block?.type === "text" && typeof block.text === "string") chars += block.text.length;
|
|
99
|
+
else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS;
|
|
100
|
+
}
|
|
101
|
+
return chars;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Token estimate for one message, mirroring Pi's `estimateTokens` so the two agree on what a
|
|
106
|
+
* conversation "costs". Unknown roles contribute 0, exactly as Pi does.
|
|
107
|
+
*/
|
|
108
|
+
export function estimateMessageTokens(message: GuardMessage): number {
|
|
109
|
+
if (!message || typeof message.role !== "string") return 0;
|
|
110
|
+
let chars = 0;
|
|
111
|
+
switch (message.role) {
|
|
112
|
+
case "user":
|
|
113
|
+
case "custom":
|
|
114
|
+
case "toolResult":
|
|
115
|
+
chars = contentChars(message.content);
|
|
116
|
+
break;
|
|
117
|
+
case "assistant": {
|
|
118
|
+
const blocks = Array.isArray(message.content) ? (message.content as ContentBlock[]) : [];
|
|
119
|
+
for (const block of blocks) {
|
|
120
|
+
if (block?.type === "text" && typeof block.text === "string") chars += block.text.length;
|
|
121
|
+
else if (block?.type === "thinking" && typeof block.thinking === "string")
|
|
122
|
+
chars += block.thinking.length;
|
|
123
|
+
else if (block?.type === "toolCall")
|
|
124
|
+
chars += (block.name?.length ?? 0) + JSON.stringify(block.arguments ?? null).length;
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case "bashExecution":
|
|
129
|
+
chars =
|
|
130
|
+
(typeof message.command === "string" ? message.command.length : 0) +
|
|
131
|
+
(typeof message.output === "string" ? message.output.length : 0);
|
|
132
|
+
break;
|
|
133
|
+
case "branchSummary":
|
|
134
|
+
case "compactionSummary":
|
|
135
|
+
chars = typeof message.summary === "string" ? message.summary.length : 0;
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
return Math.ceil(chars / 4);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Raw estimate of everything that goes into the request: the message list plus the system prompt.
|
|
145
|
+
*
|
|
146
|
+
* The system prompt matters. Pi's own fallback estimate (`estimateContextTokens`) sums messages
|
|
147
|
+
* only, so the system prompt and the tool schemas — tens of thousands of tokens — are invisible to
|
|
148
|
+
* it whenever provider usage is missing. Here the system prompt is counted; the tool schemas,
|
|
149
|
+
* which extensions cannot see, are what the learned overhead below is for.
|
|
150
|
+
*/
|
|
151
|
+
export function estimateRawTokens(messages: GuardMessage[], systemPrompt?: string): number {
|
|
152
|
+
let total = 0;
|
|
153
|
+
for (const message of messages) total += estimateMessageTokens(message);
|
|
154
|
+
if (systemPrompt) total += Math.ceil(systemPrompt.length / 4);
|
|
155
|
+
return total;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface OverheadTracker {
|
|
159
|
+
/**
|
|
160
|
+
* Learn from a request we did NOT trim: how far the provider's prompt count sat above our raw
|
|
161
|
+
* estimate. Returns false when the observation is rejected as untrustworthy.
|
|
162
|
+
*/
|
|
163
|
+
observe(rawTokens: number, reportedPromptTokens: number): boolean;
|
|
164
|
+
/** Current learned additive overhead (system prompt residue, tool schemas, tokenizer drift). */
|
|
165
|
+
overhead(): number;
|
|
166
|
+
/** Raw estimate corrected into a conservative "what the provider will actually count". */
|
|
167
|
+
adjust(rawTokens: number): number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Learns one number: how much bigger the real prompt is than our raw estimate.
|
|
172
|
+
*
|
|
173
|
+
* Observations outside [0.5x, 4x] of the raw estimate are thrown away rather than learned from.
|
|
174
|
+
* That is the defence against Cursor's and Codex's server-side counters: when a provider reports
|
|
175
|
+
* the size of ITS copy of the conversation instead of the request we sent, the ratio blows out and
|
|
176
|
+
* the observation is discarded instead of poisoning the model.
|
|
177
|
+
*/
|
|
178
|
+
export function createOverheadTracker(initialOverhead = INITIAL_OVERHEAD): OverheadTracker {
|
|
179
|
+
let overhead = Math.max(0, Math.min(initialOverhead, MAX_LEARNED_OVERHEAD));
|
|
180
|
+
return {
|
|
181
|
+
observe(rawTokens, reportedPromptTokens) {
|
|
182
|
+
if (!(rawTokens > 0) || !(reportedPromptTokens > 0)) return false;
|
|
183
|
+
const ratio = reportedPromptTokens / rawTokens;
|
|
184
|
+
if (ratio < 0.5 || ratio > 4) return false;
|
|
185
|
+
const gap = reportedPromptTokens - rawTokens * SAFETY_SLOPE;
|
|
186
|
+
const clamped = Math.max(0, Math.min(gap, MAX_LEARNED_OVERHEAD));
|
|
187
|
+
overhead = overhead * 0.7 + clamped * 0.3;
|
|
188
|
+
return true;
|
|
189
|
+
},
|
|
190
|
+
overhead() {
|
|
191
|
+
return Math.round(overhead);
|
|
192
|
+
},
|
|
193
|
+
adjust(rawTokens) {
|
|
194
|
+
return Math.ceil(Math.max(0, rawTokens) * SAFETY_SLOPE + overhead);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface ContextGuardSettings {
|
|
200
|
+
enabled: boolean;
|
|
201
|
+
/** Start eliding old tool results once the estimate crosses this share of the usable window. */
|
|
202
|
+
softPercent: number;
|
|
203
|
+
/** Elide down to this share of the usable window. */
|
|
204
|
+
targetPercent: number;
|
|
205
|
+
/** Ask for a real compaction at the next safe boundary above this share. */
|
|
206
|
+
compactPercent: number;
|
|
207
|
+
/** Most recent slice of the conversation that is never elided. */
|
|
208
|
+
keepVerbatimTokens: number;
|
|
209
|
+
/** Do not bother eliding a result smaller than this. */
|
|
210
|
+
minElideTokens: number;
|
|
211
|
+
/**
|
|
212
|
+
* Hard ceiling on a model's advertised window; 0 trusts the advertisement.
|
|
213
|
+
*
|
|
214
|
+
* Needed because the advertisement is often aspirational: claude-opus-5 and kimi k3 declare
|
|
215
|
+
* 1 000 000+, which puts Pi's own threshold at ~1 032 192 — unreachable — and lets a coding
|
|
216
|
+
* session run to 700 k tokens with no compaction and no complaint.
|
|
217
|
+
*/
|
|
218
|
+
maxWindowTokens: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const DEFAULT_CONTEXT_GUARD_SETTINGS: ContextGuardSettings = {
|
|
222
|
+
enabled: true,
|
|
223
|
+
softPercent: 0.75,
|
|
224
|
+
targetPercent: 0.6,
|
|
225
|
+
compactPercent: 0.7,
|
|
226
|
+
keepVerbatimTokens: 40_000,
|
|
227
|
+
minElideTokens: 500,
|
|
228
|
+
maxWindowTokens: 400_000,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** The window the guard actually works against: what the model claims, capped by policy. */
|
|
232
|
+
export function effectiveWindow(declaredWindow: number, settings: ContextGuardSettings): number {
|
|
233
|
+
if (!(declaredWindow > 0)) return 0;
|
|
234
|
+
if (settings.maxWindowTokens > 0) return Math.min(declaredWindow, settings.maxWindowTokens);
|
|
235
|
+
return declaredWindow;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface GuardDecision {
|
|
239
|
+
/** Usable window after capping; 0 when the window is unknown and the guard must stand down. */
|
|
240
|
+
window: number;
|
|
241
|
+
/** Share of the usable window the current estimate occupies. */
|
|
242
|
+
percent: number;
|
|
243
|
+
/** Whether this request should be trimmed before it goes out. */
|
|
244
|
+
trim: boolean;
|
|
245
|
+
/** Size to trim down to. */
|
|
246
|
+
targetTokens: number;
|
|
247
|
+
/** Whether a real compaction should be requested at the next safe boundary. */
|
|
248
|
+
wantCompaction: boolean;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function decideGuard(
|
|
252
|
+
adjustedTokens: number,
|
|
253
|
+
declaredWindow: number,
|
|
254
|
+
settings: ContextGuardSettings,
|
|
255
|
+
): GuardDecision {
|
|
256
|
+
const window = effectiveWindow(declaredWindow, settings);
|
|
257
|
+
if (!settings.enabled || window <= 0) {
|
|
258
|
+
return { window, percent: 0, trim: false, targetTokens: 0, wantCompaction: false };
|
|
259
|
+
}
|
|
260
|
+
const percent = adjustedTokens / window;
|
|
261
|
+
return {
|
|
262
|
+
window,
|
|
263
|
+
percent,
|
|
264
|
+
trim: percent > settings.softPercent,
|
|
265
|
+
targetTokens: Math.floor(window * settings.targetPercent),
|
|
266
|
+
wantCompaction: percent > settings.compactPercent,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface ElisionSettings {
|
|
271
|
+
targetTokens: number;
|
|
272
|
+
keepVerbatimTokens: number;
|
|
273
|
+
minElideTokens: number;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface ElisionResult {
|
|
277
|
+
/** The message list to send. Identical reference to the input when nothing changed. */
|
|
278
|
+
messages: GuardMessage[];
|
|
279
|
+
changed: boolean;
|
|
280
|
+
/** How many messages were elided for the first time in this pass. */
|
|
281
|
+
elidedNow: number;
|
|
282
|
+
/** How many messages are stubbed in the returned list, new and previously elided together. */
|
|
283
|
+
elidedTotal: number;
|
|
284
|
+
/** Raw tokens removed from the request. */
|
|
285
|
+
freedTokens: number;
|
|
286
|
+
/** Raw estimate of the returned list (system prompt not included). */
|
|
287
|
+
tokensAfter: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Stable identity for an elidable message.
|
|
292
|
+
*
|
|
293
|
+
* Deliberately not the array index: `preserveInterruptedTurns` rewrites entries and compaction
|
|
294
|
+
* rebuilds the list wholesale, so an index would silently start pointing at a different message.
|
|
295
|
+
*/
|
|
296
|
+
export function elisionKey(message: GuardMessage): string | undefined {
|
|
297
|
+
if (!message) return undefined;
|
|
298
|
+
if (message.role === "toolResult" && typeof message.toolCallId === "string")
|
|
299
|
+
return `tr:${message.toolCallId}`;
|
|
300
|
+
if (message.role === "bashExecution" && typeof message.timestamp === "number")
|
|
301
|
+
return `bash:${message.timestamp}`;
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function stubText(message: GuardMessage, tokens: number): string {
|
|
306
|
+
const what =
|
|
307
|
+
message.role === "bashExecution"
|
|
308
|
+
? "a `!` bash execution"
|
|
309
|
+
: `a \`${typeof message.toolName === "string" ? message.toolName : "tool"}\` result`;
|
|
310
|
+
return (
|
|
311
|
+
`${ELISION_MARKER} ~${tokens.toLocaleString("en-US")} tokens of ${what} from earlier in this ` +
|
|
312
|
+
`session were dropped from this request to keep it inside the model's context window. ` +
|
|
313
|
+
`The full output is still in the session transcript — re-run or re-read if you need it.`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Replace a message's payload with a one-line stub, keeping every field the protocol needs. */
|
|
318
|
+
function makeStub(message: GuardMessage, tokens: number): GuardMessage | undefined {
|
|
319
|
+
const text = stubText(message, tokens);
|
|
320
|
+
if (message.role === "toolResult") {
|
|
321
|
+
return { ...message, content: [{ type: "text", text }] };
|
|
322
|
+
}
|
|
323
|
+
if (message.role === "bashExecution") {
|
|
324
|
+
return { ...message, output: text, truncated: true };
|
|
325
|
+
}
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isAlreadyStub(message: GuardMessage): boolean {
|
|
330
|
+
if (message.role === "bashExecution")
|
|
331
|
+
return typeof message.output === "string" && message.output.startsWith(ELISION_MARKER);
|
|
332
|
+
if (message.role === "toolResult") {
|
|
333
|
+
const content = message.content;
|
|
334
|
+
if (!Array.isArray(content) || content.length !== 1) return false;
|
|
335
|
+
const block = content[0] as ContentBlock;
|
|
336
|
+
return block?.type === "text" && typeof block.text === "string" && block.text.startsWith(ELISION_MARKER);
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Build the trimmed message list.
|
|
343
|
+
*
|
|
344
|
+
* Two passes on purpose. The first re-applies every key that was elided before — unconditionally,
|
|
345
|
+
* even when the request would already fit — because a request whose prefix keeps changing throws
|
|
346
|
+
* away the provider's prompt cache on every single turn. The second pass adds new elisions,
|
|
347
|
+
* oldest first, and stops the moment the target is met, so no more history is given up than the
|
|
348
|
+
* window actually demands.
|
|
349
|
+
*
|
|
350
|
+
* Messages are never removed and assistant tool calls are never touched: a tool result must stay
|
|
351
|
+
* paired with the call that produced it or providers reject the request outright.
|
|
352
|
+
*/
|
|
353
|
+
export function planElision(
|
|
354
|
+
messages: GuardMessage[],
|
|
355
|
+
elidedKeys: Set<string>,
|
|
356
|
+
settings: ElisionSettings,
|
|
357
|
+
): ElisionResult {
|
|
358
|
+
const sizes = messages.map(estimateMessageTokens);
|
|
359
|
+
let total = 0;
|
|
360
|
+
for (const size of sizes) total += size;
|
|
361
|
+
|
|
362
|
+
// Everything from `firstProtected` onwards is the recent slice the agent is actively working in.
|
|
363
|
+
let tail = 0;
|
|
364
|
+
let firstProtected = messages.length;
|
|
365
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
366
|
+
tail += sizes[i];
|
|
367
|
+
firstProtected = i;
|
|
368
|
+
if (tail >= settings.keepVerbatimTokens) break;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let out: GuardMessage[] | undefined;
|
|
372
|
+
let elidedNow = 0;
|
|
373
|
+
let elidedTotal = 0;
|
|
374
|
+
let freedTokens = 0;
|
|
375
|
+
|
|
376
|
+
const apply = (index: number): boolean => {
|
|
377
|
+
const message = messages[index];
|
|
378
|
+
if (isAlreadyStub(message)) {
|
|
379
|
+
elidedTotal++;
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
const stub = makeStub(message, sizes[index]);
|
|
383
|
+
if (!stub) return false;
|
|
384
|
+
if (!out) out = messages.slice();
|
|
385
|
+
out[index] = stub;
|
|
386
|
+
const delta = sizes[index] - estimateMessageTokens(stub);
|
|
387
|
+
if (delta > 0) {
|
|
388
|
+
total -= delta;
|
|
389
|
+
freedTokens += delta;
|
|
390
|
+
}
|
|
391
|
+
elidedTotal++;
|
|
392
|
+
return true;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// Pass 1 — everything already given up stays given up, so the prefix stays stable.
|
|
396
|
+
for (let i = 0; i < firstProtected; i++) {
|
|
397
|
+
const key = elisionKey(messages[i]);
|
|
398
|
+
if (!key || !elidedKeys.has(key)) continue;
|
|
399
|
+
apply(i);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Pass 2 — give up as little more as the target demands, oldest first.
|
|
403
|
+
for (let i = 0; i < firstProtected && total > settings.targetTokens; i++) {
|
|
404
|
+
const key = elisionKey(messages[i]);
|
|
405
|
+
if (!key || elidedKeys.has(key)) continue;
|
|
406
|
+
if (sizes[i] < settings.minElideTokens) continue;
|
|
407
|
+
if (!apply(i)) continue;
|
|
408
|
+
elidedKeys.add(key);
|
|
409
|
+
elidedNow++;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
messages: out ?? messages,
|
|
414
|
+
changed: out !== undefined,
|
|
415
|
+
elidedNow,
|
|
416
|
+
elidedTotal,
|
|
417
|
+
freedTokens,
|
|
418
|
+
tokensAfter: total,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Netanel Draiman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|