@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.6
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/CHANGELOG.md +107 -0
- package/dist/cli.js +692 -607
- package/dist/types/cli/usage-cli.d.ts +10 -1
- package/dist/types/commands/usage.d.ts +9 -0
- package/dist/types/config/api-key-resolver.d.ts +9 -3
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/model-discovery.d.ts +6 -4
- package/dist/types/config/model-registry.d.ts +7 -4
- package/dist/types/config/settings-schema.d.ts +508 -155
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/mnemopi/config.d.ts +3 -1
- package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/settings-defs.d.ts +9 -2
- package/dist/types/modes/components/settings-selector.d.ts +9 -4
- package/dist/types/modes/components/tool-execution.d.ts +26 -1
- package/dist/types/modes/components/transcript-container.d.ts +12 -0
- package/dist/types/modes/controllers/input-controller.d.ts +9 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -0
- package/dist/types/modes/session-observer-registry.d.ts +2 -0
- package/dist/types/modes/theme/theme.d.ts +23 -3
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/context-usage.d.ts +6 -1
- package/dist/types/session/agent-session.d.ts +28 -8
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/codex-auto-reset.d.ts +107 -0
- package/dist/types/session/snapcompact-inline.d.ts +129 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
- package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
- package/dist/types/system-prompt.d.ts +3 -1
- package/dist/types/task/render.d.ts +17 -6
- package/dist/types/tools/gh.d.ts +3 -0
- package/dist/types/tools/render-utils.d.ts +8 -16
- package/dist/types/tools/todo.d.ts +0 -11
- package/dist/types/utils/session-color.d.ts +15 -3
- package/dist/types/web/kagi.d.ts +1 -2
- package/dist/types/web/search/providers/codex.d.ts +1 -1
- package/dist/types/web/search/providers/gemini.d.ts +9 -6
- package/package.json +11 -11
- package/src/auto-thinking/classifier.ts +1 -5
- package/src/cli/usage-cli.ts +187 -16
- package/src/commands/usage.ts +8 -0
- package/src/commit/model-selection.ts +3 -6
- package/src/config/api-key-resolver.ts +10 -3
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-discovery.ts +60 -46
- package/src/config/model-registry.ts +21 -8
- package/src/config/model-resolver.ts +57 -3
- package/src/config/settings-schema.ts +654 -153
- package/src/config/settings.ts +9 -0
- package/src/eval/completion-bridge.ts +1 -5
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +13 -6
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/internal-urls/issue-pr-protocol.ts +10 -4
- package/src/memories/index.ts +2 -10
- package/src/mnemopi/backend.ts +30 -8
- package/src/mnemopi/config.ts +6 -1
- package/src/mnemopi/state.ts +6 -0
- package/src/modes/components/extensions/inspector-panel.ts +6 -2
- package/src/modes/components/plan-review-overlay.ts +15 -17
- package/src/modes/components/plugin-settings.ts +22 -5
- package/src/modes/components/reset-usage-selector.ts +161 -0
- package/src/modes/components/session-selector.ts +8 -2
- package/src/modes/components/settings-defs.ts +19 -4
- package/src/modes/components/settings-selector.ts +510 -95
- package/src/modes/components/status-line/component.ts +3 -1
- package/src/modes/components/status-line/segments.ts +3 -1
- package/src/modes/components/tool-execution.ts +87 -12
- package/src/modes/components/transcript-container.ts +49 -1
- package/src/modes/components/tree-selector.ts +16 -6
- package/src/modes/controllers/command-controller.ts +61 -8
- package/src/modes/controllers/event-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +68 -6
- package/src/modes/controllers/selector-controller.ts +149 -61
- package/src/modes/interactive-mode.ts +63 -2
- package/src/modes/rpc/rpc-mode.ts +2 -1
- package/src/modes/session-observer-registry.ts +61 -3
- package/src/modes/shared.ts +2 -0
- package/src/modes/theme/theme.ts +102 -9
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/context-usage.ts +78 -2
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +9 -5
- package/src/prompts/system/personalities/default.md +26 -0
- package/src/prompts/system/personalities/friendly.md +17 -0
- package/src/prompts/system/personalities/pragmatic.md +15 -0
- package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
- package/src/prompts/system/snapcompact-context-stub.md +1 -0
- package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
- package/src/prompts/system/snapcompact-system-stub.md +1 -0
- package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
- package/src/prompts/system/system-prompt.md +5 -22
- package/src/prompts/tools/browser.md +33 -43
- package/src/prompts/tools/eval.md +27 -50
- package/src/prompts/tools/irc.md +29 -31
- package/src/prompts/tools/read.md +31 -37
- package/src/prompts/tools/task.md +3 -3
- package/src/prompts/tools/todo.md +1 -2
- package/src/sdk.ts +23 -1
- package/src/session/agent-session.ts +221 -29
- package/src/session/auth-storage.ts +4 -0
- package/src/session/codex-auto-reset.ts +190 -0
- package/src/session/session-dump-format.ts +8 -1
- package/src/session/session-manager.ts +5 -5
- package/src/session/snapcompact-inline.ts +524 -0
- package/src/slash-commands/builtin-registry.ts +145 -8
- package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
- package/src/slash-commands/helpers/context-report.ts +28 -1
- package/src/slash-commands/helpers/reset-usage.ts +66 -0
- package/src/slash-commands/helpers/usage-report.ts +36 -3
- package/src/system-prompt.ts +15 -1
- package/src/task/index.ts +30 -7
- package/src/task/render.ts +57 -32
- package/src/tool-discovery/tool-index.ts +2 -0
- package/src/tools/bash.ts +10 -3
- package/src/tools/eval-render.ts +13 -8
- package/src/tools/gh.ts +39 -1
- package/src/tools/image-gen.ts +114 -78
- package/src/tools/inspect-image.ts +1 -5
- package/src/tools/job.ts +25 -5
- package/src/tools/read.ts +1 -57
- package/src/tools/render-utils.ts +29 -31
- package/src/tools/ssh.ts +3 -3
- package/src/tools/todo.ts +8 -128
- package/src/tools/tts.ts +40 -20
- package/src/utils/clipboard.ts +56 -4
- package/src/utils/commit-message-generator.ts +1 -5
- package/src/utils/session-color.ts +83 -9
- package/src/utils/title-generator.ts +1 -1
- package/src/web/kagi.ts +26 -27
- package/src/web/search/providers/codex.ts +42 -40
- package/src/web/search/providers/gemini.ts +42 -22
- package/src/web/search/providers/perplexity.ts +22 -10
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
Snowflake,
|
|
28
28
|
toError,
|
|
29
29
|
} from "@oh-my-pi/pi-utils";
|
|
30
|
-
import
|
|
30
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
31
31
|
import { ArtifactManager } from "./artifacts";
|
|
32
32
|
import {
|
|
33
33
|
type BlobPutOptions,
|
|
@@ -712,7 +712,7 @@ export function buildSessionContext(
|
|
|
712
712
|
// the component can report them.
|
|
713
713
|
for (const entry of path) {
|
|
714
714
|
if (entry.type === "compaction") {
|
|
715
|
-
const snapcompactArchive =
|
|
715
|
+
const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
|
|
716
716
|
messages.push(
|
|
717
717
|
createCompactionSummaryMessage(
|
|
718
718
|
entry.summary,
|
|
@@ -720,7 +720,7 @@ export function buildSessionContext(
|
|
|
720
720
|
entry.timestamp,
|
|
721
721
|
entry.shortSummary,
|
|
722
722
|
undefined,
|
|
723
|
-
snapcompactArchive ?
|
|
723
|
+
snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
|
|
724
724
|
),
|
|
725
725
|
);
|
|
726
726
|
} else {
|
|
@@ -744,7 +744,7 @@ export function buildSessionContext(
|
|
|
744
744
|
|
|
745
745
|
// Emit summary first; re-attach any archived snapcompact frames so the
|
|
746
746
|
// model can keep reading the archived history after every context rebuild.
|
|
747
|
-
const snapcompactArchive =
|
|
747
|
+
const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
|
|
748
748
|
messages.push(
|
|
749
749
|
createCompactionSummaryMessage(
|
|
750
750
|
compaction.summary,
|
|
@@ -752,7 +752,7 @@ export function buildSessionContext(
|
|
|
752
752
|
compaction.timestamp,
|
|
753
753
|
compaction.shortSummary,
|
|
754
754
|
providerPayload,
|
|
755
|
-
snapcompactArchive ?
|
|
755
|
+
snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
|
|
756
756
|
),
|
|
757
757
|
);
|
|
758
758
|
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapcompact inline imaging: per-request transform that swaps the system
|
|
3
|
+
* prompt, loaded context-file instructions, and/or large historical tool
|
|
4
|
+
* results for dense PNG frames on vision-capable models.
|
|
5
|
+
* Runs inside the agent loop's `transformProviderContext` hook — after the
|
|
6
|
+
* persisted history is converted to the outgoing `Context`, before the
|
|
7
|
+
* provider stream call. It only ever builds NEW message objects/arrays; the
|
|
8
|
+
* input context shares `content` array references with the persisted
|
|
9
|
+
* `SessionMessageEntry` messages, so mutation would leak rendered images
|
|
10
|
+
* into session.jsonl.
|
|
11
|
+
*
|
|
12
|
+
* The swap policy (budget, savings gate, skip rules) lives in
|
|
13
|
+
* `planInlineSwaps`, shared by the transform and the `/context` savings
|
|
14
|
+
* estimate (`estimateInlineSavings`) so the two can never disagree.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
|
|
18
|
+
import { countTokens } from "@oh-my-pi/pi-natives";
|
|
19
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
20
|
+
import contextFramesNote from "../prompts/system/snapcompact-context-frames-note.md" with { type: "text" };
|
|
21
|
+
import contextStub from "../prompts/system/snapcompact-context-stub.md" with { type: "text" };
|
|
22
|
+
import systemFramesNote from "../prompts/system/snapcompact-system-frames-note.md" with { type: "text" };
|
|
23
|
+
import systemStub from "../prompts/system/snapcompact-system-stub.md" with { type: "text" };
|
|
24
|
+
import toolResultNote from "../prompts/system/snapcompact-toolresult-note.md" with { type: "text" };
|
|
25
|
+
|
|
26
|
+
export type SnapcompactSystemPromptMode = "none" | "agents-md" | "all";
|
|
27
|
+
|
|
28
|
+
export interface SnapcompactInlineOptions {
|
|
29
|
+
renderSystemPrompt: SnapcompactSystemPromptMode;
|
|
30
|
+
renderToolResults: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Image-count budget per provider. Snapcompact frames are 1568px (<2000px) so
|
|
35
|
+
* dimension/size limits never bind; only COUNT does. Strictest mainstream is
|
|
36
|
+
* Groq (~5), so unknown providers get the safe floor.
|
|
37
|
+
*/
|
|
38
|
+
const INLINE_IMAGE_BUDGET_BY_PROVIDER: Record<string, number> = {
|
|
39
|
+
anthropic: 90,
|
|
40
|
+
"amazon-bedrock": 90,
|
|
41
|
+
openai: 200,
|
|
42
|
+
google: 200,
|
|
43
|
+
"google-vertex": 200,
|
|
44
|
+
"google-gemini-cli": 200,
|
|
45
|
+
};
|
|
46
|
+
const DEFAULT_INLINE_IMAGE_BUDGET = 5;
|
|
47
|
+
const MAX_SYSTEM_PROMPT_FRAMES = 6;
|
|
48
|
+
/** Tool results under this many tokens are never rasterized — the swap can't
|
|
49
|
+
* save enough to justify trading crisp text for an image. */
|
|
50
|
+
const MIN_TOOL_RESULT_TOKENS = 3000;
|
|
51
|
+
/** Render only if imageTokens <= textTokens * SAVINGS_MARGIN. */
|
|
52
|
+
const SAVINGS_MARGIN = 0.9;
|
|
53
|
+
|
|
54
|
+
/** Count image blocks already present across all message contents. */
|
|
55
|
+
function countContextImages(context: Context): number {
|
|
56
|
+
let count = 0;
|
|
57
|
+
for (const message of context.messages) {
|
|
58
|
+
const content = message.content;
|
|
59
|
+
if (typeof content === "string") continue;
|
|
60
|
+
for (const block of content) {
|
|
61
|
+
if (block.type === "image") count++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return count;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isTextContent(block: TextContent | ImageContent): block is TextContent {
|
|
68
|
+
return block.type === "text";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Image tokens must undercut text tokens by the margin to be worth rendering. */
|
|
72
|
+
function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens: number): boolean {
|
|
73
|
+
return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface SystemPromptImageTarget {
|
|
77
|
+
scope: Exclude<SnapcompactSystemPromptMode, "none">;
|
|
78
|
+
text: string;
|
|
79
|
+
replacement: string[];
|
|
80
|
+
userNote: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const CONTEXT_SECTION_PATTERNS = [
|
|
84
|
+
/<context>\n[\s\S]*?\n<\/context>/g,
|
|
85
|
+
/## Context\n<instructions>\n[\s\S]*?\n<\/instructions>/g,
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
function replaceContextSections(block: string, extracted: string[]): string {
|
|
89
|
+
let replaced = block;
|
|
90
|
+
for (const pattern of CONTEXT_SECTION_PATTERNS) {
|
|
91
|
+
replaced = replaced.replace(pattern, match => {
|
|
92
|
+
extracted.push(match.trim());
|
|
93
|
+
return contextStub.trim();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return replaced;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function selectSystemPromptImageTarget(
|
|
100
|
+
systemPrompt: readonly string[] | undefined,
|
|
101
|
+
mode: SnapcompactSystemPromptMode,
|
|
102
|
+
): SystemPromptImageTarget | undefined {
|
|
103
|
+
if (!systemPrompt?.length || mode === "none") return undefined;
|
|
104
|
+
if (mode === "all") {
|
|
105
|
+
const text = systemPrompt.join("\n\n");
|
|
106
|
+
if (!text) return undefined;
|
|
107
|
+
return {
|
|
108
|
+
scope: "all",
|
|
109
|
+
text,
|
|
110
|
+
replacement: [systemStub],
|
|
111
|
+
userNote: systemFramesNote,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const extracted: string[] = [];
|
|
116
|
+
const replacement = systemPrompt.map(block => replaceContextSections(block, extracted));
|
|
117
|
+
const text = extracted.join("\n\n");
|
|
118
|
+
if (!text) return undefined;
|
|
119
|
+
return {
|
|
120
|
+
scope: "agents-md",
|
|
121
|
+
text,
|
|
122
|
+
replacement,
|
|
123
|
+
userNote: contextFramesNote,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// Swap planning (shared by the live transform and /context estimation)
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
/** Tool-result swap candidate, in context order. */
|
|
132
|
+
export interface InlineToolResultCandidate {
|
|
133
|
+
/** toolCallId — stable identity for render caching and application. */
|
|
134
|
+
id: string;
|
|
135
|
+
/** Token count of the joined text blocks (0 when empty or image-carrying). */
|
|
136
|
+
textTokens: number;
|
|
137
|
+
/** Frames needed to render the text (0 = empty or below the token floor). */
|
|
138
|
+
frames: number;
|
|
139
|
+
/** Already carries an image (screenshot etc.) — never re-imaged. */
|
|
140
|
+
hasImage: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface InlineSystemPromptCandidate {
|
|
144
|
+
textTokens: number;
|
|
145
|
+
frames: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface InlinePlanInput {
|
|
149
|
+
options: SnapcompactInlineOptions;
|
|
150
|
+
shape: snapcompact.Shape;
|
|
151
|
+
/** Provider image-count budget minus images already present in the context. */
|
|
152
|
+
budget: number;
|
|
153
|
+
/** All tool results in context order, INCLUDING the most recent one. */
|
|
154
|
+
toolResults: readonly InlineToolResultCandidate[];
|
|
155
|
+
/** Selected prompt text; undefined when system-prompt imaging is off or empty. */
|
|
156
|
+
systemPrompt: InlineSystemPromptCandidate | undefined;
|
|
157
|
+
/** Whether a user message exists to carry the prompt frames. */
|
|
158
|
+
hasUserMessage: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface InlineSwapPlan {
|
|
162
|
+
/** Tool results to swap, oldest first. */
|
|
163
|
+
toolResults: Array<{ id: string; textTokens: number; frames: number }>;
|
|
164
|
+
/** Set when the system prompt should swap to frames (uses leftover budget). */
|
|
165
|
+
systemPrompt: InlineSystemPromptCandidate | undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Decide which content gets swapped for frames. Pure — the same rules drive
|
|
170
|
+
* the provider-request transform and the /context savings estimate.
|
|
171
|
+
*/
|
|
172
|
+
export function planInlineSwaps(input: InlinePlanInput): InlineSwapPlan {
|
|
173
|
+
let budget = input.budget;
|
|
174
|
+
|
|
175
|
+
const toolResults: InlineSwapPlan["toolResults"] = [];
|
|
176
|
+
if (input.options.renderToolResults) {
|
|
177
|
+
// Oldest-first for cache-stable bytes; skip the LAST tool result so the
|
|
178
|
+
// freshest output stays crisp text. A candidate too big for the
|
|
179
|
+
// remaining budget is skipped, not a stop — later smaller ones may fit.
|
|
180
|
+
for (let k = 0; k < input.toolResults.length - 1 && budget > 0; k++) {
|
|
181
|
+
const candidate = input.toolResults[k];
|
|
182
|
+
if (candidate.hasImage) continue;
|
|
183
|
+
if (candidate.textTokens < MIN_TOOL_RESULT_TOKENS) continue;
|
|
184
|
+
if (candidate.frames === 0 || candidate.frames > budget) continue;
|
|
185
|
+
if (!passesSavingsGate(candidate.frames, input.shape, candidate.textTokens)) continue;
|
|
186
|
+
toolResults.push({ id: candidate.id, textTokens: candidate.textTokens, frames: candidate.frames });
|
|
187
|
+
budget -= candidate.frames;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let systemPrompt: InlineSystemPromptCandidate | undefined;
|
|
192
|
+
if (
|
|
193
|
+
input.options.renderSystemPrompt !== "none" &&
|
|
194
|
+
input.systemPrompt &&
|
|
195
|
+
budget > 0 &&
|
|
196
|
+
input.systemPrompt.frames > 0 &&
|
|
197
|
+
input.systemPrompt.frames <= Math.min(budget, MAX_SYSTEM_PROMPT_FRAMES) &&
|
|
198
|
+
passesSavingsGate(input.systemPrompt.frames, input.shape, input.systemPrompt.textTokens) &&
|
|
199
|
+
// No user message to carry the frames → leave the prompt as text.
|
|
200
|
+
input.hasUserMessage
|
|
201
|
+
) {
|
|
202
|
+
systemPrompt = input.systemPrompt;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { toolResults, systemPrompt };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ============================================================================
|
|
209
|
+
// /context savings estimation
|
|
210
|
+
// ============================================================================
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Minimal structural view of a history message — both pi-ai `Message`s (the
|
|
214
|
+
* outgoing context) and agent-core `AgentMessage`s (the live session) satisfy
|
|
215
|
+
* it, so the estimator can read session state without conversion.
|
|
216
|
+
*/
|
|
217
|
+
export interface InlineMessageView {
|
|
218
|
+
role: string;
|
|
219
|
+
toolCallId?: string;
|
|
220
|
+
content?: unknown;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface SnapcompactSavingsEstimate {
|
|
224
|
+
/** Frames only ship on models that accept image input. */
|
|
225
|
+
visionCapable: boolean;
|
|
226
|
+
/** Present iff system-prompt imaging is enabled. */
|
|
227
|
+
systemPrompt?: {
|
|
228
|
+
applied: boolean;
|
|
229
|
+
/** Why the prompt stays text when `applied` is false. */
|
|
230
|
+
reason?: "empty" | "margin" | "budget";
|
|
231
|
+
textTokens: number;
|
|
232
|
+
frames: number;
|
|
233
|
+
/** Estimated billed tokens for the frames (0 when there are none). */
|
|
234
|
+
imageTokens: number;
|
|
235
|
+
savedTokens: number;
|
|
236
|
+
scope: Exclude<SnapcompactSystemPromptMode, "none">;
|
|
237
|
+
};
|
|
238
|
+
/** Present iff tool-result imaging is enabled. */
|
|
239
|
+
toolResults?: {
|
|
240
|
+
/** Tool results currently in history. */
|
|
241
|
+
total: number;
|
|
242
|
+
swapped: number;
|
|
243
|
+
/** Text tokens of the swapped results only. */
|
|
244
|
+
textTokens: number;
|
|
245
|
+
frames: number;
|
|
246
|
+
imageTokens: number;
|
|
247
|
+
savedTokens: number;
|
|
248
|
+
};
|
|
249
|
+
/** Net estimated wire savings for the next request. */
|
|
250
|
+
savedTokens: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Loose block-array view of unknown message content. */
|
|
254
|
+
type BlockViews = ReadonlyArray<{ type?: unknown; text?: unknown }>;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Estimate what `SnapcompactInlineTransformer.transform` would save on the
|
|
258
|
+
* NEXT request, given the session's live system prompt and message history.
|
|
259
|
+
*
|
|
260
|
+
* Mirrors the transform exactly via `planInlineSwaps`, with one deliberate
|
|
261
|
+
* difference: `hasUserMessage` is assumed true, because the request being
|
|
262
|
+
* estimated is always triggered by a user prompt — even when the current
|
|
263
|
+
* history is still empty.
|
|
264
|
+
*/
|
|
265
|
+
export function estimateInlineSavings(input: {
|
|
266
|
+
options: SnapcompactInlineOptions;
|
|
267
|
+
model: Model | undefined;
|
|
268
|
+
systemPrompt: readonly string[];
|
|
269
|
+
messages: readonly InlineMessageView[];
|
|
270
|
+
}): SnapcompactSavingsEstimate {
|
|
271
|
+
const { options, model } = input;
|
|
272
|
+
if (!model?.input.includes("image")) {
|
|
273
|
+
return { visionCapable: false, savedTokens: 0 };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const shape = snapcompact.resolveShape(model.api);
|
|
277
|
+
let existingImages = 0;
|
|
278
|
+
for (const message of input.messages) {
|
|
279
|
+
if (!Array.isArray(message.content)) continue;
|
|
280
|
+
for (const block of message.content as BlockViews) {
|
|
281
|
+
if (block.type === "image") existingImages++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const budget = (INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - existingImages;
|
|
285
|
+
|
|
286
|
+
const candidates: InlineToolResultCandidate[] = [];
|
|
287
|
+
if (options.renderToolResults) {
|
|
288
|
+
for (const message of input.messages) {
|
|
289
|
+
if (message.role !== "toolResult" || typeof message.toolCallId !== "string") continue;
|
|
290
|
+
const blocks: BlockViews = Array.isArray(message.content) ? (message.content as BlockViews) : [];
|
|
291
|
+
const hasImage = blocks.some(block => block.type === "image");
|
|
292
|
+
const text = hasImage
|
|
293
|
+
? ""
|
|
294
|
+
: blocks
|
|
295
|
+
.filter(block => block.type === "text" && typeof block.text === "string")
|
|
296
|
+
.map(block => block.text as string)
|
|
297
|
+
.join("\n");
|
|
298
|
+
const textTokens = text.length > 0 ? countTokens(text) : 0;
|
|
299
|
+
candidates.push({
|
|
300
|
+
id: message.toolCallId,
|
|
301
|
+
textTokens,
|
|
302
|
+
frames: textTokens >= MIN_TOOL_RESULT_TOKENS ? snapcompact.frames(text, { shape }) : 0,
|
|
303
|
+
hasImage,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let systemPromptTarget: SystemPromptImageTarget | undefined;
|
|
309
|
+
let systemPromptCandidate: InlineSystemPromptCandidate | undefined;
|
|
310
|
+
if (options.renderSystemPrompt !== "none") {
|
|
311
|
+
systemPromptTarget = selectSystemPromptImageTarget(input.systemPrompt, options.renderSystemPrompt);
|
|
312
|
+
if (systemPromptTarget) {
|
|
313
|
+
systemPromptCandidate = {
|
|
314
|
+
textTokens: countTokens(systemPromptTarget.text),
|
|
315
|
+
frames: snapcompact.frames(systemPromptTarget.text, { shape }),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const plan = planInlineSwaps({
|
|
321
|
+
options,
|
|
322
|
+
shape,
|
|
323
|
+
budget,
|
|
324
|
+
toolResults: candidates,
|
|
325
|
+
systemPrompt: systemPromptCandidate,
|
|
326
|
+
hasUserMessage: true,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
let savedTokens = 0;
|
|
330
|
+
let systemPromptEstimate: SnapcompactSavingsEstimate["systemPrompt"];
|
|
331
|
+
if (options.renderSystemPrompt !== "none") {
|
|
332
|
+
const candidate = systemPromptCandidate ?? { textTokens: 0, frames: 0 };
|
|
333
|
+
const applied = plan.systemPrompt !== undefined;
|
|
334
|
+
const imageTokens = candidate.frames * shape.frameTokenEstimate;
|
|
335
|
+
const saved = applied ? Math.max(0, candidate.textTokens - imageTokens) : 0;
|
|
336
|
+
let reason: "empty" | "margin" | "budget" | undefined;
|
|
337
|
+
if (!applied) {
|
|
338
|
+
const leftover = budget - plan.toolResults.reduce((sum, swap) => sum + swap.frames, 0);
|
|
339
|
+
if (candidate.frames === 0) reason = "empty";
|
|
340
|
+
else if (candidate.frames > Math.min(leftover, MAX_SYSTEM_PROMPT_FRAMES)) reason = "budget";
|
|
341
|
+
else reason = "margin";
|
|
342
|
+
}
|
|
343
|
+
systemPromptEstimate = {
|
|
344
|
+
applied,
|
|
345
|
+
...(reason ? { reason } : {}),
|
|
346
|
+
textTokens: candidate.textTokens,
|
|
347
|
+
frames: candidate.frames,
|
|
348
|
+
imageTokens,
|
|
349
|
+
savedTokens: saved,
|
|
350
|
+
scope: systemPromptTarget?.scope ?? options.renderSystemPrompt,
|
|
351
|
+
};
|
|
352
|
+
savedTokens += saved;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let toolResultsEstimate: SnapcompactSavingsEstimate["toolResults"];
|
|
356
|
+
if (options.renderToolResults) {
|
|
357
|
+
let textTokens = 0;
|
|
358
|
+
let frames = 0;
|
|
359
|
+
for (const swap of plan.toolResults) {
|
|
360
|
+
textTokens += swap.textTokens;
|
|
361
|
+
frames += swap.frames;
|
|
362
|
+
}
|
|
363
|
+
const imageTokens = frames * shape.frameTokenEstimate;
|
|
364
|
+
const saved = Math.max(0, textTokens - imageTokens);
|
|
365
|
+
toolResultsEstimate = {
|
|
366
|
+
total: candidates.length,
|
|
367
|
+
swapped: plan.toolResults.length,
|
|
368
|
+
textTokens,
|
|
369
|
+
frames,
|
|
370
|
+
imageTokens,
|
|
371
|
+
savedTokens: saved,
|
|
372
|
+
};
|
|
373
|
+
savedTokens += saved;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
visionCapable: true,
|
|
378
|
+
...(systemPromptEstimate ? { systemPrompt: systemPromptEstimate } : {}),
|
|
379
|
+
...(toolResultsEstimate ? { toolResults: toolResultsEstimate } : {}),
|
|
380
|
+
savedTokens,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ============================================================================
|
|
385
|
+
// Provider-request transform
|
|
386
|
+
// ============================================================================
|
|
387
|
+
|
|
388
|
+
interface FrameCacheEntry {
|
|
389
|
+
hash: number | bigint;
|
|
390
|
+
frames: ImageContent[];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Stateless with respect to the model (passed per call, so mid-session model
|
|
395
|
+
* switches re-resolve shape and budget); stateful only for the render caches,
|
|
396
|
+
* which live as long as the session's Agent.
|
|
397
|
+
*/
|
|
398
|
+
export class SnapcompactInlineTransformer {
|
|
399
|
+
/** Rendered tool-result frames keyed by toolCallId. */
|
|
400
|
+
#toolCache = new Map<string, FrameCacheEntry>();
|
|
401
|
+
#systemCache?: FrameCacheEntry;
|
|
402
|
+
|
|
403
|
+
constructor(private readonly options: SnapcompactInlineOptions) {}
|
|
404
|
+
|
|
405
|
+
transform(context: Context, model: Model): Context {
|
|
406
|
+
// Vision gate: providers silently DROP images on text-only models —
|
|
407
|
+
// rendering would lose the content entirely.
|
|
408
|
+
if (!model.input.includes("image")) return context;
|
|
409
|
+
|
|
410
|
+
const shape = snapcompact.resolveShape(model.api);
|
|
411
|
+
const budget =
|
|
412
|
+
(INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - countContextImages(context);
|
|
413
|
+
if (budget <= 0) return context;
|
|
414
|
+
|
|
415
|
+
const messages = [...context.messages];
|
|
416
|
+
|
|
417
|
+
// Collect tool-result candidates (in order) for the planner, plus the
|
|
418
|
+
// text/index needed to apply swaps and the live ids for cache eviction.
|
|
419
|
+
const candidates: InlineToolResultCandidate[] = [];
|
|
420
|
+
const targets = new Map<string, { index: number; message: ToolResultMessage; text: string }>();
|
|
421
|
+
const liveToolCallIds = new Set<string>();
|
|
422
|
+
if (this.options.renderToolResults) {
|
|
423
|
+
for (let i = 0; i < messages.length; i++) {
|
|
424
|
+
const message = messages[i];
|
|
425
|
+
if (message.role !== "toolResult") continue;
|
|
426
|
+
liveToolCallIds.add(message.toolCallId);
|
|
427
|
+
// Don't re-image results that already carry images (screenshots etc.).
|
|
428
|
+
const hasImage = message.content.some(block => block.type === "image");
|
|
429
|
+
const text = hasImage
|
|
430
|
+
? ""
|
|
431
|
+
: message.content
|
|
432
|
+
.filter(isTextContent)
|
|
433
|
+
.map(block => block.text)
|
|
434
|
+
.join("\n");
|
|
435
|
+
const textTokens = text.length > 0 ? countTokens(text) : 0;
|
|
436
|
+
candidates.push({
|
|
437
|
+
id: message.toolCallId,
|
|
438
|
+
textTokens,
|
|
439
|
+
frames: textTokens >= MIN_TOOL_RESULT_TOKENS ? snapcompact.frames(text, { shape }) : 0,
|
|
440
|
+
hasImage,
|
|
441
|
+
});
|
|
442
|
+
targets.set(message.toolCallId, { index: i, message, text });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let systemPromptTarget: SystemPromptImageTarget | undefined;
|
|
447
|
+
let systemPromptCandidate: InlineSystemPromptCandidate | undefined;
|
|
448
|
+
if (this.options.renderSystemPrompt !== "none") {
|
|
449
|
+
systemPromptTarget = selectSystemPromptImageTarget(context.systemPrompt, this.options.renderSystemPrompt);
|
|
450
|
+
if (systemPromptTarget) {
|
|
451
|
+
systemPromptCandidate = {
|
|
452
|
+
textTokens: countTokens(systemPromptTarget.text),
|
|
453
|
+
frames: snapcompact.frames(systemPromptTarget.text, { shape }),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const userIndex = messages.findIndex(message => message.role === "user");
|
|
459
|
+
const plan = planInlineSwaps({
|
|
460
|
+
options: this.options,
|
|
461
|
+
shape,
|
|
462
|
+
budget,
|
|
463
|
+
toolResults: candidates,
|
|
464
|
+
systemPrompt: systemPromptCandidate,
|
|
465
|
+
hasUserMessage: userIndex >= 0,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
let changed = false;
|
|
469
|
+
for (const swap of plan.toolResults) {
|
|
470
|
+
const target = targets.get(swap.id);
|
|
471
|
+
if (!target) continue;
|
|
472
|
+
const frames = this.#framesFor(this.#toolCache, swap.id, target.text, shape);
|
|
473
|
+
messages[target.index] = { ...target.message, content: [{ type: "text", text: toolResultNote }, ...frames] };
|
|
474
|
+
changed = true;
|
|
475
|
+
}
|
|
476
|
+
if (this.options.renderToolResults) {
|
|
477
|
+
// Drop cache entries for tool calls no longer in the context
|
|
478
|
+
// (compacted away) so the cache stays bounded by live history.
|
|
479
|
+
for (const key of this.#toolCache.keys()) {
|
|
480
|
+
if (!liveToolCallIds.has(key)) this.#toolCache.delete(key);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let systemPrompt = context.systemPrompt;
|
|
485
|
+
if (plan.systemPrompt && userIndex >= 0 && systemPromptTarget) {
|
|
486
|
+
const hash = Bun.hash(systemPromptTarget.text);
|
|
487
|
+
let cached = this.#systemCache;
|
|
488
|
+
if (!cached || cached.hash !== hash) {
|
|
489
|
+
cached = {
|
|
490
|
+
hash,
|
|
491
|
+
frames: snapcompact.renderMany(systemPromptTarget.text, { shape, maxFrames: MAX_SYSTEM_PROMPT_FRAMES }),
|
|
492
|
+
};
|
|
493
|
+
this.#systemCache = cached;
|
|
494
|
+
}
|
|
495
|
+
const frames = cached.frames;
|
|
496
|
+
const original = messages[userIndex] as UserMessage;
|
|
497
|
+
const originalContent: (TextContent | ImageContent)[] =
|
|
498
|
+
typeof original.content === "string" ? [{ type: "text", text: original.content }] : original.content;
|
|
499
|
+
messages[userIndex] = {
|
|
500
|
+
...original,
|
|
501
|
+
content: [{ type: "text", text: systemPromptTarget.userNote }, ...frames, ...originalContent],
|
|
502
|
+
};
|
|
503
|
+
systemPrompt = systemPromptTarget.replacement;
|
|
504
|
+
changed = true;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (!changed) return context;
|
|
508
|
+
return { ...context, systemPrompt, messages };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#framesFor(
|
|
512
|
+
cache: Map<string, FrameCacheEntry>,
|
|
513
|
+
key: string,
|
|
514
|
+
text: string,
|
|
515
|
+
shape: snapcompact.Shape,
|
|
516
|
+
): ImageContent[] {
|
|
517
|
+
const hash = Bun.hash(text);
|
|
518
|
+
const cached = cache.get(key);
|
|
519
|
+
if (cached && cached.hash === hash) return cached.frames;
|
|
520
|
+
const frames = snapcompact.renderMany(text, { shape });
|
|
521
|
+
cache.set(key, { hash, frames });
|
|
522
|
+
return frames;
|
|
523
|
+
}
|
|
524
|
+
}
|