Merge Mac + Pi: add system_get_info/system_speech_api_status + restore codex-image/gemini-image/gemini-chrome-prompt tools from Mac

Mac mini is on macOS 26.5.2 with SpeechAnalyzer available - verified via sw_vers
Needed to sync diverged repos: Pi had system tools, Mac had image tools
This commit is contained in:
aeroreyna
2026-07-13 16:16:34 -04:00
parent 9498079c5c
commit 4bed6145b9
4 changed files with 415 additions and 3 deletions
+175
View File
@@ -0,0 +1,175 @@
import { spawn } from "node:child_process";
import { mkdir, stat } from "node:fs/promises";
import path from "node:path";
const DEFAULT_OUTPUT_DIR = new URL("../../generated-images", import.meta.url).pathname;
const DEFAULT_CODEX_PATH = "codex";
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
function getOutputDir() {
return process.env.CODEX_IMAGE_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
}
function getCodexPath() {
return process.env.CODEX_CLI_PATH || DEFAULT_CODEX_PATH;
}
function safeFilename(filename) {
const fallback = `codex-${new Date().toISOString().replaceAll(/[:.]/g, "-")}.png`;
const base = path.basename(filename || fallback).replaceAll(/[^a-zA-Z0-9._-]/g, "-");
const trimmed = base.replaceAll(/-+/g, "-").replaceAll(/^\.+/g, "");
return trimmed || fallback;
}
function parseJsonFromOutput(output) {
const trimmed = output.trim();
if (!trimmed) {
return null;
}
try {
return JSON.parse(trimmed);
} catch {
const match = trimmed.match(/\{[\s\S]*\}$/);
if (!match) {
return null;
}
try {
return JSON.parse(match[0]);
} catch {
return null;
}
}
}
function runCodex(codexPath, args, timeoutMs) {
return new Promise((resolve, reject) => {
const child = spawn(codexPath, args, {
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
if (timedOut) {
reject(new Error(`timed out after ${timeoutMs}ms. ${stderr.trim()}`));
return;
}
if (code !== 0) {
reject(new Error(stderr.trim() || stdout.trim() || `exited with ${signal || code}`));
return;
}
resolve({ stdout, stderr });
});
child.stdin.end();
});
}
export function getCodexImageConfigStatus() {
return {
codexCliPath: getCodexPath(),
outputDir: getOutputDir(),
model: process.env.CODEX_IMAGE_MODEL || "(Codex CLI default)",
timeoutMs: Number.parseInt(process.env.CODEX_IMAGE_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS), 10),
};
}
export async function generateCodexImage({
prompt,
filename,
size,
quality,
style,
referenceImage,
}) {
const outputDir = getOutputDir();
await mkdir(outputDir, { recursive: true });
const outputFilename = safeFilename(filename);
const outputPath = path.join(outputDir, outputFilename);
const codexPath = getCodexPath();
const timeoutMs = Number.parseInt(process.env.CODEX_IMAGE_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS), 10);
const details = [
size ? `Requested size/aspect: ${size}` : null,
quality ? `Requested quality: ${quality}` : null,
style ? `Requested style: ${style}` : null,
].filter(Boolean).join("\n");
const workerPrompt = [
"Use $imagegen to generate exactly one raster image.",
`Save the final image file at this exact absolute path: ${outputPath}`,
"Do not modify any other files.",
"After saving the file, respond only with JSON matching this shape:",
`{"ok":true,"path":"${outputPath.replaceAll("\\", "\\\\")}","note":"short description"}`,
details ? `Generation details:\n${details}` : null,
`Image prompt:\n${prompt}`,
].filter(Boolean).join("\n\n");
const args = [
"exec",
"--ephemeral",
"--sandbox",
"workspace-write",
"--enable",
"image_generation",
"-C",
new URL("../..", import.meta.url).pathname,
];
if (process.env.CODEX_IMAGE_MODEL) {
args.push("--model", process.env.CODEX_IMAGE_MODEL);
}
if (referenceImage) {
args.push("--image", referenceImage);
}
args.push(workerPrompt);
let stdout = "";
let stderr = "";
try {
const result = await runCodex(codexPath, args, timeoutMs);
stdout = result.stdout;
stderr = result.stderr;
} catch (error) {
throw new Error(`Codex image generation failed. ${error.message}`);
}
try {
const file = await stat(outputPath);
const parsed = parseJsonFromOutput(stdout);
return {
ok: true,
path: outputPath,
filename: outputFilename,
bytes: file.size,
codexCliPath: codexPath,
note: parsed?.note || null,
stderr: stderr.trim() || null,
};
} catch {
throw new Error(`Codex completed but did not create ${outputPath}. Output: ${stdout.trim() || "(empty)"}`);
}
}
+67
View File
@@ -0,0 +1,67 @@
import path from "node:path";
const DEFAULT_OUTPUT_DIR = new URL("../../generated-images", import.meta.url).pathname;
const DEFAULT_PROFILE_NAME = "ReynaFamilyBot";
function getOutputDir() {
return process.env.GEMINI_CHROME_IMAGE_OUTPUT_DIR || process.env.CODEX_IMAGE_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
}
function getProfileName() {
return process.env.GEMINI_CHROME_PROFILE_NAME || DEFAULT_PROFILE_NAME;
}
function safeFilename(filename) {
const fallback = `gemini-chrome-${new Date().toISOString().replaceAll(/[:.]/g, "-")}.png`;
const base = path.basename(filename || fallback).replaceAll(/[^a-zA-Z0-9._-]/g, "-");
const trimmed = base.replaceAll(/-+/g, "-").replaceAll(/^\.+/g, "");
const name = trimmed || fallback;
return name.endsWith(".png") ? name : `${name}.png`;
}
function codexPrompt({ prompt, outputPath, profileName }) {
return [
"Use the Chrome skill, not Playwright and not MacMiniMCP browser tools.",
"",
"Goal: generate an image in Gemini using my Chrome profile named `" + profileName + "`, then save the downloaded image locally.",
"",
"Steps:",
"1. Connect to Chrome through the Codex Chrome Extension.",
"2. Verify the selected Chrome browser metadata has `profileName: \"" + profileName + "\"`. If not, stop and tell me.",
"3. Open or create a Gemini tab at https://gemini.google.com/app.",
"4. If Gemini shows the first-run notice, click `Got it`.",
"5. Submit this image prompt:",
"",
prompt,
"",
"6. Wait until Gemini finishes and shows `Download full size image`.",
"7. Click `Download full size image`.",
"8. Find the newest `Gemini_Generated_Image_*.png` in `/Users/adolforeyna/Downloads`.",
"9. Copy it to:",
" `" + outputPath + "`",
"10. Show me the saved path and render the image in the response.",
"",
"Do not expose browser control through MCP. Do not use arbitrary browsing. Only use Chrome for this Gemini image-generation task.",
].join("\n");
}
export function getGeminiChromePromptConfigStatus() {
return {
outputDir: getOutputDir(),
profileName: getProfileName(),
note: "This MCP tool builds a Codex prompt. It does not control Chrome itself because the Chrome skill is only available inside an active Codex session.",
};
}
export function buildGeminiChromePrompt({ prompt, filename } = {}) {
const outputFilename = safeFilename(filename);
const outputPath = path.join(getOutputDir(), outputFilename);
const profileName = getProfileName();
return {
prompt: codexPrompt({ prompt, outputPath, profileName }),
outputPath,
filename: outputFilename,
profileName,
};
}
+99
View File
@@ -0,0 +1,99 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const defaultOutputDir = path.join(projectRoot, "generated-images");
const defaultModel = "gemini-3.1-flash-image";
const interactionsUrl = "https://generativelanguage.googleapis.com/v1beta/interactions";
function outputDir() {
return process.env.GEMINI_IMAGE_OUTPUT_DIR || defaultOutputDir;
}
function apiKey() {
return process.env.GEMINI_API_KEY || "";
}
function safeFilename(name) {
const fallback = `gemini-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
const base = path.basename(name || fallback).replace(/[^a-zA-Z0-9._-]/g, "-");
if (!base) {
return fallback;
}
return base.endsWith(".png") ? base : `${base}.png`;
}
function buildResponseFormat({ aspectRatio, imageSize }) {
if (!aspectRatio && !imageSize) {
return undefined;
}
return {
type: "image",
mime_type: "image/png",
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
...(imageSize ? { image_size: imageSize } : {}),
};
}
export function getGeminiImageConfigStatus() {
return {
configured: Boolean(apiKey()),
outputDir: outputDir(),
defaultModel,
};
}
export async function generateGeminiImage({
prompt,
filename,
model = defaultModel,
aspectRatio,
imageSize,
useGoogleSearch = false,
} = {}) {
const key = apiKey();
if (!key) {
throw new Error("GEMINI_API_KEY is required for gemini_image_generate.");
}
const responseFormat = buildResponseFormat({ aspectRatio, imageSize });
const body = {
model,
input: [{ type: "text", text: prompt }],
...(responseFormat ? { response_format: responseFormat } : {}),
...(useGoogleSearch ? { tools: [{ type: "google_search" }] } : {}),
};
const response = await fetch(interactionsUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-goog-api-key": key,
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const message = data?.error?.message || response.statusText || "Gemini image generation failed.";
throw new Error(`Gemini API error ${response.status}: ${message}`);
}
const image = data?.output_image;
if (!image?.data) {
throw new Error("Gemini API did not return output_image.data.");
}
const destinationDir = outputDir();
await fs.mkdir(destinationDir, { recursive: true });
const destination = path.join(destinationDir, safeFilename(filename));
await fs.writeFile(destination, Buffer.from(image.data, "base64"));
return {
path: destination,
model,
mimeType: image.mime_type || "image/png",
interactionId: data.id,
};
}
+74 -3
View File
@@ -11,7 +11,19 @@ import {
readContact,
searchContacts,
} from "./integrations/contacts.js";
import {
generateCodexImage,
getCodexImageConfigStatus,
} from "./integrations/codex-image.js";
import { getDecoConfigStatus, getDecoStats } from "./integrations/deco.js";
import {
buildGeminiChromePrompt,
getGeminiChromePromptConfigStatus,
} from "./integrations/gemini-chrome-prompt.js";
import {
generateGeminiImage,
getGeminiImageConfigStatus,
} from "./integrations/gemini-image.js";
import { createNote, listNotes, readNote } from "./integrations/notes.js";
import {
createReminder,
@@ -119,14 +131,14 @@ export function createMacMiniMcpServer() {
server.tool(
"reminders_list_lists",
"List Reminders lists.",
"List Reminders lists, including account context and assignment metadata availability for shared lists.",
{},
handled(listReminderLists),
);
server.tool(
"reminders_list",
"List reminders with optional list and completion filters.",
"List reminders with optional list and completion filters. Returns assignment details for shared-list reminders when available, plus title/notes-derived assignment hints.",
{
list: z.string().optional().describe("Exact Reminders list name."),
completed: z.boolean().nullable().default(false).describe("Use null to return both complete and incomplete items."),
@@ -208,7 +220,7 @@ export function createMacMiniMcpServer() {
server.tool(
"deco_list_clients",
"List online TP-Link Deco clients with hostname, IP, MAC, connection type, and current up/down speeds.",
"List online TP-Link Deco clients with hostname, IP, MAC, connection type, current up/down speeds, and linked mesh node when available.",
{},
handled(() => getDecoStats("clients")),
);
@@ -241,5 +253,64 @@ export function createMacMiniMcpServer() {
handled(() => getSpeechApiStatus()),
);
server.tool(
"codex_image_get_config_status",
"Show local Codex CLI image generation configuration.",
{},
handled(getCodexImageConfigStatus),
);
server.tool(
"codex_image_generate",
"Generate an image using this Mac's Codex CLI image generation and save it to the MacMiniMCP generated-images directory.",
{
prompt: z.string().min(1).max(8000).describe("Text prompt describing the image to generate."),
filename: z.string().min(1).max(200).optional().describe("Optional local filename. Directory components are ignored."),
size: z.string().max(100).optional().describe("Optional size or aspect request, such as 1024x1024, 16:9, or square."),
quality: z.string().max(100).optional().describe("Optional quality request, such as draft, standard, or high."),
style: z.string().max(500).optional().describe("Optional visual style guidance."),
referenceImage: z.string().optional().describe("Optional absolute path to a local reference image for Codex CLI --image."),
},
handled(generateCodexImage),
);
server.tool(
"gemini_image_get_config_status",
"Show Gemini image generation configuration without revealing the API key.",
{},
handled(getGeminiImageConfigStatus),
);
server.tool(
"gemini_image_generate",
"Generate one image with the Gemini API and save it to the local generated-images directory. This tool does not expose browser control.",
{
prompt: z.string().min(1).max(8000).describe("Text prompt describing the image to generate."),
filename: z.string().min(1).max(200).optional().describe("Optional local PNG filename. Directory components are ignored."),
model: z.string().min(1).max(100).default("gemini-3.1-flash-image"),
aspectRatio: z.enum(["1:1", "3:4", "4:3", "9:16", "16:9"]).optional(),
imageSize: z.enum(["1K", "2K", "4K"]).optional(),
useGoogleSearch: z.boolean().default(false).describe("Allow Gemini to use Google Search for prompts that need current real-world context."),
},
handled(generateGeminiImage),
);
server.tool(
"gemini_chrome_prompt_get_config_status",
"Show configuration for the Codex Chrome-skill Gemini image prompt builder.",
{},
handled(getGeminiChromePromptConfigStatus),
);
server.tool(
"gemini_chrome_prompt_build",
"Build a ready-to-run Codex prompt for generating one Gemini web-app image through the verified ReynaFamilyBot Chrome profile. This MCP tool does not control Chrome.",
{
prompt: z.string().min(1).max(8000).describe("Text prompt describing the image to generate in Gemini."),
filename: z.string().min(1).max(200).optional().describe("Optional local PNG filename. Directory components are ignored."),
},
handled(buildGeminiChromePrompt),
);
return server;
}