@oh-my-pi/pi-coding-agent 15.5.9 → 15.5.11

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.
@@ -0,0 +1,79 @@
1
+ import ultrathinkNotice from "../prompts/system/ultrathink-notice.md" with { type: "text" };
2
+ import { theme } from "./theme/theme";
3
+
4
+ /**
5
+ * "ultrathink" keyword support, mirroring Claude Code's affordance.
6
+ *
7
+ * Typing the standalone word in the input editor paints it with a rainbow
8
+ * gradient ({@link highlightUltrathink}); submitting a message that mentions it
9
+ * appends a hidden {@link ULTRATHINK_NOTICE} nudging the model toward careful
10
+ * multi-step reasoning. Matching is word-bounded and case-insensitive, so
11
+ * "ultrathinking"/"ultrathinks" never trigger either behavior.
12
+ */
13
+
14
+ // Cheap, stateless presence probe used to skip the boundary regex on most lines.
15
+ const ULTRATHINK_PROBE = /ultrathink/i;
16
+ // Detection: standalone keyword, any case. Non-global so `.test` stays stateless.
17
+ const ULTRATHINK_WORD = /\bultrathink\b/i;
18
+ // Highlight: global so `.replace` walks every occurrence.
19
+ const ULTRATHINK_HIGHLIGHT = /\bultrathink\b/gi;
20
+
21
+ /** Hidden system notice appended after a user message that mentions "ultrathink". */
22
+ export const ULTRATHINK_NOTICE: string = ultrathinkNotice.trim();
23
+
24
+ /** Whether `text` contains the standalone keyword "ultrathink" (any case). */
25
+ export function containsUltrathink(text: string): boolean {
26
+ return ULTRATHINK_WORD.test(text);
27
+ }
28
+
29
+ const FG_RESET = "\x1b[39m";
30
+ // Hue stops swept across the visible spectrum. More stops than the keyword has
31
+ // letters so the gradient resolves smoothly regardless of casing/match length.
32
+ const RAINBOW_STOPS = 14;
33
+
34
+ let cachedMode: string | undefined;
35
+ let cachedPalette: readonly string[] | undefined;
36
+
37
+ /** Rainbow foreground escapes for the active color mode, compiled once per mode. */
38
+ function rainbowPalette(): readonly string[] {
39
+ const mode = theme.getColorMode();
40
+ if (cachedPalette && cachedMode === mode) return cachedPalette;
41
+ const format = mode === "truecolor" ? "ansi-16m" : "ansi-256";
42
+ const palette: string[] = [];
43
+ for (let i = 0; i < RAINBOW_STOPS; i++) {
44
+ // Sweep red→violet (0..330°), stopping short of the wrap back to red.
45
+ const hue = Math.round((i / RAINBOW_STOPS) * 330);
46
+ palette.push(Bun.color(`hsl(${hue}, 90%, 62%)`, format) ?? "");
47
+ }
48
+ cachedMode = mode;
49
+ cachedPalette = palette;
50
+ return palette;
51
+ }
52
+
53
+ /** Paint each character of `word` with the next rainbow stop, resetting fg after. */
54
+ function rainbow(word: string): string {
55
+ const palette = rainbowPalette();
56
+ const n = word.length;
57
+ let out = "";
58
+ let prev = "";
59
+ for (let i = 0; i < n; i++) {
60
+ const color = palette[Math.floor((i / n) * palette.length)] ?? palette[0] ?? "";
61
+ // Coalesce consecutive characters that resolve to the same stop.
62
+ if (color !== prev) {
63
+ out += color;
64
+ prev = color;
65
+ }
66
+ out += word[i];
67
+ }
68
+ return `${out}${FG_RESET}`;
69
+ }
70
+
71
+ /**
72
+ * Rainbow-highlight every standalone "ultrathink" in `text` for editor display.
73
+ * Adds only zero-width SGR escapes — the visible width is unchanged — and returns
74
+ * the input untouched when the keyword is absent.
75
+ */
76
+ export function highlightUltrathink(text: string): string {
77
+ if (!ULTRATHINK_PROBE.test(text)) return text;
78
+ return text.replace(ULTRATHINK_HIGHLIGHT, rainbow);
79
+ }
@@ -0,0 +1,3 @@
1
+ <system-notice>
2
+ This task involves multi-step reasoning. Think carefully through the problem before responding.
3
+ </system-notice>
@@ -148,6 +148,7 @@ import type { HindsightSessionState } from "../hindsight/state";
148
148
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
149
149
  import { resolveMemoryBackend } from "../memory-backend";
150
150
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
151
+ import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
151
152
  import type { PlanModeState } from "../plan-mode/state";
152
153
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
153
154
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
@@ -196,6 +197,7 @@ import {
196
197
  type PythonExecutionMessage,
197
198
  readPendingDisplayTag,
198
199
  SILENT_ABORT_MARKER,
200
+ stripImagesFromMessage,
199
201
  } from "./messages";
200
202
  import { formatSessionDumpText } from "./session-dump-format";
