Skip to content

Commit bccdbe5

Browse files
committed
chore: efficient token use in phoenix builder log scans mcp
1 parent c14cd8a commit bccdbe5

5 files changed

Lines changed: 192 additions & 15 deletions

File tree

phoenix-builder-mcp/log-buffer.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ export class LogBuffer {
44
constructor() {
55
this._entries = [];
66
this._readIndex = 0;
7+
this._totalPushed = 0;
78
}
89

910
push(entry) {
1011
this._entries.push(entry);
12+
this._totalPushed++;
1113
if (this._entries.length > MAX_ENTRIES) {
1214
const overflow = this._entries.length - MAX_ENTRIES;
1315
this._entries.splice(0, overflow);
@@ -25,6 +27,23 @@ export class LogBuffer {
2527
return newEntries;
2628
}
2729

30+
totalPushed() {
31+
return this._totalPushed;
32+
}
33+
34+
getTail(n, before) {
35+
const firstIndex = this._totalPushed - this._entries.length;
36+
let endIdx = this._entries.length;
37+
if (before != null) {
38+
endIdx = Math.max(0, Math.min(this._entries.length, before - firstIndex));
39+
}
40+
if (n === 0) {
41+
return this._entries.slice(0, endIdx);
42+
}
43+
const startIdx = Math.max(0, endIdx - n);
44+
return this._entries.slice(startIdx, endIdx);
45+
}
46+
2847
clear() {
2948
this._entries = [];
3049
this._readIndex = 0;

phoenix-builder-mcp/mcp-tools.js

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import { z } from "zod";
22

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+
318
export function registerTools(server, processManager, wsControlServer, phoenixDesktopPath) {
419
server.tool(
520
"start_phoenix",
@@ -67,39 +82,138 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe
6782

6883
server.tool(
6984
"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 }) => {
7397
let logs;
7498
if (clear) {
7599
logs = processManager.getTerminalLogs(false);
76100
processManager.clearTerminalLogs();
77101
} else {
78102
logs = processManager.getTerminalLogs(true);
79103
}
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("");
81152
return {
82153
content: [{
83154
type: "text",
84-
text: text || "(no terminal logs)"
155+
text: text ? header + "\n" + text : "(no terminal logs)"
85156
}]
86157
};
87158
}
88159
);
89160

90161
server.tool(
91162
"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.",
93167
{
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.")
95173
},
96-
async ({ instance }) => {
174+
async ({ instance, tail, before, filter, maxChars }) => {
97175
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");
99213
return {
100214
content: [{
101215
type: "text",
102-
text: JSON.stringify(logs.length > 0 ? logs : "(no browser logs)")
216+
text: header + "\n" + text
103217
}]
104218
};
105219
} catch (err) {

phoenix-builder-mcp/process-manager.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,17 @@ export function createProcessManager() {
112112
terminalLogs.clear();
113113
}
114114

115+
function getTerminalLogsTotalPushed() {
116+
return terminalLogs.totalPushed();
117+
}
118+
115119
return {
116120
start,
117121
stop,
118122
isRunning,
119123
getPid,
120124
getTerminalLogs,
121-
clearTerminalLogs
125+
clearTerminalLogs,
126+
getTerminalLogsTotalPushed
122127
};
123128
}

phoenix-builder-mcp/ws-control-server.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ export function createWSControlServer(port) {
7373
const pending4 = pendingRequests.get(msg.id);
7474
if (pending4) {
7575
pendingRequests.delete(msg.id);
76-
pending4.resolve(msg.entries || []);
76+
pending4.resolve({
77+
entries: msg.entries || [],
78+
totalEntries: msg.totalEntries || (msg.entries ? msg.entries.length : 0),
79+
matchedEntries: msg.matchedEntries,
80+
rangeEnd: msg.rangeEnd
81+
});
7782
}
7883
break;
7984
}
@@ -260,7 +265,7 @@ export function createWSControlServer(port) {
260265
});
261266
}
262267

263-
function requestLogs(instanceName) {
268+
function requestLogs(instanceName, { tail = 50, before, filter } = {}) {
264269
return new Promise((resolve, reject) => {
265270
const resolved = _resolveClient(instanceName);
266271
if (resolved.error) {
@@ -291,7 +296,14 @@ export function createWSControlServer(port) {
291296
}
292297
});
293298

294-
client.ws.send(JSON.stringify({ type: "get_logs_request", id }));
299+
const msg = { type: "get_logs_request", id, tail };
300+
if (before != null) {
301+
msg.before = before;
302+
}
303+
if (filter) {
304+
msg.filter = filter;
305+
}
306+
client.ws.send(JSON.stringify(msg));
295307
});
296308
}
297309

src/phoenix-builder/phoenix-builder-boot.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
let ws = null;
5050
let logBuffer = [];
5151
const capturedLogs = [];
52+
let totalLogsPushed = 0;
5253
let flushTimer = null;
5354
let reconnectTimer = null;
5455
let reconnectDelay = RECONNECT_BASE_MS;
@@ -133,6 +134,7 @@
133134
};
134135
logBuffer.push(entry);
135136
capturedLogs.push(entry);
137+
totalLogsPushed++;
136138
// Cap buffer size — drop oldest entries to prevent unbounded memory growth
137139
if (logBuffer.length > MAX_BUFFER_SIZE) {
138140
logBuffer = logBuffer.slice(logBuffer.length - MAX_BUFFER_SIZE);
@@ -309,10 +311,35 @@
309311
// --- Register built-in handler for get_logs_request ---
310312
// Returns the full capturedLogs buffer to the MCP server on demand.
311313
registerHandler("get_logs_request", function (msg) {
314+
const tail = typeof msg.tail === "number" ? msg.tail : 50;
315+
const before = typeof msg.before === "number" ? msg.before : null;
316+
const filterStr = msg.filter || null;
317+
let source = capturedLogs;
318+
if (filterStr) {
319+
try {
320+
const re = new RegExp(filterStr, "i");
321+
source = capturedLogs.filter(function (e) { return re.test(e.message); });
322+
} catch (e) {
323+
// invalid regex — skip filtering
324+
source = capturedLogs;
325+
}
326+
}
327+
const matchedEntries = source.length;
328+
const endIdx = before != null ? Math.max(0, Math.min(matchedEntries, before)) : matchedEntries;
329+
let entries;
330+
if (tail === 0) {
331+
entries = source.slice(0, endIdx);
332+
} else {
333+
const startIdx = Math.max(0, endIdx - tail);
334+
entries = source.slice(startIdx, endIdx);
335+
}
312336
_sendMessage({
313337
type: "get_logs_response",
314338
id: msg.id,
315-
entries: capturedLogs.slice()
339+
entries: entries,
340+
totalEntries: totalLogsPushed,
341+
matchedEntries: matchedEntries,
342+
rangeEnd: endIdx
316343
});
317344
});
318345

0 commit comments

Comments
 (0)