@tuan_son.dinh/gsd 2.6.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -0
  3. package/dist/app-paths.d.ts +4 -0
  4. package/dist/app-paths.js +6 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +269 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +70 -0
  9. package/dist/logo.d.ts +16 -0
  10. package/dist/logo.js +25 -0
  11. package/dist/onboarding.d.ts +43 -0
  12. package/dist/onboarding.js +418 -0
  13. package/dist/pi-migration.d.ts +14 -0
  14. package/dist/pi-migration.js +57 -0
  15. package/dist/resource-loader.d.ts +22 -0
  16. package/dist/resource-loader.js +60 -0
  17. package/dist/tool-bootstrap.d.ts +4 -0
  18. package/dist/tool-bootstrap.js +74 -0
  19. package/dist/wizard.d.ts +7 -0
  20. package/dist/wizard.js +25 -0
  21. package/package.json +60 -0
  22. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
  23. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  24. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  25. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  26. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  27. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  28. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  29. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  30. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  31. package/pkg/package.json +8 -0
  32. package/scripts/postinstall.js +127 -0
  33. package/src/resources/GSD-WORKFLOW.md +661 -0
  34. package/src/resources/agents/researcher.md +29 -0
  35. package/src/resources/agents/scout.md +56 -0
  36. package/src/resources/agents/worker.md +31 -0
  37. package/src/resources/extensions/ask-user-questions.ts +249 -0
  38. package/src/resources/extensions/bg-shell/index.ts +2808 -0
  39. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  40. package/src/resources/extensions/browser-tools/core.js +1057 -0
  41. package/src/resources/extensions/browser-tools/index.ts +4989 -0
  42. package/src/resources/extensions/browser-tools/package.json +20 -0
  43. package/src/resources/extensions/context7/index.ts +428 -0
  44. package/src/resources/extensions/context7/package.json +11 -0
  45. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  46. package/src/resources/extensions/google-search/index.ts +323 -0
  47. package/src/resources/extensions/google-search/package.json +9 -0
  48. package/src/resources/extensions/gsd/activity-log.ts +69 -0
  49. package/src/resources/extensions/gsd/auto.ts +2744 -0
  50. package/src/resources/extensions/gsd/commands.ts +313 -0
  51. package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
  52. package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
  53. package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
  54. package/src/resources/extensions/gsd/doctor.ts +690 -0
  55. package/src/resources/extensions/gsd/files.ts +732 -0
  56. package/src/resources/extensions/gsd/git-service.ts +597 -0
  57. package/src/resources/extensions/gsd/gitignore.ts +168 -0
  58. package/src/resources/extensions/gsd/guided-flow.ts +817 -0
  59. package/src/resources/extensions/gsd/index.ts +558 -0
  60. package/src/resources/extensions/gsd/metrics.ts +374 -0
  61. package/src/resources/extensions/gsd/migrate/command.ts +218 -0
  62. package/src/resources/extensions/gsd/migrate/index.ts +42 -0
  63. package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
  64. package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
  65. package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
  66. package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
  67. package/src/resources/extensions/gsd/migrate/types.ts +370 -0
  68. package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
  69. package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
  70. package/src/resources/extensions/gsd/observability-validator.ts +408 -0
  71. package/src/resources/extensions/gsd/package.json +11 -0
  72. package/src/resources/extensions/gsd/paths.ts +308 -0
  73. package/src/resources/extensions/gsd/preferences.ts +757 -0
  74. package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
  75. package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
  76. package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
  77. package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
  78. package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
  79. package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
  80. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
  81. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
  82. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
  83. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
  84. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
  85. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
  86. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
  87. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
  88. package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
  89. package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
  90. package/src/resources/extensions/gsd/prompts/queue.md +85 -0
  91. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
  92. package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
  93. package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
  94. package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
  95. package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
  96. package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
  97. package/src/resources/extensions/gsd/prompts/system.md +187 -0
  98. package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
  99. package/src/resources/extensions/gsd/session-forensics.ts +487 -0
  100. package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
  101. package/src/resources/extensions/gsd/state.ts +460 -0
  102. package/src/resources/extensions/gsd/templates/context.md +76 -0
  103. package/src/resources/extensions/gsd/templates/decisions.md +8 -0
  104. package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
  105. package/src/resources/extensions/gsd/templates/plan.md +131 -0
  106. package/src/resources/extensions/gsd/templates/preferences.md +24 -0
  107. package/src/resources/extensions/gsd/templates/project.md +31 -0
  108. package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
  109. package/src/resources/extensions/gsd/templates/requirements.md +81 -0
  110. package/src/resources/extensions/gsd/templates/research.md +46 -0
  111. package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
  112. package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
  113. package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
  114. package/src/resources/extensions/gsd/templates/state.md +19 -0
  115. package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
  116. package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
  117. package/src/resources/extensions/gsd/templates/uat.md +54 -0
  118. package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
  119. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
  120. package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
  121. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
  122. package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
  123. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
  124. package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
  125. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  126. package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
  127. package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
  128. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
  129. package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
  130. package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
  131. package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
  132. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
  133. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
  134. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
  135. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
  136. package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
  137. package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
  138. package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
  139. package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
  140. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
  141. package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
  142. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
  143. package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
  144. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
  145. package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
  146. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
  147. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
  148. package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
  149. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
  150. package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
  151. package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
  152. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
  153. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
  154. package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
  155. package/src/resources/extensions/gsd/types.ts +159 -0
  156. package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
  157. package/src/resources/extensions/gsd/workspace-index.ts +203 -0
  158. package/src/resources/extensions/gsd/worktree-command.ts +845 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
  160. package/src/resources/extensions/gsd/worktree.ts +183 -0
  161. package/src/resources/extensions/mac-tools/index.ts +852 -0
  162. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  163. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  164. package/src/resources/extensions/mcporter/index.ts +429 -0
  165. package/src/resources/extensions/remote-questions/config.ts +81 -0
  166. package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
  167. package/src/resources/extensions/remote-questions/format.ts +163 -0
  168. package/src/resources/extensions/remote-questions/manager.ts +192 -0
  169. package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
  170. package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
  171. package/src/resources/extensions/remote-questions/status.ts +31 -0
  172. package/src/resources/extensions/remote-questions/store.ts +77 -0
  173. package/src/resources/extensions/remote-questions/types.ts +75 -0
  174. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  175. package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
  176. package/src/resources/extensions/search-the-web/format.ts +258 -0
  177. package/src/resources/extensions/search-the-web/http.ts +238 -0
  178. package/src/resources/extensions/search-the-web/index.ts +65 -0
  179. package/src/resources/extensions/search-the-web/native-search.ts +157 -0
  180. package/src/resources/extensions/search-the-web/provider.ts +118 -0
  181. package/src/resources/extensions/search-the-web/tavily.ts +116 -0
  182. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  183. package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
  184. package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
  185. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  186. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  187. package/src/resources/extensions/shared/interview-ui.ts +613 -0
  188. package/src/resources/extensions/shared/next-action-ui.ts +197 -0
  189. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  190. package/src/resources/extensions/shared/terminal.ts +23 -0
  191. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  192. package/src/resources/extensions/shared/ui.ts +400 -0
  193. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  194. package/src/resources/extensions/slash-commands/audit.ts +88 -0
  195. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  196. package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
  197. package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
  198. package/src/resources/extensions/slash-commands/index.ts +12 -0
  199. package/src/resources/extensions/subagent/agents.ts +126 -0
  200. package/src/resources/extensions/subagent/index.ts +1020 -0
  201. package/src/resources/extensions/voice/index.ts +195 -0
  202. package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
  203. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  204. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  205. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  206. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  207. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  208. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  209. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  210. package/src/resources/skills/swiftui/SKILL.md +208 -0
  211. package/src/resources/skills/swiftui/references/animations.md +921 -0
  212. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  213. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  214. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  215. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  216. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  217. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  218. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  219. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  220. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  221. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  222. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  223. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  224. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  225. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  226. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  227. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * HTTP utilities: retry with backoff, abort signal merging, error types, timing.
