@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.
Files changed (170) hide show
  1. package/README.md +1 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
  5. package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -2
  7. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +12 -0
  8. package/node_modules/@herbertgao/pi-subagents/README.md +427 -120
  9. package/node_modules/@herbertgao/pi-subagents/docs/rpc.md +184 -0
  10. package/node_modules/@herbertgao/pi-subagents/docs/workflows.md +466 -0
  11. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +6 -6
  12. package/node_modules/@herbertgao/pi-subagents/examples/workflows/compose.js +52 -0
  13. package/node_modules/@herbertgao/pi-subagents/examples/workflows/fan-out-audit.js +56 -0
  14. package/node_modules/@herbertgao/pi-subagents/examples/workflows/gated-fix.js +60 -0
  15. package/node_modules/@herbertgao/pi-subagents/examples/workflows/lib/count-child.js +30 -0
  16. package/node_modules/@herbertgao/pi-subagents/examples/workflows/review-panel.js +68 -0
  17. package/node_modules/@herbertgao/pi-subagents/examples/workflows/structured-findings.js +81 -0
  18. package/node_modules/@herbertgao/pi-subagents/package.json +12 -10
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +52 -12
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +837 -146
  21. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +213 -39
  22. package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +73 -14
  23. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +101 -47
  24. package/node_modules/@herbertgao/pi-subagents/src/index.ts +2249 -914
  25. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +13 -0
  26. package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +215 -0
  27. package/node_modules/@herbertgao/pi-subagents/src/mention.ts +147 -0
  28. package/node_modules/@herbertgao/pi-subagents/src/model-resolver.ts +9 -1
  29. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +40 -26
  30. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +18 -8
  31. package/node_modules/@herbertgao/pi-subagents/src/prompts.ts +46 -9
  32. package/node_modules/@herbertgao/pi-subagents/src/schedule.ts +21 -16
  33. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +137 -7
  34. package/node_modules/@herbertgao/pi-subagents/src/structured-output.ts +136 -0
  35. package/node_modules/@herbertgao/pi-subagents/src/types.ts +126 -8
  36. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-mention.ts +274 -0
  37. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +20 -5
  38. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +10 -4
  39. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +167 -22
  40. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-card.ts +555 -0
  41. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-dialog.ts +1304 -0
  42. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-menu.ts +226 -0
  43. package/node_modules/@herbertgao/pi-subagents/src/workflow/collisions.ts +122 -0
  44. package/node_modules/@herbertgao/pi-subagents/src/workflow/entry.ts +47 -0
  45. package/node_modules/@herbertgao/pi-subagents/src/workflow/host.ts +463 -0
  46. package/node_modules/@herbertgao/pi-subagents/src/workflow/journal.ts +164 -0
  47. package/node_modules/@herbertgao/pi-subagents/src/workflow/json-schema.ts +142 -0
  48. package/node_modules/@herbertgao/pi-subagents/src/workflow/meta.ts +401 -0
  49. package/node_modules/@herbertgao/pi-subagents/src/workflow/progress.ts +622 -0
  50. package/node_modules/@herbertgao/pi-subagents/src/workflow/runtime.ts +1399 -0
  51. package/node_modules/@herbertgao/pi-subagents/src/workflow/saved.ts +230 -0
  52. package/node_modules/@herbertgao/pi-subagents/src/workflow/task.ts +333 -0
  53. package/node_modules/@herbertgao/pi-subagents/src/workflow/tool-description.ts +200 -0
  54. package/node_modules/@herbertgao/pi-subagents/src/workflow/worker-source.ts +781 -0
  55. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +97 -95
  56. package/node_modules/@herbertgao/pi-subagents/src/xml.ts +13 -0
  57. package/node_modules/@herbertgao/resume-from/package.json +1 -1
  58. package/node_modules/@herbertgao/sol-pi/README.md +3 -3
  59. package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
  60. package/node_modules/@herbertgao/sol-pi/agents-install.md +4 -4
  61. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +6 -6
  62. package/node_modules/@herbertgao/sol-pi/package.json +2 -2
  63. package/node_modules/@narumitw/pi-btw/dist/index.ts +39 -89
  64. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  65. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  66. package/node_modules/@narumitw/pi-btw/src/btw.ts +28 -87
  67. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +8 -0
  68. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +40 -37
  69. package/node_modules/@narumitw/pi-caffeinate/README.md +21 -66
  70. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +10 -41
  71. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +2 -2
  72. package/node_modules/@narumitw/pi-caffeinate/package.json +50 -51
  73. package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +637 -663
  74. package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +114 -120
  75. package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +29 -29
  76. package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +108 -126
  77. package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +124 -128
  78. package/node_modules/pi-multi-account/CHANGELOG.md +1209 -0
  79. package/node_modules/pi-multi-account/CONTRIBUTING.md +61 -0
  80. package/node_modules/pi-multi-account/LICENSE +21 -0
  81. package/node_modules/pi-multi-account/README.md +197 -0
  82. package/node_modules/pi-multi-account/SECURITY.md +27 -0
  83. package/node_modules/pi-multi-account/auth-file-transaction.ts +56 -0
  84. package/node_modules/pi-multi-account/child-usability.ts +233 -0
  85. package/node_modules/pi-multi-account/compaction-summary.ts +32 -0
  86. package/node_modules/pi-multi-account/completion-route-planner.ts +224 -0
  87. package/node_modules/pi-multi-account/context-guard.ts +420 -0
  88. package/node_modules/pi-multi-account/cursor/LICENSE +21 -0
  89. package/node_modules/pi-multi-account/cursor/NOTICE +2 -0
  90. package/node_modules/pi-multi-account/cursor/auth.ts +165 -0
  91. package/node_modules/pi-multi-account/cursor/bridge-handle.ts +155 -0
  92. package/node_modules/pi-multi-account/cursor/conversation-registry.ts +104 -0
  93. package/node_modules/pi-multi-account/cursor/cursor-models-raw.json +611 -0
  94. package/node_modules/pi-multi-account/cursor/cursor-shared.ts +192 -0
  95. package/node_modules/pi-multi-account/cursor/h2-bridge.mjs +175 -0
  96. package/node_modules/pi-multi-account/cursor/index.ts +572 -0
  97. package/node_modules/pi-multi-account/cursor/message-parsing.ts +323 -0
  98. package/node_modules/pi-multi-account/cursor/prompt-usage.ts +53 -0
  99. package/node_modules/pi-multi-account/cursor/proto/agent_pb.ts +15294 -0
  100. package/node_modules/pi-multi-account/cursor/proxy.ts +2510 -0
  101. package/node_modules/pi-multi-account/cursor/session-lifecycle.ts +40 -0
  102. package/node_modules/pi-multi-account/cursor/sse-keepalive.ts +24 -0
  103. package/node_modules/pi-multi-account/cursor/stream-lifecycle.ts +193 -0
  104. package/node_modules/pi-multi-account/cursor/upstream-watchdog.ts +88 -0
  105. package/node_modules/pi-multi-account/cursor-bridge.ts +240 -0
  106. package/node_modules/pi-multi-account/cursor-model-name.ts +12 -0
  107. package/node_modules/pi-multi-account/index.ts +11825 -0
  108. package/node_modules/pi-multi-account/model-catalog.ts +354 -0
  109. package/node_modules/pi-multi-account/package.json +101 -0
  110. package/node_modules/pi-multi-account/pi-contract.ts +281 -0
  111. package/node_modules/pi-multi-account/provider-payload-stream.ts +44 -0
  112. package/node_modules/pi-multi-account/provider-priority.ts +189 -0
  113. package/node_modules/pi-multi-account/slot-proxy-auth.ts +167 -0
  114. package/node_modules/pi-multi-account/slot-proxy.ts +344 -0
  115. package/node_modules/pi-multi-account/state-file-transaction.ts +67 -0
  116. package/node_modules/pi-multi-account/usage.ts +1099 -0
  117. package/node_modules/pi-typesafe/README.md +6 -2
  118. package/node_modules/pi-typesafe/dist/client.d.ts +11 -0
  119. package/node_modules/pi-typesafe/dist/client.js +45 -10
  120. package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
  121. package/node_modules/pi-typesafe/dist/index.js +1 -1
  122. package/node_modules/pi-typesafe/package.json +2 -2
  123. package/node_modules/pi-web-access/CHANGELOG.md +36 -0
  124. package/node_modules/pi-web-access/README.md +75 -18
  125. package/node_modules/pi-web-access/anysearch.ts +4 -15
  126. package/node_modules/pi-web-access/bocha.ts +3 -22
  127. package/node_modules/pi-web-access/brave.ts +3 -21
  128. package/node_modules/pi-web-access/brightdata.ts +5 -32
  129. package/node_modules/pi-web-access/content-find.ts +168 -53
  130. package/node_modules/pi-web-access/curator-page.ts +4 -1
  131. package/node_modules/pi-web-access/curator-run.ts +2 -1
  132. package/node_modules/pi-web-access/curator-server.ts +1 -0
  133. package/node_modules/pi-web-access/dist/index.js +24620 -0
  134. package/node_modules/pi-web-access/domain-filter-normalization.ts +14 -0
  135. package/node_modules/pi-web-access/duckduckgo.ts +3 -21
  136. package/node_modules/pi-web-access/extract.ts +3 -1
  137. package/node_modules/pi-web-access/firecrawl.ts +5 -29
  138. package/node_modules/pi-web-access/gemini-search.ts +81 -32
  139. package/node_modules/pi-web-access/index.ts +149 -148
  140. package/node_modules/pi-web-access/jina-search.ts +4 -15
  141. package/node_modules/pi-web-access/kagi.ts +4 -13
  142. package/node_modules/pi-web-access/kimi-search.ts +5 -30
  143. package/node_modules/pi-web-access/mistral-search.ts +1 -15
  144. package/node_modules/pi-web-access/ollama.ts +2 -7
  145. package/node_modules/pi-web-access/openai-search.ts +174 -36
  146. package/node_modules/pi-web-access/opencode-session-headers.ts +24 -0
  147. package/node_modules/pi-web-access/package.json +10 -4
  148. package/node_modules/pi-web-access/page-query.ts +10 -2
  149. package/node_modules/pi-web-access/parallel.ts +1 -15
  150. package/node_modules/pi-web-access/pdf-extract.ts +3 -0
  151. package/node_modules/pi-web-access/querit.ts +5 -29
  152. package/node_modules/pi-web-access/search-answer-formatting.ts +11 -0
  153. package/node_modules/pi-web-access/search-result-count-normalization.ts +4 -0
  154. package/node_modules/pi-web-access/search1api.ts +5 -29
  155. package/node_modules/pi-web-access/searchinfinity.ts +5 -29
  156. package/node_modules/pi-web-access/searxng.ts +3 -21
  157. package/node_modules/pi-web-access/serpapi.ts +5 -28
  158. package/node_modules/pi-web-access/serpbase.ts +3 -22
  159. package/node_modules/pi-web-access/serpdive.ts +3 -21
  160. package/node_modules/pi-web-access/serper.ts +5 -28
  161. package/node_modules/pi-web-access/serply.ts +197 -0
  162. package/node_modules/pi-web-access/source-check.ts +11 -47
  163. package/node_modules/pi-web-access/summary-review.ts +7 -3
  164. package/node_modules/pi-web-access/tavily.ts +3 -21
  165. package/node_modules/pi-web-access/tinyfish.ts +5 -29
  166. package/node_modules/pi-web-access/utils.ts +9 -1
  167. package/node_modules/pi-web-access/valyu.ts +5 -28
  168. package/node_modules/pi-web-access/xai-search.ts +1 -15
  169. package/node_modules/pi-web-access/xcrawl.ts +5 -32
  170. 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.
@@ -0,0 +1,2 @@
1
+ Vendored into pi-multi-account from https://github.com/ndraiman/pi-cursor-provider (MIT, Netanel Draiman).
2
+ This is not a separate Pi extension. Cursor subscription support is part of pi-multi-account.