@makefinks/daemon 0.1.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli.js +22 -0
  4. package/package.json +79 -0
  5. package/src/ai/agent-turn-runner.ts +130 -0
  6. package/src/ai/daemon-ai.ts +403 -0
  7. package/src/ai/exa-client.ts +21 -0
  8. package/src/ai/exa-fetch-cache.ts +104 -0
  9. package/src/ai/model-config.ts +99 -0
  10. package/src/ai/sanitize-messages.ts +83 -0
  11. package/src/ai/system-prompt.ts +363 -0
  12. package/src/ai/tools/fetch-urls.ts +187 -0
  13. package/src/ai/tools/grounding-manager.ts +94 -0
  14. package/src/ai/tools/index.ts +52 -0
  15. package/src/ai/tools/read-file.ts +100 -0
  16. package/src/ai/tools/render-url.ts +275 -0
  17. package/src/ai/tools/run-bash.ts +224 -0
  18. package/src/ai/tools/subagents.ts +195 -0
  19. package/src/ai/tools/todo-manager.ts +150 -0
  20. package/src/ai/tools/web-search.ts +91 -0
  21. package/src/app/App.tsx +711 -0
  22. package/src/app/components/AppOverlays.tsx +131 -0
  23. package/src/app/components/AvatarLayer.tsx +51 -0
  24. package/src/app/components/ConversationPane.tsx +476 -0
  25. package/src/avatar/DaemonAvatarRenderable.ts +343 -0
  26. package/src/avatar/daemon-avatar-rig.ts +1165 -0
  27. package/src/avatar-preview.ts +186 -0
  28. package/src/cli.ts +26 -0
  29. package/src/components/ApiKeyInput.tsx +99 -0
  30. package/src/components/ApiKeyStep.tsx +95 -0
  31. package/src/components/ApprovalPicker.tsx +109 -0
  32. package/src/components/ContentBlockView.tsx +141 -0
  33. package/src/components/DaemonText.tsx +34 -0
  34. package/src/components/DeviceMenu.tsx +166 -0
  35. package/src/components/GroundingBadge.tsx +21 -0
  36. package/src/components/GroundingMenu.tsx +310 -0
  37. package/src/components/HotkeysPane.tsx +115 -0
  38. package/src/components/InlineStatusIndicator.tsx +106 -0
  39. package/src/components/ModelMenu.tsx +411 -0
  40. package/src/components/OnboardingOverlay.tsx +446 -0
  41. package/src/components/ProviderMenu.tsx +177 -0
  42. package/src/components/SessionMenu.tsx +297 -0
  43. package/src/components/SettingsMenu.tsx +291 -0
  44. package/src/components/StatusBar.tsx +126 -0
  45. package/src/components/TokenUsageDisplay.tsx +92 -0
  46. package/src/components/ToolCallView.tsx +113 -0
  47. package/src/components/TypingInputBar.tsx +131 -0
  48. package/src/components/tool-layouts/components.tsx +120 -0
  49. package/src/components/tool-layouts/defaults.ts +9 -0
  50. package/src/components/tool-layouts/index.ts +22 -0
  51. package/src/components/tool-layouts/layouts/bash.ts +110 -0
  52. package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
  53. package/src/components/tool-layouts/layouts/index.ts +8 -0
  54. package/src/components/tool-layouts/layouts/read-file.ts +59 -0
  55. package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
  56. package/src/components/tool-layouts/layouts/system-info.ts +8 -0
  57. package/src/components/tool-layouts/layouts/todo.tsx +139 -0
  58. package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
  59. package/src/components/tool-layouts/layouts/web-search.ts +110 -0
  60. package/src/components/tool-layouts/registry.ts +17 -0
  61. package/src/components/tool-layouts/types.ts +94 -0
  62. package/src/hooks/daemon-event-handlers.ts +944 -0
  63. package/src/hooks/keyboard-handlers.ts +399 -0
  64. package/src/hooks/menu-navigation.ts +147 -0
  65. package/src/hooks/use-app-audio-devices-loader.ts +71 -0
  66. package/src/hooks/use-app-callbacks.ts +202 -0
  67. package/src/hooks/use-app-context-builder.ts +159 -0
  68. package/src/hooks/use-app-display-state.ts +162 -0
  69. package/src/hooks/use-app-menus.ts +51 -0
  70. package/src/hooks/use-app-model-pricing-loader.ts +45 -0
  71. package/src/hooks/use-app-model.ts +123 -0
  72. package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
  73. package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
  74. package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
  75. package/src/hooks/use-app-sessions.ts +105 -0
  76. package/src/hooks/use-app-settings.ts +62 -0
  77. package/src/hooks/use-conversation-manager.ts +163 -0
  78. package/src/hooks/use-copy-on-select.ts +50 -0
  79. package/src/hooks/use-daemon-events.ts +396 -0
  80. package/src/hooks/use-daemon-keyboard.ts +397 -0
  81. package/src/hooks/use-grounding.ts +46 -0
  82. package/src/hooks/use-input-history.ts +92 -0
  83. package/src/hooks/use-menu-keyboard.ts +93 -0
  84. package/src/hooks/use-playwright-notification.ts +23 -0
  85. package/src/hooks/use-reasoning-animation.ts +97 -0
  86. package/src/hooks/use-response-timer.ts +55 -0
  87. package/src/hooks/use-tool-approval.tsx +202 -0
  88. package/src/hooks/use-typing-mode.ts +137 -0
  89. package/src/hooks/use-voice-dependencies-notification.ts +37 -0
  90. package/src/index.tsx +48 -0
  91. package/src/scripts/setup-browsers.ts +42 -0
  92. package/src/state/app-context.tsx +160 -0
  93. package/src/state/daemon-events.ts +67 -0
  94. package/src/state/daemon-state.ts +493 -0
  95. package/src/state/migrations/001-init.ts +33 -0
  96. package/src/state/migrations/index.ts +8 -0
  97. package/src/state/model-history-store.ts +45 -0
  98. package/src/state/runtime-context.ts +21 -0
  99. package/src/state/session-store.ts +359 -0
  100. package/src/types/index.ts +405 -0
  101. package/src/types/theme.ts +52 -0
  102. package/src/ui/constants.ts +157 -0
  103. package/src/utils/clipboard.ts +89 -0
  104. package/src/utils/debug-logger.ts +69 -0
  105. package/src/utils/formatters.ts +242 -0
  106. package/src/utils/js-rendering.ts +77 -0
  107. package/src/utils/markdown-tables.ts +234 -0
  108. package/src/utils/model-metadata.ts +191 -0
  109. package/src/utils/openrouter-endpoints.ts +212 -0
  110. package/src/utils/openrouter-models.ts +205 -0
  111. package/src/utils/openrouter-pricing.ts +59 -0
  112. package/src/utils/openrouter-reported-cost.ts +16 -0
  113. package/src/utils/paste.ts +33 -0
  114. package/src/utils/preferences.ts +289 -0
  115. package/src/utils/text-fragment.ts +39 -0
  116. package/src/utils/tool-output-preview.ts +250 -0
  117. package/src/utils/voice-dependencies.ts +107 -0
  118. package/src/utils/workspace-manager.ts +85 -0
  119. package/src/voice/audio-recorder.ts +579 -0
  120. package/src/voice/mic-level.ts +35 -0
  121. package/src/voice/tts/openai-tts-stream.ts +222 -0
  122. package/src/voice/tts/speech-controller.ts +64 -0
  123. package/src/voice/tts/tts-player.ts +257 -0
  124. package/src/voice/voice-input-controller.ts +96 -0
