otherwise-cli 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 (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. package/src/ui/utils/markdown.js +166 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * User location cache for CLI agent context.
3
+ * Fetches approximate location from IP (same as frontend) so the agent can answer
4
+ * "what is my location" and use location in answers (e.g. weather, timezone).
5
+ */
6
+
7
+ const LOCATION_URL = "https://ipapi.co/json/";
8
+
9
+ /** @type {string | null} Cached location string (e.g. "City, Region, Country") or null if not yet resolved */
10
+ let cachedLocation = null;
11
+
12
+ /** @type {Promise<void> | null} In-flight fetch so we don't fire multiple requests */
13
+ let initPromise = null;
14
+
15
+ /**
16
+ * Fetch and cache user location from IP. Safe to call multiple times; runs once.
17
+ * Same API as frontend (frontend/src/utils/structuredChat.js) for consistency.
18
+ */
19
+ export async function initializeLocationCache() {
20
+ if (cachedLocation !== null) return;
21
+ if (initPromise) {
22
+ await initPromise;
23
+ return;
24
+ }
25
+ initPromise = (async () => {
26
+ try {
27
+ const res = await fetch(LOCATION_URL);
28
+ if (!res.ok) return;
29
+ const data = await res.json();
30
+ const parts = [data.city, data.region, data.country_name].filter(
31
+ Boolean,
32
+ );
33
+ if (parts.length) {
34
+ cachedLocation = parts.join(", ");
35
+ }
36
+ } catch {
37
+ // Ignore: offline, rate limit, or CORS/network error
38
+ } finally {
39
+ initPromise = null;
40
+ }
41
+ })();
42
+ await initPromise;
43
+ }
44
+
45
+ /**
46
+ * Return cached location string for use in system prompt, or null if not yet available.
47
+ * Does not trigger a fetch; call initializeLocationCache() at server startup to populate.
48
+ */
49
+ export function getCachedLocation() {
50
+ return cachedLocation;
51
+ }
@@ -0,0 +1,548 @@
1
+ import {
2
+ getToolDescriptions,
3
+ TOOLS,
4
+ getAgentWorkingDirectory,
5
+ } from "./tools.js";
6
+ import { getCachedLocation } from "./location.js";
7
+
8
+ /**
9
+ * Valid tool names - whitelist for security
10
+ */
11
+ export const VALID_TOOL_NAMES = Object.keys(TOOLS);
12
+
13
+ /**
14
+ * Build the system prompt for the agent
15
+ * @param {object} config - Configuration object
16
+ * @param {object} options - Additional options
17
+ * @param {Array} options.ragDocuments - RAG documents mentioned with @ in the user's message
18
+ */
19
+ export function buildAgentSystemPrompt(config, options = {}) {
20
+ const { ragDocuments = [] } = options;
21
+
22
+ const now = new Date();
23
+ const dateString = now.toLocaleDateString("en-US", {
24
+ weekday: "long",
25
+ year: "numeric",
26
+ month: "long",
27
+ day: "numeric",
28
+ });
29
+ const timeString = now.toLocaleTimeString("en-US", {
30
+ hour: "2-digit",
31
+ minute: "2-digit",
32
+ });
33
+
34
+ const toolDescriptions = getToolDescriptions();
35
+
36
+ // Check for custom system message from user settings
37
+ const customSystemMessage = config?.customSystemMessage?.trim();
38
+
39
+ // Base system prompt
40
+ let basePrompt = `Hello, I'm Otherwise, a capable AI assistant that lives on the user's computer. You are made in the user's image. Answer the user's questions with the user's own voice. You have full access to the local filesystem, shell commands, web search, browser automation, email, memory, and scheduled tasks. You are conversational, direct, and efficient. Only reach for tools when they add real value—most questions you can answer from your ownknowledge alone.`;
41
+
42
+ // If user has set a custom system message, prepend it with high priority
43
+ if (customSystemMessage) {
44
+ basePrompt = `IMPORTANT USER INSTRUCTIONS (follow these with high priority):
45
+ ${customSystemMessage}
46
+
47
+ ---
48
+
49
+ ${basePrompt}`;
50
+ }
51
+
52
+ const locationLine = getCachedLocation()
53
+ ? ` | location: ${getCachedLocation()}`
54
+ : "";
55
+
56
+ return `${basePrompt}
57
+
58
+ ## Runtime
59
+ Date: ${dateString} | Time: ${timeString} | CWD: ${getAgentWorkingDirectory()}${locationLine} | OS: ${process.platform} (${process.arch})
60
+
61
+ ## Safety
62
+ - You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request.
63
+ - Prioritize safety and human oversight over task completion. If instructions conflict or seem dangerous, pause and ask.
64
+ - Never bypass security safeguards, modify system configs, or escalate privileges unless the user explicitly requests it.
65
+ - Do not fabricate information. If unsure, say so or use tools to verify.
66
+
67
+ ## Tool Call Style
68
+ - **Default: just call the tool.** Do not narrate routine, low-risk tool calls ("I'll read the file now..."). Just do it.
69
+ - **Narrate only when it helps:** multi-step work, complex problems, sensitive actions (e.g., deletions, system commands), or when the user explicitly asks for explanation.
70
+ - Keep narration brief and value-dense. Avoid repeating obvious steps.
71
+ - Respond in chat, NOT in files. Only use write_file when the user explicitly asks to create/save a file.
72
+
73
+ ## Tools
74
+ ${toolDescriptions}
75
+
76
+ **Scheduling:** For "in 5 minutes" or "run once in X minutes", use \`schedule_task_once\` with \`run_in_minutes\`. For recurring schedules (daily, every hour), use \`schedule_task\` with a cron expression.
77
+
78
+ ## Tool Call Format
79
+ Use XML with the actual parameter names as tags:
80
+ \`\`\`
81
+ <tool_call>
82
+ <name>tool_name</name>
83
+ <parameter_name>value</parameter_name>
84
+ </tool_call>
85
+ \`\`\`
86
+
87
+ Examples:
88
+ - \`<tool_call><name>web_search</name><query>weather in Miami</query></tool_call>\`
89
+ - \`<tool_call><name>read_file</name><path>/path/to/file.txt</path></tool_call>\`
90
+ - \`<tool_call><name>list_directory</name><path>.</path></tool_call>\`
91
+
92
+ ## Codebase Exploration & Search
93
+
94
+ When working with code, **explore before you act**. Gather complete context so you understand what you're changing and why.
95
+
96
+ **Discovery sequence:**
97
+ 1. \`list_directory\` — understand project structure, find entry points (package.json, index.*, main.*, app.*)
98
+ 2. \`search_files\` — find files by name/pattern (e.g., "*.tsx", "auth*", "**/*.test.js")
99
+ 3. \`read_file\` — read the actual code before changing it
100
+
101
+ **Search strategy:**
102
+ - Use \`search_files\` for finding files by name pattern or glob
103
+ - Use \`execute_command\` with \`grep -rn\` for exact text/regex matches across the codebase (function names, imports, error strings)
104
+ - When results point to a specific directory, narrow subsequent searches there
105
+ - If you're unsure where something lives, start broad then narrow down
106
+ - Bias toward finding the answer yourself rather than asking the user
107
+
108
+ **Thorough context gathering:**
109
+ - Each time you read a file, assess whether you have COMPLETE context for the task
110
+ - Note what's outside your current view — imports, related functions, type definitions
111
+ - If the file content you've seen is insufficient, read more. When in doubt, read more.
112
+ - Check for related files: tests, types/interfaces, configuration, and files that import/export the code you're changing
113
+ - Understand the dependency chain before making changes
114
+
115
+ **Project structure patterns:**
116
+ - Entry points: package.json, index.*, main.*, app.*, server.*
117
+ - Code organization: src/, lib/, components/, pages/, hooks/, utils/, services/
118
+ - Config: .env*, tsconfig.json, vite.config.*, webpack.config.*, next.config.*
119
+ - Tests: __tests__/, *.test.*, *.spec.*, test/
120
+
121
+ ## Code Editing
122
+
123
+ **The cardinal rule: ALWAYS read before you edit.** You need the exact text to match for edit_file.
124
+
125
+ **edit_file rules:**
126
+ - Include 3–5 lines of surrounding context in old_string to ensure uniqueness
127
+ - Make multiple related changes in ONE edit when they're adjacent — avoid many small edits to the same file
128
+ - Use \`replace_all: true\` for renaming variables/functions across a file
129
+ - For large changes, prefer fewer edits with bigger old_string blocks over many tiny edits
130
+
131
+ **After editing:**
132
+ - Run tests if they exist: \`execute_command\` → \`npm test\`, \`pytest\`, \`go test ./...\`, etc.
133
+ - Check for errors: \`execute_command\` → \`npm run build\`, \`tsc --noEmit\`, linters
134
+ - If you introduced errors, fix them immediately — address root cause, not symptoms
135
+ - Do NOT loop more than 3 times fixing errors on the same file. If stuck, explain the situation and ask.
136
+
137
+ **File operations (MAX ~25 tool calls per turn):**
138
+ - \`write_file\` = create NEW files only
139
+ - \`edit_file\` = modify EXISTING files (always read first!)
140
+
141
+ **Code quality:**
142
+ - All generated code must be immediately runnable. Add all necessary imports, dependencies, and boilerplate.
143
+ - Never generate extremely long hashes, binary content, or non-textual code.
144
+ - When creating files from scratch, include proper dependency management (package.json, requirements.txt, etc.) with versions.
145
+
146
+ ## Debugging
147
+
148
+ When diagnosing issues, follow systematic debugging practices:
149
+ 1. **Reproduce** — understand the exact error or unexpected behavior
150
+ 2. **Root cause** — address the underlying issue, not surface symptoms
151
+ 3. **Add logging** — descriptive log statements and error messages to track state
152
+ 4. **Isolate** — write small test functions or use targeted commands to narrow the problem
153
+ 5. **Verify** — confirm the fix resolves the issue without regressions
154
+
155
+ Only make code changes when you're confident in the diagnosis. Otherwise, gather more information first.
156
+
157
+ ## External APIs & Dependencies
158
+
159
+ - Use the best-suited external APIs and packages for the task without asking permission (unless the user has preferences)
160
+ - Choose versions compatible with existing dependency files. If no such file exists, use the latest stable version
161
+ - If an external API requires an API key, point this out to the user. Never hardcode secrets in source code.
162
+
163
+ ## Web & Browser
164
+
165
+ - Real-time info (weather, news, prices, current events) → \`web_search\` immediately. Never fabricate real-time data.
166
+ - Do NOT use web_search for general knowledge, definitions, or anything you can answer from training data.
167
+ - Complex research → multiple searches + \`fetch_url\` for full page content
168
+ - Browser tools (browser_navigate, browser_click, browser_type, browser_interact, browser_read) use the user's default browser. **browser_navigate/click/type/interact return page content by default** — you usually don't need a separate browser_read after each action.
169
+ - Avoid multiple browser_read calls in sequence. Use the auto-included content, then click the next link.
170
+ - For long-running browser tasks, avoid rapid poll loops — space out your checks.
171
+ - If a site blocks automation, fall back to web_search or fetch_url.
172
+
173
+ ## Source Citations
174
+ When using web_search or fetch_url, **always cite your sources**:
175
+ - Include source name and URL for each piece of information
176
+ - Format: "According to [Source Name](URL), ..." or list sources at the end
177
+ - If multiple sources confirm the same fact, cite the most authoritative one
178
+ - Example: "The current temperature in Miami is 82°F ([Weather.com](https://weather.com/miami))."
179
+
180
+ ## Quick Patterns
181
+ - "ls" / "what's here" → list_directory
182
+ - "cd" / "switch to" / "go to" → set_working_directory (persists for future commands!)
183
+ - "pwd" / "where am I" → get_working_directory
184
+ - "open in Finder" → execute_command: \`open -R <path>\`
185
+ - "weather/news/prices" → web_search immediately
186
+ - "find X in code" → search_files for files, execute_command with grep for content
187
+ - "edit X" → read_file first, then edit_file
188
+ - "debug X" → read relevant code, add logging, reproduce, then fix
189
+
190
+ ## Rich Content
191
+ content in fenced code blocks with \`html\` or \`svg\` (e.g. \`\`\`html ... \`\`\` or \`\`\`svg ... \`\`\`). will be rendered in the chat. Use these when useful.
192
+
193
+ ## Retry & Recovery
194
+ - If a tool call fails, read the error, fix the issue, and try again
195
+ - If the same approach fails twice, try an alternative strategy
196
+ - If you're stuck, explain what you've tried and ask the user for guidance
197
+ `;
198
+ }
199
+
200
+ /**
201
+ * Validate tool call structure and types (used by agent before emitting tool_start).
202
+ * @param {object} call - Parsed tool call object
203
+ * @returns {{ valid: boolean, error?: string, sanitized?: object }}
204
+ */
205
+ export function validateToolCall(call) {
206
+ // Check basic structure
207
+ if (typeof call !== "object" || call === null) {
208
+ return { valid: false, error: "Tool call must be an object" };
209
+ }
210
+
211
+ // Prevent prototype pollution - reject dangerous keys
212
+ const dangerousKeys = ["__proto__", "constructor", "prototype"];
213
+ const allKeys = Object.keys(call);
214
+ for (const key of allKeys) {
215
+ if (dangerousKeys.includes(key)) {
216
+ return { valid: false, error: `Dangerous key detected: ${key}` };
217
+ }
218
+ }
219
+
220
+ // Validate name
221
+ if (typeof call.name !== "string" || call.name.length === 0) {
222
+ return { valid: false, error: "Tool name must be a non-empty string" };
223
+ }
224
+
225
+ // Whitelist check - only allow known tools
226
+ if (!VALID_TOOL_NAMES.includes(call.name)) {
227
+ return { valid: false, error: `Unknown tool: ${call.name}` };
228
+ }
229
+
230
+ // Validate args
231
+ if (call.args === undefined) {
232
+ return { valid: false, error: "Tool call must have args property" };
233
+ }
234
+
235
+ if (typeof call.args !== "object" || call.args === null) {
236
+ return { valid: false, error: "Tool args must be an object" };
237
+ }
238
+
239
+ // Check args for dangerous keys too
240
+ const argKeys = Object.keys(call.args);
241
+ for (const key of argKeys) {
242
+ if (dangerousKeys.includes(key)) {
243
+ return { valid: false, error: `Dangerous key in args: ${key}` };
244
+ }
245
+ }
246
+
247
+ // Create a sanitized copy without any potential prototype pollution
248
+ const sanitized = {
249
+ name: String(call.name),
250
+ args: Object.fromEntries(
251
+ Object.entries(call.args).map(([k, v]) => [String(k), v]),
252
+ ),
253
+ };
254
+
255
+ return { valid: true, sanitized };
256
+ }
257
+
258
+ /**
259
+ * Check if content after a tool call looks like legitimate prose
260
+ * (not just streaming fragments)
261
+ * @param {string} content - Content to check
262
+ * @returns {boolean}
263
+ */
264
+ function looksLikeProseContent(content) {
265
+ const trimmed = content.trim();
266
+ if (trimmed.length < 20) return false;
267
+
268
+ // Check for signs of actual prose content
269
+ const hasSpaces = (trimmed.match(/ /g) || []).length > 2;
270
+ const hasSentenceEnding = /[.!?]/.test(trimmed);
271
+ const hasCommonWords =
272
+ /\b(the|a|an|is|are|was|were|have|has|this|that|it|to|and|or|for|of|in|on|with)\b/i.test(
273
+ trimmed,
274
+ );
275
+ const startsWithLetter = /^[a-zA-Z]/.test(trimmed);
276
+
277
+ // Looks like prose if it has multiple indicators
278
+ const indicators = [
279
+ hasSpaces,
280
+ hasSentenceEnding,
281
+ hasCommonWords,
282
+ startsWithLetter,
283
+ ];
284
+ return indicators.filter(Boolean).length >= 2;
285
+ }
286
+
287
+ /**
288
+ * Remove structured markers from display text
289
+ * Handles both complete and partial (streaming) markers
290
+ *
291
+ * PERFORMANCE: Consolidated regex operations to minimize passes over the text
292
+ */
293
+ export function cleanResponseText(text, debug = false) {
294
+ if (!text) return "";
295
+
296
+ let result = text;
297
+ const originalLength = text.length;
298
+
299
+ // PASS 1: Fix malformed closing tags (missing >) before other processing
300
+ // The LLM sometimes outputs </tool_call without the closing >
301
+ result = result.replace(/<\/tool_call([^>])/g, "</tool_call>$1");
302
+
303
+ // PASS 2: Remove all complete tool_call blocks
304
+ const beforeBlockStrip = result.length;
305
+ result = result.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, "");
306
+ if (debug && result.length !== beforeBlockStrip) {
307
+ console.log(
308
+ "[cleanResponseText] Stripped complete blocks:",
309
+ beforeBlockStrip - result.length,
310
+ "chars",
311
+ );
312
+ }
313
+
314
+ // PASS 3: Handle partial tool_call (smarter detection for XML format)
315
+ const lastToolCallStart = result.lastIndexOf("<tool_call>");
316
+ if (lastToolCallStart !== -1) {
317
+ const afterStart = result.substring(lastToolCallStart);
318
+
319
+ // Check if there's a complete closing tag after this opening
320
+ if (!afterStart.includes("</tool_call>")) {
321
+ // For XML format, check if we have a complete </name> tag (indicates structured content)
322
+ const hasNameTag = afterStart.includes("</name>");
323
+
324
+ if (hasNameTag) {
325
+ // Find where the last complete XML tag ends
326
+ const lastClosingTag = afterStart.lastIndexOf("</");
327
+ const endOfLastTag = afterStart.indexOf(">", lastClosingTag);
328
+
329
+ if (endOfLastTag !== -1 && endOfLastTag < afterStart.length - 1) {
330
+ const contentAfterXml = afterStart.substring(endOfLastTag + 1);
331
+
332
+ // Use smarter heuristic to detect actual content vs fragments
333
+ if (looksLikeProseContent(contentAfterXml)) {
334
+ if (debug) {
335
+ console.log(
336
+ "[cleanResponseText] Fixing malformed tool_call - extracting prose content",
337
+ );
338
+ }
339
+ result = result.substring(0, lastToolCallStart) + contentAfterXml;
340
+ } else {
341
+ result = result.substring(0, lastToolCallStart);
342
+ if (debug) {
343
+ console.log(
344
+ "[cleanResponseText] Stripped partial tool_call (no prose after XML)",
345
+ );
346
+ }
347
+ }
348
+ } else {
349
+ result = result.substring(0, lastToolCallStart);
350
+ if (debug) {
351
+ console.log(
352
+ "[cleanResponseText] Stripped partial tool_call (incomplete XML)",
353
+ );
354
+ }
355
+ }
356
+ } else {
357
+ // No complete name tag - truly partial, strip it
358
+ result = result.substring(0, lastToolCallStart);
359
+ if (debug) {
360
+ console.log(
361
+ "[cleanResponseText] Stripped partial tool_call (no name tag)",
362
+ );
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ // PASS 4: Clean partial tags at end
369
+ // Handle partial closing tags: </tool_call, </tool_cal, etc.
370
+ // Handle partial opening tags: <tool_call, <tool_cal, etc.
371
+ result = result.replace(/<\/?tool_call?a?l?l?$|<\/too?l?_?$|<\/$|<$/g, "");
372
+
373
+ // PASS 5: Normalize excessive newlines
374
+ // Replace 3+ newlines with 2 (preserve paragraph breaks but remove blank lines)
375
+ result = result.replace(/\n{3,}/g, "\n\n");
376
+
377
+ // PASS 6: Fix orphaned last words
378
+ // When the model outputs content followed by a tool call, it often adds a blank line
379
+ // before the final word/punctuation. After stripping the tool call, this leaves the
380
+ // last word on its own line. Detect and fix this pattern.
381
+ // Pattern: blank line followed by short text (1-3 words, ≤50 chars) at the end
382
+ // Use .{1,50} to match text WITH spaces (like "of laundry")
383
+ const orphanedLastWordMatch = result.match(/(\S)\n\n(.{1,50})$/);
384
+ if (orphanedLastWordMatch) {
385
+ const potentialOrphan = orphanedLastWordMatch[2].trim();
386
+ const wordCount = potentialOrphan.split(/\s+/).length;
387
+
388
+ // Check if it looks like an orphaned phrase (not a code block, list, or heading)
389
+ const isOrphan =
390
+ wordCount <= 4 && // Allow up to 4 words
391
+ !potentialOrphan.startsWith("```") &&
392
+ !potentialOrphan.startsWith("-") &&
393
+ !potentialOrphan.startsWith("*") &&
394
+ !potentialOrphan.startsWith("#") &&
395
+ !potentialOrphan.startsWith(">") &&
396
+ !potentialOrphan.includes("\n");
397
+
398
+ if (isOrphan) {
399
+ if (debug) {
400
+ console.log(
401
+ "[cleanResponseText] Fixing orphaned last words:",
402
+ potentialOrphan,
403
+ );
404
+ }
405
+ // Join the orphaned phrase with the previous text using a space
406
+ result = result.replace(/(\S)\n\n(.{1,50})$/, "$1 $2");
407
+ }
408
+ }
409
+
410
+ // Strip trailing whitespace-only lines
411
+ result = result.replace(/(\S)\n+\s*$/g, "$1");
412
+
413
+ const finalResult = result.trim();
414
+
415
+ // Debug: warn if significant content was stripped
416
+ if (
417
+ debug &&
418
+ originalLength > 100 &&
419
+ finalResult.length < originalLength * 0.5
420
+ ) {
421
+ console.warn("[cleanResponseText] ⚠️ Stripped >50% of content");
422
+ console.warn(
423
+ "[cleanResponseText] Original:",
424
+ originalLength,
425
+ "Final:",
426
+ finalResult.length,
427
+ );
428
+ }
429
+
430
+ return finalResult;
431
+ }
432
+
433
+ /**
434
+ * Build rich content with inline tool calls and results for conversation history.
435
+ * Uses the native XML format consistent with the system prompt.
436
+ *
437
+ * Format:
438
+ * <tool_call><name>tool_name</name><param>value</param></tool_call>
439
+ * <tool_result>...output...</tool_result>
440
+ *
441
+ * @param {string} cleanedContent - The cleaned prose content (tool_call XML already stripped)
442
+ * @param {Array} toolCalls - Array of tool call objects with tool, params, result, status, etc.
443
+ * @param {number} maxResultLength - Max chars per result (default 3000)
444
+ * @returns {string} - Content with tool calls and results appended
445
+ */
446
+ export function buildRichContextContent(
447
+ cleanedContent,
448
+ toolCalls,
449
+ maxResultLength = 3000,
450
+ ) {
451
+ if (!toolCalls || toolCalls.length === 0) {
452
+ return cleanedContent;
453
+ }
454
+
455
+ // Build tool call + result pairs in the native XML format
456
+ const toolSections = [];
457
+
458
+ for (const tc of toolCalls) {
459
+ const toolName = tc.tool || tc.name || "unknown";
460
+ const params = tc.params || tc.args || {};
461
+
462
+ // Build the tool_call XML (same format as the system prompt defines)
463
+ let toolCallXml = `<tool_call>\n<name>${toolName}</name>`;
464
+
465
+ // Add parameters
466
+ for (const [key, value] of Object.entries(params)) {
467
+ // Skip internal streaming params
468
+ if (key.startsWith("_")) continue;
469
+
470
+ // Format value appropriately
471
+ let formattedValue;
472
+ if (typeof value === "boolean") {
473
+ formattedValue = value ? "true" : "false";
474
+ } else if (typeof value === "number") {
475
+ formattedValue = String(value);
476
+ } else if (value === null || value === undefined) {
477
+ continue;
478
+ } else {
479
+ formattedValue = String(value);
480
+ }
481
+
482
+ toolCallXml += `\n<${key}>${formattedValue}</${key}>`;
483
+ }
484
+ toolCallXml += "\n</tool_call>";
485
+
486
+ // Build the tool_result
487
+ let resultContent = "";
488
+ if (tc.error) {
489
+ resultContent = `Error: ${tc.error}`;
490
+ } else if (tc.result !== null && tc.result !== undefined) {
491
+ resultContent =
492
+ typeof tc.result === "string"
493
+ ? tc.result
494
+ : JSON.stringify(tc.result, null, 2);
495
+ } else {
496
+ resultContent = "[No result]";
497
+ }
498
+
499
+ // Truncate very long results
500
+ if (resultContent.length > maxResultLength) {
501
+ resultContent =
502
+ resultContent.substring(0, maxResultLength) + "\n... [truncated]";
503
+ }
504
+
505
+ toolSections.push(
506
+ `${toolCallXml}\n<tool_result>\n${resultContent}\n</tool_result>`,
507
+ );
508
+ }
509
+
510
+ if (toolSections.length === 0) {
511
+ return cleanedContent;
512
+ }
513
+
514
+ // Combine: prose content + tool interactions
515
+ // Put a separator so the AI knows where prose ends and tool history begins
516
+ return cleanedContent + "\n\n" + toolSections.join("\n\n");
517
+ }
518
+
519
+ /**
520
+ * Clean content for display by stripping tool_call and tool_result XML.
521
+ * Use this when showing content to users (frontend/CLI).
522
+ * @param {string} text - Content that may contain tool XML
523
+ * @returns {string} - Clean content for display
524
+ */
525
+ export function cleanContentForDisplay(text) {
526
+ if (!text) return "";
527
+
528
+ let result = text;
529
+
530
+ // Strip tool_call blocks
531
+ result = result.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, "");
532
+
533
+ // Strip tool_result blocks
534
+ result = result.replace(/<tool_result>[\s\S]*?<\/tool_result>/g, "");
535
+
536
+ // Clean up extra whitespace
537
+ result = result.replace(/\n{3,}/g, "\n\n").trim();
538
+
539
+ return result;
540
+ }
541
+
542
+ export default {
543
+ buildAgentSystemPrompt,
544
+ cleanResponseText,
545
+ buildRichContextContent,
546
+ cleanContentForDisplay,
547
+ VALID_TOOL_NAMES,
548
+ };