tau-coding-agent 0.1.0
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/LICENSE +21 -0
- package/README.md +42 -0
- package/extensions/answer.ts +601 -0
- package/extensions/branch-term.ts +405 -0
- package/extensions/btw.ts +444 -0
- package/extensions/ghostty.ts +301 -0
- package/extensions/git-diff-stats.ts +277 -0
- package/extensions/git-pr-status.ts +286 -0
- package/extensions/insights.ts +2367 -0
- package/extensions/interlude.ts +144 -0
- package/extensions/loop.ts +529 -0
- package/extensions/memory.ts +1889 -0
- package/extensions/notify.ts +161 -0
- package/extensions/openai-fast.ts +227 -0
- package/extensions/openai-verbosity.ts +223 -0
- package/extensions/review.ts +4347 -0
- package/extensions/sandbox/index.ts +2578 -0
- package/extensions/usage/anthropic.ts +198 -0
- package/extensions/usage/github-copilot.ts +204 -0
- package/extensions/usage/google-gemini-cli.ts +232 -0
- package/extensions/usage/index.ts +2310 -0
- package/extensions/usage/minimax.ts +208 -0
- package/extensions/usage/openai-codex.ts +180 -0
- package/extensions/usage/openrouter.ts +168 -0
- package/extensions/usage/providers.ts +26 -0
- package/extensions/usage/shared.ts +156 -0
- package/extensions/usage/types.ts +53 -0
- package/extensions/usage/zai.ts +186 -0
- package/extensions/websearch/README.md +68 -0
- package/extensions/websearch/browser/chromium.ts +330 -0
- package/extensions/websearch/browser/discovery.ts +75 -0
- package/extensions/websearch/browser/firefox.ts +150 -0
- package/extensions/websearch/browser/sqlite.ts +38 -0
- package/extensions/websearch/config.ts +76 -0
- package/extensions/websearch/index.ts +312 -0
- package/extensions/websearch/normalize.ts +155 -0
- package/extensions/websearch/providers/anthropic.pi.ts +139 -0
- package/extensions/websearch/providers/gemini.browser.ts +185 -0
- package/extensions/websearch/providers/gemini.pi.ts +108 -0
- package/extensions/websearch/providers/openai-codex.browser.ts +77 -0
- package/extensions/websearch/providers/openai-codex.pi.ts +49 -0
- package/extensions/websearch/providers/openai-codex.shared.ts +123 -0
- package/extensions/websearch/providers/pi-model.shared.ts +86 -0
- package/extensions/websearch/providers/search-prompt.shared.ts +17 -0
- package/extensions/websearch/providers/shared.ts +76 -0
- package/extensions/websearch/types.ts +52 -0
- package/extensions/worktree.ts +2223 -0
- package/package.json +72 -0
- package/skills/browser-tools/SKILL.md +253 -0
- package/skills/browser-tools/scripts/browser-content.js +100 -0
- package/skills/browser-tools/scripts/browser-cookies.js +33 -0
- package/skills/browser-tools/scripts/browser-dismiss-cookies.js +455 -0
- package/skills/browser-tools/scripts/browser-eval.js +75 -0
- package/skills/browser-tools/scripts/browser-logs-tail.js +89 -0
- package/skills/browser-tools/scripts/browser-nav.js +28 -0
- package/skills/browser-tools/scripts/browser-net-summary.js +115 -0
- package/skills/browser-tools/scripts/browser-pick.js +133 -0
- package/skills/browser-tools/scripts/browser-screenshot.js +18 -0
- package/skills/browser-tools/scripts/browser-start.js +281 -0
- package/skills/browser-tools/scripts/browser-watch.js +321 -0
- package/skills/browser-tools/scripts/utils.js +60 -0
- package/skills/git-clean-history/SKILL.md +45 -0
- package/skills/git-commit/SKILL.md +55 -0
- package/skills/oracle/SKILL.md +64 -0
- package/skills/oracle/scripts/oracle +249 -0
- package/skills/oracle/scripts/oracle-bundle +227 -0
- package/skills/sentry/SKILL.md +198 -0
- package/skills/sentry/lib/auth.js +99 -0
- package/skills/sentry/scripts/fetch-event.js +325 -0
- package/skills/sentry/scripts/fetch-issue.js +368 -0
- package/skills/sentry/scripts/list-issues.js +245 -0
- package/skills/sentry/scripts/search-events.js +291 -0
- package/skills/sentry/scripts/search-logs.js +234 -0
- package/skills/update-changelog/SKILL.md +135 -0
- package/skills/web-design/SKILL.md +117 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { BrowserCookie, BrowserSession, WebsearchResult } from "../types.js";
|
|
2
|
+
import {
|
|
3
|
+
browserHeaders,
|
|
4
|
+
buildCookieHeader,
|
|
5
|
+
dedupeSources,
|
|
6
|
+
extractMarkdownSources,
|
|
7
|
+
hasCookie,
|
|
8
|
+
} from "../normalize.js";
|
|
9
|
+
import { buildWebsearchPrompt } from "./search-prompt.shared.js";
|
|
10
|
+
import { fetchText, withTimeout } from "./shared.js";
|
|
11
|
+
|
|
12
|
+
const GEMINI_APP_URL = "https://gemini.google.com/app";
|
|
13
|
+
const GEMINI_STREAM_GENERATE_URL =
|
|
14
|
+
"https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
|
|
15
|
+
const GOOGLE_LIST_ACCOUNTS_URL =
|
|
16
|
+
"https://accounts.google.com/ListAccounts?gpsia=1&source=ChromiumBrowser&laf=b64bin&json=standard";
|
|
17
|
+
const REQUIRED_COOKIE_NAMES = ["__Secure-1PSID", "__Secure-1PSIDTS"];
|
|
18
|
+
|
|
19
|
+
export const browserGemini = {
|
|
20
|
+
backend: "gemini" as const,
|
|
21
|
+
domains: ["google.com"],
|
|
22
|
+
async search(
|
|
23
|
+
session: BrowserSession,
|
|
24
|
+
query: string,
|
|
25
|
+
signal?: AbortSignal,
|
|
26
|
+
): Promise<WebsearchResult> {
|
|
27
|
+
for (const cookieName of REQUIRED_COOKIE_NAMES) {
|
|
28
|
+
if (!hasCookie(GEMINI_APP_URL, session.cookies, cookieName)) {
|
|
29
|
+
throw new Error(`Missing required Google cookie: ${cookieName}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const accessToken = await fetchAccessToken(session.cookies, signal);
|
|
34
|
+
let accountLabel: string | null | undefined;
|
|
35
|
+
void getActiveGoogleEmail(session.cookies, signal)
|
|
36
|
+
.then((value) => {
|
|
37
|
+
accountLabel = value;
|
|
38
|
+
})
|
|
39
|
+
.catch(() => {
|
|
40
|
+
accountLabel = null;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const answer = await queryGemini(query, session.cookies, accessToken, signal);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
backend: "gemini",
|
|
47
|
+
authSource: session.profile.family,
|
|
48
|
+
browserName: session.profile.browserName,
|
|
49
|
+
profile: session.profile.profileName,
|
|
50
|
+
accountLabel: accountLabel ?? undefined,
|
|
51
|
+
answer,
|
|
52
|
+
sources: dedupeSources(extractMarkdownSources(answer)),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
async function queryGemini(
|
|
58
|
+
query: string,
|
|
59
|
+
cookies: BrowserCookie[],
|
|
60
|
+
accessToken: string,
|
|
61
|
+
signal?: AbortSignal,
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const body = new URLSearchParams();
|
|
64
|
+
body.set("at", accessToken);
|
|
65
|
+
body.set(
|
|
66
|
+
"f.req",
|
|
67
|
+
JSON.stringify([null, JSON.stringify([[buildWebsearchPrompt(query)], null, null])]),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const rawText = await fetchText(GEMINI_STREAM_GENERATE_URL, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
...browserHeaders({
|
|
74
|
+
cookieHeader: buildCookieHeader(GEMINI_STREAM_GENERATE_URL, cookies),
|
|
75
|
+
origin: "https://gemini.google.com",
|
|
76
|
+
referer: "https://gemini.google.com/",
|
|
77
|
+
contentType: "application/x-www-form-urlencoded;charset=utf-8",
|
|
78
|
+
}),
|
|
79
|
+
host: "gemini.google.com",
|
|
80
|
+
"x-same-domain": "1",
|
|
81
|
+
},
|
|
82
|
+
body: body.toString(),
|
|
83
|
+
signal: withTimeout(signal, 120_000),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const parsed = parseGeminiResponse(rawText);
|
|
87
|
+
if (!parsed.trim()) {
|
|
88
|
+
throw new Error("Gemini Web returned an empty response.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return parsed.trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function fetchAccessToken(cookies: BrowserCookie[], signal?: AbortSignal): Promise<string> {
|
|
95
|
+
const html = await fetchText(GEMINI_APP_URL, {
|
|
96
|
+
headers: browserHeaders({
|
|
97
|
+
cookieHeader: buildCookieHeader(GEMINI_APP_URL, cookies),
|
|
98
|
+
origin: "https://gemini.google.com",
|
|
99
|
+
referer: "https://gemini.google.com/",
|
|
100
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
101
|
+
}),
|
|
102
|
+
signal: withTimeout(signal, 20_000),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
for (const key of ["SNlM0e", "thykhd"]) {
|
|
106
|
+
const match = html.match(new RegExp(`"${key}":"([^\\"]*)"`));
|
|
107
|
+
if (match?.[1]) return match[1];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error("Could not authenticate with Gemini Web.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function getActiveGoogleEmail(
|
|
114
|
+
cookies: BrowserCookie[],
|
|
115
|
+
signal?: AbortSignal,
|
|
116
|
+
): Promise<string | null> {
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetchText(GOOGLE_LIST_ACCOUNTS_URL, {
|
|
119
|
+
headers: browserHeaders({
|
|
120
|
+
cookieHeader: buildCookieHeader(GOOGLE_LIST_ACCOUNTS_URL, cookies),
|
|
121
|
+
origin: "https://accounts.google.com",
|
|
122
|
+
referer: "https://accounts.google.com/",
|
|
123
|
+
}),
|
|
124
|
+
signal: withTimeout(signal, 10_000),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return findFirstEmail(response);
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseGeminiResponse(rawText: string): string {
|
|
134
|
+
const start = rawText.indexOf("[");
|
|
135
|
+
const end = rawText.lastIndexOf("]");
|
|
136
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
137
|
+
throw new Error("Gemini Web returned an invalid payload.");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const responseJson = JSON.parse(rawText.slice(start, end + 1));
|
|
141
|
+
const parts = Array.isArray(responseJson) ? responseJson : [];
|
|
142
|
+
|
|
143
|
+
for (const part of parts) {
|
|
144
|
+
const payload = getNestedValue(part, [2]);
|
|
145
|
+
if (typeof payload !== "string") continue;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(payload);
|
|
149
|
+
const candidateList = getNestedValue(parsed, [4]);
|
|
150
|
+
const firstCandidate = Array.isArray(candidateList) ? candidateList[0] : undefined;
|
|
151
|
+
const text = getNestedValue(firstCandidate, [1, 0]);
|
|
152
|
+
if (typeof text === "string" && text.trim().length > 0) {
|
|
153
|
+
return text;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const alternateText = getNestedValue(firstCandidate, [22, 0]);
|
|
157
|
+
if (typeof alternateText === "string" && alternateText.trim().length > 0) {
|
|
158
|
+
return alternateText;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Ignore non-message chunks.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
throw new Error("Gemini Web returned no assistant text.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getNestedValue(value: unknown, path: number[]): unknown {
|
|
169
|
+
let current: unknown = value;
|
|
170
|
+
for (const index of path) {
|
|
171
|
+
if (!Array.isArray(current)) return undefined;
|
|
172
|
+
current = current[index];
|
|
173
|
+
}
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findFirstEmail(value: string): string | null {
|
|
178
|
+
const normalized = value
|
|
179
|
+
.replace(/\\u0040/gi, "@")
|
|
180
|
+
.replace(/\\x40/gi, "@")
|
|
181
|
+
.replace(/@/gi, "@")
|
|
182
|
+
.replace(/@/gi, "@");
|
|
183
|
+
const match = normalized.match(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i);
|
|
184
|
+
return match?.[0] ?? null;
|
|
185
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
2
|
+
|
|
3
|
+
import { dedupeSources, extractMarkdownSources, normalizeSourceTitle } from "../normalize.js";
|
|
4
|
+
import type { WebsearchResult, WebsearchSource } from "../types.js";
|
|
5
|
+
import type { PiModelSelection } from "./pi-model.shared.js";
|
|
6
|
+
import { buildWebsearchPrompt } from "./search-prompt.shared.js";
|
|
7
|
+
import { fetchJson, withTimeout } from "./shared.js";
|
|
8
|
+
|
|
9
|
+
export async function searchWithPiGemini(
|
|
10
|
+
selection: PiModelSelection,
|
|
11
|
+
query: string,
|
|
12
|
+
signal?: AbortSignal,
|
|
13
|
+
): Promise<WebsearchResult> {
|
|
14
|
+
const interaction = await fetchJson<Record<string, unknown>>(
|
|
15
|
+
resolveGeminiInteractionsUrl(selection.model.baseUrl),
|
|
16
|
+
{
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: buildGeminiHeaders(selection),
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
model: selection.model.id,
|
|
21
|
+
input: buildWebsearchPrompt(query),
|
|
22
|
+
tools: [{ googleSearch: {} }],
|
|
23
|
+
}),
|
|
24
|
+
signal: withTimeout(signal, 120_000),
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const textOutputs = (Array.isArray(interaction.outputs) ? interaction.outputs : []).filter(
|
|
29
|
+
(output) => {
|
|
30
|
+
return Boolean(
|
|
31
|
+
output &&
|
|
32
|
+
typeof output === "object" &&
|
|
33
|
+
(output as { type?: unknown }).type === "text" &&
|
|
34
|
+
typeof (output as { text?: unknown }).text === "string",
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
) as Array<{ text: string; annotations?: Array<{ source?: string }> }>;
|
|
38
|
+
|
|
39
|
+
const answer = textOutputs
|
|
40
|
+
.map((output) => output.text)
|
|
41
|
+
.join("\n\n")
|
|
42
|
+
.trim();
|
|
43
|
+
|
|
44
|
+
if (!answer) {
|
|
45
|
+
throw new Error("Gemini API returned no text content.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const annotationSources = textOutputs.flatMap((output) =>
|
|
49
|
+
extractAnnotationSources(output as { annotations?: Array<{ source?: string }> }),
|
|
50
|
+
);
|
|
51
|
+
return {
|
|
52
|
+
backend: "gemini",
|
|
53
|
+
authSource: "pi",
|
|
54
|
+
answer,
|
|
55
|
+
sources: dedupeSources([...annotationSources, ...extractMarkdownSources(answer)]),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveGeminiInteractionsUrl(baseUrl?: string): string {
|
|
60
|
+
const normalized = String(baseUrl || "https://generativelanguage.googleapis.com/v1beta").replace(
|
|
61
|
+
/\/+$/,
|
|
62
|
+
"",
|
|
63
|
+
);
|
|
64
|
+
return normalized.endsWith("/interactions") ? normalized : `${normalized}/interactions`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildGeminiHeaders(selection: PiModelSelection): Record<string, string> {
|
|
68
|
+
const headers = { ...selection.headers };
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...headers,
|
|
72
|
+
...(hasHeader(headers, "x-goog-api-key") || !selection.apiKey
|
|
73
|
+
? {}
|
|
74
|
+
: { "x-goog-api-key": selection.apiKey }),
|
|
75
|
+
"content-type": "application/json",
|
|
76
|
+
accept: "application/json",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
81
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractAnnotationSources(output: {
|
|
85
|
+
annotations?: Array<{ source?: string; url?: string; title?: string }>;
|
|
86
|
+
}): WebsearchSource[] {
|
|
87
|
+
const sources: WebsearchSource[] = [];
|
|
88
|
+
|
|
89
|
+
for (const annotation of output.annotations ?? []) {
|
|
90
|
+
const url =
|
|
91
|
+
typeof annotation?.url === "string"
|
|
92
|
+
? annotation.url
|
|
93
|
+
: typeof annotation?.source === "string"
|
|
94
|
+
? annotation.source
|
|
95
|
+
: null;
|
|
96
|
+
if (!url || !/^https?:\/\//i.test(url)) continue;
|
|
97
|
+
sources.push({
|
|
98
|
+
title: normalizeSourceTitle(url, annotation.title),
|
|
99
|
+
url,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return sources;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isPiGeminiModel(model: Model<Api>): boolean {
|
|
107
|
+
return model.api === "google-generative-ai" && model.provider === "google";
|
|
108
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { BrowserSession, WebsearchResult } from "../types.js";
|
|
2
|
+
import { browserHeaders, buildCookieHeader } from "../normalize.js";
|
|
3
|
+
import { fetchJson, withTimeout } from "./shared.js";
|
|
4
|
+
import { decodeJwtAccountId, runOpenAICodexSearch } from "./openai-codex.shared.js";
|
|
5
|
+
|
|
6
|
+
const CHATGPT_BASE_URL = "https://chatgpt.com";
|
|
7
|
+
const SESSION_URL = `${CHATGPT_BASE_URL}/api/auth/session`;
|
|
8
|
+
const PREFERRED_MODELS = ["gpt-5.4", "gpt-5.3-codex-spark", "gpt-5.1-codex-mini"];
|
|
9
|
+
|
|
10
|
+
interface ChatGptSessionPayload {
|
|
11
|
+
accessToken?: string;
|
|
12
|
+
user?: {
|
|
13
|
+
email?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const browserOpenAICodex = {
|
|
19
|
+
backend: "openai-codex" as const,
|
|
20
|
+
domains: ["chatgpt.com"],
|
|
21
|
+
async search(
|
|
22
|
+
session: BrowserSession,
|
|
23
|
+
query: string,
|
|
24
|
+
signal?: AbortSignal,
|
|
25
|
+
): Promise<WebsearchResult> {
|
|
26
|
+
const cookieHeader = buildCookieHeader(SESSION_URL, session.cookies);
|
|
27
|
+
if (!cookieHeader) {
|
|
28
|
+
throw new Error("No ChatGPT session cookies found in browser profile.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const chatSession = await fetchJson<ChatGptSessionPayload>(SESSION_URL, {
|
|
32
|
+
headers: browserHeaders({
|
|
33
|
+
cookieHeader,
|
|
34
|
+
origin: CHATGPT_BASE_URL,
|
|
35
|
+
referer: `${CHATGPT_BASE_URL}/`,
|
|
36
|
+
}),
|
|
37
|
+
signal: withTimeout(signal, 20_000),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!chatSession.accessToken) {
|
|
41
|
+
throw new Error("No ChatGPT access token found in session.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await searchWithPreferredModel(chatSession.accessToken, query, signal);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
backend: "openai-codex",
|
|
48
|
+
authSource: session.profile.family,
|
|
49
|
+
browserName: session.profile.browserName,
|
|
50
|
+
profile: session.profile.profileName,
|
|
51
|
+
accountLabel: chatSession.user?.email ?? chatSession.user?.name,
|
|
52
|
+
answer: result.answer,
|
|
53
|
+
sources: result.sources,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function searchWithPreferredModel(accessToken: string, query: string, signal?: AbortSignal) {
|
|
59
|
+
let lastError: string | null = null;
|
|
60
|
+
|
|
61
|
+
for (const model of PREFERRED_MODELS) {
|
|
62
|
+
try {
|
|
63
|
+
return await runOpenAICodexSearch({
|
|
64
|
+
apiKey: accessToken,
|
|
65
|
+
accountId: decodeJwtAccountId(accessToken),
|
|
66
|
+
model,
|
|
67
|
+
query,
|
|
68
|
+
baseUrl: `${CHATGPT_BASE_URL}/backend-api`,
|
|
69
|
+
signal,
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw new Error(lastError ?? "ChatGPT Codex search failed.");
|
|
77
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
2
|
+
|
|
3
|
+
import type { WebsearchResult } from "../types.js";
|
|
4
|
+
import { decodeJwtAccountId, runOpenAICodexSearch } from "./openai-codex.shared.js";
|
|
5
|
+
import type { PiModelSelection } from "./pi-model.shared.js";
|
|
6
|
+
|
|
7
|
+
export async function searchWithPiOpenAICodex(
|
|
8
|
+
selection: PiModelSelection,
|
|
9
|
+
query: string,
|
|
10
|
+
signal?: AbortSignal,
|
|
11
|
+
): Promise<WebsearchResult> {
|
|
12
|
+
const apiKey = resolveApiKey(selection);
|
|
13
|
+
if (!apiKey) {
|
|
14
|
+
throw new Error("OpenAI Codex auth is not configured.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const result = await runOpenAICodexSearch({
|
|
18
|
+
apiKey,
|
|
19
|
+
accountId: decodeJwtAccountId(apiKey),
|
|
20
|
+
model: selection.model.id,
|
|
21
|
+
query,
|
|
22
|
+
baseUrl: selection.model.baseUrl,
|
|
23
|
+
headers: selection.headers,
|
|
24
|
+
signal,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
backend: "openai-codex",
|
|
29
|
+
authSource: "pi",
|
|
30
|
+
answer: result.answer,
|
|
31
|
+
sources: result.sources,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveApiKey(selection: PiModelSelection): string | undefined {
|
|
36
|
+
if (selection.apiKey) return selection.apiKey;
|
|
37
|
+
|
|
38
|
+
const authorization = Object.entries(selection.headers ?? {}).find(
|
|
39
|
+
([name]) => name.toLowerCase() === "authorization",
|
|
40
|
+
)?.[1];
|
|
41
|
+
if (!authorization) return undefined;
|
|
42
|
+
|
|
43
|
+
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
|
44
|
+
return match?.[1];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isPiOpenAICodexModel(model: Model<Api>): boolean {
|
|
48
|
+
return model.api === "openai-codex-responses" && model.provider === "openai-codex";
|
|
49
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { WebsearchSource } from "../types.js";
|
|
2
|
+
import { dedupeSources, extractMarkdownSources } from "../normalize.js";
|
|
3
|
+
import { buildWebsearchPrompt, WEBSEARCH_SYSTEM_PROMPT } from "./search-prompt.shared.js";
|
|
4
|
+
import { readEventStream, withTimeout } from "./shared.js";
|
|
5
|
+
|
|
6
|
+
export function decodeJwtAccountId(jwt: string | undefined): string | undefined {
|
|
7
|
+
if (!jwt || typeof jwt !== "string") return undefined;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const parts = jwt.split(".");
|
|
11
|
+
if (parts.length !== 3) return undefined;
|
|
12
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<
|
|
13
|
+
string,
|
|
14
|
+
unknown
|
|
15
|
+
>;
|
|
16
|
+
const auth = payload["https://api.openai.com/auth"];
|
|
17
|
+
return auth && typeof auth === "object"
|
|
18
|
+
? ((auth as Record<string, unknown>).chatgpt_account_id as string | undefined)
|
|
19
|
+
: undefined;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveCodexUrl(baseUrl = "https://chatgpt.com/backend-api"): string {
|
|
26
|
+
const normalized = String(baseUrl || "https://chatgpt.com/backend-api").replace(/\/+$/, "");
|
|
27
|
+
if (normalized.endsWith("/codex/responses")) return normalized;
|
|
28
|
+
if (normalized.endsWith("/codex")) return `${normalized}/responses`;
|
|
29
|
+
return `${normalized}/codex/responses`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runOpenAICodexSearch(options: {
|
|
33
|
+
apiKey: string;
|
|
34
|
+
model: string;
|
|
35
|
+
query: string;
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
accountId?: string;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}): Promise<{ answer: string; sources: WebsearchSource[] }> {
|
|
41
|
+
const response = await fetch(resolveCodexUrl(options.baseUrl), {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
...options.headers,
|
|
45
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
46
|
+
...(options.accountId ? { "chatgpt-account-id": options.accountId } : {}),
|
|
47
|
+
"content-type": "application/json",
|
|
48
|
+
accept: "text/event-stream",
|
|
49
|
+
"OpenAI-Beta": "responses=experimental",
|
|
50
|
+
originator: "pi-websearch",
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
model: options.model,
|
|
54
|
+
store: false,
|
|
55
|
+
stream: true,
|
|
56
|
+
instructions: WEBSEARCH_SYSTEM_PROMPT,
|
|
57
|
+
input: [{ role: "user", content: buildWebsearchPrompt(options.query) }],
|
|
58
|
+
tools: [{ type: "web_search" }],
|
|
59
|
+
tool_choice: "auto",
|
|
60
|
+
}),
|
|
61
|
+
signal: withTimeout(options.signal, 120_000),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let answer = "";
|
|
65
|
+
let fallbackAnswer = "";
|
|
66
|
+
|
|
67
|
+
await readEventStream(response, ({ data }) => {
|
|
68
|
+
if (!data.trim()) return;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const event = JSON.parse(data) as Record<string, unknown>;
|
|
72
|
+
|
|
73
|
+
if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
|
|
74
|
+
answer += event.delta;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (event.type === "response.output_item.done") {
|
|
78
|
+
const item = event.item as Record<string, unknown> | undefined;
|
|
79
|
+
const content = Array.isArray(item?.content) ? item.content : [];
|
|
80
|
+
const fullText = content
|
|
81
|
+
.filter(
|
|
82
|
+
(part) =>
|
|
83
|
+
part &&
|
|
84
|
+
typeof part === "object" &&
|
|
85
|
+
(part as Record<string, unknown>).type === "output_text",
|
|
86
|
+
)
|
|
87
|
+
.map((part) => (part as Record<string, unknown>).text)
|
|
88
|
+
.filter((text): text is string => typeof text === "string")
|
|
89
|
+
.join("\n");
|
|
90
|
+
if (fullText) fallbackAnswer = fullText;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (event.type === "response.failed" || event.type === "error") {
|
|
94
|
+
const failedResponse = event.response;
|
|
95
|
+
const eventMessage = typeof event.message === "string" ? event.message : undefined;
|
|
96
|
+
if (failedResponse && typeof failedResponse === "object") {
|
|
97
|
+
const error = (failedResponse as Record<string, unknown>).error;
|
|
98
|
+
if (
|
|
99
|
+
error &&
|
|
100
|
+
typeof error === "object" &&
|
|
101
|
+
typeof (error as Record<string, unknown>).message === "string"
|
|
102
|
+
) {
|
|
103
|
+
throw new Error((error as Record<string, unknown>).message as string);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
throw new Error(eventMessage ?? "OpenAI Codex search failed.");
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof SyntaxError) return;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const finalAnswer = (answer || fallbackAnswer).trim();
|
|
115
|
+
if (!finalAnswer) {
|
|
116
|
+
throw new Error("OpenAI Codex returned an empty response.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
answer: finalAnswer,
|
|
121
|
+
sources: dedupeSources(extractMarkdownSources(finalAnswer)),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
2
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export interface PiModelSelection {
|
|
5
|
+
model: Model<Api>;
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function selectCurrentPiModel(
|
|
11
|
+
ctx: ExtensionContext,
|
|
12
|
+
predicate: (model: Model<Api>) => boolean,
|
|
13
|
+
): Promise<PiModelSelection | null> {
|
|
14
|
+
if (!ctx.model || !predicate(ctx.model)) return null;
|
|
15
|
+
|
|
16
|
+
return resolvePiModelSelection(ctx.model, ctx);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function selectFallbackPiModel(
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
predicate: (model: Model<Api>) => boolean,
|
|
22
|
+
): Promise<PiModelSelection | null> {
|
|
23
|
+
const currentKey =
|
|
24
|
+
ctx.model && predicate(ctx.model) ? `${ctx.model.provider}:${ctx.model.id}` : null;
|
|
25
|
+
const candidates = rankPiModels(
|
|
26
|
+
ctx.modelRegistry.getAvailable().filter((model) => {
|
|
27
|
+
if (!predicate(model)) return false;
|
|
28
|
+
return `${model.provider}:${model.id}` !== currentKey;
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
for (const model of candidates) {
|
|
33
|
+
const selection = await resolvePiModelSelection(model, ctx);
|
|
34
|
+
if (selection) return selection;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function resolvePiModelSelection(
|
|
41
|
+
model: Model<Api>,
|
|
42
|
+
ctx: Pick<ExtensionContext, "modelRegistry">,
|
|
43
|
+
): Promise<PiModelSelection | null> {
|
|
44
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
45
|
+
return auth.ok ? { model, apiKey: auth.apiKey, headers: auth.headers } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rankPiModels(models: Model<Api>[]): Model<Api>[] {
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
|
|
51
|
+
return [...models]
|
|
52
|
+
.filter((model) => {
|
|
53
|
+
const key = `${model.provider}:${model.id}`;
|
|
54
|
+
if (seen.has(key)) return false;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
return true;
|
|
57
|
+
})
|
|
58
|
+
.sort(comparePiModels);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function comparePiModels(left: Model<Api>, right: Model<Api>): number {
|
|
62
|
+
return (
|
|
63
|
+
compareBoolean(Boolean(right.reasoning), Boolean(left.reasoning)) ||
|
|
64
|
+
compareNumber(right.contextWindow, left.contextWindow) ||
|
|
65
|
+
compareNumber(right.maxTokens, left.maxTokens) ||
|
|
66
|
+
compareNumber(modelCostScore(right), modelCostScore(left)) ||
|
|
67
|
+
right.id.localeCompare(left.id)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function modelCostScore(model: Model<Api>): number {
|
|
72
|
+
return (
|
|
73
|
+
(model.cost?.input ?? 0) +
|
|
74
|
+
(model.cost?.output ?? 0) +
|
|
75
|
+
(model.cost?.cacheRead ?? 0) +
|
|
76
|
+
(model.cost?.cacheWrite ?? 0)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function compareBoolean(left: boolean, right: boolean): number {
|
|
81
|
+
return Number(left) - Number(right);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compareNumber(left: number, right: number): number {
|
|
85
|
+
return left - right;
|
|
86
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const WEBSEARCH_SYSTEM_PROMPT =
|
|
2
|
+
"You are a web research assistant. Always use web search, produce practical summaries, and include full canonical URLs (no shortened links).";
|
|
3
|
+
|
|
4
|
+
export function buildWebsearchPrompt(query: string): string {
|
|
5
|
+
return `Search the internet for: ${query}
|
|
6
|
+
|
|
7
|
+
Requirements:
|
|
8
|
+
- Always use web search.
|
|
9
|
+
- Use current web information.
|
|
10
|
+
- Prefer primary or official sources.
|
|
11
|
+
- Open and read relevant source pages before answering.
|
|
12
|
+
- Base the answer on the content of the pages you read, not on search-result snippets alone.
|
|
13
|
+
- Be concise.
|
|
14
|
+
- Call out important source disagreements.
|
|
15
|
+
- Do not mention internal tools or implementation details.
|
|
16
|
+
- End with a Sources section that includes full canonical URLs.`;
|
|
17
|
+
}
|