@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.
- package/CHANGELOG.md +31 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +14 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/ultrathink.d.ts +10 -0
- package/dist/types/session/agent-session.d.ts +15 -0
- package/dist/types/session/messages.d.ts +10 -0
- package/dist/types/session/redis-session-storage.d.ts +124 -0
- package/dist/types/session/sql-session-storage.d.ts +141 -0
- package/examples/sdk/12-redis-sessions.ts +54 -0
- package/examples/sdk/13-sql-sessions.ts +61 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +14 -9
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +15 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +63 -22
- package/src/index.ts +3 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/memories/index.ts +8 -3
- package/src/modes/components/custom-editor.ts +3 -0
- package/src/modes/ultrathink.ts +79 -0
- package/src/prompts/system/ultrathink-notice.md +3 -0
- package/src/session/agent-session.ts +86 -0
- package/src/session/messages.ts +92 -0
- package/src/session/redis-session-storage.ts +481 -0
- package/src/session/sql-session-storage.ts +565 -0
- package/src/slash-commands/builtin-registry.ts +24 -0
- package/src/tools/read.ts +23 -6
- package/src/tools/write.ts +40 -6
|
@@ -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
|
+
}
|
|
@@ -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
|
|
package/src/session/messages.ts
CHANGED
|
@@ -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
|
*/
|