@kata-sh/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,519 @@
1
+ /**
2
+ * fetch_page tool — Extract clean markdown from any URL.
3
+ *
4
+ * v3 improvements:
5
+ * - offset parameter for continuation reading (like file read offsets)
6
+ * - selector parameter for Jina's X-Target-Selector (extract specific sections)
7
+ * - Jina failure diagnostics surfaced in details
8
+ * - Content-type awareness (JSON passthrough, PDF detection)
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent";
13
+ import { Text } from "@mariozechner/pi-tui";
14
+ import { Type } from "@sinclair/typebox";
15
+
16
+ import { LRUTTLCache } from "./cache";
17
+ import { fetchSimple, HttpError } from "./http";
18
+ import { extractDomain } from "./url-utils";
19
+ import { formatPageContent, type FormatPageOptions } from "./format";
20
+
21
+ // =============================================================================
22
+ // Cache
23
+ // =============================================================================
24
+
25
+ interface CachedPage {
26
+ content: string;
27
+ title?: string;
28
+ source: "jina" | "direct";
29
+ }
30
+
31
+ // Page content cache: max 30 entries, 15-minute TTL
32
+ const pageCache = new LRUTTLCache<CachedPage>({ max: 30, ttlMs: 900_000 });
33
+ pageCache.startPurgeInterval(120_000);
34
+
35
+ // =============================================================================
36
+ // Jina Reader
37
+ // =============================================================================
38
+
39
+ /**
40
+ * Fetch page content via Jina Reader API.
41
+ * Returns content + metadata, or throws with a descriptive error.
42
+ */
43
+ async function fetchViaJina(
44
+ url: string,
45
+ options: { signal?: AbortSignal; selector?: string } = {}
46
+ ): Promise<{ content: string; title?: string }> {
47
+ const jinaUrl = `https://r.jina.ai/${url}`;
48
+
49
+ const headers: Record<string, string> = {
50
+ "Accept": "text/plain",
51
+ "X-Return-Format": "markdown",
52
+ "X-No-Cache": "false",
53
+ };
54
+
55
+ // Use Jina API key if available for higher rate limits
56
+ const jinaKey = process.env.JINA_API_KEY;
57
+ if (jinaKey) {
58
+ headers["Authorization"] = `Bearer ${jinaKey}`;
59
+ }
60
+
61
+ // Target specific CSS selector on the page
62
+ if (options.selector) {
63
+ headers["X-Target-Selector"] = options.selector;
64
+ }
65
+
66
+ const response = await fetchSimple(jinaUrl, {
67
+ method: "GET",
68
+ headers,
69
+ signal: options.signal,
70
+ timeoutMs: 20_000,
71
+ });
72
+
73
+ const text = await response.text();
74
+
75
+ // Jina returns markdown with a title line at the top
76
+ // Format: "Title: <title>\nURL Source: <url>\n\n<content>"
77
+ let title: string | undefined;
78
+ let content = text;
79
+
80
+ const titleMatch = text.match(/^Title:\s*(.+)\n/);
81
+ if (titleMatch) {
82
+ title = titleMatch[1].trim();
83
+ content = text.replace(/^Title:\s*.+\n/, "");
84
+ }
85
+
86
+ // Strip the URL Source line
87
+ content = content.replace(/^URL Source:\s*.+\n\n?/, "");
88
+
89
+ // Strip Markdown images to save tokens
90
+ content = content.replace(/!\[([^\]]*)\]\([^)]+\)/g, "");
91
+
92
+ // Collapse excessive whitespace
93
+ content = content.replace(/\n{4,}/g, "\n\n\n");
94
+
95
+ return { content: content.trim(), title };
96
+ }
97
+
98
+ /**
99
+ * Basic fallback: fetch raw HTML and do crude text extraction.
100
+ */
101
+ async function fetchDirectFallback(
102
+ url: string,
103
+ signal?: AbortSignal
104
+ ): Promise<{ content: string; title?: string; contentType?: string }> {
105
+ const response = await fetchSimple(url, {
106
+ method: "GET",
107
+ headers: {
108
+ "Accept": "text/html,application/xhtml+xml,application/json,text/plain",
109
+ "User-Agent": "Mozilla/5.0 (compatible; pi-coding-agent/1.0)",
110
+ },
111
+ signal,
112
+ timeoutMs: 15_000,
113
+ });
114
+
115
+ const contentType = response.headers.get("content-type") || "";
116
+
117
+ // JSON passthrough — return formatted JSON directly
118
+ if (contentType.includes("application/json")) {
119
+ const text = await response.text();
120
+ try {
121
+ const parsed = JSON.parse(text);
122
+ return {
123
+ content: "```json\n" + JSON.stringify(parsed, null, 2) + "\n```",
124
+ title: undefined,
125
+ contentType: "application/json",
126
+ };
127
+ } catch {
128
+ return { content: text, title: undefined, contentType };
129
+ }
130
+ }
131
+
132
+ // Plain text passthrough
133
+ if (contentType.includes("text/plain")) {
134
+ const text = await response.text();
135
+ return { content: text, title: undefined, contentType: "text/plain" };
136
+ }
137
+
138
+ // PDF detection — can't extract, but tell the agent
139
+ if (contentType.includes("application/pdf")) {
140
+ return {
141
+ content: "[This URL is a PDF document. Content extraction is not supported for PDFs.]",
142
+ title: undefined,
143
+ contentType: "application/pdf",
144
+ };
145
+ }
146
+
147
+ const html = await response.text();
148
+
149
+ // Extract title
150
+ const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
151
+ const title = titleMatch ? titleMatch[1].trim() : undefined;
152
+
153
+ // Strip tags, decode entities, collapse whitespace
154
+ let text = html
155
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
156
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
157
+ .replace(/<nav[\s\S]*?<\/nav>/gi, "")
158
+ .replace(/<header[\s\S]*?<\/header>/gi, "")
159
+ .replace(/<footer[\s\S]*?<\/footer>/gi, "")
160
+ .replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|pre|section|article)[^>]*>/gi, "\n")
161
+ .replace(/<[^>]+>/g, " ")
162
+ .replace(/&amp;/g, "&")
163
+ .replace(/&lt;/g, "<")
164
+ .replace(/&gt;/g, ">")
165
+ .replace(/&quot;/g, '"')
166
+ .replace(/&#39;/g, "'")
167
+ .replace(/&nbsp;/g, " ")
168
+ .replace(/[ \t]+/g, " ")
169
+ .replace(/\n[ \t]+/g, "\n")
170
+ .replace(/\n{3,}/g, "\n\n")
171
+ .trim();
172
+
173
+ return { content: text, title, contentType };
174
+ }
175
+
176
+ // =============================================================================
177
+ // Smart Truncation
178
+ // =============================================================================
179
+
180
+ /**
181
+ * Truncate page content to a target character count, trying to break
182
+ * at paragraph boundaries rather than mid-sentence.
183
+ */
184
+ function smartTruncate(
185
+ content: string,
186
+ maxChars: number,
187
+ offset: number = 0
188
+ ): { content: string; truncated: boolean; hasMore: boolean; nextOffset?: number } {
189
+ // Apply offset first
190
+ const sliced = offset > 0 ? content.slice(offset) : content;
191
+
192
+ if (sliced.length <= maxChars) {
193
+ return { content: sliced, truncated: false, hasMore: false };
194
+ }
195
+
196
+ // Find the last paragraph break before maxChars
197
+ const window = sliced.slice(0, maxChars);
198
+ const lastParagraph = window.lastIndexOf("\n\n");
199
+ const lastSentence = window.lastIndexOf(". ");
200
+ const lastNewline = window.lastIndexOf("\n");
201
+
202
+ // Prefer paragraph > sentence > newline > hard cut
203
+ let cutPoint = maxChars;
204
+ if (lastParagraph > maxChars * 0.6) {
205
+ cutPoint = lastParagraph;
206
+ } else if (lastSentence > maxChars * 0.6) {
207
+ cutPoint = lastSentence + 1;
208
+ } else if (lastNewline > maxChars * 0.6) {
209
+ cutPoint = lastNewline;
210
+ }
211
+
212
+ const nextOffset = offset + cutPoint;
213
+ const hasMore = nextOffset < content.length;
214
+
215
+ return {
216
+ content: sliced.slice(0, cutPoint).trim() + "\n\n[... content truncated]",
217
+ truncated: true,
218
+ hasMore,
219
+ nextOffset: hasMore ? nextOffset : undefined,
220
+ };
221
+ }
222
+
223
+ // =============================================================================
224
+ // Single page fetch (shared between single and multi modes)
225
+ // =============================================================================
226
+
227
+ interface FetchPageResult {
228
+ content: string;
229
+ title?: string;
230
+ source: "jina" | "direct";
231
+ jinaError?: string;
232
+ contentType?: string;
233
+ originalChars: number;
234
+ }
235
+
236
+ async function fetchOnePage(
237
+ url: string,
238
+ options: { signal?: AbortSignal; selector?: string }
239
+ ): Promise<FetchPageResult> {
240
+ let pageContent: string;
241
+ let pageTitle: string | undefined;
242
+ let source: "jina" | "direct" = "jina";
243
+ let jinaError: string | undefined;
244
+ let contentType: string | undefined;
245
+
246
+ try {
247
+ const result = await fetchViaJina(url, options);
248
+ pageContent = result.content;
249
+ pageTitle = result.title;
250
+ } catch (err) {
251
+ // Capture Jina failure reason for diagnostics
252
+ jinaError = err instanceof HttpError
253
+ ? `Jina HTTP ${err.statusCode}`
254
+ : (err as Error).message ?? String(err);
255
+ source = "direct";
256
+
257
+ const result = await fetchDirectFallback(url, options.signal);
258
+ pageContent = result.content;
259
+ pageTitle = result.title;
260
+ contentType = result.contentType;
261
+ }
262
+
263
+ return {
264
+ content: pageContent,
265
+ title: pageTitle,
266
+ source,
267
+ jinaError,
268
+ contentType,
269
+ originalChars: pageContent.length,
270
+ };
271
+ }
272
+
273
+ // =============================================================================
274
+ // Details Interface
275
+ // =============================================================================
276
+
277
+ interface FetchPageDetails {
278
+ url: string;
279
+ title?: string;
280
+ charCount: number;
281
+ originalChars?: number;
282
+ truncated: boolean;
283
+ cached: boolean;
284
+ source?: "jina" | "direct";
285
+ jinaError?: string;
286
+ contentType?: string;
287
+ hasMore?: boolean;
288
+ nextOffset?: number;
289
+ offset?: number;
290
+ selector?: string;
291
+ error?: string;
292
+ }
293
+
294
+ // =============================================================================
295
+ // Tool Registration
296
+ // =============================================================================
297
+
298
+ export function registerFetchPageTool(pi: ExtensionAPI) {
299
+ pi.registerTool({
300
+ name: "fetch_page",
301
+ label: "Fetch Page",
302
+ description:
303
+ "Fetch a web page and extract its content as clean markdown. " +
304
+ "Use this to read the full content of URLs found via search-the-web. " +
305
+ "Uses Jina Reader for high-quality markdown extraction. " +
306
+ "Control the amount of content returned with maxChars (default: 8000, max: 30000).",
307
+ promptSnippet: "Fetch and extract clean content from a web page URL as markdown",
308
+ promptGuidelines: [
309
+ "Use fetch_page to read the content of URLs found via search-the-web when you need more detail than snippets provide.",
310
+ "Start with the default maxChars (8000). Increase only if the first fetch lacks the detail you need.",
311
+ "For very long pages, use a smaller maxChars and increase if needed — this saves context tokens.",
312
+ "The extracted content is already clean markdown — no HTML tags, no navigation, no ads.",
313
+ ],
314
+ parameters: Type.Object({
315
+ url: Type.String({ description: "URL to fetch and extract content from" }),
316
+ maxChars: Type.Optional(
317
+ Type.Number({
318
+ minimum: 1000,
319
+ maximum: 30000,
320
+ default: 8000,
321
+ description: "Maximum characters of content to return (default: 8000, max: 30000). Controls context token usage.",
322
+ })
323
+ ),
324
+ offset: Type.Optional(
325
+ Type.Number({
326
+ minimum: 0,
327
+ description: "Character offset to start reading from (for continuation of truncated pages). Use the nextOffset value from a previous fetch_page result.",
328
+ })
329
+ ),
330
+ selector: Type.Optional(
331
+ Type.String({
332
+ description: "CSS selector to extract only a specific section of the page (e.g., 'main', 'article', '.api-docs'). Reduces noise and token usage.",
333
+ })
334
+ ),
335
+ }),
336
+
337
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
338
+ if (signal?.aborted) {
339
+ return { content: [{ type: "text", text: "Fetch cancelled." }] };
340
+ }
341
+
342
+ const maxChars = params.maxChars ?? 8000;
343
+ const offset = params.offset ?? 0;
344
+ const url = params.url.trim();
345
+
346
+ // Validate URL
347
+ try {
348
+ new URL(url);
349
+ } catch {
350
+ return {
351
+ content: [{ type: "text", text: `Invalid URL: ${url}` }],
352
+ isError: true,
353
+ details: { error: "Invalid URL", url } satisfies Partial<FetchPageDetails>,
354
+ };
355
+ }
356
+
357
+ // ------------------------------------------------------------------
358
+ // Cache lookup (full content cached, offset/truncation applied after)
359
+ // ------------------------------------------------------------------
360
+ const cacheKey = params.selector ? `${url}|sel:${params.selector}` : url;
361
+ const cached = pageCache.get(cacheKey);
362
+
363
+ if (cached) {
364
+ const trunc = smartTruncate(cached.content, maxChars, offset);
365
+ const opts: FormatPageOptions = {
366
+ title: cached.title,
367
+ charCount: trunc.content.length,
368
+ truncated: trunc.truncated,
369
+ originalChars: trunc.truncated ? cached.content.length : undefined,
370
+ hasMore: trunc.hasMore,
371
+ nextOffset: trunc.nextOffset,
372
+ };
373
+ const output = formatPageContent(url, trunc.content, opts);
374
+
375
+ const finalTruncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
376
+ const details: FetchPageDetails = {
377
+ url,
378
+ title: cached.title,
379
+ charCount: trunc.content.length,
380
+ originalChars: cached.content.length,
381
+ truncated: trunc.truncated,
382
+ cached: true,
383
+ source: cached.source,
384
+ hasMore: trunc.hasMore,
385
+ nextOffset: trunc.nextOffset,
386
+ offset: offset || undefined,
387
+ };
388
+ return {
389
+ content: [{ type: "text", text: finalTruncation.content }],
390
+ details,
391
+ };
392
+ }
393
+
394
+ const domain = extractDomain(url);
395
+ onUpdate?.({ content: [{ type: "text", text: `Fetching ${domain}...` }] });
396
+
397
+ // ------------------------------------------------------------------
398
+ // Fetch page content
399
+ // ------------------------------------------------------------------
400
+ let result: FetchPageResult;
401
+ try {
402
+ result = await fetchOnePage(url, { signal, selector: params.selector });
403
+ } catch (err) {
404
+ const message = err instanceof HttpError
405
+ ? `HTTP ${err.statusCode}`
406
+ : (err as Error).message ?? String(err);
407
+ return {
408
+ content: [{ type: "text", text: `Failed to fetch ${domain}: ${message}` }],
409
+ isError: true,
410
+ details: { error: message, url } satisfies Partial<FetchPageDetails>,
411
+ };
412
+ }
413
+
414
+ // Check for empty content
415
+ if (!result.content || result.content.length < 50) {
416
+ return {
417
+ content: [{ type: "text", text: `Page at ${domain} returned no extractable content.` }],
418
+ details: { url, charCount: 0, source: result.source, cached: false, truncated: false, jinaError: result.jinaError } satisfies FetchPageDetails,
419
+ };
420
+ }
421
+
422
+ // Cache the full content
423
+ pageCache.set(cacheKey, { content: result.content, title: result.title, source: result.source });
424
+
425
+ // Smart truncate with offset
426
+ const trunc = smartTruncate(result.content, maxChars, offset);
427
+
428
+ const opts: FormatPageOptions = {
429
+ title: result.title,
430
+ charCount: trunc.content.length,
431
+ truncated: trunc.truncated,
432
+ originalChars: trunc.truncated ? result.originalChars : undefined,
433
+ hasMore: trunc.hasMore,
434
+ nextOffset: trunc.nextOffset,
435
+ };
436
+
437
+ const output = formatPageContent(url, trunc.content, opts);
438
+
439
+ const finalTruncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
440
+ let content = finalTruncation.content;
441
+ if (finalTruncation.truncated) {
442
+ const tempFile = await pi.writeTempFile(output, { prefix: "fetch-page-" });
443
+ content += `\n\n[Truncated to fit context. Full content: ${tempFile}]`;
444
+ }
445
+
446
+ const details: FetchPageDetails = {
447
+ url,
448
+ title: result.title,
449
+ charCount: trunc.content.length,
450
+ originalChars: result.originalChars,
451
+ truncated: trunc.truncated,
452
+ cached: false,
453
+ source: result.source,
454
+ jinaError: result.jinaError,
455
+ contentType: result.contentType,
456
+ hasMore: trunc.hasMore,
457
+ nextOffset: trunc.nextOffset,
458
+ offset: offset || undefined,
459
+ selector: params.selector,
460
+ };
461
+
462
+ return {
463
+ content: [{ type: "text", text: content }],
464
+ details,
465
+ };
466
+ },
467
+
468
+ renderCall(args, theme) {
469
+ const domain = extractDomain(args.url);
470
+ let text = theme.fg("toolTitle", theme.bold("fetch_page "));
471
+ text += theme.fg("accent", domain);
472
+
473
+ const meta: string[] = [];
474
+ if (args.maxChars && args.maxChars !== 8000) meta.push(`max ${(args.maxChars / 1000).toFixed(0)}k`);
475
+ if (args.offset) meta.push(`offset:${args.offset}`);
476
+ if (args.selector) meta.push(`sel:"${args.selector}"`);
477
+ if (meta.length > 0) {
478
+ text += " " + theme.fg("dim", `(${meta.join(", ")})`);
479
+ }
480
+
481
+ return new Text(text, 0, 0);
482
+ },
483
+
484
+ renderResult(result, { expanded }, theme) {
485
+ const details = result.details as FetchPageDetails | undefined;
486
+ if (details?.error) {
487
+ return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
488
+ }
489
+
490
+ const domain = extractDomain(details?.url || "");
491
+ const title = details?.title ? ` — ${details.title}` : "";
492
+ const chars = details?.charCount ? `${(details.charCount / 1000).toFixed(1)}k chars` : "";
493
+ const cacheTag = details?.cached ? theme.fg("dim", " [cached]") : "";
494
+ const sourceTag = details?.source === "direct" ? theme.fg("dim", " [direct]") : "";
495
+ const truncTag = details?.truncated && details?.originalChars
496
+ ? theme.fg("dim", ` [${(details.originalChars / 1000).toFixed(0)}k total]`)
497
+ : "";
498
+ const moreTag = details?.hasMore && details?.nextOffset
499
+ ? theme.fg("accent", ` [more→offset:${details.nextOffset}]`)
500
+ : "";
501
+ const jinaTag = details?.jinaError
502
+ ? theme.fg("warning", ` [jina failed: ${details.jinaError}]`)
503
+ : "";
504
+
505
+ let text = theme.fg("success", `✓ ${domain}${title}`) + ` ${chars}` +
506
+ cacheTag + sourceTag + truncTag + moreTag + jinaTag;
507
+
508
+ if (expanded) {
509
+ const content = result.content[0];
510
+ if (content?.type === "text") {
511
+ const preview = content.text.split("\n").slice(0, 8).join("\n");
512
+ text += "\n\n" + theme.fg("dim", preview);
513
+ }
514
+ }
515
+
516
+ return new Text(text, 0, 0);
517
+ },
518
+ });
519
+ }