zidane 5.0.6 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -1
- package/dist/{agent-JhicgLOV.d.ts → agent-B0vrSTQ9.d.ts} +162 -4
- package/dist/agent-B0vrSTQ9.d.ts.map +1 -0
- package/dist/chat.d.ts +526 -81
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +2 -2
- package/dist/contexts/docker.d.ts +7 -0
- package/dist/contexts/docker.d.ts.map +1 -0
- package/dist/{contexts-3Arvn7yR.js → contexts/docker.js} +2 -128
- package/dist/contexts/docker.js.map +1 -0
- package/dist/contexts-BwiHIr2w.js +129 -0
- package/dist/contexts-BwiHIr2w.js.map +1 -0
- package/dist/contexts.d.ts +3 -2
- package/dist/contexts.js +2 -2
- package/dist/index-CFxhms_B.d.ts +1303 -0
- package/dist/index-CFxhms_B.d.ts.map +1 -0
- package/dist/index-DYcymRtr.d.ts +26 -0
- package/dist/index-DYcymRtr.d.ts.map +1 -0
- package/dist/{index-t_W9i7Ql.d.ts → index-X6Q9PN_A.d.ts} +3 -3
- package/dist/{index-t_W9i7Ql.d.ts.map → index-X6Q9PN_A.d.ts.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +10 -9
- package/dist/index.js.map +1 -1
- package/dist/login-BiuHyuEh.js +1276 -0
- package/dist/login-BiuHyuEh.js.map +1 -0
- package/dist/{mcp-Dw-fRPVk.js → mcp-BgwK6ySj.js} +184 -9
- package/dist/mcp-BgwK6ySj.js.map +1 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +1 -1
- package/dist/{messages-xaYMMFlb.js → messages-BAFLvH_z.js} +1 -1
- package/dist/{messages-xaYMMFlb.js.map → messages-BAFLvH_z.js.map} +1 -1
- package/dist/{presets-BRFH2qsQ.js → presets-CI8_fyvX.js} +2 -2
- package/dist/{presets-BRFH2qsQ.js.map → presets-CI8_fyvX.js.map} +1 -1
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +1 -1
- package/dist/{providers-BCbdv99U.js → providers-C6-vhaVu.js} +2 -2
- package/dist/{providers-BCbdv99U.js.map → providers-C6-vhaVu.js.map} +1 -1
- package/dist/providers.d.ts +1 -1
- package/dist/providers.js +2 -2
- package/dist/session/sqlite.d.ts +1 -1
- package/dist/{session-791hhrFa.js → session-pS4Vt4dl.js} +1 -1
- package/dist/{session-791hhrFa.js.map → session-pS4Vt4dl.js.map} +1 -1
- package/dist/session.d.ts +1 -1
- package/dist/session.js +2 -2
- package/dist/skills.d.ts +2 -2
- package/dist/{stats-DZIsGqzu.js → stats-DvCtBRwK.js} +1 -1
- package/dist/{stats-DZIsGqzu.js.map → stats-DvCtBRwK.js.map} +1 -1
- package/dist/{theme-C3JHZ5y9.d.ts → theme-CcGLMJrn.d.ts} +732 -20
- package/dist/theme-CcGLMJrn.d.ts.map +1 -0
- package/dist/{tools-CLazLRb4.js → tools-d1yeA6xK.js} +399 -13
- package/dist/tools-d1yeA6xK.js.map +1 -0
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +1 -1
- package/dist/tui.d.ts +421 -39
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +5755 -2367
- package/dist/tui.js.map +1 -1
- package/dist/{turn-operations-BfEh-GER.js → turn-operations-BzOIM6Of.js} +1984 -108
- package/dist/turn-operations-BzOIM6Of.js.map +1 -0
- package/dist/types-Bx_F8jet.js.map +1 -1
- package/dist/{index-CXVvqTQj.d.ts → types-OtrV6LJT.d.ts} +2 -27
- package/dist/types-OtrV6LJT.d.ts.map +1 -0
- package/dist/types.d.ts +4 -3
- package/dist/types.js +1 -1
- package/package.json +5 -1
- package/dist/agent-JhicgLOV.d.ts.map +0 -1
- package/dist/contexts-3Arvn7yR.js.map +0 -1
- package/dist/index-2yLUyTbc.d.ts +0 -430
- package/dist/index-2yLUyTbc.d.ts.map +0 -1
- package/dist/index-CXVvqTQj.d.ts.map +0 -1
- package/dist/mcp-Dw-fRPVk.js.map +0 -1
- package/dist/theme-C3JHZ5y9.d.ts.map +0 -1
- package/dist/tools-CLazLRb4.js.map +0 -1
- package/dist/turn-operations-BfEh-GER.js.map +0 -1
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
import { C as getReadState } from "./tools-d1yeA6xK.js";
|
|
2
|
+
import { t as toolOutputByteLength } from "./types-Bx_F8jet.js";
|
|
3
|
+
import { a as createTolerantClient, o as sseToJsonFetchIfNeeded, s as McpOAuthProvider } from "./mcp-BgwK6ySj.js";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
//#region src/compact/errors.ts
|
|
6
|
+
/**
|
|
7
|
+
* Typed errors thrown by the compaction helper.
|
|
8
|
+
*
|
|
9
|
+
* Lives in its own file so both the runner and the pure messages module
|
|
10
|
+
* can import without circular dependencies.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Raised when the caller's inputs make compaction meaningless before any
|
|
14
|
+
* API call is attempted. Common cases:
|
|
15
|
+
* - empty `turns`
|
|
16
|
+
* - `keepTurns >= turns.length` (no older content to summarize)
|
|
17
|
+
* - `'from'` / `'up_to'` anchor id not found in `turns`
|
|
18
|
+
* - the resolved `toSummarize` slice has no text-bearing content
|
|
19
|
+
*
|
|
20
|
+
* Synchronous — thrown from `compactConversation()` before the provider
|
|
21
|
+
* call so the caller can recover without a network round-trip.
|
|
22
|
+
*/
|
|
23
|
+
var CompactInvalidInputError = class extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "CompactInvalidInputError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Raised when the provider rejects the compaction request with
|
|
31
|
+
* `prompt_too_long` (or an equivalent) and the head-truncation retry
|
|
32
|
+
* budget has been exhausted. Callers can inspect `ptlRetries` to log
|
|
33
|
+
* how far the retry loop got before giving up.
|
|
34
|
+
*/
|
|
35
|
+
var CompactPromptTooLongError = class extends Error {
|
|
36
|
+
ptlRetries;
|
|
37
|
+
constructor(message, ptlRetries) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.ptlRetries = ptlRetries;
|
|
40
|
+
this.name = "CompactPromptTooLongError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/compact/messages.ts
|
|
45
|
+
/**
|
|
46
|
+
* Partition `turns` into `(toSummarize, preserved)` according to `scope`.
|
|
47
|
+
*
|
|
48
|
+
* Throws {@link CompactInvalidInputError} on degenerate inputs so the
|
|
49
|
+
* caller doesn't pay for a doomed provider call:
|
|
50
|
+
* - empty `turns`
|
|
51
|
+
* - `scope: 'tail'` with `keepTurns >= turns.length` (nothing to summarize)
|
|
52
|
+
* - `scope: { from | up_to }` with an anchor id that isn't in `turns`
|
|
53
|
+
* - the resulting `toSummarize` slice contains no text-bearing content
|
|
54
|
+
* (only system turns or empty content)
|
|
55
|
+
*
|
|
56
|
+
* Pure. Returns references to the original `SessionTurn` objects — the
|
|
57
|
+
* caller can compare by identity (=== Object.is) to confirm.
|
|
58
|
+
*/
|
|
59
|
+
function sliceForCompaction(turns, scope, keepTurns) {
|
|
60
|
+
if (turns.length === 0) throw new CompactInvalidInputError("No turns to compact.");
|
|
61
|
+
let toSummarize;
|
|
62
|
+
let preserved;
|
|
63
|
+
if (scope === "full") {
|
|
64
|
+
toSummarize = turns;
|
|
65
|
+
preserved = [];
|
|
66
|
+
} else if (scope === "tail") {
|
|
67
|
+
const keep = Math.max(0, keepTurns);
|
|
68
|
+
if (keep >= turns.length) throw new CompactInvalidInputError(`Nothing to compact: keepTurns (${keep}) covers the entire conversation (${turns.length} turns).`);
|
|
69
|
+
const safeCut = findSafeRoundBoundary(turns, turns.length - keep);
|
|
70
|
+
toSummarize = turns.slice(0, safeCut);
|
|
71
|
+
preserved = turns.slice(safeCut);
|
|
72
|
+
} else if (scope.kind === "from") {
|
|
73
|
+
const idx = turns.findIndex((t) => t.id === scope.turnId);
|
|
74
|
+
if (idx < 0) throw new CompactInvalidInputError(`Anchor turn not found: "${scope.turnId}".`);
|
|
75
|
+
const safeIdx = findSafeRoundBoundary(turns, idx);
|
|
76
|
+
preserved = turns.slice(0, safeIdx);
|
|
77
|
+
toSummarize = turns.slice(safeIdx);
|
|
78
|
+
} else {
|
|
79
|
+
const idx = turns.findIndex((t) => t.id === scope.turnId);
|
|
80
|
+
if (idx < 0) throw new CompactInvalidInputError(`Anchor turn not found: "${scope.turnId}".`);
|
|
81
|
+
const safeCut = findSafeRoundBoundary(turns, idx + 1);
|
|
82
|
+
toSummarize = turns.slice(0, safeCut);
|
|
83
|
+
preserved = turns.slice(safeCut);
|
|
84
|
+
}
|
|
85
|
+
if (toSummarize.length === 0) throw new CompactInvalidInputError("Compaction scope resolved to zero turns.");
|
|
86
|
+
if (!hasTextBearingContent(toSummarize)) throw new CompactInvalidInputError("Compaction scope contains no text-bearing turns to summarize.");
|
|
87
|
+
return {
|
|
88
|
+
toSummarize,
|
|
89
|
+
preserved
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Replace every image block in `turns` with a `[image]` text marker.
|
|
94
|
+
*
|
|
95
|
+
* Covers two shapes:
|
|
96
|
+
* - Top-level `{ type: 'image', ... }` content blocks on user turns.
|
|
97
|
+
* - Image entries inside `tool_result.output` array form (multimodal
|
|
98
|
+
* tool results — e.g. an MCP browser screenshot).
|
|
99
|
+
*
|
|
100
|
+
* Unconditional by design: even on vision-capable models, the summary
|
|
101
|
+
* call doesn't benefit from raw image bytes (the model can't refer to
|
|
102
|
+
* them after the summary lands), and stripping uniformly avoids
|
|
103
|
+
* `prompt_too_long` on image-heavy sessions.
|
|
104
|
+
*
|
|
105
|
+
* Returns a fresh array; input turns / blocks are never mutated.
|
|
106
|
+
*/
|
|
107
|
+
function stripImagesFromTurns(turns) {
|
|
108
|
+
return turns.map((turn) => stripImagesFromTurn(turn));
|
|
109
|
+
}
|
|
110
|
+
function stripImagesFromTurn(turn) {
|
|
111
|
+
let touched = false;
|
|
112
|
+
const nextContent = [];
|
|
113
|
+
for (const block of turn.content) {
|
|
114
|
+
if (block.type === "image") {
|
|
115
|
+
touched = true;
|
|
116
|
+
nextContent.push({
|
|
117
|
+
type: "text",
|
|
118
|
+
text: "[image]"
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (block.type === "tool_result" && Array.isArray(block.output)) {
|
|
123
|
+
const flat = stripImagesFromToolResult(block.output);
|
|
124
|
+
if (flat) {
|
|
125
|
+
touched = true;
|
|
126
|
+
nextContent.push({
|
|
127
|
+
...block,
|
|
128
|
+
output: flat
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
nextContent.push(block);
|
|
134
|
+
}
|
|
135
|
+
return touched ? {
|
|
136
|
+
...turn,
|
|
137
|
+
content: nextContent
|
|
138
|
+
} : turn;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Return a fresh `ToolResultContent[]` with images flattened to `[image]`
|
|
142
|
+
* text placeholders, or `null` when no image blocks were present (caller
|
|
143
|
+
* keeps the original input). Returning a new mutable array — never the
|
|
144
|
+
* input — keeps the `tool_result.output` slot's mutable type contract
|
|
145
|
+
* satisfied without leaky `as`-casts at the call site.
|
|
146
|
+
*/
|
|
147
|
+
function stripImagesFromToolResult(parts) {
|
|
148
|
+
let touched = false;
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const part of parts) if (part.type === "image") {
|
|
151
|
+
touched = true;
|
|
152
|
+
out.push({
|
|
153
|
+
type: "text",
|
|
154
|
+
text: "[image]"
|
|
155
|
+
});
|
|
156
|
+
} else out.push(part);
|
|
157
|
+
return touched ? out : null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Drop the oldest "round" from `turns` and return a fresh array. Used by
|
|
161
|
+
* the PTL retry path to shrink the prompt one round at a time.
|
|
162
|
+
*
|
|
163
|
+
* A round is a contiguous `[user, assistant?, tool_results?]` group. The
|
|
164
|
+
* function walks forward from index 0, advances through the user turn
|
|
165
|
+
* and any trailing assistant + tool-result turns belonging to the same
|
|
166
|
+
* exchange, and returns the remainder.
|
|
167
|
+
*
|
|
168
|
+
* Adjacency-safe: when the oldest user turn carries `tool_result` blocks
|
|
169
|
+
* answering an assistant turn ahead of it (rare — happens during
|
|
170
|
+
* resume), the function keeps walking until the next clean boundary so
|
|
171
|
+
* the resulting array still respects every provider's `tool_use ↔
|
|
172
|
+
* tool_result` adjacency rule.
|
|
173
|
+
*
|
|
174
|
+
* Returns `turns` unchanged when only one round (or less) remains — the
|
|
175
|
+
* caller is expected to interpret that as "cannot shrink further" and
|
|
176
|
+
* give up the retry loop.
|
|
177
|
+
*/
|
|
178
|
+
function truncateHeadForPtlRetry(turns) {
|
|
179
|
+
if (turns.length <= 1) return turns.slice();
|
|
180
|
+
const firstUserIdx = turns.findIndex((t) => t.role === "user");
|
|
181
|
+
if (firstUserIdx < 0) return turns.slice();
|
|
182
|
+
let cursor = firstUserIdx + 1;
|
|
183
|
+
while (cursor < turns.length) {
|
|
184
|
+
const turn = turns[cursor];
|
|
185
|
+
if (turn.role === "assistant") {
|
|
186
|
+
cursor++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (turn.role === "user" && isToolResultsOnlyTurn(turn)) {
|
|
190
|
+
cursor++;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (cursor >= turns.length) return turns.slice();
|
|
196
|
+
return turns.slice(cursor);
|
|
197
|
+
}
|
|
198
|
+
function isToolResultsOnlyTurn(turn) {
|
|
199
|
+
if (turn.content.length === 0) return false;
|
|
200
|
+
return turn.content.every((block) => block.type === "tool_result");
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Walk `proposedCut` backward to the nearest position where splitting at
|
|
204
|
+
* that index produces a round-boundary-clean partition — i.e. neither
|
|
205
|
+
* half breaks the `tool_use ↔ tool_result` adjacency rule that every
|
|
206
|
+
* provider (most strictly Anthropic) enforces.
|
|
207
|
+
*
|
|
208
|
+
* The hazardous case: the proposed cut lands BETWEEN an assistant turn
|
|
209
|
+
* carrying `tool_call` blocks and the user turn carrying the matching
|
|
210
|
+
* `tool_result` blocks. Then `toSummarize` ends with an orphan
|
|
211
|
+
* `tool_use` (provider 400 on the summarization request) AND `preserved`
|
|
212
|
+
* starts with an orphan `tool_result` (provider 400 on the next live
|
|
213
|
+
* agent run against the wire-level cutoff output).
|
|
214
|
+
*
|
|
215
|
+
* Algorithm: keep walking `cut` backward as long as `turns[cut - 1]`
|
|
216
|
+
* is an assistant turn with at least one `tool_call` block. The walk
|
|
217
|
+
* stops when:
|
|
218
|
+
* - we reach `cut = 0` (slice would be empty; caller's existing
|
|
219
|
+
* "scope resolved to zero turns" guard handles it), or
|
|
220
|
+
* - the trailing turn is user-role (clean — model emits no pending
|
|
221
|
+
* tool_use from user turns), or
|
|
222
|
+
* - the trailing turn is assistant text without tool_use (clean —
|
|
223
|
+
* text-only response is a complete round).
|
|
224
|
+
*
|
|
225
|
+
* Returning the adjusted cut over-preserves the tail relative to the
|
|
226
|
+
* caller's request — `keepTurns` is interpreted as the MINIMUM number
|
|
227
|
+
* of turns kept verbatim, not the exact count.
|
|
228
|
+
*/
|
|
229
|
+
function findSafeRoundBoundary(turns, proposedCut) {
|
|
230
|
+
let cut = Math.max(0, Math.min(turns.length, proposedCut));
|
|
231
|
+
while (cut > 0 && hasPendingToolUse(turns[cut - 1])) cut--;
|
|
232
|
+
return cut;
|
|
233
|
+
}
|
|
234
|
+
/** Does this turn end with any unanswered `tool_use` blocks? */
|
|
235
|
+
function hasPendingToolUse(turn) {
|
|
236
|
+
if (turn.role !== "assistant") return false;
|
|
237
|
+
for (const block of turn.content) if (block.type === "tool_call") return true;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
function hasTextBearingContent(turns) {
|
|
241
|
+
for (const turn of turns) {
|
|
242
|
+
if (turn.role === "system") continue;
|
|
243
|
+
for (const block of turn.content) {
|
|
244
|
+
if (block.type === "text" && block.text.trim().length > 0) return true;
|
|
245
|
+
if (block.type === "tool_call" || block.type === "tool_result") return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Maximum length of an anchor turn's textual preview, in characters. Long
|
|
252
|
+
* enough to give the model recognizable context (the first paragraph of
|
|
253
|
+
* a typical user message), short enough that it doesn't blow the
|
|
254
|
+
* cache-stability invariant for the prompt prefix.
|
|
255
|
+
*/
|
|
256
|
+
const ANCHOR_PREVIEW_MAX_CHARS = 200;
|
|
257
|
+
/**
|
|
258
|
+
* Extract the first ~200 chars of text-bearing content from a turn — the
|
|
259
|
+
* preview surfaced in `from` / `up_to` direction prompts so the model
|
|
260
|
+
* knows where the slice begins.
|
|
261
|
+
*/
|
|
262
|
+
function anchorPreviewFor(turn) {
|
|
263
|
+
for (const block of turn.content) if (block.type === "text" && block.text.trim().length > 0) {
|
|
264
|
+
const flat = block.text.replace(/\s+/g, " ").trim();
|
|
265
|
+
return flat.length > 200 ? `${flat.slice(0, 199)}…` : flat;
|
|
266
|
+
}
|
|
267
|
+
return "(no preview available)";
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Build a synthetic `SessionTurn` carrying a single `compact-summary`
|
|
271
|
+
* block, ready to append to a session.
|
|
272
|
+
*
|
|
273
|
+
* The turn's role is `'user'` so it sits at a conversational boundary
|
|
274
|
+
* the way the model expects. The caller is responsible for
|
|
275
|
+
* `session.appendTurns([turn])`. The id is freshly generated via
|
|
276
|
+
* `crypto.randomUUID()` so collisions are statistically impossible.
|
|
277
|
+
*
|
|
278
|
+
* Typical use after running `compactConversation`:
|
|
279
|
+
*
|
|
280
|
+
* ```ts
|
|
281
|
+
* const result = await compactConversation({ provider, turns })
|
|
282
|
+
* const turn = summaryToTurn({
|
|
283
|
+
* summary: result.summary,
|
|
284
|
+
* replacesTurnIds: result.summarizedTurnIds,
|
|
285
|
+
* model: result.model,
|
|
286
|
+
* usage: result.usage,
|
|
287
|
+
* })
|
|
288
|
+
* await session.appendTurns([turn])
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
function summaryToTurn(input) {
|
|
292
|
+
const compactedAt = input.compactedAt ?? Date.now();
|
|
293
|
+
return {
|
|
294
|
+
id: crypto.randomUUID(),
|
|
295
|
+
role: "user",
|
|
296
|
+
content: [{
|
|
297
|
+
type: "compact-summary",
|
|
298
|
+
replacesTurnIds: input.replacesTurnIds,
|
|
299
|
+
summary: input.summary,
|
|
300
|
+
model: input.model,
|
|
301
|
+
usage: input.usage,
|
|
302
|
+
compactedAt
|
|
303
|
+
}],
|
|
304
|
+
createdAt: compactedAt
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/compact/prompt.ts
|
|
309
|
+
/**
|
|
310
|
+
* No-tools guard. The runner sends `tools: []` to the provider already,
|
|
311
|
+
* but some models still hallucinate tool-call intent on a long
|
|
312
|
+
* conversation. The prose guard is cheap insurance.
|
|
313
|
+
*/
|
|
314
|
+
const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
|
|
315
|
+
|
|
316
|
+
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
|
|
317
|
+
- You already have all the context you need in the conversation above.
|
|
318
|
+
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
|
|
319
|
+
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.`;
|
|
320
|
+
/**
|
|
321
|
+
* Body shared by every direction. Lays out the 9-section scaffold,
|
|
322
|
+
* mirrors Claude Code's `BASE_COMPACT_PROMPT` so a model already trained
|
|
323
|
+
* on the layout produces the same shape.
|
|
324
|
+
*/
|
|
325
|
+
const BASE_INSTRUCTIONS = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.
|
|
326
|
+
|
|
327
|
+
This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context.
|
|
328
|
+
|
|
329
|
+
Before providing your final summary, wrap your analysis in <analysis> tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process:
|
|
330
|
+
|
|
331
|
+
1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify:
|
|
332
|
+
- The user's explicit requests and intents
|
|
333
|
+
- Your approach to addressing the user's requests
|
|
334
|
+
- Key decisions, technical concepts and code patterns
|
|
335
|
+
- Specific details like file names, full code snippets, function signatures, file edits
|
|
336
|
+
- Errors that you ran into and how you fixed them
|
|
337
|
+
- Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.
|
|
338
|
+
2. Double-check for technical accuracy and completeness.
|
|
339
|
+
|
|
340
|
+
Your summary, wrapped in <summary> tags, must include the following sections:
|
|
341
|
+
|
|
342
|
+
1. Primary Request and Intent
|
|
343
|
+
2. Key Technical Concepts
|
|
344
|
+
3. Files and Code Sections (with paths; include code snippets only when load-bearing)
|
|
345
|
+
4. Errors and fixes
|
|
346
|
+
5. Problem Solving
|
|
347
|
+
6. All user messages (list ALL non-tool user messages, verbatim)
|
|
348
|
+
7. Pending Tasks
|
|
349
|
+
8. Current Work
|
|
350
|
+
9. Optional Next Step (include direct quotes from the most recent conversation when relevant)`;
|
|
351
|
+
/** Trailer prompting the model to begin. Same on every direction. */
|
|
352
|
+
const TRAILER = "Provide your <analysis> and <summary> now.";
|
|
353
|
+
const FULL_BLURB = `## Scope
|
|
354
|
+
The conversation above is being summarized in full. Capture every section the user might need to resume from a fresh context.`;
|
|
355
|
+
const TAIL_BLURB = `## Scope
|
|
356
|
+
Summarize the conversation above. The most recent turns will be preserved verbatim alongside your summary, so prioritize older context that would otherwise be lost.`;
|
|
357
|
+
const FROM_BLURB = `## Scope
|
|
358
|
+
Summarize the conversation FROM the marked anchor onward (the recent portion). Everything before the anchor will be preserved verbatim.
|
|
359
|
+
|
|
360
|
+
Anchor turn (preview):
|
|
361
|
+
%ANCHOR_PREVIEW%`;
|
|
362
|
+
const UP_TO_BLURB = `## Scope
|
|
363
|
+
Summarize the conversation UP TO the marked anchor (the older portion). Everything from the anchor onward will be preserved verbatim — your summary's job is to compress the prior context the user can no longer scroll back to.
|
|
364
|
+
|
|
365
|
+
Anchor turn (preview):
|
|
366
|
+
%ANCHOR_PREVIEW%`;
|
|
367
|
+
/** Compose the full prompt with a custom direction-blurb. Internal helper. */
|
|
368
|
+
function compose(blurb) {
|
|
369
|
+
return [
|
|
370
|
+
NO_TOOLS_PREAMBLE,
|
|
371
|
+
BASE_INSTRUCTIONS,
|
|
372
|
+
blurb,
|
|
373
|
+
TRAILER
|
|
374
|
+
].join("\n\n");
|
|
375
|
+
}
|
|
376
|
+
function buildFullCompactPrompt() {
|
|
377
|
+
return compose(FULL_BLURB);
|
|
378
|
+
}
|
|
379
|
+
function buildTailCompactPrompt() {
|
|
380
|
+
return compose(TAIL_BLURB);
|
|
381
|
+
}
|
|
382
|
+
function buildFromCompactPrompt(anchorPreview) {
|
|
383
|
+
return compose(FROM_BLURB.replace("%ANCHOR_PREVIEW%", anchorPreview));
|
|
384
|
+
}
|
|
385
|
+
function buildUpToCompactPrompt(anchorPreview) {
|
|
386
|
+
return compose(UP_TO_BLURB.replace("%ANCHOR_PREVIEW%", anchorPreview));
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Default public builder. Dispatches by direction to the four named
|
|
390
|
+
* builders. Throws when `from` / `up_to` are passed without an
|
|
391
|
+
* `anchorPreview` — those scopes only make sense with an anchor and a
|
|
392
|
+
* silent fallback would produce a prompt that doesn't tell the model
|
|
393
|
+
* where the slice begins.
|
|
394
|
+
*/
|
|
395
|
+
const buildCompactPrompt = (opts) => {
|
|
396
|
+
switch (opts.direction) {
|
|
397
|
+
case "full": return buildFullCompactPrompt();
|
|
398
|
+
case "tail": return buildTailCompactPrompt();
|
|
399
|
+
case "from": {
|
|
400
|
+
const preview = opts.anchorPreview ?? "";
|
|
401
|
+
if (preview.length === 0) throw new Error("buildCompactPrompt: `anchorPreview` is required for direction \"from\".");
|
|
402
|
+
return buildFromCompactPrompt(preview);
|
|
403
|
+
}
|
|
404
|
+
case "up_to": {
|
|
405
|
+
const preview = opts.anchorPreview ?? "";
|
|
406
|
+
if (preview.length === 0) throw new Error("buildCompactPrompt: `anchorPreview` is required for direction \"up_to\".");
|
|
407
|
+
return buildUpToCompactPrompt(preview);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region src/compact/utils.ts
|
|
413
|
+
/**
|
|
414
|
+
* Shared utilities for the compact module — extracted so the runner
|
|
415
|
+
* (`compact.ts`) and the restoration helper (`restore.ts`) don't carry
|
|
416
|
+
* redundant copies of the same low-level math.
|
|
417
|
+
*
|
|
418
|
+
* Kept zero-dependency by design: no Node/Bun imports, no zidane types
|
|
419
|
+
* either. Both `utf8ByteLength` and `estimateTokens` are total
|
|
420
|
+
* functions of a single `string` argument, so they're trivially
|
|
421
|
+
* portable to a worker/browser context if the compact module ever
|
|
422
|
+
* needs to render outside Node.
|
|
423
|
+
*/
|
|
424
|
+
/**
|
|
425
|
+
* UTF-8 byte length, matching `Buffer.byteLength(text, 'utf-8')` but
|
|
426
|
+
* without pulling `node:buffer` for a one-liner. Stable for surrogate
|
|
427
|
+
* pairs — a high+low surrogate pair counts as a single 4-byte sequence
|
|
428
|
+
* (not 2 × 3-byte). Cheap to call in hot loops.
|
|
429
|
+
*
|
|
430
|
+
* Pure. Identical bytes for identical input across every JS runtime.
|
|
431
|
+
*/
|
|
432
|
+
function utf8ByteLength(text) {
|
|
433
|
+
let bytes = 0;
|
|
434
|
+
for (let i = 0; i < text.length; i++) {
|
|
435
|
+
const code = text.charCodeAt(i);
|
|
436
|
+
if (code < 128) bytes += 1;
|
|
437
|
+
else if (code < 2048) bytes += 2;
|
|
438
|
+
else if (code >= 55296 && code <= 56319) {
|
|
439
|
+
bytes += 4;
|
|
440
|
+
i++;
|
|
441
|
+
} else bytes += 3;
|
|
442
|
+
}
|
|
443
|
+
return bytes;
|
|
444
|
+
}
|
|
445
|
+
/** Same constant Claude Code's `CONTEXT_MANAGEMENT.md` documents. */
|
|
446
|
+
const BYTES_PER_TOKEN = 4;
|
|
447
|
+
/**
|
|
448
|
+
* Approximate token count for `text`. Mirrors Claude Code's
|
|
449
|
+
* `BYTES_PER_TOKEN = 4` heuristic — acceptable for budget arithmetic
|
|
450
|
+
* (sizing summarization scopes, enforcing per-file caps in restoration).
|
|
451
|
+
*
|
|
452
|
+
* Accurate to ~10% on English + code. Use a real tokenizer when exact
|
|
453
|
+
* counts matter (cost reporting, hard caps); this is for budget gates
|
|
454
|
+
* where being off by 10% is fine.
|
|
455
|
+
*
|
|
456
|
+
* Pure. Exported so callers (TUI, SDK consumers) can do their own
|
|
457
|
+
* pre-budgeting against the same heuristic the harness uses internally.
|
|
458
|
+
*/
|
|
459
|
+
function estimateTokens(text) {
|
|
460
|
+
return Math.ceil(text.length / 4);
|
|
461
|
+
}
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/compact/compact.ts
|
|
464
|
+
/** Default `keepTurns` for `scope: 'tail'`. Matches `AgentBehavior.compactKeepTurns`. */
|
|
465
|
+
const DEFAULT_KEEP_TURNS = 4;
|
|
466
|
+
/** Default max output tokens for the summary call. */
|
|
467
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 2e4;
|
|
468
|
+
/** Default PTL retry budget. */
|
|
469
|
+
const DEFAULT_MAX_PTL_RETRIES = 3;
|
|
470
|
+
/** Maximum transient-error retries before giving up. Independent of PTL. */
|
|
471
|
+
const TRANSIENT_RETRY_BUDGET = 2;
|
|
472
|
+
async function compactConversation(opts) {
|
|
473
|
+
const slice = sliceForCompaction(opts.turns, opts.scope ?? "tail", opts.keepTurns ?? DEFAULT_KEEP_TURNS);
|
|
474
|
+
const direction = scopeToDirection(opts.scope ?? "tail");
|
|
475
|
+
const anchorPreview = direction === "from" || direction === "up_to" ? anchorPreviewFor(anchorTurnFor(slice, opts.scope)) : void 0;
|
|
476
|
+
const systemPrompt = (opts.prompt ?? buildCompactPrompt)({
|
|
477
|
+
direction,
|
|
478
|
+
...anchorPreview !== void 0 ? { anchorPreview } : {}
|
|
479
|
+
});
|
|
480
|
+
const model = opts.model ?? opts.provider.meta.defaultModel;
|
|
481
|
+
const maxOutputTokens = opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
482
|
+
const maxPtlRetries = Math.max(0, opts.maxPtlRetries ?? DEFAULT_MAX_PTL_RETRIES);
|
|
483
|
+
let workingTurns = stripImagesFromTurns(slice.toSummarize);
|
|
484
|
+
let ptlRetries = 0;
|
|
485
|
+
let transientRetries = 0;
|
|
486
|
+
let attempt = 0;
|
|
487
|
+
while (true) {
|
|
488
|
+
attempt++;
|
|
489
|
+
const kind = ptlRetries > 0 ? "ptl-retry" : transientRetries > 0 ? "transient-retry" : "initial";
|
|
490
|
+
opts.onAttempt?.({
|
|
491
|
+
attempt,
|
|
492
|
+
kind
|
|
493
|
+
});
|
|
494
|
+
try {
|
|
495
|
+
const messages = ensureEndsWithUserMessage(turnsToMessages(workingTurns), opts.provider);
|
|
496
|
+
const { summary, usage } = await runOnce({
|
|
497
|
+
provider: opts.provider,
|
|
498
|
+
model,
|
|
499
|
+
system: systemPrompt,
|
|
500
|
+
messages,
|
|
501
|
+
maxTokens: maxOutputTokens,
|
|
502
|
+
...opts.thinking !== void 0 ? { thinking: opts.thinking } : {},
|
|
503
|
+
...opts.signal !== void 0 ? { signal: opts.signal } : {}
|
|
504
|
+
});
|
|
505
|
+
const workingIds = new Set(workingTurns.map((t) => t.id));
|
|
506
|
+
const droppedDueToPtl = [];
|
|
507
|
+
if (ptlRetries > 0) {
|
|
508
|
+
for (const t of slice.toSummarize) if (!workingIds.has(t.id)) droppedDueToPtl.push(t.id);
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
summary,
|
|
512
|
+
usage: {
|
|
513
|
+
...usage,
|
|
514
|
+
modelId: usage.modelId ?? model
|
|
515
|
+
},
|
|
516
|
+
model,
|
|
517
|
+
ptlRetries,
|
|
518
|
+
summarizedTurnIds: workingTurns.map((t) => t.id),
|
|
519
|
+
droppedDueToPtl,
|
|
520
|
+
preservedTurns: slice.preserved,
|
|
521
|
+
beforeBytes: bytesIn(workingTurns),
|
|
522
|
+
afterBytes: utf8ByteLength(summary)
|
|
523
|
+
};
|
|
524
|
+
} catch (err) {
|
|
525
|
+
if (isAbortError(err, opts.signal)) throw err;
|
|
526
|
+
if (isPromptTooLongError(err)) {
|
|
527
|
+
if (ptlRetries >= maxPtlRetries) throw new CompactPromptTooLongError(`Compaction failed: prompt_too_long after ${ptlRetries} retries.`, ptlRetries);
|
|
528
|
+
const truncated = truncateHeadForPtlRetry(workingTurns);
|
|
529
|
+
if (truncated.length === workingTurns.length) throw new CompactPromptTooLongError(`Compaction failed: prompt_too_long and conversation cannot be shrunk further.`, ptlRetries);
|
|
530
|
+
workingTurns = truncated;
|
|
531
|
+
ptlRetries++;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (isTransientError(err) && transientRetries < TRANSIENT_RETRY_BUDGET) {
|
|
535
|
+
transientRetries++;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
throw err;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Single provider call. Strips the `<analysis>` block and returns the
|
|
544
|
+
* post-trim summary text along with the usage report.
|
|
545
|
+
*
|
|
546
|
+
* Why not just use `result.text`: some providers stream deltas without
|
|
547
|
+
* emitting a concatenated `text` field at the end (or set it to an
|
|
548
|
+
* empty string when the stream finished via tool-call). Accumulating
|
|
549
|
+
* deltas in `text` and falling back to `result.text` mirrors
|
|
550
|
+
* `generateSessionTitle` and handles every adapter shape.
|
|
551
|
+
*/
|
|
552
|
+
async function runOnce(opts) {
|
|
553
|
+
let streamed = "";
|
|
554
|
+
const result = await opts.provider.stream({
|
|
555
|
+
model: opts.model,
|
|
556
|
+
system: opts.system,
|
|
557
|
+
tools: [],
|
|
558
|
+
messages: opts.messages,
|
|
559
|
+
maxTokens: opts.maxTokens,
|
|
560
|
+
...opts.thinking !== void 0 ? { thinking: opts.thinking } : {},
|
|
561
|
+
...opts.signal !== void 0 ? { signal: opts.signal } : {}
|
|
562
|
+
}, { onText: (delta) => {
|
|
563
|
+
streamed += delta;
|
|
564
|
+
} });
|
|
565
|
+
let summary = stripAnalysisBlock(streamed.length > 0 ? streamed : result.text).trim();
|
|
566
|
+
if (summary.length === 0) {
|
|
567
|
+
const thinking = collectThinkingText(result.assistantMessage);
|
|
568
|
+
if (thinking.length > 0) summary = stripAnalysisBlock(thinking).trim();
|
|
569
|
+
}
|
|
570
|
+
if (summary.length === 0) throw new Error("Compaction failed: provider returned no summary text.");
|
|
571
|
+
return {
|
|
572
|
+
summary,
|
|
573
|
+
usage: result.usage
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Concatenate every `thinking`-block's text from an assistant message.
|
|
578
|
+
*
|
|
579
|
+
* Reasoning models route their output through provider-specific
|
|
580
|
+
* "thinking" channels (Anthropic native thinking, OpenAI o-series
|
|
581
|
+
* reasoning, OpenAI-compat `reasoning_content`, OpenRouter
|
|
582
|
+
* `reasoning_details`). All of them surface as `{ type: 'thinking', text }`
|
|
583
|
+
* blocks on the normalized `SessionMessage.content` by the time the
|
|
584
|
+
* provider's `stream()` returns. Walking the blocks is provider-agnostic
|
|
585
|
+
* — works for every adapter without bespoke per-provider handling.
|
|
586
|
+
*
|
|
587
|
+
* Returns the empty string when no thinking blocks are present, so the
|
|
588
|
+
* caller's "did we get anything?" check stays a single `.length === 0`.
|
|
589
|
+
*/
|
|
590
|
+
function collectThinkingText(message) {
|
|
591
|
+
const parts = [];
|
|
592
|
+
for (const block of message.content) if (block.type === "thinking" && typeof block.text === "string" && block.text.length > 0) parts.push(block.text);
|
|
593
|
+
return parts.join("\n");
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Extract the summary text from the provider's response, peeling off
|
|
597
|
+
* any envelope the model wrapped it in.
|
|
598
|
+
*
|
|
599
|
+
* The compact prompt asks for `<analysis>...</analysis><summary>...</summary>`
|
|
600
|
+
* but real models drift from the format. This function tries four
|
|
601
|
+
* extraction paths in order of strictness:
|
|
602
|
+
*
|
|
603
|
+
* 1. **Strict path** — strip `<analysis>...</analysis>` blocks and
|
|
604
|
+
* extract the `<summary>...</summary>` envelope. Matches the
|
|
605
|
+
* prompt-following ideal.
|
|
606
|
+
* 2. **Loose path** — same strip, but accept whatever's outside the
|
|
607
|
+
* `<analysis>` tags as the summary (no `<summary>` envelope
|
|
608
|
+
* required). Handles models that drop the wrapper but keep the
|
|
609
|
+
* analysis.
|
|
610
|
+
* 3. **Analysis-as-summary fallback** — when the entire response is a
|
|
611
|
+
* single `<analysis>` block (model conflated the two concepts),
|
|
612
|
+
* return the analysis content. Better than failing the compaction
|
|
613
|
+
* and forcing the user to retry.
|
|
614
|
+
* 4. **Raw passthrough** — no recognized envelope. Return the text
|
|
615
|
+
* as-is and let `runOnce`'s empty-check decide whether to throw.
|
|
616
|
+
*
|
|
617
|
+
* Matches Claude Code's `formatCompactSummary` for path (1) + (2) and
|
|
618
|
+
* adds (3) as a graceful-degradation layer we ran into in the wild
|
|
619
|
+
* (smaller / non-Anthropic models sometimes produce analysis-only).
|
|
620
|
+
*/
|
|
621
|
+
function stripAnalysisBlock(text) {
|
|
622
|
+
const analysisStripped = text.replace(/<analysis>[\s\S]*?<\/analysis>/g, "");
|
|
623
|
+
const summaryMatch = analysisStripped.match(/<summary>([\s\S]*?)<\/summary>/);
|
|
624
|
+
if (summaryMatch) return summaryMatch[1];
|
|
625
|
+
if (analysisStripped.trim().length > 0) return analysisStripped;
|
|
626
|
+
const analysisMatch = text.match(/<analysis>([\s\S]*?)<\/analysis>/);
|
|
627
|
+
if (analysisMatch) return analysisMatch[1];
|
|
628
|
+
return text;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Convert turns into the wire-level `SessionMessage[]` shape. Drops
|
|
632
|
+
* system turns (rare; they'd confuse a summary call), and inlines any
|
|
633
|
+
* pre-existing `compact-summary` markers as plain text so the
|
|
634
|
+
* provider's wire converter doesn't have to know about zidane's
|
|
635
|
+
* internal block type.
|
|
636
|
+
*
|
|
637
|
+
* Inlining (instead of dropping) is intentional: a session already
|
|
638
|
+
* compacted once still contains the prior summary as load-bearing
|
|
639
|
+
* context. Surfacing it as `[Previous compaction summary]\n…` lets the
|
|
640
|
+
* new summarization integrate it instead of forgetting it.
|
|
641
|
+
*/
|
|
642
|
+
function turnsToMessages(turns) {
|
|
643
|
+
const out = [];
|
|
644
|
+
for (const turn of turns) {
|
|
645
|
+
if (turn.role === "system") continue;
|
|
646
|
+
const content = [];
|
|
647
|
+
for (const block of turn.content) {
|
|
648
|
+
if (block.type === "compact-summary") {
|
|
649
|
+
content.push({
|
|
650
|
+
type: "text",
|
|
651
|
+
text: `[Previous compaction summary]\n${block.summary}`
|
|
652
|
+
});
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
content.push(block);
|
|
656
|
+
}
|
|
657
|
+
if (content.length === 0) continue;
|
|
658
|
+
out.push({
|
|
659
|
+
role: turn.role,
|
|
660
|
+
content
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Directive appended as the trailing user message when the conversation
|
|
667
|
+
* to summarize ends with an assistant turn. Mirrors the system-prompt
|
|
668
|
+
* TRAILER text so the model gets the same imperative cue from both
|
|
669
|
+
* surfaces — system instruction + final user message.
|
|
670
|
+
*/
|
|
671
|
+
const SUMMARY_USER_DIRECTIVE = "Provide your <analysis> and <summary> now.";
|
|
672
|
+
/**
|
|
673
|
+
* Guarantee the message list ends with a `role: 'user'` message.
|
|
674
|
+
*
|
|
675
|
+
* Why: Anthropic's API (and several OpenAI-compat reasoning models)
|
|
676
|
+
* rejects requests whose final message is `role: 'assistant'` with
|
|
677
|
+
* "This model does not support assistant message prefill. The
|
|
678
|
+
* conversation must end with a user message." Compaction is the
|
|
679
|
+
* common trigger — it runs against the session's existing history,
|
|
680
|
+
* which most often ends with an assistant turn (the agent just
|
|
681
|
+
* responded). Auto-compact fires immediately after `agent.run()`
|
|
682
|
+
* returns, which makes the assistant-tail case the rule, not the
|
|
683
|
+
* exception.
|
|
684
|
+
*
|
|
685
|
+
* Empty input passes through unchanged so the surrounding code can
|
|
686
|
+
* keep its own "nothing to summarize" guard upstream; we don't
|
|
687
|
+
* synthesize a user message into thin air. Otherwise: append a user
|
|
688
|
+
* message containing the same directive the system prompt's TRAILER
|
|
689
|
+
* carries, constructed via `provider.userMessage()` so each provider's
|
|
690
|
+
* native message shape is honored.
|
|
691
|
+
*/
|
|
692
|
+
function ensureEndsWithUserMessage(messages, provider) {
|
|
693
|
+
if (messages.length === 0) return messages;
|
|
694
|
+
if (messages[messages.length - 1].role === "user") return messages;
|
|
695
|
+
return [...messages, provider.userMessage(SUMMARY_USER_DIRECTIVE)];
|
|
696
|
+
}
|
|
697
|
+
function scopeToDirection(scope) {
|
|
698
|
+
if (scope === "full" || scope === "tail") return scope;
|
|
699
|
+
return scope.kind;
|
|
700
|
+
}
|
|
701
|
+
function anchorTurnFor(slice, scope) {
|
|
702
|
+
if (typeof scope === "string") throw new Error("anchorTurnFor: scope must be object form");
|
|
703
|
+
if (scope.kind === "from") return slice.toSummarize[0];
|
|
704
|
+
return slice.toSummarize[slice.toSummarize.length - 1];
|
|
705
|
+
}
|
|
706
|
+
function bytesIn(turns) {
|
|
707
|
+
let total = 0;
|
|
708
|
+
for (const turn of turns) for (const block of turn.content) if (block.type === "text") total += utf8ByteLength(block.text);
|
|
709
|
+
else if (block.type === "tool_result") total += toolOutputByteLength(block.output);
|
|
710
|
+
else if (block.type === "tool_call") total += utf8ByteLength(JSON.stringify(block.input));
|
|
711
|
+
else if (block.type === "thinking") total += utf8ByteLength(block.text);
|
|
712
|
+
return total;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Provider-agnostic predicate for the "prompt is too long" rejection.
|
|
716
|
+
* Inspects error code, type, status, and message substring — every
|
|
717
|
+
* provider names this case differently but the message is recognizable.
|
|
718
|
+
*/
|
|
719
|
+
function isPromptTooLongError(err) {
|
|
720
|
+
if (!err || typeof err !== "object") return false;
|
|
721
|
+
const e = err;
|
|
722
|
+
if (typeof e.code === "string" && /prompt[_ ]too[_ ]long/i.test(e.code)) return true;
|
|
723
|
+
if (typeof e.status === "number" && (e.status === 413 || e.status === 400)) {
|
|
724
|
+
const message = typeof e.message === "string" ? e.message : "";
|
|
725
|
+
if (/prompt[_ ]too[_ ]long|context[_ ]length|maximum[_ ]context|too many tokens/i.test(message)) return true;
|
|
726
|
+
}
|
|
727
|
+
if (typeof e.message === "string" && /prompt[_ ]too[_ ]long|context[_ ]length[_ ]exceeded|context window/i.test(e.message)) return true;
|
|
728
|
+
const nested = e.error ?? {};
|
|
729
|
+
if (typeof nested.type === "string" && nested.type === "invalid_request_error") {
|
|
730
|
+
const message = typeof nested.message === "string" ? nested.message : "";
|
|
731
|
+
if (/prompt[_ ]too[_ ]long|too long|context window/i.test(message)) return true;
|
|
732
|
+
}
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Transient network / 5xx errors worth retrying once or twice.
|
|
737
|
+
*/
|
|
738
|
+
function isTransientError(err) {
|
|
739
|
+
if (!err || typeof err !== "object") return false;
|
|
740
|
+
const e = err;
|
|
741
|
+
if (typeof e.status === "number" && e.status >= 500 && e.status < 600) return true;
|
|
742
|
+
if (typeof e.code === "string" && /ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN|fetch failed/i.test(e.code)) return true;
|
|
743
|
+
if (typeof e.message === "string" && /socket hang up|fetch failed|network error|terminated|ECONNRESET|read ETIMEDOUT/i.test(e.message)) return true;
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
function isAbortError(err, signal) {
|
|
747
|
+
if (signal?.aborted) return true;
|
|
748
|
+
if (!err || typeof err !== "object") return false;
|
|
749
|
+
const e = err;
|
|
750
|
+
if (e.name === "AbortError") return true;
|
|
751
|
+
if (typeof e.message === "string" && /aborted/i.test(e.message)) return true;
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
//#endregion
|
|
755
|
+
//#region src/compact/restore.ts
|
|
756
|
+
const DEFAULT_FILE_TOKEN_BUDGET = 5e4;
|
|
757
|
+
const DEFAULT_FILE_TOKEN_PER_FILE_CAP = 5e3;
|
|
758
|
+
const DEFAULT_MAX_FILES_TO_RESTORE = 5;
|
|
759
|
+
const DEFAULT_SKILL_TOKEN_BUDGET = 25e3;
|
|
760
|
+
const DEFAULT_SKILL_TOKEN_PER_SKILL_CAP = 5e3;
|
|
761
|
+
/** Default canonical tool names — `rewriteMessagesToWire` handles aliasing. */
|
|
762
|
+
const DEFAULT_READ_FILE_TOOL_NAME = "read_file";
|
|
763
|
+
const DEFAULT_SKILLS_USE_TOOL_NAME = "skills_use";
|
|
764
|
+
/**
|
|
765
|
+
* Convert a raw read-state map into a deduped, path-ranked list ready for
|
|
766
|
+
* restoration. Multiple entries for the same path (different
|
|
767
|
+
* `(offset, limit, maxBytes)` slices) collapse to one — keeping the most
|
|
768
|
+
* recent `mtimeMs`.
|
|
769
|
+
*
|
|
770
|
+
* Filters out entries whose key doesn't share the given `cwd` prefix:
|
|
771
|
+
* those came from a different execution context and can't be read back
|
|
772
|
+
* through this agent's handle.
|
|
773
|
+
*
|
|
774
|
+
* Pure. Most callers want {@link selectFilesFromSession}, which wraps
|
|
775
|
+
* `getReadState(session)` + this function in one call so the host
|
|
776
|
+
* doesn't have to reach into the tools layer.
|
|
777
|
+
*/
|
|
778
|
+
function selectFilesFromReadState(readState, cwd) {
|
|
779
|
+
const prefix = `${cwd}::`;
|
|
780
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
781
|
+
for (const [key, entry] of readState) {
|
|
782
|
+
if (!key.startsWith(prefix)) continue;
|
|
783
|
+
const path = key.slice(prefix.length);
|
|
784
|
+
if (path.length === 0) continue;
|
|
785
|
+
const prior = byPath.get(path) ?? -Infinity;
|
|
786
|
+
if (entry.mtimeMs > prior) byPath.set(path, entry.mtimeMs);
|
|
787
|
+
}
|
|
788
|
+
return Array.from(byPath, ([path, mtimeMs]) => ({
|
|
789
|
+
path,
|
|
790
|
+
mtimeMs
|
|
791
|
+
})).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Session-aware convenience: extract recently-read files directly from
|
|
795
|
+
* a {@link Session} via its per-session read-state map.
|
|
796
|
+
*
|
|
797
|
+
* Hosts (TUI / SDK consumers) typically have a `Session` and a `cwd`
|
|
798
|
+
* (the active agent's `handle.cwd`) on hand — this wrapper saves them
|
|
799
|
+
* from reaching into `src/tools/read-state.ts` directly. Returns an
|
|
800
|
+
* empty list when no read state has been recorded yet (fresh session,
|
|
801
|
+
* or `behavior.dedupReads === false`).
|
|
802
|
+
*
|
|
803
|
+
* Equivalent to:
|
|
804
|
+
*
|
|
805
|
+
* ```ts
|
|
806
|
+
* const state = getReadState(session)
|
|
807
|
+
* return state ? selectFilesFromReadState(state, cwd) : []
|
|
808
|
+
* ```
|
|
809
|
+
*/
|
|
810
|
+
function selectFilesFromSession(session, cwd) {
|
|
811
|
+
const state = getReadState(session);
|
|
812
|
+
return state ? selectFilesFromReadState(state, cwd) : [];
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Pick the top `maxFiles` from `files` (descending by `mtimeMs`),
|
|
816
|
+
* dropping any whose path appears in `excludePaths`.
|
|
817
|
+
*
|
|
818
|
+
* Stable for equal mtimes — files with the same timestamp retain their
|
|
819
|
+
* input order. Pure.
|
|
820
|
+
*/
|
|
821
|
+
function selectRecentFiles(files, opts) {
|
|
822
|
+
const excluded = new Set(opts.excludePaths ?? []);
|
|
823
|
+
return files.filter((f) => !excluded.has(f.path)).slice().sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, Math.max(0, opts.maxFiles));
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Format a file's contents to match `read_file`'s output shape — 1-indexed
|
|
827
|
+
* line numbers separated by tabs, identical to what the model has seen
|
|
828
|
+
* from real `read_file` calls. Applies the per-file token cap by truncating
|
|
829
|
+
* at the nearest line boundary; appends a footer pointing at the next
|
|
830
|
+
* offset so the model can re-read the rest if needed.
|
|
831
|
+
*/
|
|
832
|
+
function formatFileForRestoration(content, perFileTokenCap) {
|
|
833
|
+
const totalBytes = utf8ByteLength(content);
|
|
834
|
+
const allLines = content.split("\n");
|
|
835
|
+
const totalLines = allLines.length;
|
|
836
|
+
const numbered = [];
|
|
837
|
+
let runningChars = 0;
|
|
838
|
+
const charCap = Math.max(1, perFileTokenCap) * 4;
|
|
839
|
+
let truncatedAt = -1;
|
|
840
|
+
let midLineCut = false;
|
|
841
|
+
for (let i = 0; i < allLines.length; i++) {
|
|
842
|
+
const numberedLine = `${i + 1}\t${allLines[i]}`;
|
|
843
|
+
const lineCharCost = numberedLine.length + (i < allLines.length - 1 ? 1 : 0);
|
|
844
|
+
if (runningChars + lineCharCost > charCap) {
|
|
845
|
+
if (numbered.length === 0) {
|
|
846
|
+
const cutTo = Math.max(1, charCap - `${i + 1}\t`.length);
|
|
847
|
+
numbered.push(`${i + 1}\t${allLines[i].slice(0, cutTo)}`);
|
|
848
|
+
midLineCut = true;
|
|
849
|
+
}
|
|
850
|
+
truncatedAt = i;
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
numbered.push(numberedLine);
|
|
854
|
+
runningChars += lineCharCost;
|
|
855
|
+
}
|
|
856
|
+
const body = numbered.join("\n");
|
|
857
|
+
if (truncatedAt < 0) return {
|
|
858
|
+
body,
|
|
859
|
+
truncated: false,
|
|
860
|
+
estimatedTokens: estimateTokens(body)
|
|
861
|
+
};
|
|
862
|
+
const truncated = body + `\n\n…truncated at ${midLineCut ? `line ${truncatedAt + 1} (mid-line)` : `line ${truncatedAt}`} (post-compact restoration cap: ${perFileTokenCap} tokens). File has ${totalLines} lines, ${totalBytes} bytes total — ${midLineCut ? `mid-line cut prevents a precise offset — re-read with offset=${truncatedAt + 1} and a larger maxBytes` : `re-read with offset=${truncatedAt + 1} to continue`}.`;
|
|
863
|
+
return {
|
|
864
|
+
body: truncated,
|
|
865
|
+
truncated: true,
|
|
866
|
+
estimatedTokens: estimateTokens(truncated)
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Format a skill's instructions for restoration. Skills are plain markdown
|
|
871
|
+
* — no line numbering, no wrapping XML envelope (the model already
|
|
872
|
+
* understands the format from real `skills_use` calls). Truncation cuts
|
|
873
|
+
* at a line boundary when possible; appends a marker so the model knows
|
|
874
|
+
* the body is incomplete.
|
|
875
|
+
*/
|
|
876
|
+
function formatSkillForRestoration(instructions, perSkillTokenCap) {
|
|
877
|
+
const charCap = Math.max(1, perSkillTokenCap) * 4;
|
|
878
|
+
if (instructions.length <= charCap) return {
|
|
879
|
+
body: instructions,
|
|
880
|
+
truncated: false,
|
|
881
|
+
estimatedTokens: estimateTokens(instructions)
|
|
882
|
+
};
|
|
883
|
+
const lastNewline = instructions.slice(0, charCap).lastIndexOf("\n");
|
|
884
|
+
const cutoff = lastNewline > 0 ? lastNewline : charCap;
|
|
885
|
+
const truncatedBody = `${instructions.slice(0, cutoff).trimEnd()}\n\n…[truncated post-compact at ${perSkillTokenCap} tokens; full skill body lives at the skill's location]`;
|
|
886
|
+
return {
|
|
887
|
+
body: truncatedBody,
|
|
888
|
+
truncated: true,
|
|
889
|
+
estimatedTokens: estimateTokens(truncatedBody)
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
/** UTF-8 byte length, matching `Buffer.byteLength(text, 'utf-8')`. */
|
|
893
|
+
/**
|
|
894
|
+
* Build the synthetic turns to append after a `compact-summary` marker.
|
|
895
|
+
*
|
|
896
|
+
* Returns a `PostCompactAttachments` envelope; the caller is responsible
|
|
897
|
+
* for `session.appendTurns([summaryTurn, ...result.turns])`. The two
|
|
898
|
+
* synthetic turns are always emitted together (or both omitted when
|
|
899
|
+
* nothing was restored) so the `tool_use ↔ tool_result` adjacency
|
|
900
|
+
* invariant holds regardless of caller code path.
|
|
901
|
+
*
|
|
902
|
+
* Failure isolation: a single file's read failure never blocks the rest
|
|
903
|
+
* of restoration — that file is skipped silently and processing
|
|
904
|
+
* continues. The returned `restoredFiles` count reflects what actually
|
|
905
|
+
* landed in the synthesized turns.
|
|
906
|
+
*/
|
|
907
|
+
async function buildPostCompactAttachments(opts) {
|
|
908
|
+
const fileTokenBudget = opts.fileTokenBudget ?? DEFAULT_FILE_TOKEN_BUDGET;
|
|
909
|
+
const fileTokenPerFileCap = opts.fileTokenPerFileCap ?? DEFAULT_FILE_TOKEN_PER_FILE_CAP;
|
|
910
|
+
const maxFilesToRestore = opts.maxFilesToRestore ?? DEFAULT_MAX_FILES_TO_RESTORE;
|
|
911
|
+
const skillTokenBudget = opts.skillTokenBudget ?? DEFAULT_SKILL_TOKEN_BUDGET;
|
|
912
|
+
const skillTokenPerSkillCap = opts.skillTokenPerSkillCap ?? DEFAULT_SKILL_TOKEN_PER_SKILL_CAP;
|
|
913
|
+
const readFileToolName = opts.readFileToolName ?? DEFAULT_READ_FILE_TOOL_NAME;
|
|
914
|
+
const skillsUseToolName = opts.skillsUseToolName ?? DEFAULT_SKILLS_USE_TOOL_NAME;
|
|
915
|
+
const candidateFiles = opts.recentFiles && opts.recentFiles.length > 0 ? selectRecentFiles(opts.recentFiles, {
|
|
916
|
+
maxFiles: maxFilesToRestore,
|
|
917
|
+
...opts.excludePaths ? { excludePaths: opts.excludePaths } : {}
|
|
918
|
+
}) : [];
|
|
919
|
+
const candidateSkills = opts.activeSkills ?? [];
|
|
920
|
+
const fileCalls = [];
|
|
921
|
+
let fileBudgetUsed = 0;
|
|
922
|
+
if (candidateFiles.length > 0 && opts.execution && opts.handle) for (let i = 0; i < candidateFiles.length; i++) {
|
|
923
|
+
const file = candidateFiles[i];
|
|
924
|
+
let content;
|
|
925
|
+
try {
|
|
926
|
+
content = await opts.execution.readFile(opts.handle, file.path);
|
|
927
|
+
} catch {
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const formatted = formatFileForRestoration(content, fileTokenPerFileCap);
|
|
931
|
+
if (fileBudgetUsed + formatted.estimatedTokens > fileTokenBudget) break;
|
|
932
|
+
fileBudgetUsed += formatted.estimatedTokens;
|
|
933
|
+
fileCalls.push({
|
|
934
|
+
callId: `compact-restore-file-${i}`,
|
|
935
|
+
path: file.path,
|
|
936
|
+
body: formatted.body,
|
|
937
|
+
estimatedTokens: formatted.estimatedTokens
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
const skillCalls = [];
|
|
941
|
+
let skillBudgetUsed = 0;
|
|
942
|
+
for (let i = 0; i < candidateSkills.length; i++) {
|
|
943
|
+
const active = candidateSkills[i];
|
|
944
|
+
const instructions = active.skill.instructions ?? "";
|
|
945
|
+
if (instructions.trim().length === 0) continue;
|
|
946
|
+
const formatted = formatSkillForRestoration(instructions, skillTokenPerSkillCap);
|
|
947
|
+
if (skillBudgetUsed + formatted.estimatedTokens > skillTokenBudget) break;
|
|
948
|
+
skillBudgetUsed += formatted.estimatedTokens;
|
|
949
|
+
skillCalls.push({
|
|
950
|
+
callId: `compact-restore-skill-${i}`,
|
|
951
|
+
name: active.skill.name,
|
|
952
|
+
body: formatted.body,
|
|
953
|
+
estimatedTokens: formatted.estimatedTokens
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
if (fileCalls.length === 0 && skillCalls.length === 0) return {
|
|
957
|
+
turns: [],
|
|
958
|
+
restoredFiles: 0,
|
|
959
|
+
restoredSkills: 0,
|
|
960
|
+
estimatedTokens: 0
|
|
961
|
+
};
|
|
962
|
+
const assistantBlocks = [];
|
|
963
|
+
const userBlocks = [];
|
|
964
|
+
for (const fc of fileCalls) {
|
|
965
|
+
assistantBlocks.push({
|
|
966
|
+
type: "tool_call",
|
|
967
|
+
id: fc.callId,
|
|
968
|
+
name: readFileToolName,
|
|
969
|
+
input: { path: fc.path }
|
|
970
|
+
});
|
|
971
|
+
userBlocks.push({
|
|
972
|
+
type: "tool_result",
|
|
973
|
+
callId: fc.callId,
|
|
974
|
+
output: fc.body
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
for (const sc of skillCalls) {
|
|
978
|
+
assistantBlocks.push({
|
|
979
|
+
type: "tool_call",
|
|
980
|
+
id: sc.callId,
|
|
981
|
+
name: skillsUseToolName,
|
|
982
|
+
input: { name: sc.name }
|
|
983
|
+
});
|
|
984
|
+
userBlocks.push({
|
|
985
|
+
type: "tool_result",
|
|
986
|
+
callId: sc.callId,
|
|
987
|
+
output: sc.body
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
const now = Date.now();
|
|
991
|
+
const tag = opts.runId ? { runId: opts.runId } : {};
|
|
992
|
+
return {
|
|
993
|
+
turns: [{
|
|
994
|
+
id: crypto.randomUUID(),
|
|
995
|
+
role: "assistant",
|
|
996
|
+
content: assistantBlocks,
|
|
997
|
+
createdAt: now,
|
|
998
|
+
...tag
|
|
999
|
+
}, {
|
|
1000
|
+
id: crypto.randomUUID(),
|
|
1001
|
+
role: "user",
|
|
1002
|
+
content: userBlocks,
|
|
1003
|
+
createdAt: now + 1,
|
|
1004
|
+
...tag
|
|
1005
|
+
}],
|
|
1006
|
+
restoredFiles: fileCalls.length,
|
|
1007
|
+
restoredSkills: skillCalls.length,
|
|
1008
|
+
estimatedTokens: fileBudgetUsed + skillBudgetUsed
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/mcp/oauth-callback.ts
|
|
1013
|
+
const DEFAULT_PATH = "/callback";
|
|
1014
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
1015
|
+
const SUCCESS_HTML = `<!doctype html>
|
|
1016
|
+
<html><head><meta charset="utf-8"><title>Logged in</title>
|
|
1017
|
+
<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;margin:6rem auto;max-width:28rem;text-align:center;color:#1d1d1f}.ok{color:#1f8a4c;font-weight:600}</style>
|
|
1018
|
+
</head><body>
|
|
1019
|
+
<p class="ok">Logged in.</p>
|
|
1020
|
+
<p>You can close this tab and return to the terminal.</p>
|
|
1021
|
+
</body></html>`;
|
|
1022
|
+
function errorHtml(message) {
|
|
1023
|
+
return `<!doctype html>
|
|
1024
|
+
<html><head><meta charset="utf-8"><title>Login failed</title>
|
|
1025
|
+
<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;margin:6rem auto;max-width:28rem;text-align:center;color:#1d1d1f}.err{color:#c43c2c;font-weight:600}</style>
|
|
1026
|
+
</head><body>
|
|
1027
|
+
<p class="err">Login failed.</p>
|
|
1028
|
+
<p>${message.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</p>
|
|
1029
|
+
<p>You can close this tab and return to the terminal.</p>
|
|
1030
|
+
</body></html>`;
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Start a one-shot OAuth callback server. The returned handle's `redirectUri`
|
|
1034
|
+
* should be passed to the authorization server as the `redirect_uri` query
|
|
1035
|
+
* parameter; `promise` resolves once the user finishes the browser flow.
|
|
1036
|
+
*
|
|
1037
|
+
* Always `await handle.close()` in a `finally` block — even on success, the
|
|
1038
|
+
* server stays open until told to shut down (so it can serve the
|
|
1039
|
+
* "you can close this tab" page).
|
|
1040
|
+
*/
|
|
1041
|
+
async function startOAuthCallback(opts = {}) {
|
|
1042
|
+
const path = opts.path ?? DEFAULT_PATH;
|
|
1043
|
+
const host = opts.host ?? DEFAULT_HOST;
|
|
1044
|
+
const port = opts.port ?? 0;
|
|
1045
|
+
if (!path.startsWith("/")) throw new Error(`OAuth callback path must start with "/" (got: ${JSON.stringify(path)})`);
|
|
1046
|
+
if (opts.signal?.aborted) throw new Error("OAuth callback aborted");
|
|
1047
|
+
let resolveResult;
|
|
1048
|
+
let rejectResult;
|
|
1049
|
+
const promise = new Promise((resolve, reject) => {
|
|
1050
|
+
resolveResult = resolve;
|
|
1051
|
+
rejectResult = reject;
|
|
1052
|
+
});
|
|
1053
|
+
promise.catch(() => {});
|
|
1054
|
+
let settled = false;
|
|
1055
|
+
const resolveOnce = (value) => {
|
|
1056
|
+
if (settled) return;
|
|
1057
|
+
settled = true;
|
|
1058
|
+
resolveResult(value);
|
|
1059
|
+
};
|
|
1060
|
+
const rejectOnce = (error) => {
|
|
1061
|
+
if (settled) return;
|
|
1062
|
+
settled = true;
|
|
1063
|
+
rejectResult(error);
|
|
1064
|
+
};
|
|
1065
|
+
const server = createServer((req, res) => {
|
|
1066
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
1067
|
+
if (url.pathname !== path) {
|
|
1068
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
1069
|
+
res.end("Not Found");
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const htmlHeaders = {
|
|
1073
|
+
"content-type": "text/html; charset=utf-8",
|
|
1074
|
+
"cache-control": "no-store"
|
|
1075
|
+
};
|
|
1076
|
+
const error = url.searchParams.get("error");
|
|
1077
|
+
if (error) {
|
|
1078
|
+
const desc = url.searchParams.get("error_description") ?? error;
|
|
1079
|
+
res.writeHead(400, htmlHeaders);
|
|
1080
|
+
res.end(errorHtml(desc));
|
|
1081
|
+
rejectOnce(/* @__PURE__ */ new Error(`OAuth authorization failed: ${desc}`));
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const code = url.searchParams.get("code");
|
|
1085
|
+
if (!code) {
|
|
1086
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
1087
|
+
res.end("Missing \"code\" query parameter.");
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const state = url.searchParams.get("state") ?? void 0;
|
|
1091
|
+
res.writeHead(200, htmlHeaders);
|
|
1092
|
+
res.end(SUCCESS_HTML);
|
|
1093
|
+
resolveOnce({
|
|
1094
|
+
code,
|
|
1095
|
+
state
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
server.on("error", (err) => {
|
|
1099
|
+
rejectOnce(err instanceof Error ? err : new Error(String(err)));
|
|
1100
|
+
});
|
|
1101
|
+
await new Promise((resolve, reject) => {
|
|
1102
|
+
server.once("error", reject);
|
|
1103
|
+
server.listen(port, host, () => {
|
|
1104
|
+
server.off("error", reject);
|
|
1105
|
+
resolve();
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
const addr = server.address();
|
|
1109
|
+
if (!addr || typeof addr === "string") {
|
|
1110
|
+
server.close();
|
|
1111
|
+
throw new Error("OAuth callback server did not bind to a TCP port");
|
|
1112
|
+
}
|
|
1113
|
+
let closing;
|
|
1114
|
+
let closeServer;
|
|
1115
|
+
const onAbort = () => {
|
|
1116
|
+
rejectOnce(/* @__PURE__ */ new Error("OAuth callback aborted"));
|
|
1117
|
+
closeServer();
|
|
1118
|
+
};
|
|
1119
|
+
closeServer = () => {
|
|
1120
|
+
if (closing) return closing;
|
|
1121
|
+
closing = new Promise((resolve) => {
|
|
1122
|
+
server.closeAllConnections?.();
|
|
1123
|
+
server.close(() => {
|
|
1124
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
1125
|
+
if (opts.signal?.aborted) rejectOnce(/* @__PURE__ */ new Error("OAuth callback aborted"));
|
|
1126
|
+
else rejectOnce(/* @__PURE__ */ new Error("OAuth callback server closed"));
|
|
1127
|
+
resolve();
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
return closing;
|
|
1131
|
+
};
|
|
1132
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1133
|
+
return {
|
|
1134
|
+
redirectUri: `http://${host}:${addr.port}${path}`,
|
|
1135
|
+
promise,
|
|
1136
|
+
close: closeServer
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
//#endregion
|
|
1140
|
+
//#region src/mcp/login.ts
|
|
1141
|
+
const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 6e4;
|
|
1142
|
+
/**
|
|
1143
|
+
* Run the full interactive OAuth flow for `config`. Only supports `sse` and
|
|
1144
|
+
* `streamable-http` transports — `stdio` MCP servers don't speak OAuth.
|
|
1145
|
+
*
|
|
1146
|
+
* Throws on:
|
|
1147
|
+
* - Wrong transport.
|
|
1148
|
+
* - Abort signal.
|
|
1149
|
+
* - Browser-side error (user denied, server rejected, etc.).
|
|
1150
|
+
* - Code exchange failure.
|
|
1151
|
+
* - Post-exchange connect failure.
|
|
1152
|
+
*
|
|
1153
|
+
* Always closes the loopback callback server before returning, success or
|
|
1154
|
+
* failure.
|
|
1155
|
+
*/
|
|
1156
|
+
async function loginMcpServer(config, options) {
|
|
1157
|
+
if (config.transport !== "sse" && config.transport !== "streamable-http") throw new Error(`MCP OAuth: cannot login for transport "${config.transport}" — only sse / streamable-http are supported`);
|
|
1158
|
+
if (!config.url) throw new Error(`MCP OAuth: server "${config.name}" is missing a url`);
|
|
1159
|
+
const hooks = options.hooks;
|
|
1160
|
+
let callback;
|
|
1161
|
+
try {
|
|
1162
|
+
callback = await startOAuthCallback({
|
|
1163
|
+
signal: options.signal,
|
|
1164
|
+
path: options.callbackPath
|
|
1165
|
+
});
|
|
1166
|
+
const handle = callback;
|
|
1167
|
+
const provider = new McpOAuthProvider({
|
|
1168
|
+
name: config.name,
|
|
1169
|
+
store: options.store,
|
|
1170
|
+
redirectUri: handle.redirectUri,
|
|
1171
|
+
clientName: options.clientName,
|
|
1172
|
+
scope: options.scope,
|
|
1173
|
+
onAuthorizationUrl: async (url) => {
|
|
1174
|
+
await hooks?.callHook("mcp:auth:url", {
|
|
1175
|
+
name: config.name,
|
|
1176
|
+
url: url.toString()
|
|
1177
|
+
});
|
|
1178
|
+
await options.onAuthorizationUrl?.(url);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
const transport = await createInteractiveTransport(config, provider);
|
|
1182
|
+
const client = await createTolerantClient({
|
|
1183
|
+
name: "zidane",
|
|
1184
|
+
version: "1.0.0"
|
|
1185
|
+
});
|
|
1186
|
+
let needsAuth = false;
|
|
1187
|
+
try {
|
|
1188
|
+
await client.connect(transport);
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
if (!isUnauthorizedError(err)) {
|
|
1191
|
+
await client.close().catch(() => {});
|
|
1192
|
+
throw err;
|
|
1193
|
+
}
|
|
1194
|
+
needsAuth = true;
|
|
1195
|
+
}
|
|
1196
|
+
if (needsAuth) {
|
|
1197
|
+
const { code } = await raceLoginCallback(handle, options.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS, options.signal);
|
|
1198
|
+
await transport.finishAuth(code);
|
|
1199
|
+
await transport.close().catch(() => {});
|
|
1200
|
+
const freshTransport = await createInteractiveTransport(config, provider);
|
|
1201
|
+
await client.connect(freshTransport);
|
|
1202
|
+
}
|
|
1203
|
+
const { tools } = await client.listTools();
|
|
1204
|
+
await client.close();
|
|
1205
|
+
const tokens = provider.tokens();
|
|
1206
|
+
if (!tokens) throw new Error(`MCP OAuth: login for "${config.name}" returned no tokens (server may have rejected the exchange silently)`);
|
|
1207
|
+
await hooks?.callHook("mcp:auth:success", { name: config.name });
|
|
1208
|
+
return {
|
|
1209
|
+
tokens,
|
|
1210
|
+
tools
|
|
1211
|
+
};
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1214
|
+
await hooks?.callHook("mcp:auth:error", {
|
|
1215
|
+
name: config.name,
|
|
1216
|
+
error
|
|
1217
|
+
});
|
|
1218
|
+
throw error;
|
|
1219
|
+
} finally {
|
|
1220
|
+
await callback?.close();
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Build an `sse` / `streamable-http` transport pre-wired with `authProvider`.
|
|
1225
|
+
* Mirrors the bootstrap-side `createTransport` shape but inlined here so the
|
|
1226
|
+
* login flow doesn't depend on the bootstrap module's private export.
|
|
1227
|
+
*/
|
|
1228
|
+
async function createInteractiveTransport(config, authProvider) {
|
|
1229
|
+
if (config.transport === "sse") {
|
|
1230
|
+
const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
|
|
1231
|
+
return new SSEClientTransport(new URL(config.url), {
|
|
1232
|
+
requestInit: config.headers ? { headers: config.headers } : void 0,
|
|
1233
|
+
authProvider
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
1237
|
+
return new StreamableHTTPClientTransport(new URL(config.url), {
|
|
1238
|
+
requestInit: config.headers ? { headers: config.headers } : void 0,
|
|
1239
|
+
fetch: sseToJsonFetchIfNeeded(),
|
|
1240
|
+
authProvider
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
function isUnauthorizedError(err) {
|
|
1244
|
+
if (!err || typeof err !== "object") return false;
|
|
1245
|
+
const e = err;
|
|
1246
|
+
if (e.name === "UnauthorizedError" || e.constructor?.name === "UnauthorizedError") return true;
|
|
1247
|
+
return typeof e.message === "string" && e.message.toLowerCase().startsWith("unauthorized");
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Wait on the callback handle, with a hard timeout AND honor the external
|
|
1251
|
+
* abort signal. Unlike `callback.promise` alone, this provides a deterministic
|
|
1252
|
+
* failure mode for "user opened the browser then walked away" — the modal
|
|
1253
|
+
* doesn't hang forever waiting for a redirect that may never come.
|
|
1254
|
+
*/
|
|
1255
|
+
async function raceLoginCallback(handle, timeoutMs, signal) {
|
|
1256
|
+
if (signal?.aborted) throw new Error("OAuth login aborted");
|
|
1257
|
+
let timer;
|
|
1258
|
+
let onAbort;
|
|
1259
|
+
try {
|
|
1260
|
+
return await new Promise((resolve, reject) => {
|
|
1261
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`OAuth login timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
1262
|
+
if (signal) {
|
|
1263
|
+
onAbort = () => reject(/* @__PURE__ */ new Error("OAuth login aborted"));
|
|
1264
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1265
|
+
}
|
|
1266
|
+
handle.promise.then(resolve, reject);
|
|
1267
|
+
});
|
|
1268
|
+
} finally {
|
|
1269
|
+
if (timer) clearTimeout(timer);
|
|
1270
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
//#endregion
|
|
1274
|
+
export { summaryToTurn as C, CompactPromptTooLongError as E, stripImagesFromTurns as S, CompactInvalidInputError as T, buildTailCompactPrompt as _, selectFilesFromSession as a, anchorPreviewFor as b, BYTES_PER_TOKEN as c, BASE_INSTRUCTIONS as d, NO_TOOLS_PREAMBLE as f, buildFullCompactPrompt as g, buildFromCompactPrompt as h, selectFilesFromReadState as i, estimateTokens as l, buildCompactPrompt as m, startOAuthCallback as n, selectRecentFiles as o, TRAILER as p, buildPostCompactAttachments as r, compactConversation as s, loginMcpServer as t, utf8ByteLength as u, buildUpToCompactPrompt as v, truncateHeadForPtlRetry as w, sliceForCompaction as x, ANCHOR_PREVIEW_MAX_CHARS as y };
|
|
1275
|
+
|
|
1276
|
+
//# sourceMappingURL=login-BiuHyuEh.js.map
|