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.
Files changed (48) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -1
  3. package/src/animation.tsx +20 -7
  4. package/src/app.tsx +786 -40
  5. package/src/check-mode-prompt.ts +13 -13
  6. package/src/check-mode.ts +105 -10
  7. package/src/cli.ts +36 -1
  8. package/src/commands.ts +37 -1
  9. package/src/context-estimate.ts +55 -0
  10. package/src/context-guidance.ts +5 -5
  11. package/src/context-recovery.ts +22 -0
  12. package/src/context-window.ts +127 -48
  13. package/src/conversation-branch-popup.tsx +46 -0
  14. package/src/conversation-branch.ts +227 -0
  15. package/src/file-checkpoints.ts +337 -0
  16. package/src/file-mutation-guard.ts +224 -0
  17. package/src/filesystem-sandbox.ts +5 -2
  18. package/src/headless.ts +87 -9
  19. package/src/help-popup.tsx +6 -0
  20. package/src/index.tsx +2 -0
  21. package/src/lsp-files.ts +102 -0
  22. package/src/lsp-protocol.ts +275 -0
  23. package/src/lsp.ts +203 -0
  24. package/src/main.tsx +154 -16
  25. package/src/mcp-config.ts +100 -0
  26. package/src/mcp-process.ts +244 -0
  27. package/src/mcp-protocol.ts +385 -0
  28. package/src/mcp.ts +230 -0
  29. package/src/memory-context.ts +91 -0
  30. package/src/memory.ts +25 -23
  31. package/src/operational-context.ts +91 -0
  32. package/src/plan-mode.ts +139 -0
  33. package/src/project-validation.ts +331 -0
  34. package/src/replay.ts +6 -0
  35. package/src/request-diagnostics-access.ts +25 -0
  36. package/src/request-diagnostics.ts +210 -0
  37. package/src/runtime-settings.ts +352 -0
  38. package/src/sandbox/linux.ts +31 -6
  39. package/src/sandbox/types.ts +5 -0
  40. package/src/session-lock-runtime.ts +332 -25
  41. package/src/status-bar.tsx +2 -0
  42. package/src/status-metadata.ts +8 -1
  43. package/src/subagents/manager.ts +137 -23
  44. package/src/subagents/readonly.ts +34 -11
  45. package/src/theme.ts +10 -9
  46. package/src/tool-groups.ts +60 -26
  47. package/src/transcript-history.ts +93 -18
  48. package/src/web-search.ts +228 -33
@@ -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 PromptSnapshot { systemPrompt: string; tools: string; modelKey?: string }
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
- stateSystemPrompt: string;
25
- stateTools: string;
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 ? `${model.provider}/${model.id}:${model.contextWindow}` : undefined;
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 available through history. The rollover generated no summary. Restore project memory and the session todo list with the available tools. Recover exact user instructions and relevant results with history before continuing. Current system instructions still apply.`
54
- + (navigation ? `\nHistory navigation: latest prior user entry ID: ${navigation.userId ?? "none"}; previous transcript entry ID: ${navigation.previousId ?? "none"}. Use history with op "read" and entryId, then follow parentId links to recover exact earlier instructions.` : "")
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, RequestSnapshot>();
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 - Math.min(model.maxTokens, Math.floor(model.contextWindow / 4)));
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
- if (this.requestSnapshot) this.usageSnapshots.set(event.message, this.requestSnapshot);
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
- this.observedExtraTokens = Math.max(0, transformed.reduce((sum, message) => sum + estimateTokens(message), 0)
182
- - originalTokens);
183
- this.requestSnapshot = { ...prompt, windowId: this.windowId, stateSystemPrompt, stateTools,
184
- injectedTokens: this.observedExtraTokens };
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
- ? Math.min(model?.maxTokens ?? 0, Math.floor(capacity / 4)) : configuredReserve;
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 toolSchemas(tools = this.requireSession().agent.state.tools): string {
310
- return JSON.stringify(tools.map((tool) => ({
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.ceil((state.systemPrompt.length + this.toolSchemas().length) / 4) + this.observedExtraTokens;
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: RequestSnapshot): number {
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 prompt = state.systemPrompt === snapshot.stateSystemPrompt ? snapshot.systemPrompt : state.systemPrompt;
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
- return Math.ceil(Math.max(0, prompt.length - snapshot.systemPrompt.length) / 4)
327
- + Math.ceil(Math.max(0, tools.length - snapshot.tools.length) / 4)
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: RequestSnapshot | undefined;
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) continue;
347
- const usage = message.usage;
348
- if (!usage || typeof usage !== "object") continue;
349
- const values = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
350
- if (message.stopReason !== "error" && message.stopReason !== "aborted"
351
- && values.every((value) => Number.isFinite(value) && value >= 0)) {
352
- const components = values.reduce((sum, value) => sum + value, 0);
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 trailing = messages.slice(usageIndex + 1).reduce((sum, message) => sum + estimateTokens(message), 0);
360
- const overhead = usageSnapshot ? this.overheadGrowth(usageSnapshot) : this.overheadTokens();
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
- note: "Remaining capacity is approximate. Provider usage requires a matching request, model capacity, and active window. Conservative estimates add only positive prompt, tool-schema, and observed injected-context growth; shrinkage never reduces measured usage. Unobserved dynamic context 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.",
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
+ }