gsd-pi 2.11.0 → 2.13.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.
Files changed (165) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/onboarding.js +3 -0
  3. package/dist/resource-loader.d.ts +2 -0
  4. package/dist/resource-loader.js +36 -1
  5. package/dist/resources/extensions/bg-shell/index.ts +51 -7
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
  7. package/dist/resources/extensions/gsd/auto.ts +381 -13
  8. package/dist/resources/extensions/gsd/commands.ts +9 -3
  9. package/dist/resources/extensions/gsd/doctor.ts +254 -3
  10. package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
  11. package/dist/resources/extensions/gsd/git-service.ts +11 -0
  12. package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
  13. package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  14. package/dist/resources/extensions/gsd/preferences.ts +209 -1
  15. package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
  16. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  17. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  18. package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
  19. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
  20. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  21. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  22. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  23. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  24. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  25. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  26. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  27. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  28. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  29. package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
  30. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  31. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  32. package/dist/resources/extensions/gsd/prompts/system.md +32 -29
  33. package/dist/resources/extensions/gsd/templates/context.md +1 -1
  34. package/dist/resources/extensions/gsd/templates/state.md +3 -3
  35. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  36. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  37. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  38. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  39. package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  40. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  41. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  42. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  43. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  44. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  45. package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  46. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  47. package/dist/resources/extensions/gsd/types.ts +109 -0
  48. package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
  49. package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  50. package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
  51. package/dist/resources/extensions/search-the-web/provider.ts +19 -2
  52. package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  53. package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  54. package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
  55. package/dist/wizard.js +1 -0
  56. package/package.json +1 -1
  57. package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
  58. package/packages/pi-agent-core/dist/agent-loop.js +169 -55
  59. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  60. package/packages/pi-agent-core/dist/agent.d.ts +13 -1
  61. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  62. package/packages/pi-agent-core/dist/agent.js +16 -0
  63. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  64. package/packages/pi-agent-core/dist/types.d.ts +91 -1
  65. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  66. package/packages/pi-agent-core/dist/types.js.map +1 -1
  67. package/packages/pi-agent-core/src/agent-loop.ts +273 -63
  68. package/packages/pi-agent-core/src/agent.ts +24 -0
  69. package/packages/pi-agent-core/src/types.ts +98 -0
  70. package/packages/pi-ai/dist/env-api-keys.js +1 -0
  71. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  72. package/packages/pi-ai/dist/models.generated.d.ts +314 -0
  73. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  74. package/packages/pi-ai/dist/models.generated.js +236 -0
  75. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  76. package/packages/pi-ai/dist/types.d.ts +1 -1
  77. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  78. package/packages/pi-ai/dist/types.js.map +1 -1
  79. package/packages/pi-ai/src/env-api-keys.ts +1 -0
  80. package/packages/pi-ai/src/models.generated.ts +236 -0
  81. package/packages/pi-ai/src/types.ts +2 -1
  82. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  83. package/packages/pi-coding-agent/dist/cli/args.js +2 -1
  84. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  85. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
  86. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
  88. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
  90. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
  92. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
  94. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  96. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  97. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  98. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  99. package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
  100. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  101. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  102. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
  103. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  104. package/packages/pi-coding-agent/src/cli/args.ts +2 -1
  105. package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
  106. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
  107. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  108. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  109. package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
  110. package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
  111. package/packages/pi-tui/dist/components/editor.d.ts +11 -0
  112. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  113. package/packages/pi-tui/dist/components/editor.js +64 -6
  114. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  115. package/packages/pi-tui/src/components/editor.ts +71 -6
  116. package/src/resources/extensions/bg-shell/index.ts +51 -7
  117. package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
  118. package/src/resources/extensions/gsd/auto.ts +381 -13
  119. package/src/resources/extensions/gsd/commands.ts +9 -3
  120. package/src/resources/extensions/gsd/doctor.ts +254 -3
  121. package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
  122. package/src/resources/extensions/gsd/git-service.ts +11 -0
  123. package/src/resources/extensions/gsd/guided-flow.ts +81 -9
  124. package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  125. package/src/resources/extensions/gsd/preferences.ts +209 -1
  126. package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
  127. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  128. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  129. package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
  130. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
  131. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  132. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  133. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  134. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  135. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  136. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  137. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  138. package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  139. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  140. package/src/resources/extensions/gsd/prompts/queue.md +3 -1
  141. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  142. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  143. package/src/resources/extensions/gsd/prompts/system.md +32 -29
  144. package/src/resources/extensions/gsd/templates/context.md +1 -1
  145. package/src/resources/extensions/gsd/templates/state.md +3 -3
  146. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  147. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  148. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  149. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  150. package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  151. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  152. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  153. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  154. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  155. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  156. package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  157. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  158. package/src/resources/extensions/gsd/types.ts +109 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
  160. package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  161. package/src/resources/extensions/search-the-web/native-search.ts +15 -10
  162. package/src/resources/extensions/search-the-web/provider.ts +19 -2
  163. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  164. package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  165. package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