@@ -0,0 +1,94 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { daemonEvents } from "../../state/daemon-events";
4
+ import { getRuntimeContext } from "../../state/runtime-context";
5
+ import { loadLatestGroundingMap, saveGroundingMap } from "../../state/session-store";
6
+
7
+ const groundingSourceSchema = z.object({
8
+ url: z.string().url().describe("The source URL where the information was found."),
9
+ quote: z
10
+ .string()
11
+ .min(1)
12
+ .max(300)
13
+ .describe("A short excerpt (1-2 sentences) from the source that supports the statement."),
14
+ textFragment: z
15
+ .string()
16
+ .min(1)
17
+ .max(100)
18
+ .describe("A short phrase or subphrase (MUST BE COPIED VERBATIM) from the source text for deep-linking."),
19
+ });
20
+
21
+ const groundedStatementSchema = z.object({
22
+ id: z.string().min(1).describe("Unique identifier for this grounding (e.g., 'g1', 'g2')."),
23
+ statement: z.string().min(1).describe("The factual claim being grounded."),
24
+ source: groundingSourceSchema.describe("The source backing this statement."),
25
+ });
26
+
27
+ // Preprocess items that may arrive as a JSON string (some models stringify arrays)
28
+ const itemsSchema = z.preprocess((val) => {
29
+ if (typeof val === "string") {
30
+ try {
31
+ return JSON.parse(val);
32
+ } catch {
33
+ return val; // Let Zod validation handle the error
34
+ }
35
+ }
36
+ return val;
37
+ }, z
38
+ .array(groundedStatementSchema)
39
+ .min(1)
40
+ .describe(
41
+ "Array of grounded statements. Each item has an id, a statement (the claim), and a source (URL, quote, and optional text fragment)."
42
+ ));
43
+
44
+ export const groundingManager = tool({
45
+ description:
46
+ "Manage the list of grounded statements (facts supported by sources) for the current session. " +
47
+ "You can 'set' (overwrite) the entire list or 'append' new items to the existing list. " +
48
+ "Use this to maintain a persistent list of verified claims and their sources.",
49
+ inputSchema: z.object({
50
+ action: z
51
+ .enum(["set", "append"])
52
+ .describe("Action to perform: 'set' replaces all groundings, 'append' adds to existing ones."),
53
+ items: itemsSchema,
54
+ }),
55
+ execute: async ({ action, items }) => {
56
+ const context = getRuntimeContext();
57
+
58
+ if (!context.sessionId) {
59
+ return {
60
+ success: false,
61
+ error: "No active session for grounding",
62
+ };
63
+ }
64
+
65
+ try {
66
+ let finalItems = items;
67
+
68
+ if (action === "append") {
69
+ const existingMap = await loadLatestGroundingMap(context.sessionId);
70
+ if (existingMap) {
71
+ finalItems = [...existingMap.items, ...items];
72
+ }
73
+ }
74
+
75
+ const groundingMap = await saveGroundingMap(context.sessionId, context.messageId, finalItems);
76
+
77
+ daemonEvents.emit("groundingSaved", context.sessionId, context.messageId, groundingMap.id);
78
+
79
+ return {
80
+ success: true,
81
+ action,
82
+ addedCount: items.length,
83
+ totalCount: finalItems.length,
84
+ currentItems: finalItems,
85
+ };
86
+ } catch (error) {
87
+ const err = error instanceof Error ? error : new Error(String(error));
88
+ return {
89
+ success: false,
90
+ error: err.message,
91
+ };
92
+ }
93
+ },
94
+ });
@@ -0,0 +1,52 @@
1
+ import type { ToolSet } from "ai";
2
+ import { fetchUrls } from "./fetch-urls";
3
+
4
+ import { readFile } from "./read-file";
5
+ import { groundingManager } from "./grounding-manager";
6
+ import { renderUrl } from "./render-url";
7
+ import { runBash } from "./run-bash";
8
+ import { todoManager } from "./todo-manager";
9
+ import { subagent } from "./subagents";
10
+ import { webSearch } from "./web-search";
11
+
12
+ import { detectLocalPlaywrightChromium } from "../../utils/js-rendering";
13
+
14
+ let cachedDaemonTools: Promise<ToolSet> | null = null;
15
+
16
+ export function isWebSearchAvailable(): boolean {
17
+ return Boolean(process.env.EXA_API_KEY);
18
+ }
19
+
20
+ export function invalidateDaemonToolsCache(): void {
21
+ cachedDaemonTools = null;
22
+ }
23
+
24
+ export async function getDaemonTools(): Promise<ToolSet> {
25
+ if (cachedDaemonTools) {
26
+ return cachedDaemonTools;
27
+ }
28
+
29
+ cachedDaemonTools = (async () => {
30
+ const tools: ToolSet = {
31
+ readFile,
32
+ groundingManager,
33
+ runBash,
34
+ todoManager,
35
+ subagent,
36
+ };
37
+
38
+ if (isWebSearchAvailable()) {
39
+ (tools as ToolSet & { webSearch: typeof webSearch }).webSearch = webSearch;
40
+ (tools as ToolSet & { fetchUrls: typeof fetchUrls }).fetchUrls = fetchUrls;
41
+ }
42
+
43
+ const jsRendering = await detectLocalPlaywrightChromium();
44
+ if (jsRendering.available) {
45
+ return { ...tools, renderUrl };
46
+ }
47
+
48
+ return tools;
49
+ })();
50
+
51
+ return cachedDaemonTools;
52
+ }
@@ -0,0 +1,100 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import fs from "node:fs";
4
+ import readline from "node:readline";
5
+
6
+ const DEFAULT_LINE_LIMIT = 2000;
7
+ const MAX_LINE_LIMIT = 2000;
8
+
9
+ export const readFile = tool({
10
+ description:
11
+ "Read a local text file. By default, reads up to 2000 lines from the start when no offset/limit are provided. Use offset+limit together only for partial reads when needed.",
12
+ inputSchema: z.object({
13
+ path: z.string().describe("Path to the file to read."),
14
+ lineOffset: z
15
+ .number()
16
+ .int()
17
+ .min(0)
18
+ .optional()
19
+ .describe("0-based line offset to start reading from (for partial reads)."),
20
+ lineLimit: z
21
+ .number()
22
+ .int()
23
+ .min(1)
24
+ .max(MAX_LINE_LIMIT)
25
+ .optional()
26
+ .describe(`Maximum number of lines to read (max ${MAX_LINE_LIMIT}).`),
27
+ }),
28
+ execute: async ({ path, lineOffset, lineLimit }) => {
29
+ try {
30
+ const hasOffset = typeof lineOffset === "number";
31
+ const hasLimit = typeof lineLimit === "number";
32
+ if ((hasOffset && !hasLimit) || (!hasOffset && hasLimit)) {
33
+ return {
34
+ success: false,
35
+ path,
36
+ lineOffset,
37
+ lineLimit,
38
+ error:
39
+ "Provide both lineOffset and lineLimit for partial reads, or omit both to read from the start.",
40
+ };
41
+ }
42
+
43
+ const effectiveOffset = hasOffset ? lineOffset : 0;
44
+ const effectiveLimit = hasLimit ? lineLimit : DEFAULT_LINE_LIMIT;
45
+ const usedDefault = !hasOffset && !hasLimit;
46
+
47
+ const lines: string[] = [];
48
+ let lineNumber = 0;
49
+ let hasMore = false;
50
+ const targetEnd = effectiveOffset + effectiveLimit;
51
+
52
+ const stream = fs.createReadStream(path, { encoding: "utf8" });
53
+ const rl = readline.createInterface({
54
+ input: stream,
55
+ crlfDelay: Infinity,
56
+ });
57
+
58
+ for await (const line of rl) {
59
+ if (lineNumber >= effectiveOffset && lineNumber < targetEnd) {
60
+ lines.push(line);
61
+ }
62
+ lineNumber += 1;
63
+ if (lineNumber > targetEnd) {
64
+ hasMore = true;
65
+ break;
66
+ }
67
+ }
68
+
69
+ if (hasMore) {
70
+ rl.close();
71
+ stream.destroy();
72
+ }
73
+
74
+ const startLine = lines.length > 0 ? effectiveOffset + 1 : 0;
75
+ const endLine = lines.length > 0 ? effectiveOffset + lines.length : 0;
76
+
77
+ return {
78
+ success: true,
79
+ path,
80
+ lineOffset: effectiveOffset,
81
+ lineLimit: effectiveLimit,
82
+ usedDefault,
83
+ startLine,
84
+ endLine,
85
+ hasMore,
86
+ lines,
87
+ content: lines.join("\n"),
88
+ };
89
+ } catch (error: unknown) {
90
+ const err = error instanceof Error ? error : new Error(String(error));
91
+ return {
92
+ success: false,
93
+ path,
94
+ lineOffset,
95
+ lineLimit,
96
+ error: err.message,
97
+ };
98
+ }
99
+ },
100
+ });
@@ -0,0 +1,275 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { tryImportPlaywright } from "../../utils/js-rendering";
4
+
5
+ const DEFAULT_LINE_LIMIT = 80;
6
+ const MAX_CHAR_LIMIT = 50000;
7
+ const MAX_LINE_LIMIT = 1000;
8
+ const RENDER_CACHE_TTL_MS = 2 * 60 * 1000;
9
+ const RENDER_CACHE_MAX_ENTRIES = 20;
10
+ const HARD_TIMEOUT_MS = 20000;
11
+
12
+ const DEFAULT_USER_AGENT =
13
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36";
14
+
15
+ type RenderCacheEntry = {
16
+ text: string;
17
+ createdAt: number;
18
+ };
19
+
20
+ const renderCache = new Map<string, RenderCacheEntry>();
21
+
22
+ function getCachedRender(url: string): string | null {
23
+ const entry = renderCache.get(url);
24
+ if (!entry) return null;
25
+ if (Date.now() - entry.createdAt > RENDER_CACHE_TTL_MS) {
26
+ renderCache.delete(url);
27
+ return null;
28
+ }
29
+ return entry.text;
30
+ }
31
+
32
+ function pruneRenderCache(): void {
33
+ while (renderCache.size > RENDER_CACHE_MAX_ENTRIES) {
34
+ const oldestKey = renderCache.keys().next().value as string | undefined;
35
+ if (!oldestKey) return;
36
+ renderCache.delete(oldestKey);
37
+ }
38
+ }
39
+
40
+ function setCachedRender(url: string, text: string): void {
41
+ if (!text.trim()) return;
42
+ renderCache.set(url, { text, createdAt: Date.now() });
43
+ pruneRenderCache();
44
+ }
45
+
46
+ function normalizeLines(text: string): string[] {
47
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
48
+ }
49
+
50
+ async function tryWaitForNetworkIdle(page: any, timeoutMs: number): Promise<void> {
51
+ try {
52
+ await page.waitForLoadState("networkidle", { timeout: timeoutMs });
53
+ } catch {
54
+ // Some SPAs never reach networkidle; ignore.
55
+ }
56
+ }
57
+
58
+ async function tryWaitForNonEmptyText(page: any, timeoutMs: number): Promise<void> {
59
+ try {
60
+ await page.waitForFunction(
61
+ () => {
62
+ const doc = (globalThis as any).document;
63
+ if (!doc) return false;
64
+
65
+ const h1 = doc.querySelector("main h1, h1");
66
+ if (h1 && typeof h1.innerText === "string" && h1.innerText.trim().length > 0) {
67
+ return true;
68
+ }
69
+
70
+ const main = doc.querySelector("main");
71
+ if (main && typeof main.innerText === "string" && main.innerText.trim().length > 200) {
72
+ return true;
73
+ }
74
+
75
+ const article = doc.querySelector("article");
76
+ if (article && typeof article.innerText === "string" && article.innerText.trim().length > 200) {
77
+ return true;
78
+ }
79
+
80
+ return false;
81
+ },
82
+ { timeout: timeoutMs }
83
+ );
84
+ } catch {
85
+ // Best-effort only.
86
+ }
87
+ }
88
+
89
+ async function extractRenderedText(page: any): Promise<string> {
90
+ return await page.evaluate(() => {
91
+ const doc = (globalThis as any).document;
92
+ if (!doc) return "";
93
+
94
+ const pick = (selector: string): string => {
95
+ const el = doc.querySelector(selector);
96
+ if (!el) return "";
97
+ if (typeof (el as any).innerText === "string") return (el as any).innerText;
98
+ return "";
99
+ };
100
+
101
+ // Prefer semantic containers to reduce nav/footer noise.
102
+ const mainText = pick("main");
103
+ if (mainText.trim().length > 200) return mainText;
104
+
105
+ const articleText = pick("article");
106
+ if (articleText.trim().length > 200) return articleText;
107
+
108
+ const body = doc.body as any;
109
+ const bodyInnerText = typeof body?.innerText === "string" ? body.innerText : "";
110
+ if (bodyInnerText.trim().length > 0) return bodyInnerText;
111
+
112
+ // Fallback: some sites hide text visually; textContent can still capture the payload.
113
+ return typeof body?.textContent === "string" ? body.textContent : "";
114
+ });
115
+ }
116
+
117
+ function normalizeError(error: unknown): Error {
118
+ return error instanceof Error ? error : new Error(String(error));
119
+ }
120
+
121
+ export const renderUrl = tool({
122
+ description:
123
+ "Render a JavaScript-heavy page locally (Playwright Chromium) and extract visible text from the live DOM. By default, reads up to 80 lines from the start of the rendered page (capped at 50k characters). For pagination (lineOffset > 0), provide both lineOffset and lineLimit. If the requested range exceeds what exists, returns whatever is available. Returns remainingLines (exact when knowable, otherwise null).",
124
+ inputSchema: z.object({
125
+ url: z.string().url().describe("URL to render and extract text from."),
126
+ lineOffset: z
127
+ .number()
128
+ .int()
129
+ .min(0)
130
+ .optional()
131
+ .describe(
132
+ "0-based line offset to start reading from. For pagination (lineOffset > 0), provide lineLimit too."
133
+ ),
134
+ lineLimit: z
135
+ .number()
136
+ .int()
137
+ .min(1)
138
+ .max(MAX_LINE_LIMIT)
139
+ .optional()
140
+ .describe(
141
+ `Maximum lines to read (max ${MAX_LINE_LIMIT}). If provided without lineOffset, reads from the start.`
142
+ ),
143
+ }),
144
+ execute: async ({ url, lineOffset, lineLimit }, { abortSignal }) => {
145
+ const normalizedUrl = new URL(url).toString();
146
+ const hasOffset = typeof lineOffset === "number";
147
+ const hasLimit = typeof lineLimit === "number";
148
+
149
+ if (hasOffset && !hasLimit && (lineOffset ?? 0) > 0) {
150
+ return {
151
+ success: false,
152
+ url,
153
+ lineOffset,
154
+ lineLimit,
155
+ error: "Provide both lineOffset and lineLimit for paginated reads (lineOffset > 0).",
156
+ };
157
+ }
158
+
159
+ const effectiveOffset = hasOffset ? lineOffset : 0;
160
+ const effectiveLimit = hasLimit ? lineLimit : DEFAULT_LINE_LIMIT;
161
+
162
+ const cachedText = getCachedRender(normalizedUrl);
163
+ if (cachedText !== null) {
164
+ const cappedText = cachedText.slice(0, MAX_CHAR_LIMIT);
165
+ const lines = normalizeLines(cappedText);
166
+ const cappedOffset = Math.min(effectiveOffset, lines.length);
167
+ const cappedEnd = Math.min(cappedOffset + effectiveLimit, lines.length);
168
+ const slicedText = lines.slice(cappedOffset, cappedEnd).join("\n");
169
+ const truncatedByCap = cachedText.length > MAX_CHAR_LIMIT;
170
+
171
+ return {
172
+ success: true,
173
+ url,
174
+ text: slicedText,
175
+ lineOffset: effectiveOffset,
176
+ lineLimit: effectiveLimit,
177
+ totalLines: lines.length,
178
+ remainingLines: truncatedByCap ? null : Math.max(0, lines.length - cappedEnd),
179
+ };
180
+ }
181
+
182
+ // Use `any` here to avoid a hard dependency on Playwright types.
183
+ let browser: any = null;
184
+
185
+ // Hard timeout wrapper to prevent indefinite hangs on browser operations.
186
+ const timeoutPromise = new Promise<never>((_, reject) => {
187
+ setTimeout(
188
+ () => reject(new Error(`Render timed out after ${HARD_TIMEOUT_MS / 1000}s`)),
189
+ HARD_TIMEOUT_MS
190
+ );
191
+ });
192
+
193
+ const renderWork = async () => {
194
+ if (abortSignal?.aborted) {
195
+ return { success: false as const, url, error: "Aborted." };
196
+ }
197
+
198
+ const playwright = await tryImportPlaywright();
199
+ if (!playwright) {
200
+ return {
201
+ success: false as const,
202
+ url,
203
+ error: "Playwright is not installed. Run: npm i -g playwright && npx playwright install chromium",
204
+ };
205
+ }
206
+
207
+ browser = await playwright.chromium.launch({ headless: true });
208
+
209
+ const context = await browser.newContext({
210
+ userAgent: DEFAULT_USER_AGENT,
211
+ locale: "en-US",
212
+ extraHTTPHeaders: {
213
+ "Accept-Language": "en-US,en;q=0.9",
214
+ },
215
+ });
216
+ const page = await context.newPage();
217
+
218
+ await page.goto(url, {
219
+ waitUntil: "domcontentloaded",
220
+ timeout: 10000,
221
+ });
222
+
223
+ // Best-effort waits:
224
+ // - networkidle (short) helps for classic pages
225
+ // - non-empty text helps for SPAs that render after first paint
226
+ await tryWaitForNetworkIdle(page, 1500);
227
+ await tryWaitForNonEmptyText(page, 8000);
228
+
229
+ if (abortSignal?.aborted) {
230
+ return { success: false as const, url, error: "Aborted." };
231
+ }
232
+
233
+ const fullText = await extractRenderedText(page);
234
+ setCachedRender(normalizedUrl, fullText);
235
+
236
+ // Cap the maximum readable window per URL to keep tool I/O small and predictable.
237
+ const cappedText = fullText.slice(0, MAX_CHAR_LIMIT);
238
+ const lines = normalizeLines(cappedText);
239
+ const cappedOffset = Math.min(effectiveOffset, lines.length);
240
+ const cappedEnd = Math.min(cappedOffset + effectiveLimit, lines.length);
241
+ const slicedText = lines.slice(cappedOffset, cappedEnd).join("\n");
242
+ const truncatedByCap = fullText.length > MAX_CHAR_LIMIT;
243
+ const remainingLines = truncatedByCap ? null : Math.max(0, lines.length - cappedEnd);
244
+
245
+ return {
246
+ success: true as const,
247
+ url,
248
+ text: slicedText,
249
+ lineOffset: effectiveOffset,
250
+ lineLimit: effectiveLimit,
251
+ totalLines: lines.length,
252
+ remainingLines,
253
+ };
254
+ };
255
+
256
+ try {
257
+ return await Promise.race([renderWork(), timeoutPromise]);
258
+ } catch (error) {
259
+ const err = normalizeError(error);
260
+ return {
261
+ success: false,
262
+ url,
263
+ lineOffset,
264
+ lineLimit,
265
+ error: err.message,
266
+ };
267
+ } finally {
268
+ try {
269
+ await browser?.close();
270
+ } catch {
271
+ // Best-effort cleanup only.
272
+ }
273
+ }
274
+ },
275
+ });