@bubblebrain-ai/bubble 0.0.58 → 0.0.60

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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
@@ -59,7 +59,9 @@ export function isCompactionSummaryMessage(message) {
59
59
  }
60
60
  /** A message the user actually typed (not a projected reminder, not a summary). */
61
61
  export function isRealUserMessage(message) {
62
- if (message.role !== "user")
62
+ // Tool screenshots ride in a user-role message for the provider, but they
63
+ // belong to the tool turn: never a turn boundary.
64
+ if (message.role !== "user" || message.toolObservation)
63
65
  return false;
64
66
  const text = messageText(message);
65
67
  if (!text.trim() || isInternalBlockContent(text))
@@ -197,10 +199,8 @@ export function compactSessionEntries(entries, options = {}) {
197
199
  export function compactMessages(messages, options = {}) {
198
200
  const keepRecentTurns = options.keepRecentTurns ?? 2;
199
201
  const maxSummaryItems = options.maxSummaryItems ?? 4;
200
- // Replace semantics: prior summaries (any form, any position) die here —
201
- // at most one summary exists after every compaction. Their content is
202
- // template-grade (goal truncated to 140 chars, constant next-steps line);
203
- // the pinned original instruction is the durable record, not old summaries.
202
+ // Replace envelopes, not their contents: prior LLM summaries can be the only
203
+ // surviving record of decisions and constraints. Merge their prose below.
204
204
  const { leading, body } = splitLeadingContext(messages);
205
205
  const priorSummaries = body.filter(isCompactionSummaryMessage);
206
206
  const bodyMessages = body.filter((message) => !isCompactionSummaryMessage(message));
@@ -232,7 +232,31 @@ export function compactMessages(messages, options = {}) {
232
232
  if (!summary) {
233
233
  return { compacted: false };
234
234
  }
235
- const summaryWithFiles = appendFileBlocks(summary, collectCompactionFileOps(oldMessages, priorSummaries));
235
+ // Flatten prior envelopes and dedupe lines instead of nesting summaries on
236
+ // every pass. Keep an explicitly bounded prose record (head + recent tail),
237
+ // with a durable warning when the heuristic cannot retain everything.
238
+ const pinOverflow = pinnedMessage
239
+ ? messageText(pinnedMessage).slice(PINNED_INSTRUCTION_MAX_CHARS)
240
+ : "";
241
+ const summaryLines = [
242
+ ...priorSummaries.map((message) => stripFileBlocks(messageText(message))),
243
+ ...(pinOverflow ? [`Original instruction beyond retained pin:\n${pinOverflow}`] : []),
244
+ summary,
245
+ ].join("\n")
246
+ .replace(/<\/?bubble_internal_(?:context|reminder)\b[^>]*>/g, "")
247
+ .split("\n")
248
+ .map((line) => line.trim()
249
+ .replace(/^Another language model previously worked on this task[^\n]*?Summary:\s*/, "")
250
+ .replace(/^Previous conversation summary:\s*/, "")
251
+ .replace(/^Earlier in this turn \(compacted to free context\):\s*/, ""))
252
+ .filter(Boolean);
253
+ const mergedSummary = [...new Set(summaryLines)].join("\n");
254
+ const proseLimit = 8192;
255
+ const overflowNotice = "\n[Heuristic compaction degraded: accumulated summary exceeded 8192 characters; middle omitted.]\n";
256
+ const boundedSummary = mergedSummary.length <= proseLimit ? mergedSummary
257
+ : mergedSummary.slice(0, 4096) + overflowNotice
258
+ + mergedSummary.slice(-(proseLimit - 4096 - overflowNotice.length));
259
+ const summaryWithFiles = appendFileBlocks(boundedSummary, collectCompactionFileOps(oldMessages, priorSummaries));
236
260
  const compactedMessages = [
237
261
  ...leading.map((message) => cloneMessage(message)),
238
262
  ...(pinnedMessage ? [clonePinnedUserMessage(pinnedMessage)] : []),
@@ -243,6 +267,11 @@ export function compactMessages(messages, options = {}) {
243
267
  compacted: true,
244
268
  summary: summaryWithFiles,
245
269
  messages: compactedMessages,
270
+ // The pin survives verbatim only up to its cap; the tail beyond it exists
271
+ // nowhere else, so an external summarizer must see it in the pin's place.
272
+ evictedMessages: [...priorSummaries, ...oldMessages.flatMap((message, index) => index !== pinnedIndex ? [message]
273
+ : pinOverflow ? [{ role: "user", content: `Original instruction beyond retained pin:\n${pinOverflow}` }] : [])],
274
+ summaryIndex: leading.length + (pinnedMessage ? 1 : 0),
246
275
  droppedEntries: summaryInput.length,
247
276
  };
248
277
  }
@@ -275,7 +304,9 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
275
304
  groups.push(current);
276
305
  current = { assistant: msg, toolResults: [] };
277
306
  }
278
- else if (msg.role === "tool" && current) {
307
+ else if ((msg.role === "tool" || (msg.role === "user" && msg.toolObservation)) && current) {
308
+ // A screenshot observation follows its tool result: it lives and dies
309
+ // with that group instead of vanishing from kept groups.
279
310
  current.toolResults.push(msg);
280
311
  }
281
312
  }
@@ -317,6 +348,8 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
317
348
  compacted: true,
318
349
  summary,
319
350
  messages: compactedMessages,
351
+ evictedMessages: [...priorSubturnSummaries, ...evictable.flatMap((g) => [g.assistant, ...g.toolResults])],
352
+ summaryIndex: leading.length + preTurn.length,
320
353
  droppedEntries: evictable.length,
321
354
  };
322
355
  }
@@ -1,11 +1,13 @@
1
1
  import type { Message, Provider } from "../types.js";
2
- export declare const LLM_COMPACTION_PROMPT = "You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n\nInclude:\n- Current progress and key decisions made\n- Important context, constraints, or user preferences\n- What remains to be done (clear next steps)\n- Any critical data, examples, or references needed to continue\n\nBe concise, structured, and focused on helping the next LLM seamlessly continue the work.";
2
+ export declare const LLM_COMPACTION_PROMPT = "You are performing a CONTEXT CHECKPOINT COMPACTION. The conversation below is about to be removed from the model's context and replaced by your summary. Another LLM will resume the task seeing only your summary, the user's original instruction, and the most recent messages. Anything you leave out is gone.\n\nWrite a handoff summary that lets it continue without repeating the investigation. Cover:\n- Goal and constraints: what the user wants, in their terms. Explicit requirements, preferences, and anything they ruled out. Quote exact wording where precision matters.\n- Decisions made and why, including approaches that were tried and rejected, so they are not retried.\n- Current state: what is done and verified, what is in progress, what is broken or still unverified. Keep verified facts distinct from assumptions.\n- Specifics needed to continue: file paths, identifiers, commands, error messages, config values, ids, URLs. Exact, never paraphrased.\n- Next steps in order, starting with the immediate one.\n\nLength follows content: as long as needed to preserve the above, and no longer. Spend words on what cannot be cheaply re-derived (decisions, constraints, findings). Do not reproduce file contents or tool output that can simply be read again, and do not narrate the conversation turn by turn. The lists of files read and modified are recorded separately; name only the files that matter for the next steps.\n\nCarry forward every fact from prior summaries and the original user constraints. If the input says parts of the history were omitted, say so explicitly.";
3
3
  export declare const LLM_SUMMARY_PREFIX = "Another language model previously worked on this task and produced this handoff summary. Build on what's already done; avoid re-running the same investigation. Summary:";
4
4
  export interface LLMCompactOptions {
5
5
  provider: Provider;
6
6
  modelId: string;
7
- /** Compactor model call must complete within this token-cost ceiling. */
7
+ /** Total estimated request ceiling, including the compaction prompt. */
8
8
  maxInputTokens?: number;
9
+ providerId?: string;
10
+ contextWindow?: number;
9
11
  /** Number of trailing (assistant + tool-results) groups in the current turn to keep verbatim. */
10
12
  keepRecentGroups?: number;
11
13
  abortSignal?: AbortSignal;
@@ -15,5 +17,37 @@ export interface LLMCompactResult {
15
17
  summary?: string;
16
18
  messages?: Message[];
17
19
  reason?: string;
20
+ /** Explicit lossy-input diagnostics; also persisted in a successful summary. */
21
+ degradation?: string;
18
22
  }
19
23
  export declare function compactWithLLM(messages: Message[], options: LLMCompactOptions): Promise<LLMCompactResult>;
24
+ /** Fit the summarization input under the model's window, degrading breadth-first:
25
+ * 1. everything verbatim;
26
+ * 2. cap tool payloads (results and tool-call arguments alike) at the LARGEST
27
+ * common length that fits — short payloads such as paths and commands stay
28
+ * whole, only long ones lose their middle, and every group still informs
29
+ * the summary;
30
+ * 3. only when even empty payloads do not fit, drop the oldest assistant/tool
31
+ * group and search again, so what survives is as complete as the room allows.
32
+ * The cap is derived from the budget, never a fixed size. Prior summaries, user
33
+ * constraints and runtime context are never trimmed; if they alone exceed the
34
+ * budget this fails explicitly and the caller chooses a fallback.
35
+ * `degradation` is the durable part: whole steps the summary never saw. Trimmed
36
+ * middles are stated in the input only — that loss is inherent to summarizing.
37
+ * Exported for regression tests only — callers use compactWithLLM. */
38
+ export declare function fitSummaryInput(messages: Message[], prompt: string, maxTokens: number, providerId: string): {
39
+ historyText: string;
40
+ degradation?: string;
41
+ } | undefined;
42
+ /** One trimming pass: the cap, how to price text, and whether anything was cut. */
43
+ interface PayloadTrim {
44
+ cap: number;
45
+ estimate: (text: string) => number;
46
+ trimmed: boolean;
47
+ }
48
+ /** Keep the head and tail of a payload longer than `cap`; the marker states
49
+ * exactly how much is missing so the summarizer never mistakes it for the whole.
50
+ * Returns the text unchanged (and leaves `trim.trimmed` alone) when cutting would
51
+ * not make it cheaper. Exported for regression tests only. */
52
+ export declare function capPayload(text: string, trim: PayloadTrim | undefined): string;
53
+ export {};
@@ -10,23 +10,41 @@
10
10
  // Failure modes are explicit: returns { compacted: false, reason } so the
11
11
  // caller can fall back to algorithmic compaction without an exception.
12
12
  import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
13
- import { estimateContextTokens } from "./budget.js";
13
+ import { estimateTextTokens, getMaxInputTokens } from "./budget.js";
14
+ import { getTokenEstimator } from "./token-estimator.js";
15
+ import { getModelContextWindow } from "../model-catalog.js";
14
16
  import { appendFileBlocks, stripFileBlocks } from "./compaction-files.js";
15
- import { collectCompactionFileOps, messageText, buildCompactionSummaryMessage, clonePinnedUserMessage, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, } from "./compact.js";
16
- export const LLM_COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
17
+ import { collectCompactionFileOps, messageText, buildCompactionSummaryMessage, clonePinnedUserMessage, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, PINNED_INSTRUCTION_MAX_CHARS, } from "./compact.js";
18
+ export const LLM_COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. The conversation below is about to be removed from the model's context and replaced by your summary. Another LLM will resume the task seeing only your summary, the user's original instruction, and the most recent messages. Anything you leave out is gone.
17
19
 
18
- Include:
19
- - Current progress and key decisions made
20
- - Important context, constraints, or user preferences
21
- - What remains to be done (clear next steps)
22
- - Any critical data, examples, or references needed to continue
20
+ Write a handoff summary that lets it continue without repeating the investigation. Cover:
21
+ - Goal and constraints: what the user wants, in their terms. Explicit requirements, preferences, and anything they ruled out. Quote exact wording where precision matters.
22
+ - Decisions made and why, including approaches that were tried and rejected, so they are not retried.
23
+ - Current state: what is done and verified, what is in progress, what is broken or still unverified. Keep verified facts distinct from assumptions.
24
+ - Specifics needed to continue: file paths, identifiers, commands, error messages, config values, ids, URLs. Exact, never paraphrased.
25
+ - Next steps in order, starting with the immediate one.
23
26
 
24
- Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
27
+ Length follows content: as long as needed to preserve the above, and no longer. Spend words on what cannot be cheaply re-derived (decisions, constraints, findings). Do not reproduce file contents or tool output that can simply be read again, and do not narrate the conversation turn by turn. The lists of files read and modified are recorded separately; name only the files that matter for the next steps.
28
+
29
+ Carry forward every fact from prior summaries and the original user constraints. If the input says parts of the history were omitted, say so explicitly.`;
25
30
  export const LLM_SUMMARY_PREFIX = `Another language model previously worked on this task and produced this handoff summary. Build on what's already done; avoid re-running the same investigation. Summary:`;
26
31
  export async function compactWithLLM(messages, options) {
27
32
  const { provider, modelId, abortSignal } = options;
28
- const maxInputTokens = options.maxInputTokens ?? 100_000;
29
- const keepRecentGroups = options.keepRecentGroups ?? 2;
33
+ if (abortSignal?.aborted)
34
+ return { compacted: false, reason: "compactor cancelled" };
35
+ const providerId = options.providerId ?? modelId.split(":")[0];
36
+ const catalogModelId = modelId.startsWith(`${providerId}:`) ? modelId.slice(providerId.length + 1) : modelId;
37
+ // Unknown models use a conservative window rather than an unbounded 100k call.
38
+ const contextWindow = options.contextWindow ?? getModelContextWindow(providerId, catalogModelId) ?? 8192;
39
+ // getMaxInputTokens already reserves the window's answer space, which is what
40
+ // the summary is written into. No fixed output ceiling: summary length is
41
+ // judged against what it replaces, below, and by the caller's before/after
42
+ // budget comparison.
43
+ const maxInputTokens = Math.min(options.maxInputTokens ?? Infinity, getMaxInputTokens(contextWindow) ?? 0);
44
+ if (![contextWindow, maxInputTokens].every((n) => Number.isFinite(n) && n > 0)) {
45
+ return { compacted: false, reason: "invalid compactor token budget" };
46
+ }
47
+ const keepRecentGroups = Math.max(0, Math.floor(options.keepRecentGroups ?? 2));
30
48
  // Positional leading prefix, not role-global filtering: filtering by role
31
49
  // used to relocate mid-history system messages (including prior summaries)
32
50
  // to the head, where the projector then fused them into the system prompt.
@@ -72,9 +90,13 @@ export async function compactWithLLM(messages, options) {
72
90
  groups.push(active);
73
91
  active = { assistant: msg, toolResults: [] };
74
92
  }
75
- else if (msg.role === "tool" && active) {
93
+ else if (active) {
76
94
  active.toolResults.push(msg);
77
95
  }
96
+ else {
97
+ // Preserve interleaved reminders and other context, not just tool results.
98
+ active = { assistant: msg, toolResults: [] };
99
+ }
78
100
  }
79
101
  if (active)
80
102
  groups.push(active);
@@ -91,6 +113,10 @@ export async function compactWithLLM(messages, options) {
91
113
  role: "user",
92
114
  content: `[Prior compaction summary]\n${stripFileBlocks(messageText(m))}`,
93
115
  })),
116
+ // The pin is capped; its tail is protected summary input, never silently lost.
117
+ ...(pinnedFirstUser && messageText(pinnedFirstUser).length > PINNED_INSTRUCTION_MAX_CHARS
118
+ ? [{ role: "user", content: `[Original instruction beyond retained pin]\n${messageText(pinnedFirstUser).slice(PINNED_INSTRUCTION_MAX_CHARS)}` }]
119
+ : []),
94
120
  ...summarizablePriorTurns,
95
121
  ...evictedGroups.flatMap((g) => [g.assistant, ...g.toolResults]),
96
122
  ];
