|
1 | 1 | import { z } from "zod"; |
2 | 2 |
|
| 3 | +const DEFAULT_MAX_CHARS = 10000; |
| 4 | + |
| 5 | +function _trimToCharBudget(lines, maxChars) { |
| 6 | + let total = 0; |
| 7 | + // Walk backwards (newest first) to keep the most recent entries |
| 8 | + let startIdx = lines.length; |
| 9 | + for (let i = lines.length - 1; i >= 0; i--) { |
| 10 | + const cost = lines[i].length + 1; // +1 for newline |
| 11 | + if (total + cost > maxChars) { break; } |
| 12 | + total += cost; |
| 13 | + startIdx = i; |
| 14 | + } |
| 15 | + return { lines: lines.slice(startIdx), trimmed: startIdx }; |
| 16 | +} |
| 17 | + |
3 | 18 | export function registerTools(server, processManager, wsControlServer, phoenixDesktopPath) { |
4 | 19 | server.tool( |
5 | 20 | "start_phoenix", |
@@ -67,39 +82,138 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe |
67 | 82 |
|
68 | 83 | server.tool( |
69 | 84 | "get_terminal_logs", |
70 | | - "Get stdout/stderr output from the Electron process. By default returns new logs since last call; set clear=true to get all logs and clear the buffer.", |
71 | | - { clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read.") }, |
72 | | - async ({ clear }) => { |
| 85 | + "Get stdout/stderr output from the Electron process. Returns last 50 entries by default. " + |
| 86 | + "USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " + |
| 87 | + "Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " + |
| 88 | + "prefer filter + small tail to keep responses compact.", |
| 89 | + { |
| 90 | + clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read."), |
| 91 | + tail: z.number().default(50).describe("Return last N entries. 0 = all."), |
| 92 | + before: z.number().optional().describe("Cursor: return entries before this totalEntries position. Use the totalEntries value from a previous response to page back stably."), |
| 93 | + filter: z.string().optional().describe("Optional regex to filter log entries by text content. Applied before tail/before."), |
| 94 | + maxChars: z.number().default(DEFAULT_MAX_CHARS).describe("Max character budget for log content. Oldest entries are dropped first to fit. 0 = unlimited.") |
| 95 | + }, |
| 96 | + async ({ clear, tail, before, filter, maxChars }) => { |
73 | 97 | let logs; |
74 | 98 | if (clear) { |
75 | 99 | logs = processManager.getTerminalLogs(false); |
76 | 100 | processManager.clearTerminalLogs(); |
77 | 101 | } else { |
78 | 102 | logs = processManager.getTerminalLogs(true); |
79 | 103 | } |
80 | | - const text = logs.map(e => `[${e.stream}] ${e.text}`).join(""); |
| 104 | + const totalEntries = processManager.getTerminalLogsTotalPushed(); |
| 105 | + let filterRe; |
| 106 | + if (filter) { |
| 107 | + try { |
| 108 | + filterRe = new RegExp(filter, "i"); |
| 109 | + } catch (e) { |
| 110 | + return { |
| 111 | + content: [{ |
| 112 | + type: "text", |
| 113 | + text: `Invalid filter regex: ${e.message}` |
| 114 | + }] |
| 115 | + }; |
| 116 | + } |
| 117 | + logs = logs.filter(e => filterRe.test(e.text)); |
| 118 | + } |
| 119 | + const matchedEntries = logs.length; |
| 120 | + const endIdx = before != null ? Math.max(0, Math.min(matchedEntries, before)) : matchedEntries; |
| 121 | + if (tail > 0) { |
| 122 | + const startIdx = Math.max(0, endIdx - tail); |
| 123 | + logs = logs.slice(startIdx, endIdx); |
| 124 | + } else { |
| 125 | + logs = logs.slice(0, endIdx); |
| 126 | + } |
| 127 | + let lines = logs.map(e => `[${e.stream}] ${e.text}`); |
| 128 | + let trimmed = 0; |
| 129 | + if (maxChars > 0) { |
| 130 | + const result = _trimToCharBudget(lines, maxChars); |
| 131 | + lines = result.lines; |
| 132 | + trimmed = result.trimmed; |
| 133 | + } |
| 134 | + const showing = lines.length; |
| 135 | + const rangeEnd = endIdx; |
| 136 | + const rangeStart = rangeEnd - logs.length; |
| 137 | + const actualStart = rangeStart + trimmed; |
| 138 | + const hasMore = actualStart > 0; |
| 139 | + let header = `[Logs: ${totalEntries} total`; |
| 140 | + if (filter) { |
| 141 | + header += `, ${matchedEntries} matched /${filter}/i`; |
| 142 | + } |
| 143 | + header += `, showing ${actualStart}-${rangeEnd} (${showing} entries).`; |
| 144 | + if (trimmed > 0) { |
| 145 | + header += ` ${trimmed} entries trimmed to fit maxChars=${maxChars}.`; |
| 146 | + } |
| 147 | + if (hasMore) { |
| 148 | + header += ` hasMore=true, use before=${actualStart} to page back.`; |
| 149 | + } |
| 150 | + header += `]`; |
| 151 | + const text = lines.join(""); |
81 | 152 | return { |
82 | 153 | content: [{ |
83 | 154 | type: "text", |
84 | | - text: text || "(no terminal logs)" |
| 155 | + text: text ? header + "\n" + text : "(no terminal logs)" |
85 | 156 | }] |
86 | 157 | }; |
87 | 158 | } |
88 | 159 | ); |
89 | 160 |
|
90 | 161 | server.tool( |
91 | 162 | "get_browser_console_logs", |
92 | | - "Get console logs captured from the Phoenix browser runtime from boot time. Fetches the full retained log buffer directly from the browser instance.", |
| 163 | + "Get console logs from the Phoenix browser runtime. Returns last 50 entries by default. " + |
| 164 | + "USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " + |
| 165 | + "Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " + |
| 166 | + "prefer filter + small tail to keep responses compact.", |
93 | 167 | { |
94 | | - instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.") |
| 168 | + instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected."), |
| 169 | + tail: z.number().default(50).describe("Return last N entries. 0 = all."), |
| 170 | + before: z.number().optional().describe("Cursor: return entries before this totalEntries position. Use the totalEntries value from a previous response to page back stably."), |
| 171 | + filter: z.string().optional().describe("Optional regex to filter log entries by message content. Applied before tail/before."), |
| 172 | + maxChars: z.number().default(DEFAULT_MAX_CHARS).describe("Max character budget for log content. Oldest entries are dropped first to fit. 0 = unlimited.") |
95 | 173 | }, |
96 | | - async ({ instance }) => { |
| 174 | + async ({ instance, tail, before, filter, maxChars }) => { |
97 | 175 | try { |
98 | | - const logs = await wsControlServer.requestLogs(instance); |
| 176 | + const result = await wsControlServer.requestLogs(instance, { tail, before, filter }); |
| 177 | + const entries = result.entries || []; |
| 178 | + const totalEntries = result.totalEntries || entries.length; |
| 179 | + const matchedEntries = result.matchedEntries != null ? result.matchedEntries : entries.length; |
| 180 | + const rangeEnd = result.rangeEnd != null ? result.rangeEnd : matchedEntries; |
| 181 | + let lines = entries.map(e => `[${e.level}] ${e.message}`); |
| 182 | + let trimmed = 0; |
| 183 | + if (maxChars > 0) { |
| 184 | + const trimResult = _trimToCharBudget(lines, maxChars); |
| 185 | + lines = trimResult.lines; |
| 186 | + trimmed = trimResult.trimmed; |
| 187 | + } |
| 188 | + const showing = lines.length; |
| 189 | + const rangeStart = rangeEnd - entries.length; |
| 190 | + const actualStart = rangeStart + trimmed; |
| 191 | + const hasMore = actualStart > 0; |
| 192 | + let header = `[Logs: ${totalEntries} total`; |
| 193 | + if (filter) { |
| 194 | + header += `, ${matchedEntries} matched /${filter}/i`; |
| 195 | + } |
| 196 | + header += `, showing ${actualStart}-${rangeEnd} (${showing} entries).`; |
| 197 | + if (trimmed > 0) { |
| 198 | + header += ` ${trimmed} entries trimmed to fit maxChars=${maxChars}.`; |
| 199 | + } |
| 200 | + if (hasMore) { |
| 201 | + header += ` hasMore=true, use before=${actualStart} to page back.`; |
| 202 | + } |
| 203 | + header += `]`; |
| 204 | + if (showing === 0) { |
| 205 | + return { |
| 206 | + content: [{ |
| 207 | + type: "text", |
| 208 | + text: "(no browser logs)" |
| 209 | + }] |
| 210 | + }; |
| 211 | + } |
| 212 | + const text = lines.join("\n"); |
99 | 213 | return { |
100 | 214 | content: [{ |
101 | 215 | type: "text", |
102 | | - text: JSON.stringify(logs.length > 0 ? logs : "(no browser logs)") |
| 216 | + text: header + "\n" + text |
103 | 217 | }] |
104 | 218 | }; |
105 | 219 | } catch (err) { |
|
0 commit comments