@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.18
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 +49 -0
- package/dist/cli.js +16646 -16608
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/cli/usage-cli.d.ts +14 -0
- package/dist/types/commands/token.d.ts +9 -0
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +7 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +34 -15
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/extensions/extension-dashboard.d.ts +26 -10
- package/dist/types/modes/components/extensions/extension-list.d.ts +13 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/thinking.d.ts +8 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +9 -16
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/usage-cli.ts +40 -5
- package/src/commands/token.ts +54 -0
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +40 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +240 -90
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/extensions/extension-dashboard.ts +221 -165
- package/src/modes/components/extensions/extension-list.ts +66 -33
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +16 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +23 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +193 -49
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/parallel.ts +6 -1
- package/src/thinking.ts +9 -2
- package/src/tiny/title-client.ts +75 -21
- package/src/utils/tools-manager.ts +67 -10
|
@@ -84,6 +84,22 @@ export class SelectorController {
|
|
|
84
84
|
),
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Restore keyboard focus to whatever currently owns the editor slot. The
|
|
90
|
+
* slot can hold the editor itself or a hook selector/input/editor pushed
|
|
91
|
+
* in by `ExtensionUiController` — e.g. an approval prompt that fired while
|
|
92
|
+
* a fullscreen overlay was up. `overlayHandle.hide()` restores focus to
|
|
93
|
+
* the component focused when the overlay opened, which is stale in that
|
|
94
|
+
* case (the editor was swapped out): keys land on a hidden editor and the
|
|
95
|
+
* visible prompt receives nothing (issue #3349). Call this after the
|
|
96
|
+
* overlay hides to re-target focus at the visible slot owner.
|
|
97
|
+
*/
|
|
98
|
+
focusActiveEditorArea(): void {
|
|
99
|
+
const visible = this.ctx.editorContainer.children[0] ?? this.ctx.editor;
|
|
100
|
+
this.ctx.ui.setFocus(visible);
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
/**
|
|
88
104
|
* Shows a selector component in place of the editor.
|
|
89
105
|
* @param create Factory that receives a `done` callback and returns the component and focus target
|
|
@@ -109,7 +125,7 @@ export class SelectorController {
|
|
|
109
125
|
let overlayHandle: OverlayHandle | undefined;
|
|
110
126
|
const done = () => {
|
|
111
127
|
overlayHandle?.hide();
|
|
112
|
-
this.
|
|
128
|
+
this.focusActiveEditorArea();
|
|
113
129
|
this.ctx.ui.requestRender();
|
|
114
130
|
};
|
|
115
131
|
const selector = new SettingsSelectorComponent(
|
|
@@ -216,14 +232,19 @@ export class SelectorController {
|
|
|
216
232
|
*/
|
|
217
233
|
async showExtensionsDashboard(): Promise<void> {
|
|
218
234
|
const dashboard = await ExtensionDashboard.create(getProjectDir(), this.ctx.settings, this.ctx.ui.terminal.rows);
|
|
235
|
+
// Fullscreen dashboard on the alternate screen (the /settings idiom): the
|
|
236
|
+
// overlay borrows the terminal's alt buffer and enables mouse tracking for
|
|
237
|
+
// its lifetime, leaving the transcript untouched underneath.
|
|
219
238
|
const overlay = this.ctx.ui.showOverlay(dashboard, {
|
|
220
239
|
width: "100%",
|
|
221
240
|
maxHeight: "100%",
|
|
222
241
|
anchor: "top-left",
|
|
223
242
|
margin: 0,
|
|
243
|
+
fullscreen: true,
|
|
224
244
|
});
|
|
225
245
|
dashboard.onClose = () => {
|
|
226
246
|
overlay.hide();
|
|
247
|
+
this.focusActiveEditorArea();
|
|
227
248
|
this.ctx.ui.requestRender();
|
|
228
249
|
};
|
|
229
250
|
dashboard.onRequestRender = () => {
|
|
@@ -251,6 +272,7 @@ export class SelectorController {
|
|
|
251
272
|
});
|
|
252
273
|
dashboard.onClose = () => {
|
|
253
274
|
overlay.hide();
|
|
275
|
+
this.focusActiveEditorArea();
|
|
254
276
|
this.ctx.ui.requestRender();
|
|
255
277
|
};
|
|
256
278
|
dashboard.onRequestRender = () => {
|
|
@@ -1441,9 +1441,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1441
1441
|
|
|
1442
1442
|
rebuildChatFromMessages(): void {
|
|
1443
1443
|
this.chatContainer.clear();
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
const context = this.viewSession.buildTranscriptSessionContext();
|
|
1444
|
+
// Live display uses the compacted transcript tail; export/resume callers
|
|
1445
|
+
// can still request the full inline compaction history.
|
|
1446
|
+
const context = this.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true });
|
|
1447
1447
|
this.renderSessionContext(context);
|
|
1448
1448
|
// During the pre-streaming window — after `startPendingSubmission` has
|
|
1449
1449
|
// optimistically rendered the user's message but before the user
|
|
@@ -517,9 +517,9 @@ export class UiHelpers {
|
|
|
517
517
|
this.ctx.pendingBashComponents = [];
|
|
518
518
|
this.ctx.pendingPythonComponents = [];
|
|
519
519
|
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
const context = this.ctx.viewSession.buildTranscriptSessionContext();
|
|
520
|
+
// Live display uses the compacted transcript tail; export/resume callers
|
|
521
|
+
// can still request the full inline compaction history.
|
|
522
|
+
const context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true });
|
|
523
523
|
this.ctx.renderSessionContext(context, {
|
|
524
524
|
updateFooter: true,
|
|
525
525
|
populateHistory: !this.ctx.focusedAgentId,
|
package/src/sdk.ts
CHANGED
|
@@ -115,6 +115,7 @@ import {
|
|
|
115
115
|
USER_INTERRUPT_LABEL,
|
|
116
116
|
wrapSteeringForModel,
|
|
117
117
|
} from "./session/messages";
|
|
118
|
+
import { clampProviderContextImages } from "./session/provider-image-budget";
|
|
118
119
|
import { getRestorableSessionModels } from "./session/session-context";
|
|
119
120
|
import { SessionManager } from "./session/session-manager";
|
|
120
121
|
import { SnapcompactInlineTransformer } from "./session/snapcompact-inline";
|
|
@@ -2420,8 +2421,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2420
2421
|
return wrapSteeringForModel(withContext);
|
|
2421
2422
|
};
|
|
2422
2423
|
// Per-request provider-context transforms. Obfuscate FIRST so secrets are
|
|
2423
|
-
// redacted from text before snapcompact rasterizes it into PNG frames
|
|
2424
|
-
//
|
|
2424
|
+
// redacted from text before snapcompact rasterizes it into PNG frames, then
|
|
2425
|
+
// clamp images to the active provider budget before the request is sent.
|
|
2425
2426
|
const snapcompactSystemPromptMode = settings.get("snapcompact.systemPrompt");
|
|
2426
2427
|
const snapcompactInline =
|
|
2427
2428
|
snapcompactSystemPromptMode !== "none" || settings.get("snapcompact.toolResults")
|
|
@@ -2436,14 +2437,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2436
2437
|
createSnapcompactSavingsRecorder(() => sessionManager.getSessionFile() ?? null),
|
|
2437
2438
|
)
|
|
2438
2439
|
: undefined;
|
|
2439
|
-
const transformProviderContext =
|
|
2440
|
-
obfuscator
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
return transformed;
|
|
2445
|
-
}
|
|
2446
|
-
: undefined;
|
|
2440
|
+
const transformProviderContext = async (context: Context, transformModel: Model): Promise<Context> => {
|
|
2441
|
+
let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
|
|
2442
|
+
if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
|
|
2443
|
+
return clampProviderContextImages(transformed, transformModel);
|
|
2444
|
+
};
|
|
2447
2445
|
const onPayload = async (payload: unknown, _model?: Model) => {
|
|
2448
2446
|
return await extensionRunner.emitBeforeProviderRequest(payload);
|
|
2449
2447
|
};
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
CompactionCancelledError,
|
|
45
45
|
type CompactionPreparation,
|
|
46
46
|
type CompactionResult,
|
|
47
|
+
type CompactionSettings,
|
|
47
48
|
calculateContextTokens,
|
|
48
49
|
calculatePromptTokens,
|
|
49
50
|
collectEntriesForBranchSummary,
|
|
@@ -303,7 +304,7 @@ import {
|
|
|
303
304
|
stripImagesFromMessage,
|
|
304
305
|
USER_INTERRUPT_LABEL,
|
|
305
306
|
} from "./messages";
|
|
306
|
-
import type { SessionContext } from "./session-context";
|
|
307
|
+
import type { BuildSessionContextOptions, SessionContext } from "./session-context";
|
|
307
308
|
import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
|
|
308
309
|
import { formatSessionDumpText } from "./session-dump-format";
|
|
309
310
|
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
|
|
@@ -1365,6 +1366,13 @@ export class AgentSession {
|
|
|
1365
1366
|
#pendingRewindReport: string | undefined = undefined;
|
|
1366
1367
|
#rewoundToolResultIds = new Set<string>();
|
|
1367
1368
|
#lastSuccessfulYieldToolCallId: string | undefined = undefined;
|
|
1369
|
+
/**
|
|
1370
|
+
* Sticky across an in-flight prompt run: a successful `yield` makes the run
|
|
1371
|
+
* terminal for execution purposes, so any trailing empty/aborted assistant
|
|
1372
|
+
* stop must NOT trigger empty-stop/unexpected-stop/compaction continuations.
|
|
1373
|
+
* Cleared in `#promptWithMessage` so the next prompt evaluates cleanly.
|
|
1374
|
+
*/
|
|
1375
|
+
#yieldTerminationPending = false;
|
|
1368
1376
|
#providerSessionState = new Map<string, ProviderSessionState>();
|
|
1369
1377
|
#hindsightSessionState: HindsightSessionState | undefined = undefined;
|
|
1370
1378
|
readonly rawSseDebugBuffer: RawSseDebugBuffer;
|
|
@@ -2554,6 +2562,7 @@ export class AgentSession {
|
|
|
2554
2562
|
}
|
|
2555
2563
|
if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
|
|
2556
2564
|
this.#lastSuccessfulYieldToolCallId = event.toolCallId;
|
|
2565
|
+
this.#yieldTerminationPending = true;
|
|
2557
2566
|
}
|
|
2558
2567
|
|
|
2559
2568
|
// TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
|
|
@@ -2824,15 +2833,24 @@ export class AgentSession {
|
|
|
2824
2833
|
}
|
|
2825
2834
|
|
|
2826
2835
|
const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
|
|
2827
|
-
|
|
2836
|
+
const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
|
|
2837
|
+
// A successful `yield` in this run is terminal for execution purposes.
|
|
2838
|
+
// Suppress empty-stop retry, unexpected-stop retry, queued-message drain,
|
|
2839
|
+
// and compaction-driven continuations for the rest of this prompt cycle:
|
|
2840
|
+
// the executor consumed the yield as the terminal result, so a trailing
|
|
2841
|
+
// empty/aborted assistant stop must NOT revive the agent loop. The
|
|
2842
|
+
// `#yieldTerminationPending` sticky flag clears on the next `prompt()`.
|
|
2843
|
+
if (yieldOnThisMessage || this.#yieldTerminationPending) {
|
|
2828
2844
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
2829
|
-
if (activeGoal) {
|
|
2845
|
+
if (yieldOnThisMessage && activeGoal) {
|
|
2830
2846
|
maintenanceRoute("successful-yield-active-goal-checkCompaction");
|
|
2831
2847
|
const compactionTask = this.#checkCompaction(msg);
|
|
2832
2848
|
this.#trackPostPromptTask(compactionTask);
|
|
2833
2849
|
await compactionTask;
|
|
2834
|
-
} else {
|
|
2850
|
+
} else if (yieldOnThisMessage) {
|
|
2835
2851
|
maintenanceRoute("successful-yield-no-active-goal");
|
|
2852
|
+
} else {
|
|
2853
|
+
maintenanceRoute("post-yield-trailing-stop-suppressed");
|
|
2836
2854
|
}
|
|
2837
2855
|
await emitAgentEndNotification();
|
|
2838
2856
|
return;
|
|
@@ -5288,13 +5306,21 @@ export class AgentSession {
|
|
|
5288
5306
|
}
|
|
5289
5307
|
|
|
5290
5308
|
/**
|
|
5291
|
-
*
|
|
5292
|
-
*
|
|
5293
|
-
*
|
|
5294
|
-
*
|
|
5309
|
+
* Transcript for TUI display. Full history is kept for export/resume-style
|
|
5310
|
+
* callers; live chat can collapse compacted history to keep the hot render
|
|
5311
|
+
* surface bounded. Display-only — NEVER feed the result to
|
|
5312
|
+
* `agent.replaceMessages` or a provider.
|
|
5295
5313
|
*/
|
|
5296
|
-
buildTranscriptSessionContext(
|
|
5297
|
-
|
|
5314
|
+
buildTranscriptSessionContext(
|
|
5315
|
+
options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory">,
|
|
5316
|
+
): SessionContext {
|
|
5317
|
+
return deobfuscateSessionContext(
|
|
5318
|
+
this.sessionManager.buildSessionContext({
|
|
5319
|
+
transcript: true,
|
|
5320
|
+
collapseCompactedHistory: options?.collapseCompactedHistory,
|
|
5321
|
+
}),
|
|
5322
|
+
this.#obfuscator,
|
|
5323
|
+
);
|
|
5298
5324
|
}
|
|
5299
5325
|
|
|
5300
5326
|
#obfuscateTextForProvider(text: string | undefined): string | undefined {
|
|
@@ -6000,6 +6026,10 @@ export class AgentSession {
|
|
|
6000
6026
|
this.#todoReminderAwaitingProgress = false;
|
|
6001
6027
|
this.#emptyStopRetryCount = 0;
|
|
6002
6028
|
this.#unexpectedStopRetryCount = 0;
|
|
6029
|
+
// A new prompt cycle starts: drop any sticky yield-termination from the
|
|
6030
|
+
// previous run so empty-stop / unexpected-stop / compaction maintenance
|
|
6031
|
+
// can evaluate this turn normally.
|
|
6032
|
+
this.#yieldTerminationPending = false;
|
|
6003
6033
|
|
|
6004
6034
|
await this.#maybeRestoreRetryFallbackPrimary();
|
|
6005
6035
|
|
|
@@ -7028,20 +7058,22 @@ export class AgentSession {
|
|
|
7028
7058
|
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
7029
7059
|
}
|
|
7030
7060
|
|
|
7061
|
+
const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
|
|
7062
|
+
|
|
7031
7063
|
this.#clearActiveRetryFallback();
|
|
7032
|
-
this.#setModelWithProviderSessionReset(
|
|
7033
|
-
this.sessionManager.appendModelChange(`${
|
|
7064
|
+
this.#setModelWithProviderSessionReset(targetModel);
|
|
7065
|
+
this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
|
|
7034
7066
|
if (options?.persist) {
|
|
7035
7067
|
this.settings.setModelRole(
|
|
7036
7068
|
role,
|
|
7037
|
-
this.#formatRoleModelValue(role,
|
|
7069
|
+
this.#formatRoleModelValue(role, targetModel, options.selector, options.thinkingLevel),
|
|
7038
7070
|
);
|
|
7039
7071
|
}
|
|
7040
|
-
this.settings.getStorage()?.recordModelUsage(`${
|
|
7072
|
+
this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
|
|
7041
7073
|
|
|
7042
7074
|
// Re-apply thinking for the newly selected model. Prefer the model's
|
|
7043
7075
|
// configured defaultLevel; otherwise preserve the current level (or auto).
|
|
7044
|
-
this.#reapplyThinkingLevel(
|
|
7076
|
+
this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
|
|
7045
7077
|
await this.#syncAfterModelChange(previousEditMode);
|
|
7046
7078
|
}
|
|
7047
7079
|
|
|
@@ -7062,20 +7094,22 @@ export class AgentSession {
|
|
|
7062
7094
|
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
7063
7095
|
}
|
|
7064
7096
|
|
|
7097
|
+
const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
|
|
7098
|
+
|
|
7065
7099
|
this.#clearActiveRetryFallback();
|
|
7066
|
-
this.#setModelWithProviderSessionReset(
|
|
7100
|
+
this.#setModelWithProviderSessionReset(targetModel);
|
|
7067
7101
|
this.sessionManager.appendModelChange(
|
|
7068
|
-
`${
|
|
7102
|
+
`${targetModel.provider}/${targetModel.id}`,
|
|
7069
7103
|
options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
|
|
7070
7104
|
);
|
|
7071
|
-
this.settings.getStorage()?.recordModelUsage(`${
|
|
7105
|
+
this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
|
|
7072
7106
|
|
|
7073
7107
|
// Apply explicit thinking level if given; otherwise prefer the model's
|
|
7074
7108
|
// configured defaultLevel; otherwise re-clamp the current level (or auto).
|
|
7075
7109
|
if (thinkingLevel !== undefined) {
|
|
7076
7110
|
this.setThinkingLevel(thinkingLevel);
|
|
7077
7111
|
} else {
|
|
7078
|
-
this.#reapplyThinkingLevel(
|
|
7112
|
+
this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
|
|
7079
7113
|
}
|
|
7080
7114
|
await this.#syncAfterModelChange(previousEditMode);
|
|
7081
7115
|
}
|
|
@@ -7363,6 +7397,10 @@ export class AgentSession {
|
|
|
7363
7397
|
async #applyAutoThinkingLevel(promptText: string, generation: number): Promise<void> {
|
|
7364
7398
|
const model = this.model;
|
|
7365
7399
|
if (!model?.reasoning) return;
|
|
7400
|
+
// Models with reasoning but no controllable effort surface (devin-agent
|
|
7401
|
+
// Cascade routes effort via sibling model ids, not a wire param) have
|
|
7402
|
+
// nothing to pick — skip classification rather than discard its result.
|
|
7403
|
+
if (getSupportedEfforts(model).length === 0) return;
|
|
7366
7404
|
|
|
7367
7405
|
let resolved: Effort | undefined;
|
|
7368
7406
|
if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
|
|
@@ -7859,31 +7897,45 @@ export class AgentSession {
|
|
|
7859
7897
|
let tokensBefore: number;
|
|
7860
7898
|
let details: unknown;
|
|
7861
7899
|
|
|
7862
|
-
// Snapcompact runs locally first
|
|
7863
|
-
//
|
|
7864
|
-
//
|
|
7900
|
+
// Snapcompact runs locally first. The frame cap is sized from the live
|
|
7901
|
+
// model window via #computeSnapcompactMaxFrames so the post-render context
|
|
7902
|
+
// fits without the warning loop (issue #3247). Zero-frame budget → skip
|
|
7903
|
+
// snapcompact and take the summarizer path immediately.
|
|
7865
7904
|
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
7866
7905
|
if (snapcompactReady) {
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
7871
|
-
});
|
|
7872
|
-
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
7873
|
-
const budget =
|
|
7874
|
-
ctxWindow > 0
|
|
7875
|
-
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
|
|
7876
|
-
: Number.POSITIVE_INFINITY;
|
|
7877
|
-
if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
|
|
7878
|
-
logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
|
|
7906
|
+
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
|
|
7907
|
+
if (maxFrames < 1) {
|
|
7908
|
+
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
|
|
7879
7909
|
model: this.model?.id,
|
|
7880
7910
|
});
|
|
7881
7911
|
this.emitNotice(
|
|
7882
7912
|
"warning",
|
|
7883
|
-
"snapcompact
|
|
7913
|
+
"snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
|
|
7884
7914
|
"compaction",
|
|
7885
7915
|
);
|
|
7886
|
-
|
|
7916
|
+
} else {
|
|
7917
|
+
snapcompactResult = await snapcompact.compact(preparation, {
|
|
7918
|
+
convertToLlm,
|
|
7919
|
+
model: this.model,
|
|
7920
|
+
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
7921
|
+
maxFrames,
|
|
7922
|
+
});
|
|
7923
|
+
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
7924
|
+
const budget =
|
|
7925
|
+
ctxWindow > 0
|
|
7926
|
+
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
|
|
7927
|
+
: Number.POSITIVE_INFINITY;
|
|
7928
|
+
if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
|
|
7929
|
+
logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
|
|
7930
|
+
model: this.model?.id,
|
|
7931
|
+
});
|
|
7932
|
+
this.emitNotice(
|
|
7933
|
+
"warning",
|
|
7934
|
+
"snapcompact could not bring the context under the limit — using an LLM summary instead",
|
|
7935
|
+
"compaction",
|
|
7936
|
+
);
|
|
7937
|
+
snapcompactResult = undefined;
|
|
7938
|
+
}
|
|
7887
7939
|
}
|
|
7888
7940
|
}
|
|
7889
7941
|
|
|
@@ -9614,6 +9666,82 @@ export class AgentSession {
|
|
|
9614
9666
|
return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
|
|
9615
9667
|
}
|
|
9616
9668
|
|
|
9669
|
+
/**
|
|
9670
|
+
* Cap on snapcompact frames the post-compaction context can carry without
|
|
9671
|
+
* busting the model window. Mirrors the per-frame token charge used by the
|
|
9672
|
+
* projection ({@link snapcompact.FRAME_TOKEN_ESTIMATE}, the conservative
|
|
9673
|
+
* high-res Anthropic ceiling), so picking `maxFrames` from this helper makes
|
|
9674
|
+
* {@link #projectSnapcompactContextTokens} succeed by construction.
|
|
9675
|
+
*
|
|
9676
|
+
* Skip vs. cap use different reserves on purpose. The **skip** decision
|
|
9677
|
+
* (return `0`) trips only when kept-recent plus non-message tokens already
|
|
9678
|
+
* eat the entire `ctxWindow − reserve` envelope: at that point no archive
|
|
9679
|
+
* shape — frame-bearing or text-only — can fit, and the caller MUST
|
|
9680
|
+
* shortcut to the LLM summarizer instead of re-running snapcompact to
|
|
9681
|
+
* re-emit the "could not bring the context under the limit" warning every
|
|
9682
|
+
* threshold tick. The **cap** calculation subtracts a shape-aware reserve
|
|
9683
|
+
* (`2 × geometry(shape).capacity` chars worth of text edges, billed at the
|
|
9684
|
+
* tiktoken cl100k baseline, plus a 2k summary-template allowance) sized
|
|
9685
|
+
* from the same `shape` snapcompact will use, so the projection still
|
|
9686
|
+
* passes once frames land — but it MUST NOT gate the skip decision, since
|
|
9687
|
+
* a frame-less archive (`text.length <= 2 * edgeCap` short-circuit in
|
|
9688
|
+
* `planArchive`) typically costs only a few hundred tokens of summary
|
|
9689
|
+
* lead and would fit under residual headroom far smaller than the cap
|
|
9690
|
+
* reserve (chatgpt-codex reviews on #3249).
|
|
9691
|
+
*
|
|
9692
|
+
* Returns `1` when the frame charge would overflow but the text-only path
|
|
9693
|
+
* still has room: snapcompact's planner picks the frame-less layout
|
|
9694
|
+
* automatically when the discarded text fits in the edges, so giving it
|
|
9695
|
+
* the minimum cap lets it succeed instead of being skipped outright.
|
|
9696
|
+
*
|
|
9697
|
+
* Without this cap, the bundled `MAX_FRAMES_DEFAULT = 80` × 5024 tokens =
|
|
9698
|
+
* ~402k frame-token projection always overflows any sub-1M-token window
|
|
9699
|
+
* (issue #3247).
|
|
9700
|
+
*/
|
|
9701
|
+
#computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
|
|
9702
|
+
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
9703
|
+
if (ctxWindow <= 0) return snapcompact.MAX_FRAMES_DEFAULT;
|
|
9704
|
+
const reserve = effectiveReserveTokens(ctxWindow, settings);
|
|
9705
|
+
let baseTokens = computeNonMessageTokens(this);
|
|
9706
|
+
for (const message of preparation.recentMessages) {
|
|
9707
|
+
baseTokens += estimateTokens(message);
|
|
9708
|
+
}
|
|
9709
|
+
const totalBudget = ctxWindow - reserve;
|
|
9710
|
+
// Skip iff there is no headroom whatsoever; a text-only archive costs
|
|
9711
|
+
// far less than the cap reserve below, so any positive residual is
|
|
9712
|
+
// worth attempting and the projection guard catches actual overflow.
|
|
9713
|
+
if (baseTokens >= totalBudget) return 0;
|
|
9714
|
+
// Cap reserve mirrors what `estimateTokens(summaryMessage)` will charge
|
|
9715
|
+
// when frames > 0: `countTokens(summaryTemplate ‖ textHead ‖ textTail)`
|
|
9716
|
+
// plus `numFrames × FRAME_TOKEN_ESTIMATE`. Resolve the shape this
|
|
9717
|
+
// snapcompact pass will actually use (matches the `shape` argument
|
|
9718
|
+
// passed to `snapcompact.compact` in the auto and manual paths) so the
|
|
9719
|
+
// text-edge cost reflects the live frame geometry rather than a fixed
|
|
9720
|
+
// approximation. Reviewer (chatgpt-codex on #3249): a 4k reserve
|
|
9721
|
+
// undersized the ~7k text-edge cost on the default Anthropic
|
|
9722
|
+
// 11on16-bw shape, so the projection then rejected the `maxFrames`
|
|
9723
|
+
// the cap had picked and the warning loop reappeared.
|
|
9724
|
+
//
|
|
9725
|
+
// - `textHead` and `textTail` each consume up to `geometry.capacity`
|
|
9726
|
+
// chars when frames > 0 (one HQ-capacity page per edge: see
|
|
9727
|
+
// `TEXT_EDGE_PAGES = 1` in `planArchive`), so 2 × capacity chars
|
|
9728
|
+
// total. Per-shape capacity: Anthropic 11on16-bw ~13.9k, Opus
|
|
9729
|
+
// 1932px ~21k, Gemini 8on22-bw 2048px ~23.8k, OpenAI 1568px ~13.9k.
|
|
9730
|
+
// - tiktoken cl100k ≈ 4 chars/token on ASCII (verified empirically
|
|
9731
|
+
// for prose, code, and JSON); a 1.15 multiplier absorbs tokenizer
|
|
9732
|
+
// drift on denser content (e.g. dense JSON / tool-result blobs).
|
|
9733
|
+
// - Summary template (intro + FILES section + grid notes) bills
|
|
9734
|
+
// ~2k tokens for typical sessions.
|
|
9735
|
+
const shape = snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape"));
|
|
9736
|
+
const edgeCap = snapcompact.geometry(shape).capacity;
|
|
9737
|
+
const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
|
|
9738
|
+
const SUMMARY_TEMPLATE_TOKENS = 2000;
|
|
9739
|
+
const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
|
|
9740
|
+
const frameBudget = totalBudget - baseTokens - capReserve;
|
|
9741
|
+
if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
|
|
9742
|
+
return Math.min(Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT);
|
|
9743
|
+
}
|
|
9744
|
+
|
|
9617
9745
|
/**
|
|
9618
9746
|
* Project the post-compaction context size of a snapcompact result: kept
|
|
9619
9747
|
* recent messages + the summary message with its re-attached frames + the
|
|
@@ -9862,24 +9990,20 @@ export class AgentSession {
|
|
|
9862
9990
|
let tokensBefore: number;
|
|
9863
9991
|
let details: unknown;
|
|
9864
9992
|
|
|
9865
|
-
// Snapcompact runs locally first
|
|
9866
|
-
//
|
|
9867
|
-
//
|
|
9868
|
-
//
|
|
9869
|
-
//
|
|
9993
|
+
// Snapcompact runs locally first. The post-compaction context = kept-recent
|
|
9994
|
+
// + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
|
|
9995
|
+
// per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
|
|
9996
|
+
// live window so we don't run snapcompact just to overflow and fall back
|
|
9997
|
+
// every threshold tick. Kept-recent already over budget → skip snapcompact
|
|
9998
|
+
// outright (a single frame won't fit). Otherwise the projection below is
|
|
9999
|
+
// only a defensive guard for summary-text drift.
|
|
9870
10000
|
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
9871
10001
|
if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
|
|
9872
10002
|
const text = snapcompact.serializeConversation(
|
|
9873
10003
|
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
|
|
9874
10004
|
);
|
|
9875
10005
|
const renderScan = snapcompact.scanRenderability(text);
|
|
9876
|
-
if (renderScan.isSafe) {
|
|
9877
|
-
snapcompactResult = await snapcompact.compact(preparation, {
|
|
9878
|
-
convertToLlm,
|
|
9879
|
-
model: this.model,
|
|
9880
|
-
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
9881
|
-
});
|
|
9882
|
-
} else {
|
|
10006
|
+
if (!renderScan.isSafe) {
|
|
9883
10007
|
logger.warn("Snapcompact disabled: high non-ASCII rate detected; falling back to an LLM summary", {
|
|
9884
10008
|
model: this.model?.id,
|
|
9885
10009
|
unrenderableRatio: renderScan.unrenderableRatio,
|
|
@@ -9890,6 +10014,26 @@ export class AgentSession {
|
|
|
9890
10014
|
"compaction",
|
|
9891
10015
|
);
|
|
9892
10016
|
action = "context-full";
|
|
10017
|
+
} else {
|
|
10018
|
+
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
|
|
10019
|
+
if (maxFrames < 1) {
|
|
10020
|
+
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
|
|
10021
|
+
model: this.model?.id,
|
|
10022
|
+
});
|
|
10023
|
+
this.emitNotice(
|
|
10024
|
+
"warning",
|
|
10025
|
+
"snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
|
|
10026
|
+
"compaction",
|
|
10027
|
+
);
|
|
10028
|
+
action = "context-full";
|
|
10029
|
+
} else {
|
|
10030
|
+
snapcompactResult = await snapcompact.compact(preparation, {
|
|
10031
|
+
convertToLlm,
|
|
10032
|
+
model: this.model,
|
|
10033
|
+
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
10034
|
+
maxFrames,
|
|
10035
|
+
});
|
|
10036
|
+
}
|
|
9893
10037
|
}
|
|
9894
10038
|
|
|
9895
10039
|
if (snapcompactResult) {
|
|
@@ -9900,7 +10044,7 @@ export class AgentSession {
|
|
|
9900
10044
|
: Number.POSITIVE_INFINITY;
|
|
9901
10045
|
const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
|
|
9902
10046
|
if (projected > budget) {
|
|
9903
|
-
logger.warn("Snapcompact still overflows the window; falling back
|
|
10047
|
+
logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
|
|
9904
10048
|
model: this.model?.id,
|
|
9905
10049
|
projected,
|
|
9906
10050
|
budget,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Context,
|
|
3
|
+
DeveloperMessage,
|
|
4
|
+
ImageContent,
|
|
5
|
+
Model,
|
|
6
|
+
TextContent,
|
|
7
|
+
ToolResultMessage,
|
|
8
|
+
UserMessage,
|
|
9
|
+
} from "@oh-my-pi/pi-ai";
|
|
10
|
+
import { providerImageBudget } from "@oh-my-pi/snapcompact";
|
|
11
|
+
|
|
12
|
+
const TOOL_RESULT_IMAGE_OMISSION: TextContent = {
|
|
13
|
+
type: "text",
|
|
14
|
+
text: "[image omitted: provider image limit]",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function countImages(context: Context): number {
|
|
18
|
+
let count = 0;
|
|
19
|
+
for (const message of context.messages) {
|
|
20
|
+
if (!Array.isArray(message.content)) continue;
|
|
21
|
+
for (const part of message.content) {
|
|
22
|
+
if (part.type === "image") count++;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return count;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clampContent(
|
|
29
|
+
content: readonly (TextContent | ImageContent)[],
|
|
30
|
+
state: { remainingDrops: number },
|
|
31
|
+
): (TextContent | ImageContent)[] | undefined {
|
|
32
|
+
let changed = false;
|
|
33
|
+
const clamped: (TextContent | ImageContent)[] = [];
|
|
34
|
+
for (const part of content) {
|
|
35
|
+
if (part.type === "image" && state.remainingDrops > 0) {
|
|
36
|
+
state.remainingDrops--;
|
|
37
|
+
changed = true;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
clamped.push(part);
|
|
41
|
+
}
|
|
42
|
+
return changed ? clamped : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clampUserMessage(message: UserMessage, state: { remainingDrops: number }): UserMessage {
|
|
46
|
+
if (!Array.isArray(message.content) || state.remainingDrops <= 0) return message;
|
|
47
|
+
const content = clampContent(message.content, state);
|
|
48
|
+
return content ? { ...message, content } : message;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clampDeveloperMessage(message: DeveloperMessage, state: { remainingDrops: number }): DeveloperMessage {
|
|
52
|
+
if (!Array.isArray(message.content) || state.remainingDrops <= 0) return message;
|
|
53
|
+
const content = clampContent(message.content, state);
|
|
54
|
+
return content ? { ...message, content } : message;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clampToolResultMessage(message: ToolResultMessage, state: { remainingDrops: number }): ToolResultMessage {
|
|
58
|
+
if (state.remainingDrops <= 0) return message;
|
|
59
|
+
const content = clampContent(message.content, state);
|
|
60
|
+
if (!content) return message;
|
|
61
|
+
return { ...message, content: content.length > 0 ? content : [TOOL_RESULT_IMAGE_OMISSION] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Drops oldest transient image blocks so outgoing vision requests fit the active provider's image cap. */
|
|
65
|
+
export function clampProviderContextImages(context: Context, model: Model): Context {
|
|
66
|
+
if (!model.input.includes("image")) return context;
|
|
67
|
+
const limit = providerImageBudget(model.provider);
|
|
68
|
+
const totalImages = countImages(context);
|
|
69
|
+
if (totalImages <= limit) return context;
|
|
70
|
+
|
|
71
|
+
const state = { remainingDrops: totalImages - limit };
|
|
72
|
+
const messages = context.messages.map(message => {
|
|
73
|
+
switch (message.role) {
|
|
74
|
+
case "user":
|
|
75
|
+
return clampUserMessage(message, state);
|
|
76
|
+
case "developer":
|
|
77
|
+
return clampDeveloperMessage(message, state);
|
|
78
|
+
case "toolResult":
|
|
79
|
+
return clampToolResultMessage(message, state);
|
|
80
|
+
case "assistant":
|
|
81
|
+
return message;
|
|
82
|
+
}
|
|
83
|
+
return message;
|
|
84
|
+
});
|
|
85
|
+
return { ...context, messages };
|
|
86
|
+
}
|
|
@@ -62,13 +62,14 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
|
|
|
62
62
|
|
|
63
63
|
export interface BuildSessionContextOptions {
|
|
64
64
|
/**
|
|
65
|
-
* Build the
|
|
66
|
-
* every path entry
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* result to a provider.
|
|
65
|
+
* Build the display transcript instead of the LLM context. By default this
|
|
66
|
+
* preserves every path entry with compactions inline; set
|
|
67
|
+
* `collapseCompactedHistory` for the live TUI surface to render only the
|
|
68
|
+
* latest compacted tail.
|
|
70
69
|
*/
|
|
71
70
|
transcript?: boolean;
|
|
71
|
+
/** In transcript mode, elide entries replaced by the latest compaction. */
|
|
72
|
+
collapseCompactedHistory?: boolean;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/**
|
|
@@ -255,7 +256,7 @@ export function buildSessionContext(
|
|
|
255
256
|
}
|
|
256
257
|
};
|
|
257
258
|
|
|
258
|
-
if (options?.transcript) {
|
|
259
|
+
if (options?.transcript && !options.collapseCompactedHistory) {
|
|
259
260
|
// Display transcript: every entry in chronological order. Compactions do
|
|
260
261
|
// not erase prior history here — each renders inline (as a divider in the
|
|
261
262
|
// TUI) at the point it fired, with any snapcompact frames re-attached so
|
|
@@ -294,6 +295,7 @@ export function buildSessionContext(
|
|
|
294
295
|
})();
|
|
295
296
|
const remoteReplacementHistory = providerPayload?.items;
|
|
296
297
|
|
|
298
|
+
if (options?.transcript) handleEntryResetTracking(compaction);
|
|
297
299
|
// Emit summary first; re-attach any archived snapcompact frames so the
|
|
298
300
|
// model can keep reading the archived history after every context rebuild.
|
|
299
301
|
const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
|
|
@@ -312,7 +314,12 @@ export function buildSessionContext(
|
|
|
312
314
|
// Find compaction index in path
|
|
313
315
|
const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
|
|
314
316
|
|
|
315
|
-
|
|
317
|
+
// The remote replacement payload (OpenAI remote compaction) carries the
|
|
318
|
+
// kept turns for the LLM context only; it is not rendered as visible
|
|
319
|
+
// messages. The collapsed display transcript must still emit the kept
|
|
320
|
+
// SessionEntry rows so a remotely-compacted session keeps its recent
|
|
321
|
+
// turns visible instead of showing only the summary and post-compaction.
|
|
322
|
+
if (!remoteReplacementHistory || options?.transcript) {
|
|
316
323
|
// Emit kept messages (before compaction, starting from firstKeptEntryId)
|
|
317
324
|
let foundFirstKept = false;
|
|
318
325
|
for (let i = 0; i < compactionIdx; i++) {
|