@oh-my-pi/pi-coding-agent 13.5.3 → 13.5.5
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 +26 -0
- package/package.json +7 -7
- package/src/cli/agents-cli.ts +138 -0
- package/src/cli/args.ts +4 -1
- package/src/cli.ts +1 -0
- package/src/commands/agents.ts +57 -0
- package/src/config/settings-schema.ts +1 -0
- package/src/mcp/oauth-discovery.ts +111 -36
- package/src/modes/components/mcp-add-wizard.ts +1 -1
- package/src/modes/components/settings-defs.ts +1 -0
- package/src/modes/controllers/command-controller.ts +0 -32
- package/src/modes/controllers/mcp-command-controller.ts +2 -2
- package/src/prompts/tools/task.md +2 -3
- package/src/session/agent-session.ts +15 -3
- package/src/task/index.ts +3 -1
- package/src/web/search/index.ts +15 -1
- package/src/web/search/provider.ts +3 -0
- package/src/web/search/providers/gemini.ts +74 -33
- package/src/web/search/providers/kagi.ts +148 -0
- package/src/web/search/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [13.5.5] - 2026-03-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added Kagi web search provider (Search API v0) with related searches support and automatic `KAGI_API_KEY` detection
|
|
10
|
+
|
|
11
|
+
## [13.5.4] - 2026-03-01
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added `authServerUrl` field to `AuthDetectionResult` to capture OAuth server metadata from `Mcp-Auth-Server` headers
|
|
15
|
+
- Added `extractMcpAuthServerUrl()` function to parse and validate `Mcp-Auth-Server` URLs from error messages
|
|
16
|
+
- Added support for `/.well-known/oauth-protected-resource` discovery endpoint to resolve authorization servers
|
|
17
|
+
- Added recursive auth server discovery to follow `authorization_servers` references when discovering OAuth endpoints
|
|
18
|
+
|
|
19
|
+
- Added `omp agents unpack` CLI subcommand to export bundled subagent definitions to `~/.omp/agent/agents` by default, with `--project` support for `./.omp/agents`
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Enhanced `discoverOAuthEndpoints()` to accept optional `authServerUrl` parameter and query both auth server and resource server for OAuth metadata
|
|
23
|
+
- Improved OAuth metadata extraction to handle additional field name variations (`clientId`, `default_client_id`, `public_client_id`)
|
|
24
|
+
- Refactored OAuth endpoint discovery logic into reusable `findEndpoints()` helper for consistent metadata parsing across multiple sources
|
|
25
|
+
- Task subagents now strip inherited `AGENTS.md` context files and the task tool prompt no longer warns against repeating AGENTS guidance, aligning subagent context with explicit task inputs ([#233](https://github.com/can1357/oh-my-pi/issues/233))
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Fixed MCP OAuth discovery to honor `Mcp-Auth-Server` metadata and resolve authorization endpoints from the declared auth server, restoring Figma MCP login URLs with `client_id` ([#235](https://github.com/can1357/oh-my-pi/issues/235))
|
|
30
|
+
|
|
5
31
|
## [13.5.3] - 2026-03-01
|
|
6
32
|
|
|
7
33
|
### Added
|
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": "13.5.
|
|
4
|
+
"version": "13.5.5",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@mozilla/readability": "^0.6",
|
|
44
|
-
"@oh-my-pi/omp-stats": "13.5.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "13.5.
|
|
46
|
-
"@oh-my-pi/pi-ai": "13.5.
|
|
47
|
-
"@oh-my-pi/pi-natives": "13.5.
|
|
48
|
-
"@oh-my-pi/pi-tui": "13.5.
|
|
49
|
-
"@oh-my-pi/pi-utils": "13.5.
|
|
44
|
+
"@oh-my-pi/omp-stats": "13.5.5",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "13.5.5",
|
|
46
|
+
"@oh-my-pi/pi-ai": "13.5.5",
|
|
47
|
+
"@oh-my-pi/pi-natives": "13.5.5",
|
|
48
|
+
"@oh-my-pi/pi-tui": "13.5.5",
|
|
49
|
+
"@oh-my-pi/pi-utils": "13.5.5",
|
|
50
50
|
"@sinclair/typebox": "^0.34",
|
|
51
51
|
"@xterm/headless": "^6.0",
|
|
52
52
|
"ajv": "^8.18",
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agents CLI command handlers.
|
|
3
|
+
*
|
|
4
|
+
* Handles `omp agents unpack` for writing bundled agent definitions to disk.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs/promises";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { getAgentDir, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils";
|
|
9
|
+
import { YAML } from "bun";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { theme } from "../modes/theme/theme";
|
|
12
|
+
import { loadBundledAgents } from "../task/agents";
|
|
13
|
+
import type { AgentDefinition } from "../task/types";
|
|
14
|
+
|
|
15
|
+
export type AgentsAction = "unpack";
|
|
16
|
+
|
|
17
|
+
export interface AgentsCommandArgs {
|
|
18
|
+
action: AgentsAction;
|
|
19
|
+
flags: {
|
|
20
|
+
force?: boolean;
|
|
21
|
+
json?: boolean;
|
|
22
|
+
dir?: string;
|
|
23
|
+
user?: boolean;
|
|
24
|
+
project?: boolean;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface UnpackResult {
|
|
29
|
+
targetDir: string;
|
|
30
|
+
total: number;
|
|
31
|
+
written: string[];
|
|
32
|
+
skipped: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeStdout(line: string): void {
|
|
36
|
+
process.stdout.write(`${line}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveTargetDir(flags: AgentsCommandArgs["flags"]): string {
|
|
40
|
+
if (flags.dir && flags.dir.trim().length > 0) {
|
|
41
|
+
return path.resolve(getProjectDir(), flags.dir.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (flags.user && flags.project) {
|
|
45
|
+
throw new Error("Choose either --user or --project, not both.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (flags.project) {
|
|
49
|
+
return path.resolve(getProjectDir(), ".omp", "agents");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return path.join(getAgentDir(), "agents");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toFrontmatter(agent: AgentDefinition): Record<string, unknown> {
|
|
56
|
+
const frontmatter: Record<string, unknown> = {
|
|
57
|
+
name: agent.name,
|
|
58
|
+
description: agent.description,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (agent.tools && agent.tools.length > 0) frontmatter.tools = agent.tools;
|
|
62
|
+
if (agent.spawns !== undefined) frontmatter.spawns = agent.spawns;
|
|
63
|
+
if (agent.model && agent.model.length > 0) frontmatter.model = agent.model;
|
|
64
|
+
if (agent.thinkingLevel) frontmatter["thinking-level"] = agent.thinkingLevel;
|
|
65
|
+
if (agent.output !== undefined) frontmatter.output = agent.output;
|
|
66
|
+
if (agent.blocking) frontmatter.blocking = true;
|
|
67
|
+
|
|
68
|
+
return frontmatter;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function serializeAgent(agent: AgentDefinition): string {
|
|
72
|
+
const frontmatter = YAML.stringify(toFrontmatter(agent), null, 2).trimEnd();
|
|
73
|
+
const body = agent.systemPrompt.trim();
|
|
74
|
+
return `---\n${frontmatter}\n---\n\n${body}\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function unpackBundledAgents(flags: AgentsCommandArgs["flags"]): Promise<UnpackResult> {
|
|
78
|
+
const targetDir = resolveTargetDir(flags);
|
|
79
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
80
|
+
|
|
81
|
+
const bundledAgents = [...loadBundledAgents()].sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
const written: string[] = [];
|
|
83
|
+
const skipped: string[] = [];
|
|
84
|
+
|
|
85
|
+
for (const agent of bundledAgents) {
|
|
86
|
+
const filePath = path.join(targetDir, `${agent.name}.md`);
|
|
87
|
+
if (!flags.force) {
|
|
88
|
+
try {
|
|
89
|
+
await fs.stat(filePath);
|
|
90
|
+
skipped.push(filePath);
|
|
91
|
+
continue;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!isEnoent(error)) throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await Bun.write(filePath, serializeAgent(agent));
|
|
98
|
+
written.push(filePath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
targetDir,
|
|
103
|
+
total: bundledAgents.length,
|
|
104
|
+
written,
|
|
105
|
+
skipped,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function runAgentsCommand(cmd: AgentsCommandArgs): Promise<void> {
|
|
110
|
+
switch (cmd.action) {
|
|
111
|
+
case "unpack": {
|
|
112
|
+
const result = await unpackBundledAgents(cmd.flags);
|
|
113
|
+
if (cmd.flags.json) {
|
|
114
|
+
writeStdout(JSON.stringify(result, null, 2));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
writeStdout(chalk.bold(`Bundled agents: ${result.total}`));
|
|
119
|
+
writeStdout(chalk.dim(`Target directory: ${result.targetDir}`));
|
|
120
|
+
writeStdout(chalk.green(`${theme.status.success} Written: ${result.written.length}`));
|
|
121
|
+
if (result.skipped.length > 0) {
|
|
122
|
+
writeStdout(
|
|
123
|
+
chalk.yellow(
|
|
124
|
+
`${theme.status.warning} Skipped existing: ${result.skipped.length} (use --force to overwrite)`,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const filePath of result.written) {
|
|
130
|
+
writeStdout(chalk.dim(` + ${filePath}`));
|
|
131
|
+
}
|
|
132
|
+
for (const filePath of result.skipped) {
|
|
133
|
+
writeStdout(chalk.dim(` = ${filePath}`));
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/cli/args.ts
CHANGED
|
@@ -247,7 +247,10 @@ ${chalk.bold("Available Tools (all enabled by default):")}
|
|
|
247
247
|
fetch - Fetch and process URLs
|
|
248
248
|
web_search - Search the web
|
|
249
249
|
ask - Ask user questions (interactive mode only)
|
|
250
|
-
|
|
250
|
+
|
|
251
|
+
${chalk.bold("Useful Commands:")}
|
|
252
|
+
omp agents unpack - Export bundled subagents to ~/.omp/agent/agents (default)
|
|
253
|
+
omp agents unpack --project - Export bundled subagents to ./.omp/agents`;
|
|
251
254
|
}
|
|
252
255
|
|
|
253
256
|
export function printHelp(): void {
|
package/src/cli.ts
CHANGED
|
@@ -16,6 +16,7 @@ process.title = APP_NAME;
|
|
|
16
16
|
|
|
17
17
|
const commands: CommandEntry[] = [
|
|
18
18
|
{ name: "launch", load: () => import("./commands/launch").then(m => m.default) },
|
|
19
|
+
{ name: "agents", load: () => import("./commands/agents").then(m => m.default) },
|
|
19
20
|
{ name: "commit", load: () => import("./commands/commit").then(m => m.default) },
|
|
20
21
|
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
|
|
21
22
|
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manage bundled task agents.
|
|
3
|
+
*/
|
|
4
|
+
import { Args, Command, Flags, renderCommandHelp } from "@oh-my-pi/pi-utils/cli";
|
|
5
|
+
import { type AgentsAction, type AgentsCommandArgs, runAgentsCommand } from "../cli/agents-cli";
|
|
6
|
+
import { initTheme } from "../modes/theme/theme";
|
|
7
|
+
|
|
8
|
+
const ACTIONS: AgentsAction[] = ["unpack"];
|
|
9
|
+
|
|
10
|
+
export default class Agents extends Command {
|
|
11
|
+
static description = "Manage bundled task agents";
|
|
12
|
+
|
|
13
|
+
static args = {
|
|
14
|
+
action: Args.string({
|
|
15
|
+
description: "Agents action",
|
|
16
|
+
required: false,
|
|
17
|
+
options: ACTIONS,
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
static flags = {
|
|
22
|
+
force: Flags.boolean({ char: "f", description: "Overwrite existing agent files" }),
|
|
23
|
+
json: Flags.boolean({ description: "Output JSON" }),
|
|
24
|
+
dir: Flags.string({ description: "Output directory (overrides --user/--project)" }),
|
|
25
|
+
user: Flags.boolean({ description: "Write to ~/.omp/agent/agents (default)" }),
|
|
26
|
+
project: Flags.boolean({ description: "Write to ./.omp/agents" }),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
static examples = [
|
|
30
|
+
"# Export bundled agents into user config (default)\n omp agents unpack",
|
|
31
|
+
"# Export bundled agents into project config\n omp agents unpack --project",
|
|
32
|
+
"# Overwrite existing local agent files\n omp agents unpack --project --force",
|
|
33
|
+
"# Export into a custom directory\n omp agents unpack --dir ./tmp/agents --json",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
async run(): Promise<void> {
|
|
37
|
+
const { args, flags } = await this.parse(Agents);
|
|
38
|
+
if (!args.action) {
|
|
39
|
+
renderCommandHelp("omp", "agents", Agents);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const cmd: AgentsCommandArgs = {
|
|
44
|
+
action: args.action as AgentsAction,
|
|
45
|
+
flags: {
|
|
46
|
+
force: flags.force,
|
|
47
|
+
json: flags.json,
|
|
48
|
+
dir: flags.dir,
|
|
49
|
+
user: flags.user,
|
|
50
|
+
project: flags.project,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
await initTheme();
|
|
55
|
+
await runAgentsCommand(cmd);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -16,9 +16,25 @@ export interface AuthDetectionResult {
|
|
|
16
16
|
requiresAuth: boolean;
|
|
17
17
|
authType?: "oauth" | "apikey" | "unknown";
|
|
18
18
|
oauth?: OAuthEndpoints;
|
|
19
|
+
authServerUrl?: string;
|
|
19
20
|
message?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
function parseMcpAuthServerUrl(errorMessage: string): string | undefined {
|
|
24
|
+
const match = errorMessage.match(/Mcp-Auth-Server:\s*([^;\]\s]+)/i);
|
|
25
|
+
if (!match || !match[1]) return undefined;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
return new URL(match[1]).toString();
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractMcpAuthServerUrl(error: Error): string | undefined {
|
|
35
|
+
return parseMcpAuthServerUrl(error.message);
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
/**
|
|
23
39
|
* Detect if an error indicates authentication is required.
|
|
24
40
|
* Checks for common auth error patterns.
|
|
@@ -178,6 +194,8 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
178
194
|
return { requiresAuth: false };
|
|
179
195
|
}
|
|
180
196
|
|
|
197
|
+
const authServerUrl = extractMcpAuthServerUrl(error);
|
|
198
|
+
|
|
181
199
|
// Try to extract OAuth endpoints
|
|
182
200
|
const oauth = extractOAuthEndpoints(error);
|
|
183
201
|
|
|
@@ -186,6 +204,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
186
204
|
requiresAuth: true,
|
|
187
205
|
authType: "oauth",
|
|
188
206
|
oauth,
|
|
207
|
+
authServerUrl,
|
|
189
208
|
message: "Server requires OAuth authentication. Launching authorization flow...",
|
|
190
209
|
};
|
|
191
210
|
}
|
|
@@ -201,6 +220,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
201
220
|
return {
|
|
202
221
|
requiresAuth: true,
|
|
203
222
|
authType: "apikey",
|
|
223
|
+
authServerUrl,
|
|
204
224
|
message: "Server requires API key authentication.",
|
|
205
225
|
};
|
|
206
226
|
}
|
|
@@ -209,6 +229,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
209
229
|
return {
|
|
210
230
|
requiresAuth: true,
|
|
211
231
|
authType: "unknown",
|
|
232
|
+
authServerUrl,
|
|
212
233
|
message: "Server requires authentication but type could not be determined.",
|
|
213
234
|
};
|
|
214
235
|
}
|
|
@@ -217,56 +238,110 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
|
|
|
217
238
|
* Try to discover OAuth endpoints by querying the server's well-known endpoints.
|
|
218
239
|
* This is a fallback when error responses don't include OAuth metadata.
|
|
219
240
|
*/
|
|
220
|
-
export async function discoverOAuthEndpoints(
|
|
241
|
+
export async function discoverOAuthEndpoints(
|
|
242
|
+
serverUrl: string,
|
|
243
|
+
authServerUrl?: string,
|
|
244
|
+
): Promise<OAuthEndpoints | null> {
|
|
221
245
|
const wellKnownPaths = [
|
|
222
246
|
"/.well-known/oauth-authorization-server",
|
|
223
247
|
"/.well-known/openid-configuration",
|
|
248
|
+
"/.well-known/oauth-protected-resource",
|
|
224
249
|
"/oauth/metadata",
|
|
225
250
|
"/.mcp/auth",
|
|
226
251
|
"/authorize", // Some MCP servers expose OAuth config here
|
|
227
252
|
];
|
|
253
|
+
const urlsToQuery = [authServerUrl, serverUrl].filter((value): value is string => Boolean(value));
|
|
254
|
+
const visitedAuthServers = new Set<string>();
|
|
228
255
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
256
|
+
const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
|
|
257
|
+
if (metadata.authorization_endpoint && metadata.token_endpoint) {
|
|
258
|
+
const scopesSupported = Array.isArray(metadata.scopes_supported)
|
|
259
|
+
? metadata.scopes_supported.filter((scope): scope is string => typeof scope === "string").join(" ")
|
|
260
|
+
: undefined;
|
|
261
|
+
return {
|
|
262
|
+
authorizationUrl: String(metadata.authorization_endpoint),
|
|
263
|
+
tokenUrl: String(metadata.token_endpoint),
|
|
264
|
+
clientId:
|
|
265
|
+
typeof metadata.client_id === "string"
|
|
266
|
+
? metadata.client_id
|
|
267
|
+
: typeof metadata.clientId === "string"
|
|
268
|
+
? metadata.clientId
|
|
269
|
+
: typeof metadata.default_client_id === "string"
|
|
270
|
+
? metadata.default_client_id
|
|
271
|
+
: typeof metadata.public_client_id === "string"
|
|
272
|
+
? metadata.public_client_id
|
|
273
|
+
: undefined,
|
|
274
|
+
scopes:
|
|
275
|
+
scopesSupported ||
|
|
276
|
+
(typeof metadata.scopes === "string"
|
|
277
|
+
? metadata.scopes
|
|
278
|
+
: typeof metadata.scope === "string"
|
|
279
|
+
? metadata.scope
|
|
280
|
+
: undefined),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
236
283
|
|
|
237
|
-
|
|
238
|
-
|
|
284
|
+
if (metadata.oauth || metadata.authorization || metadata.auth) {
|
|
285
|
+
const oauthData = (metadata.oauth || metadata.authorization || metadata.auth) as Record<string, unknown>;
|
|
286
|
+
if (typeof oauthData.authorization_url === "string" && typeof oauthData.token_url === "string") {
|
|
287
|
+
return {
|
|
288
|
+
authorizationUrl: oauthData.authorization_url || String(oauthData.authorizationUrl),
|
|
289
|
+
tokenUrl: oauthData.token_url || String(oauthData.tokenUrl),
|
|
290
|
+
clientId:
|
|
291
|
+
typeof oauthData.client_id === "string"
|
|
292
|
+
? oauthData.client_id
|
|
293
|
+
: typeof oauthData.clientId === "string"
|
|
294
|
+
? oauthData.clientId
|
|
295
|
+
: typeof oauthData.default_client_id === "string"
|
|
296
|
+
? oauthData.default_client_id
|
|
297
|
+
: typeof oauthData.public_client_id === "string"
|
|
298
|
+
? oauthData.public_client_id
|
|
299
|
+
: undefined,
|
|
300
|
+
scopes:
|
|
301
|
+
typeof oauthData.scopes === "string"
|
|
302
|
+
? oauthData.scopes
|
|
303
|
+
: typeof oauthData.scope === "string"
|
|
304
|
+
? oauthData.scope
|
|
305
|
+
: undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
239
309
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
310
|
+
return null;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
for (const baseUrl of urlsToQuery) {
|
|
314
|
+
visitedAuthServers.add(baseUrl);
|
|
315
|
+
for (const path of wellKnownPaths) {
|
|
316
|
+
try {
|
|
317
|
+
const url = new URL(path, baseUrl);
|
|
318
|
+
const response = await fetch(url.toString(), {
|
|
319
|
+
method: "GET",
|
|
320
|
+
headers: { Accept: "application/json" },
|
|
321
|
+
});
|
|
250
322
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
323
|
+
if (response.ok) {
|
|
324
|
+
const metadata = (await response.json()) as Record<string, unknown>;
|
|
325
|
+
const endpoints = findEndpoints(metadata);
|
|
326
|
+
if (endpoints) return endpoints;
|
|
327
|
+
|
|
328
|
+
if (path === "/.well-known/oauth-protected-resource") {
|
|
329
|
+
const authServers = Array.isArray(metadata.authorization_servers)
|
|
330
|
+
? metadata.authorization_servers.filter((entry): entry is string => typeof entry === "string")
|
|
331
|
+
: [];
|
|
332
|
+
|
|
333
|
+
for (const discoveredAuthServer of authServers) {
|
|
334
|
+
if (visitedAuthServers.has(discoveredAuthServer)) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const discovered = await discoverOAuthEndpoints(serverUrl, discoveredAuthServer);
|
|
338
|
+
if (discovered) return discovered;
|
|
339
|
+
}
|
|
265
340
|
}
|
|
266
341
|
}
|
|
342
|
+
} catch {
|
|
343
|
+
// Ignore errors, try next path
|
|
267
344
|
}
|
|
268
|
-
} catch {
|
|
269
|
-
// Ignore errors, try next path
|
|
270
345
|
}
|
|
271
346
|
}
|
|
272
347
|
|
|
@@ -947,7 +947,7 @@ export class MCPAddWizard extends Container {
|
|
|
947
947
|
let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
|
|
948
948
|
if (!oauth && this.#state.transport !== "stdio" && this.#state.url) {
|
|
949
949
|
try {
|
|
950
|
-
oauth = await discoverOAuthEndpoints(this.#state.url);
|
|
950
|
+
oauth = await discoverOAuthEndpoints(this.#state.url, authResult.authServerUrl);
|
|
951
951
|
} catch {
|
|
952
952
|
// Ignore discovery failures and fallback to manual auth.
|
|
953
953
|
}
|
|
@@ -194,6 +194,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
|
|
194
194
|
{ value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_COOKIES or PERPLEXITY_API_KEY" },
|
|
195
195
|
{ value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" },
|
|
196
196
|
{ value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
|
|
197
|
+
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY" },
|
|
197
198
|
{ value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
|
|
198
199
|
],
|
|
199
200
|
"providers.image": [
|
|
@@ -983,19 +983,6 @@ function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] {
|
|
|
983
983
|
return "exhausted";
|
|
984
984
|
}
|
|
985
985
|
|
|
986
|
-
function isZeroUsage(limit: UsageLimit): boolean {
|
|
987
|
-
const amount = limit.amount;
|
|
988
|
-
if (amount.usedFraction !== undefined) return amount.usedFraction <= 0;
|
|
989
|
-
if (amount.used !== undefined) return amount.used <= 0;
|
|
990
|
-
if (amount.unit === "percent" && amount.used !== undefined) return amount.used <= 0;
|
|
991
|
-
if (amount.remainingFraction !== undefined) return amount.remainingFraction >= 1;
|
|
992
|
-
return false;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
function isZeroUsageGroup(limits: UsageLimit[]): boolean {
|
|
996
|
-
return limits.length > 0 && limits.every(limit => isZeroUsage(limit));
|
|
997
|
-
}
|
|
998
|
-
|
|
999
986
|
function formatAggregateAmount(limits: UsageLimit[]): string {
|
|
1000
987
|
const fractions = limits
|
|
1001
988
|
.map(limit => resolveFraction(limit))
|
|
@@ -1117,13 +1104,6 @@ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs
|
|
|
1117
1104
|
}
|
|
1118
1105
|
}
|
|
1119
1106
|
|
|
1120
|
-
const providerAllZero = isZeroUsageGroup(Array.from(limitGroups.values()).flatMap(group => group.limits));
|
|
1121
|
-
if (providerAllZero) {
|
|
1122
|
-
const providerTitle = `${resolveStatusIcon("ok", uiTheme)} ${uiTheme.fg("accent", `${providerName} (0%)`)}`;
|
|
1123
|
-
lines.push(uiTheme.bold(providerTitle));
|
|
1124
|
-
continue;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
1107
|
lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
|
|
1128
1108
|
|
|
1129
1109
|
for (const group of limitGroups.values()) {
|
|
@@ -1144,18 +1124,6 @@ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs
|
|
|
1144
1124
|
|
|
1145
1125
|
const status = resolveAggregateStatus(sortedLimits);
|
|
1146
1126
|
const statusIcon = resolveStatusIcon(status, uiTheme);
|
|
1147
|
-
if (isZeroUsageGroup(sortedLimits)) {
|
|
1148
|
-
const resetText = resolveResetRange(sortedLimits, nowMs);
|
|
1149
|
-
const resetSuffix = resetText ? ` | ${resetText}` : "";
|
|
1150
|
-
const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
|
|
1151
|
-
lines.push(
|
|
1152
|
-
`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix} ${uiTheme.fg(
|
|
1153
|
-
"dim",
|
|
1154
|
-
`0%${resetSuffix}`,
|
|
1155
|
-
)}`.trim(),
|
|
1156
|
-
);
|
|
1157
|
-
continue;
|
|
1158
|
-
}
|
|
1159
1127
|
|
|
1160
1128
|
const windowSuffix = formatWindowSuffix(group.label, group.windowLabel, uiTheme);
|
|
1161
1129
|
lines.push(`${statusIcon} ${uiTheme.bold(group.label)} ${windowSuffix}`.trim());
|
|
@@ -264,7 +264,7 @@ export class MCPCommandController {
|
|
|
264
264
|
let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
|
|
265
265
|
if (!oauth && finalConfig.url) {
|
|
266
266
|
try {
|
|
267
|
-
oauth = await discoverOAuthEndpoints(finalConfig.url);
|
|
267
|
+
oauth = await discoverOAuthEndpoints(finalConfig.url, authResult.authServerUrl);
|
|
268
268
|
} catch {
|
|
269
269
|
// Ignore discovery error and handle below.
|
|
270
270
|
}
|
|
@@ -574,7 +574,7 @@ export class MCPCommandController {
|
|
|
574
574
|
let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
|
|
575
575
|
|
|
576
576
|
if (!oauth && (config.type === "http" || config.type === "sse") && config.url) {
|
|
577
|
-
oauth = await discoverOAuthEndpoints(config.url);
|
|
577
|
+
oauth = await discoverOAuthEndpoints(config.url, authResult.authServerUrl);
|
|
578
578
|
}
|
|
579
579
|
|
|
580
580
|
if (!oauth) {
|
|
@@ -19,7 +19,6 @@ Subagents lack your conversation history. Every decision, file content, and user
|
|
|
19
19
|
</parameters>
|
|
20
20
|
|
|
21
21
|
<critical>
|
|
22
|
-
- **MUST NOT** include AGENTS.md rules, coding conventions, or style guidelines — subagents already have them.
|
|
23
22
|
- **MUST NOT** duplicate shared constraints across assignments — put them in `context` once.
|
|
24
23
|
- **MUST NOT** tell tasks to run project-wide build/test/lint. Parallel agents share the working tree; each task edits, stops. Caller verifies after all complete.
|
|
25
24
|
- For large payloads (traces, JSON blobs), write to `local://<path>` and pass the path in context.
|
|
@@ -47,7 +46,7 @@ Each task: **at most 3–5 files**. Globs in file paths, "update all", or packag
|
|
|
47
46
|
```
|
|
48
47
|
## Goal ← one sentence: what the batch accomplishes
|
|
49
48
|
## Non-goals ← what tasks must not touch
|
|
50
|
-
## Constraints ← MUST/MUST NOT rules
|
|
49
|
+
## Constraints ← MUST/MUST NOT rules and session decisions
|
|
51
50
|
## API Contract ← exact types/signatures if tasks share an interface (omit if N/A)
|
|
52
51
|
## Acceptance ← definition of done; build/lint runs AFTER all tasks complete
|
|
53
52
|
```
|
|
@@ -62,7 +61,7 @@ Each task: **at most 3–5 files**. Globs in file paths, "update all", or packag
|
|
|
62
61
|
|
|
63
62
|
<checklist>
|
|
64
63
|
Before invoking:
|
|
65
|
-
- `context` contains only session-specific info
|
|
64
|
+
- `context` contains only session-specific info
|
|
66
65
|
- Every `assignment` follows the template; no one-liners; edge cases covered
|
|
67
66
|
- Tasks are truly parallel — you can articulate why none depends on another's output
|
|
68
67
|
- File paths are explicit; no globs
|
|
@@ -38,7 +38,13 @@ import type {
|
|
|
38
38
|
Usage,
|
|
39
39
|
UsageReport,
|
|
40
40
|
} from "@oh-my-pi/pi-ai";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
calculateRateLimitBackoffMs,
|
|
43
|
+
isContextOverflow,
|
|
44
|
+
modelsAreEqual,
|
|
45
|
+
parseRateLimitReason,
|
|
46
|
+
supportsXhigh,
|
|
47
|
+
} from "@oh-my-pi/pi-ai";
|
|
42
48
|
import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
43
49
|
import type { AsyncJob, AsyncJobManager } from "../async";
|
|
44
50
|
import type { Rule } from "../capability/rule";
|
|
@@ -3959,7 +3965,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3959
3965
|
}
|
|
3960
3966
|
|
|
3961
3967
|
#isUsageLimitErrorMessage(errorMessage: string): boolean {
|
|
3962
|
-
return /usage.?limit|usage_limit_reached|limit_reached/i.test(errorMessage);
|
|
3968
|
+
return /usage.?limit|usage_limit_reached|limit_reached|quota.?exceeded|resource.?exhausted/i.test(errorMessage);
|
|
3963
3969
|
}
|
|
3964
3970
|
|
|
3965
3971
|
#parseRetryAfterMsFromError(errorMessage: string): number | undefined {
|
|
@@ -4004,6 +4010,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
4004
4010
|
}
|
|
4005
4011
|
}
|
|
4006
4012
|
|
|
4013
|
+
// Smart Fallback if no exact headers found
|
|
4007
4014
|
return undefined;
|
|
4008
4015
|
}
|
|
4009
4016
|
|
|
@@ -4043,7 +4050,9 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
4043
4050
|
let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
|
|
4044
4051
|
|
|
4045
4052
|
if (this.model && this.#isUsageLimitErrorMessage(errorMessage)) {
|
|
4046
|
-
const retryAfterMs =
|
|
4053
|
+
const retryAfterMs =
|
|
4054
|
+
this.#parseRetryAfterMsFromError(errorMessage) ??
|
|
4055
|
+
calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
|
|
4047
4056
|
const switched = await this.#modelRegistry.authStorage.markUsageLimitReached(
|
|
4048
4057
|
this.model.provider,
|
|
4049
4058
|
this.sessionId,
|
|
@@ -4054,6 +4063,9 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
4054
4063
|
);
|
|
4055
4064
|
if (switched) {
|
|
4056
4065
|
delayMs = 0;
|
|
4066
|
+
} else if (retryAfterMs > delayMs) {
|
|
4067
|
+
// No more accounts to switch to — wait out the backoff
|
|
4068
|
+
delayMs = retryAfterMs;
|
|
4057
4069
|
}
|
|
4058
4070
|
}
|
|
4059
4071
|
|
package/src/task/index.ts
CHANGED
|
@@ -691,7 +691,9 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
|
|
691
691
|
// Build full prompts with context prepended
|
|
692
692
|
const tasksWithContext = tasksWithUniqueIds.map(t => renderTemplate(context, t));
|
|
693
693
|
const availableSkills = [...(this.session.skills ?? [])];
|
|
694
|
-
const contextFiles = this.session.contextFiles
|
|
694
|
+
const contextFiles = this.session.contextFiles?.filter(
|
|
695
|
+
file => path.basename(file.path).toLowerCase() !== "agents.md",
|
|
696
|
+
);
|
|
695
697
|
const promptTemplates = this.session.promptTemplates;
|
|
696
698
|
|
|
697
699
|
// Initialize progress for all tasks
|
package/src/web/search/index.ts
CHANGED
|
@@ -34,7 +34,20 @@ export const webSearchSchema = Type.Object({
|
|
|
34
34
|
query: Type.String({ description: "Search query" }),
|
|
35
35
|
provider: Type.Optional(
|
|
36
36
|
StringEnum(
|
|
37
|
-
[
|
|
37
|
+
[
|
|
38
|
+
"auto",
|
|
39
|
+
"exa",
|
|
40
|
+
"brave",
|
|
41
|
+
"jina",
|
|
42
|
+
"kimi",
|
|
43
|
+
"zai",
|
|
44
|
+
"anthropic",
|
|
45
|
+
"perplexity",
|
|
46
|
+
"gemini",
|
|
47
|
+
"codex",
|
|
48
|
+
"kagi",
|
|
49
|
+
"synthetic",
|
|
50
|
+
],
|
|
38
51
|
{
|
|
39
52
|
description: "Search provider (default: auto)",
|
|
40
53
|
},
|
|
@@ -64,6 +77,7 @@ export type SearchParams = {
|
|
|
64
77
|
| "perplexity"
|
|
65
78
|
| "gemini"
|
|
66
79
|
| "codex"
|
|
80
|
+
| "kagi"
|
|
67
81
|
| "synthetic";
|
|
68
82
|
recency?: "day" | "week" | "month" | "year";
|
|
69
83
|
limit?: number;
|
|
@@ -5,6 +5,7 @@ import { CodexProvider } from "./providers/codex";
|
|
|
5
5
|
import { ExaProvider } from "./providers/exa";
|
|
6
6
|
import { GeminiProvider } from "./providers/gemini";
|
|
7
7
|
import { JinaProvider } from "./providers/jina";
|
|
8
|
+
import { KagiProvider } from "./providers/kagi";
|
|
8
9
|
import { KimiProvider } from "./providers/kimi";
|
|
9
10
|
import { PerplexityProvider } from "./providers/perplexity";
|
|
10
11
|
import { SyntheticProvider } from "./providers/synthetic";
|
|
@@ -24,6 +25,7 @@ const SEARCH_PROVIDERS: Record<SearchProviderId, SearchProvider> = {
|
|
|
24
25
|
anthropic: new AnthropicProvider(),
|
|
25
26
|
gemini: new GeminiProvider(),
|
|
26
27
|
codex: new CodexProvider(),
|
|
28
|
+
kagi: new KagiProvider(),
|
|
27
29
|
synthetic: new SyntheticProvider(),
|
|
28
30
|
} as const;
|
|
29
31
|
|
|
@@ -37,6 +39,7 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
|
|
|
37
39
|
"codex",
|
|
38
40
|
"zai",
|
|
39
41
|
"exa",
|
|
42
|
+
"kagi",
|
|
40
43
|
"synthetic",
|
|
41
44
|
];
|
|
42
45
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
9
|
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
|
10
|
+
extractRetryDelay,
|
|
10
11
|
getAntigravityHeaders,
|
|
11
12
|
getGeminiCliHeaders,
|
|
12
13
|
refreshGoogleCloudToken,
|
|
@@ -23,6 +24,9 @@ const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
|
23
24
|
const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
|
24
25
|
const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
|
|
25
26
|
const DEFAULT_MODEL = "gemini-2.5-flash";
|
|
27
|
+
const MAX_RETRIES = 3;
|
|
28
|
+
const BASE_DELAY_MS = 1000;
|
|
29
|
+
const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
|
|
26
30
|
|
|
27
31
|
interface GeminiToolParams {
|
|
28
32
|
google_search?: Record<string, unknown>;
|
|
@@ -270,46 +274,83 @@ async function callGeminiSearch(
|
|
|
270
274
|
(requestBody.request as Record<string, unknown>).generationConfig = generationConfig;
|
|
271
275
|
}
|
|
272
276
|
let response: Response | undefined;
|
|
277
|
+
let rateLimitTimeSpent = 0;
|
|
278
|
+
let lastError: Error | undefined;
|
|
279
|
+
|
|
273
280
|
for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
|
|
274
281
|
const url = `${endpoints[endpointIndex]}/v1internal:streamGenerateContent?alt=sse`;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
282
|
+
|
|
283
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
284
|
+
try {
|
|
285
|
+
response = await fetch(url, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
headers: {
|
|
288
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
Accept: "text/event-stream",
|
|
291
|
+
...headers,
|
|
292
|
+
},
|
|
293
|
+
body: JSON.stringify(requestBody),
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (attempt < MAX_RETRIES) {
|
|
297
|
+
await Bun.sleep(BASE_DELAY_MS * 2 ** attempt);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (auth.isAntigravity && endpointIndex < endpoints.length - 1) {
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
throw error;
|
|
289
306
|
}
|
|
290
|
-
throw error;
|
|
291
|
-
}
|
|
292
307
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
308
|
+
if (response.ok) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
296
311
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
312
|
+
const errorText = await response.text();
|
|
313
|
+
const isRetryableStatus =
|
|
314
|
+
response.status === 429 ||
|
|
315
|
+
response.status === 500 ||
|
|
316
|
+
response.status === 502 ||
|
|
317
|
+
response.status === 503 ||
|
|
318
|
+
response.status === 504;
|
|
319
|
+
|
|
320
|
+
if (isRetryableStatus && attempt < MAX_RETRIES) {
|
|
321
|
+
const serverDelay = extractRetryDelay(errorText, response);
|
|
322
|
+
if (response.status === 429) {
|
|
323
|
+
if (serverDelay && rateLimitTimeSpent + serverDelay <= RATE_LIMIT_BUDGET_MS) {
|
|
324
|
+
rateLimitTimeSpent += serverDelay;
|
|
325
|
+
await Bun.sleep(serverDelay);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!serverDelay) {
|
|
329
|
+
await Bun.sleep(BASE_DELAY_MS * 2 ** attempt);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
await Bun.sleep(serverDelay ?? BASE_DELAY_MS * 2 ** attempt);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
lastError = new SearchProviderError(
|
|
339
|
+
"gemini",
|
|
340
|
+
`Gemini Cloud Code API error (${response.status}): ${errorText}`,
|
|
341
|
+
response.status,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
if (auth.isAntigravity && isRetryableStatus && endpointIndex < endpoints.length - 1) {
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
throw lastError;
|
|
306
349
|
}
|
|
307
350
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
response.status,
|
|
312
|
-
);
|
|
351
|
+
if (response?.ok) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
313
354
|
}
|
|
314
355
|
|
|
315
356
|
if (!response) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kagi Web Search Provider
|
|
3
|
+
*
|
|
4
|
+
* Calls Kagi's Search API (v0) and maps results into the unified
|
|
5
|
+
* SearchResponse shape used by the web search tool.
|
|
6
|
+
*/
|
|
7
|
+
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
|
|
8
|
+
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
|
9
|
+
import { SearchProviderError } from "../../../web/search/types";
|
|
10
|
+
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
|
11
|
+
import type { SearchParams } from "./base";
|
|
12
|
+
import { SearchProvider } from "./base";
|
|
13
|
+
|
|
14
|
+
const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
|
|
15
|
+
const DEFAULT_NUM_RESULTS = 10;
|
|
16
|
+
const MAX_NUM_RESULTS = 40;
|
|
17
|
+
|
|
18
|
+
interface KagiSearchResult {
|
|
19
|
+
t: 0;
|
|
20
|
+
url: string;
|
|
21
|
+
title: string;
|
|
22
|
+
snippet?: string;
|
|
23
|
+
published?: string;
|
|
24
|
+
thumbnail?: {
|
|
25
|
+
url: string;
|
|
26
|
+
width?: number | null;
|
|
27
|
+
height?: number | null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface KagiRelatedSearches {
|
|
32
|
+
t: 1;
|
|
33
|
+
list: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type KagiSearchObject = KagiSearchResult | KagiRelatedSearches;
|
|
37
|
+
|
|
38
|
+
interface KagiMeta {
|
|
39
|
+
id: string;
|
|
40
|
+
node: string;
|
|
41
|
+
ms: number;
|
|
42
|
+
api_balance?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface KagiSearchResponse {
|
|
46
|
+
meta: KagiMeta;
|
|
47
|
+
data: KagiSearchObject[];
|
|
48
|
+
error?: Array<{ code: number; msg: string; ref?: unknown }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Find KAGI_API_KEY from environment or .env files. */
|
|
52
|
+
export function findApiKey(): string | null {
|
|
53
|
+
return getEnvApiKey("kagi") ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function callKagiSearch(
|
|
57
|
+
apiKey: string,
|
|
58
|
+
query: string,
|
|
59
|
+
limit: number,
|
|
60
|
+
signal?: AbortSignal,
|
|
61
|
+
): Promise<KagiSearchResponse> {
|
|
62
|
+
const url = new URL(KAGI_SEARCH_URL);
|
|
63
|
+
url.searchParams.set("q", query);
|
|
64
|
+
url.searchParams.set("limit", String(limit));
|
|
65
|
+
|
|
66
|
+
const response = await fetch(url, {
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bot ${apiKey}`,
|
|
69
|
+
Accept: "application/json",
|
|
70
|
+
},
|
|
71
|
+
signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const errorText = await response.text();
|
|
76
|
+
throw new SearchProviderError("kagi", `Kagi API error (${response.status}): ${errorText}`, response.status);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const data = (await response.json()) as KagiSearchResponse;
|
|
80
|
+
|
|
81
|
+
if (data.error && data.error.length > 0) {
|
|
82
|
+
const firstError = data.error[0];
|
|
83
|
+
throw new SearchProviderError("kagi", `Kagi API error: ${firstError.msg}`, firstError.code);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return data;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Execute Kagi web search. */
|
|
90
|
+
export async function searchKagi(params: {
|
|
91
|
+
query: string;
|
|
92
|
+
num_results?: number;
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
}): Promise<SearchResponse> {
|
|
95
|
+
const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
|
96
|
+
const apiKey = findApiKey();
|
|
97
|
+
if (!apiKey) {
|
|
98
|
+
throw new Error("KAGI_API_KEY not found. Set it in environment or .env file.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data = await callKagiSearch(apiKey, params.query, numResults, params.signal);
|
|
102
|
+
|
|
103
|
+
const sources: SearchSource[] = [];
|
|
104
|
+
const relatedQuestions: string[] = [];
|
|
105
|
+
|
|
106
|
+
for (const item of data.data) {
|
|
107
|
+
if (item.t === 0) {
|
|
108
|
+
sources.push({
|
|
109
|
+
title: item.title,
|
|
110
|
+
url: item.url,
|
|
111
|
+
snippet: item.snippet,
|
|
112
|
+
publishedDate: item.published ?? undefined,
|
|
113
|
+
ageSeconds: dateToAgeSeconds(item.published),
|
|
114
|
+
});
|
|
115
|
+
} else if (item.t === 1) {
|
|
116
|
+
relatedQuestions.push(...item.list);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
provider: "kagi",
|
|
122
|
+
sources: sources.slice(0, numResults),
|
|
123
|
+
relatedQuestions: relatedQuestions.length > 0 ? relatedQuestions : undefined,
|
|
124
|
+
requestId: data.meta.id,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Search provider for Kagi web search. */
|
|
129
|
+
export class KagiProvider extends SearchProvider {
|
|
130
|
+
readonly id = "kagi";
|
|
131
|
+
readonly label = "Kagi";
|
|
132
|
+
|
|
133
|
+
isAvailable() {
|
|
134
|
+
try {
|
|
135
|
+
return !!findApiKey();
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
search(params: SearchParams): Promise<SearchResponse> {
|
|
142
|
+
return searchKagi({
|
|
143
|
+
query: params.query,
|
|
144
|
+
num_results: params.numSearchResults ?? params.limit,
|
|
145
|
+
signal: params.signal,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|