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:
@@ -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)"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -11,7 +11,19 @@ import {
|
|||||||
readContact,
|
readContact,
|
||||||
searchContacts,
|
searchContacts,
|
||||||
} from "./integrations/contacts.js";
|
} from "./integrations/contacts.js";
|
||||||
|
import {
|
||||||
|
generateCodexImage,
|
||||||
|
getCodexImageConfigStatus,
|
||||||
|
} from "./integrations/codex-image.js";
|
||||||
import { getDecoConfigStatus, getDecoStats } from "./integrations/deco.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 { createNote, listNotes, readNote } from "./integrations/notes.js";
|
||||||
import {
|
import {
|
||||||
createReminder,
|
createReminder,
|
||||||
@@ -119,14 +131,14 @@ export function createMacMiniMcpServer() {
|
|||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"reminders_list_lists",
|
"reminders_list_lists",
|
||||||
"List Reminders lists.",
|
"List Reminders lists, including account context and assignment metadata availability for shared lists.",
|
||||||
{},
|
{},
|
||||||
handled(listReminderLists),
|
handled(listReminderLists),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"reminders_list",
|
"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."),
|
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."),
|
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(
|
server.tool(
|
||||||
"deco_list_clients",
|
"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")),
|
handled(() => getDecoStats("clients")),
|
||||||
);
|
);
|
||||||
@@ -241,5 +253,64 @@ export function createMacMiniMcpServer() {
|
|||||||
handled(() => getSpeechApiStatus()),
|
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;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user