@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.6
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 +107 -0
- package/dist/cli.js +692 -607
- package/dist/types/cli/usage-cli.d.ts +10 -1
- package/dist/types/commands/usage.d.ts +9 -0
- 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 +508 -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/reset-usage-selector.d.ts +12 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -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 +26 -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/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -0
- package/dist/types/modes/session-observer-registry.d.ts +2 -0
- package/dist/types/modes/theme/theme.d.ts +23 -3
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/context-usage.d.ts +6 -1
- package/dist/types/session/agent-session.d.ts +28 -8
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/codex-auto-reset.d.ts +107 -0
- package/dist/types/session/snapcompact-inline.d.ts +129 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
- package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
- package/dist/types/system-prompt.d.ts +3 -1
- package/dist/types/task/render.d.ts +17 -6
- package/dist/types/tools/gh.d.ts +3 -0
- package/dist/types/tools/render-utils.d.ts +8 -16
- package/dist/types/tools/todo.d.ts +0 -11
- 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/cli/usage-cli.ts +187 -16
- package/src/commands/usage.ts +8 -0
- 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 +654 -153
- package/src/config/settings.ts +9 -0
- 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 +6 -6
- 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/reset-usage-selector.ts +161 -0
- package/src/modes/components/session-selector.ts +8 -2
- package/src/modes/components/settings-defs.ts +19 -4
- package/src/modes/components/settings-selector.ts +510 -95
- 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 +87 -12
- package/src/modes/components/transcript-container.ts +49 -1
- package/src/modes/components/tree-selector.ts +16 -6
- package/src/modes/controllers/command-controller.ts +61 -8
- 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 +149 -61
- package/src/modes/interactive-mode.ts +63 -2
- package/src/modes/rpc/rpc-mode.ts +2 -1
- package/src/modes/session-observer-registry.ts +61 -3
- package/src/modes/shared.ts +2 -0
- package/src/modes/theme/theme.ts +102 -9
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/context-usage.ts +78 -2
- 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-context-frames-note.md +1 -0
- package/src/prompts/system/snapcompact-context-stub.md +1 -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/browser.md +33 -43
- package/src/prompts/tools/eval.md +27 -50
- package/src/prompts/tools/irc.md +29 -31
- package/src/prompts/tools/read.md +31 -37
- package/src/prompts/tools/task.md +3 -3
- package/src/prompts/tools/todo.md +1 -2
- package/src/sdk.ts +23 -1
- package/src/session/agent-session.ts +221 -29
- package/src/session/auth-storage.ts +4 -0
- package/src/session/codex-auto-reset.ts +190 -0
- package/src/session/session-dump-format.ts +8 -1
- package/src/session/session-manager.ts +5 -5
- package/src/session/snapcompact-inline.ts +524 -0
- package/src/slash-commands/builtin-registry.ts +145 -8
- package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
- package/src/slash-commands/helpers/context-report.ts +28 -1
- package/src/slash-commands/helpers/reset-usage.ts +66 -0
- package/src/slash-commands/helpers/usage-report.ts +36 -3
- package/src/system-prompt.ts +15 -1
- package/src/task/index.ts +30 -7
- package/src/task/render.ts +57 -32
- 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/todo.ts +8 -128
- 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
package/src/tools/tts.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Ported from NousResearch/hermes-agent (MIT) — tools/tts_tool.py L167-171, L896-959.
|
|
2
2
|
|
|
3
3
|
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
4
|
+
import { type ApiKey, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai";
|
|
4
5
|
import * as z from "zod/v4";
|
|
5
6
|
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
|
|
6
7
|
import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
|
|
@@ -96,27 +97,46 @@ export const ttsTool: CustomTool<typeof ttsSchema, TtsToolDetails> = {
|
|
|
96
97
|
const timeoutSignal = AbortSignal.timeout(60_000);
|
|
97
98
|
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
98
99
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"Content-Type": "application/json",
|
|
104
|
-
"User-Agent": ohMyPiXAIUserAgent(),
|
|
105
|
-
},
|
|
106
|
-
body: JSON.stringify(payload),
|
|
107
|
-
signal: combinedSignal,
|
|
100
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
101
|
+
const apiKey: ApiKey = ctx.modelRegistry.resolver(creds.provider, {
|
|
102
|
+
sessionId,
|
|
103
|
+
baseUrl: creds.baseURL,
|
|
108
104
|
});
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
105
|
+
|
|
106
|
+
let response: Response;
|
|
107
|
+
try {
|
|
108
|
+
response = await withAuth(
|
|
109
|
+
apiKey,
|
|
110
|
+
async key => {
|
|
111
|
+
const resp = await fetch(`${creds.baseURL}/tts`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: {
|
|
114
|
+
Authorization: `Bearer ${key}`,
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
"User-Agent": ohMyPiXAIUserAgent(),
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify(payload),
|
|
119
|
+
signal: combinedSignal,
|
|
120
|
+
});
|
|
121
|
+
if (!resp.ok) {
|
|
122
|
+
const detail = await resp.text();
|
|
123
|
+
throw new ProviderHttpError(`xAI TTS failed (${resp.status}): ${detail.slice(0, 300)}`, resp.status, {
|
|
124
|
+
headers: resp.headers,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return resp;
|
|
128
|
+
},
|
|
129
|
+
{ signal: combinedSignal },
|
|
130
|
+
);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const status = (error as { status?: unknown }).status;
|
|
133
|
+
if (error instanceof Error && typeof status === "number") {
|
|
134
|
+
return {
|
|
135
|
+
isError: true,
|
|
136
|
+
content: [{ type: "text", text: error.message }],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
120
140
|
}
|
|
121
141
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
122
142
|
await Bun.write(outputPath, bytes);
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -125,6 +125,56 @@ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// PowerShell one-liner that emits the clipboard text verbatim on stdout, or
|
|
129
|
+
// nothing when the clipboard holds no text. `[Console]::Out.Write` avoids the
|
|
130
|
+
// trailing newline Write-Output would add; output encoding is forced to UTF-8
|
|
131
|
+
// so non-ASCII text survives the interop boundary regardless of console
|
|
132
|
+
// codepage.
|
|
133
|
+
const POWERSHELL_TEXT_SCRIPT = `
|
|
134
|
+
$ErrorActionPreference = 'Stop'
|
|
135
|
+
[Console]::OutputEncoding = [Text.Encoding]::UTF8
|
|
136
|
+
[Console]::Out.Write([string](Get-Clipboard -Raw))
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read clipboard text through Windows PowerShell — native win32 or the WSL
|
|
141
|
+
* host over interop.
|
|
142
|
+
*
|
|
143
|
+
* Same rationale as `readImageViaPowerShell`: under WSL, the WSLg Wayland
|
|
144
|
+
* clipboard only works when `wl-clipboard` happens to be installed in the
|
|
145
|
+
* distro, while `powershell.exe` is always reachable. Forcing UTF-8 output
|
|
146
|
+
* encoding keeps non-ASCII text intact regardless of the console codepage
|
|
147
|
+
* (the legacy win32 `Get-Clipboard` shell-out mangled it), and `Bun.spawn`
|
|
148
|
+
* keeps a cold PowerShell start off the TUI event loop.
|
|
149
|
+
*
|
|
150
|
+
* Returns null when the bridge fails (WSL callers fall through to
|
|
151
|
+
* wl-paste/xclip); an empty string is a successful "no text" read.
|
|
152
|
+
*/
|
|
153
|
+
async function readTextViaPowerShell(): Promise<string | null> {
|
|
154
|
+
try {
|
|
155
|
+
const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_TEXT_SCRIPT], {
|
|
156
|
+
stdout: "pipe",
|
|
157
|
+
stderr: "ignore",
|
|
158
|
+
stdin: "ignore",
|
|
159
|
+
});
|
|
160
|
+
const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
|
|
161
|
+
let stdout = "";
|
|
162
|
+
try {
|
|
163
|
+
stdout = await new Response(proc.stdout).text();
|
|
164
|
+
await proc.exited;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
logger.warn("clipboard: powershell text read failed", { error: String(err) });
|
|
167
|
+
return null;
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
if (proc.exitCode !== 0) return null;
|
|
172
|
+
return stdout.replaceAll("\r\n", "\n");
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
128
178
|
/**
|
|
129
179
|
* Read an image from the system clipboard.
|
|
130
180
|
*
|
|
@@ -165,14 +215,16 @@ export async function readTextFromClipboard(): Promise<string> {
|
|
|
165
215
|
return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString();
|
|
166
216
|
}
|
|
167
217
|
if (p === "win32") {
|
|
168
|
-
return
|
|
169
|
-
encoding: "utf8",
|
|
170
|
-
timeout: 2000,
|
|
171
|
-
}).toString();
|
|
218
|
+
return (await readTextViaPowerShell()) ?? "";
|
|
172
219
|
}
|
|
173
220
|
if (process.env.TERMUX_VERSION) {
|
|
174
221
|
return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString();
|
|
175
222
|
}
|
|
223
|
+
if (isWsl()) {
|
|
224
|
+
const text = await readTextViaPowerShell();
|
|
225
|
+
if (text !== null) return text;
|
|
226
|
+
// Bridge failed — fall through to the wl-paste/xclip paths below.
|
|
227
|
+
}
|
|
176
228
|
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
|
|
177
229
|
const hasX11Display = Boolean(process.env.DISPLAY);
|
|
178
230
|
if (hasWaylandDisplay) {
|
|
@@ -112,11 +112,7 @@ export async function generateCommitMessage(
|
|
|
112
112
|
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
|
-
apiKey: registry.resolver(candidate.model
|
|
116
|
-
sessionId,
|
|
117
|
-
baseUrl: candidate.model.baseUrl,
|
|
118
|
-
modelId: candidate.model.id,
|
|
119
|
-
}),
|
|
115
|
+
apiKey: registry.resolver(candidate.model, sessionId),
|
|
120
116
|
maxTokens,
|
|
121
117
|
reasoning: toReasoningEffort(candidate.thinkingLevel),
|
|
122
118
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
|
|
1
|
+
import { hexToHsv, hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Derive a stable hue (0-359) from a string using djb2 hash.
|
|
@@ -25,23 +25,97 @@ function accentLuminanceCap(surfaceLuminance: number): number {
|
|
|
25
25
|
return Math.max(0, (surfaceLuminance + 0.05) / ACCENT_MIN_CONTRAST - 0.05);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Minimum angular distance in hue degrees from any theme color to avoid visual collision. */
|
|
29
|
+
const MIN_HUE_DISTANCE = 10;
|
|
30
|
+
/** Saturation threshold below which hue is meaningless (near-gray). */
|
|
31
|
+
const MIN_SATURATION_FOR_HUE = 0.1;
|
|
32
|
+
|
|
33
|
+
/** Angular distance between two hue values (0-360). */
|
|
34
|
+
function hueDistance(a: number, b: number): number {
|
|
35
|
+
const d = Math.abs(a - b);
|
|
36
|
+
return Math.min(d, 360 - d);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse hue (0-360) from a hex color string.
|
|
41
|
+
* Returns undefined for near-gray colors where hue is not meaningful.
|
|
42
|
+
*/
|
|
43
|
+
function hexToHue(hex: string): number | undefined {
|
|
44
|
+
const hsv = hexToHsv(hex);
|
|
45
|
+
if (hsv.s < MIN_SATURATION_FOR_HUE) return undefined;
|
|
46
|
+
return hsv.h;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find a hue at least {@link MIN_HUE_DISTANCE} from all occupied hues,
|
|
51
|
+
* clamped to [lo, hi] to prevent leaving the intended hue band.
|
|
52
|
+
* Returns `target` unchanged if no safe hue exists within bounds.
|
|
53
|
+
*/
|
|
54
|
+
function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number {
|
|
55
|
+
if (occupied.length === 0) return target;
|
|
56
|
+
if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) {
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
59
|
+
for (let d = 1; d <= hi - lo; d++) {
|
|
60
|
+
for (const dir of [1, -1]) {
|
|
61
|
+
const candidate = Math.max(lo, Math.min(hi, target + d * dir));
|
|
62
|
+
if (occupied.every(h => hueDistance(candidate, h) >= MIN_HUE_DISTANCE)) {
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// fallback: keep the original target if no safe spot exists within the band
|
|
68
|
+
return target;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Hue range low and high for dark themes (warm: red → yellow → green). */
|
|
72
|
+
const DARK_HUE_START = 0;
|
|
73
|
+
const DARK_HUE_END = 120;
|
|
74
|
+
/** Hue range low and high for light themes (cool: cyan → blue → purple). */
|
|
75
|
+
const LIGHT_HUE_START = 180;
|
|
76
|
+
const LIGHT_HUE_END = 300;
|
|
77
|
+
|
|
28
78
|
/**
|
|
29
|
-
* Derive a stable CSS hex accent color from a session name.
|
|
79
|
+
* Derive a stable CSS hex accent color from a session name and the active theme.
|
|
80
|
+
*
|
|
81
|
+
* Picks a hue from a **dark/light-specific range** so the accent feels natural
|
|
82
|
+
* for the theme type (warm on dark, cool on light). The session name hash
|
|
83
|
+
* determines the exact hue within the range. The result is checked against
|
|
84
|
+
* all theme color hues and shifted if it lands within {@link MIN_HUE_DISTANCE}
|
|
85
|
+
* of an existing theme hue, but is clamped to the hue band so it never
|
|
86
|
+
* drifts into an unrelated part of the spectrum.
|
|
30
87
|
*
|
|
31
88
|
* On dark themes (`surfaceLuminance` undefined) the accent is vivid (high
|
|
32
89
|
* saturation, high lightness). On light themes the lightness is reduced until the
|
|
33
90
|
* accent's perceived luminance clears {@link ACCENT_MIN_CONTRAST} against the
|
|
34
91
|
* actual surface it renders on — so it stays legible on near-white *and* mid-light
|
|
35
|
-
* backgrounds
|
|
92
|
+
* backgrounds.
|
|
93
|
+
*
|
|
94
|
+
* @param name — session name for per-session uniqueness.
|
|
95
|
+
* @param themeColorHexes — all theme colors to check collision against.
|
|
96
|
+
* @param surfaceLuminance — undefined on dark themes; WCAG luminance of the
|
|
97
|
+
* status-line background on light themes.
|
|
36
98
|
*/
|
|
37
|
-
export function getSessionAccentHex(name: string, surfaceLuminance?: number): string {
|
|
38
|
-
|
|
99
|
+
export function getSessionAccentHex(name: string, themeColorHexes: string[], surfaceLuminance?: number): string {
|
|
100
|
+
// 1. Pick hue range based on theme mode
|
|
101
|
+
const hueStart = surfaceLuminance === undefined ? DARK_HUE_START : LIGHT_HUE_START;
|
|
102
|
+
const hueEnd = surfaceLuminance === undefined ? DARK_HUE_END : LIGHT_HUE_END;
|
|
103
|
+
const range = hueEnd - hueStart;
|
|
104
|
+
|
|
105
|
+
// 2. Session name picks within the range
|
|
106
|
+
let targetHue = hueStart + (nameToHue(name) % range);
|
|
107
|
+
|
|
108
|
+
// 3. Shift away if too close to any theme color — stays within [hueStart, hueEnd]
|
|
109
|
+
const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
|
|
110
|
+
targetHue = findSafeHue(targetHue, themeHues, hueStart, hueEnd);
|
|
111
|
+
|
|
112
|
+
// 4. Lightness/contrast — vivid on dark, bisected for AA on light
|
|
39
113
|
if (surfaceLuminance === undefined) {
|
|
40
|
-
return hslToHex(
|
|
114
|
+
return hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
|
|
41
115
|
}
|
|
42
116
|
|
|
43
117
|
const cap = accentLuminanceCap(surfaceLuminance);
|
|
44
|
-
const top = hslToHex(
|
|
118
|
+
const top = hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
|
|
45
119
|
if ((relativeLuminance(top) ?? 0) <= cap) return top;
|
|
46
120
|
|
|
47
121
|
// Bisect lightness: `lo` always yields luminance <= cap, `hi` always above it.
|
|
@@ -49,13 +123,13 @@ export function getSessionAccentHex(name: string, surfaceLuminance?: number): st
|
|
|
49
123
|
let hi = ACCENT_DARK_LIGHTNESS;
|
|
50
124
|
for (let i = 0; i < 20; i++) {
|
|
51
125
|
const mid = (lo + hi) / 2;
|
|
52
|
-
if ((relativeLuminance(hslToHex(
|
|
126
|
+
if ((relativeLuminance(hslToHex(targetHue, ACCENT_SATURATION, mid)) ?? 0) > cap) {
|
|
53
127
|
hi = mid;
|
|
54
128
|
} else {
|
|
55
129
|
lo = mid;
|
|
56
130
|
}
|
|
57
131
|
}
|
|
58
|
-
return hslToHex(
|
|
132
|
+
return hslToHex(targetHue, ACCENT_SATURATION, lo);
|
|
59
133
|
}
|
|
60
134
|
|
|
61
135
|
/**
|
|
@@ -258,7 +258,7 @@ export async function generateTitleOnline(
|
|
|
258
258
|
tools: [setTitleTool],
|
|
259
259
|
},
|
|
260
260
|
{
|
|
261
|
-
apiKey: registry.resolver(model
|
|
261
|
+
apiKey: registry.resolver(model, sessionId),
|
|
262
262
|
maxTokens,
|
|
263
263
|
disableReasoning: true,
|
|
264
264
|
toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
|
package/src/web/kagi.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* through the shared {@link AuthStorage} broker (Bearer token), and responses
|
|
7
7
|
* are categorized result buckets rather than the legacy flat object array.
|
|
8
8
|
*/
|
|
9
|
-
import type
|
|
9
|
+
import { type AuthStorage, type FetchImpl, withAuth } from "@oh-my-pi/pi-ai";
|
|
10
10
|
import { withHardTimeout } from "./search/providers/utils";
|
|
11
11
|
|
|
12
12
|
const KAGI_SEARCH_URL = "https://kagi.com/api/v1/search";
|
|
@@ -173,14 +173,6 @@ export interface KagiSearchResult {
|
|
|
173
173
|
answer?: string;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
export async function findKagiApiKey(
|
|
177
|
-
authStorage: AuthStorage,
|
|
178
|
-
sessionId?: string,
|
|
179
|
-
signal?: AbortSignal,
|
|
180
|
-
): Promise<string | null> {
|
|
181
|
-
return (await authStorage.getApiKey("kagi", sessionId, { signal })) ?? null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
176
|
/**
|
|
185
177
|
* Compute a YYYY-MM-DD date string `recency` units before now, in UTC.
|
|
186
178
|
* UTC keeps the recency window deterministic regardless of host timezone and
|
|
@@ -247,27 +239,34 @@ export async function searchWithKagi(
|
|
|
247
239
|
options: KagiSearchOptions = {},
|
|
248
240
|
authStorage: AuthStorage,
|
|
249
241
|
): Promise<KagiSearchResult> {
|
|
250
|
-
const apiKey = await findKagiApiKey(authStorage, options.sessionId, options.signal);
|
|
251
|
-
if (!apiKey) {
|
|
252
|
-
throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
|
|
253
|
-
}
|
|
254
|
-
|
|
255
242
|
const fetchImpl = options.fetch ?? fetch;
|
|
243
|
+
const body = JSON.stringify(buildRequestBody(query, options));
|
|
244
|
+
|
|
245
|
+
const response = await withAuth(
|
|
246
|
+
authStorage.resolver("kagi", { sessionId: options.sessionId }),
|
|
247
|
+
async apiKey => {
|
|
248
|
+
const res = await fetchImpl(KAGI_SEARCH_URL, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: {
|
|
251
|
+
Authorization: `Bearer ${apiKey}`,
|
|
252
|
+
"Content-Type": "application/json",
|
|
253
|
+
Accept: "application/json",
|
|
254
|
+
},
|
|
255
|
+
body,
|
|
256
|
+
signal: withHardTimeout(options.signal),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!res.ok) {
|
|
260
|
+
throw parseKagiErrorResponse(res.status, await res.text());
|
|
261
|
+
}
|
|
256
262
|
|
|
257
|
-
|
|
258
|
-
method: "POST",
|
|
259
|
-
headers: {
|
|
260
|
-
Authorization: `Bearer ${apiKey}`,
|
|
261
|
-
"Content-Type": "application/json",
|
|
262
|
-
Accept: "application/json",
|
|
263
|
+
return res;
|
|
263
264
|
},
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
throw parseKagiErrorResponse(response.status, await response.text());
|
|
270
|
-
}
|
|
265
|
+
{
|
|
266
|
+
signal: options.signal,
|
|
267
|
+
missingKeyMessage: "Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.",
|
|
268
|
+
},
|
|
269
|
+
);
|
|
271
270
|
|
|
272
271
|
const payload = (await response.json()) as KagiSearchResponse;
|
|
273
272
|
if (payload.error && payload.error.length > 0) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* SQLite store, never POSTs the broker sentinel to an OpenAI token endpoint.
|
|
8
8
|
*/
|
|
9
9
|
import * as os from "node:os";
|
|
10
|
-
import type
|
|
10
|
+
import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
|
|
11
11
|
import { decodeJwt } from "@oh-my-pi/pi-ai/oauth/openai-codex";
|
|
12
12
|
import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
|
|
13
13
|
import { $env, readSseJson } from "@oh-my-pi/pi-utils";
|
|
@@ -287,12 +287,12 @@ async function findCodexAuth(
|
|
|
287
287
|
authStorage: AuthStorage,
|
|
288
288
|
sessionId: string | undefined,
|
|
289
289
|
signal: AbortSignal | undefined,
|
|
290
|
-
): Promise<{
|
|
290
|
+
): Promise<{ access: OAuthAccess; accountId: string } | null> {
|
|
291
291
|
const access = await authStorage.getOAuthAccess("openai-codex", sessionId, { signal });
|
|
292
292
|
if (!access) return null;
|
|
293
293
|
const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
|
|
294
294
|
if (!accountId) return null;
|
|
295
|
-
return {
|
|
295
|
+
return { access, accountId };
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/**
|
|
@@ -495,8 +495,8 @@ async function callCodexSearch(
|
|
|
495
495
|
* `gpt-5-codex-mini` first on ChatGPT accounts, which OpenAI rejects.
|
|
496
496
|
*/
|
|
497
497
|
export async function searchCodex(params: SearchParams): Promise<SearchResponse> {
|
|
498
|
-
const
|
|
499
|
-
if (!
|
|
498
|
+
const seed = await findCodexAuth(params.authStorage, params.sessionId, params.signal);
|
|
499
|
+
if (!seed) {
|
|
500
500
|
throw new Error(
|
|
501
501
|
"No Codex OAuth credentials found. Login with 'omp /login openai-codex' to enable Codex web search.",
|
|
502
502
|
);
|
|
@@ -505,42 +505,44 @@ export async function searchCodex(params: SearchParams): Promise<SearchResponse>
|
|
|
505
505
|
const configuredModel = getConfiguredModel();
|
|
506
506
|
const modelCandidates = configuredModel ? [configuredModel] : getDefaultModelCandidates();
|
|
507
507
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
for (let index = 0; index < modelCandidates.length; index += 1) {
|
|
520
|
-
const modelId = modelCandidates[index];
|
|
521
|
-
if (!modelId) continue;
|
|
522
|
-
|
|
523
|
-
try {
|
|
524
|
-
result = await callCodexSearch(auth, params.query, {
|
|
525
|
-
signal: params.signal,
|
|
526
|
-
systemPrompt: params.systemPrompt,
|
|
527
|
-
searchContextSize: "high",
|
|
528
|
-
modelId,
|
|
529
|
-
fetch: params.fetch,
|
|
530
|
-
});
|
|
531
|
-
break;
|
|
532
|
-
} catch (error) {
|
|
533
|
-
lastError = error;
|
|
534
|
-
const isLastCandidate = index === modelCandidates.length - 1;
|
|
535
|
-
if (configuredModel || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
|
|
536
|
-
throw error;
|
|
508
|
+
const result = await withOAuthAccess(
|
|
509
|
+
params.authStorage,
|
|
510
|
+
"openai-codex",
|
|
511
|
+
async access => {
|
|
512
|
+
// Derive ALL auth material from the access this attempt received —
|
|
513
|
+
// a refreshed/rotated credential carries a different bearer and
|
|
514
|
+
// ChatGPT account id than the seed.
|
|
515
|
+
const accountId = access.accountId ?? getAccountIdFromJwt(access.accessToken);
|
|
516
|
+
if (!accountId) {
|
|
517
|
+
throw new Error("Codex OAuth credential is missing a ChatGPT account id");
|
|
537
518
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
519
|
+
const auth = { accessToken: access.accessToken, accountId };
|
|
520
|
+
|
|
521
|
+
let lastError: unknown;
|
|
522
|
+
for (let index = 0; index < modelCandidates.length; index += 1) {
|
|
523
|
+
const modelId = modelCandidates[index];
|
|
524
|
+
if (!modelId) continue;
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
return await callCodexSearch(auth, params.query, {
|
|
528
|
+
signal: params.signal,
|
|
529
|
+
systemPrompt: params.systemPrompt,
|
|
530
|
+
searchContextSize: "high",
|
|
531
|
+
modelId,
|
|
532
|
+
fetch: params.fetch,
|
|
533
|
+
});
|
|
534
|
+
} catch (error) {
|
|
535
|
+
lastError = error;
|
|
536
|
+
const isLastCandidate = index === modelCandidates.length - 1;
|
|
537
|
+
if (configuredModel || isLastCandidate || !shouldRetryWithNextDefaultModel(error)) {
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
throw lastError ?? new Error("Codex search failed without returning a result");
|
|
543
|
+
},
|
|
544
|
+
{ sessionId: params.sessionId, signal: params.signal, seed: seed.access },
|
|
545
|
+
);
|
|
544
546
|
|
|
545
547
|
let sources = result.sources;
|
|
546
548
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* sibling SQLite store and never POSTs the broker sentinel to a Google token
|
|
9
9
|
* endpoint.
|
|
10
10
|
*/
|
|
11
|
-
import type
|
|
11
|
+
import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
|
|
12
12
|
import {
|
|
13
13
|
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
|
14
14
|
getAntigravityUserAgent,
|
|
@@ -72,25 +72,29 @@ interface GeminiAuth {
|
|
|
72
72
|
isAntigravity: boolean;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** First configured Gemini OAuth provider plus its pre-resolved access. */
|
|
76
|
+
interface GeminiAuthSeed {
|
|
77
|
+
provider: GeminiProviderId;
|
|
78
|
+
access: OAuthAccess;
|
|
79
|
+
projectId: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
/**
|
|
76
83
|
* Walks the configured Gemini OAuth providers in deterministic order and
|
|
77
84
|
* returns the first one that yields a usable access token + projectId via
|
|
78
85
|
* {@link AuthStorage.getOAuthAccess}. AuthStorage handles refresh + broker
|
|
79
86
|
* routing internally; this helper never touches refresh tokens directly.
|
|
87
|
+
* The resolved access seeds `withOAuthAccess` so the happy path resolves once.
|
|
80
88
|
*/
|
|
81
89
|
export async function findGeminiAuth(
|
|
82
90
|
authStorage: AuthStorage,
|
|
83
91
|
sessionId: string | undefined,
|
|
84
92
|
signal: AbortSignal | undefined,
|
|
85
|
-
): Promise<
|
|
93
|
+
): Promise<GeminiAuthSeed | null> {
|
|
86
94
|
for (const provider of GEMINI_PROVIDERS) {
|
|
87
95
|
const access = await authStorage.getOAuthAccess(provider, sessionId, { signal });
|
|
88
96
|
if (!access?.accessToken || !access.projectId) continue;
|
|
89
|
-
return {
|
|
90
|
-
accessToken: access.accessToken,
|
|
91
|
-
projectId: access.projectId,
|
|
92
|
-
isAntigravity: provider === "google-antigravity",
|
|
93
|
-
};
|
|
97
|
+
return { provider, access, projectId: access.projectId };
|
|
94
98
|
}
|
|
95
99
|
return null;
|
|
96
100
|
}
|
|
@@ -390,26 +394,42 @@ async function callGeminiSearch(
|
|
|
390
394
|
* Executes a web search using Google Gemini with Google Search grounding.
|
|
391
395
|
*/
|
|
392
396
|
export async function searchGemini(params: GeminiSearchParams): Promise<SearchResponse> {
|
|
393
|
-
const
|
|
394
|
-
if (!
|
|
397
|
+
const seed = await findGeminiAuth(params.authStorage, params.sessionId, params.signal);
|
|
398
|
+
if (!seed) {
|
|
395
399
|
throw new Error(
|
|
396
400
|
"No Gemini OAuth credentials found. Login with 'omp /login google-gemini-cli' or 'omp /login google-antigravity' to enable Gemini web search.",
|
|
397
401
|
);
|
|
398
402
|
}
|
|
399
403
|
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
params.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
404
|
+
const isAntigravity = seed.provider === "google-antigravity";
|
|
405
|
+
const result = await withOAuthAccess(
|
|
406
|
+
params.authStorage,
|
|
407
|
+
seed.provider,
|
|
408
|
+
access =>
|
|
409
|
+
// Derive bearer + projectId from the access this attempt received; a
|
|
410
|
+
// re-resolved access may omit projectId, in which case the seed's
|
|
411
|
+
// project is still the right tenant for the credential. The
|
|
412
|
+
// `fetchWithRetry` transport backoff stays INSIDE this attempt — auth
|
|
413
|
+
// retry wraps transport retry.
|
|
414
|
+
callGeminiSearch(
|
|
415
|
+
{
|
|
416
|
+
accessToken: access.accessToken,
|
|
417
|
+
projectId: access.projectId ?? seed.projectId,
|
|
418
|
+
isAntigravity,
|
|
419
|
+
},
|
|
420
|
+
params.query,
|
|
421
|
+
params.system_prompt,
|
|
422
|
+
params.max_output_tokens,
|
|
423
|
+
params.temperature,
|
|
424
|
+
{
|
|
425
|
+
google_search: params.google_search,
|
|
426
|
+
code_execution: params.code_execution,
|
|
427
|
+
url_context: params.url_context,
|
|
428
|
+
},
|
|
429
|
+
params.fetch,
|
|
430
|
+
params.signal,
|
|
431
|
+
),
|
|
432
|
+
{ sessionId: params.sessionId, signal: params.signal, seed: seed.access },
|
|
413
433
|
);
|
|
414
434
|
|
|
415
435
|
let sources = result.sources;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Anonymous via `www.perplexity.ai/rest/sse/perplexity_ask`
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { type AuthStorage, type FetchImpl, getEnvApiKey } from "@oh-my-pi/pi-ai";
|
|
11
|
+
import { type AuthStorage, type FetchImpl, getEnvApiKey, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
|
|
12
12
|
import { $env, readSseJson } from "@oh-my-pi/pi-utils";
|
|
13
13
|
import type {
|
|
14
14
|
PerplexityMessageOutput,
|
|
@@ -43,7 +43,7 @@ type PerplexityAuth =
|
|
|
43
43
|
}
|
|
44
44
|
| {
|
|
45
45
|
type: "oauth";
|
|
46
|
-
|
|
46
|
+
access: OAuthAccess;
|
|
47
47
|
}
|
|
48
48
|
| {
|
|
49
49
|
type: "cookies";
|
|
@@ -302,11 +302,11 @@ function jwtExpiryMs(token: string): number | undefined {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
async function
|
|
305
|
+
async function findOAuthAccess(
|
|
306
306
|
authStorage: AuthStorage,
|
|
307
307
|
sessionId: string | undefined,
|
|
308
308
|
signal: AbortSignal | undefined,
|
|
309
|
-
): Promise<
|
|
309
|
+
): Promise<OAuthAccess | null> {
|
|
310
310
|
try {
|
|
311
311
|
// `getOAuthAccess` returns the raw OAuth bearer only — runtime/config
|
|
312
312
|
// api_key overrides and stored api_key credentials are intentionally
|
|
@@ -314,12 +314,12 @@ async function findOAuthToken(
|
|
|
314
314
|
// `www.perplexity.ai` session/SSE endpoint.
|
|
315
315
|
const access = await authStorage.getOAuthAccess("perplexity", sessionId, { signal });
|
|
316
316
|
const token = access?.accessToken;
|
|
317
|
-
if (!token) return null;
|
|
317
|
+
if (!access || !token) return null;
|
|
318
318
|
// Trust the JWT's own `exp` claim if it has one; otherwise treat as
|
|
319
319
|
// non-expiring. Perplexity session JWTs commonly omit `exp`.
|
|
320
320
|
const jwtExpiry = jwtExpiryMs(token);
|
|
321
321
|
if (jwtExpiry !== undefined && jwtExpiry <= Date.now() + OAUTH_EXPIRY_BUFFER_MS) return null;
|
|
322
|
-
return
|
|
322
|
+
return access;
|
|
323
323
|
} catch {
|
|
324
324
|
return null;
|
|
325
325
|
}
|
|
@@ -339,9 +339,9 @@ async function findPerplexityAuth(
|
|
|
339
339
|
const apiKey = findApiKey();
|
|
340
340
|
|
|
341
341
|
// 2. OAuth/session bearer from AuthStorage.
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
344
|
-
return { type: "oauth",
|
|
342
|
+
const oauthAccess = await findOAuthAccess(authStorage, sessionId, signal);
|
|
343
|
+
if (oauthAccess) {
|
|
344
|
+
return { type: "oauth", access: oauthAccess };
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
// 3. PERPLEXITY_API_KEY env var
|
|
@@ -646,7 +646,19 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
|
|
|
646
646
|
const auth = await findPerplexityAuth(params.authStorage, params.sessionId, params.signal);
|
|
647
647
|
|
|
648
648
|
if (auth.type !== "api_key") {
|
|
649
|
-
|
|
649
|
+
// OAuth bearer mode routes the whole authenticated unit (the ask
|
|
650
|
+
// session/SSE request) through the central auth-retry policy so a 401 or
|
|
651
|
+
// usage-limit force-refreshes, then rotates to a sibling credential.
|
|
652
|
+
// Cookie/env/anonymous modes have no rotatable credential — untouched.
|
|
653
|
+
const askResult =
|
|
654
|
+
auth.type === "oauth"
|
|
655
|
+
? await withOAuthAccess(
|
|
656
|
+
params.authStorage,
|
|
657
|
+
"perplexity",
|
|
658
|
+
access => callPerplexityAsk({ type: "oauth", token: access.accessToken }, params),
|
|
659
|
+
{ sessionId: params.sessionId, signal: params.signal, seed: auth.access },
|
|
660
|
+
)
|
|
661
|
+
: await callPerplexityAsk(auth, params);
|
|
650
662
|
return applySourceLimit(
|
|
651
663
|
{
|
|
652
664
|
provider: "perplexity",
|