@@ -101,12 +127,18 @@ export async function compactWithLLM(messages, options) {
101
127
  if (toSummarize.length === 0) {
102
128
  return { compacted: false, reason: "nothing to evict" };
103
129
  }
104
- const trimmedHistory = trimToFitTokenBudget(toSummarize, maxInputTokens);
105
- const historyText = serializeHistoryAsText(trimmedHistory);
130
+ const prompt = LLM_COMPACTION_PROMPT;
131
+ const fitted = fitSummaryInput(toSummarize, prompt, maxInputTokens, providerId);
132
+ if (!fitted) {
133
+ return { compacted: false, reason: "compactor input budget cannot retain prior summaries and user constraints" };
134
+ }
135
+ const { historyText, degradation } = fitted;
106
136
  const summaryInput = [
107
- { role: "system", content: LLM_COMPACTION_PROMPT },
137
+ { role: "system", content: prompt },
108
138
  { role: "user", content: historyText },
109
139
  ];
140
+ if (abortSignal?.aborted)
141
+ return { compacted: false, reason: "compactor cancelled" };
110
142
  let summaryText;
111
143
  try {
112
144
  summaryText = await provider.complete(summaryInput, {
@@ -118,6 +150,9 @@ export async function compactWithLLM(messages, options) {
118
150
  catch (err) {
119
151
  return { compacted: false, reason: `compactor call failed: ${err.message}` };
120
152
  }
153
+ // Some providers resolve even after abort; never apply that late result.
154
+ if (abortSignal?.aborted)
155
+ return { compacted: false, reason: "compactor cancelled" };
121
156
  if (!summaryText || summaryText.trim().length === 0) {
122
157
  return { compacted: false, reason: "compactor returned empty summary" };
123
158
  }
@@ -125,10 +160,20 @@ export async function compactWithLLM(messages, options) {
125
160
  // (resident history holds them after pruned-mode projection), and models
126
161
  // quote their input. This summary is persisted via onCompactionApplied, so
127
162
  // scrub markup before it can reach the session file.
128
- summaryText = sanitizeInternalReminderBlocks(summaryText).trim();
163
+ summaryText = stripFileBlocks(sanitizeInternalReminderBlocks(summaryText)).trim();
129
164
  if (!summaryText) {
130
165
  return { compacted: false, reason: "compactor returned only internal markup" };
131
166
  }
167
+ // The only meaningful length bound is relative: a summary that is not shorter
168
+ // than the history it replaces (a model echoing its input) compacts nothing.
169
+ // Judge the model's prose alone — the deterministic file lists are not its
170
+ // output — and reject rather than clip decisions/constraints out of it.
171
+ if (estimateTextTokens(summaryText, providerId) >= estimateTextTokens(serializeHistoryAsText(toSummarize), providerId)) {
172
+ return { compacted: false, reason: "compactor summary is not shorter than the history it replaces", degradation };
173
+ }
174
+ if (degradation)
175
+ summaryText = `${degradation}\n\n${summaryText}`;
176
+ const summaryWithFiles = appendFileBlocks(summaryText, fileOps);
132
177
  // New history shape (prefix-cache-friendly: preserved system+meta stay at the
133
178
  // absolute prefix unchanged; summary is injected after as a user-role envelope
134
179
  // so it can't pollute the cacheable system-prompt prefix):
@@ -152,23 +197,169 @@ export async function compactWithLLM(messages, options) {
152
197
  ];
153
198
  return {
154
199
  compacted: true,
200
+ degradation,
155
201
  // Same payload the resident message carries (minus the envelope prefix —
156
202
  // session replay adds its own "Previous conversation summary:" header), so
157
203
  // the persisted checkpoint matches the in-memory state, file blocks included.
158
- summary: appendFileBlocks(summaryText, fileOps),
204
+ summary: summaryWithFiles,
159
205
  messages: compacted,
160
206
  };
161
207
  }
162
- function trimToFitTokenBudget(messages, maxTokens) {
163
- // Drop from the front (oldest first) until estimate fits. Front-trim matches
164
- // Codex's pattern and preserves the most recent context the user cares about.
165
- let working = [...messages];
166
- while (working.length > 0 && estimateContextTokens(working) > maxTokens) {
167
- working = working.slice(1);
168
- }
169
- return working;
208
+ /** Fit the summarization input under the model's window, degrading breadth-first:
209
+ * 1. everything verbatim;
210
+ * 2. cap tool payloads (results and tool-call arguments alike) at the LARGEST
211
+ * common length that fits — short payloads such as paths and commands stay
212
+ * whole, only long ones lose their middle, and every group still informs
213
+ * the summary;
214
+ * 3. only when even empty payloads do not fit, drop the oldest assistant/tool
215
+ * group and search again, so what survives is as complete as the room allows.
216
+ * The cap is derived from the budget, never a fixed size. Prior summaries, user
217
+ * constraints and runtime context are never trimmed; if they alone exceed the
218
+ * budget this fails explicitly and the caller chooses a fallback.
219
+ * `degradation` is the durable part: whole steps the summary never saw. Trimmed
220
+ * middles are stated in the input only — that loss is inherent to summarizing.
221
+ * Exported for regression tests only — callers use compactWithLLM. */
222
+ export function fitSummaryInput(messages, prompt, maxTokens, providerId) {
223
+ // Group by pending tool-call ids, not adjacency: an interleaved meta reminder
224
+ // between a call and its result must not split the pair — otherwise dropping
225
+ // can remove the call while keeping an orphan `TOOL_RESULT[tool]` line that
226
+ // has lost its name and provenance.
227
+ const groups = [];
228
+ const groupByCallId = new Map();
229
+ for (const message of messages) {
230
+ if (message.role === "tool") {
231
+ const group = groupByCallId.get(message.toolCallId);
232
+ if (group) {
233
+ group.push(message);
234
+ continue;
235
+ }
236
+ }
237
+ const group = [message];
238
+ groups.push(group);
239
+ if (message.role === "assistant" && message.toolCalls) {
240
+ for (const toolCall of message.toolCalls)
241
+ groupByCallId.set(toolCall.id, group);
242
+ }
243
+ }
244
+ const promptTokens = estimateTextTokens(prompt, providerId);
245
+ const render = (cap, dropped, estimate = (text) => estimateTextTokens(text, providerId)) => {
246
+ const degradation = dropped
247
+ ? `[Compaction input degraded: omitted ${dropped} older assistant/tool groups to fit the model window.]`
248
+ : undefined;
249
+ const trim = cap === undefined ? undefined : { cap, estimate, trimmed: false };
250
+ const body = serializeHistoryAsText(groups.flat(), trim);
251
+ // The note is part of the cost, so it appears only when something was cut:
252
+ // an input with nothing to trim must measure exactly like the verbatim one.
253
+ const trimNote = trim?.trimmed
254
+ ? `[Tool results and tool-call arguments longer than ${cap} characters show only their head and tail.]`
255
+ : undefined;
256
+ const historyText = [degradation, trimNote, body].filter(Boolean).join("\n\n");
257
+ // Measure the actual serialized payload (including labels and prompt), with
258
+ // the same conservative first-turn safety margin as the context budget.
259
+ const tokens = Math.ceil((promptTokens + estimate(historyText) + 32) * 1.25);
260
+ // What the note itself costs: trimming that saves less than this makes the
261
+ // input LARGER than the verbatim one.
262
+ const noteTokens = trimNote ? Math.ceil((estimate(trimNote) + 2) * 1.25) + 1 : 0;
263
+ return { fits: tokens <= maxTokens, overBy: tokens - maxTokens, noteTokens, trimmed: !!trim?.trimmed, fitted: { historyText, degradation } };
264
+ };
265
+ /** Largest cap the predicate accepts, assuming it is monotone; the returned
266
+ * cap is always one that was actually probed and accepted. */
267
+ const searchCap = (longest, accepts) => {
268
+ let low = 0;
269
+ let high = longest;
270
+ while (low < high) {
271
+ const middle = Math.ceil((low + high) / 2);
272
+ if (accepts(middle))
273
+ low = middle;
274
+ else
275
+ high = middle - 1;
276
+ }
277
+ return low;
278
+ };
279
+ // A provider tokenizer is not monotone in the cap: the tiktoken estimator
280
+ // switches to the heuristic above a length limit and BPE counts wobble at cut
281
+ // points, so a failing midpoint can hide a fitting interval above it. The
282
+ // character-class heuristic IS monotone (every character only adds), so it
283
+ // also proposes a cap; whichever real-estimator-verified cap is larger wins.
284
+ const heuristicOnly = getTokenEstimator(providerId) === getTokenEstimator();
285
+ for (let dropped = 0; groups.length > 0; dropped++) {
286
+ // Empty payloads are the cheapest probe and the floor of what trimming can reach.
287
+ const floor = render(0, dropped);
288
+ if (floor.fits) {
289
+ if (!floor.trimmed)
290
+ return floor.fitted; // Nothing to trim: this IS the verbatim input.
291
+ const whole = render(undefined, dropped);
292
+ if (whole.fits)
293
+ return whole.fitted;
294
+ const longest = longestPayload(groups);
295
+ let bestCap = searchCap(longest, (cap) => render(cap, dropped).fits);
296
+ if (!heuristicOnly) {
297
+ const proposed = searchCap(longest, (cap) => render(cap, dropped, (text) => estimateTextTokens(text)).fits);
298
+ if (proposed > bestCap && render(proposed, dropped).fits)
299
+ bestCap = proposed;
300
+ }
301
+ return render(bestCap, dropped).fitted;
302
+ }
303
+ // Emptying the payloads is not always the smallest rendering. With the
304
+ // additive heuristic the verbatim input can only be smaller when trimming
305
+ // saves less than its note costs, i.e. within that cost of the budget, so it
306
+ // is probed just then. A provider tokenizer gives no such bound: past its
307
+ // length limit it switches to the (cheaper) heuristic, so a longer verbatim
308
+ // input can measure far below a shorter trimmed one — always probe there.
309
+ if (floor.trimmed && (!heuristicOnly || floor.overBy <= floor.noteTokens)) {
310
+ const whole = render(undefined, dropped);
311
+ if (whole.fits)
312
+ return whole.fitted;
313
+ }
314
+ const removable = groups.findIndex((group) => group.every((m) => m.role === "assistant" || m.role === "tool"));
315
+ if (removable < 0)
316
+ return undefined;
317
+ groups.splice(removable, 1);
318
+ }
319
+ return undefined;
320
+ }
321
+ function longestPayload(groups) {
322
+ let longest = 0;
323
+ for (const group of groups) {
324
+ for (const message of group) {
325
+ if (message.role === "tool")
326
+ longest = Math.max(longest, message.content.length);
327
+ else if (message.role === "assistant") {
328
+ for (const toolCall of message.toolCalls ?? [])
329
+ longest = Math.max(longest, (toolCall.arguments || "").length);
330
+ }
331
+ }
332
+ }
333
+ return longest;
334
+ }
335
+ /** Keep the head and tail of a payload longer than `cap`; the marker states
336
+ * exactly how much is missing so the summarizer never mistakes it for the whole.
337
+ * Returns the text unchanged (and leaves `trim.trimmed` alone) when cutting would
338
+ * not make it cheaper. Exported for regression tests only. */
339
+ export function capPayload(text, trim) {
340
+ if (!trim || text.length <= trim.cap)
341
+ return text;
342
+ let headEnd = Math.ceil(trim.cap / 2);
343
+ let tailStart = text.length - (trim.cap - headEnd);
344
+ // Never cut a surrogate pair in half: a lone surrogate is invalid Unicode that
345
+ // some provider endpoints reject outright.
346
+ if (headEnd > 0 && isHighSurrogate(text.charCodeAt(headEnd - 1)))
347
+ headEnd--;
348
+ if (tailStart < text.length && isLowSurrogate(text.charCodeAt(tailStart)))
349
+ tailStart++;
350
+ const capped = `${text.slice(0, headEnd)}[... ${tailStart - headEnd} of ${text.length} characters omitted ...]${text.slice(tailStart)}`;
351
+ // Worth it only if it is cheaper in TOKENS. Fewer characters is taken as
352
+ // cheaper without pricing (large payloads, every probe); a marker longer than
353
+ // what it replaces can still be cheaper for token-dense text such as CJK, and
354
+ // that case is always short enough to price exactly.
355
+ if (capped.length >= text.length && trim.estimate(capped) >= trim.estimate(text))
356
+ return text;
357
+ trim.trimmed = true;
358
+ return capped;
170
359
  }
171
- function serializeHistoryAsText(messages) {
360
+ const isHighSurrogate = (code) => code >= 0xd800 && code <= 0xdbff;
361
+ const isLowSurrogate = (code) => code >= 0xdc00 && code <= 0xdfff;
362
+ function serializeHistoryAsText(messages, trim) {
172
363
  const lines = [];
173
364
  const toolNameByCallId = new Map();
174
365
  for (const msg of messages) {
@@ -187,39 +378,31 @@ function serializeHistoryAsText(messages) {
187
378
  if (msg.toolCalls && msg.toolCalls.length > 0) {
188
379
  for (const tc of msg.toolCalls) {
189
380
  toolNameByCallId.set(tc.id, tc.name);
190
- lines.push(`TOOL_CALL[${tc.name}]: ${summarizeToolCallArgs(tc)}`);
381
+ lines.push(`TOOL_CALL[${tc.name}]: ${capPayload(summarizeToolCallArgs(tc), trim)}`);
191
382
  }
192
383
  }
193
384
  break;
194
385
  }
195
386
  case "tool": {
196
387
  const name = toolNameByCallId.get(msg.toolCallId) ?? "tool";
197
- lines.push(`TOOL_RESULT[${name}]: ${truncateInline(msg.content, 1500)}`);
388
+ lines.push(`TOOL_RESULT[${name}]: ${capPayload(msg.content, trim)}`);
198
389
  break;
199
390
  }
391
+ case "meta":
392
+ // Expired runtime reminders remain in history but are no longer model
393
+ // context. Prior summaries have already been folded into user input.
394
+ if (msg.includeInLlm !== false)
395
+ lines.push(`CONTEXT: ${msg.content}`);
396
+ break;
200
397
  default:
398
+ lines.push(`CONTEXT: ${messageText(msg)}`);
201
399
  break;
202
400
  }
203
401
  }
204
402
  return lines.join("\n\n");
205
403
  }
206
404
  function summarizeToolCallArgs(tc) {
207
- try {
208
- const parsed = JSON.parse(tc.arguments || "{}");
209
- const pairs = Object.entries(parsed)
210
- .filter(([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean")
211
- .map(([k, v]) => `${k}=${JSON.stringify(v).slice(0, 200)}`);
212
- return pairs.join(" ") || "(no args)";
213
- }
214
- catch {
215
- return truncateInline(tc.arguments || "", 200);
216
- }
217
- }
218
- function truncateInline(text, max) {
219
- const normalized = text.replace(/\s+/g, " ").trim();
220
- if (normalized.length <= max)
221
- return normalized;
222
- return `${normalized.slice(0, max - 3)}...`;
405
+ return tc.arguments || "{}";
223
406
  }
224
407
  function cloneMessage(message) {
225
408
  if (message.role === "assistant") {
@@ -0,0 +1,20 @@
1
+ /** Identity of one incarnation of a pid: the OS start time of the process that
2
+ * currently has it. Pids are recycled, so a pid that answers kill(0) may be an
3
+ * unrelated process; only a matching start time proves it is the same owner.
4
+ * Undefined when the platform cannot say, which callers treat as "unknown",
5
+ * never as "gone". Exported for tests. */
6
+ export declare function processStartId(pid: number): string | undefined;
7
+ /** Field 22 (starttime) counts clock ticks since boot, and lock files outlive
8
+ * reboots, so the boot id is part of the identity: the same pid and tick after
9
+ * another boot is a different process. The comm field may itself contain
10
+ * spaces and parentheses, hence the split after the last ")". Exported for tests. */
11
+ export declare function linuxStartId(stat: string, bootId: string): string | undefined;
12
+ /** A live owner held the lock past the bounded wait. Nothing was written. */
13
+ export declare class SessionWriteLockBusyError extends Error {
14
+ constructor(path: string);
15
+ }
16
+ /** Synchronous short transaction lock. An owner is abandoned only when its
17
+ * process is provably gone: the pid no longer exists, or it now belongs to a
18
+ * different process incarnation. Live owners are never expired by age;
19
+ * contenders wait a bounded time for them, since transactions are short. */
20
+ export declare function withSessionWriteLock<T>(path: string, action: () => T, waitMs?: number): T;