pum-agent 0.2.34-beta.2 → 0.2.35-beta.1
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 +10 -2
- package/package.json +1 -1
- package/src/animation.tsx +20 -7
- package/src/app.tsx +786 -40
- package/src/check-mode-prompt.ts +13 -13
- package/src/check-mode.ts +105 -10
- package/src/cli.ts +36 -1
- package/src/commands.ts +37 -1
- package/src/context-estimate.ts +55 -0
- package/src/context-guidance.ts +5 -5
- package/src/context-recovery.ts +22 -0
- package/src/context-window.ts +127 -48
- package/src/conversation-branch-popup.tsx +46 -0
- package/src/conversation-branch.ts +227 -0
- package/src/file-checkpoints.ts +337 -0
- package/src/file-mutation-guard.ts +224 -0
- package/src/filesystem-sandbox.ts +5 -2
- package/src/headless.ts +87 -9
- package/src/help-popup.tsx +6 -0
- package/src/index.tsx +2 -0
- package/src/lsp-files.ts +102 -0
- package/src/lsp-protocol.ts +275 -0
- package/src/lsp.ts +203 -0
- package/src/main.tsx +154 -16
- package/src/mcp-config.ts +100 -0
- package/src/mcp-process.ts +244 -0
- package/src/mcp-protocol.ts +385 -0
- package/src/mcp.ts +230 -0
- package/src/memory-context.ts +91 -0
- package/src/memory.ts +25 -23
- package/src/operational-context.ts +91 -0
- package/src/plan-mode.ts +139 -0
- package/src/project-validation.ts +331 -0
- package/src/replay.ts +6 -0
- package/src/request-diagnostics-access.ts +25 -0
- package/src/request-diagnostics.ts +210 -0
- package/src/runtime-settings.ts +352 -0
- package/src/sandbox/linux.ts +31 -6
- package/src/sandbox/types.ts +5 -0
- package/src/session-lock-runtime.ts +332 -25
- package/src/status-bar.tsx +2 -0
- package/src/status-metadata.ts +8 -1
- package/src/subagents/manager.ts +137 -23
- package/src/subagents/readonly.ts +34 -11
- package/src/theme.ts +10 -9
- package/src/tool-groups.ts +60 -26
- package/src/transcript-history.ts +93 -18
- package/src/web-search.ts +228 -33
package/src/context-window.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
4
|
import {
|
|
4
|
-
estimateTokens,
|
|
5
5
|
sessionEntryToContextMessages,
|
|
6
6
|
type AgentSession,
|
|
7
7
|
type ExtensionAPI,
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
} from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { registerTranscriptHistoryTool } from "./transcript-history";
|
|
13
13
|
import { CONTEXT_GUIDANCE } from "./context-guidance";
|
|
14
|
+
import { contextRecoveryGuidance } from "./context-recovery";
|
|
15
|
+
import { contextCalibration, contextUsage, estimateContextMessage as estimateTokens, estimateContextText,
|
|
16
|
+
CONTEXT_MESSAGE_TOKENS } from "./context-estimate";
|
|
14
17
|
|
|
15
18
|
export const CONTEXT_TOOL_NAMES = ["history", "get_context_remaining", "new_context"] as const;
|
|
16
19
|
export const CONTEXT_WINDOW_CUSTOM_TYPE = "pum.context_window";
|
|
@@ -18,16 +21,38 @@ export const CONTEXT_HANDOFF_MAX_CHARS = 20_000;
|
|
|
18
21
|
|
|
19
22
|
interface BoundaryData { version: 1; handoff?: string }
|
|
20
23
|
interface Pending { id: string; handoff?: string; signal?: AbortSignal; duplicate: boolean }
|
|
21
|
-
interface
|
|
24
|
+
interface EstimateFingerprint { hash: string; tokens: number }
|
|
25
|
+
interface PromptSnapshot { systemPrompt: EstimateFingerprint; tools: EstimateFingerprint; modelKey?: string }
|
|
22
26
|
interface RequestSnapshot extends PromptSnapshot {
|
|
23
27
|
windowId: string | null;
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
generation: number;
|
|
29
|
+
stateSystemPrompt: EstimateFingerprint;
|
|
30
|
+
stateTools: EstimateFingerprint;
|
|
26
31
|
injectedTokens: number;
|
|
32
|
+
estimatedInputTokens: number;
|
|
33
|
+
sourceHash: string;
|
|
34
|
+
sourceCount: number;
|
|
35
|
+
}
|
|
36
|
+
interface UsageSnapshot extends RequestSnapshot {
|
|
37
|
+
responseHash: string;
|
|
38
|
+
total: number;
|
|
39
|
+
factor: number;
|
|
40
|
+
limited: boolean;
|
|
41
|
+
eligible: boolean;
|
|
42
|
+
growth: number;
|
|
43
|
+
}
|
|
44
|
+
function fingerprint(text: string): EstimateFingerprint {
|
|
45
|
+
return { hash: createHash("sha256").update(text).digest("hex"), tokens: estimateContextText(text) };
|
|
46
|
+
}
|
|
47
|
+
function messageHash(messages: readonly AgentMessage[]): string {
|
|
48
|
+
const hash = createHash("sha256");
|
|
49
|
+
for (const message of messages) hash.update(JSON.stringify(message)).update("\n");
|
|
50
|
+
return hash.digest("hex");
|
|
27
51
|
}
|
|
28
52
|
const MANUAL_COMPACTION_REFUSAL = "Manual /compress is unavailable after a PUM context rollover on the active branch. Use new_context instead. The full transcript is retained.";
|
|
29
53
|
function modelIdentity(model: AgentSession["model"]): string | undefined {
|
|
30
|
-
return model
|
|
54
|
+
return model && Number.isFinite(model.contextWindow) && model.contextWindow > 0
|
|
55
|
+
? JSON.stringify([model.provider, model.id, model.api, model.contextWindow]) : undefined;
|
|
31
56
|
}
|
|
32
57
|
|
|
33
58
|
function object(value: unknown): value is Record<string, unknown> {
|
|
@@ -47,11 +72,11 @@ function boundaryData(value: unknown): BoundaryData {
|
|
|
47
72
|
const { handoff } = handoffParams({ handoff: value.handoff });
|
|
48
73
|
return { version: 1, ...(handoff === undefined ? {} : { handoff }) };
|
|
49
74
|
}
|
|
50
|
-
function header(id: string, handoff?: string, navigation?: { userId?: string; previousId?: string | null }): AgentMessage {
|
|
75
|
+
function header(id: string, handoff?: string, navigation?: { userId?: string; previousId?: string | null }, recovery = ""): AgentMessage {
|
|
51
76
|
return {
|
|
52
77
|
role: "custom", customType: CONTEXT_WINDOW_CUSTOM_TYPE, display: false, timestamp: 0,
|
|
53
|
-
content: `Fresh PUM context window: ${id}. Earlier transcript entries remain
|
|
54
|
-
+ (navigation ? `\nHistory navigation: latest prior user entry ID: ${navigation.userId ?? "none"}; previous transcript entry ID: ${navigation.previousId ?? "none"}.
|
|
78
|
+
content: `Fresh PUM context window: ${id}. Earlier transcript entries remain retained in the full session transcript. The rollover generated no summary. Current system instructions still apply. ${recovery}`
|
|
79
|
+
+ (navigation ? `\nHistory navigation: latest prior user entry ID: ${navigation.userId ?? "none"}; previous transcript entry ID: ${navigation.previousId ?? "none"}. These IDs are navigation metadata, not a requirement to restore earlier content.` : "")
|
|
55
80
|
+ (handoff === undefined ? "" : `\n\nLiteral handoff supplied to new_context:\n${handoff}`),
|
|
56
81
|
};
|
|
57
82
|
}
|
|
@@ -73,7 +98,11 @@ export class ContextWindowController {
|
|
|
73
98
|
private usageFloor = 0;
|
|
74
99
|
private preparedPrompt?: PromptSnapshot;
|
|
75
100
|
private requestSnapshot?: RequestSnapshot;
|
|
76
|
-
private usageSnapshots = new WeakMap<AgentMessage,
|
|
101
|
+
private usageSnapshots = new WeakMap<AgentMessage, UsageSnapshot>();
|
|
102
|
+
private latestUsageSnapshot?: UsageSnapshot;
|
|
103
|
+
private generation = 0;
|
|
104
|
+
|
|
105
|
+
constructor(private readonly options: { memoryInjected?: boolean } = {}) {}
|
|
77
106
|
|
|
78
107
|
extension(): InlineExtension {
|
|
79
108
|
return { name: "pum-context-window", factory: (pi: ExtensionAPI) => {
|
|
@@ -81,10 +110,10 @@ export class ContextWindowController {
|
|
|
81
110
|
const meter = this.remaining();
|
|
82
111
|
if (meter.reserveExceedsCapacity === true && typeof meter.remainingTokens === "number") {
|
|
83
112
|
const model = this.requireSession().model!;
|
|
84
|
-
return Math.max(0, meter.remainingTokens -
|
|
113
|
+
return this.historyBudget(Math.max(0, meter.remainingTokens - this.responseHeadroom(model.contextWindow)), meter);
|
|
85
114
|
}
|
|
86
115
|
return typeof meter.remainingBeforeReserve === "number" && typeof meter.remainingTokens === "number"
|
|
87
|
-
? Math.min(meter.remainingBeforeReserve, meter.remainingTokens) : undefined;
|
|
116
|
+
? this.historyBudget(Math.min(meter.remainingBeforeReserve, meter.remainingTokens), meter) : undefined;
|
|
88
117
|
});
|
|
89
118
|
pi.on("session_start", () => { this.disableAutomaticCompaction(); this.restore(); });
|
|
90
119
|
pi.on("session_tree", () => { this.pending = undefined; this.restore(); });
|
|
@@ -92,6 +121,7 @@ export class ContextWindowController {
|
|
|
92
121
|
const key = modelIdentity(event.model);
|
|
93
122
|
if (key !== this.modelKey) {
|
|
94
123
|
this.modelKey = key;
|
|
124
|
+
this.generation++;
|
|
95
125
|
this.usageFloor = this.session?.agent.state.messages.length ?? 0;
|
|
96
126
|
}
|
|
97
127
|
});
|
|
@@ -154,8 +184,18 @@ export class ContextWindowController {
|
|
|
154
184
|
this.preparedPrompt = undefined;
|
|
155
185
|
this.requestSnapshot = undefined;
|
|
156
186
|
} else if (event.type === "message_end" && event.message.role === "assistant") {
|
|
157
|
-
|
|
187
|
+
const snapshot = this.requestSnapshot;
|
|
158
188
|
this.requestSnapshot = undefined;
|
|
189
|
+
const usage = contextUsage(event.message.usage);
|
|
190
|
+
if (snapshot && snapshot.modelKey && usage && ["stop", "toolUse", "length"].includes(event.message.stopReason)
|
|
191
|
+
&& snapshot.generation === this.generation && snapshot.modelKey === modelIdentity(session.model)
|
|
192
|
+
&& event.message.provider === session.model?.provider && event.message.model === session.model?.id
|
|
193
|
+
&& event.message.api === session.model?.api) {
|
|
194
|
+
const paired = { ...snapshot, responseHash: messageHash([event.message]),
|
|
195
|
+
total: usage.total, ...contextCalibration(usage.input, snapshot.estimatedInputTokens), growth: 0 };
|
|
196
|
+
this.usageSnapshots.set(event.message, paired);
|
|
197
|
+
this.latestUsageSnapshot = paired;
|
|
198
|
+
}
|
|
159
199
|
}
|
|
160
200
|
});
|
|
161
201
|
// Reload and unrelated settings saves replace effective overrides. Native
|
|
@@ -171,17 +211,34 @@ export class ContextWindowController {
|
|
|
171
211
|
const transform = session.agent.transformContext;
|
|
172
212
|
session.agent.transformContext = async (messages, signal) => {
|
|
173
213
|
const originalTokens = messages.reduce((sum, message) => sum + estimateTokens(message), 0);
|
|
174
|
-
const stateSystemPrompt = session.agent.state.systemPrompt;
|
|
214
|
+
const stateSystemPrompt = fingerprint(session.agent.state.systemPrompt);
|
|
175
215
|
const stateTools = this.toolSchemas();
|
|
176
216
|
const prompt = this.preparedPrompt ?? { systemPrompt: stateSystemPrompt, tools: stateTools, modelKey: modelIdentity(session.model) };
|
|
177
217
|
this.requestSnapshot = undefined;
|
|
218
|
+
const generation = this.generation;
|
|
219
|
+
const windowId = this.windowId;
|
|
220
|
+
const sourceHash = messageHash(messages);
|
|
221
|
+
const sourceCount = messages.length;
|
|
178
222
|
const transformed = transform ? await transform.call(session.agent, messages, signal) : messages;
|
|
179
223
|
// Observe the complete extension chain, including memory injected after our
|
|
180
224
|
// extension. Return it unchanged; memory and dynamic instructions stay active.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
225
|
+
if (generation === this.generation && !signal?.aborted) {
|
|
226
|
+
this.observedExtraTokens = Math.max(0, transformed.reduce((sum, message) => sum + estimateTokens(message), 0)
|
|
227
|
+
- originalTokens);
|
|
228
|
+
const measured = this.latestUsageSnapshot;
|
|
229
|
+
if (measured?.generation === generation && measured.modelKey === prompt.modelKey) {
|
|
230
|
+
// A failed request may have observed growth followed by shrinkage
|
|
231
|
+
// without an intervening meter call. Do not reclaim that observation.
|
|
232
|
+
measured.growth = Math.max(measured.growth,
|
|
233
|
+
Math.max(0, prompt.systemPrompt.tokens - measured.systemPrompt.tokens)
|
|
234
|
+
+ Math.max(0, prompt.tools.tokens - measured.tools.tokens)
|
|
235
|
+
+ Math.max(0, this.observedExtraTokens - measured.injectedTokens));
|
|
236
|
+
}
|
|
237
|
+
this.requestSnapshot = { ...prompt, windowId, generation, stateSystemPrompt, stateTools,
|
|
238
|
+
injectedTokens: this.observedExtraTokens, sourceHash, sourceCount,
|
|
239
|
+
estimatedInputTokens: transformed.reduce((sum, message) => sum + estimateTokens(message), 0)
|
|
240
|
+
+ prompt.systemPrompt.tokens + prompt.tools.tokens };
|
|
241
|
+
}
|
|
185
242
|
return transformed;
|
|
186
243
|
};
|
|
187
244
|
const previous = session.agent.prepareNextTurnWithContext;
|
|
@@ -193,7 +250,7 @@ export class ContextWindowController {
|
|
|
193
250
|
const input = fresh ? { ...turn, context: { ...turn.context, messages: session.agent.state.messages.slice() } } : turn;
|
|
194
251
|
const update = previous ? await previous.call(session.agent, input, signal) : await legacy?.call(session.agent, signal);
|
|
195
252
|
const context = update?.context ?? input.context;
|
|
196
|
-
this.preparedPrompt = { systemPrompt: context.systemPrompt, tools: this.toolSchemas(context.tools),
|
|
253
|
+
this.preparedPrompt = { systemPrompt: fingerprint(context.systemPrompt), tools: this.toolSchemas(context.tools),
|
|
197
254
|
modelKey: modelIdentity(update?.model ?? session.model) };
|
|
198
255
|
return fresh ? { ...update, context } : update;
|
|
199
256
|
};
|
|
@@ -215,11 +272,13 @@ export class ContextWindowController {
|
|
|
215
272
|
const branch = session.sessionManager.getBranch();
|
|
216
273
|
const index = branch.findLastIndex(isBoundary);
|
|
217
274
|
this.windowId = index < 0 ? null : branch[index]!.id;
|
|
275
|
+
this.generation++;
|
|
218
276
|
this.usageFloor = 0;
|
|
219
277
|
this.modelKey = modelIdentity(session.model);
|
|
220
278
|
this.requestSnapshot = undefined;
|
|
221
279
|
this.preparedPrompt = undefined;
|
|
222
280
|
this.usageSnapshots = new WeakMap();
|
|
281
|
+
this.latestUsageSnapshot = undefined;
|
|
223
282
|
if (index < 0) { this.restoreUsageFloor(branch); return; }
|
|
224
283
|
const boundary = branch[index]!;
|
|
225
284
|
if (boundary.type !== "custom") return;
|
|
@@ -239,7 +298,7 @@ export class ContextWindowController {
|
|
|
239
298
|
const latestUser = branch.slice(0, index).findLast((entry) => entry.type === "message" && entry.message.role === "user");
|
|
240
299
|
session.agent.state.messages = [header(boundary.id, data.handoff, {
|
|
241
300
|
userId: latestUser?.id, previousId: boundary.parentId,
|
|
242
|
-
}), ...entries.flatMap(sessionEntryToContextMessages)];
|
|
301
|
+
}, this.recoveryGuidance()), ...entries.flatMap(sessionEntryToContextMessages)];
|
|
243
302
|
this.restoreUsageFloor(branch);
|
|
244
303
|
this.refreshPending = true;
|
|
245
304
|
}
|
|
@@ -292,12 +351,12 @@ export class ContextWindowController {
|
|
|
292
351
|
const latestUser = branch.findLast((entry) => entry.type === "message" && entry.message.role === "user");
|
|
293
352
|
const freshTokens = estimateTokens(header("pending", handoff, {
|
|
294
353
|
userId: latestUser?.id, previousId: session.sessionManager.getLeafId(),
|
|
295
|
-
})) + this.overheadTokens();
|
|
354
|
+
}, this.recoveryGuidance())) + this.overheadTokens();
|
|
296
355
|
const configuredReserve = this.reserveTokens();
|
|
297
356
|
// A default reserve can exceed a small model's entire window. It is not an
|
|
298
357
|
// automatic threshold; use bounded output headroom for explicit rollover.
|
|
299
358
|
const reserve = capacity && configuredReserve >= capacity
|
|
300
|
-
?
|
|
359
|
+
? this.responseHeadroom(capacity) : configuredReserve;
|
|
301
360
|
if (!capacity || !Number.isFinite(capacity) || freshTokens >= Math.max(0, capacity - reserve)) {
|
|
302
361
|
throw new Error("The handoff and prompt overhead do not fit the current model's fresh context with response headroom.");
|
|
303
362
|
}
|
|
@@ -306,36 +365,55 @@ export class ContextWindowController {
|
|
|
306
365
|
const reserve = this.requireSession().settingsManager.getCompactionSettings().reserveTokens;
|
|
307
366
|
return Number.isFinite(reserve) ? Math.max(0, reserve) : 0;
|
|
308
367
|
}
|
|
309
|
-
private
|
|
310
|
-
return
|
|
368
|
+
private recoveryGuidance(): string {
|
|
369
|
+
return contextRecoveryGuidance(this.requireSession().agent.state.tools.map((tool) => tool.name),
|
|
370
|
+
this.options.memoryInjected === true);
|
|
371
|
+
}
|
|
372
|
+
private responseHeadroom(capacity: number): number {
|
|
373
|
+
const max = this.requireSession().model?.maxTokens;
|
|
374
|
+
return Math.min(typeof max === "number" && Number.isFinite(max) && max > 0 ? max : 1024, Math.floor(capacity / 4));
|
|
375
|
+
}
|
|
376
|
+
private historyBudget(available: number, meter: Record<string, unknown>): number {
|
|
377
|
+
const factor = typeof meter.calibrationFactor === "number" ? meter.calibrationFactor : 1;
|
|
378
|
+
return Math.max(0, Math.floor(available / factor) - CONTEXT_MESSAGE_TOKENS);
|
|
379
|
+
}
|
|
380
|
+
private toolSchemas(tools = this.requireSession().agent.state.tools): EstimateFingerprint {
|
|
381
|
+
const schema = fingerprint(JSON.stringify(tools.map((tool) => ({
|
|
311
382
|
name: tool.name, description: tool.description, parameters: tool.parameters,
|
|
312
|
-
})));
|
|
383
|
+
}))));
|
|
384
|
+
// Provider envelopes/deferred references differ. Reserve framing per active
|
|
385
|
+
// definition; measured requests already include this, so only growth is added.
|
|
386
|
+
schema.tokens += tools.length * 32;
|
|
387
|
+
return schema;
|
|
313
388
|
}
|
|
314
389
|
private overheadTokens(): number {
|
|
315
390
|
const state = this.requireSession().agent.state;
|
|
316
|
-
return Math.
|
|
391
|
+
return Math.max(estimateContextText(state.systemPrompt), this.preparedPrompt?.systemPrompt.tokens ?? 0)
|
|
392
|
+
+ Math.max(this.toolSchemas().tokens, this.preparedPrompt?.tools.tokens ?? 0) + this.observedExtraTokens;
|
|
317
393
|
}
|
|
318
|
-
private overheadGrowth(snapshot:
|
|
394
|
+
private overheadGrowth(snapshot: UsageSnapshot): number {
|
|
319
395
|
const state = this.requireSession().agent.state;
|
|
320
396
|
// A public next-turn hook can supply a request prompt different from state.
|
|
321
397
|
// Keep that effective baseline until state actually changes. Never subtract
|
|
322
398
|
// estimated shrinkage from measured provider usage or offset tool growth.
|
|
323
|
-
const
|
|
399
|
+
const statePrompt = fingerprint(state.systemPrompt);
|
|
400
|
+
const prompt = statePrompt.hash === snapshot.stateSystemPrompt.hash ? snapshot.systemPrompt : statePrompt;
|
|
324
401
|
const stateTools = this.toolSchemas();
|
|
325
|
-
const tools = stateTools === snapshot.stateTools ? snapshot.tools : stateTools;
|
|
326
|
-
|
|
327
|
-
+ Math.
|
|
328
|
-
+ Math.max(0, this.observedExtraTokens - snapshot.injectedTokens);
|
|
402
|
+
const tools = stateTools.hash === snapshot.stateTools.hash ? snapshot.tools : stateTools;
|
|
403
|
+
snapshot.growth = Math.max(snapshot.growth, Math.max(0, prompt.tokens - snapshot.systemPrompt.tokens)
|
|
404
|
+
+ Math.max(0, tools.tokens - snapshot.tools.tokens)
|
|
405
|
+
+ Math.max(0, this.observedExtraTokens - snapshot.injectedTokens));
|
|
406
|
+
return snapshot.growth;
|
|
329
407
|
}
|
|
330
408
|
private remaining(): Record<string, unknown> {
|
|
331
409
|
const session = this.requireSession();
|
|
332
410
|
const model = session.model;
|
|
333
411
|
const messages = session.agent.state.messages;
|
|
334
412
|
const key = modelIdentity(model);
|
|
335
|
-
if (key !== this.modelKey) { this.modelKey = key; this.usageFloor = messages.length; }
|
|
413
|
+
if (key !== this.modelKey) { this.modelKey = key; this.generation++; this.usageFloor = messages.length; }
|
|
336
414
|
let usageIndex = -1;
|
|
337
415
|
let usageTokens = 0;
|
|
338
|
-
let usageSnapshot:
|
|
416
|
+
let usageSnapshot: UsageSnapshot | undefined;
|
|
339
417
|
for (let index = messages.length - 1; index >= this.usageFloor; index--) {
|
|
340
418
|
const message = messages[index]!;
|
|
341
419
|
if (message.role !== "assistant") continue;
|
|
@@ -343,21 +421,19 @@ export class ContextWindowController {
|
|
|
343
421
|
if (message.provider !== model?.provider || message.model !== model?.id) break;
|
|
344
422
|
const snapshot = this.usageSnapshots.get(message);
|
|
345
423
|
// A restored usage count has no trustworthy prompt/schema baseline.
|
|
346
|
-
if (!snapshot || snapshot.modelKey !== key || snapshot.windowId !== this.windowId
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const total = Number.isFinite(usage.totalTokens) && usage.totalTokens >= 0 ? usage.totalTokens : 0;
|
|
354
|
-
if (Math.max(components, total) > 0) {
|
|
355
|
-
usageTokens = Math.max(components, total); usageIndex = index; usageSnapshot = snapshot; break;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
424
|
+
if (!snapshot || snapshot.modelKey !== key || snapshot.windowId !== this.windowId
|
|
425
|
+
|| snapshot.generation !== this.generation) continue;
|
|
426
|
+
// A non-append source or modified response invalidates this projection.
|
|
427
|
+
// Fall back once rather than hashing every older prefix (quadratic work).
|
|
428
|
+
if (snapshot.sourceCount !== index || snapshot.responseHash !== messageHash([message])
|
|
429
|
+
|| snapshot.sourceHash !== messageHash(messages.slice(0, index))) break;
|
|
430
|
+
usageTokens = snapshot.total; usageIndex = index; usageSnapshot = snapshot; break;
|
|
358
431
|
}
|
|
359
|
-
const
|
|
360
|
-
const
|
|
432
|
+
const rawTrailing = messages.slice(usageIndex + 1).reduce((sum, message) => sum + estimateTokens(message), 0);
|
|
433
|
+
const rawOverhead = usageSnapshot ? this.overheadGrowth(usageSnapshot) : this.overheadTokens();
|
|
434
|
+
const factor = usageSnapshot?.factor ?? 1;
|
|
435
|
+
const trailing = Math.ceil(rawTrailing * factor);
|
|
436
|
+
const overhead = Math.ceil(rawOverhead * factor);
|
|
361
437
|
const used = Math.ceil(usageTokens + trailing + overhead);
|
|
362
438
|
const capacity = model && Number.isFinite(model.contextWindow) && model.contextWindow > 0 ? model.contextWindow : null;
|
|
363
439
|
const reserve = this.reserveTokens();
|
|
@@ -368,7 +444,10 @@ export class ContextWindowController {
|
|
|
368
444
|
remainingBeforeReserve: capacity === null ? null : Math.max(0, capacity - reserve - used),
|
|
369
445
|
source: usageIndex < 0 ? "estimate" : trailing > 0 || overhead > 0 ? "provider_usage_plus_estimate" : "provider_usage",
|
|
370
446
|
providerUsageTokens: usageTokens, estimatedTrailingTokens: trailing, estimatedOverheadTokens: overhead,
|
|
371
|
-
|
|
447
|
+
calibrationFactor: factor, calibrationLimited: usageSnapshot?.limited ?? false,
|
|
448
|
+
calibrationEligible: usageSnapshot?.eligible ?? false,
|
|
449
|
+
uncalibratedTrailingTokens: rawTrailing, uncalibratedOverheadTokens: rawOverhead,
|
|
450
|
+
note: "Remaining capacity is approximate. Provider usage requires a matching request, model capacity, and active window. Conservative estimates use UTF-8 bytes / 3, 1200 tokens per image and message framing. The upward-only calibration factor (1–2, prompt samples >=1024 estimated tokens) applies only to heuristic tail and positive overhead growth, never measured totals. Growth stays at its per-anchor high-water mark until a newer measured response; shrinkage never reduces measured usage. Factor saturation is uncertainty, not an accuracy guarantee. Unobserved dynamic context, payload hooks and provider tokenization can differ. Without a request baseline, the full active context is estimated. No automatic rollover threshold is enabled. If the configured reserve exhausts capacity, explicit rollover uses bounded response headroom instead.",
|
|
372
451
|
};
|
|
373
452
|
}
|
|
374
453
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ConversationBranchPoint } from "./conversation-branch";
|
|
2
|
+
import { PopupFrame } from "./popup-frame";
|
|
3
|
+
import { truncateStatusText } from "./status-metadata";
|
|
4
|
+
import type { Theme } from "./theme";
|
|
5
|
+
|
|
6
|
+
/** Small pages also bound mounted rows; App alone owns keyboard and authority. */
|
|
7
|
+
export const CONVERSATION_BRANCH_PAGE_SIZE = 8;
|
|
8
|
+
|
|
9
|
+
export function ConversationBranchPopup({ theme, points, cursor, offset, total, confirming, terminalWidth, terminalHeight }: {
|
|
10
|
+
theme: Theme;
|
|
11
|
+
points: readonly ConversationBranchPoint[];
|
|
12
|
+
cursor: number;
|
|
13
|
+
offset: number;
|
|
14
|
+
total: number;
|
|
15
|
+
confirming: boolean;
|
|
16
|
+
terminalWidth: number;
|
|
17
|
+
terminalHeight: number;
|
|
18
|
+
}) {
|
|
19
|
+
const width = Math.max(1, Math.min(100, terminalWidth - 2));
|
|
20
|
+
const height = Math.max(1, Math.min(22, terminalHeight - 2));
|
|
21
|
+
const columns = Math.max(1, width - 4);
|
|
22
|
+
const selected = points[cursor];
|
|
23
|
+
const rows = Math.max(1, height - 12);
|
|
24
|
+
const start = Math.max(0, cursor - rows + 1);
|
|
25
|
+
return <PopupFrame theme={theme} terminalWidth={terminalWidth} terminalHeight={terminalHeight}
|
|
26
|
+
geometry={{ left: Math.max(0, Math.floor((terminalWidth - width) / 2)), top: Math.max(0, Math.floor((terminalHeight - height) / 2)), width, height }}
|
|
27
|
+
title=" Same-session conversation branch " padding={1} zIndex={110}>
|
|
28
|
+
<text content="SAME session and file; full tree and archives retained." fg={theme.fg} flexShrink={0} />
|
|
29
|
+
<text content="Files/code and CURRENT goals, todos, settings, News and tool groups are NOT rewound." fg={theme.warn} flexShrink={0} />
|
|
30
|
+
<text content="No summary, file copy or automatic submission. Runtime approvals are revoked." fg={theme.dim} flexShrink={0} />
|
|
31
|
+
<box height={1} flexShrink={0} />
|
|
32
|
+
{confirming && selected ? <>
|
|
33
|
+
<text content={selected.kind === "user" ? "Return BEFORE this user message; restore plain text as an untrusted draft." : "Continue AFTER this settled assistant endpoint; editor stays empty."} fg={theme.fg} flexShrink={0} />
|
|
34
|
+
<text content={truncateStatusText(selected.label, columns) ?? ""} fg={theme.accent} flexShrink={0} />
|
|
35
|
+
<text content={`${selected.currentPath ? "current path" : "alternate path"}${selected.archived ? " · archived window" : ""}`} fg={theme.dim} flexShrink={0} />
|
|
36
|
+
</> : <>
|
|
37
|
+
{points.length === 0 ? <text content="No safe conversation points available." fg={theme.dim} /> : points.slice(start, start + rows).map((point, index) => <text key={point.entryId}
|
|
38
|
+
content={truncateStatusText(`${start + index === cursor ? "› " : " "}${point.kind === "user" ? "BEFORE user" : "AFTER assistant"} · ${point.currentPath ? "current" : "alternate"}${point.archived ? " · archived" : ""} · ${point.label}`, columns) ?? ""}
|
|
39
|
+
fg={start + index === cursor ? theme.accent : theme.fg} bg={start + index === cursor ? theme.selectionBg : theme.popupBg}
|
|
40
|
+
height={1} flexShrink={0} wrapMode="none" />)}
|
|
41
|
+
</>}
|
|
42
|
+
<box flexGrow={1} />
|
|
43
|
+
<text content={confirming ? "Enter confirm branch · Esc back · Ctrl+C close" : `Points ${total ? offset + 1 : 0}–${offset + points.length} / ${total} · ↑↓ select · ←→ page`} fg={theme.dim} flexShrink={0} />
|
|
44
|
+
{!confirming ? <text content="Enter preview · Esc close" fg={theme.dim} flexShrink={0} /> : null}
|
|
45
|
+
</PopupFrame>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { AgentSession, ModelRuntime, SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export const CONVERSATION_BRANCH_CUSTOM_TYPE = "pum.conversation_branch";
|
|
5
|
+
const MAX_ENTRIES = 100_000;
|
|
6
|
+
const MAX_TEXT = 100_000;
|
|
7
|
+
const runtimeTokens = new WeakMap<AgentSession, { token: string; manager: SessionManager; id: string; cwd: string; file: string | undefined }>();
|
|
8
|
+
const inputTracking = new WeakMap<AgentSession, { disposed: boolean; nextTurnSeen: boolean }>();
|
|
9
|
+
|
|
10
|
+
/** Bind immediately after SDK creation, BEFORE extension startup/first admission.
|
|
11
|
+
* pi's public pending count excludes nextTurn custom messages, and it has no
|
|
12
|
+
* public getter or unique consumption receipt for that queue. Conservatively
|
|
13
|
+
* refuse branching for the remainder of a runtime that ever accepted nextTurn.
|
|
14
|
+
* No private SDK fields, content copies, or persisted trust markers are used.
|
|
15
|
+
*/
|
|
16
|
+
export function bindConversationBranchSession(session: AgentSession): void {
|
|
17
|
+
if (inputTracking.has(session)) return;
|
|
18
|
+
const state = { disposed: false, nextTurnSeen: false };
|
|
19
|
+
inputTracking.set(session, state);
|
|
20
|
+
const send = session.sendCustomMessage.bind(session);
|
|
21
|
+
session.sendCustomMessage = (...args) => {
|
|
22
|
+
if (state.disposed) return Promise.reject(new Error("The conversation runtime is disposed."));
|
|
23
|
+
if (args[1]?.deliverAs === "nextTurn") state.nextTurnSeen = true;
|
|
24
|
+
return send(...args);
|
|
25
|
+
};
|
|
26
|
+
const dispose = session.dispose.bind(session);
|
|
27
|
+
session.dispose = () => { state.disposed = true; dispose(); };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hasConversationBranchPendingInput(session: AgentSession): boolean {
|
|
31
|
+
const state = inputTracking.get(session);
|
|
32
|
+
return !state || state.disposed || state.nextTurnSeen || session.agent.hasQueuedMessages();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The SDK ignores recorded model/effort when the selected context has no
|
|
36
|
+
* messages. Explicitly restore navigation-anchored state even before root-user
|
|
37
|
+
* re-edit, using pi's catalog and creation options, never global-default writes.
|
|
38
|
+
*/
|
|
39
|
+
export function conversationBranchState(manager: SessionManager, models: ModelRuntime): {
|
|
40
|
+
model: AgentSession["model"]; thinkingLevel: AgentSession["thinkingLevel"];
|
|
41
|
+
} | undefined {
|
|
42
|
+
const entries = manager.getEntries();
|
|
43
|
+
if (!entries.some((entry) => entry.type === "custom" && entry.customType === CONVERSATION_BRANCH_CUSTOM_TYPE)) return;
|
|
44
|
+
// Selection limits must not become a new general resume limit after a session
|
|
45
|
+
// grows. Bound traversal by the already-loaded entry count instead, without
|
|
46
|
+
// copying the tree or letting a malformed cycle enter SDK context building.
|
|
47
|
+
let remaining = entries.length;
|
|
48
|
+
let cursor = manager.getLeafId();
|
|
49
|
+
let anchored = false;
|
|
50
|
+
while (cursor !== null) {
|
|
51
|
+
const entry = manager.getEntry(cursor);
|
|
52
|
+
if (!entry || remaining-- <= 0) throw new Error("Conversation branch history has invalid ancestry.");
|
|
53
|
+
if (entry.type === "custom" && entry.customType === CONVERSATION_BRANCH_CUSTOM_TYPE) anchored = true;
|
|
54
|
+
cursor = entry.parentId;
|
|
55
|
+
}
|
|
56
|
+
if (!anchored) return;
|
|
57
|
+
const state = manager.buildSessionContext();
|
|
58
|
+
const model = state.model ? models.getModel(state.model.provider, state.model.modelId) : undefined;
|
|
59
|
+
if (state.model && !model) throw new Error("The conversation branch's recorded model is unavailable. Refusing to substitute global defaults.");
|
|
60
|
+
if (!["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(state.thinkingLevel)) {
|
|
61
|
+
throw new Error("The conversation branch's recorded effort is invalid.");
|
|
62
|
+
}
|
|
63
|
+
return { model, thinkingLevel: state.thinkingLevel as AgentSession["thinkingLevel"] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An opaque, runtime-local confirmation snapshot, never command authority. */
|
|
67
|
+
export interface ConversationBranchSelection {
|
|
68
|
+
entryId: string;
|
|
69
|
+
runtimeToken: string;
|
|
70
|
+
leafId: string | null;
|
|
71
|
+
entryCount: number;
|
|
72
|
+
prefixDigest: string;
|
|
73
|
+
textDigest: string;
|
|
74
|
+
}
|
|
75
|
+
export interface ConversationBranchPoint {
|
|
76
|
+
selection: ConversationBranchSelection;
|
|
77
|
+
entryId: string;
|
|
78
|
+
kind: "user" | "assistant";
|
|
79
|
+
label: string;
|
|
80
|
+
currentPath: boolean;
|
|
81
|
+
archived: boolean;
|
|
82
|
+
}
|
|
83
|
+
export interface ConversationBranchPage {
|
|
84
|
+
points: ConversationBranchPoint[];
|
|
85
|
+
total: number;
|
|
86
|
+
nextOffset: number | null;
|
|
87
|
+
}
|
|
88
|
+
const digest = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
89
|
+
function token(session: AgentSession): string {
|
|
90
|
+
let value = runtimeTokens.get(session);
|
|
91
|
+
const manager = session.sessionManager;
|
|
92
|
+
if (!value) {
|
|
93
|
+
value = { token: randomUUID(), manager, id: manager.getSessionId(), cwd: manager.getCwd(), file: manager.getSessionFile() };
|
|
94
|
+
runtimeTokens.set(session, value);
|
|
95
|
+
}
|
|
96
|
+
return value.token;
|
|
97
|
+
}
|
|
98
|
+
/** Identity/ancestry only: never inspect private custom-entry data. */
|
|
99
|
+
function prefix(entries: SessionEntry[]): string {
|
|
100
|
+
const hash = createHash("sha256");
|
|
101
|
+
for (const entry of entries) hash.update(JSON.stringify([entry.id, entry.parentId, entry.type])).update("\n");
|
|
102
|
+
return hash.digest("hex");
|
|
103
|
+
}
|
|
104
|
+
function plainText(content: unknown, user: boolean): string | undefined {
|
|
105
|
+
if (typeof content === "string") return user && content.length <= MAX_TEXT && content.trim() ? content : undefined;
|
|
106
|
+
if (!Array.isArray(content) || !content.length || content.length > 4096) return;
|
|
107
|
+
let text = "";
|
|
108
|
+
for (const part of content) {
|
|
109
|
+
if (!part || typeof part !== "object") return;
|
|
110
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
111
|
+
if (text.length + part.text.length > MAX_TEXT) return;
|
|
112
|
+
text += part.text;
|
|
113
|
+
}
|
|
114
|
+
else if (!user && part.type === "thinking") continue; // Never read reasoning/signatures.
|
|
115
|
+
else return; // No attachment loss, tool payloads, or unknown content kinds.
|
|
116
|
+
if (text.length > MAX_TEXT) return;
|
|
117
|
+
}
|
|
118
|
+
return text.trim() ? text : undefined;
|
|
119
|
+
}
|
|
120
|
+
type State = { pending: ReadonlyMap<string, string>; valid: boolean };
|
|
121
|
+
type Candidate = { entry: SessionEntry; kind: "user" | "assistant" };
|
|
122
|
+
function project(session: AgentSession) {
|
|
123
|
+
const entries = session.sessionManager.getEntries();
|
|
124
|
+
if (entries.length > MAX_ENTRIES) throw new Error("Conversation branch history exceeds the safe selection limit.");
|
|
125
|
+
const byId = new Map<string, SessionEntry>();
|
|
126
|
+
const states = new Map<string, State>();
|
|
127
|
+
const candidates: Candidate[] = [];
|
|
128
|
+
const empty: State = { pending: new Map(), valid: true };
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (typeof entry.id !== "string" || !entry.id || entry.id.length > 128 || byId.has(entry.id)
|
|
131
|
+
|| (entry.parentId !== null && !byId.has(entry.parentId))) {
|
|
132
|
+
throw new Error("Conversation branch history has invalid ancestry.");
|
|
133
|
+
}
|
|
134
|
+
byId.set(entry.id, entry);
|
|
135
|
+
const parent = entry.parentId === null ? empty : states.get(entry.parentId)!;
|
|
136
|
+
let state = parent;
|
|
137
|
+
if (entry.type === "message") {
|
|
138
|
+
const message = entry.message;
|
|
139
|
+
if (!message || typeof message !== "object") throw new Error("Conversation branch history has an invalid message.");
|
|
140
|
+
if (message.role === "assistant") {
|
|
141
|
+
const boundedContent = Array.isArray(message.content) && message.content.length <= 4096;
|
|
142
|
+
const calls = boundedContent ? message.content.filter((part) => part?.type === "toolCall") : [];
|
|
143
|
+
const pending = new Map<string, string>();
|
|
144
|
+
let valid = parent.valid && parent.pending.size === 0 && boundedContent && message.stopReason !== "pending"
|
|
145
|
+
&& message.content.every((part) => part && ["text", "thinking", "toolCall"].includes(part.type));
|
|
146
|
+
for (const call of calls) {
|
|
147
|
+
if (typeof call.id !== "string" || !call.id || typeof call.name !== "string" || !call.name || pending.has(call.id)) valid = false;
|
|
148
|
+
pending.set(call.id, call.name);
|
|
149
|
+
}
|
|
150
|
+
if (pending.size > 256) valid = false;
|
|
151
|
+
state = { pending, valid };
|
|
152
|
+
const text = plainText(message.content, false);
|
|
153
|
+
if (valid && !pending.size && message.stopReason === "stop" && text !== undefined) {
|
|
154
|
+
candidates.push({ entry, kind: "assistant" });
|
|
155
|
+
}
|
|
156
|
+
} else if (message.role === "toolResult") {
|
|
157
|
+
const pending = new Map(parent.pending);
|
|
158
|
+
const valid = parent.valid && typeof message.toolCallId === "string" && typeof message.toolName === "string"
|
|
159
|
+
&& pending.has(message.toolCallId) && pending.get(message.toolCallId) === message.toolName;
|
|
160
|
+
pending.delete(message.toolCallId);
|
|
161
|
+
state = { pending, valid };
|
|
162
|
+
} else if (message.role === "user") {
|
|
163
|
+
state = { pending: parent.pending, valid: parent.valid && parent.pending.size === 0 };
|
|
164
|
+
const text = plainText(message.content, true);
|
|
165
|
+
if (state.valid && text !== undefined) candidates.push({ entry, kind: "user" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
states.set(entry.id, state);
|
|
169
|
+
}
|
|
170
|
+
const current = new Set<string>();
|
|
171
|
+
let cursor = session.sessionManager.getLeafId();
|
|
172
|
+
if (cursor !== null && !byId.has(cursor)) throw new Error("Conversation branch history has an invalid leaf.");
|
|
173
|
+
while (cursor !== null) { current.add(cursor); cursor = byId.get(cursor)!.parentId; }
|
|
174
|
+
// Structural metadata only: a boundary archives its ancestors, not its literal handoff.
|
|
175
|
+
const archived = new Set<string>();
|
|
176
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
177
|
+
const entry = entries[index]!;
|
|
178
|
+
if ((archived.has(entry.id) || (entry.type === "custom" && entry.customType === "pum.context_window")) && entry.parentId !== null) {
|
|
179
|
+
archived.add(entry.parentId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { entries, candidates, current, archived };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Bounded newest-first UI projection. No IO, private bodies, summaries, or mutation. */
|
|
186
|
+
export function listConversationBranchPoints(session: AgentSession, options: { offset?: number; limit?: number } = {}): ConversationBranchPage {
|
|
187
|
+
const offset = options.offset ?? 0;
|
|
188
|
+
const limit = options.limit ?? 20;
|
|
189
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 50) {
|
|
190
|
+
throw new Error("Invalid conversation branch page.");
|
|
191
|
+
}
|
|
192
|
+
const { entries, candidates, current, archived } = project(session);
|
|
193
|
+
const snapshot = { runtimeToken: token(session), leafId: session.sessionManager.getLeafId(), entryCount: entries.length, prefixDigest: prefix(entries) };
|
|
194
|
+
const ordered = candidates.reverse();
|
|
195
|
+
return {
|
|
196
|
+
points: ordered.slice(offset, offset + limit).map(({ entry, kind }) => {
|
|
197
|
+
const text = entry.type === "message" && "content" in entry.message ? plainText(entry.message.content, kind === "user")! : "";
|
|
198
|
+
return {
|
|
199
|
+
entryId: entry.id, kind,
|
|
200
|
+
label: text.slice(0, 512).replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, " ").replace(/\s+/g, " ").trim().slice(0, 160),
|
|
201
|
+
currentPath: current.has(entry.id), archived: archived.has(entry.id),
|
|
202
|
+
selection: { ...snapshot, entryId: entry.id, textDigest: digest(text) },
|
|
203
|
+
};
|
|
204
|
+
}),
|
|
205
|
+
total: ordered.length,
|
|
206
|
+
nextOffset: offset + limit < ordered.length ? offset + limit : null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Re-run safety, identity, leaf and immutable-prefix validation at confirmation. */
|
|
211
|
+
export function validateConversationBranchSelection(session: AgentSession, selection: ConversationBranchSelection): { targetId: string | null; editorText?: string } {
|
|
212
|
+
const stale = () => new Error("Conversation branch selection is stale or unsafe. Open /branch again.");
|
|
213
|
+
const bound = runtimeTokens.get(session);
|
|
214
|
+
const manager = session.sessionManager;
|
|
215
|
+
if (!bound || !selection || selection.runtimeToken !== bound.token || bound.manager !== manager
|
|
216
|
+
|| bound.id !== manager.getSessionId() || bound.cwd !== manager.getCwd() || bound.file !== manager.getSessionFile()
|
|
217
|
+
|| selection.leafId !== manager.getLeafId()) throw stale();
|
|
218
|
+
const { entries, candidates } = project(session);
|
|
219
|
+
if (selection.entryCount !== entries.length || selection.prefixDigest !== prefix(entries)) throw stale();
|
|
220
|
+
const candidate = candidates.find(({ entry }) => entry.id === selection.entryId);
|
|
221
|
+
if (!candidate || candidate.entry.type !== "message" || !("content" in candidate.entry.message)) throw stale();
|
|
222
|
+
const text = plainText(candidate.entry.message.content, candidate.kind === "user");
|
|
223
|
+
if (text === undefined || selection.textDigest !== digest(text)) throw stale();
|
|
224
|
+
return candidate.kind === "user"
|
|
225
|
+
? { targetId: candidate.entry.parentId, editorText: text }
|
|
226
|
+
: { targetId: candidate.entry.id };
|
|
227
|
+
}
|