201
203
  import type {
@@ -3996,6 +3998,21 @@ export class AgentSession {
3996
3998
  // Expand file-based prompt templates if requested
3997
3999
  const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
3998
4000
 
4001
+ // "ultrathink" keyword: nudge the model toward careful multi-step reasoning by
4002
+ // appending a hidden notice after the user's message. User-authored prompts only —
4003
+ // synthetic/agent-initiated turns never trigger it.
4004
+ const ultrathinkNotice: CustomMessage | undefined =
4005
+ !options?.synthetic && containsUltrathink(expandedText)
4006
+ ? {
4007
+ role: "custom",
4008
+ customType: "ultrathink-notice",
4009
+ content: ULTRATHINK_NOTICE,
4010
+ display: false,
4011
+ attribution: "user",
4012
+ timestamp: Date.now(),
4013
+ }
4014
+ : undefined;
4015
+
3999
4016
  // If streaming, queue via steer() or followUp() based on option
4000
4017
  if (this.isStreaming) {
4001
4018
  if (!options?.streamingBehavior) {
@@ -4006,6 +4023,10 @@ export class AgentSession {
4006
4023
  } else {
4007
4024
  await this.#queueSteer(expandedText, options?.images);
4008
4025
  }
4026
+ // Steer/follow-up the ultrathink notice alongside the queued user message.
4027
+ if (ultrathinkNotice) {
4028
+ await this.sendCustomMessage(ultrathinkNotice, { deliverAs: options.streamingBehavior });
4029
+ }
4009
4030
  return;
4010
4031
  }
4011
4032
 
@@ -4034,6 +4055,7 @@ export class AgentSession {
4034
4055
  await this.#promptWithMessage(message, expandedText, {
4035
4056
  ...options,
4036
4057
  prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
4058
+ appendMessages: ultrathinkNotice ? [ultrathinkNotice] : undefined,
4037
4059
  });
4038
4060
  } finally {
4039
4061
  // Clean up residual eager-todo directive if the prompt never consumed it
@@ -4083,6 +4105,7 @@ export class AgentSession {
4083
4105
  expandedText: string,
4084
4106
  options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
4085
4107
  prependMessages?: AgentMessage[];
4108
+ appendMessages?: AgentMessage[];
4086
4109
  skipPostPromptRecoveryWait?: boolean;
4087
4110
  },
4088
4111
  ): Promise<void> {
@@ -4146,6 +4169,12 @@ export class AgentSession {
4146
4169
 
4147
4170
  messages.push(message);
4148
4171
 
4172
+ // Inject the ultrathink notice (and any other per-turn appends) right after the
4173
+ // user message so the model reads it as part of the same turn.
4174
+ if (options?.appendMessages) {
4175
+ messages.push(...options.appendMessages);
4176
+ }
4177
+
4149
4178
  // Early bail-out: if a newer abort/prompt cycle started during setup,
4150
4179
  // return before mutating shared state (nextTurn messages, system prompt).
4151
4180
  if (this.#promptGeneration !== generation) {
@@ -5310,6 +5339,55 @@ export class AgentSession {
5310
5339
  return result;
5311
5340
  }
5312
5341
 
5342
+ /**
5343
+ * Strip image content blocks from every message on the current branch and
5344
+ * persist the rewrite. Walks `SessionManager.getBranch()` in place — both
5345
+ * `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays
5346
+ * are mutated, then `rewriteEntries` durably commits the new shape. The
5347
+ * agent's runtime view is rebuilt from the freshly-mutated entries so any
5348
+ * provider sessions caching message identity (Codex Responses) are torn
5349
+ * down to force a clean replay on the next turn.
5350
+ *
5351
+ * No-op when the branch carries no images; returns `{ removed: 0 }` and
5352
+ * skips the disk rewrite.
5353
+ */
5354
+ async dropImages(): Promise<{ removed: number }> {
5355
+ const branchEntries = this.sessionManager.getBranch();
5356
+ let removed = 0;
5357
+ for (const entry of branchEntries) {
5358
+ if (entry.type === "message") {
5359
+ removed += stripImagesFromMessage(entry.message);
5360
+ continue;
5361
+ }
5362
+ if (entry.type === "custom_message" && typeof entry.content !== "string") {
5363
+ const kept: typeof entry.content = [];
5364
+ let dropped = 0;
5365
+ for (const part of entry.content) {
5366
+ if (part.type === "image") {
5367
+ dropped++;
5368
+ } else {
5369
+ kept.push(part);
5370
+ }
5371
+ }
5372
+ if (dropped > 0) {
5373
+ if (kept.length === 0) {
5374
+ kept.push({ type: "text", text: "[image removed]" });
5375
+ }
5376
+ entry.content = kept;
5377
+ removed += dropped;
5378
+ }
5379
+ }
5380
+ }
5381
+ if (removed === 0) {
5382
+ return { removed: 0 };
5383
+ }
5384
+ await this.sessionManager.rewriteEntries();
5385
+ const sessionContext = this.buildDisplaySessionContext();
5386
+ this.agent.replaceMessages(sessionContext.messages);
5387
+ this.#closeCodexProviderSessionsForHistoryRewrite();
5388
+ return { removed };
5389
+ }
5390
+
5313
5391
  /**
5314
5392
  * Manually compact the session context.
5315
5393
  * Aborts current agent operation first.
@@ -6379,6 +6457,14 @@ export class AgentSession {
6379
6457
  }
6380
6458
  #isCompactionAuthFailure(error: unknown): boolean {
6381
6459
  if (!(error instanceof Error)) return false;
6460
+ // Real provider 401/403 — surfaced as `.status` by the compaction layer
6461
+ // (see `createSummarizationError` in packages/agent/src/compaction/compaction.ts).
6462
+ // Without this branch, an expired/revoked Anthropic key would bypass the
6463
+ // authenticated-fallback path and dump the raw HTTP body into the UI.
6464
+ const status = (error as Error & { status?: number }).status;
6465
+ if (status === 401 || status === 403) return true;
6466
+ // pi-native gateway synthetic for "no credential configured" (issue #986).
6467
+ // Carries no HTTP status, so the legacy message regex stays.
6382
6468
  return /auth_unavailable|no auth available/i.test(error.message);
6383
6469
  }
6384
6470
 
@@ -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
  */