one-code-extension 0.5.0 → 0.6.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 +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- package/extensions/tool-search/announce.ts +0 -58
|
@@ -20,26 +20,72 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import type { Message } from "@earendil-works/pi-ai";
|
|
23
|
+
import { settleSystemMessages } from "./ledger/layout.ts";
|
|
24
|
+
import { isObject } from "./ledger/records.ts";
|
|
25
|
+
import { appendPoint, isToolsItem, leadingToolsItem, listKey, loadedInList, preludeLength, responsesDeclarations, stripMarkers, toolName, toolsItemFor } from "./ledger/wire.ts";
|
|
23
26
|
import { estimateMessageTokens } from "./pi-ai-estimate.ts";
|
|
24
27
|
|
|
25
28
|
/** The compaction extension publishes a RequestCapture here, or undefined when none is valid. */
|
|
26
29
|
export const LAST_REQUEST_CHANNEL = "one-code:last-request";
|
|
27
30
|
|
|
28
31
|
/** The provider APIs whose request body can be extended by appending to one list. */
|
|
29
|
-
export type ReplayApi =
|
|
32
|
+
export type ReplayApi =
|
|
33
|
+
| "anthropic-messages"
|
|
34
|
+
| "openai-completions"
|
|
35
|
+
| "openai-responses"
|
|
36
|
+
| "azure-openai-responses"
|
|
37
|
+
| "openai-codex-responses"
|
|
38
|
+
| "bedrock-converse-stream"
|
|
39
|
+
| "google-generative-ai"
|
|
40
|
+
| "google-vertex";
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Where the output cap lives, first match in the captured body wins: a
|
|
44
|
+
* top-level field, or one level down (Bedrock's `inferenceConfig.maxTokens`,
|
|
45
|
+
* Google's `config.maxOutputTokens`). Codex sends none.
|
|
46
|
+
*/
|
|
47
|
+
const CAP_PATHS: Record<ReplayApi, ReadonlyArray<readonly string[]>> = {
|
|
48
|
+
"anthropic-messages": [["max_tokens"]],
|
|
49
|
+
"openai-completions": [["max_completion_tokens"], ["max_tokens"]],
|
|
50
|
+
"openai-responses": [["max_output_tokens"]],
|
|
51
|
+
"azure-openai-responses": [["max_output_tokens"]],
|
|
52
|
+
"openai-codex-responses": [],
|
|
53
|
+
"bedrock-converse-stream": [["inferenceConfig", "maxTokens"]],
|
|
54
|
+
"google-generative-ai": [["config", "maxOutputTokens"]],
|
|
55
|
+
"google-vertex": [["config", "maxOutputTokens"]],
|
|
35
56
|
};
|
|
36
57
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"openai-
|
|
41
|
-
"openai-responses"
|
|
42
|
-
|
|
58
|
+
const REPLAY_APIS: ReadonlySet<string> = new Set<ReplayApi>([
|
|
59
|
+
"anthropic-messages",
|
|
60
|
+
"openai-completions",
|
|
61
|
+
"openai-responses",
|
|
62
|
+
"azure-openai-responses",
|
|
63
|
+
"openai-codex-responses",
|
|
64
|
+
"bedrock-converse-stream",
|
|
65
|
+
"google-generative-ai",
|
|
66
|
+
"google-vertex",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** The value at `path` in `payload`, when it is a number. */
|
|
70
|
+
function capAt(payload: Record<string, unknown>, path: readonly string[]): number | undefined {
|
|
71
|
+
let value: unknown = payload;
|
|
72
|
+
for (const key of path) value = isObject(value) ? value[key] : undefined;
|
|
73
|
+
return typeof value === "number" ? value : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The path of the cap `payload` carries for `api`, or the first one the API takes. */
|
|
77
|
+
function capPath(api: ReplayApi, payload: Record<string, unknown>): readonly string[] | undefined {
|
|
78
|
+
const paths = CAP_PATHS[api];
|
|
79
|
+
return paths.find((path) => capAt(payload, path) !== undefined) ?? paths[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `payload` with `value` at `path`, copying only the objects on the way. */
|
|
83
|
+
function withCap(payload: Record<string, unknown>, path: readonly string[], value: number): Record<string, unknown> {
|
|
84
|
+
const [head, ...rest] = path;
|
|
85
|
+
if (rest.length === 0) return { ...payload, [head]: value };
|
|
86
|
+
const inner = isObject(payload[head]) ? (payload[head] as Record<string, unknown>) : {};
|
|
87
|
+
return { ...payload, [head]: withCap(inner, rest, value) };
|
|
88
|
+
}
|
|
43
89
|
|
|
44
90
|
/** The session's last provider request body, and the model it went to. */
|
|
45
91
|
export interface RequestCapture {
|
|
@@ -68,9 +114,9 @@ interface ModelRef {
|
|
|
68
114
|
/** A capture of `payload` for `model`, or undefined when its API cannot be replayed. */
|
|
69
115
|
export function captureRequest(model: ModelRef | undefined, payload: unknown, covers?: CoveredThrough, sessionId?: string): RequestCapture | undefined {
|
|
70
116
|
const api = model?.api;
|
|
71
|
-
if (api
|
|
72
|
-
if (!model?.provider || !model.id || !
|
|
73
|
-
return { api, provider: model.provider, modelId: model.id, payload, ...(covers ? { covers } : {}), ...(sessionId ? { sessionId } : {}) };
|
|
117
|
+
if (!api || !REPLAY_APIS.has(api)) return undefined;
|
|
118
|
+
if (!model?.provider || !model.id || !isObject(payload) || !Array.isArray(payload[listKey(api)])) return undefined;
|
|
119
|
+
return { api: api as ReplayApi, provider: model.provider, modelId: model.id, payload, ...(covers ? { covers } : {}), ...(sessionId ? { sessionId } : {}) };
|
|
74
120
|
}
|
|
75
121
|
|
|
76
122
|
/** The model fields pi-ai reads to choose its session-affinity headers. */
|
|
@@ -176,23 +222,46 @@ export class LastExchange<Reply extends MessageLike = MessageLike> {
|
|
|
176
222
|
* trailing user message, so the roles still alternate.
|
|
177
223
|
*/
|
|
178
224
|
export function extendPayload(capture: RequestCapture, tail: Record<string, unknown>, maxTokens?: number): Record<string, unknown> {
|
|
179
|
-
const key =
|
|
225
|
+
const key = listKey(capture.api);
|
|
180
226
|
const base = capture.payload[key] as unknown[];
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
227
|
+
// The tail's own markers are dropped: the capture's mark the entry the session wrote.
|
|
228
|
+
const extra = stripMarkers(Array.isArray(tail[key]) ? (tail[key] as unknown[]) : []);
|
|
229
|
+
const list =
|
|
230
|
+
capture.api === "anthropic-messages"
|
|
231
|
+
? settleSystemMessages(appendAnthropic(base, extra), capture.api, base.length)
|
|
232
|
+
: capture.api === "bedrock-converse-stream" || capture.api.startsWith("google-")
|
|
233
|
+
? joinTurns(base, extra, capture.api.startsWith("google-") ? "parts" : "content")
|
|
234
|
+
: [...base, ...extra];
|
|
235
|
+
let out: Record<string, unknown> = { ...capture.payload, [key]: list };
|
|
236
|
+
// Google's config carries the request's live abort signal: the side call's own, never the session's.
|
|
237
|
+
if (capture.api.startsWith("google-") && isObject(out.config)) {
|
|
238
|
+
const { abortSignal: _session, ...config } = out.config;
|
|
239
|
+
const own = isObject(tail.config) ? tail.config.abortSignal : undefined;
|
|
240
|
+
out = { ...out, config: own === undefined ? config : { ...config, abortSignal: own } };
|
|
187
241
|
}
|
|
188
|
-
|
|
242
|
+
const path = maxTokens === undefined ? undefined : capPath(capture.api, capture.payload);
|
|
243
|
+
return path && maxTokens !== undefined ? withCap(out, path, maxTokens) : out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The tail appended so turns still alternate: a tail that opens with a user
|
|
248
|
+
* turn joins a captured trailing user turn (Bedrock and Google reject two in
|
|
249
|
+
* a row). `field` is where a turn keeps its blocks (`parts` on Google).
|
|
250
|
+
*/
|
|
251
|
+
function joinTurns(base: unknown[], tail: unknown[], field: "content" | "parts"): unknown[] {
|
|
252
|
+
const last = base.at(-1) as Record<string, unknown> | undefined;
|
|
253
|
+
const first = tail[0] as Record<string, unknown> | undefined;
|
|
254
|
+
if (last?.role === "user" && first?.role === "user" && Array.isArray(last[field]) && Array.isArray(first[field])) {
|
|
255
|
+
return [...base.slice(0, -1), { ...last, [field]: [...(last[field] as unknown[]), ...(first[field] as unknown[])] }, ...tail.slice(1)];
|
|
256
|
+
}
|
|
257
|
+
return [...base, ...tail];
|
|
189
258
|
}
|
|
190
259
|
|
|
191
260
|
/** The output cap the captured body carries, if any. */
|
|
192
261
|
function capturedMaxTokens(capture: RequestCapture): number | undefined {
|
|
193
|
-
for (const
|
|
194
|
-
const value = capture.payload
|
|
195
|
-
if (
|
|
262
|
+
for (const path of CAP_PATHS[capture.api]) {
|
|
263
|
+
const value = capAt(capture.payload, path);
|
|
264
|
+
if (value !== undefined) return value;
|
|
196
265
|
}
|
|
197
266
|
return undefined;
|
|
198
267
|
}
|
|
@@ -262,24 +331,63 @@ export const MAX_CACHE_MARKERS = 4;
|
|
|
262
331
|
* tail's mark the fork's own entry, and the earliest are dropped past four.
|
|
263
332
|
*/
|
|
264
333
|
export function forkRequestPayload(capture: RequestCapture, child: Record<string, unknown>, baseline: ReadonlySet<string>): Record<string, unknown> {
|
|
265
|
-
const key =
|
|
334
|
+
const key = listKey(capture.api);
|
|
266
335
|
const base = capture.payload[key] as unknown[];
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
336
|
+
const childList = Array.isArray(child[key]) ? (child[key] as WireMessage[]) : [];
|
|
337
|
+
const parentDeclared = captureDeclarations(capture);
|
|
338
|
+
// The child's own prelude and system messages go; a Responses load item its
|
|
339
|
+
// tool_search placed in the tail stays, less what the capture declares.
|
|
340
|
+
const tail = childList.slice(preludeLength(childList, capture.api)).flatMap((entry): WireMessage[] => {
|
|
341
|
+
if (isToolsItem(entry)) {
|
|
342
|
+
const tools = entry.tools.filter((tool) => !parentDeclared.has(toolName(tool) ?? ""));
|
|
343
|
+
return tools.length > 0 ? [{ ...entry, tools } as WireMessage] : [];
|
|
344
|
+
}
|
|
345
|
+
return entry.role !== "system" && entry.role !== "developer" ? [entry] : [];
|
|
346
|
+
});
|
|
347
|
+
const childTools = [...(Array.isArray(child.tools) ? child.tools : []), ...(leadingToolsItem(child.tools, childList)?.tools ?? [])];
|
|
348
|
+
const inTail = loadedInList(tail);
|
|
349
|
+
const gained = forkGainedTools(parentDeclared, childTools.filter((tool) => !inTail.has(toolName(tool) ?? "")), baseline, tail);
|
|
350
|
+
// A gained tool goes where the capture keeps its tools: in an item at the
|
|
351
|
+
// start of the fork's tail when the capture declares in a leading item
|
|
352
|
+
// (after the parent's prefix, which it then keeps), else appended to the
|
|
353
|
+
// top-level array.
|
|
354
|
+
const parentItem = leadingToolsItem(capture.payload.tools, base);
|
|
355
|
+
const ownTail = gained.length > 0 && parentItem ? [toolsItemFor(gained, parentItem) as WireMessage, ...tail] : tail;
|
|
356
|
+
const out: Record<string, unknown> = { ...capture.payload, [key]: capture.api === "anthropic-messages" ? settleSystemMessages(appendTurns(base, ownTail), capture.api, base.length) : [...base, ...ownTail] };
|
|
357
|
+
// A capture that declares no tools at its head takes the child's own,
|
|
358
|
+
// wherever the child declared them.
|
|
359
|
+
const parentTools = capture.payload.tools;
|
|
360
|
+
const appended = !parentItem && Array.isArray(parentTools) ? gained : [];
|
|
361
|
+
if (!parentItem) {
|
|
362
|
+
const tools = Array.isArray(parentTools) ? (appended.length > 0 ? [...parentTools, ...appended] : parentTools) : childTools.length > 0 ? stripMarkers(childTools) : undefined;
|
|
363
|
+
if (tools) out.tools = tools;
|
|
364
|
+
}
|
|
365
|
+
const capKey = CAP_PATHS[capture.api].find((path) => capAt(capture.payload, path) !== undefined);
|
|
272
366
|
// Appended tool schemas spend room too; the parent's own tools are already in its cap.
|
|
273
|
-
const
|
|
274
|
-
const tailRoom = forkOutputRoom(capture, tail);
|
|
367
|
+
const tailRoom = forkOutputRoom(capture, ownTail);
|
|
275
368
|
const room = tailRoom === undefined ? undefined : tailRoom - (appended.length > 0 ? Math.ceil(JSON.stringify(appended).length / 4) : 0);
|
|
369
|
+
let result = out;
|
|
276
370
|
if (capKey && room !== undefined) {
|
|
277
371
|
// Never above the room: the fork chose the fallback before this when too little was left.
|
|
278
|
-
const own =
|
|
279
|
-
out
|
|
372
|
+
const own = capAt(child, capKey) ?? room;
|
|
373
|
+
result = withCap(out, capKey, Math.max(1, Math.min(own, room)));
|
|
280
374
|
}
|
|
281
|
-
capCacheMarkers(
|
|
282
|
-
return
|
|
375
|
+
capCacheMarkers(result, key, capture, ownTail);
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The tool names a fork's first request declares, wherever it keeps them: the baseline its later gains are measured from. */
|
|
380
|
+
export function forkBaselineNames(payload: Record<string, unknown>, api: string): Set<string> {
|
|
381
|
+
return new Set(api.endsWith("-responses") ? responsesDeclarations(payload, api).keys() : toolNames(payload.tools));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** What each capture declares, computed once: every request of a fork splices the same capture. */
|
|
385
|
+
const declaredByCapture = new WeakMap<RequestCapture, Set<string>>();
|
|
386
|
+
|
|
387
|
+
function captureDeclarations(capture: RequestCapture): Set<string> {
|
|
388
|
+
let names = declaredByCapture.get(capture);
|
|
389
|
+
if (!names) declaredByCapture.set(capture, (names = forkBaselineNames(capture.payload, capture.api)));
|
|
390
|
+
return names;
|
|
283
391
|
}
|
|
284
392
|
|
|
285
393
|
/**
|
|
@@ -289,49 +397,60 @@ export function forkRequestPayload(capture: RequestCapture, child: Record<string
|
|
|
289
397
|
* `MIN_REPLAY_OUTPUT_TOKENS` must not be spliced: it goes out whole instead.
|
|
290
398
|
*/
|
|
291
399
|
export function forkOutputRoom(capture: RequestCapture, tail: unknown): number | undefined {
|
|
292
|
-
const
|
|
293
|
-
return
|
|
400
|
+
const cap = capturedMaxTokens(capture);
|
|
401
|
+
return cap === undefined ? undefined : cap - Math.ceil(JSON.stringify(tail).length / 4);
|
|
294
402
|
}
|
|
295
403
|
|
|
296
|
-
/** The names a body's tool list declares
|
|
404
|
+
/** The names a body's tool list declares, in any API's shape (lib/ledger/wire.ts toolName). */
|
|
297
405
|
export function toolNames(tools: unknown): string[] {
|
|
298
406
|
return Array.isArray(tools) ? tools.map(toolName).filter((name): name is string => name !== undefined) : [];
|
|
299
407
|
}
|
|
300
408
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
function forkTools(parent: unknown, child: unknown, baseline: ReadonlySet<string>, tail: unknown[]): unknown[] | undefined {
|
|
308
|
-
if (!Array.isArray(parent)) return Array.isArray(child) ? (withoutCacheControl(child) as unknown[]) : undefined;
|
|
309
|
-
const declared = new Set(toolNames(parent));
|
|
409
|
+
/**
|
|
410
|
+
* The child's tools the capture does not declare and the fork needs: one it
|
|
411
|
+
* gained after its first request (not in `baseline`), or one a tail
|
|
412
|
+
* `tool_reference` names. Markers stripped: the capture's own mark stays.
|
|
413
|
+
*/
|
|
414
|
+
function forkGainedTools(declared: ReadonlySet<string>, child: readonly unknown[], baseline: ReadonlySet<string>, tail: unknown[]): unknown[] {
|
|
310
415
|
const referenced = referencedTools(tail);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
416
|
+
return stripMarkers(
|
|
417
|
+
child.filter((tool) => {
|
|
418
|
+
const name = toolName(tool);
|
|
419
|
+
return name !== undefined && !declared.has(name) && (!baseline.has(name) || referenced.has(name));
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
316
422
|
}
|
|
317
423
|
|
|
318
424
|
/** Tool names the tail's `tool_reference` blocks load (Anthropic tool search). */
|
|
319
425
|
function referencedTools(value: unknown, found = new Set<string>()): Set<string> {
|
|
320
426
|
if (Array.isArray(value)) for (const entry of value) referencedTools(entry, found);
|
|
321
|
-
else if (
|
|
427
|
+
else if (isObject(value)) {
|
|
322
428
|
if (value.type === "tool_reference" && typeof value.tool_name === "string") found.add(value.tool_name);
|
|
323
429
|
for (const entry of Object.values(value)) referencedTools(entry, found);
|
|
324
430
|
}
|
|
325
431
|
return found;
|
|
326
432
|
}
|
|
327
433
|
|
|
328
|
-
/**
|
|
329
|
-
|
|
434
|
+
/**
|
|
435
|
+
* `tail` after a captured Anthropic list, with the capture kept a byte
|
|
436
|
+
* prefix. A tail that opens with a user turn joins the captured last one, so
|
|
437
|
+
* the roles alternate. A capture that ends in a content system run (a
|
|
438
|
+
* `<total_tokens>` countdown, a context message) took no clean reply: a user
|
|
439
|
+
* turn after it would strand the run, and the layout pass would move the run
|
|
440
|
+
* behind it, rewriting what the session cached there (findings §73). A text
|
|
441
|
+
* turn goes in as a system message instead, so the run stays where its cache
|
|
442
|
+
* entry was written. A side prompt opens its tail that way, and so does a fork
|
|
443
|
+
* taken before the capture's reply landed (a `/btw` fork while it streams).
|
|
444
|
+
*/
|
|
445
|
+
function appendTurns(base: unknown[], tail: unknown[]): unknown[] {
|
|
330
446
|
const last = base.at(-1) as WireMessage | undefined;
|
|
331
447
|
const first = tail[0] as WireMessage | undefined;
|
|
332
448
|
if (last?.role === "user" && first?.role === "user") {
|
|
333
449
|
return [...base.slice(0, -1), { ...last, content: [...blocksOf(last.content), ...blocksOf(first.content)] }, ...tail.slice(1)];
|
|
334
450
|
}
|
|
451
|
+
if (first?.role === "user" && endsInSystemRun(base) && blocksOf(first.content).every((block) => block.type === "text")) {
|
|
452
|
+
return [...base, { role: "system", content: blocksOf(first.content) }, ...tail.slice(1)];
|
|
453
|
+
}
|
|
335
454
|
return [...base, ...tail];
|
|
336
455
|
}
|
|
337
456
|
|
|
@@ -357,7 +476,7 @@ function captureMarkers(capture: RequestCapture): number {
|
|
|
357
476
|
let count = capturedMarkerCounts.get(capture);
|
|
358
477
|
if (count === undefined) {
|
|
359
478
|
const payload = capture.payload;
|
|
360
|
-
count = countMarkers(payload.tools) + countMarkers(payload.system) + countMarkers(payload[
|
|
479
|
+
count = countMarkers(payload.tools) + countMarkers(payload.system) + countMarkers(payload[listKey(capture.api)]);
|
|
361
480
|
capturedMarkerCounts.set(capture, count);
|
|
362
481
|
}
|
|
363
482
|
return count;
|
|
@@ -365,7 +484,7 @@ function captureMarkers(capture: RequestCapture): number {
|
|
|
365
484
|
|
|
366
485
|
function countMarkers(value: unknown): number {
|
|
367
486
|
if (Array.isArray(value)) return value.reduce((sum: number, entry) => sum + countMarkers(entry), 0);
|
|
368
|
-
if (!
|
|
487
|
+
if (!isObject(value)) return 0;
|
|
369
488
|
let count = "cache_control" in value ? 1 : 0;
|
|
370
489
|
for (const [key, entry] of Object.entries(value)) if (key !== "cache_control") count += countMarkers(entry);
|
|
371
490
|
return count;
|
|
@@ -382,7 +501,7 @@ function dropFirstMarkers(value: unknown, budget: { drop: number }): unknown {
|
|
|
382
501
|
});
|
|
383
502
|
return changed ? out : value;
|
|
384
503
|
}
|
|
385
|
-
if (!
|
|
504
|
+
if (!isObject(value)) return value;
|
|
386
505
|
let out: Record<string, unknown> | undefined;
|
|
387
506
|
if ("cache_control" in value) {
|
|
388
507
|
const { cache_control: _, ...rest } = value;
|
|
@@ -413,27 +532,16 @@ function appendAnthropic(base: unknown[], extra: unknown[]): unknown[] {
|
|
|
413
532
|
const content = replyBlocks.map((block, index) => (index === anchor ? { ...block, cache_control: markers.at(-1) } : block));
|
|
414
533
|
return [...base.slice(0, -1), lastUnmarked, { ...first, content }, ...extra.slice(1)];
|
|
415
534
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
535
|
+
return appendTurns(base, extra);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Whether the list, past its trailing directives, ends in a system message with content. */
|
|
539
|
+
function endsInSystemRun(list: unknown[]): boolean {
|
|
540
|
+
const last = list[appendPoint(list, "anthropic-messages") - 1] as WireMessage | undefined;
|
|
541
|
+
return last?.role === "system" && blocksOf(last.content).length > 0;
|
|
420
542
|
}
|
|
421
543
|
|
|
422
544
|
function blocksOf(content: unknown): Block[] {
|
|
423
545
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
424
546
|
return Array.isArray(content) ? (content as Block[]) : [];
|
|
425
547
|
}
|
|
426
|
-
|
|
427
|
-
function withoutCacheControl(value: unknown): unknown {
|
|
428
|
-
if (Array.isArray(value)) return value.map(withoutCacheControl);
|
|
429
|
-
if (!isRecord(value)) return value;
|
|
430
|
-
const out: Record<string, unknown> = {};
|
|
431
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
432
|
-
if (key !== "cache_control") out[key] = withoutCacheControl(entry);
|
|
433
|
-
}
|
|
434
|
-
return out;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
438
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
439
|
-
}
|
|
@@ -26,6 +26,17 @@ export function toolStubs(names: readonly string[], reason = "Unavailable for th
|
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The messages the current branch holds, as pi builds a request from them
|
|
31
|
+
* (compaction-aware), read when the side call runs. A copy kept from the last
|
|
32
|
+
* `context` event goes stale at a `/tree` or a compaction, which fire no
|
|
33
|
+
* `context` event, and the fallback then answered from the abandoned branch or
|
|
34
|
+
* the whole pre-compaction history.
|
|
35
|
+
*/
|
|
36
|
+
export function branchMessages<M>(ctx: { sessionManager: { buildSessionProjection(): { messages: M[] } } }): M[] {
|
|
37
|
+
return ctx.sessionManager.buildSessionProjection().messages;
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/**
|
|
30
41
|
* Trim a message history forward to the first clean turn boundary — a `user`
|
|
31
42
|
* prompt or an `assistant` reply. A raw history can begin mid-exchange with a
|
|
@@ -104,7 +104,6 @@ export function subagentHandbackExtension(slot: HandbackSlot, options: { deferre
|
|
|
104
104
|
pi.on("session_start", () => {
|
|
105
105
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
106
106
|
text: options.deferred ? `${SUBAGENT_HANDBACK_LOAD_FIRST}\n${SUBAGENT_HANDBACK_REMINDER}` : SUBAGENT_HANDBACK_REMINDER,
|
|
107
|
-
scope: "every-turn",
|
|
108
107
|
key: "subagent-handback",
|
|
109
108
|
placement: "sticky-append",
|
|
110
109
|
} satisfies ReminderPayload);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Claude Code's `tool_addition` for tools that arrive mid-session (pure). One
|
|
3
|
-
* Code
|
|
4
|
-
* (lib/deferred.ts deferredAddendumText), which reads correctly on any model.
|
|
3
|
+
* Code renders the arrival as the deferred-tool addendum on the request's
|
|
4
|
+
* tail (lib/deferred.ts deferredAddendumText), which reads correctly on any model.
|
|
5
5
|
* On a first-party model that takes mid-conversation tool changes, the
|
|
6
6
|
* addendum leaves the message that carries it for a system message right
|
|
7
7
|
* after it: Claude Code's sentence and names, then one reference-form
|
|
@@ -127,23 +127,3 @@ export function withToolAdditions(
|
|
|
127
127
|
if (all.size === 0) return undefined;
|
|
128
128
|
return { payload: { ...payload, messages: reseatMessageMark(out) }, names: [...all] };
|
|
129
129
|
}
|
|
130
|
-
|
|
131
|
-
/** A stored message as far as the addendum scan is concerned. */
|
|
132
|
-
type BranchEntry = { type?: string; message?: { role?: string; content?: unknown } };
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The names every addendum persisted into a tool result on the branch
|
|
136
|
-
* announced: on resume they are re-activated, since their addition blocks
|
|
137
|
-
* are rebuilt from the same stored blocks and the model may call them.
|
|
138
|
-
*/
|
|
139
|
-
export function addendumNamesOnBranch(branch: readonly BranchEntry[]): string[] {
|
|
140
|
-
const names = new Set<string>();
|
|
141
|
-
for (const entry of branch) {
|
|
142
|
-
const message = entry?.type === "message" ? entry.message : undefined;
|
|
143
|
-
if (message?.role !== "toolResult" || !Array.isArray(message.content)) continue;
|
|
144
|
-
for (const block of message.content as StoredBlock[]) {
|
|
145
|
-
for (const name of (typeof block?.text === "string" ? addendumNames(block.text) : undefined) ?? []) names.add(name);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return [...names];
|
|
149
|
-
}
|
|
@@ -73,6 +73,8 @@ export interface TurnBudgetOptions {
|
|
|
73
73
|
nudge?: boolean;
|
|
74
74
|
/** False when the budget is off: no prompt carries a marker, so a user's own tag stays text. */
|
|
75
75
|
markers?: boolean;
|
|
76
|
+
/** False when the list is a request's tail: its first user message is a later prompt, not the stack carrier. Defaults to true. */
|
|
77
|
+
stackCarrier?: boolean;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
/** The call ids a wire message answers: Anthropic `tool_result` blocks, an OpenAI output item or tool message. */
|
|
@@ -85,6 +87,16 @@ function answeredCalls(message: WireMessage): string[] {
|
|
|
85
87
|
return [];
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/** Whether the next message after `index` that is not a system message is a user message. */
|
|
91
|
+
function followedByUser(messages: readonly unknown[], index: number): boolean {
|
|
92
|
+
for (let i = index + 1; i < messages.length; i++) {
|
|
93
|
+
const role = (messages[i] as WireMessage | undefined)?.role;
|
|
94
|
+
if (role === "system" || role === "developer") continue;
|
|
95
|
+
return role === "user" && !answeredCalls(messages[i] as WireMessage).length;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
export function withTurnBudgetMessages(payload: Record<string, unknown>, opts: TurnBudgetOptions): Record<string, unknown> | undefined {
|
|
89
101
|
const key = messagesKey(opts.shape);
|
|
90
102
|
const messages = payload[key];
|
|
@@ -94,7 +106,7 @@ export function withTurnBudgetMessages(payload: Record<string, unknown>, opts: T
|
|
|
94
106
|
/** The lowest countdown of the tool results not yet followed by their message. */
|
|
95
107
|
let pending: number | undefined;
|
|
96
108
|
/** The first user message carries the context stack, never a marker (context-budget's `skipStackCarrier`). */
|
|
97
|
-
let stackCarrierSeen = false;
|
|
109
|
+
let stackCarrierSeen = opts.stackCarrier === false;
|
|
98
110
|
for (let i = 0; i < messages.length; i++) {
|
|
99
111
|
let message = messages[i] as WireMessage;
|
|
100
112
|
const after: WireMessage[] = [];
|
|
@@ -111,7 +123,10 @@ export function withTurnBudgetMessages(payload: Record<string, unknown>, opts: T
|
|
|
111
123
|
const rest = at === -1 ? undefined : withoutParts(parts, (_, index) => index === at);
|
|
112
124
|
if (rest) {
|
|
113
125
|
const line = parts[at].text as string;
|
|
114
|
-
|
|
126
|
+
// A system message must reach an assistant message: before another
|
|
127
|
+
// user message (a prompt whose request failed, pi having dropped the
|
|
128
|
+
// error reply) the line stays on the prompt, framed (findings §52).
|
|
129
|
+
if (opts.systemRole && !followedByUser(messages, i)) {
|
|
115
130
|
message = { ...message, content: rest };
|
|
116
131
|
after.push(systemMessage(opts.shape, opts.role, line));
|
|
117
132
|
} else {
|
|
@@ -127,6 +142,7 @@ export function withTurnBudgetMessages(payload: Record<string, unknown>, opts: T
|
|
|
127
142
|
// On the OpenAI APIs a batch of results is a run of items; one message follows the run.
|
|
128
143
|
const next = messages[i + 1] as WireMessage | undefined;
|
|
129
144
|
const runContinues = opts.shape !== "anthropic" && (next?.type === "function_call_output" || next?.role === "tool");
|
|
145
|
+
if (pending !== undefined && !runContinues && followedByUser(messages, i)) pending = undefined;
|
|
130
146
|
if (pending !== undefined && !runContinues) {
|
|
131
147
|
after.push(systemMessage(opts.shape, opts.role, `<total_tokens>${pending} tokens left</total_tokens>`));
|
|
132
148
|
if (opts.nudge) {
|
package/extensions/lsp/index.ts
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
* 1. A session-wide watcher (watcher.ts): new diagnostics — cross-file, all
|
|
6
6
|
* severities, deduplicated against what was already delivered — ride the
|
|
7
7
|
* tool result of the round that surfaced them as a raw `<new-diagnostics>`
|
|
8
|
-
* block (a reminder-queue one-shot,
|
|
9
|
-
*
|
|
10
|
-
* shows them), or the message that opens the next run when they arrived
|
|
8
|
+
* block (a reminder-queue one-shot, delivered on the trailing tool result
|
|
9
|
+
* of the next request — the exact place a Claude Code session shows them), or the message that opens the next run when they arrived
|
|
11
10
|
* while idle (`agent_start`, so a notification-opened run gets them too).
|
|
12
11
|
* Until 2026-09-05 they went out as a custom steer message of their own
|
|
13
12
|
* (a separate user turn on the wire). Editing a file clears its
|
|
@@ -289,11 +288,10 @@ export default function lspExtension(pi: ExtensionAPI) {
|
|
|
289
288
|
|
|
290
289
|
/**
|
|
291
290
|
* Queue the block where the model reads next. Raw (no `<system-reminder>`
|
|
292
|
-
* frame, as in Claude Code) and last-append:
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* it pins to the message that opened the run.
|
|
291
|
+
* frame, as in Claude Code) and last-append: persisted as a pending entry,
|
|
292
|
+
* it rides the trailing tool result of the next request (the same mechanism
|
|
293
|
+
* as the file-tracker's per-round report); from `agent_start` it rides the
|
|
294
|
+
* message that opened the run.
|
|
297
295
|
*/
|
|
298
296
|
const queueNewDiagnostics = (text: string) => {
|
|
299
297
|
pi.events.emit(REMINDER_CHANNEL, { text, placement: "last-append", raw: true } satisfies ReminderPayload);
|