@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.
- package/README.md +56 -6
- package/dist/agent/child-runner.js +5 -1
- package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
- package/dist/agent/internal-reminder-sanitizer.js +7 -1
- package/dist/agent/profiles.js +4 -4
- package/dist/agent/routing-catalog.d.ts +19 -1
- package/dist/agent/routing-catalog.js +32 -13
- package/dist/agent/subagent/runtime.d.ts +28 -0
- package/dist/agent/subagent/runtime.js +34 -30
- package/dist/agent/subagent-control.d.ts +7 -0
- package/dist/agent/subagent-control.js +2 -0
- package/dist/agent/subagent-lifecycle-reminder.js +2 -1
- package/dist/agent/subagent-wait-guidance.d.ts +2 -0
- package/dist/agent/subagent-wait-guidance.js +8 -0
- package/dist/agent/subtask-policy.js +4 -4
- package/dist/agent.d.ts +20 -11
- package/dist/agent.js +200 -117
- package/dist/anthropic-thinking.d.ts +4 -0
- package/dist/anthropic-thinking.js +11 -0
- package/dist/approval/controller.d.ts +20 -6
- package/dist/approval/controller.js +67 -8
- package/dist/approval/protected-paths.d.ts +1 -0
- package/dist/approval/protected-paths.js +35 -0
- package/dist/approval/session-cache.d.ts +12 -26
- package/dist/approval/session-cache.js +20 -50
- package/dist/approval/types.d.ts +27 -1
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +25 -2
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/context/checkpoint.d.ts +18 -0
- package/dist/context/checkpoint.js +117 -0
- package/dist/context/compact-llm.js +7 -0
- package/dist/context/compact.d.ts +8 -0
- package/dist/context/compact.js +40 -7
- package/dist/context/llm-compactor.d.ts +36 -2
- package/dist/context/llm-compactor.js +227 -44
- package/dist/context/session-write-lock.d.ts +20 -0
- package/dist/context/session-write-lock.js +162 -0
- package/dist/context/tool-images.d.ts +3 -0
- package/dist/context/tool-images.js +35 -0
- package/dist/context/usage.js +22 -5
- package/dist/feishu/agent-host/run-driver.js +56 -9
- package/dist/lsp/service.d.ts +13 -1
- package/dist/lsp/service.js +163 -43
- package/dist/main.js +82 -16
- package/dist/mcp/config.d.ts +4 -0
- package/dist/mcp/config.js +17 -0
- package/dist/mcp/manager.d.ts +13 -1
- package/dist/mcp/manager.js +32 -1
- package/dist/mcp/transports.js +11 -0
- package/dist/memory/db.d.ts +1 -0
- package/dist/memory/db.js +20 -4
- package/dist/memory/phase1.js +122 -16
- package/dist/memory/prompts.js +3 -0
- package/dist/model-catalog.js +19 -3
- package/dist/model-config.d.ts +1 -0
- package/dist/model-config.js +5 -5
- package/dist/model-pricing.js +26 -9
- package/dist/network/provider-transport-log.d.ts +12 -0
- package/dist/network/provider-transport-log.js +32 -0
- package/dist/network/provider-transport.js +6 -1
- package/dist/network/retry.d.ts +1 -1
- package/dist/network/retry.js +1 -1
- package/dist/oauth/grok.d.ts +1 -0
- package/dist/oauth/grok.js +20 -16
- package/dist/oauth/openai-codex.d.ts +1 -0
- package/dist/oauth/openai-codex.js +29 -25
- package/dist/oauth/refresh-control.d.ts +12 -0
- package/dist/oauth/refresh-control.js +42 -0
- package/dist/oauth/storage.d.ts +55 -1
- package/dist/oauth/storage.js +281 -16
- package/dist/permissions/rule.d.ts +6 -3
- package/dist/permissions/rule.js +71 -12
- package/dist/permissions/settings.d.ts +28 -1
- package/dist/permissions/settings.js +93 -3
- package/dist/permissions/shell-command.d.ts +36 -0
- package/dist/permissions/shell-command.js +226 -0
- package/dist/permissions/trust-prompt.d.ts +10 -0
- package/dist/permissions/trust-prompt.js +34 -0
- package/dist/permissions/trust.d.ts +26 -0
- package/dist/permissions/trust.js +113 -0
- package/dist/prompt/compose.d.ts +3 -0
- package/dist/prompt/compose.js +24 -7
- package/dist/prompt/environment.js +2 -2
- package/dist/prompt/reminders.js +1 -1
- package/dist/prompt/runtime.js +1 -1
- package/dist/provider-anthropic.d.ts +6 -2
- package/dist/provider-anthropic.js +98 -18
- package/dist/provider-error-record.d.ts +9 -0
- package/dist/provider-error-record.js +42 -14
- package/dist/provider-grok.js +5 -1
- package/dist/provider-openai-codex.d.ts +8 -0
- package/dist/provider-openai-codex.js +107 -24
- package/dist/provider-registry.d.ts +45 -2
- package/dist/provider-registry.js +300 -88
- package/dist/provider-transform.js +8 -0
- package/dist/provider-zen-errors.d.ts +9 -0
- package/dist/provider-zen-errors.js +36 -0
- package/dist/provider.js +36 -5
- package/dist/sdk/index.d.ts +69 -8
- package/dist/sdk/index.js +252 -39
- package/dist/sdk/replay-event-log.d.ts +7 -0
- package/dist/sdk/replay-event-log.js +66 -16
- package/dist/session-context-fence.d.ts +16 -0
- package/dist/session-context-fence.js +26 -0
- package/dist/session-log.d.ts +11 -2
- package/dist/session-log.js +187 -39
- package/dist/session-types.d.ts +5 -1
- package/dist/session.d.ts +39 -7
- package/dist/session.js +341 -93
- package/dist/skills/format.d.ts +31 -1
- package/dist/skills/format.js +45 -24
- package/dist/slash-commands/commands.d.ts +3 -0
- package/dist/slash-commands/commands.js +29 -14
- package/dist/system-prompt.d.ts +3 -1
- package/dist/tasks/manager.d.ts +7 -0
- package/dist/tasks/manager.js +97 -18
- package/dist/tools/agent-lifecycle.js +11 -7
- package/dist/tools/child-tools.d.ts +1 -1
- package/dist/tools/child-tools.js +4 -4
- package/dist/tools/edit-apply.d.ts +0 -3
- package/dist/tools/edit-apply.js +45 -249
- package/dist/tools/edit.js +3 -3
- package/dist/tools/exit-plan-mode.d.ts +5 -0
- package/dist/tools/exit-plan-mode.js +3 -2
- package/dist/tools/file-state.d.ts +4 -0
- package/dist/tools/file-state.js +12 -3
- package/dist/tools/grep.js +20 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +3 -3
- package/dist/tools/ls.d.ts +3 -0
- package/dist/tools/ls.js +85 -0
- package/dist/tools/path-utils.d.ts +6 -0
- package/dist/tools/path-utils.js +38 -1
- package/dist/tools/sensitive-paths.d.ts +8 -0
- package/dist/tools/sensitive-paths.js +85 -3
- package/dist/tools/skill.d.ts +2 -0
- package/dist/tools/skill.js +34 -2
- package/dist/tools/task-tools.js +11 -7
- package/dist/tui/app.js +22 -12
- package/dist/tui/components/approval-dialog.d.ts +5 -5
- package/dist/tui/components/approval-dialog.js +16 -51
- package/dist/tui/components/skills-panel.d.ts +4 -0
- package/dist/tui/components/skills-panel.js +15 -1
- package/dist/tui/components/streaming-message.js +7 -2
- package/dist/tui/components/tasks-pane.d.ts +38 -0
- package/dist/tui/components/tasks-pane.js +182 -32
- package/dist/tui/components/transcript.d.ts +11 -4
- package/dist/tui/components/transcript.js +158 -71
- package/dist/tui/composer-autocomplete.d.ts +17 -2
- package/dist/tui/composer-autocomplete.js +79 -2
- package/dist/tui/controller/controller.d.ts +9 -4
- package/dist/tui/controller/controller.js +87 -95
- package/dist/tui/controller/goal-runtime-controller.js +1 -1
- package/dist/tui/controller/session-transition.js +1 -1
- package/dist/tui/model/subagent-view.d.ts +11 -19
- package/dist/tui/model/subagent-view.js +56 -85
- package/dist/tui/model/task-lifecycle.d.ts +57 -0
- package/dist/tui/model/task-lifecycle.js +124 -0
- package/dist/tui/model/trace-groups.js +10 -9
- package/dist/tui/trace-groups.js +10 -9
- package/dist/types.d.ts +20 -0
- package/dist/variant/variant-resolver.js +2 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
- package/package.json +11 -3
- package/dist/prompt/skills.d.ts +0 -2
- package/dist/prompt/skills.js +0 -4
- package/dist/tools/glob.d.ts +0 -5
- package/dist/tools/glob.js +0 -199
package/dist/context/compact.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
201
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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 (
|
|
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
|
|
105
|
-
const
|
|
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:
|
|
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:
|
|
204
|
+
summary: summaryWithFiles,
|
|
159
205
|
messages: compacted,
|
|
160
206
|
};
|
|
161
207
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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}]: ${
|
|
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
|
-
|
|
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;
|