gsd-pi 2.4.0 → 2.5.1
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/README.md +4 -3
- package/dist/loader.js +21 -3
- package/dist/logo.d.ts +3 -3
- package/dist/logo.js +2 -2
- package/package.json +2 -2
- package/src/resources/GSD-WORKFLOW.md +7 -7
- package/src/resources/extensions/get-secrets-from-user.ts +63 -8
- package/src/resources/extensions/gsd/auto.ts +123 -34
- package/src/resources/extensions/gsd/docs/preferences-reference.md +28 -0
- package/src/resources/extensions/gsd/files.ts +70 -0
- package/src/resources/extensions/gsd/git-service.ts +151 -11
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/guided-flow.ts +6 -3
- package/src/resources/extensions/gsd/preferences.ts +59 -0
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +8 -6
- package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
- package/src/resources/extensions/gsd/prompts/execute-task.md +3 -7
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
- package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
- package/src/resources/extensions/gsd/prompts/replan-slice.md +1 -1
- package/src/resources/extensions/gsd/templates/plan.md +8 -10
- package/src/resources/extensions/gsd/templates/preferences.md +7 -0
- package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
- package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
- package/src/resources/extensions/gsd/tests/git-service.test.ts +421 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +211 -65
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +0 -2
- package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
- package/src/resources/extensions/gsd/types.ts +20 -0
- package/src/resources/extensions/gsd/worktree-command.ts +48 -6
- package/src/resources/extensions/gsd/worktree.ts +40 -147
- package/src/resources/extensions/search-the-web/index.ts +16 -25
- package/src/resources/extensions/search-the-web/native-search.ts +157 -0
|
@@ -1,40 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GSD Slice Branch Management
|
|
2
|
+
* GSD Slice Branch Management — Thin Facade
|
|
3
3
|
*
|
|
4
4
|
* Simple branch-per-slice workflow. No worktrees, no registry.
|
|
5
5
|
* Runtime state (metrics, activity, lock, STATE.md) is gitignored
|
|
6
6
|
* so branch switches are clean.
|
|
7
7
|
*
|
|
8
|
+
* All git-mutation functions delegate to GitServiceImpl from git-service.ts.
|
|
9
|
+
* Pure utility functions (detectWorktreeName, getSliceBranchName, parseSliceBranch,
|
|
10
|
+
* SLICE_BRANCH_RE) remain standalone.
|
|
11
|
+
*
|
|
8
12
|
* Flow:
|
|
9
13
|
* 1. ensureSliceBranch() — create + checkout slice branch
|
|
10
14
|
* 2. agent does work, commits
|
|
11
15
|
* 3. mergeSliceToMain() — checkout main, squash-merge, delete branch
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
|
-
import { existsSync } from "node:fs";
|
|
15
|
-
import { execSync } from "node:child_process";
|
|
16
18
|
import { sep } from "node:path";
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
mergedCommitMessage: string;
|
|
21
|
-
deletedBranch: boolean;
|
|
22
|
-
}
|
|
20
|
+
import { GitServiceImpl } from "./git-service.ts";
|
|
21
|
+
import { loadEffectiveGSDPreferences } from "./preferences.ts";
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
23
|
+
// Re-export MergeSliceResult from the canonical source (D014 — type-only re-export)
|
|
24
|
+
export type { MergeSliceResult } from "./git-service.ts";
|
|
25
|
+
|
|
26
|
+
// ─── Lazy GitServiceImpl Cache ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
let cachedService: GitServiceImpl | null = null;
|
|
29
|
+
let cachedBasePath: string | null = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get or create a GitServiceImpl for the given basePath.
|
|
33
|
+
* Resets the cache if basePath changes between calls.
|
|
34
|
+
* Lazy construction: only instantiated at call-time, never at module-evaluation.
|
|
35
|
+
*/
|
|
36
|
+
function getService(basePath: string): GitServiceImpl {
|
|
37
|
+
if (cachedService === null || cachedBasePath !== basePath) {
|
|
38
|
+
const loaded = loadEffectiveGSDPreferences();
|
|
39
|
+
const gitPrefs = loaded?.preferences?.git ?? {};
|
|
40
|
+
cachedService = new GitServiceImpl(basePath, gitPrefs);
|
|
41
|
+
cachedBasePath = basePath;
|
|
35
42
|
}
|
|
43
|
+
return cachedService;
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
// ─── Pure Utility Functions (unchanged) ────────────────────────────────────
|
|
47
|
+
|
|
38
48
|
/**
|
|
39
49
|
* Detect the active worktree name from the current working directory.
|
|
40
50
|
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
|
|
@@ -86,6 +96,8 @@ export function parseSliceBranch(branchName: string): {
|
|
|
86
96
|
};
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
// ─── Git-Mutation Functions (delegate to GitServiceImpl) ───────────────────
|
|
100
|
+
|
|
89
101
|
/**
|
|
90
102
|
* Get the "main" branch for GSD slice operations.
|
|
91
103
|
*
|
|
@@ -98,46 +110,11 @@ export function parseSliceBranch(branchName: string): {
|
|
|
98
110
|
* /worktree merge.
|
|
99
111
|
*/
|
|
100
112
|
export function getMainBranch(basePath: string): string {
|
|
101
|
-
|
|
102
|
-
// own branch (worktree/<name>), not main — main is checked out by the
|
|
103
|
-
// parent working tree and git would refuse the checkout.
|
|
104
|
-
const wtName = detectWorktreeName(basePath);
|
|
105
|
-
if (wtName) {
|
|
106
|
-
const wtBranch = `worktree/${wtName}`;
|
|
107
|
-
// Verify the branch exists (it should — createWorktree made it)
|
|
108
|
-
const exists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${wtBranch}`], { allowFailure: true });
|
|
109
|
-
if (exists) return wtBranch;
|
|
110
|
-
// Worktree branch is gone — return current branch rather than falling
|
|
111
|
-
// through to main/master which would cause a checkout conflict
|
|
112
|
-
return runGit(basePath, ["branch", "--show-current"]);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true });
|
|
116
|
-
if (symbolic) {
|
|
117
|
-
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
|
|
118
|
-
if (match) return match[1]!;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const mainExists = runGit(basePath, ["show-ref", "--verify", "refs/heads/main"], { allowFailure: true });
|
|
122
|
-
if (mainExists) return "main";
|
|
123
|
-
|
|
124
|
-
const masterExists = runGit(basePath, ["show-ref", "--verify", "refs/heads/master"], { allowFailure: true });
|
|
125
|
-
if (masterExists) return "master";
|
|
126
|
-
|
|
127
|
-
return runGit(basePath, ["branch", "--show-current"]);
|
|
113
|
+
return getService(basePath).getMainBranch();
|
|
128
114
|
}
|
|
129
115
|
|
|
130
116
|
export function getCurrentBranch(basePath: string): string {
|
|
131
|
-
return
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function branchExists(basePath: string, branch: string): boolean {
|
|
135
|
-
try {
|
|
136
|
-
runGit(basePath, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
137
|
-
return true;
|
|
138
|
-
} catch {
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
117
|
+
return getService(basePath).getCurrentBranch();
|
|
141
118
|
}
|
|
142
119
|
|
|
143
120
|
/**
|
|
@@ -150,49 +127,7 @@ function branchExists(basePath: string, branch: string): boolean {
|
|
|
150
127
|
* Returns true if the branch was newly created.
|
|
151
128
|
*/
|
|
152
129
|
export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId: string): boolean {
|
|
153
|
-
|
|
154
|
-
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
|
155
|
-
const current = getCurrentBranch(basePath);
|
|
156
|
-
|
|
157
|
-
if (current === branch) return false;
|
|
158
|
-
|
|
159
|
-
let created = false;
|
|
160
|
-
|
|
161
|
-
if (!branchExists(basePath, branch)) {
|
|
162
|
-
// Branch from the current branch when it's a normal working branch
|
|
163
|
-
// (not itself a slice branch). This ensures the new slice branch
|
|
164
|
-
// inherits planning artifacts that may only exist on the working
|
|
165
|
-
// branch and haven't been merged to main yet.
|
|
166
|
-
// If we're already on a slice branch (e.g. creating S02 while S01
|
|
167
|
-
// wasn't merged yet), fall back to main to avoid chaining slice branches.
|
|
168
|
-
const mainBranch = getMainBranch(basePath);
|
|
169
|
-
const base = SLICE_BRANCH_RE.test(current) ? mainBranch : current;
|
|
170
|
-
runGit(basePath, ["branch", branch, base]);
|
|
171
|
-
created = true;
|
|
172
|
-
} else {
|
|
173
|
-
// Check if the branch is already checked out in another worktree
|
|
174
|
-
const worktreeList = runGit(basePath, ["worktree", "list", "--porcelain"]);
|
|
175
|
-
if (worktreeList.includes(`branch refs/heads/${branch}`)) {
|
|
176
|
-
throw new Error(
|
|
177
|
-
`Branch "${branch}" is already in use by another worktree. ` +
|
|
178
|
-
`Remove that worktree first, or switch it to a different branch.`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Auto-commit dirty files before checkout to prevent "would be overwritten" errors.
|
|
184
|
-
// This handles cases where doctor, STATE.md rebuild, or agent work left uncommitted changes.
|
|
185
|
-
const status = runGit(basePath, ["status", "--short"]);
|
|
186
|
-
if (status.trim()) {
|
|
187
|
-
runGit(basePath, ["add", "-A"]);
|
|
188
|
-
const staged = runGit(basePath, ["diff", "--cached", "--stat"]);
|
|
189
|
-
if (staged.trim()) {
|
|
190
|
-
runGit(basePath, ["commit", "-m", `"chore: auto-commit before switching to ${branch}"`]);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
runGit(basePath, ["checkout", branch]);
|
|
195
|
-
return created;
|
|
130
|
+
return getService(basePath).ensureSliceBranch(milestoneId, sliceId);
|
|
196
131
|
}
|
|
197
132
|
|
|
198
133
|
/**
|
|
@@ -202,31 +137,14 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId
|
|
|
202
137
|
export function autoCommitCurrentBranch(
|
|
203
138
|
basePath: string, unitType: string, unitId: string,
|
|
204
139
|
): string | null {
|
|
205
|
-
|
|
206
|
-
if (!status.trim()) return null;
|
|
207
|
-
|
|
208
|
-
runGit(basePath, ["add", "-A"]);
|
|
209
|
-
|
|
210
|
-
const staged = runGit(basePath, ["diff", "--cached", "--stat"]);
|
|
211
|
-
if (!staged.trim()) return null;
|
|
212
|
-
|
|
213
|
-
const message = `chore(${unitId}): auto-commit after ${unitType}`;
|
|
214
|
-
runGit(basePath, ["commit", "-m", JSON.stringify(message)]);
|
|
215
|
-
return message;
|
|
140
|
+
return getService(basePath).autoCommit(unitType, unitId);
|
|
216
141
|
}
|
|
217
142
|
|
|
218
143
|
/**
|
|
219
144
|
* Switch to main, auto-committing any dirty files on the current branch first.
|
|
220
145
|
*/
|
|
221
146
|
export function switchToMain(basePath: string): void {
|
|
222
|
-
|
|
223
|
-
const current = getCurrentBranch(basePath);
|
|
224
|
-
if (current === mainBranch) return;
|
|
225
|
-
|
|
226
|
-
// Auto-commit if dirty
|
|
227
|
-
autoCommitCurrentBranch(basePath, "pre-switch", current);
|
|
228
|
-
|
|
229
|
-
runGit(basePath, ["checkout", mainBranch]);
|
|
147
|
+
getService(basePath).switchToMain();
|
|
230
148
|
}
|
|
231
149
|
|
|
232
150
|
/**
|
|
@@ -236,37 +154,12 @@ export function switchToMain(basePath: string): void {
|
|
|
236
154
|
*/
|
|
237
155
|
export function mergeSliceToMain(
|
|
238
156
|
basePath: string, milestoneId: string, sliceId: string, sliceTitle: string,
|
|
239
|
-
): MergeSliceResult {
|
|
240
|
-
|
|
241
|
-
const branch = getSliceBranchName(milestoneId, sliceId, wtName);
|
|
242
|
-
const mainBranch = getMainBranch(basePath);
|
|
243
|
-
|
|
244
|
-
const current = getCurrentBranch(basePath);
|
|
245
|
-
if (current !== mainBranch) {
|
|
246
|
-
throw new Error(`Expected to be on ${mainBranch}, found ${current}`);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (!branchExists(basePath, branch)) {
|
|
250
|
-
throw new Error(`Slice branch ${branch} does not exist`);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const ahead = runGit(basePath, ["rev-list", "--count", `${mainBranch}..${branch}`]);
|
|
254
|
-
if (Number(ahead) <= 0) {
|
|
255
|
-
throw new Error(`Slice branch ${branch} has no commits ahead of ${mainBranch}`);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
runGit(basePath, ["merge", "--squash", branch]);
|
|
259
|
-
const mergedCommitMessage = `feat(${milestoneId}/${sliceId}): ${sliceTitle}`;
|
|
260
|
-
runGit(basePath, ["commit", "-m", JSON.stringify(mergedCommitMessage)]);
|
|
261
|
-
runGit(basePath, ["branch", "-D", branch]);
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
branch,
|
|
265
|
-
mergedCommitMessage,
|
|
266
|
-
deletedBranch: true,
|
|
267
|
-
};
|
|
157
|
+
): import("./git-service.ts").MergeSliceResult {
|
|
158
|
+
return getService(basePath).mergeSliceToMain(milestoneId, sliceId, sliceTitle);
|
|
268
159
|
}
|
|
269
160
|
|
|
161
|
+
// ─── Query Functions (delegate to GitServiceImpl) ──────────────────────────
|
|
162
|
+
|
|
270
163
|
/**
|
|
271
164
|
* Check if we're currently on a slice branch (not main).
|
|
272
165
|
* Handles both plain (gsd/M001/S01) and worktree-namespaced (gsd/wt/M001/S01) branches.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Web Search Extension
|
|
2
|
+
* Web Search Extension v4
|
|
3
3
|
*
|
|
4
4
|
* Provides three tools for grounding the agent in real-world web content:
|
|
5
5
|
*
|
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
* Returns pre-extracted, relevance-scored page content.
|
|
16
16
|
* Best when you need content, not just links.
|
|
17
17
|
*
|
|
18
|
+
* v4: Native Anthropic web search
|
|
19
|
+
* - When using an Anthropic provider, injects the native `web_search_20250305`
|
|
20
|
+
* server-side tool via `before_provider_request`. This eliminates the need for
|
|
21
|
+
* a BRAVE_API_KEY when using Anthropic models — search is billed through the
|
|
22
|
+
* existing Anthropic API key ($0.01/search).
|
|
23
|
+
* - Custom Brave-based tools (search-the-web, search_and_read) are disabled when
|
|
24
|
+
* Anthropic + no BRAVE_API_KEY to avoid confusing the LLM with broken tools.
|
|
25
|
+
* - fetch_page (Jina) remains available — it works without a key at lower rate limits.
|
|
26
|
+
*
|
|
18
27
|
* v3 improvements over v2:
|
|
19
28
|
* - search_and_read: New tool — Brave LLM Context API (search + read in one call)
|
|
20
29
|
* - Structured error taxonomy: auth_error, rate_limited, network_error, etc.
|
|
@@ -30,7 +39,8 @@
|
|
|
30
39
|
* - Cache timer cleanup: purge timers use unref() to not block process exit
|
|
31
40
|
*
|
|
32
41
|
* Environment variables:
|
|
33
|
-
* BRAVE_API_KEY —
|
|
42
|
+
* BRAVE_API_KEY — Optional with Anthropic models (built-in search available).
|
|
43
|
+
* Required for non-Anthropic providers. Get one at brave.com/search/api
|
|
34
44
|
* JINA_API_KEY — Optional. Higher rate limits for page extraction.
|
|
35
45
|
*/
|
|
36
46
|
|
|
@@ -39,36 +49,17 @@ import { registerSearchTool } from "./tool-search";
|
|
|
39
49
|
import { registerFetchPageTool } from "./tool-fetch-page";
|
|
40
50
|
import { registerLLMContextTool } from "./tool-llm-context";
|
|
41
51
|
import { registerSearchProviderCommand } from "./command-search-provider.ts";
|
|
52
|
+
import { registerNativeSearchHooks } from "./native-search";
|
|
42
53
|
|
|
43
54
|
export default function (pi: ExtensionAPI) {
|
|
44
|
-
// Register all tools
|
|
45
55
|
registerSearchTool(pi);
|
|
46
56
|
registerFetchPageTool(pi);
|
|
47
57
|
registerLLMContextTool(pi);
|
|
48
58
|
|
|
59
|
+
|
|
49
60
|
// Register slash commands
|
|
50
61
|
registerSearchProviderCommand(pi);
|
|
51
62
|
|
|
52
|
-
//
|
|
53
|
-
pi
|
|
54
|
-
const hasBrave = !!process.env.BRAVE_API_KEY;
|
|
55
|
-
const hasTavily = !!process.env.TAVILY_API_KEY;
|
|
56
|
-
const hasJina = !!process.env.JINA_API_KEY;
|
|
57
|
-
const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
|
|
58
|
-
|
|
59
|
-
if (!hasBrave && !hasTavily) {
|
|
60
|
-
ctx.ui.notify(
|
|
61
|
-
"Web search: Set BRAVE_API_KEY or TAVILY_API_KEY for web search capability",
|
|
62
|
-
"warning"
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const parts: string[] = ["Web search v3"];
|
|
67
|
-
if (hasTavily) parts.push("Tavily ✓");
|
|
68
|
-
if (hasBrave) parts.push("Search ✓");
|
|
69
|
-
if (hasAnswers) parts.push("Answers ✓");
|
|
70
|
-
if (hasJina) parts.push("Jina ✓");
|
|
71
|
-
|
|
72
|
-
ctx.ui.notify(parts.join(" · "), "info");
|
|
73
|
-
});
|
|
63
|
+
// Register native Anthropic web search hooks
|
|
64
|
+
registerNativeSearchHooks(pi);
|
|
74
65
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Anthropic web search hook logic.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.ts so it can be unit-tested without importing
|
|
5
|
+
* the heavy tool-registration modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Tool names for the Brave-backed custom search tools */
|
|
9
|
+
export const BRAVE_TOOL_NAMES = ["search-the-web", "search_and_read"];
|
|
10
|
+
|
|
11
|
+
/** Thinking block types that require signature validation by the API */
|
|
12
|
+
const THINKING_TYPES = new Set(["thinking", "redacted_thinking"]);
|
|
13
|
+
|
|
14
|
+
/** Minimal interface matching the subset of ExtensionAPI we use */
|
|
15
|
+
export interface NativeSearchPI {
|
|
16
|
+
on(event: string, handler: (...args: any[]) => any): void;
|
|
17
|
+
getActiveTools(): string[];
|
|
18
|
+
setActiveTools(tools: string[]): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Strip thinking/redacted_thinking blocks from assistant messages in the
|
|
23
|
+
* conversation history.
|
|
24
|
+
*
|
|
25
|
+
* Why: The Pi SDK's streaming parser drops `server_tool_use` and
|
|
26
|
+
* `web_search_tool_result` content blocks (unknown types). When the
|
|
27
|
+
* conversation is replayed, the assistant messages are incomplete — missing
|
|
28
|
+
* those blocks. The Anthropic API detects the modification and rejects the
|
|
29
|
+
* request with "thinking blocks cannot be modified."
|
|
30
|
+
*
|
|
31
|
+
* Fix: Remove thinking blocks from all assistant messages in the history.
|
|
32
|
+
* In Anthropic's Messages API, the messages array always ends with a user
|
|
33
|
+
* message, so every assistant message is from a previous turn that has been
|
|
34
|
+
* through a store/replay cycle. The model generates fresh thinking for the
|
|
35
|
+
* current turn regardless.
|
|
36
|
+
*/
|
|
37
|
+
export function stripThinkingFromHistory(
|
|
38
|
+
messages: Array<Record<string, unknown>>
|
|
39
|
+
): void {
|
|
40
|
+
for (const msg of messages) {
|
|
41
|
+
if (msg.role !== "assistant") continue;
|
|
42
|
+
|
|
43
|
+
const content = msg.content;
|
|
44
|
+
if (!Array.isArray(content)) continue;
|
|
45
|
+
|
|
46
|
+
msg.content = content.filter(
|
|
47
|
+
(block: any) => !THINKING_TYPES.has(block?.type)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Register model_select, before_provider_request, and session_start hooks
|
|
54
|
+
* for native Anthropic web search injection.
|
|
55
|
+
*
|
|
56
|
+
* Returns the isAnthropicProvider getter for testing.
|
|
57
|
+
*/
|
|
58
|
+
export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: () => boolean } {
|
|
59
|
+
let isAnthropicProvider = false;
|
|
60
|
+
|
|
61
|
+
// Track provider changes via model selection — also handles diagnostics
|
|
62
|
+
// since model_select fires AFTER session_start and knows the provider.
|
|
63
|
+
pi.on("model_select", async (event: any, ctx: any) => {
|
|
64
|
+
const wasAnthropic = isAnthropicProvider;
|
|
65
|
+
isAnthropicProvider = event.model.provider === "anthropic";
|
|
66
|
+
|
|
67
|
+
const hasBrave = !!process.env.BRAVE_API_KEY;
|
|
68
|
+
|
|
69
|
+
// When Anthropic + no Brave key: disable custom search tools (they'd fail)
|
|
70
|
+
if (isAnthropicProvider && !hasBrave) {
|
|
71
|
+
const active = pi.getActiveTools();
|
|
72
|
+
pi.setActiveTools(
|
|
73
|
+
active.filter((t: string) => !BRAVE_TOOL_NAMES.includes(t))
|
|
74
|
+
);
|
|
75
|
+
} else if (!isAnthropicProvider && wasAnthropic && !hasBrave) {
|
|
76
|
+
// Switching away from Anthropic without Brave — re-enable so the user
|
|
77
|
+
// sees the "missing key" error rather than tools silently vanishing.
|
|
78
|
+
// Only add tools not already active to avoid duplicates on repeated toggles.
|
|
79
|
+
const active = pi.getActiveTools();
|
|
80
|
+
const toAdd = BRAVE_TOOL_NAMES.filter((t) => !active.includes(t));
|
|
81
|
+
if (toAdd.length > 0) {
|
|
82
|
+
pi.setActiveTools([...active, ...toAdd]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Show provider-aware diagnostics on first selection or provider change
|
|
87
|
+
if (isAnthropicProvider && !wasAnthropic && event.source !== "restore") {
|
|
88
|
+
ctx.ui.notify("Native Anthropic web search active", "info");
|
|
89
|
+
} else if (!isAnthropicProvider && !hasBrave) {
|
|
90
|
+
ctx.ui.notify(
|
|
91
|
+
"Web search: Set BRAVE_API_KEY or use an Anthropic model for built-in search",
|
|
92
|
+
"warning"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Inject native web search into Anthropic API requests
|
|
98
|
+
pi.on("before_provider_request", (event: any) => {
|
|
99
|
+
const payload = event.payload as Record<string, unknown>;
|
|
100
|
+
if (!payload) return;
|
|
101
|
+
|
|
102
|
+
// Detect Anthropic by model name prefix (works even before model_select fires)
|
|
103
|
+
const model = payload.model as string | undefined;
|
|
104
|
+
if (!model || !model.startsWith("claude")) return;
|
|
105
|
+
|
|
106
|
+
// Keep provider tracking in sync
|
|
107
|
+
isAnthropicProvider = true;
|
|
108
|
+
|
|
109
|
+
// Strip thinking blocks from history to avoid signature validation errors
|
|
110
|
+
// caused by the SDK dropping server_tool_use/web_search_tool_result blocks.
|
|
111
|
+
const messages = payload.messages as Array<Record<string, unknown>> | undefined;
|
|
112
|
+
if (Array.isArray(messages)) {
|
|
113
|
+
stripThinkingFromHistory(messages);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!Array.isArray(payload.tools)) payload.tools = [];
|
|
117
|
+
|
|
118
|
+
let tools = payload.tools as Array<Record<string, unknown>>;
|
|
119
|
+
|
|
120
|
+
// Don't double-inject if already present
|
|
121
|
+
if (tools.some((t) => t.type === "web_search_20250305")) return;
|
|
122
|
+
|
|
123
|
+
// When no Brave key, remove Brave-based search tool definitions from the
|
|
124
|
+
// payload so Claude doesn't see (and try to call) broken tools.
|
|
125
|
+
// This is more reliable than setActiveTools since model_select may not fire.
|
|
126
|
+
const hasBrave = !!process.env.BRAVE_API_KEY;
|
|
127
|
+
if (!hasBrave) {
|
|
128
|
+
tools = tools.filter(
|
|
129
|
+
(t) => !BRAVE_TOOL_NAMES.includes(t.name as string)
|
|
130
|
+
);
|
|
131
|
+
payload.tools = tools;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
tools.push({
|
|
135
|
+
type: "web_search_20250305",
|
|
136
|
+
name: "web_search",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return payload;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Basic startup diagnostics — provider-specific info comes from model_select
|
|
143
|
+
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
144
|
+
const hasBrave = !!process.env.BRAVE_API_KEY;
|
|
145
|
+
const hasJina = !!process.env.JINA_API_KEY;
|
|
146
|
+
const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
|
|
147
|
+
|
|
148
|
+
const parts: string[] = ["Web search v4 loaded"];
|
|
149
|
+
if (hasBrave) parts.push("Brave ✓");
|
|
150
|
+
if (hasAnswers) parts.push("Answers ✓");
|
|
151
|
+
if (hasJina) parts.push("Jina ✓");
|
|
152
|
+
|
|
153
|
+
ctx.ui.notify(parts.join(" · "), "info");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return { getIsAnthropic: () => isAnthropicProvider };
|
|
157
|
+
}
|