3
+ */
4
+
5
+ // =============================================================================
6
+ // Error Types
7
+ // =============================================================================
8
+
9
+ /** Structured error for non-2xx HTTP responses. */
10
+ export class HttpError extends Error {
11
+ readonly statusCode: number;
12
+ readonly response?: Response;
13
+
14
+ constructor(message: string, statusCode: number, response?: Response) {
15
+ super(message);
16
+ this.name = "HttpError";
17
+ this.statusCode = statusCode;
18
+ this.response = response;
19
+ Object.setPrototypeOf(this, HttpError.prototype);
20
+ }
21
+ }
22
+
23
+ /** Categorized error types for agent-friendly error handling. */
24
+ export type SearchErrorKind =
25
+ | "auth_error" // 401/403 — bad or missing API key
26
+ | "rate_limited" // 429 — too many requests
27
+ | "network_error" // DNS, timeout, connection refused
28
+ | "server_error" // 5xx
29
+ | "invalid_request" // 400, bad params
30
+ | "not_found" // 404
31
+ | "unknown";
32
+
33
+ export function classifyError(err: unknown): { kind: SearchErrorKind; message: string; retryAfterMs?: number } {
34
+ if (err instanceof HttpError) {
35
+ const code = err.statusCode;
36
+ if (code === 401 || code === 403) {
37
+ return { kind: "auth_error", message: `HTTP ${code}: Invalid or missing API key. Check your API key with secure_env_collect.` };
38
+ }
39
+ if (code === 429) {
40
+ let retryAfterMs: number | undefined;
41
+ const retryAfter = err.response?.headers.get("Retry-After");
42
+ if (retryAfter) {
43
+ const seconds = parseFloat(retryAfter);
44
+ if (!isNaN(seconds)) retryAfterMs = seconds * 1000;
45
+ }
46
+ return { kind: "rate_limited", message: `Rate limited (HTTP 429). ${retryAfterMs ? `Retry after ${Math.ceil(retryAfterMs / 1000)}s.` : "Wait before retrying."}`, retryAfterMs };
47
+ }
48
+ if (code === 400) {
49
+ return { kind: "invalid_request", message: `Bad request (HTTP 400): ${err.message}` };
50
+ }
51
+ if (code === 404) return { kind: "not_found", message: `Not found (HTTP 404)` };
52
+ if (code >= 500) return { kind: "server_error", message: `Server error (HTTP ${code}): ${err.message}` };
53
+ return { kind: "unknown", message: `HTTP ${code}: ${err.message}` };
54
+ }
55
+ if (err instanceof TypeError) {
56
+ return { kind: "network_error", message: `Network error: ${(err as Error).message}` };
57
+ }
58
+ const msg = (err as Error)?.message ?? String(err);
59
+ if (msg.includes("abort") || msg.includes("timeout")) {
60
+ return { kind: "network_error", message: `Request timed out` };
61
+ }
62
+ return { kind: "unknown", message: msg };
63
+ }
64
+
65
+ // =============================================================================
66
+ // Rate Limit Info
67
+ // =============================================================================
68
+
69
+ export interface RateLimitInfo {
70
+ remaining?: number;
71
+ limit?: number;
72
+ reset?: number; // epoch seconds
73
+ }
74
+
75
+ /** Extract rate limit headers from a Brave API response. */
76
+ export function extractRateLimitInfo(response: Response): RateLimitInfo | undefined {
77
+ const remaining = response.headers.get("x-ratelimit-remaining");
78
+ const limit = response.headers.get("x-ratelimit-limit");
79
+ const reset = response.headers.get("x-ratelimit-reset");
80
+ if (!remaining && !limit) return undefined;
81
+ return {
82
+ remaining: remaining ? parseInt(remaining, 10) : undefined,
83
+ limit: limit ? parseInt(limit, 10) : undefined,
84
+ reset: reset ? parseInt(reset, 10) : undefined,
85
+ };
86
+ }
87
+
88
+ // =============================================================================
89
+ // Timing
90
+ // =============================================================================
91
+
92
+ export interface TimedResponse {
93
+ response: Response;
94
+ latencyMs: number;
95
+ rateLimit?: RateLimitInfo;
96
+ }
97
+
98
+ // =============================================================================
99
+ // Retry Logic
100
+ // =============================================================================
101
+
102
+ function isRetryable(error: unknown): boolean {
103
+ if (error instanceof HttpError) {
104
+ return error.statusCode === 429 || error.statusCode >= 500;
105
+ }
106
+ if (error instanceof TypeError) return true;
107
+ return false;
108
+ }
109
+
110
+ function sleep(ms: number): Promise<void> {
111
+ return new Promise((resolve) => setTimeout(resolve, ms));
112
+ }
113
+
114
+ /** Merge multiple AbortSignals — aborts as soon as any fires. */
115
+ export function anySignal(signals: AbortSignal[]): AbortSignal {
116
+ const controller = new AbortController();
117
+ for (const sig of signals) {
118
+ if (sig.aborted) {
119
+ controller.abort(sig.reason);
120
+ break;
121
+ }
122
+ sig.addEventListener("abort", () => controller.abort(sig.reason), { once: true });
123
+ }
124
+ return controller.signal;
125
+ }
126
+
127
+ /**
128
+ * Fetch with automatic retry and full-jitter exponential backoff.
129
+ *
130
+ * - maxRetries: additional attempts after the first (total = maxRetries + 1)
131
+ * - Respects Retry-After header on 429 responses
132
+ * - Each attempt uses a 30-second AbortSignal timeout
133
+ * - Non-retryable errors thrown immediately
134
+ */
135
+ export async function fetchWithRetry(
136
+ url: string,
137
+ options: RequestInit,
138
+ maxRetries: number = 2
139
+ ): Promise<Response> {
140
+ let lastError: unknown;
141
+
142
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
143
+ const timeoutController = new AbortController();
144
+ const timeoutId = setTimeout(() => timeoutController.abort(), 30_000);
145
+
146
+ const callerSignal = options.signal as AbortSignal | undefined;
147
+ const signal = callerSignal
148
+ ? anySignal([callerSignal, timeoutController.signal])
149
+ : timeoutController.signal;
150
+
151
+ try {
152
+ const response = await fetch(url, { ...options, signal });
153
+ clearTimeout(timeoutId);
154
+
155
+ if (!response.ok) {
156
+ throw new HttpError(
157
+ `HTTP ${response.status}: ${response.statusText}`,
158
+ response.status,
159
+ response
160
+ );
161
+ }
162
+ return response;
163
+ } catch (err) {
164
+ clearTimeout(timeoutId);
165
+ lastError = err;
166
+
167
+ if (!isRetryable(err)) throw err;
168
+
169
+ if (attempt < maxRetries) {
170
+ let delayMs: number;
171
+ if (err instanceof HttpError && err.statusCode === 429 && err.response) {
172
+ const retryAfter = err.response.headers.get("Retry-After");
173
+ if (retryAfter) {
174
+ const seconds = parseFloat(retryAfter);
175
+ delayMs = isNaN(seconds) ? 1000 : seconds * 1000;
176
+ } else {
177
+ delayMs = Math.random() * Math.min(32_000, 1_000 * 2 ** attempt);
178
+ }
179
+ } else {
180
+ delayMs = Math.random() * Math.min(32_000, 1_000 * 2 ** attempt);
181
+ }
182
+ await sleep(delayMs);
183
+ }
184
+ }
185
+ }
186
+
187
+ throw lastError;
188
+ }
189
+
190
+ /**
191
+ * Simple fetch with timeout, no retry. For content extraction where
192
+ * we want to fail fast.
193
+ */
194
+ export async function fetchSimple(
195
+ url: string,
196
+ options: RequestInit & { timeoutMs?: number } = {}
197
+ ): Promise<Response> {
198
+ const { timeoutMs = 15_000, ...fetchOpts } = options;
199
+ const controller = new AbortController();
200
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
201
+
202
+ const callerSignal = fetchOpts.signal as AbortSignal | undefined;
203
+ const signal = callerSignal
204
+ ? anySignal([callerSignal, controller.signal])
205
+ : controller.signal;
206
+
207
+ try {
208
+ const response = await fetch(url, { ...fetchOpts, signal });
209
+ clearTimeout(timeoutId);
210
+ if (!response.ok) {
211
+ throw new HttpError(
212
+ `HTTP ${response.status}: ${response.statusText}`,
213
+ response.status,
214
+ response
215
+ );
216
+ }
217
+ return response;
218
+ } catch (err) {
219
+ clearTimeout(timeoutId);
220
+ throw err;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Fetch with retry AND timing/rate-limit extraction.
226
+ * Wraps fetchWithRetry and returns latency + rate limit info.
227
+ */
228
+ export async function fetchWithRetryTimed(
229
+ url: string,
230
+ options: RequestInit,
231
+ maxRetries: number = 2
232
+ ): Promise<TimedResponse> {
233
+ const start = performance.now();
234
+ const response = await fetchWithRetry(url, options, maxRetries);
235
+ const latencyMs = Math.round(performance.now() - start);
236
+ const rateLimit = extractRateLimitInfo(response);
237
+ return { response, latencyMs, rateLimit };
238
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Web Search Extension v4
3
+ *
4
+ * Provides three tools for grounding the agent in real-world web content:
5
+ *
6
+ * search-the-web — Rich web search with extra snippets, freshness filtering,
7
+ * domain scoping, AI summarizer, and compact output format.
8
+ * Returns links and snippets for selective browsing.
9
+ *
10
+ * fetch_page — Extract clean markdown from any URL via Jina Reader.
11
+ * Supports offset-based continuation, CSS selector targeting,
12
+ * and content-type-aware extraction.
13
+ *
14
+ * search_and_read — Single-call search + content extraction via Brave LLM Context API.
15
+ * Returns pre-extracted, relevance-scored page content.
16
+ * Best when you need content, not just links.
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
+ *
27
+ * v3 improvements over v2:
28
+ * - search_and_read: New tool — Brave LLM Context API (search + read in one call)
29
+ * - Structured error taxonomy: auth_error, rate_limited, network_error, etc.
30
+ * - Spellcheck surfacing: query corrections from Brave shown to agent
31
+ * - Latency tracking: API call timing in details for observability
32
+ * - Rate limit info: remaining quota surfaced when available
33
+ * - more_results_available: pagination hints from Brave
34
+ * - Adaptive snippet budget: snippet count adapts to result count
35
+ * - fetch_page offset: continuation reading for long pages
36
+ * - fetch_page selector: CSS selector targeting via Jina X-Target-Selector
37
+ * - fetch_page diagnostics: Jina failure reasons surfaced in details
38
+ * - Content-type awareness: JSON passthrough, PDF detection
39
+ * - Cache timer cleanup: purge timers use unref() to not block process exit
40
+ *
41
+ * Environment variables:
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
44
+ * JINA_API_KEY — Optional. Higher rate limits for page extraction.
45
+ */
46
+
47
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
48
+ import { registerSearchTool } from "./tool-search";
49
+ import { registerFetchPageTool } from "./tool-fetch-page";
50
+ import { registerLLMContextTool } from "./tool-llm-context";
51
+ import { registerSearchProviderCommand } from "./command-search-provider.ts";
52
+ import { registerNativeSearchHooks } from "./native-search";
53
+
54
+ export default function (pi: ExtensionAPI) {
55
+ registerSearchTool(pi);
56
+ registerFetchPageTool(pi);
57
+ registerLLMContextTool(pi);
58
+
59
+
60
+ // Register slash commands
61
+ registerSearchProviderCommand(pi);
62
+
63
+ // Register native Anthropic web search hooks
64
+ registerNativeSearchHooks(pi);
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
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Search provider selection and preference management.
3
+ *
4
+ * Single source of truth for which search backend (Tavily vs Brave) to use.
5
+ * Reads API keys from process.env at call time (not module load time) so
6
+ * hot-reloaded keys work. Preference is stored in auth.json under the
7
+ * synthetic provider key `search_provider` as { type: "api_key", key: "tavily" | "brave" | "auto" }.
8
+ *
9
+ * @see S01-RESEARCH.md for the storage decision rationale (D002).
10
+ */
11
+
12
+ import { AuthStorage } from '@mariozechner/pi-coding-agent'
13
+ import { homedir } from 'os'
14
+ import { join } from 'path'
15
+
16
+ // Compute authFilePath locally instead of importing from app-paths.ts,
17
+ // because extensions are copied to ~/.gsd/agent/extensions/ at runtime
18
+ // where the relative import '../../../app-paths.ts' doesn't resolve.
19
+ const authFilePath = join(homedir(), '.gsd', 'agent', 'auth.json')
20
+
21
+ export type SearchProvider = 'tavily' | 'brave'
22
+ export type SearchProviderPreference = SearchProvider | 'auto'
23
+
24
+ const VALID_PREFERENCES = new Set<string>(['tavily', 'brave', 'auto'])
25
+ const PREFERENCE_KEY = 'search_provider'
26
+
27
+ /** Returns the Tavily API key from the environment, or empty string if not set. */
28
+ export function getTavilyApiKey(): string {
29
+ return process.env.TAVILY_API_KEY || ''
30
+ }
31
+
32
+ /** Returns the Brave API key from the environment, or empty string if not set. */
33
+ export function getBraveApiKey(): string {
34
+ return process.env.BRAVE_API_KEY || ''
35
+ }
36
+
37
+ /**
38
+ * Read the user's search provider preference from auth.json.
39
+ * Returns 'auto' if no preference is stored or the stored value is invalid.
40
+ *
41
+ * @param authPath — Override auth.json path (for testing).
42
+ */
43
+ export function getSearchProviderPreference(authPath?: string): SearchProviderPreference {
44
+ const auth = AuthStorage.create(authPath ?? authFilePath)
45
+ const cred = auth.get(PREFERENCE_KEY)
46
+ if (cred?.type === 'api_key' && typeof cred.key === 'string' && VALID_PREFERENCES.has(cred.key)) {
47
+ return cred.key as SearchProviderPreference
48
+ }
49
+ return 'auto'
50
+ }
51
+
52
+ /**
53
+ * Write the user's search provider preference to auth.json.
54
+ * Uses AuthStorage to go through file locking.
55
+ *
56
+ * @param pref — The preference to store.
57
+ * @param authPath — Override auth.json path (for testing).
58
+ */
59
+ export function setSearchProviderPreference(pref: SearchProviderPreference, authPath?: string): void {
60
+ const auth = AuthStorage.create(authPath ?? authFilePath)
61
+ auth.set(PREFERENCE_KEY, { type: 'api_key', key: pref })
62
+ }
63
+
64
+ /**
65
+ * Resolve which search provider to use based on available API keys and user preference.
66
+ *
67
+ * Logic:
68
+ * 1. If an explicit override is given, use it — but only if that provider's key exists.
69
+ * If the key doesn't exist, fall through to the other provider.
70
+ * 2. Otherwise, read the stored preference.
71
+ * 3. If preference is 'auto': prefer Tavily, then Brave.
72
+ * 4. If preference is a specific provider: use it if key exists, else fall back to the other.
73
+ * 5. Return null if neither key is available — explicit signal for "no provider".
74
+ *
75
+ * @param overridePreference — Optional override (e.g. from a tool parameter).
76
+ */
77
+ export function resolveSearchProvider(overridePreference?: string): SearchProvider | null {
78
+ const tavilyKey = getTavilyApiKey()
79
+ const braveKey = getBraveApiKey()
80
+
81
+ const hasTavily = tavilyKey.length > 0
82
+ const hasBrave = braveKey.length > 0
83
+
84
+ // Determine effective preference
85
+ let pref: SearchProviderPreference
86
+ if (overridePreference && VALID_PREFERENCES.has(overridePreference)) {
87
+ pref = overridePreference as SearchProviderPreference
88
+ } else {
89
+ // Invalid override or no override — read stored preference
90
+ // If overridePreference is provided but invalid, treat as 'auto'
91
+ if (overridePreference !== undefined && !VALID_PREFERENCES.has(overridePreference)) {
92
+ pref = 'auto'
93
+ } else {
94
+ pref = getSearchProviderPreference()
95
+ }
96
+ }
97
+
98
+ // Resolve based on preference
99
+ if (pref === 'auto') {
100
+ if (hasTavily) return 'tavily'
101
+ if (hasBrave) return 'brave'
102
+ return null
103
+ }
104
+
105
+ if (pref === 'tavily') {
106
+ if (hasTavily) return 'tavily'
107
+ if (hasBrave) return 'brave'
108
+ return null
109
+ }
110
+
111
+ if (pref === 'brave') {
112
+ if (hasBrave) return 'brave'
113
+ if (hasTavily) return 'tavily'
114
+ return null
115
+ }
116
+
117
+ return null
118
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tavily API types and helper functions for normalizing Tavily search results
3
+ * into the shared SearchResultFormatted shape.
4
+ *
5
+ * Consumed by: tool-search.ts (S02), search_and_read Tavily path (S03).
6
+ * All exports are pure functions with no side effects.
7
+ */
8
+
9
+ import type { SearchResultFormatted } from "./format.ts";
10
+
11
+ // =============================================================================
12
+ // Tavily API Types
13
+ // =============================================================================
14
+
15
+ /** A single result from the Tavily Search API. */
16
+ export interface TavilyResult {
17
+ title: string;
18
+ url: string;
19
+ content: string;
20
+ score: number;
21
+ raw_content?: string | null;
22
+ published_date?: string | null;
23
+ favicon?: string | null;
24
+ }
25
+
26
+ /** Top-level response from POST https://api.tavily.com/search */
27
+ export interface TavilySearchResponse {
28
+ query: string;
29
+ answer?: string | null;
30
+ results: TavilyResult[];
31
+ response_time: string | number;
32
+ usage?: { credits: number } | null;
33
+ request_id?: string | null;
34
+ }
35
+
36
+ // =============================================================================
37
+ // Result Normalization
38
+ // =============================================================================
39
+
40
+ /**
41
+ * Map a single Tavily result to the shared SearchResultFormatted shape.
42
+ *
43
+ * - `content` → `description` (Tavily puts NLP summary or chunks inline)
44
+ * - `published_date` → `age` via publishedDateToAge()
45
+ * - No `extra_snippets` — Tavily's content already includes chunk data
46
+ */
47
+ export function normalizeTavilyResult(r: TavilyResult): SearchResultFormatted {
48
+ return {
49
+ title: r.title || "(untitled)",
50
+ url: r.url,
51
+ description: r.content || "",
52
+ age: r.published_date ? publishedDateToAge(r.published_date) : undefined,
53
+ };
54
+ }
55
+
56
+ // =============================================================================
57
+ // Date-to-Age Conversion
58
+ // =============================================================================
59
+
60
+ /**
61
+ * Convert an ISO 8601 date string to a human-readable relative age string.
62
+ *
63
+ * Examples: "3 days ago", "2 hours ago", "1 month ago", "just now"
64
+ * Returns undefined for unparseable dates or dates in the future.
65
+ */
66
+ export function publishedDateToAge(isoDate: string): string | undefined {
67
+ const date = new Date(isoDate);
68
+ if (isNaN(date.getTime())) return undefined;
69
+
70
+ const now = Date.now();
71
+ const diffMs = now - date.getTime();
72
+
73
+ // Future dates — return undefined rather than negative ages
74
+ if (diffMs < 0) return undefined;
75
+
76
+ const seconds = Math.floor(diffMs / 1000);
77
+ if (seconds < 60) return "just now";
78
+
79
+ const minutes = Math.floor(seconds / 60);
80
+ if (minutes < 60) return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
81
+
82
+ const hours = Math.floor(minutes / 60);
83
+ if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
84
+
85
+ const days = Math.floor(hours / 24);
86
+ if (days < 30) return `${days} ${days === 1 ? "day" : "days"} ago`;
87
+
88
+ const months = Math.floor(days / 30);
89
+ if (months < 12) return `${months} ${months === 1 ? "month" : "months"} ago`;
90
+
91
+ const years = Math.floor(months / 12);
92
+ return `${years} ${years === 1 ? "year" : "years"} ago`;
93
+ }
94
+
95
+ // =============================================================================
96
+ // Freshness Format Mapping
97
+ // =============================================================================
98
+
99
+ /** Brave freshness string → Tavily time_range value mapping. */
100
+ const BRAVE_TO_TAVILY_FRESHNESS: Record<string, string> = {
101
+ pd: "day",
102
+ pw: "week",
103
+ pm: "month",
104
+ py: "year",
105
+ };
106
+
107
+ /**
108
+ * Convert a Brave-format freshness string (pd/pw/pm/py) to a Tavily
109
+ * `time_range` value (day/week/month/year).
110
+ *
111
+ * Returns null if input is null or not a recognized Brave freshness value.
112
+ */
113
+ export function mapFreshnessToTavily(braveFreshness: string | null): string | null {
114
+ if (braveFreshness === null) return null;
115
+ return BRAVE_TO_TAVILY_FRESHNESS[braveFreshness] ?? null;
116
+ }