@kata-sh/cli 0.1.0 → 0.1.2

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 (199) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -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 +56 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +95 -0
  9. package/dist/resource-loader.d.ts +18 -0
  10. package/dist/resource-loader.js +50 -0
  11. package/dist/wizard.d.ts +15 -0
  12. package/dist/wizard.js +159 -0
  13. package/package.json +50 -21
  14. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  15. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  16. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  17. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  18. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  19. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  20. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  21. package/pkg/package.json +8 -0
  22. package/scripts/postinstall.js +45 -0
  23. package/src/resources/AGENTS.md +108 -0
  24. package/src/resources/KATA-WORKFLOW.md +661 -0
  25. package/src/resources/agents/researcher.md +29 -0
  26. package/src/resources/agents/scout.md +56 -0
  27. package/src/resources/agents/worker.md +31 -0
  28. package/src/resources/extensions/ask-user-questions.ts +200 -0
  29. package/src/resources/extensions/bg-shell/index.ts +2758 -0
  30. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  31. package/src/resources/extensions/browser-tools/core.js +1057 -0
  32. package/src/resources/extensions/browser-tools/index.ts +4916 -0
  33. package/src/resources/extensions/browser-tools/package.json +20 -0
  34. package/src/resources/extensions/context7/index.ts +428 -0
  35. package/src/resources/extensions/context7/package.json +11 -0
  36. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  37. package/src/resources/extensions/github/formatters.ts +207 -0
  38. package/src/resources/extensions/github/gh-api.ts +537 -0
  39. package/src/resources/extensions/github/index.ts +778 -0
  40. package/src/resources/extensions/kata/activity-log.ts +88 -0
  41. package/src/resources/extensions/kata/auto.ts +2786 -0
  42. package/src/resources/extensions/kata/commands.ts +355 -0
  43. package/src/resources/extensions/kata/crash-recovery.ts +85 -0
  44. package/src/resources/extensions/kata/dashboard-overlay.ts +516 -0
  45. package/src/resources/extensions/kata/docs/preferences-reference.md +103 -0
  46. package/src/resources/extensions/kata/doctor.ts +683 -0
  47. package/src/resources/extensions/kata/files.ts +730 -0
  48. package/src/resources/extensions/kata/gitignore.ts +165 -0
  49. package/src/resources/extensions/kata/guided-flow.ts +976 -0
  50. package/src/resources/extensions/kata/index.ts +556 -0
  51. package/src/resources/extensions/kata/metrics.ts +397 -0
  52. package/src/resources/extensions/kata/observability-validator.ts +408 -0
  53. package/src/resources/extensions/kata/package.json +11 -0
  54. package/src/resources/extensions/kata/paths.ts +346 -0
  55. package/src/resources/extensions/kata/preferences.ts +695 -0
  56. package/src/resources/extensions/kata/prompt-loader.ts +50 -0
  57. package/src/resources/extensions/kata/prompts/complete-milestone.md +25 -0
  58. package/src/resources/extensions/kata/prompts/complete-slice.md +27 -0
  59. package/src/resources/extensions/kata/prompts/discuss.md +151 -0
  60. package/src/resources/extensions/kata/prompts/doctor-heal.md +29 -0
  61. package/src/resources/extensions/kata/prompts/execute-task.md +64 -0
  62. package/src/resources/extensions/kata/prompts/guided-complete-slice.md +1 -0
  63. package/src/resources/extensions/kata/prompts/guided-discuss-milestone.md +3 -0
  64. package/src/resources/extensions/kata/prompts/guided-discuss-slice.md +59 -0
  65. package/src/resources/extensions/kata/prompts/guided-execute-task.md +1 -0
  66. package/src/resources/extensions/kata/prompts/guided-plan-milestone.md +23 -0
  67. package/src/resources/extensions/kata/prompts/guided-plan-slice.md +1 -0
  68. package/src/resources/extensions/kata/prompts/guided-research-slice.md +11 -0
  69. package/src/resources/extensions/kata/prompts/guided-resume-task.md +1 -0
  70. package/src/resources/extensions/kata/prompts/plan-milestone.md +47 -0
  71. package/src/resources/extensions/kata/prompts/plan-slice.md +63 -0
  72. package/src/resources/extensions/kata/prompts/queue.md +85 -0
  73. package/src/resources/extensions/kata/prompts/reassess-roadmap.md +48 -0
  74. package/src/resources/extensions/kata/prompts/replan-slice.md +39 -0
  75. package/src/resources/extensions/kata/prompts/research-milestone.md +37 -0
  76. package/src/resources/extensions/kata/prompts/research-slice.md +28 -0
  77. package/src/resources/extensions/kata/prompts/run-uat.md +109 -0
  78. package/src/resources/extensions/kata/prompts/system.md +341 -0
  79. package/src/resources/extensions/kata/session-forensics.ts +550 -0
  80. package/src/resources/extensions/kata/skill-discovery.ts +137 -0
  81. package/src/resources/extensions/kata/state.ts +509 -0
  82. package/src/resources/extensions/kata/templates/context.md +76 -0
  83. package/src/resources/extensions/kata/templates/decisions.md +8 -0
  84. package/src/resources/extensions/kata/templates/milestone-summary.md +73 -0
  85. package/src/resources/extensions/kata/templates/plan.md +133 -0
  86. package/src/resources/extensions/kata/templates/preferences.md +15 -0
  87. package/src/resources/extensions/kata/templates/project.md +31 -0
  88. package/src/resources/extensions/kata/templates/reassessment.md +28 -0
  89. package/src/resources/extensions/kata/templates/requirements.md +81 -0
  90. package/src/resources/extensions/kata/templates/research.md +46 -0
  91. package/src/resources/extensions/kata/templates/roadmap.md +118 -0
  92. package/src/resources/extensions/kata/templates/slice-context.md +58 -0
  93. package/src/resources/extensions/kata/templates/slice-summary.md +99 -0
  94. package/src/resources/extensions/kata/templates/state.md +19 -0
  95. package/src/resources/extensions/kata/templates/task-plan.md +52 -0
  96. package/src/resources/extensions/kata/templates/task-summary.md +57 -0
  97. package/src/resources/extensions/kata/templates/uat.md +54 -0
  98. package/src/resources/extensions/kata/tests/activity-log-prune.test.ts +327 -0
  99. package/src/resources/extensions/kata/tests/auto-preflight.test.ts +97 -0
  100. package/src/resources/extensions/kata/tests/auto-supervisor.test.mjs +53 -0
  101. package/src/resources/extensions/kata/tests/complete-milestone.test.ts +317 -0
  102. package/src/resources/extensions/kata/tests/cost-projection.test.ts +160 -0
  103. package/src/resources/extensions/kata/tests/derive-state-deps.test.ts +477 -0
  104. package/src/resources/extensions/kata/tests/derive-state.test.ts +1013 -0
  105. package/src/resources/extensions/kata/tests/doctor.test.ts +718 -0
  106. package/src/resources/extensions/kata/tests/idle-recovery.test.ts +490 -0
  107. package/src/resources/extensions/kata/tests/metrics-io.test.ts +254 -0
  108. package/src/resources/extensions/kata/tests/metrics.test.ts +217 -0
  109. package/src/resources/extensions/kata/tests/must-have-parser.test.ts +309 -0
  110. package/src/resources/extensions/kata/tests/parsers.test.ts +1257 -0
  111. package/src/resources/extensions/kata/tests/plan-milestone.test.ts +185 -0
  112. package/src/resources/extensions/kata/tests/plan-quality-validator.test.ts +386 -0
  113. package/src/resources/extensions/kata/tests/reassess-prompt.test.ts +208 -0
  114. package/src/resources/extensions/kata/tests/replan-slice.test.ts +686 -0
  115. package/src/resources/extensions/kata/tests/requirements.test.ts +151 -0
  116. package/src/resources/extensions/kata/tests/resolve-ts-hooks.mjs +17 -0
  117. package/src/resources/extensions/kata/tests/resolve-ts.mjs +11 -0
  118. package/src/resources/extensions/kata/tests/run-uat.test.ts +383 -0
  119. package/src/resources/extensions/kata/tests/unit-runtime.test.ts +388 -0
  120. package/src/resources/extensions/kata/tests/workspace-index.test.ts +118 -0
  121. package/src/resources/extensions/kata/tests/worktree.test.ts +222 -0
  122. package/src/resources/extensions/kata/types.ts +159 -0
  123. package/src/resources/extensions/kata/unit-runtime.ts +163 -0
  124. package/src/resources/extensions/kata/workspace-index.ts +203 -0
  125. package/src/resources/extensions/kata/worktree.ts +182 -0
  126. package/src/resources/extensions/mac-tools/index.ts +852 -0
  127. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  128. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  129. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  130. package/src/resources/extensions/search-the-web/format.ts +258 -0
  131. package/src/resources/extensions/search-the-web/http.ts +238 -0
  132. package/src/resources/extensions/search-the-web/index.ts +68 -0
  133. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  134. package/src/resources/extensions/search-the-web/tool-llm-context.ts +404 -0
  135. package/src/resources/extensions/search-the-web/tool-search.ts +503 -0
  136. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  137. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  138. package/src/resources/extensions/shared/interview-ui.ts +822 -0
  139. package/src/resources/extensions/shared/next-action-ui.ts +235 -0
  140. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  141. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  142. package/src/resources/extensions/shared/ui.ts +400 -0
  143. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  144. package/src/resources/extensions/slash-commands/audit.ts +92 -0
  145. package/src/resources/extensions/slash-commands/create-extension.ts +375 -0
  146. package/src/resources/extensions/slash-commands/create-slash-command.ts +280 -0
  147. package/src/resources/extensions/slash-commands/index.ts +12 -0
  148. package/src/resources/extensions/slash-commands/kata-run.ts +34 -0
  149. package/src/resources/extensions/subagent/agents.ts +126 -0
  150. package/src/resources/extensions/subagent/index.ts +1293 -0
  151. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  152. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  153. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  154. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  155. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  156. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  157. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  158. package/src/resources/skills/swiftui/SKILL.md +208 -0
  159. package/src/resources/skills/swiftui/references/animations.md +921 -0
  160. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  161. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  162. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  163. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  164. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  165. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  166. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  167. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  168. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  169. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  170. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  171. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  172. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  173. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  174. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  175. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
  176. package/dist/commands/task.d.ts +0 -9
  177. package/dist/commands/task.d.ts.map +0 -1
  178. package/dist/commands/task.js +0 -129
  179. package/dist/commands/task.js.map +0 -1
  180. package/dist/commands/task.test.d.ts +0 -2
  181. package/dist/commands/task.test.d.ts.map +0 -1
  182. package/dist/commands/task.test.js +0 -169
  183. package/dist/commands/task.test.js.map +0 -1
  184. package/dist/e2e/task-e2e.test.d.ts +0 -2
  185. package/dist/e2e/task-e2e.test.d.ts.map +0 -1
  186. package/dist/e2e/task-e2e.test.js +0 -173
  187. package/dist/e2e/task-e2e.test.js.map +0 -1
  188. package/dist/index.d.ts +0 -3
  189. package/dist/index.d.ts.map +0 -1
  190. package/dist/index.js +0 -93
  191. package/dist/index.js.map +0 -1
  192. package/dist/slug.d.ts +0 -2
  193. package/dist/slug.d.ts.map +0 -1
  194. package/dist/slug.js +0 -12
  195. package/dist/slug.js.map +0 -1
  196. package/dist/slug.test.d.ts +0 -2
  197. package/dist/slug.test.d.ts.map +0 -1
  198. package/dist/slug.test.js +0 -32
  199. package/dist/slug.test.js.map +0 -1
