@oh-my-pi/pi-coding-agent 15.5.8 → 15.5.10

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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.10] - 2026-05-28
6
+
7
+ ### Added
8
+
9
+ - Added `/drop-images` slash command that strips every `ImageContent` block from the current session's branch — `user`/`developer`/`custom`/`hookMessage`/`toolResult` content arrays plus `toolResult.details.images` and `fileMention.files[].image` — rewrites the session JSONL, rebuilds the agent's in-memory message list, tears down Codex Responses provider sessions, and rebuilds the TUI chat container so the change is visible immediately. ACP clients receive the same handler (returns `"Dropped N images …"` / `"No images found …"` through `runtime.output`). Stripping content that would leave a `toolResult` or `user` message with zero blocks inserts a single `[image removed]` placeholder so providers do not reject empty content arrays.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed compaction surfacing raw HTTP 401/403 envelopes (e.g. `Compaction failed: 401 {"type":"error","error":{"type":"authentication_error",…}}`) instead of routing to an authenticated fallback model. The compaction layer now attaches the provider-reported HTTP status onto the thrown error, and `AgentSession`'s auth-failure detector branches on `error.status === 401 || 403` in addition to the existing `auth_unavailable` regex. When a fallback model role (e.g. `modelRoles.smol`) is configured, compaction retries it transparently; otherwise the user sees the actionable "Compaction requires usable credentials for …" hint instead of the raw provider envelope.
14
+
5
15
  ## [15.5.8] - 2026-05-28
6
16
 
7
17
  ### Breaking Changes
