@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.4
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 +54 -0
- package/dist/cli.js +353 -294
- 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 +458 -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/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 +12 -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/theme/theme.d.ts +23 -3
- package/dist/types/session/agent-session.d.ts +14 -7
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/snapcompact-inline.d.ts +28 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
- package/dist/types/system-prompt.d.ts +3 -1
- package/dist/types/task/render.d.ts +16 -6
- package/dist/types/tools/gh.d.ts +3 -0
- package/dist/types/tools/render-utils.d.ts +8 -16
- 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/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 +601 -153
- 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 +5 -5
- 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/settings-defs.ts +19 -4
- package/src/modes/components/settings-selector.ts +493 -93
- 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 +69 -12
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/components/tree-selector.ts +16 -6
- package/src/modes/controllers/command-controller.ts +37 -7
- 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 +81 -61
- package/src/modes/interactive-mode.ts +4 -2
- package/src/modes/rpc/rpc-mode.ts +2 -1
- package/src/modes/shared.ts +2 -0
- package/src/modes/theme/theme.ts +100 -7
- package/src/modes/utils/context-usage.ts +3 -1
- 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-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/task.md +3 -3
- package/src/sdk.ts +22 -1
- package/src/session/agent-session.ts +91 -24
- package/src/session/auth-storage.ts +1 -0
- package/src/session/session-dump-format.ts +8 -1
- package/src/session/session-manager.ts +5 -5
- package/src/session/snapcompact-inline.ts +187 -0
- package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
- package/src/slash-commands/helpers/usage-report.ts +24 -3
- package/src/system-prompt.ts +15 -1
- package/src/task/render.ts +29 -19
- 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/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
|
@@ -107,7 +107,7 @@ import {
|
|
|
107
107
|
relativePathWithinRoot,
|
|
108
108
|
Snowflake,
|
|
109
109
|
} from "@oh-my-pi/pi-utils";
|
|
110
|
-
import
|
|
110
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
111
111
|
import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
|
|
112
112
|
import { classifyDifficulty } from "../auto-thinking/classifier";
|
|
113
113
|
import { reset as resetCapabilities } from "../capability";
|
|
@@ -855,8 +855,13 @@ function extractPermissionLocations(
|
|
|
855
855
|
* `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt
|
|
856
856
|
* custom messages queued during streaming) and is matched by the custom-role
|
|
857
857
|
* `message_start` dequeue branch; user-message pushes leave it undefined and
|
|
858
|
-
* rely on the existing text-equality match.
|
|
859
|
-
|
|
858
|
+
* rely on the existing text-equality match. `images` carries the original
|
|
859
|
+
* (pre-normalization) image blocks so queue restoration (Esc / Alt+Up) can
|
|
860
|
+
* hand them back to the editor instead of dropping them. */
|
|
861
|
+
type QueuedDisplayEntry = { text: string; tag?: string; images?: ImageContent[] };
|
|
862
|
+
|
|
863
|
+
/** Entry returned by {@link AgentSession.clearQueue} / {@link AgentSession.popLastQueuedMessage}. */
|
|
864
|
+
export type RestoredQueuedMessage = { text: string; images?: ImageContent[] };
|
|
860
865
|
|
|
861
866
|
export class AgentSession {
|
|
862
867
|
readonly agent: Agent;
|
|
@@ -5028,7 +5033,7 @@ export class AgentSession {
|
|
|
5028
5033
|
async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
|
|
5029
5034
|
const normalizedImages = await normalizeModelContextImages(images);
|
|
5030
5035
|
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
5031
|
-
this.#steeringMessages.push({ text: displayText });
|
|
5036
|
+
this.#steeringMessages.push({ text: displayText, images });
|
|
5032
5037
|
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
|
5033
5038
|
if (normalizedImages && normalizedImages.length > 0) {
|
|
5034
5039
|
content.push(...normalizedImages);
|
|
@@ -5040,6 +5045,16 @@ export class AgentSession {
|
|
|
5040
5045
|
attribution: "user",
|
|
5041
5046
|
timestamp: Date.now(),
|
|
5042
5047
|
});
|
|
5048
|
+
// A steer can land on an idle session: the caller checked isStreaming
|
|
5049
|
+
// before the (potentially slow) image normalization above, so the turn
|
|
5050
|
+
// may have ended in between. Without a drain the message would strand in
|
|
5051
|
+
// the queue until the next manual prompt — schedule an immediate continue,
|
|
5052
|
+
// mirroring #queueFollowUp's idle-path delivery.
|
|
5053
|
+
if (this.#canAutoContinueForFollowUp()) {
|
|
5054
|
+
this.#scheduleAgentContinue({
|
|
5055
|
+
shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
|
|
5056
|
+
});
|
|
5057
|
+
}
|
|
5043
5058
|
}
|
|
5044
5059
|
|
|
5045
5060
|
/**
|
|
@@ -5048,7 +5063,7 @@ export class AgentSession {
|
|
|
5048
5063
|
async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
|
|
5049
5064
|
const normalizedImages = await normalizeModelContextImages(images);
|
|
5050
5065
|
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
5051
|
-
this.#followUpMessages.push({ text: displayText });
|
|
5066
|
+
this.#followUpMessages.push({ text: displayText, images });
|
|
5052
5067
|
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
|
5053
5068
|
if (normalizedImages && normalizedImages.length > 0) {
|
|
5054
5069
|
content.push(...normalizedImages);
|
|
@@ -5297,12 +5312,14 @@ export class AgentSession {
|
|
|
5297
5312
|
}
|
|
5298
5313
|
|
|
5299
5314
|
/**
|
|
5300
|
-
* Clear queued messages and return them.
|
|
5301
|
-
* Useful for restoring to editor when user aborts.
|
|
5315
|
+
* Clear queued messages and return them (text plus any attached images).
|
|
5316
|
+
* Useful for restoring to editor when user aborts. The internal entry
|
|
5317
|
+
* arrays are handed out as-is — a `tag` (if any) is inert once the record
|
|
5318
|
+
* leaves the queue.
|
|
5302
5319
|
*/
|
|
5303
|
-
clearQueue(): { steering:
|
|
5304
|
-
const steering = this.#steeringMessages
|
|
5305
|
-
const followUp = this.#followUpMessages
|
|
5320
|
+
clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } {
|
|
5321
|
+
const steering = this.#steeringMessages;
|
|
5322
|
+
const followUp = this.#followUpMessages;
|
|
5306
5323
|
this.#steeringMessages = [];
|
|
5307
5324
|
this.#followUpMessages = [];
|
|
5308
5325
|
this.agent.clearAllQueues();
|
|
@@ -5328,21 +5345,21 @@ export class AgentSession {
|
|
|
5328
5345
|
/**
|
|
5329
5346
|
* Pop the last queued message (steering first, then follow-up).
|
|
5330
5347
|
* Used by dequeue keybinding to restore messages to editor one at a time.
|
|
5331
|
-
* Returns the popped entry's
|
|
5332
|
-
* record — no orphan state can outlive the queue entry.
|
|
5348
|
+
* Returns the popped entry's text and images; the tag (if any) dies with
|
|
5349
|
+
* the record — no orphan state can outlive the queue entry.
|
|
5333
5350
|
*/
|
|
5334
|
-
popLastQueuedMessage():
|
|
5351
|
+
popLastQueuedMessage(): RestoredQueuedMessage | undefined {
|
|
5335
5352
|
// Pop from steering first (LIFO)
|
|
5336
5353
|
if (this.#steeringMessages.length > 0) {
|
|
5337
5354
|
const entry = this.#steeringMessages.pop();
|
|
5338
5355
|
this.agent.popLastSteer();
|
|
5339
|
-
return entry
|
|
5356
|
+
return entry;
|
|
5340
5357
|
}
|
|
5341
5358
|
// Then from follow-up
|
|
5342
5359
|
if (this.#followUpMessages.length > 0) {
|
|
5343
5360
|
const entry = this.#followUpMessages.pop();
|
|
5344
5361
|
this.agent.popLastFollowUp();
|
|
5345
|
-
return entry
|
|
5362
|
+
return entry;
|
|
5346
5363
|
}
|
|
5347
5364
|
return undefined;
|
|
5348
5365
|
}
|
|
@@ -6368,7 +6385,10 @@ export class AgentSession {
|
|
|
6368
6385
|
details = compactionPrep.details;
|
|
6369
6386
|
preserveData = compactionPrep.preserveData;
|
|
6370
6387
|
} else if (snapcompactReady) {
|
|
6371
|
-
const snapcompactResult = await
|
|
6388
|
+
const snapcompactResult = await snapcompact.compact(preparation, {
|
|
6389
|
+
convertToLlm,
|
|
6390
|
+
model: this.model,
|
|
6391
|
+
});
|
|
6372
6392
|
summary = snapcompactResult.summary;
|
|
6373
6393
|
shortSummary = snapcompactResult.shortSummary;
|
|
6374
6394
|
firstKeptEntryId = snapcompactResult.firstKeptEntryId;
|
|
@@ -6582,7 +6602,7 @@ export class AgentSession {
|
|
|
6582
6602
|
const rawHandoffText = await generateHandoff(
|
|
6583
6603
|
this.agent.state.messages,
|
|
6584
6604
|
model,
|
|
6585
|
-
|
|
6605
|
+
this.#modelRegistry.resolver(model, this.sessionId),
|
|
6586
6606
|
{
|
|
6587
6607
|
systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
|
|
6588
6608
|
tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools),
|
|
@@ -7252,6 +7272,22 @@ export class AgentSession {
|
|
|
7252
7272
|
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
|
|
7253
7273
|
}
|
|
7254
7274
|
|
|
7275
|
+
#resetCurrentResponsesProviderSession(reason: string): void {
|
|
7276
|
+
const currentModel = this.model;
|
|
7277
|
+
if (currentModel?.api !== "openai-responses" && currentModel?.api !== "openai-codex-responses") {
|
|
7278
|
+
return;
|
|
7279
|
+
}
|
|
7280
|
+
|
|
7281
|
+
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
|
|
7282
|
+
this.agent.appendOnlyContext?.invalidateForModelChange();
|
|
7283
|
+
logger.debug("Reset Responses provider session after stale replay error", {
|
|
7284
|
+
provider: currentModel.provider,
|
|
7285
|
+
model: currentModel.id,
|
|
7286
|
+
api: currentModel.api,
|
|
7287
|
+
reason,
|
|
7288
|
+
});
|
|
7289
|
+
}
|
|
7290
|
+
|
|
7255
7291
|
/**
|
|
7256
7292
|
* Re-evaluate append-only context mode, creating or destroying the
|
|
7257
7293
|
* manager as needed. Called on model switch AND setting change.
|
|
@@ -7577,7 +7613,7 @@ export class AgentSession {
|
|
|
7577
7613
|
return await compact(
|
|
7578
7614
|
this.#obfuscatePreparationForProvider(preparation),
|
|
7579
7615
|
candidate,
|
|
7580
|
-
|
|
7616
|
+
this.#modelRegistry.resolver(candidate, this.sessionId),
|
|
7581
7617
|
this.#obfuscateTextForProvider(customInstructions),
|
|
7582
7618
|
signal,
|
|
7583
7619
|
{
|
|
@@ -7882,7 +7918,10 @@ export class AgentSession {
|
|
|
7882
7918
|
} else if (action === "snapcompact") {
|
|
7883
7919
|
// Local, deterministic: render discarded history onto PNG frames.
|
|
7884
7920
|
// No model candidates, no API key, no retry loop.
|
|
7885
|
-
const snapcompactResult = await
|
|
7921
|
+
const snapcompactResult = await snapcompact.compact(preparation, {
|
|
7922
|
+
convertToLlm,
|
|
7923
|
+
model: this.model,
|
|
7924
|
+
});
|
|
7886
7925
|
summary = snapcompactResult.summary;
|
|
7887
7926
|
shortSummary = snapcompactResult.shortSummary;
|
|
7888
7927
|
firstKeptEntryId = snapcompactResult.firstKeptEntryId;
|
|
@@ -7906,7 +7945,7 @@ export class AgentSession {
|
|
|
7906
7945
|
compactResult = await compact(
|
|
7907
7946
|
this.#obfuscatePreparationForProvider(preparation),
|
|
7908
7947
|
candidate,
|
|
7909
|
-
|
|
7948
|
+
this.#modelRegistry.resolver(candidate, this.sessionId),
|
|
7910
7949
|
undefined,
|
|
7911
7950
|
autoCompactionSignal,
|
|
7912
7951
|
{
|
|
@@ -8293,11 +8332,32 @@ export class AgentSession {
|
|
|
8293
8332
|
if (isContextOverflow(message, contextWindow)) return false;
|
|
8294
8333
|
|
|
8295
8334
|
if (this.#isClassifierRefusal(message)) return true;
|
|
8335
|
+
if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
|
|
8296
8336
|
|
|
8297
8337
|
const err = message.errorMessage;
|
|
8298
8338
|
return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
|
|
8299
8339
|
}
|
|
8300
8340
|
|
|
8341
|
+
#isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
|
|
8342
|
+
const currentApi = this.model?.api;
|
|
8343
|
+
if (
|
|
8344
|
+
message.api !== "openai-responses" &&
|
|
8345
|
+
message.api !== "openai-codex-responses" &&
|
|
8346
|
+
currentApi !== "openai-responses" &&
|
|
8347
|
+
currentApi !== "openai-codex-responses"
|
|
8348
|
+
) {
|
|
8349
|
+
return false;
|
|
8350
|
+
}
|
|
8351
|
+
|
|
8352
|
+
const errorMessage = message.errorMessage;
|
|
8353
|
+
if (!errorMessage) return false;
|
|
8354
|
+
|
|
8355
|
+
return (
|
|
8356
|
+
/\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
|
|
8357
|
+
(/previous[ _]?response/i.test(errorMessage) && /not[ _]?found|invalid|expired|stale/i.test(errorMessage))
|
|
8358
|
+
);
|
|
8359
|
+
}
|
|
8360
|
+
|
|
8301
8361
|
#isClassifierRefusal(message: AssistantMessage): boolean {
|
|
8302
8362
|
if (message.stopReason !== "error") return false;
|
|
8303
8363
|
const stopType = message.stopDetails?.type;
|
|
@@ -8631,15 +8691,22 @@ export class AgentSession {
|
|
|
8631
8691
|
}
|
|
8632
8692
|
|
|
8633
8693
|
const errorMessage = message.errorMessage || "Unknown error";
|
|
8694
|
+
const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message);
|
|
8634
8695
|
const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
|
|
8635
|
-
let delayMs =
|
|
8696
|
+
let delayMs = staleOpenAIResponsesReplayError
|
|
8697
|
+
? 0
|
|
8698
|
+
: calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt);
|
|
8636
8699
|
let switchedCredential = false;
|
|
8637
8700
|
let switchedModel = false;
|
|
8638
8701
|
// Set when a usage-limit error pinned the wait to credential
|
|
8639
8702
|
// availability — suppresses the generic retry-after bump below.
|
|
8640
8703
|
let usageLimitWaitMs: number | undefined;
|
|
8641
8704
|
|
|
8642
|
-
if (
|
|
8705
|
+
if (staleOpenAIResponsesReplayError) {
|
|
8706
|
+
this.#resetCurrentResponsesProviderSession("stale replay error");
|
|
8707
|
+
}
|
|
8708
|
+
|
|
8709
|
+
if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) {
|
|
8643
8710
|
const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
|
|
8644
8711
|
const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
|
|
8645
8712
|
this.model.provider,
|
|
@@ -8676,7 +8743,7 @@ export class AgentSession {
|
|
|
8676
8743
|
}
|
|
8677
8744
|
|
|
8678
8745
|
const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
|
|
8679
|
-
if (!switchedCredential && currentSelector) {
|
|
8746
|
+
if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
|
|
8680
8747
|
if (retrySettings.modelFallback) {
|
|
8681
8748
|
if (!classifierRefusal) {
|
|
8682
8749
|
this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
|
|
@@ -9813,7 +9880,7 @@ export class AgentSession {
|
|
|
9813
9880
|
const branchSummarySettings = this.settings.getGroup("branchSummary");
|
|
9814
9881
|
const result = await generateBranchSummary(entriesToSummarize, {
|
|
9815
9882
|
model,
|
|
9816
|
-
apiKey,
|
|
9883
|
+
apiKey: this.#modelRegistry.resolver(model, this.sessionId),
|
|
9817
9884
|
signal: this.#branchSummaryAbortController.signal,
|
|
9818
9885
|
customInstructions: this.#obfuscateTextForProvider(options.customInstructions),
|
|
9819
9886
|
reserveTokens: branchSummarySettings.reserveTokens,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
5
5
|
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
|
|
6
6
|
import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
|
|
7
|
+
import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
7
8
|
import {
|
|
8
9
|
type BashExecutionMessage,
|
|
9
10
|
type BranchSummaryMessage,
|
|
@@ -47,6 +48,12 @@ function stripTypeBoxFields(obj: unknown): unknown {
|
|
|
47
48
|
return obj;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/** Resolve tool parameters to a plain JSON Schema object for dump output. */
|
|
52
|
+
function toolParametersToJsonSchema(parameters: unknown): unknown {
|
|
53
|
+
if (isZodSchema(parameters)) return zodToWireSchema(parameters);
|
|
54
|
+
return stripTypeBoxFields(parameters);
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
/** Serialize an object as XML parameter elements, one per key. */
|
|
51
58
|
function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
|
|
52
59
|
const parts: string[] = [];
|
|
@@ -89,7 +96,7 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
|
|
|
89
96
|
for (const tool of tools) {
|
|
90
97
|
lines.push(`<tool name="${tool.name}">`);
|
|
91
98
|
lines.push(tool.description);
|
|
92
|
-
const parametersClean =
|
|
99
|
+
const parametersClean = toolParametersToJsonSchema(tool.parameters);
|
|
93
100
|
lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
|
|
94
101
|
lines.push("<" + "/tool>\n");
|
|
95
102
|
}
|
|
@@ -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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapcompact inline imaging: per-request transform that swaps the system
|
|
3
|
+
* prompt and/or large historical tool results for dense PNG frames on
|
|
4
|
+
* vision-capable models.
|
|
5
|
+
*
|
|
6
|
+
* Runs inside the agent loop's `transformProviderContext` hook — after the
|
|
7
|
+
* persisted history is converted to the outgoing `Context`, before the
|
|
8
|
+
* provider stream call. It only ever builds NEW message objects/arrays; the
|
|
9
|
+
* input context shares `content` array references with the persisted
|
|
10
|
+
* `SessionMessageEntry` messages, so mutation would leak rendered images
|
|
11
|
+
* into session.jsonl.
|
|
12
|
+
*/
|
|
13
|
+
import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
|
|
14
|
+
import { countTokens } from "@oh-my-pi/pi-natives";
|
|
15
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
16
|
+
import systemFramesNote from "../prompts/system/snapcompact-system-frames-note.md" with { type: "text" };
|
|
17
|
+
import systemStub from "../prompts/system/snapcompact-system-stub.md" with { type: "text" };
|
|
18
|
+
import toolResultNote from "../prompts/system/snapcompact-toolresult-note.md" with { type: "text" };
|
|
19
|
+
|
|
20
|
+
export interface SnapcompactInlineOptions {
|
|
21
|
+
renderSystemPrompt: boolean;
|
|
22
|
+
renderToolResults: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Image-count budget per provider. Snapcompact frames are 1568px (<2000px) so
|
|
27
|
+
* dimension/size limits never bind; only COUNT does. Strictest mainstream is
|
|
28
|
+
* Groq (~5), so unknown providers get the safe floor.
|
|
29
|
+
*/
|
|
30
|
+
const INLINE_IMAGE_BUDGET_BY_PROVIDER: Record<string, number> = {
|
|
31
|
+
anthropic: 90,
|
|
32
|
+
"amazon-bedrock": 90,
|
|
33
|
+
openai: 200,
|
|
34
|
+
google: 200,
|
|
35
|
+
"google-vertex": 200,
|
|
36
|
+
"google-gemini-cli": 200,
|
|
37
|
+
};
|
|
38
|
+
const DEFAULT_INLINE_IMAGE_BUDGET = 5;
|
|
39
|
+
const MAX_SYSTEM_PROMPT_FRAMES = 6;
|
|
40
|
+
/** Tool results under this many tokens are never rasterized — the swap can't
|
|
41
|
+
* save enough to justify trading crisp text for an image. */
|
|
42
|
+
const MIN_TOOL_RESULT_TOKENS = 3000;
|
|
43
|
+
/** Render only if imageTokens <= textTokens * SAVINGS_MARGIN. */
|
|
44
|
+
const SAVINGS_MARGIN = 0.9;
|
|
45
|
+
|
|
46
|
+
/** Count image blocks already present across all message contents. */
|
|
47
|
+
function countContextImages(context: Context): number {
|
|
48
|
+
let count = 0;
|
|
49
|
+
for (const message of context.messages) {
|
|
50
|
+
const content = message.content;
|
|
51
|
+
if (typeof content === "string") continue;
|
|
52
|
+
for (const block of content) {
|
|
53
|
+
if (block.type === "image") count++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return count;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isTextContent(block: TextContent | ImageContent): block is TextContent {
|
|
60
|
+
return block.type === "text";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Image tokens must undercut text tokens by the margin to be worth rendering. */
|
|
64
|
+
function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens: number): boolean {
|
|
65
|
+
return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface FrameCacheEntry {
|
|
69
|
+
hash: number | bigint;
|
|
70
|
+
frames: ImageContent[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Stateless with respect to the model (passed per call, so mid-session model
|
|
75
|
+
* switches re-resolve shape and budget); stateful only for the render caches,
|
|
76
|
+
* which live as long as the session's Agent.
|
|
77
|
+
*/
|
|
78
|
+
export class SnapcompactInlineTransformer {
|
|
79
|
+
/** Rendered tool-result frames keyed by toolCallId. */
|
|
80
|
+
#toolCache = new Map<string, FrameCacheEntry>();
|
|
81
|
+
#systemCache?: FrameCacheEntry;
|
|
82
|
+
|
|
83
|
+
constructor(private readonly options: SnapcompactInlineOptions) {}
|
|
84
|
+
|
|
85
|
+
transform(context: Context, model: Model): Context {
|
|
86
|
+
// Vision gate: providers silently DROP images on text-only models —
|
|
87
|
+
// rendering would lose the content entirely.
|
|
88
|
+
if (!model.input.includes("image")) return context;
|
|
89
|
+
|
|
90
|
+
const shape = snapcompact.resolveShape(model.api);
|
|
91
|
+
let budget =
|
|
92
|
+
(INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - countContextImages(context);
|
|
93
|
+
if (budget <= 0) return context;
|
|
94
|
+
|
|
95
|
+
const messages = [...context.messages];
|
|
96
|
+
let changed = false;
|
|
97
|
+
|
|
98
|
+
if (this.options.renderToolResults) {
|
|
99
|
+
const toolResultIndices: number[] = [];
|
|
100
|
+
const liveToolCallIds = new Set<string>();
|
|
101
|
+
for (let i = 0; i < messages.length; i++) {
|
|
102
|
+
const message = messages[i];
|
|
103
|
+
if (message.role !== "toolResult") continue;
|
|
104
|
+
toolResultIndices.push(i);
|
|
105
|
+
liveToolCallIds.add(message.toolCallId);
|
|
106
|
+
}
|
|
107
|
+
// Oldest-first for cache-stable bytes; skip the LAST tool result so
|
|
108
|
+
// the freshest output stays crisp text.
|
|
109
|
+
for (let k = 0; k < toolResultIndices.length - 1 && budget > 0; k++) {
|
|
110
|
+
const index = toolResultIndices[k];
|
|
111
|
+
const message = messages[index] as ToolResultMessage;
|
|
112
|
+
// Don't re-image results that already carry images (screenshots etc.).
|
|
113
|
+
if (message.content.some(block => block.type === "image")) continue;
|
|
114
|
+
const text = message.content
|
|
115
|
+
.filter(isTextContent)
|
|
116
|
+
.map(block => block.text)
|
|
117
|
+
.join("\n");
|
|
118
|
+
const textTokens = countTokens(text);
|
|
119
|
+
if (textTokens < MIN_TOOL_RESULT_TOKENS) continue;
|
|
120
|
+
const needed = snapcompact.frames(text, { shape });
|
|
121
|
+
if (needed === 0 || needed > budget) continue;
|
|
122
|
+
if (!passesSavingsGate(needed, shape, textTokens)) continue;
|
|
123
|
+
const frames = this.#framesFor(this.#toolCache, message.toolCallId, text, shape);
|
|
124
|
+
messages[index] = { ...message, content: [{ type: "text", text: toolResultNote }, ...frames] };
|
|
125
|
+
budget -= frames.length;
|
|
126
|
+
changed = true;
|
|
127
|
+
}
|
|
128
|
+
// Drop cache entries for tool calls no longer in the context
|
|
129
|
+
// (compacted away) so the cache stays bounded by live history.
|
|
130
|
+
for (const key of this.#toolCache.keys()) {
|
|
131
|
+
if (!liveToolCallIds.has(key)) this.#toolCache.delete(key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let systemPrompt = context.systemPrompt;
|
|
136
|
+
if (this.options.renderSystemPrompt && context.systemPrompt?.length && budget > 0) {
|
|
137
|
+
const joined = context.systemPrompt.join("\n\n");
|
|
138
|
+
const needed = snapcompact.frames(joined, { shape });
|
|
139
|
+
const userIndex = messages.findIndex(message => message.role === "user");
|
|
140
|
+
if (
|
|
141
|
+
needed > 0 &&
|
|
142
|
+
needed <= Math.min(budget, MAX_SYSTEM_PROMPT_FRAMES) &&
|
|
143
|
+
passesSavingsGate(needed, shape, countTokens(joined)) &&
|
|
144
|
+
// No user message to carry the frames → leave the prompt as text.
|
|
145
|
+
userIndex >= 0
|
|
146
|
+
) {
|
|
147
|
+
const hash = Bun.hash(joined);
|
|
148
|
+
let cached = this.#systemCache;
|
|
149
|
+
if (!cached || cached.hash !== hash) {
|
|
150
|
+
cached = {
|
|
151
|
+
hash,
|
|
152
|
+
frames: snapcompact.renderMany(joined, { shape, maxFrames: MAX_SYSTEM_PROMPT_FRAMES }),
|
|
153
|
+
};
|
|
154
|
+
this.#systemCache = cached;
|
|
155
|
+
}
|
|
156
|
+
const frames = cached.frames;
|
|
157
|
+
const original = messages[userIndex] as UserMessage;
|
|
158
|
+
const originalContent: (TextContent | ImageContent)[] =
|
|
159
|
+
typeof original.content === "string" ? [{ type: "text", text: original.content }] : original.content;
|
|
160
|
+
messages[userIndex] = {
|
|
161
|
+
...original,
|
|
162
|
+
content: [{ type: "text", text: systemFramesNote }, ...frames, ...originalContent],
|
|
163
|
+
};
|
|
164
|
+
systemPrompt = [systemStub];
|
|
165
|
+
budget -= frames.length;
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!changed) return context;
|
|
171
|
+
return { ...context, systemPrompt, messages };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#framesFor(
|
|
175
|
+
cache: Map<string, FrameCacheEntry>,
|
|
176
|
+
key: string,
|
|
177
|
+
text: string,
|
|
178
|
+
shape: snapcompact.Shape,
|
|
179
|
+
): ImageContent[] {
|
|
180
|
+
const hash = Bun.hash(text);
|
|
181
|
+
const cached = cache.get(key);
|
|
182
|
+
if (cached && cached.hash === hash) return cached.frames;
|
|
183
|
+
const frames = snapcompact.renderMany(text, { shape });
|
|
184
|
+
cache.set(key, { hash, frames });
|
|
185
|
+
return frames;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import type { OAuthAccountIdentity } from "../../session/auth-storage";
|
|
3
|
+
|
|
4
|
+
function normalizeIdentityValue(value: unknown): string | undefined {
|
|
5
|
+
return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* True when a single usage-limit column belongs to the given OAuth identity.
|
|
10
|
+
*
|
|
11
|
+
* Single definition of the matching rules for both `/usage` renderers:
|
|
12
|
+
* - `accountId` ↔ report metadata `accountId`/`account_id` or `limit.scope.accountId`
|
|
13
|
+
* - `email` ↔ report metadata `email`
|
|
14
|
+
* - `projectId` ↔ report metadata `projectId` or `limit.scope.projectId`
|
|
15
|
+
* (Google-style providers key usage on the GCP project, not an account id)
|
|
16
|
+
*/
|
|
17
|
+
export function limitMatchesActiveAccount(
|
|
18
|
+
report: UsageReport,
|
|
19
|
+
limit: UsageLimit,
|
|
20
|
+
identity: OAuthAccountIdentity | undefined,
|
|
21
|
+
): boolean {
|
|
22
|
+
if (!identity) return false;
|
|
23
|
+
const metadata = report.metadata ?? {};
|
|
24
|
+
const activeAccountId = normalizeIdentityValue(identity.accountId);
|
|
25
|
+
if (activeAccountId) {
|
|
26
|
+
const reportAccountId = normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id);
|
|
27
|
+
if (reportAccountId === activeAccountId) return true;
|
|
28
|
+
if (normalizeIdentityValue(limit.scope.accountId) === activeAccountId) return true;
|
|
29
|
+
}
|
|
30
|
+
const activeEmail = normalizeIdentityValue(identity.email);
|
|
31
|
+
if (activeEmail && normalizeIdentityValue(metadata.email) === activeEmail) return true;
|
|
32
|
+
const activeProjectId = normalizeIdentityValue(identity.projectId);
|
|
33
|
+
if (activeProjectId) {
|
|
34
|
+
if (normalizeIdentityValue(metadata.projectId) === activeProjectId) return true;
|
|
35
|
+
if (normalizeIdentityValue(limit.scope.projectId) === activeProjectId) return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when any limit column in `report` belongs to the given OAuth identity. */
|
|
41
|
+
export function reportMatchesActiveAccount(report: UsageReport, identity: OAuthAccountIdentity | undefined): boolean {
|
|
42
|
+
if (!identity) return false;
|
|
43
|
+
return report.limits.some(limit => limitMatchesActiveAccount(report, limit, identity));
|
|
44
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import type { OAuthAccountIdentity } from "../../session/auth-storage";
|
|
2
3
|
import type { SlashCommandRuntime } from "../types";
|
|
4
|
+
import { reportMatchesActiveAccount } from "./active-oauth-account";
|
|
3
5
|
import { formatDuration, renderAsciiBar } from "./format";
|
|
4
6
|
|
|
5
7
|
function formatProviderName(provider: string): string {
|
|
@@ -31,7 +33,11 @@ function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index:
|
|
|
31
33
|
return `account ${index + 1}`;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
function renderUsageReports(
|
|
36
|
+
function renderUsageReports(
|
|
37
|
+
reports: UsageReport[],
|
|
38
|
+
nowMs: number,
|
|
39
|
+
resolveActiveAccount?: (provider: string) => OAuthAccountIdentity | undefined,
|
|
40
|
+
): string {
|
|
35
41
|
const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
|
|
36
42
|
const lines = [`Usage${latestFetchedAt ? ` (${formatDuration(nowMs - latestFetchedAt)} ago)` : ""}`];
|
|
37
43
|
const grouped = new Map<string, UsageReport[]>();
|
|
@@ -45,7 +51,9 @@ function renderUsageReports(reports: UsageReport[], nowMs: number): string {
|
|
|
45
51
|
left.localeCompare(right),
|
|
46
52
|
)) {
|
|
47
53
|
lines.push("", formatProviderName(provider));
|
|
54
|
+
const activeAccount = resolveActiveAccount?.(provider);
|
|
48
55
|
for (const report of providerReports) {
|
|
56
|
+
const inUse = reportMatchesActiveAccount(report, activeAccount);
|
|
49
57
|
if (report.limits.length === 0) {
|
|
50
58
|
const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account";
|
|
51
59
|
lines.push(`- ${email}: no limits reported`);
|
|
@@ -56,7 +64,9 @@ function renderUsageReports(reports: UsageReport[], nowMs: number): string {
|
|
|
56
64
|
const window = limit.window?.label ?? limit.scope.windowId;
|
|
57
65
|
const tier = limit.scope.tier ? ` (${limit.scope.tier})` : "";
|
|
58
66
|
lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`);
|
|
59
|
-
lines.push(
|
|
67
|
+
lines.push(
|
|
68
|
+
` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}${inUse ? " ← in use by this session" : ""}`,
|
|
69
|
+
);
|
|
60
70
|
lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`);
|
|
61
71
|
if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) {
|
|
62
72
|
lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
|
|
@@ -79,7 +89,18 @@ export async function buildUsageReportText(runtime: SlashCommandRuntime): Promis
|
|
|
79
89
|
};
|
|
80
90
|
if (provider.fetchUsageReports) {
|
|
81
91
|
const reports = await provider.fetchUsageReports();
|
|
82
|
-
if (reports && reports.length > 0)
|
|
92
|
+
if (reports && reports.length > 0) {
|
|
93
|
+
const currentProvider = runtime.session.model?.provider;
|
|
94
|
+
const activeAccount = currentProvider
|
|
95
|
+
? runtime.session.modelRegistry.authStorage.getOAuthAccountIdentity(
|
|
96
|
+
currentProvider,
|
|
97
|
+
runtime.session.sessionId,
|
|
98
|
+
)
|
|
99
|
+
: undefined;
|
|
100
|
+
return renderUsageReports(reports, Date.now(), providerId =>
|
|
101
|
+
providerId === currentProvider ? activeAccount : undefined,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
83
104
|
}
|
|
84
105
|
|
|
85
106
|
const stats = runtime.session.sessionManager.getUsageStatistics();
|