@@ -185,3 +185,112 @@ export interface GSDState {
185
185
  tasks?: { done: number; total: number };
186
186
  };
187
187
  }
188
+
189
+ // ─── Post-Unit Hook Types ─────────────────────────────────────────────────
190
+
191
+ export interface PostUnitHookConfig {
192
+ /** Unique hook identifier — used in idempotency keys and logging. */
193
+ name: string;
194
+ /** Unit types that trigger this hook (e.g., ["execute-task"]). */
195
+ after: string[];
196
+ /** Prompt sent to the LLM session. Supports {milestoneId}, {sliceId}, {taskId} substitutions. */
197
+ prompt: string;
198
+ /** Max times this hook can fire for the same trigger unit. Default 1, max 10. */
199
+ max_cycles?: number;
200
+ /** Model override for hook sessions. */
201
+ model?: string;
202
+ /** Expected output file name (relative to task/slice dir). Used for idempotency — skip if exists. */
203
+ artifact?: string;
204
+ /** If this file is produced instead of artifact, re-run the trigger unit then re-run hooks. */
205
+ retry_on?: string;
206
+ /** Agent definition file to use. */
207
+ agent?: string;
208
+ /** Set false to disable without removing config. Default true. */
209
+ enabled?: boolean;
210
+ }
211
+
212
+ export interface HookExecutionState {
213
+ /** Hook name. */
214
+ hookName: string;
215
+ /** The unit type that triggered this hook. */
216
+ triggerUnitType: string;
217
+ /** The unit ID that triggered this hook. */
218
+ triggerUnitId: string;
219
+ /** Current cycle (1-based). */
220
+ cycle: number;
221
+ /** Whether the hook completed with a retry signal (retry_on artifact found). */
222
+ pendingRetry: boolean;
223
+ }
224
+
225
+ export interface HookDispatchResult {
226
+ /** Hook name for display. */
227
+ hookName: string;
228
+ /** The prompt to send. */
229
+ prompt: string;
230
+ /** Model override, if configured. */
231
+ model?: string;
232
+ /** Synthetic unit type, e.g. "hook/code-review". */
233
+ unitType: string;
234
+ /** The trigger unit's ID, reused for the hook. */
235
+ unitId: string;
236
+ }
237
+
238
+ // ─── Pre-Dispatch Hook Types ──────────────────────────────────────────────
239
+
240
+ export interface PreDispatchHookConfig {
241
+ /** Unique hook identifier. */
242
+ name: string;
243
+ /** Unit types this hook intercepts before dispatch (e.g., ["execute-task"]). */
244
+ before: string[];
245
+ /** Action to take: "modify" mutates the prompt, "skip" skips the unit, "replace" swaps it. */
246
+ action: 'modify' | 'skip' | 'replace';
247
+ /** For "modify": text prepended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */
248
+ prepend?: string;
249
+ /** For "modify": text appended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */
250
+ append?: string;
251
+ /** For "replace": the replacement prompt. Supports {milestoneId}, {sliceId}, {taskId}. */
252
+ prompt?: string;
253
+ /** For "replace": override the unit type label. */
254
+ unit_type?: string;
255
+ /** For "skip": optional condition file — only skip if this file exists (relative to unit dir). */
256
+ skip_if?: string;
257
+ /** Model override when this hook fires. */
258
+ model?: string;
259
+ /** Set false to disable without removing config. Default true. */
260
+ enabled?: boolean;
261
+ }
262
+
263
+ export interface PreDispatchResult {
264
+ /** What happened: the unit proceeds with modifications, was skipped, or was replaced. */
265
+ action: 'proceed' | 'skip' | 'replace';
266
+ /** Modified/replacement prompt (for "proceed" and "replace"). */
267
+ prompt?: string;
268
+ /** Override unit type (for "replace"). */
269
+ unitType?: string;
270
+ /** Model override. */
271
+ model?: string;
272
+ /** Names of hooks that fired, for logging. */
273
+ firedHooks: string[];
274
+ }
275
+
276
+ // ─── Hook State Persistence Types ─────────────────────────────────────────
277
+
278
+ export interface PersistedHookState {
279
+ /** Cycle counts keyed as "hookName/triggerUnitType/triggerUnitId". */
280
+ cycleCounts: Record<string, number>;
281
+ /** Timestamp of last state save. */
282
+ savedAt: string;
283
+ }
284
+
285
+ export interface HookStatusEntry {
286
+ /** Hook name. */
287
+ name: string;
288
+ /** Hook type: "post" or "pre". */
289
+ type: 'post' | 'pre';
290
+ /** Whether hook is enabled. */
291
+ enabled: boolean;
292
+ /** What unit types it targets. */
293
+ targets: string[];
294
+ /** Current cycle counts for active triggers. */
295
+ activeCycles: Record<string, number>;
296
+ }
@@ -120,15 +120,17 @@ export function worktreeBranchName(name: string): string {
120
120
  /**
121
121
  * Create a new git worktree under .gsd/worktrees/<name>/ with branch worktree/<name>.
122
122
  * The branch is created from the current HEAD of the main branch.
123
+ *
124
+ * @param opts.branch — override the default `worktree/<name>` branch name
123
125
  */
124
- export function createWorktree(basePath: string, name: string): WorktreeInfo {
126
+ export function createWorktree(basePath: string, name: string, opts: { branch?: string } = {}): WorktreeInfo {
125
127
  // Validate name: alphanumeric, hyphens, underscores only
126
128
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
127
129
  throw new Error(`Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`);
128
130
  }
129
131
 
130
132
  const wtPath = worktreePath(basePath, name);
131
- const branch = worktreeBranchName(name);
133
+ const branch = opts.branch ?? worktreeBranchName(name);
132
134
 
133
135
  if (existsSync(wtPath)) {
134
136
  throw new Error(`Worktree "${name}" already exists at ${wtPath}`);
@@ -260,11 +262,11 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
260
262
  export function removeWorktree(
261
263
  basePath: string,
262
264
  name: string,
263
- opts: { deleteBranch?: boolean; force?: boolean } = {},
265
+ opts: { deleteBranch?: boolean; force?: boolean; branch?: string } = {},
264
266
  ): void {
265
267
  const wtPath = worktreePath(basePath, name);
266
268
  const resolvedWtPath = existsSync(wtPath) ? realpathSync(wtPath) : wtPath;
267
- const branch = worktreeBranchName(name);
269
+ const branch = opts.branch ?? worktreeBranchName(name);
268
270
  const { deleteBranch = true, force = false } = opts;
269
271
 
270
272
  // If we're inside the worktree, move out first — git can't remove an in-use directory
@@ -13,16 +13,18 @@ import type { AutocompleteItem } from '@gsd/pi-tui'
13
13
  import {
14
14
  getTavilyApiKey,
15
15
  getBraveApiKey,
16
+ getOllamaApiKey,
16
17
  getSearchProviderPreference,
17
18
  setSearchProviderPreference,
18
19
  resolveSearchProvider,
19
20
  type SearchProviderPreference,
20
21
  } from './provider.ts'
21
22
 
22
- const VALID_PREFERENCES: SearchProviderPreference[] = ['tavily', 'brave', 'auto']
23
+ const VALID_PREFERENCES: SearchProviderPreference[] = ['tavily', 'brave', 'ollama', 'auto']
23
24
 
24
- function keyStatus(provider: 'tavily' | 'brave'): string {
25
+ function keyStatus(provider: 'tavily' | 'brave' | 'ollama'): string {
25
26
  if (provider === 'tavily') return getTavilyApiKey() ? '✓' : '✗'
27
+ if (provider === 'ollama') return getOllamaApiKey() ? '✓' : '✗'
26
28
  return getBraveApiKey() ? '✓' : '✗'
27
29
  }
28
30
 
@@ -30,6 +32,7 @@ function buildSelectOptions(): string[] {
30
32
  return [
31
33
  `tavily (key: ${keyStatus('tavily')})`,
32
34
  `brave (key: ${keyStatus('brave')})`,
35
+ `ollama (key: ${keyStatus('ollama')})`,
33
36
  `auto`,
34
37
  ]
35
38
  }
@@ -37,12 +40,13 @@ function buildSelectOptions(): string[] {
37
40
  function parseSelectChoice(choice: string): SearchProviderPreference {
38
41
  if (choice.startsWith('tavily')) return 'tavily'
39
42
  if (choice.startsWith('brave')) return 'brave'
43
+ if (choice.startsWith('ollama')) return 'ollama'
40
44
  return 'auto'
41
45
  }
42
46
 
43
47
  export function registerSearchProviderCommand(pi: ExtensionAPI): void {
44
48
  pi.registerCommand('search-provider', {
45
- description: 'Switch search provider (tavily, brave, auto)',
49
+ description: 'Switch search provider (tavily, brave, ollama, auto)',
46
50
 
47
51
  getArgumentCompletions(prefix: string): AutocompleteItem[] | null {
48
52
  const trimmed = prefix.trim().toLowerCase()
@@ -51,7 +55,7 @@ export function registerSearchProviderCommand(pi: ExtensionAPI): void {
51
55
  .map((p) => {
52
56
  let description: string
53
57
  if (p === 'auto') {
54
- description = `Auto-select (tavily: ${keyStatus('tavily')}, brave: ${keyStatus('brave')})`
58
+ description = `Auto-select (tavily: ${keyStatus('tavily')}, brave: ${keyStatus('brave')}, ollama: ${keyStatus('ollama')})`
55
59
  } else {
56
60
  description = `key: ${keyStatus(p)}`
57
61
  }
@@ -105,16 +105,21 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic:
105
105
  const payload = event.payload as Record<string, unknown>;
106
106
  if (!payload) return;
107
107
 
108
- // Detect Anthropic provider. Prefer the model_select flag when available,
109
- // but fall back to checking the model name in the payload. model_select
110
- // may not fire when the session restores with the same model already set
111
- // (modelsAreEqual guard in the SDK suppresses the event). When model_select
112
- // HAS fired and said "not Anthropic" (e.g. Copilot serving claude-*),
113
- // respect that don't override with model name heuristic.
114
- const modelName = typeof payload.model === "string" ? payload.model : "";
115
- const isAnthropic = modelSelectFired
116
- ? isAnthropicProvider
117
- : modelName.startsWith("claude-");
108
+ // Detect Anthropic provider. Use the model object from the event (most
109
+ // reliable comes directly from the resolved Model), then fall back to
110
+ // the model_select flag, then to the model name heuristic (last resort).
111
+ // The model name heuristic is needed for session restores where
112
+ // modelsAreEqual suppresses model_select AND the SDK doesn't pass model.
113
+ const eventModel = event.model as { provider: string } | undefined;
114
+ let isAnthropic: boolean;
115
+ if (eventModel?.provider) {
116
+ isAnthropic = eventModel.provider === "anthropic";
117
+ } else if (modelSelectFired) {
118
+ isAnthropic = isAnthropicProvider;
119
+ } else {
120
+ const modelName = typeof payload.model === "string" ? payload.model : "";
121
+ isAnthropic = modelName.startsWith("claude-");
122
+ }
118
123
  if (!isAnthropic) return;
119
124
 
120
125
  // Strip thinking blocks from history to avoid signature validation errors
@@ -18,10 +18,10 @@ import { join } from 'path'
18
18
  // where the relative import '../../../app-paths.ts' doesn't resolve.
19
19
  const authFilePath = join(homedir(), '.gsd', 'agent', 'auth.json')
20
20
 
21
- export type SearchProvider = 'tavily' | 'brave'
21
+ export type SearchProvider = 'tavily' | 'brave' | 'ollama'
22
22
  export type SearchProviderPreference = SearchProvider | 'auto'
23
23
 
24
- const VALID_PREFERENCES = new Set<string>(['tavily', 'brave', 'auto'])
24
+ const VALID_PREFERENCES = new Set<string>(['tavily', 'brave', 'ollama', 'auto'])
25
25
  const PREFERENCE_KEY = 'search_provider'
26
26
 
27
27
  /** Returns the Tavily API key from the environment, or empty string if not set. */
@@ -34,6 +34,11 @@ export function getBraveApiKey(): string {
34
34
  return process.env.BRAVE_API_KEY || ''
35
35
  }
36
36
 
37
+ /** Returns the Ollama API key from the environment, or empty string if not set. */
38
+ export function getOllamaApiKey(): string {
39
+ return process.env.OLLAMA_API_KEY || ''
40
+ }
41
+
37
42
  /**
38
43
  * Read the user's search provider preference from auth.json.
39
44
  * Returns 'auto' if no preference is stored or the stored value is invalid.
@@ -78,9 +83,11 @@ export function setSearchProviderPreference(pref: SearchProviderPreference, auth
78
83
  export function resolveSearchProvider(overridePreference?: string): SearchProvider | null {
79
84
  const tavilyKey = getTavilyApiKey()
80
85
  const braveKey = getBraveApiKey()
86
+ const ollamaKey = getOllamaApiKey()
81
87
 
82
88
  const hasTavily = tavilyKey.length > 0
83
89
  const hasBrave = braveKey.length > 0
90
+ const hasOllama = ollamaKey.length > 0
84
91
 
85
92
  // Determine effective preference
86
93
  let pref: SearchProviderPreference
@@ -100,18 +107,28 @@ export function resolveSearchProvider(overridePreference?: string): SearchProvid
100
107
  if (pref === 'auto') {
101
108
  if (hasTavily) return 'tavily'
102
109
  if (hasBrave) return 'brave'
110
+ if (hasOllama) return 'ollama'
103
111
  return null
104
112
  }
105
113
 
106
114
  if (pref === 'tavily') {
107
115
  if (hasTavily) return 'tavily'
108
116
  if (hasBrave) return 'brave'
117
+ if (hasOllama) return 'ollama'
109
118
  return null
110
119
  }
111
120
 
112
121
  if (pref === 'brave') {
113
122
  if (hasBrave) return 'brave'
114
123
  if (hasTavily) return 'tavily'
124
+ if (hasOllama) return 'ollama'
125
+ return null
126
+ }
127
+
128
+ if (pref === 'ollama') {
129
+ if (hasOllama) return 'ollama'
130
+ if (hasTavily) return 'tavily'
131
+ if (hasBrave) return 'brave'
115
132
  return null
116
133
  }
117
134
 
@@ -17,6 +17,7 @@ import { LRUTTLCache } from "./cache.js";
17
17
  import { fetchSimple, HttpError } from "./http.js";
18
18
  import { extractDomain } from "./url-utils.js";
19
19
  import { formatPageContent, type FormatPageOptions } from "./format.js";
20
+ import { getOllamaApiKey } from "./provider.js";
20
21
 
21
22
  // =============================================================================
22
23
  // Cache
@@ -173,6 +174,43 @@ async function fetchDirectFallback(
173
174
  return { content: text, title, contentType };
174
175
  }
175
176
 
177
+ // =============================================================================
178
+ // Ollama Web Fetch
179
+ // =============================================================================
180
+
181
+ interface OllamaWebFetchResponse {
182
+ title?: string;
183
+ content?: string;
184
+ links?: string[];
185
+ }
186
+
187
+ /**
188
+ * Fetch page content via Ollama web_fetch API.
189
+ * Returns content + metadata, or throws on failure.
190
+ */
191
+ async function fetchViaOllama(
192
+ url: string,
193
+ signal?: AbortSignal,
194
+ ): Promise<{ content: string; title?: string }> {
195
+ const response = await fetchSimple("https://ollama.com/api/web_fetch", {
196
+ method: "POST",
197
+ headers: {
198
+ "Content-Type": "application/json",
199
+ "Authorization": `Bearer ${getOllamaApiKey()}`,
200
+ },
201
+ body: JSON.stringify({ url }),
202
+ signal,
203
+ timeoutMs: 20_000,
204
+ });
205
+
206
+ const data: OllamaWebFetchResponse = await response.json();
207
+
208
+ const content = (data.content || "").trim();
209
+ const title = data.title?.trim() || undefined;
210
+
211
+ return { content, title };
212
+ }
213
+
176
214
  // =============================================================================
177
215
  // Smart Truncation
178
216
  // =============================================================================
@@ -252,6 +290,30 @@ async function fetchOnePage(
252
290
  jinaError = err instanceof HttpError
253
291
  ? `Jina HTTP ${err.statusCode}`
254
292
  : (err as Error).message ?? String(err);
293
+
294
+ // Try Ollama web_fetch as intermediate fallback if API key is available
295
+ const ollamaKey = getOllamaApiKey();
296
+ if (ollamaKey) {
297
+ try {
298
+ const ollamaResult = await fetchViaOllama(url, options.signal);
299
+ if (ollamaResult.content && ollamaResult.content.length >= 50) {
300
+ pageContent = ollamaResult.content;
301
+ pageTitle = ollamaResult.title;
302
+ source = "direct";
303
+ return {
304
+ content: pageContent,
305
+ title: pageTitle,
306
+ source,
307
+ jinaError,
308
+ contentType,
309
+ originalChars: pageContent.length,
310
+ };
311
+ }
312
+ } catch {
313
+ // Ollama fetch failed too — fall through to direct
314
+ }
315
+ }
316
+
255
317
  source = "direct";
256
318
 
257
319
  const result = await fetchDirectFallback(url, options.signal);
@@ -27,7 +27,7 @@ import { normalizeQuery, extractDomain } from "./url-utils.js";
27
27
  import { formatLLMContext, type LLMContextSnippet, type LLMContextSource } from "./format.js";
28
28
  import type { TavilyResult, TavilySearchResponse } from "./tavily.js";
29
29
  import { publishedDateToAge } from "./tavily.js";
30
- import { getTavilyApiKey, resolveSearchProvider } from "./provider.js";
30
+ import { getTavilyApiKey, getOllamaApiKey, resolveSearchProvider } from "./provider.js";
31
31
 
32
32
  // =============================================================================
33
33
  // Types
@@ -79,7 +79,7 @@ interface LLMContextDetails {
79
79
  errorKind?: string;
80
80
  error?: string;
81
81
  retryAfterMs?: number;
82
- provider?: 'tavily' | 'brave';
82
+ provider?: 'tavily' | 'brave' | 'ollama';
83
83
  }
84
84
 
85
85
  // =============================================================================
@@ -230,6 +230,57 @@ async function executeTavilyLLMContext(
230
230
  return { cached, latencyMs: timed.latencyMs, rateLimit: timed.rateLimit };
231
231
  }
232
232
 
233
+ // =============================================================================
234
+ // Ollama LLM Context Execution
235
+ // =============================================================================
236
+
237
+ interface OllamaWebSearchResult {
238
+ title: string;
239
+ url: string;
240
+ content: string;
241
+ }
242
+
243
+ interface OllamaWebSearchResponse {
244
+ results: OllamaWebSearchResult[];
245
+ }
246
+
247
+ /**
248
+ * Execute a search_and_read query against the Ollama web_search API.
249
+ *
250
+ * Uses the same web_search endpoint as tool-search, then applies
251
+ * budgetContent() for client-side token budgeting (similar to Tavily path).
252
+ */
253
+ async function executeOllamaLLMContext(
254
+ params: { query: string; maxTokens: number; count: number; threshold: string },
255
+ signal?: AbortSignal,
256
+ ): Promise<{ cached: CachedLLMContext; latencyMs: number; rateLimit?: RateLimitInfo }> {
257
+ const scoreThreshold = THRESHOLD_TO_SCORE[params.threshold] ?? 0.5;
258
+
259
+ const timed = await fetchWithRetryTimed("https://ollama.com/api/web_search", {
260
+ method: "POST",
261
+ headers: {
262
+ "Content-Type": "application/json",
263
+ "Authorization": `Bearer ${getOllamaApiKey()}`,
264
+ },
265
+ body: JSON.stringify({ query: params.query, max_results: params.count }),
266
+ signal,
267
+ }, 2);
268
+
269
+ const data: OllamaWebSearchResponse = await timed.response.json();
270
+
271
+ // Convert Ollama results to TavilyResult-compatible format for budgetContent
272
+ const tavilyLikeResults: TavilyResult[] = (data.results || []).map(r => ({
273
+ title: r.title || "(untitled)",
274
+ url: r.url,
275
+ content: r.content || "",
276
+ score: 1.0, // Ollama doesn't provide scores, assume all are relevant
277
+ }));
278
+
279
+ const cached = budgetContent(tavilyLikeResults, params.maxTokens, scoreThreshold);
280
+
281
+ return { cached, latencyMs: timed.latencyMs, rateLimit: timed.rateLimit };
282
+ }
283
+
233
284
  // =============================================================================
234
285
  // Tool Registration
235
286
  // =============================================================================
@@ -295,7 +346,7 @@ export function registerLLMContextTool(pi: ExtensionAPI) {
295
346
  const provider = resolveSearchProvider();
296
347
  if (!provider) {
297
348
  return {
298
- content: [{ type: "text", text: "search_and_read unavailable: No search API key is set. Use secure_env_collect to set TAVILY_API_KEY or BRAVE_API_KEY." }],
349
+ content: [{ type: "text", text: "search_and_read unavailable: No search API key is set. Use secure_env_collect to set TAVILY_API_KEY, BRAVE_API_KEY, or OLLAMA_API_KEY." }],
299
350
  isError: true,
300
351
  details: { errorKind: "auth_error", error: "No search API key set" } satisfies Partial<LLMContextDetails>,
301
352
  };
@@ -358,6 +409,14 @@ export function registerLLMContextTool(pi: ExtensionAPI) {
358
409
  result = tavilyResult.cached;
359
410
  latencyMs = tavilyResult.latencyMs;
360
411
  rateLimit = tavilyResult.rateLimit;
412
+ } else if (provider === "ollama") {
413
+ const ollamaResult = await executeOllamaLLMContext(
414
+ { query: params.query, maxTokens, count, threshold },
415
+ signal,
416
+ );
417
+ result = ollamaResult.cached;
418
+ latencyMs = ollamaResult.latencyMs;
419
+ rateLimit = ollamaResult.rateLimit;
361
420
  } else {
362
421
  // ================================================================
363
422
  // BRAVE PATH (unchanged API logic)
@@ -20,7 +20,7 @@ import { LRUTTLCache } from "./cache.js";
20
20
  import { fetchWithRetryTimed, fetchWithRetry, classifyError, type RateLimitInfo } from "./http.js";
21
21
  import { normalizeQuery, toDedupeKey, detectFreshness } from "./url-utils.js";
22
22
  import { formatSearchResults, type SearchResultFormatted, type FormatSearchOptions } from "./format.js";
23
- import { getTavilyApiKey, resolveSearchProvider } from "./provider.js";
23
+ import { getTavilyApiKey, getOllamaApiKey, resolveSearchProvider } from "./provider.js";
24
24
  import { normalizeTavilyResult, mapFreshnessToTavily, type TavilySearchResponse } from "./tavily.js";
25
25
 
26
26
  // =============================================================================
@@ -93,7 +93,7 @@ interface SearchDetails {
93
93
  errorKind?: string;
94
94
  error?: string;
95
95
  retryAfterMs?: number;
96
- provider?: 'tavily' | 'brave';
96
+ provider?: 'tavily' | 'brave' | 'ollama';
97
97
  }
98
98
 
99
99
  // =============================================================================
@@ -245,6 +245,57 @@ async function executeTavilySearch(
245
245
  };
246
246
  }
247
247
 
248
+ // =============================================================================
249
+ // Ollama API execution
250
+ // =============================================================================
251
+
252
+ interface OllamaWebSearchResult {
253
+ title: string;
254
+ url: string;
255
+ content: string;
256
+ }
257
+
258
+ interface OllamaWebSearchResponse {
259
+ results: OllamaWebSearchResult[];
260
+ }
261
+
262
+ /**
263
+ * Execute a search against the Ollama web_search API.
264
+ * Returns a CachedSearchResult with normalized, deduplicated results.
265
+ */
266
+ async function executeOllamaSearch(
267
+ params: { query: string; count: number },
268
+ signal?: AbortSignal
269
+ ): Promise<{ results: CachedSearchResult; latencyMs: number; rateLimit?: RateLimitInfo }> {
270
+ const timed = await fetchWithRetryTimed("https://ollama.com/api/web_search", {
271
+ method: "POST",
272
+ headers: {
273
+ "Content-Type": "application/json",
274
+ "Authorization": `Bearer ${getOllamaApiKey()}`,
275
+ },
276
+ body: JSON.stringify({ query: params.query, max_results: params.count }),
277
+ signal,
278
+ }, 2);
279
+
280
+ const data: OllamaWebSearchResponse = await timed.response.json();
281
+ const normalized: SearchResultFormatted[] = (data.results || []).map(r => ({
282
+ title: r.title || "(untitled)",
283
+ url: r.url,
284
+ description: r.content || "",
285
+ }));
286
+ const deduplicated = deduplicateResults(normalized);
287
+
288
+ return {
289
+ results: {
290
+ results: deduplicated,
291
+ queryCorrected: false,
292
+ moreResultsAvailable: false,
293
+ },
294
+ latencyMs: timed.latencyMs,
295
+ rateLimit: timed.rateLimit,
296
+ };
297
+ }
298
+
248
299
  // =============================================================================
249
300
  // Tool Registration
250
301
  // =============================================================================
@@ -300,7 +351,7 @@ export function registerSearchTool(pi: ExtensionAPI) {
300
351
  const provider = resolveSearchProvider();
301
352
  if (!provider) {
302
353
  return {
303
- content: [{ type: "text", text: "Web search unavailable: No search API key is set. Use secure_env_collect to set TAVILY_API_KEY or BRAVE_API_KEY." }],
354
+ content: [{ type: "text", text: "Web search unavailable: No search API key is set. Use secure_env_collect to set TAVILY_API_KEY, BRAVE_API_KEY, or OLLAMA_API_KEY." }],
304
355
  isError: true,
305
356
  details: { errorKind: "auth_error", error: "No search API key set" } satisfies Partial<SearchDetails>,
306
357
  };
@@ -405,6 +456,14 @@ export function registerSearchTool(pi: ExtensionAPI) {
405
456
  searchResult = tavilyResult.results;
406
457
  latencyMs = tavilyResult.latencyMs;
407
458
  rateLimit = tavilyResult.rateLimit;
459
+ } else if (provider === "ollama") {
460
+ const ollamaResult = await executeOllamaSearch(
461
+ { query: params.query, count: 10 },
462
+ signal
463
+ );
464
+ searchResult = ollamaResult.results;
465
+ latencyMs = ollamaResult.latencyMs;
466
+ rateLimit = ollamaResult.rateLimit;
408
467
  } else {
409
468
  // ================================================================
410
469
  // BRAVE PATH (unchanged API logic)