@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,33 @@
1
+ import type { TextareaRenderable } from "@opentui/core";
2
+ import { readClipboardText } from "./clipboard";
3
+ import { debug } from "./debug-logger";
4
+
5
+ export interface PasteOptions {
6
+ singleLine?: boolean;
7
+ source?: string;
8
+ }
9
+
10
+ export function normalizeClipboardText(text: string): string {
11
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
12
+ }
13
+
14
+ export async function pasteClipboardIntoTextarea(
15
+ textarea: TextareaRenderable | null,
16
+ options: PasteOptions = {}
17
+ ): Promise<boolean> {
18
+ const raw = await readClipboardText();
19
+ const normalized = normalizeClipboardText(raw);
20
+ const text = options.singleLine ? normalized.replace(/[\r\n]/g, "") : normalized;
21
+
22
+ if (!text) {
23
+ debug.log("[Paste] Clipboard empty", { source: options.source });
24
+ return false;
25
+ }
26
+
27
+ textarea?.insertText(text);
28
+ debug.log("[Paste] Inserted clipboard text", {
29
+ source: options.source,
30
+ length: text.length,
31
+ });
32
+ return true;
33
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Preferences persistence for DAEMON.
3
+ * Stores user configuration in OS-appropriate config directories.
4
+ */
5
+
6
+ import { promises as fs } from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import type { AppPreferences } from "../types";
10
+
11
+ const PREFERENCES_VERSION = 1;
12
+ const APP_DIR_NAME = "daemon";
13
+ const PREFERENCES_FILE = "preferences.json";
14
+ const CREDENTIALS_FILE = "credentials.json";
15
+
16
+ /** Keys that belong in credentials.json (secrets) vs preferences.json (settings) */
17
+ const CREDENTIAL_KEYS = ["openRouterApiKey", "openAiApiKey", "exaApiKey"] as const;
18
+ type CredentialKey = (typeof CREDENTIAL_KEYS)[number];
19
+
20
+ function isCredentialKey(key: string): key is CredentialKey {
21
+ return CREDENTIAL_KEYS.includes(key as CredentialKey);
22
+ }
23
+
24
+ function getBaseConfigDir(): string {
25
+ if (process.platform === "win32") {
26
+ return process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
27
+ }
28
+ if (process.platform === "darwin") {
29
+ return path.join(os.homedir(), ".config");
30
+ }
31
+ return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
32
+ }
33
+
34
+ export function getAppConfigDir(): string {
35
+ return path.join(getBaseConfigDir(), APP_DIR_NAME);
36
+ }
37
+
38
+ export function getPreferencesPath(): string {
39
+ return path.join(getAppConfigDir(), PREFERENCES_FILE);
40
+ }
41
+
42
+ export function getCredentialsPath(): string {
43
+ return path.join(getAppConfigDir(), CREDENTIALS_FILE);
44
+ }
45
+
46
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null;
48
+ }
49
+
50
+ export function parsePreferences(raw: unknown): AppPreferences | null {
51
+ if (!isRecord(raw)) return null;
52
+
53
+ const version = typeof raw.version === "number" ? raw.version : PREFERENCES_VERSION;
54
+ const createdAt = typeof raw.createdAt === "string" ? raw.createdAt : new Date().toISOString();
55
+ const updatedAt = typeof raw.updatedAt === "string" ? raw.updatedAt : createdAt;
56
+ const onboardingCompleted = typeof raw.onboardingCompleted === "boolean" ? raw.onboardingCompleted : false;
57
+
58
+ const prefs: AppPreferences = {
59
+ version,
60
+ createdAt,
61
+ updatedAt,
62
+ onboardingCompleted,
63
+ };
64
+
65
+ if (typeof raw.audioDeviceName === "string") {
66
+ prefs.audioDeviceName = raw.audioDeviceName;
67
+ }
68
+ if (typeof raw.audioOutputDeviceName === "string") {
69
+ prefs.audioOutputDeviceName = raw.audioOutputDeviceName;
70
+ }
71
+ if (typeof raw.modelId === "string") {
72
+ prefs.modelId = raw.modelId;
73
+ }
74
+ if (typeof raw.openRouterProviderTag === "string") {
75
+ prefs.openRouterProviderTag = raw.openRouterProviderTag;
76
+ }
77
+ if (raw.interactionMode === "text" || raw.interactionMode === "voice") {
78
+ prefs.interactionMode = raw.interactionMode;
79
+ }
80
+ if (raw.voiceInteractionType === "direct" || raw.voiceInteractionType === "review") {
81
+ prefs.voiceInteractionType = raw.voiceInteractionType;
82
+ } else {
83
+ prefs.voiceInteractionType = "direct";
84
+ }
85
+ if (
86
+ raw.speechSpeed === 1.0 ||
87
+ raw.speechSpeed === 1.25 ||
88
+ raw.speechSpeed === 1.5 ||
89
+ raw.speechSpeed === 1.75 ||
90
+ raw.speechSpeed === 2.0
91
+ ) {
92
+ prefs.speechSpeed = raw.speechSpeed;
93
+ }
94
+ if (raw.reasoningEffort === "low" || raw.reasoningEffort === "medium" || raw.reasoningEffort === "high") {
95
+ prefs.reasoningEffort = raw.reasoningEffort;
96
+ }
97
+ if (typeof raw.openRouterApiKey === "string") {
98
+ prefs.openRouterApiKey = raw.openRouterApiKey;
99
+ }
100
+ if (typeof raw.openAiApiKey === "string") {
101
+ prefs.openAiApiKey = raw.openAiApiKey;
102
+ }
103
+ if (typeof raw.exaApiKey === "string") {
104
+ prefs.exaApiKey = raw.exaApiKey;
105
+ }
106
+ if (typeof raw.showFullReasoning === "boolean") {
107
+ prefs.showFullReasoning = raw.showFullReasoning;
108
+ }
109
+ if (typeof raw.showToolOutput === "boolean") {
110
+ prefs.showToolOutput = raw.showToolOutput;
111
+ }
112
+ if (
113
+ raw.bashApprovalLevel === "none" ||
114
+ raw.bashApprovalLevel === "dangerous" ||
115
+ raw.bashApprovalLevel === "all"
116
+ ) {
117
+ prefs.bashApprovalLevel = raw.bashApprovalLevel;
118
+ }
119
+ if (Array.isArray(raw.inputHistory)) {
120
+ const validHistory = raw.inputHistory.filter((item): item is string => typeof item === "string");
121
+ prefs.inputHistory = validHistory.slice(0, 20);
122
+ }
123
+
124
+ return prefs;
125
+ }
126
+
127
+ export async function loadPreferences(): Promise<AppPreferences | null> {
128
+ try {
129
+ const prefsPath = getPreferencesPath();
130
+ const credsPath = getCredentialsPath();
131
+
132
+ let prefsRaw: unknown = {};
133
+ let credsRaw: unknown = {};
134
+
135
+ try {
136
+ const prefsContents = await fs.readFile(prefsPath, "utf8");
137
+ prefsRaw = JSON.parse(prefsContents) as unknown;
138
+ } catch {}
139
+
140
+ try {
141
+ const credsContents = await fs.readFile(credsPath, "utf8");
142
+ credsRaw = JSON.parse(credsContents) as unknown;
143
+ } catch {}
144
+
145
+ const merged = {
146
+ ...(isRecord(prefsRaw) ? prefsRaw : {}),
147
+ ...(isRecord(credsRaw) ? credsRaw : {}),
148
+ };
149
+
150
+ if (Object.keys(merged).length === 0) {
151
+ return null;
152
+ }
153
+
154
+ return parsePreferences(merged);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ async function writeJsonFile(filePath: string, data: Record<string, unknown>, mode?: number): Promise<void> {
161
+ const dir = path.dirname(filePath);
162
+ await fs.mkdir(dir, { recursive: true });
163
+
164
+ const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
165
+ const payload = JSON.stringify(data, null, 2);
166
+
167
+ await fs.writeFile(tempPath, payload, { encoding: "utf8", mode: mode ?? 0o644 });
168
+ await fs.rename(tempPath, filePath);
169
+
170
+ if (mode !== undefined) {
171
+ await fs.chmod(filePath, mode);
172
+ }
173
+ }
174
+
175
+ export async function savePreferences(preferences: AppPreferences): Promise<void> {
176
+ const prefsPath = getPreferencesPath();
177
+ const credsPath = getCredentialsPath();
178
+
179
+ const prefsData: Record<string, unknown> = {};
180
+ const credsData: Record<string, unknown> = {};
181
+
182
+ for (const [key, value] of Object.entries(preferences)) {
183
+ if (value === undefined) continue;
184
+ if (isCredentialKey(key)) {
185
+ credsData[key] = value;
186
+ } else {
187
+ prefsData[key] = value;
188
+ }
189
+ }
190
+
191
+ await writeJsonFile(prefsPath, prefsData);
192
+
193
+ if (Object.keys(credsData).length > 0) {
194
+ await writeJsonFile(credsPath, credsData, 0o600);
195
+ }
196
+ }
197
+
198
+ export async function updatePreferences(updates: Partial<AppPreferences>): Promise<AppPreferences> {
199
+ const existing = await loadPreferences();
200
+ const now = new Date().toISOString();
201
+ const base: AppPreferences = existing ?? {
202
+ version: PREFERENCES_VERSION,
203
+ createdAt: now,
204
+ updatedAt: now,
205
+ onboardingCompleted: false,
206
+ };
207
+
208
+ const next: AppPreferences = {
209
+ ...base,
210
+ ...updates,
211
+ version: PREFERENCES_VERSION,
212
+ updatedAt: now,
213
+ };
214
+
215
+ await savePreferences(next);
216
+ return next;
217
+ }
218
+
219
+ /**
220
+ * Check if OpenRouter API key is available (from env or stored preferences).
221
+ */
222
+ export function hasOpenRouterApiKey(): boolean {
223
+ return Boolean(process.env.OPENROUTER_API_KEY);
224
+ }
225
+
226
+ /**
227
+ * Check if OpenAI API key is available (from env or stored preferences).
228
+ */
229
+ export function hasOpenAiApiKey(): boolean {
230
+ return Boolean(process.env.OPENAI_API_KEY);
231
+ }
232
+
233
+ /**
234
+ * Check if Exa API key is available (from env or stored preferences).
235
+ */
236
+ export function hasExaApiKey(): boolean {
237
+ return Boolean(process.env.EXA_API_KEY);
238
+ }
239
+
240
+ /**
241
+ * Get the effective OpenRouter API key (env takes precedence over stored).
242
+ */
243
+ export function getOpenRouterApiKey(storedKey?: string): string | undefined {
244
+ return process.env.OPENROUTER_API_KEY || storedKey;
245
+ }
246
+
247
+ /**
248
+ * Get the effective OpenAI API key (env takes precedence over stored).
249
+ */
250
+ export function getOpenAiApiKey(storedKey?: string): string | undefined {
251
+ return process.env.OPENAI_API_KEY || storedKey;
252
+ }
253
+
254
+ /**
255
+ * Get the effective Exa API key (env takes precedence over stored).
256
+ */
257
+ export function getExaApiKey(storedKey?: string): string | undefined {
258
+ return process.env.EXA_API_KEY || storedKey;
259
+ }
260
+
261
+ /**
262
+ * Set API keys in process.env for the current session.
263
+ * Called after loading preferences to make stored keys available to SDK clients.
264
+ */
265
+ export function applyApiKeysToEnv(prefs: AppPreferences): void {
266
+ if (prefs.openRouterApiKey && !process.env.OPENROUTER_API_KEY) {
267
+ process.env.OPENROUTER_API_KEY = prefs.openRouterApiKey;
268
+ }
269
+ if (prefs.openAiApiKey && !process.env.OPENAI_API_KEY) {
270
+ process.env.OPENAI_API_KEY = prefs.openAiApiKey;
271
+ }
272
+ if (prefs.exaApiKey && !process.env.EXA_API_KEY) {
273
+ process.env.EXA_API_KEY = prefs.exaApiKey;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Open a URL in the default browser.
279
+ */
280
+ export function openUrlInBrowser(url: string): void {
281
+ const { spawn } = require("node:child_process");
282
+ if (process.platform === "darwin") {
283
+ spawn("open", [url], { detached: true, stdio: "ignore" });
284
+ } else if (process.platform === "win32") {
285
+ spawn("cmd", ["/c", "start", url], { detached: true, stdio: "ignore" });
286
+ } else {
287
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
288
+ }
289
+ }
@@ -0,0 +1,39 @@
1
+ function encodeTextFragment(text: string): string {
2
+ return encodeURIComponent(text).replace(/-/g, "%2D");
3
+ }
4
+
5
+ export function buildTextFragmentUrl(url: string, fragment: { fragmentText: string }): string {
6
+ if (!fragment || !fragment.fragmentText) return url;
7
+
8
+ const encoded = encodeTextFragment(fragment.fragmentText);
9
+ const textDirective = `text=${encoded}`;
10
+
11
+ try {
12
+ const parsed = new URL(url);
13
+ const existingHash = parsed.hash;
14
+
15
+ if (!existingHash) {
16
+ parsed.hash = `:~:${textDirective}`;
17
+ } else if (existingHash.includes(":~:text=")) {
18
+ parsed.hash = existingHash.replace(/:~:text=[^&]*/, `:~:${textDirective}`);
19
+ } else {
20
+ parsed.hash = `${existingHash}:~:${textDirective}`;
21
+ }
22
+
23
+ return parsed.toString();
24
+ } catch {
25
+ const hashIdx = url.indexOf("#");
26
+ if (hashIdx === -1) {
27
+ return `${url}#:~:${textDirective}`;
28
+ }
29
+
30
+ const base = url.slice(0, hashIdx);
31
+ const existingHash = url.slice(hashIdx);
32
+
33
+ if (existingHash.includes(":~:text=")) {
34
+ return base + existingHash.replace(/:~:text=[^&]*/, `:~:${textDirective}`);
35
+ }
36
+
37
+ return `${url}:~:${textDirective}`;
38
+ }
39
+ }
@@ -0,0 +1,250 @@
1
+ type UnknownRecord = Record<string, unknown>;
2
+
3
+ function isRecord(value: unknown): value is UnknownRecord {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ function normalizeWhitespace(text: string): string {
8
+ return text.replace(/\r\n/g, "\n").replace(/\t/g, " ");
9
+ }
10
+
11
+ function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } {
12
+ if (text.length <= maxChars) return { text, truncated: false };
13
+ if (maxChars <= 1) return { text: "…", truncated: true };
14
+ return { text: `${text.slice(0, maxChars - 1)}…`, truncated: true };
15
+ }
16
+
17
+ function splitPreviewLines(text: string, maxLines: number): { lines: string[]; truncated: boolean } {
18
+ const normalized = normalizeWhitespace(text);
19
+ const rawLines = normalized.split("\n");
20
+ const trimmedLines = rawLines.map((l) => l.trimEnd()).filter((l) => l.length > 0);
21
+ if (trimmedLines.length <= maxLines) return { lines: trimmedLines, truncated: false };
22
+ return { lines: trimmedLines.slice(0, maxLines), truncated: true };
23
+ }
24
+
25
+ function pickFirstNonEmpty(...parts: Array<string | undefined | null>): string {
26
+ for (const part of parts) {
27
+ if (typeof part === "string" && part.trim().length > 0) return part;
28
+ }
29
+ return "";
30
+ }
31
+
32
+ function formatBashResult(result: unknown): string | null {
33
+ if (!isRecord(result)) return null;
34
+ const success = "success" in result ? result.success : undefined;
35
+ const exitCode =
36
+ typeof result.exitCode === "number" || result.exitCode === null ? result.exitCode : undefined;
37
+
38
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
39
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
40
+ const error = typeof result.error === "string" ? result.error : "";
41
+
42
+ const body = pickFirstNonEmpty(stdout, stderr, error);
43
+ if (!body) {
44
+ if (typeof success === "boolean") {
45
+ return `success=${success}${exitCode !== undefined ? ` exit=${String(exitCode)}` : ""}`;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ const label = stdout.trim().length > 0 ? "stdout" : stderr.trim().length > 0 ? "stderr" : "error";
51
+ const meta =
52
+ typeof success === "boolean" || exitCode !== undefined
53
+ ? ` (${typeof success === "boolean" ? `success=${success}` : ""}${exitCode !== undefined ? `${typeof success === "boolean" ? " " : ""}exit=${String(exitCode)}` : ""})`
54
+ : "";
55
+ return `${label}${meta}: ${body}`;
56
+ }
57
+
58
+ type ExaLikeItem = {
59
+ title?: unknown;
60
+ url?: unknown;
61
+ text?: unknown;
62
+ lineOffset?: unknown;
63
+ lineLimit?: unknown;
64
+ };
65
+
66
+ function extractExaItems(data: unknown): ExaLikeItem[] | null {
67
+ if (!isRecord(data)) return null;
68
+ const direct = data.results;
69
+ if (Array.isArray(direct)) return direct as ExaLikeItem[];
70
+ const contents = data.contents;
71
+ if (Array.isArray(contents)) return contents as ExaLikeItem[];
72
+ return null;
73
+ }
74
+
75
+ function extractToolDataContainer(result: UnknownRecord): unknown {
76
+ if ("data" in result) return result.data;
77
+ return result;
78
+ }
79
+
80
+ function formatExaItemLabel(item: ExaLikeItem): string {
81
+ const title = typeof item.title === "string" ? item.title : "";
82
+ const url = typeof item.url === "string" ? item.url : "";
83
+ return pickFirstNonEmpty(title, url, "(untitled)");
84
+ }
85
+
86
+ function formatExaSearchResult(result: unknown): string | null {
87
+ if (!isRecord(result)) return null;
88
+ if (result.success === false && typeof result.error === "string") {
89
+ return `error: ${result.error}`;
90
+ }
91
+ if (result.success !== true) return null;
92
+ const data = extractToolDataContainer(result);
93
+ const items = extractExaItems(data);
94
+ if (!items) return null;
95
+
96
+ const top = items.slice(0, 4).map((item, idx) => {
97
+ const url = typeof item.url === "string" ? item.url : "";
98
+ const title = typeof item.title === "string" ? item.title : "";
99
+ const label = formatExaItemLabel(item);
100
+ const urlSuffix = url && title ? ` — ${url}` : "";
101
+ return `${idx + 1}) ${label}${urlSuffix}`;
102
+ });
103
+
104
+ return top.length > 0 ? top.join("\n") : null;
105
+ }
106
+
107
+ function formatExaFetchResult(result: unknown): string | null {
108
+ if (!isRecord(result)) return null;
109
+ if (result.success === false && typeof result.error === "string") {
110
+ return `error: ${result.error}`;
111
+ }
112
+ if (result.success !== true) return null;
113
+ const data = extractToolDataContainer(result);
114
+ const candidate = isRecord(data) ? (data as ExaLikeItem & { remainingLines?: unknown }) : {};
115
+ const label = formatExaItemLabel(candidate);
116
+ const url = typeof candidate.url === "string" ? candidate.url : "";
117
+ const title = typeof candidate.title === "string" ? candidate.title : "";
118
+ const lineOffset = typeof candidate.lineOffset === "number" ? candidate.lineOffset : undefined;
119
+ const lineLimit = typeof candidate.lineLimit === "number" ? candidate.lineLimit : undefined;
120
+ const remainingLines =
121
+ typeof (candidate as { remainingLines?: unknown }).remainingLines === "number" ||
122
+ (candidate as { remainingLines?: unknown }).remainingLines === null
123
+ ? (candidate as { remainingLines: number | null }).remainingLines
124
+ : undefined;
125
+ const rangeParts: string[] = [];
126
+ if (lineOffset !== undefined) rangeParts.push(`lineOffset=${lineOffset}`);
127
+ if (lineLimit !== undefined) rangeParts.push(`lineLimit=${lineLimit}`);
128
+ if (remainingLines !== undefined) {
129
+ rangeParts.push(remainingLines === null ? "remainingLines=unknown" : `remainingLines=${remainingLines}`);
130
+ }
131
+ const remainingSuffix = rangeParts.length > 0 ? ` (${rangeParts.join(", ")})` : "";
132
+
133
+ const headerBase = url && title ? `${label} — ${url}` : label;
134
+ const header = `${headerBase}${remainingSuffix}`;
135
+
136
+ const text = typeof candidate.text === "string" ? candidate.text : "";
137
+ if (!text.trim()) return header;
138
+
139
+ // Provide a small snippet; downstream truncation enforces the hard caps.
140
+ const snippet = normalizeWhitespace(text)
141
+ .replace(/\n{3,}/g, "\n\n")
142
+ .trim();
143
+
144
+ return `${header}\n${snippet}`;
145
+ }
146
+
147
+ function formatRenderUrlResult(result: unknown): string | null {
148
+ if (!isRecord(result)) return null;
149
+ if (result.success === false && typeof result.error === "string") {
150
+ return `error: ${result.error}`;
151
+ }
152
+ if (result.success !== true) return null;
153
+
154
+ const url = typeof result.url === "string" ? result.url : "(unknown url)";
155
+ const lineOffset = typeof result.lineOffset === "number" ? result.lineOffset : undefined;
156
+ const lineLimit = typeof result.lineLimit === "number" ? result.lineLimit : undefined;
157
+ const remainingLines = typeof result.remainingLines === "number" ? result.remainingLines : null;
158
+ const rangeParts: string[] = [];
159
+ if (lineOffset !== undefined) rangeParts.push(`lineOffset=${lineOffset}`);
160
+ if (lineLimit !== undefined) rangeParts.push(`lineLimit=${lineLimit}`);
161
+ rangeParts.push(remainingLines === null ? "remainingLines=unknown" : `remainingLines=${remainingLines}`);
162
+ const remainingSuffix = rangeParts.length > 0 ? ` (${rangeParts.join(", ")})` : "";
163
+
164
+ const header = `${url}${remainingSuffix}`;
165
+
166
+ const text = typeof result.text === "string" ? result.text : "";
167
+ if (!text.trim()) return header;
168
+
169
+ const snippet = normalizeWhitespace(text)
170
+ .replace(/\n{3,}/g, "\n\n")
171
+ .trim();
172
+
173
+ return `${header}\n${snippet}`;
174
+ }
175
+
176
+ function formatReadFileResult(result: unknown): string | null {
177
+ if (!isRecord(result)) return null;
178
+ if (result.success === false && typeof result.error === "string") {
179
+ return `error: ${result.error}`;
180
+ }
181
+ if (result.success !== true) return null;
182
+ const path = typeof result.path === "string" ? result.path : "";
183
+ const startLine = typeof result.startLine === "number" ? result.startLine : undefined;
184
+ const endLine = typeof result.endLine === "number" ? result.endLine : undefined;
185
+ const hasMore = typeof result.hasMore === "boolean" ? result.hasMore : undefined;
186
+ const content = typeof result.content === "string" ? result.content : "";
187
+
188
+ const range =
189
+ startLine !== undefined && endLine !== undefined && startLine > 0 && endLine > 0
190
+ ? ` (${startLine}-${endLine}${hasMore ? "+" : ""})`
191
+ : "";
192
+
193
+ if (!content.trim()) return path ? `${path}${range}` : null;
194
+ return `${path}${range}:\n${content}`;
195
+ }
196
+
197
+ /**
198
+ * Format a very small, terminal-safe preview of a tool result.
199
+ * Intended for the tool call UI box (not full logs).
200
+ */
201
+ export function formatToolOutputPreview(toolName: string, result: unknown): string[] | null {
202
+ if (result === undefined) return null;
203
+
204
+ let raw: string | null = null;
205
+ if (toolName === "runBash") raw = formatBashResult(result);
206
+ if (toolName === "webSearch") raw = formatExaSearchResult(result);
207
+ if (toolName === "fetchUrls") raw = formatExaFetchResult(result);
208
+ if (toolName === "renderUrl") raw = formatRenderUrlResult(result);
209
+ if (toolName === "readFile") raw = formatReadFileResult(result);
210
+
211
+ if (!raw) {
212
+ if (isRecord(result) && result.success === false && typeof result.error === "string") {
213
+ raw = `error: ${result.error}`;
214
+ } else {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ const MAX_LINES = 4;
220
+ const MAX_CHARS_PER_LINE = 160;
221
+ const MAX_TOTAL_CHARS = 260;
222
+
223
+ const { lines, truncated: lineTruncated } = splitPreviewLines(raw, MAX_LINES);
224
+
225
+ let usedChars = 0;
226
+ const outputLines: string[] = [];
227
+ let anyTruncated = lineTruncated;
228
+
229
+ for (const line of lines) {
230
+ const remaining = Math.max(0, MAX_TOTAL_CHARS - usedChars);
231
+ if (remaining <= 0) {
232
+ anyTruncated = true;
233
+ break;
234
+ }
235
+ const { text: truncatedLine, truncated } = truncateText(line, Math.min(MAX_CHARS_PER_LINE, remaining));
236
+ anyTruncated = anyTruncated || truncated;
237
+ outputLines.push(truncatedLine);
238
+ usedChars += truncatedLine.length;
239
+ }
240
+
241
+ if (anyTruncated && outputLines.length > 0) {
242
+ const last = outputLines[outputLines.length - 1] ?? "";
243
+ if (!last.endsWith("…")) {
244
+ const { text } = truncateText(last, Math.max(1, last.length - 1));
245
+ outputLines[outputLines.length - 1] = `${text}…`.replace(/……$/, "…");
246
+ }
247
+ }
248
+
249
+ return outputLines.length > 0 ? outputLines : null;
250
+ }