@@ -667,6 +667,21 @@ export declare class AgentSession {
667
667
  * Saves to settings.
668
668
  */
669
669
  setInterruptMode(mode: "immediate" | "wait"): void;
670
+ /**
671
+ * Strip image content blocks from every message on the current branch and
672
+ * persist the rewrite. Walks `SessionManager.getBranch()` in place — both
673
+ * `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays
674
+ * are mutated, then `rewriteEntries` durably commits the new shape. The
675
+ * agent's runtime view is rebuilt from the freshly-mutated entries so any
676
+ * provider sessions caching message identity (Codex Responses) are torn
677
+ * down to force a clean replay on the next turn.
678
+ *
679
+ * No-op when the branch carries no images; returns `{ removed: 0 }` and
680
+ * skips the disk rewrite.
681
+ */
682
+ dropImages(): Promise<{
683
+ removed: number;
684
+ }>;
670
685
  /**
671
686
  * Manually compact the session context.
672
687
  * Aborts current agent operation first.
@@ -55,6 +55,16 @@ export declare const INTERNAL_DETAILS_FIELDS: readonly ["__pendingDisplayTag"];
55
55
  * (null/non-object, or no listed fields present) so callers don't pay a
56
56
  * clone cost on the common path. */
57
57
  export declare function stripInternalDetailsFields<T>(details: T | undefined): T | undefined;
58
+ /**
59
+ * Strip image content blocks from `message` in place. Returns the count of
60
+ * images removed across `content` (every role that carries `ImageContent`) and
61
+ * any tool-result `details.images` payload. Callers MUST rewrite session
62
+ * entries (`SessionManager.rewriteEntries`) and replay them through
63
+ * `Agent.replaceMessages` afterwards so persisted state and provider-side
64
+ * caches stay aligned with the mutated tree — `stripImagesFromMessage` is a
65
+ * pure local mutation and intentionally does neither.
66
+ */
67
+ export declare function stripImagesFromMessage(message: AgentMessage): number;
58
68
  /**
59
69
  * Message type for bash executions via the ! command.
60
70
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.5.8",
4
+ "version": "15.5.10",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,13 +47,13 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.5.8",
51
- "@oh-my-pi/omp-stats": "15.5.8",
52
- "@oh-my-pi/pi-agent-core": "15.5.8",
53
- "@oh-my-pi/pi-ai": "15.5.8",
54
- "@oh-my-pi/pi-natives": "15.5.8",
55
- "@oh-my-pi/pi-tui": "15.5.8",
56
- "@oh-my-pi/pi-utils": "15.5.8",
50
+ "@oh-my-pi/hashline": "15.5.10",
51
+ "@oh-my-pi/omp-stats": "15.5.10",
52
+ "@oh-my-pi/pi-agent-core": "15.5.10",
53
+ "@oh-my-pi/pi-ai": "15.5.10",
54
+ "@oh-my-pi/pi-natives": "15.5.10",
55
+ "@oh-my-pi/pi-tui": "15.5.10",
56
+ "@oh-my-pi/pi-utils": "15.5.10",
57
57
  "@puppeteer/browsers": "^2.13.0",
58
58
  "@types/turndown": "5.0.6",
59
59
  "@xterm/headless": "^6.0.0",
@@ -196,6 +196,7 @@ import {
196
196
  type PythonExecutionMessage,
197
197
  readPendingDisplayTag,
198
198
  SILENT_ABORT_MARKER,
199
+ stripImagesFromMessage,
199
200
  } from "./messages";
200
201
  import { formatSessionDumpText } from "./session-dump-format";
201
202
  import type {
@@ -5310,6 +5311,55 @@ export class AgentSession {
5310
5311
  return result;
5311
5312
  }
5312
5313
 
5314
+ /**
5315
+ * Strip image content blocks from every message on the current branch and
5316
+ * persist the rewrite. Walks `SessionManager.getBranch()` in place — both
5317
+ * `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays
5318
+ * are mutated, then `rewriteEntries` durably commits the new shape. The
5319
+ * agent's runtime view is rebuilt from the freshly-mutated entries so any
5320
+ * provider sessions caching message identity (Codex Responses) are torn
5321
+ * down to force a clean replay on the next turn.
5322
+ *
5323
+ * No-op when the branch carries no images; returns `{ removed: 0 }` and
5324
+ * skips the disk rewrite.
5325
+ */
5326
+ async dropImages(): Promise<{ removed: number }> {
5327
+ const branchEntries = this.sessionManager.getBranch();
5328
+ let removed = 0;
5329
+ for (const entry of branchEntries) {
5330
+ if (entry.type === "message") {
5331
+ removed += stripImagesFromMessage(entry.message);
5332
+ continue;
5333
+ }
5334
+ if (entry.type === "custom_message" && typeof entry.content !== "string") {
5335
+ const kept: typeof entry.content = [];
5336
+ let dropped = 0;
5337
+ for (const part of entry.content) {
5338
+ if (part.type === "image") {
5339
+ dropped++;
5340
+ } else {
5341
+ kept.push(part);
5342
+ }
5343
+ }
5344
+ if (dropped > 0) {
5345
+ if (kept.length === 0) {
5346
+ kept.push({ type: "text", text: "[image removed]" });
5347
+ }
5348
+ entry.content = kept;
5349
+ removed += dropped;
5350
+ }
5351
+ }
5352
+ }
5353
+ if (removed === 0) {
5354
+ return { removed: 0 };
5355
+ }
5356
+ await this.sessionManager.rewriteEntries();
5357
+ const sessionContext = this.buildDisplaySessionContext();
5358
+ this.agent.replaceMessages(sessionContext.messages);
5359
+ this.#closeCodexProviderSessionsForHistoryRewrite();
5360
+ return { removed };
5361
+ }
5362
+
5313
5363
  /**
5314
5364
  * Manually compact the session context.
5315
5365
  * Aborts current agent operation first.
@@ -6379,6 +6429,14 @@ export class AgentSession {
6379
6429
  }
6380
6430
  #isCompactionAuthFailure(error: unknown): boolean {
6381
6431
  if (!(error instanceof Error)) return false;
6432
+ // Real provider 401/403 — surfaced as `.status` by the compaction layer
6433
+ // (see `createSummarizationError` in packages/agent/src/compaction/compaction.ts).
6434
+ // Without this branch, an expired/revoked Anthropic key would bypass the
6435
+ // authenticated-fallback path and dump the raw HTTP body into the UI.
6436
+ const status = (error as Error & { status?: number }).status;
6437
+ if (status === 401 || status === 403) return true;
6438
+ // pi-native gateway synthetic for "no credential configured" (issue #986).
6439
+ // Carries no HTTP status, so the legacy message regex stays.
6382
6440
  return /auth_unavailable|no auth available/i.test(error.message);
6383
6441
  }
6384
6442
 
@@ -114,6 +114,98 @@ function getPrunedToolResultContent(message: ToolResultMessage): (TextContent |
114
114
  return [{ type: "text", text }];
115
115
  }
116
116
 
117
+ /** Result of filtering image blocks out of a `(TextContent | ImageContent)[]` array. */
118
+ interface StripContentResult {
119
+ content: (TextContent | ImageContent)[];
120
+ removed: number;
121
+ }
122
+
123
+ function stripImagesFromArrayContent(content: (TextContent | ImageContent)[]): StripContentResult {
124
+ let removed = 0;
125
+ const kept: (TextContent | ImageContent)[] = [];
126
+ for (const part of content) {
127
+ if (part.type === "image") {
128
+ removed++;
129
+ } else {
130
+ kept.push(part);
131
+ }
132
+ }
133
+ if (removed === 0) {
134
+ return { content, removed };
135
+ }
136
+ // Avoid emitting an empty `content` array — providers reject zero-block user/tool
137
+ // messages and the LLM still needs to see *something* where the image used to be.
138
+ if (kept.length === 0) {
139
+ kept.push({ type: "text", text: "[image removed]" });
140
+ }
141
+ return { content: kept, removed };
142
+ }
143
+
144
+ /**
145
+ * Strip image content blocks from `message` in place. Returns the count of
146
+ * images removed across `content` (every role that carries `ImageContent`) and
147
+ * any tool-result `details.images` payload. Callers MUST rewrite session
148
+ * entries (`SessionManager.rewriteEntries`) and replay them through
149
+ * `Agent.replaceMessages` afterwards so persisted state and provider-side
150
+ * caches stay aligned with the mutated tree — `stripImagesFromMessage` is a
151
+ * pure local mutation and intentionally does neither.
152
+ */
153
+ export function stripImagesFromMessage(message: AgentMessage): number {
154
+ switch (message.role) {
155
+ case "user":
156
+ case "developer":
157
+ case "custom":
158
+ case "hookMessage": {
159
+ if (typeof message.content === "string") return 0;
160
+ const { content, removed } = stripImagesFromArrayContent(message.content);
161
+ if (removed > 0) {
162
+ // All four roles type `content` as `string | (TextContent | ImageContent)[]`;
163
+ // TypeScript can't narrow the assignment across the union, so cast once.
164
+ (message as { content: typeof content }).content = content;
165
+ }
166
+ return removed;
167
+ }
168
+ case "toolResult": {
169
+ let removed = 0;
170
+ const { content, removed: contentRemoved } = stripImagesFromArrayContent(message.content);
171
+ if (contentRemoved > 0) {
172
+ message.content = content;
173
+ removed += contentRemoved;
174
+ }
175
+ const details = message.details as { images?: unknown } | null | undefined;
176
+ if (details && Array.isArray(details.images)) {
177
+ const original = details.images as unknown[];
178
+ const kept: unknown[] = [];
179
+ for (const candidate of original) {
180
+ const looksLikeImageBlock =
181
+ !!candidate && typeof candidate === "object" && (candidate as { type?: unknown }).type === "image";
182
+ if (looksLikeImageBlock) {
183
+ removed++;
184
+ } else {
185
+ kept.push(candidate);
186
+ }
187
+ }
188
+ if (kept.length !== original.length) {
189
+ details.images = kept;
190
+ }
191
+ }
192
+ return removed;
193
+ }
194
+ case "fileMention": {
195
+ let removed = 0;
196
+ for (const file of message.files) {
197
+ if (file.image) {
198
+ file.image = undefined;
199
+ removed++;
200
+ }
201
+ }
202
+ return removed;
203
+ }
204
+ default:
205
+ return 0;
206
+ }
207
+ }
208
+
117
209
  /**
118
210
  * Message type for bash executions via the ! command.
119
211
  */
@@ -802,6 +802,30 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
802
802
  await runtime.ctx.handleCompactCommand(customInstructions);
803
803
  },
804
804
  },
805
+ {
806
+ name: "drop-images",
807
+ description: "Strip every image from this session's history",
808
+ acpDescription: "Drop all images from the conversation history",
809
+ handle: async (_command, runtime) => {
810
+ const { removed } = await runtime.session.dropImages();
811
+ await runtime.output(
812
+ removed === 0
813
+ ? "No images found in this session."
814
+ : `Dropped ${removed} image${removed === 1 ? "" : "s"} from this session.`,
815
+ );
816
+ return commandConsumed();
817
+ },
818
+ handleTui: async (_command, runtime) => {
819
+ runtime.ctx.editor.setText("");
820
+ const { removed } = await runtime.ctx.session.dropImages();
821
+ if (removed === 0) {
822
+ runtime.ctx.showStatus("No images found in this session.");
823
+ return;
824
+ }
825
+ runtime.ctx.rebuildChatFromMessages();
826
+ runtime.ctx.showStatus(`Dropped ${removed} image${removed === 1 ? "" : "s"} from this session.`);
827
+ },
828
+ },
805
829
  {
806
830
  name: "handoff",
807
831
  description: "Hand off session context to a new session",