@@ -0,0 +1,78 @@
1
+ /**
2
+ * LRU cache with TTL — zero external dependencies.
3
+ *
4
+ * - max: maximum entries before oldest is evicted
5
+ * - ttlMs: time-to-live per entry
6
+ *
7
+ * Uses a Map (insertion-ordered) for O(1) LRU eviction:
8
+ * on every access the entry is deleted and re-inserted at the tail.
9
+ */
10
+ export class LRUTTLCache<V> {
11
+ private readonly max: number;
12
+ private readonly ttlMs: number;
13
+ private readonly store = new Map<string, { value: V; expiresAt: number }>();
14
+ private purgeTimer: ReturnType<typeof setInterval> | null = null;
15
+
16
+ constructor(options: { max: number; ttlMs: number }) {
17
+ this.max = options.max;
18
+ this.ttlMs = options.ttlMs;
19
+ }
20
+
21
+ get(key: string): V | undefined {
22
+ const entry = this.store.get(key);
23
+ if (!entry) return undefined;
24
+ if (Date.now() > entry.expiresAt) {
25
+ this.store.delete(key);
26
+ return undefined;
27
+ }
28
+ // Refresh to tail (most-recently-used)
29
+ this.store.delete(key);
30
+ this.store.set(key, entry);
31
+ return entry.value;
32
+ }
33
+
34
+ set(key: string, value: V): void {
35
+ if (this.store.has(key)) {
36
+ this.store.delete(key);
37
+ } else if (this.store.size >= this.max) {
38
+ const oldest = this.store.keys().next().value;
39
+ if (oldest !== undefined) this.store.delete(oldest);
40
+ }
41
+ this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
42
+ }
43
+
44
+ has(key: string): boolean {
45
+ return this.get(key) !== undefined;
46
+ }
47
+
48
+ purgeStale(): void {
49
+ const now = Date.now();
50
+ for (const [key, entry] of this.store) {
51
+ if (now > entry.expiresAt) this.store.delete(key);
52
+ }
53
+ }
54
+
55
+ startPurgeInterval(intervalMs: number): void {
56
+ if (this.purgeTimer !== null) return;
57
+ this.purgeTimer = setInterval(() => this.purgeStale(), intervalMs);
58
+ // Don't keep the process alive just for cache cleanup
59
+ if (this.purgeTimer && typeof this.purgeTimer === "object" && "unref" in this.purgeTimer) {
60
+ (this.purgeTimer as NodeJS.Timeout).unref();
61
+ }
62
+ }
63
+
64
+ stopPurgeInterval(): void {
65
+ if (this.purgeTimer !== null) {
66
+ clearInterval(this.purgeTimer);
67
+ this.purgeTimer = null;
68
+ }
69
+ }
70
+
71
+ clear(): void {
72
+ this.store.clear();
73
+ }
74
+
75
+ get size(): number {
76
+ return this.store.size;
77
+ }
78
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Token-efficient output formatting for search results, page content,
3
+ * and LLM context responses.
4
+ */
5
+
6
+ import { extractDomain } from "./url-utils";
7
+
8
+ export interface SearchResultFormatted {
9
+ title: string;
10
+ url: string;
11
+ description: string;
12
+ age?: string;
13
+ extra_snippets?: string[];
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ // =============================================================================
18
+ // Adaptive Snippet Budget
19
+ // =============================================================================
20
+
21
+ /**
22
+ * Compute how many extra_snippets to show per result based on total count.
23
+ * Fewer results → more snippets each. More results → fewer snippets each.
24
+ *
25
+ * This keeps total output roughly constant regardless of result count.
26
+ */
27
+ function snippetsPerResult(resultCount: number): number {
28
+ if (resultCount <= 2) return 5; // show all available
29
+ if (resultCount <= 4) return 3;
30
+ if (resultCount <= 6) return 2;
31
+ if (resultCount <= 8) return 1;
32
+ return 0; // 9-10 results: descriptions only
33
+ }
34
+
35
+ // =============================================================================
36
+ // Search Results Formatting
37
+ // =============================================================================
38
+
39
+ export interface FormatSearchOptions {
40
+ cached?: boolean;
41
+ summary?: string;
42
+ queryCorrected?: boolean;
43
+ originalQuery?: string;
44
+ correctedQuery?: string;
45
+ moreResultsAvailable?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Format search results in a compact, token-efficient format.
50
+ *
51
+ * Produces:
52
+ * [1] Python Web Frameworks — example.com (2024-11)
53
+ * Main snippet text...
54
+ * + "additional excerpt 1"
55
+ * + "additional excerpt 2"
56
+ *
57
+ * Snippet count per result adapts to total result count.
58
+ */
59
+ export function formatSearchResults(
60
+ query: string,
61
+ results: SearchResultFormatted[],
62
+ options: FormatSearchOptions = {}
63
+ ): string {
64
+ const parts: string[] = [];
65
+
66
+ // Header
67
+ const cacheTag = options.cached ? " (cached)" : "";
68
+ parts.push(`Search: "${query}"${cacheTag}`);
69
+
70
+ // Spellcheck/query correction notice
71
+ if (options.queryCorrected && options.correctedQuery) {
72
+ parts.push(`Note: Query was corrected to "${options.correctedQuery}" (original: "${options.originalQuery ?? query}")`);
73
+ }
74
+
75
+ parts.push(""); // blank line after header
76
+
77
+ // AI summary block if available (from Brave Summarizer)
78
+ if (options.summary) {
79
+ parts.push(`Summary: ${options.summary}\n`);
80
+ }
81
+
82
+ if (results.length === 0) {
83
+ parts.push("No results found.");
84
+ return parts.join("\n");
85
+ }
86
+
87
+ const maxSnippets = snippetsPerResult(results.length);
88
+
89
+ // Results
90
+ for (let i = 0; i < results.length; i++) {
91
+ const r = results[i];
92
+ const domain = extractDomain(r.url);
93
+ const age = r.age ? ` (${r.age})` : "";
94
+
95
+ // Compact header line: [N] Title — domain (age)
96
+ parts.push(`[${i + 1}] ${r.title} — ${domain}${age}`);
97
+ parts.push(r.url);
98
+
99
+ // Primary description
100
+ if (r.description) {
101
+ parts.push(r.description);
102
+ }
103
+
104
+ // Extra snippets — adaptive count based on total results
105
+ if (maxSnippets > 0 && r.extra_snippets && r.extra_snippets.length > 0) {
106
+ for (const snippet of r.extra_snippets.slice(0, maxSnippets)) {
107
+ const clean = snippet.replace(/\n/g, " ").trim();
108
+ if (clean) parts.push(`+ ${clean}`);
109
+ }
110
+ }
111
+
112
+ parts.push(""); // blank line between results
113
+ }
114
+
115
+ // Pagination hint
116
+ if (options.moreResultsAvailable) {
117
+ parts.push("[More results available — increase count or refine query]");
118
+ }
119
+
120
+ return parts.join("\n");
121
+ }
122
+
123
+ // =============================================================================
124
+ // Page Content Formatting
125
+ // =============================================================================
126
+
127
+ export interface FormatPageOptions {
128
+ title?: string;
129
+ charCount: number;
130
+ truncated: boolean;
131
+ originalChars?: number;
132
+ hasMore?: boolean;
133
+ nextOffset?: number;
134
+ }
135
+
136
+ /**
137
+ * Format extracted page content with metadata header.
138
+ */
139
+ export function formatPageContent(
140
+ url: string,
141
+ content: string,
142
+ options: FormatPageOptions
143
+ ): string {
144
+ const domain = extractDomain(url);
145
+ const title = options.title ? ` — ${options.title}` : "";
146
+ const truncNote = options.truncated && options.originalChars
147
+ ? ` [truncated from ${options.originalChars.toLocaleString()} chars]`
148
+ : "";
149
+ const moreNote = options.hasMore && options.nextOffset
150
+ ? ` [use offset:${options.nextOffset} to continue reading]`
151
+ : "";
152
+
153
+ const header = `Page: ${domain}${title} (${options.charCount.toLocaleString()} chars)${truncNote}${moreNote}\n${url}\n---`;
154
+
155
+ return `${header}\n${content}`;
156
+ }
157
+
158
+ // =============================================================================
159
+ // LLM Context Formatting
160
+ // =============================================================================
161
+
162
+ export interface LLMContextSnippet {
163
+ url: string;
164
+ title: string;
165
+ snippets: string[];
166
+ }
167
+
168
+ export interface LLMContextSource {
169
+ title: string;
170
+ hostname: string;
171
+ age: string[] | null;
172
+ }
173
+
174
+ /**
175
+ * Format LLM Context API response in a compact, agent-optimized format.
176
+ *
177
+ * Output:
178
+ * Context: "query" (N sources, ~Mk tokens)
179
+ *
180
+ * [1] Page Title — domain.com (age)
181
+ * url
182
+ * Snippet text...
183
+ * ---
184
+ * Another snippet...
185
+ */
186
+ export function formatLLMContext(
187
+ query: string,
188
+ grounding: LLMContextSnippet[],
189
+ sources: Record<string, LLMContextSource>,
190
+ options: { cached?: boolean; tokenCount?: number } = {}
191
+ ): string {
192
+ const parts: string[] = [];
193
+
194
+ const cacheTag = options.cached ? " (cached)" : "";
195
+ const tokenTag = options.tokenCount ? ` (~${Math.round(options.tokenCount / 1000)}k tokens)` : "";
196
+ parts.push(`Context: "${query}" (${grounding.length} sources${tokenTag})${cacheTag}`);
197
+ parts.push("");
198
+
199
+ if (grounding.length === 0) {
200
+ parts.push("No relevant content found.");
201
+ return parts.join("\n");
202
+ }
203
+
204
+ for (let i = 0; i < grounding.length; i++) {
205
+ const g = grounding[i];
206
+ const source = sources[g.url];
207
+ const domain = source?.hostname || extractDomain(g.url);
208
+ const age = source?.age?.[2] ? ` (${source.age[2]})` : ""; // [2] is "N days ago" format
209
+
210
+ parts.push(`[${i + 1}] ${g.title || source?.title || "(untitled)"} — ${domain}${age}`);
211
+ parts.push(g.url);
212
+
213
+ // Join snippets with separator
214
+ for (const snippet of g.snippets) {
215
+ const clean = snippet.trim();
216
+ if (clean) parts.push(clean);
217
+ }
218
+
219
+ parts.push(""); // blank line between sources
220
+ }
221
+
222
+ return parts.join("\n");
223
+ }
224
+
225
+ // =============================================================================
226
+ // Multi-Page Formatting
227
+ // =============================================================================
228
+
229
+ /**
230
+ * Format multiple page extractions compactly.
231
+ */
232
+ export function formatMultiplePages(
233
+ pages: Array<{
234
+ url: string;
235
+ title?: string;
236
+ content: string;
237
+ charCount: number;
238
+ error?: string;
239
+ }>
240
+ ): string {
241
+ const parts: string[] = [];
242
+
243
+ for (const page of pages) {
244
+ const domain = extractDomain(page.url);
245
+ if (page.error) {
246
+ parts.push(`[✗] ${domain}: ${page.error}`);
247
+ } else {
248
+ const title = page.title ? ` — ${page.title}` : "";
249
+ parts.push(`[✓] ${domain}${title} (${page.charCount.toLocaleString()} chars)`);
250
+ parts.push(page.url);
251
+ parts.push("---");
252
+ parts.push(page.content);
253
+ }
254
+ parts.push(""); // separator
255
+ }
256
+
257
+ return parts.join("\n");
258
+ }
@@ -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. Use secure_env_collect to set BRAVE_API_KEY.` };
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,68 @@
1
+ /**
2
+ * Web Search Extension v3
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
+ * v3 improvements over v2:
19
+ * - search_and_read: New tool — Brave LLM Context API (search + read in one call)
20
+ * - Structured error taxonomy: auth_error, rate_limited, network_error, etc.
21
+ * - Spellcheck surfacing: query corrections from Brave shown to agent
22
+ * - Latency tracking: API call timing in details for observability
23
+ * - Rate limit info: remaining quota surfaced when available
24
+ * - more_results_available: pagination hints from Brave
25
+ * - Adaptive snippet budget: snippet count adapts to result count
26
+ * - fetch_page offset: continuation reading for long pages
27
+ * - fetch_page selector: CSS selector targeting via Jina X-Target-Selector
28
+ * - fetch_page diagnostics: Jina failure reasons surfaced in details
29
+ * - Content-type awareness: JSON passthrough, PDF detection
30
+ * - Cache timer cleanup: purge timers use unref() to not block process exit
31
+ *
32
+ * Environment variables:
33
+ * BRAVE_API_KEY — Required for search. Get one at brave.com/search/api
34
+ * JINA_API_KEY — Optional. Higher rate limits for page extraction.
35
+ */
36
+
37
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
38
+ import { registerSearchTool } from "./tool-search";
39
+ import { registerFetchPageTool } from "./tool-fetch-page";
40
+ import { registerLLMContextTool } from "./tool-llm-context";
41
+
42
+ export default function (pi: ExtensionAPI) {
43
+ // Register all tools
44
+ registerSearchTool(pi);
45
+ registerFetchPageTool(pi);
46
+ registerLLMContextTool(pi);
47
+
48
+ // Startup diagnostics
49
+ pi.on("session_start", async (_event, ctx) => {
50
+ const hasBrave = !!process.env.BRAVE_API_KEY;
51
+ const hasJina = !!process.env.JINA_API_KEY;
52
+ const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
53
+
54
+ if (!hasBrave) {
55
+ ctx.ui.notify(
56
+ "Web search: Set BRAVE_API_KEY for web search + LLM context capability",
57
+ "warning"
58
+ );
59
+ }
60
+
61
+ const parts: string[] = ["Web search v3 loaded"];
62
+ if (hasBrave) parts.push("Search ✓");
63
+ if (hasAnswers) parts.push("Answers ✓");
64
+ if (hasJina) parts.push("Jina ✓");
65
+
66
+ ctx.ui.notify(parts.join(" · "), "info");
67
+ });
68
+ }