chore: archive mac mini automation baseline
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function runJxa(script, input = {}) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"/usr/bin/osascript",
|
||||
["-l", "JavaScript", "-e", script, "--", JSON.stringify(input)],
|
||||
{
|
||||
timeout: 30_000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
const text = stdout.trim();
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch (error) {
|
||||
const detail = error.stderr?.trim() || error.message;
|
||||
throw new Error(
|
||||
`macOS automation failed. Grant Automation access to the service if prompted. ${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function dateFromInput(value, fieldName) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.valueOf())) {
|
||||
throw new Error(`${fieldName} must be a valid ISO-8601 date and time.`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
export function plainTextToNoteHtml(title, body) {
|
||||
const escape = (text) =>
|
||||
text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
|
||||
const paragraphs = body
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => `<div>${escape(paragraph).replaceAll("\n", "<br>")}</div>`)
|
||||
.join("");
|
||||
|
||||
return `<h1>${escape(title)}</h1>${paragraphs}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { loadEnvFile } from "node:process";
|
||||
|
||||
try {
|
||||
loadEnvFile();
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
const port = Number.parseInt(process.env.MACMINI_MCP_PORT || "7331", 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("MACMINI_MCP_PORT must be an integer between 1 and 65535.");
|
||||
}
|
||||
|
||||
const config = {
|
||||
host: process.env.MACMINI_MCP_HOST || "127.0.0.1",
|
||||
port,
|
||||
token: process.env.MACMINI_MCP_TOKEN || "",
|
||||
};
|
||||
|
||||
const isLoopback = ["127.0.0.1", "::1", "localhost"].includes(config.host);
|
||||
if (!isLoopback && !config.token) {
|
||||
throw new Error("MACMINI_MCP_TOKEN is required when listening beyond localhost.");
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import http from "node:http";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { getConfig } from "./config.js";
|
||||
import { createMacMiniMcpServer } from "./server.js";
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
function authorized(request) {
|
||||
if (!config.token) {
|
||||
return true;
|
||||
}
|
||||
const authorization = request.headers.authorization || "";
|
||||
const expected = Buffer.from(`Bearer ${config.token}`);
|
||||
const provided = Buffer.from(authorization);
|
||||
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
||||
}
|
||||
|
||||
const service = http.createServer(async (request, response) => {
|
||||
const pathname = new URL(request.url, `http://${request.headers.host || "localhost"}`).pathname;
|
||||
|
||||
if (pathname === "/health" && request.method === "GET") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true, service: "macmini-mcp" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname !== "/mcp") {
|
||||
response.writeHead(404).end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authorized(request)) {
|
||||
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const server = createMacMiniMcpServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
|
||||
response.on("close", () => {
|
||||
transport.close();
|
||||
server.close();
|
||||
});
|
||||
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(request, response);
|
||||
} catch (error) {
|
||||
console.error("MCP HTTP request failed:", error);
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(500).end("MCP request failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
service.listen(config.port, config.host, () => {
|
||||
console.error(`macmini-mcp listening on http://${config.host}:${config.port}/mcp`);
|
||||
});
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, rm, mkdtemp } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function swiftSourceLLMPolish() {
|
||||
// From whisper-translation/apple_speech/Sources/AppleLLMPolish/main.swift
|
||||
// Added instant-reply mode + general conversation
|
||||
return `
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
|
||||
struct InMsg: Decodable {
|
||||
var id: String?
|
||||
var mode: String // "line" | "paragraph" | "check" | "quick_reply" | "chat"
|
||||
var text: String?
|
||||
var prev1: String?
|
||||
var prev2: String?
|
||||
var context: String?
|
||||
var prevSource: String?
|
||||
var language: String?
|
||||
var instructions: String?
|
||||
var history: String? // JSON array [{"role":"user","text":".."},...]
|
||||
}
|
||||
struct OutMsg: Encodable {
|
||||
var id: String?
|
||||
var ok: Bool
|
||||
var text: String
|
||||
var error: String?
|
||||
var ms: Int?
|
||||
var mode: String?
|
||||
}
|
||||
func log(_ s: String) { fputs(s+"\\n", stderr) }
|
||||
|
||||
@main
|
||||
struct AppleLLMPolish {
|
||||
static func main() async {
|
||||
let args = CommandLine.arguments
|
||||
if args.contains("--help") || args.contains("-h") {
|
||||
fputs("Usage: apple-llm-polish [--check]\\nPipe JSONL in stdin, JSONL out\\nModes: line, paragraph, quick_reply, chat, check\\n", stderr); exit(0)
|
||||
}
|
||||
if args.contains("--check") { await runCheck(); return }
|
||||
await runPipe()
|
||||
}
|
||||
static func runCheck() async {
|
||||
let m = SystemLanguageModel.default
|
||||
var pingText = "unavailable"
|
||||
var ok = false
|
||||
if m.isAvailable {
|
||||
do {
|
||||
let session = LanguageModelSession(model: m, instructions: "You are concise.")
|
||||
let r = try await session.respond(to: "Say ok")
|
||||
pingText = r.content
|
||||
ok = true
|
||||
} catch { pingText = error.localizedDescription }
|
||||
}
|
||||
let out: [String: Any] = [
|
||||
"available": m.isAvailable,
|
||||
"availability": "\\(m.availability)",
|
||||
"ping": pingText,
|
||||
"ok": ok,
|
||||
"model": "SystemLanguageModel 3B ANE"
|
||||
]
|
||||
if let d = try? JSONSerialization.data(withJSONObject: out), let s = String(data: d, encoding: .utf8) { print(s) }
|
||||
}
|
||||
static func runPipe() async {
|
||||
let m = SystemLanguageModel.default
|
||||
guard m.isAvailable else {
|
||||
let reason = "\\(m.availability)"
|
||||
while let line = readLine() {
|
||||
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
|
||||
var idv: String? = nil
|
||||
if let data = line.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String:Any] { idv = dict["id"] as? String }
|
||||
let out = OutMsg(id: idv, ok: false, text: "", error: "model unavailable: \\(reason)", ms: nil, mode: "error")
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
}
|
||||
return
|
||||
}
|
||||
// Keep sessions warm — separate useCases
|
||||
let lineSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: lineSystemPrompt())
|
||||
let paraSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: paraSystemPrompt())
|
||||
let quickSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: quickReplySystemPrompt())
|
||||
let chatSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: "You are Hermes, a concise helpful voice assistant for ESP32 devices. Keep replies under 40 words, warm and concrete, kid-safe.")
|
||||
|
||||
lineSession.prewarm()
|
||||
paraSession.prewarm()
|
||||
quickSession.prewarm()
|
||||
chatSession.prewarm()
|
||||
|
||||
log("[apple-llm] ready, ANE-backed 3B")
|
||||
|
||||
while let line = readLine() {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { continue }
|
||||
guard let data = line.data(using: .utf8), let req = try? JSONDecoder().decode(InMsg.self, from: data) else {
|
||||
let out = OutMsg(id: nil, ok: false, text: "", error: "bad json", ms: nil, mode: "error")
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
continue
|
||||
}
|
||||
if req.mode == "check" {
|
||||
let out = OutMsg(id: req.id, ok: m.isAvailable, text: "\\(m.availability)", error: nil, ms: 0, mode: "check")
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
continue
|
||||
}
|
||||
let t0 = Date()
|
||||
do {
|
||||
let (session, prompt, temp): (LanguageModelSession, String, Double)
|
||||
switch req.mode {
|
||||
case "paragraph":
|
||||
session = paraSession
|
||||
prompt = buildParagraphPrompt(context: req.context ?? "", prevSource: req.prevSource ?? "", newText: req.text ?? "")
|
||||
temp = 0.2
|
||||
case "quick_reply":
|
||||
session = quickSession
|
||||
prompt = buildQuickReplyPrompt(draft: req.text ?? "", context: req.context, instructions: req.instructions)
|
||||
temp = 0.4
|
||||
case "chat":
|
||||
// For chat, rebuild prompt from history if provided, else use text directly
|
||||
session = chatSession
|
||||
if let hist = req.history, !hist.isEmpty {
|
||||
prompt = buildChatPrompt(historyJSON: hist, newText: req.text ?? "", instructions: req.instructions)
|
||||
} else {
|
||||
prompt = req.text ?? ""
|
||||
}
|
||||
temp = 0.5
|
||||
default: // line
|
||||
session = lineSession
|
||||
prompt = buildLinePrompt(text: req.text ?? "", prev1: req.prev1 ?? "", prev2: req.prev2 ?? "")
|
||||
temp = 0.1
|
||||
}
|
||||
var opts = GenerationOptions()
|
||||
opts.temperature = temp
|
||||
let resp = try await session.respond(to: prompt, options: opts)
|
||||
let ms = Int(Date().timeIntervalSince(t0)*1000)
|
||||
let cleaned = resp.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let out = OutMsg(id: req.id, ok: true, text: cleaned.isEmpty ? (req.text ?? "") : cleaned, error: nil, ms: ms, mode: req.mode)
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
} catch {
|
||||
let ms = Int(Date().timeIntervalSince(t0)*1000)
|
||||
let out = OutMsg(id: req.id, ok: false, text: req.text ?? "", error: error.localizedDescription, ms: ms, mode: req.mode)
|
||||
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
|
||||
}
|
||||
}
|
||||
}
|
||||
static func lineSystemPrompt() -> String {
|
||||
return "You are a real-time caption polisher. Fix punctuation, casing, STT typos. Remove filler (uh, um). Keep meaning. Output one polished line only."
|
||||
}
|
||||
static func paraSystemPrompt() -> String {
|
||||
return "You are a careful live transcript editor. Goal: most faithful readable English. NEW SOURCE TEXT is primary. PREVIOUS CONTEXT only if helps continuity. English only. No meta commentary. Return revised transcript only."
|
||||
}
|
||||
static func quickReplySystemPrompt() -> String {
|
||||
return """
|
||||
You are Hermes instant-reply for ESP32 voice devices (iPhone, Watch, kids). You get LIVE draft transcript from user, possibly partial with typos.
|
||||
|
||||
Goal: produce a super-short, HIGHLY CONTEXTUAL reply preview (max 20 words) that shows you actually understood their specific request, not generic.
|
||||
|
||||
Rules:
|
||||
- Reference SPECIFIC keywords/entities from draft: names (Grace Priss/Rain), topics (Mac mini voice, weather, homework), intent.
|
||||
- Sound human, warm, playful for kids, concise.
|
||||
- If draft mentions Mac mini voice/boys voice/speech, acknowledge you'll use Mac mini voice.
|
||||
- If draft mentions a name, use it.
|
||||
- If draft asks something, hint at answer direction without fully answering (full answer comes next).
|
||||
- Never say "Thinking on full answer" verbatim — too generic. Instead vary: "Let me check...", "One sec, pulling that...", "Nice name! Love it..."
|
||||
- Under 20 words. Return ONLY reply text, no quotes.
|
||||
|
||||
Examples:
|
||||
Draft: "what's the weather today" -> "Checking weather now — one sec..."
|
||||
Draft: "Perfect. My first name is Grace Priss, and my other name is Grace Reign." -> "Wow, Grace Priss and Grace Reign — royal names! Love them!"
|
||||
Draft: "Why you're not answering with boys voice?" -> "Got it — you want boy voice, switching to Mac mini voice now..."
|
||||
Draft: "Can you use the Mac mini voice to generate answers?" -> "Yes! Using Mac mini voice for better audio, one sec..."
|
||||
Draft: "tell me a joke" -> "Joke coming up..."
|
||||
Draft: "Hey improvement I think now you should show quick response" -> "Nice! Quick response is live, working on full answer too..."
|
||||
"""
|
||||
}
|
||||
static func buildLinePrompt(text: String, prev1: String, prev2: String) -> String {
|
||||
var p = ""
|
||||
if !prev2.isEmpty { p += "Previous 2: \\(prev2)\\n" }
|
||||
if !prev1.isEmpty { p += "Previous 1: \\(prev1)\\n" }
|
||||
p += "Current: \\(text)\\nPolished:"
|
||||
return p
|
||||
}
|
||||
static func buildParagraphPrompt(context: String, prevSource: String, newText: String) -> String {
|
||||
return "Edit this transcript.\\n[PREVIOUS CONTEXT]\\n\\(context)\\n[PREVIOUS SOURCE]\\n\\(prevSource)\\n[NEW]\\n\\(newText)"
|
||||
}
|
||||
static func buildQuickReplyPrompt(draft: String, context: String?, instructions: String?) -> String {
|
||||
var p = "LIVE DRAFT from user speaking (may have typos, partial): \\\"\\(draft)\\\"\\n"
|
||||
if let c = context, !c.isEmpty { p += "Previous full transcript: \\(c)\\n" }
|
||||
if let i = instructions, !i.isEmpty { p += "Extra instructions: \\(i)\\n" }
|
||||
p += "\\nTask: produce contextual instant reply (max 20 words) that references SPECIFIC words from draft, not generic. If draft unclear, fall back to 'Heard you — working on full answer...'"
|
||||
return p
|
||||
}
|
||||
static func buildChatPrompt(historyJSON: String, newText: String, instructions: String?) -> String {
|
||||
// history is JSON array serialized
|
||||
return "Conversation history: \\(historyJSON)\\nUser says (draft/final): \\(newText)\\n\\(instructions != nil ? \"Instructions: \\(instructions!)\\n\" : \"\")Reply concisely for voice device (<40 words):"
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
class AppleLLMSession {
|
||||
constructor() {
|
||||
this.proc = null;
|
||||
this.tmpDir = null;
|
||||
this.binFile = null;
|
||||
this.ready = false;
|
||||
this.reqId = 0;
|
||||
this.pending = new Map();
|
||||
this.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
async ensureBuilt() {
|
||||
const tmpBase = path.join(os.tmpdir(), "apple-llm-");
|
||||
this.tmpDir = await mkdtemp(tmpBase);
|
||||
const swiftFile = path.join(this.tmpDir, "Main.swift");
|
||||
this.binFile = path.join(this.tmpDir, "apple-llm-polish");
|
||||
await writeFile(swiftFile, swiftSourceLLMPolish(), "utf8");
|
||||
try {
|
||||
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", this.binFile, "-framework", "Foundation", "-framework", "FoundationModels"], { timeout: 60000, maxBuffer: 20*1024*1024 });
|
||||
} catch (e) {
|
||||
throw new Error(`swiftc LLM build failed: ${e.stderr||e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.proc && this.proc.exitCode === null && this.ready) return;
|
||||
if (!this.binFile) await this.ensureBuilt();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.proc = spawn(this.binFile, [], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stderrBuf = "";
|
||||
let stdoutBuf = "";
|
||||
this.proc.stderr.on("data", d => { stderrBuf += d.toString(); });
|
||||
this.proc.stdout.on("data", d => {
|
||||
const txt = d.toString();
|
||||
stdoutBuf += txt;
|
||||
// Parse lines for responses
|
||||
let lines = stdoutBuf.split("\n");
|
||||
stdoutBuf = lines.pop() || "";
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
const id = obj.id;
|
||||
if (id && this.pending.has(id)) {
|
||||
const {resolve} = this.pending.get(id);
|
||||
this.pending.delete(id);
|
||||
resolve(obj);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.proc.exitCode !== null) {
|
||||
reject(new Error(`apple-llm exited early code=${this.proc.exitCode} stderr=${stderrBuf.slice(0,2000)}`));
|
||||
} else {
|
||||
this.ready = true;
|
||||
// Capture remaining stdout buffering setup
|
||||
this._stdoutLeftover = "";
|
||||
resolve();
|
||||
}
|
||||
}, 1500);
|
||||
this.proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure reader continues after initial start
|
||||
_ensureReader() {
|
||||
if (this._readerSetup) return;
|
||||
this._readerSetup = true;
|
||||
// Already setup in start() via stdout.on data - but need to handle leftover buffering for late responses
|
||||
let buf = "";
|
||||
if (this.proc) {
|
||||
// Additional listener for any missed
|
||||
this.proc.stdout.on("data", chunk => {
|
||||
buf += chunk.toString();
|
||||
let lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
const id = obj.id;
|
||||
if (id && this.pending.has(id)) {
|
||||
const {resolve} = this.pending.get(id);
|
||||
this.pending.delete(id);
|
||||
resolve(obj);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async call(args, timeoutMs = 10000) {
|
||||
await this.start();
|
||||
this._ensureReader();
|
||||
const id = String(this.reqId++);
|
||||
const payload = { id, ...args };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = setTimeout(() => {
|
||||
if (this.pending.has(id)) {
|
||||
this.pending.delete(id);
|
||||
resolve({ ok: false, text: args.text || "", error: `timeout ${timeoutMs}ms`, id, ms: timeoutMs });
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: (obj) => {
|
||||
clearTimeout(timer);
|
||||
resolve(obj);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
this.proc.stdin.write(JSON.stringify(payload) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(id);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
try {
|
||||
if (this.proc) {
|
||||
try { this.proc.stdin.end(); } catch {}
|
||||
await new Promise(r => { this.proc.on("close", r); setTimeout(r, 1500); });
|
||||
try { this.proc.kill(); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
try { if (this.tmpDir) await rm(this.tmpDir, {recursive:true, force:true}); } catch {}
|
||||
this.proc = null;
|
||||
this.ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton global session for all LLM calls (fast, keeps KV cache warm)
|
||||
let globalSession = null;
|
||||
|
||||
async function getSession() {
|
||||
if (!globalSession) {
|
||||
globalSession = new AppleLLMSession();
|
||||
await globalSession.start();
|
||||
}
|
||||
globalSession.lastActivity = Date.now();
|
||||
return globalSession;
|
||||
}
|
||||
|
||||
export async function appleLLMCheck() {
|
||||
const s = await getSession();
|
||||
const res = await s.call({ mode: "check", text: "check" }, 10000);
|
||||
return {
|
||||
ok: res.ok,
|
||||
available: res.ok,
|
||||
text: res.text || res.error || "",
|
||||
ms: res.ms,
|
||||
engine: "Apple FoundationModels 3B ANE"
|
||||
};
|
||||
}
|
||||
|
||||
export async function appleLLMPolish({ text, prev1, prev2, mode } = {}) {
|
||||
if (!text) throw new Error("text required");
|
||||
const s = await getSession();
|
||||
const m = mode || "line";
|
||||
const res = await s.call({ mode: m, text, prev1: prev1||"", prev2: prev2||"" }, 8000);
|
||||
return {
|
||||
ok: res.ok,
|
||||
text: res.text || text,
|
||||
original: text,
|
||||
ms: res.ms,
|
||||
mode: m,
|
||||
error: res.error || undefined
|
||||
};
|
||||
}
|
||||
|
||||
export async function appleLLMQuickReply({ draft, context, instructions } = {}) {
|
||||
if (!draft) throw new Error("draft required");
|
||||
const s = await getSession();
|
||||
const res = await s.call({ mode: "quick_reply", text: draft, context: context||"", instructions: instructions||"" }, 3000);
|
||||
return {
|
||||
ok: res.ok,
|
||||
text: res.text || "Got it, working on it...",
|
||||
draft,
|
||||
ms: res.ms,
|
||||
engine: "Apple FoundationModels 3B instant"
|
||||
};
|
||||
}
|
||||
|
||||
export async function appleLLMChat({ text, history, instructions } = {}) {
|
||||
if (!text) throw new Error("text required");
|
||||
const s = await getSession();
|
||||
const histStr = history ? JSON.stringify(history) : "";
|
||||
const res = await s.call({ mode: "chat", text, history: histStr, instructions: instructions||"" }, 5000);
|
||||
return {
|
||||
ok: res.ok,
|
||||
text: res.text || "",
|
||||
input: text,
|
||||
ms: res.ms,
|
||||
engine: "Apple FoundationModels 3B voice"
|
||||
};
|
||||
}
|
||||
|
||||
export async function appleLLMClose() {
|
||||
if (globalSession) {
|
||||
await globalSession.close();
|
||||
globalSession = null;
|
||||
}
|
||||
return { ok: true, closed: true };
|
||||
}
|
||||
|
||||
export async function appleLLMStatus() {
|
||||
return {
|
||||
active: !!globalSession,
|
||||
ready: globalSession?.ready || false,
|
||||
lastActivity: globalSession ? new Date(globalSession.lastActivity).toISOString() : null,
|
||||
pid: globalSession?.proc?.pid || null
|
||||
};
|
||||
}
|
||||
|
||||
// Idle cleanup 2 min
|
||||
setInterval(async () => {
|
||||
if (globalSession && Date.now() - globalSession.lastActivity > 120000) {
|
||||
try { await globalSession.close(); } catch {}
|
||||
globalSession = null;
|
||||
}
|
||||
}, 20000);
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { dateFromInput, runJxa } from "../apple-events.js";
|
||||
|
||||
export const FOCUS_CALENDAR = Object.freeze({
|
||||
calendarIndex: 2,
|
||||
calendar: "Home",
|
||||
});
|
||||
|
||||
const CALENDAR_LIST_SCRIPT = String.raw`
|
||||
function run() {
|
||||
const app = Application("/System/Applications/Calendar.app");
|
||||
return JSON.stringify(app.calendars().map(function (calendar, index) {
|
||||
return {
|
||||
index: index,
|
||||
name: String(calendar.name()),
|
||||
writable: Boolean(calendar.writable())
|
||||
};
|
||||
}));
|
||||
}`;
|
||||
|
||||
const EVENTS_LIST_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Calendar.app");
|
||||
const start = new Date(input.start);
|
||||
const end = new Date(input.end);
|
||||
const calendars = app.calendars();
|
||||
const found = [];
|
||||
|
||||
for (let c = 0; c < calendars.length; c++) {
|
||||
const calendar = calendars[c];
|
||||
const calendarName = String(calendar.name());
|
||||
if (input.calendarIndex !== null && c !== input.calendarIndex) continue;
|
||||
if (input.calendar && calendarName !== input.calendar) continue;
|
||||
// Calendar's JXA bridge treats multi-property date tests inconsistently.
|
||||
// Bound one indexed property here, then enforce overlap below.
|
||||
const events = calendar.events.whose({
|
||||
startDate: {_greaterThanEquals: start, _lessThanEquals: end}
|
||||
})();
|
||||
for (let e = 0; e < events.length; e++) {
|
||||
const event = events[e];
|
||||
const eventStart = event.startDate();
|
||||
const eventEnd = event.endDate();
|
||||
if (eventEnd < start || eventStart > end) continue;
|
||||
found.push({
|
||||
id: String(event.uid()),
|
||||
calendarIndex: c,
|
||||
calendar: calendarName,
|
||||
title: String(event.summary()),
|
||||
start: eventStart.toISOString(),
|
||||
end: eventEnd.toISOString(),
|
||||
allDay: Boolean(event.alldayEvent()),
|
||||
location: String(event.location() || "")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
found.sort(function (a, b) { return a.start.localeCompare(b.start); });
|
||||
return JSON.stringify(found.slice(0, input.limit));
|
||||
}`;
|
||||
|
||||
const EVENT_CREATE_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Calendar.app");
|
||||
const calendars = app.calendars();
|
||||
let destination = null;
|
||||
let destinationIndex = null;
|
||||
for (let c = 0; c < calendars.length; c++) {
|
||||
const indexMatches = input.calendarIndex !== null && c === input.calendarIndex &&
|
||||
(!input.calendar || String(calendars[c].name()) === input.calendar);
|
||||
const nameMatches = input.calendarIndex === null && String(calendars[c].name()) === input.calendar;
|
||||
if (indexMatches || nameMatches) {
|
||||
destination = calendars[c];
|
||||
destinationIndex = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!destination) throw new Error("Calendar not found");
|
||||
if (!destination.writable()) throw new Error("Calendar is read-only");
|
||||
const event = app.Event({
|
||||
summary: input.title,
|
||||
startDate: new Date(input.start),
|
||||
endDate: new Date(input.end),
|
||||
alldayEvent: input.allDay,
|
||||
description: input.notes || "",
|
||||
location: input.location || ""
|
||||
});
|
||||
destination.events.push(event);
|
||||
return JSON.stringify({
|
||||
id: String(event.uid()),
|
||||
calendarIndex: destinationIndex,
|
||||
calendar: String(destination.name()),
|
||||
title: String(event.summary()),
|
||||
start: event.startDate().toISOString(),
|
||||
end: event.endDate().toISOString()
|
||||
});
|
||||
}`;
|
||||
|
||||
export function listCalendars() {
|
||||
return runJxa(CALENDAR_LIST_SCRIPT);
|
||||
}
|
||||
|
||||
export function listEvents({ start, end, calendar, calendarIndex, limit }) {
|
||||
dateFromInput(start, "start");
|
||||
dateFromInput(end, "end");
|
||||
if (new Date(start) > new Date(end)) {
|
||||
throw new Error("start must occur before end.");
|
||||
}
|
||||
return runJxa(EVENTS_LIST_SCRIPT, {
|
||||
start,
|
||||
end,
|
||||
calendar,
|
||||
calendarIndex: calendarIndex ?? null,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEvent(input) {
|
||||
const start = dateFromInput(input.start, "start");
|
||||
const end = dateFromInput(input.end, "end");
|
||||
if (start >= end) {
|
||||
throw new Error("start must occur before end.");
|
||||
}
|
||||
return runJxa(EVENT_CREATE_SCRIPT, input);
|
||||
}
|
||||
+175
@@ -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)"}`);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { runJxa } from "../apple-events.js";
|
||||
|
||||
const CONTACTS_SEARCH_SCRIPT = String.raw`
|
||||
function text(value) {
|
||||
try { return value ? String(value) : ""; } catch (_) { return ""; }
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Contacts.app");
|
||||
const query = (input.query || "").toLowerCase();
|
||||
const people = app.people();
|
||||
const found = [];
|
||||
|
||||
for (let i = 0; i < people.length && found.length < input.limit; i++) {
|
||||
const person = people[i];
|
||||
const name = text(person.name());
|
||||
const organization = text(person.organization());
|
||||
if (query && (name + "\n" + organization).toLowerCase().indexOf(query) === -1) continue;
|
||||
found.push({
|
||||
id: String(person.id()),
|
||||
name: name,
|
||||
organization: organization,
|
||||
modifiedAt: person.modificationDate().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const CONTACTS_READ_SCRIPT = String.raw`
|
||||
function text(value) {
|
||||
try { return value ? String(value) : ""; } catch (_) { return ""; }
|
||||
}
|
||||
|
||||
function items(values) {
|
||||
return values.map(function (value) {
|
||||
return { label: text(value.label()), value: text(value.value()) };
|
||||
});
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Contacts.app");
|
||||
const people = app.people();
|
||||
for (let i = 0; i < people.length; i++) {
|
||||
const person = people[i];
|
||||
if (String(person.id()) !== input.id) continue;
|
||||
return JSON.stringify({
|
||||
id: String(person.id()),
|
||||
name: text(person.name()),
|
||||
firstName: text(person.firstName()),
|
||||
lastName: text(person.lastName()),
|
||||
organization: text(person.organization()),
|
||||
jobTitle: text(person.jobTitle()),
|
||||
emails: items(person.emails()),
|
||||
phones: items(person.phones()),
|
||||
modifiedAt: person.modificationDate().toISOString()
|
||||
});
|
||||
}
|
||||
throw new Error("Contact not found");
|
||||
}`;
|
||||
|
||||
const CONTACTS_CREATE_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Contacts.app");
|
||||
const person = app.Person({
|
||||
firstName: input.firstName || "",
|
||||
lastName: input.lastName || "",
|
||||
organization: input.organization || "",
|
||||
jobTitle: input.jobTitle || "",
|
||||
note: input.note || ""
|
||||
});
|
||||
|
||||
app.people.push(person);
|
||||
if (input.email) {
|
||||
person.emails.push(app.Email({label: input.email.label, value: input.email.value}));
|
||||
}
|
||||
if (input.phone) {
|
||||
person.phones.push(app.Phone({label: input.phone.label, value: input.phone.value}));
|
||||
}
|
||||
app.save();
|
||||
|
||||
return JSON.stringify({
|
||||
id: String(person.id()),
|
||||
name: String(person.name()),
|
||||
organization: input.organization || ""
|
||||
});
|
||||
}`;
|
||||
|
||||
export function searchContacts(input) {
|
||||
return runJxa(CONTACTS_SEARCH_SCRIPT, input);
|
||||
}
|
||||
|
||||
export function readContact(id) {
|
||||
return runJxa(CONTACTS_READ_SCRIPT, { id });
|
||||
}
|
||||
|
||||
export function createContact(input) {
|
||||
return runJxa(CONTACTS_CREATE_SCRIPT, input);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { loadEnvFile } from "node:process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
try {
|
||||
loadEnvFile();
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const PYTHON = new URL("../../.venv/bin/python", import.meta.url).pathname;
|
||||
const BRIDGE = new URL("../../scripts/deco_bridge.py", import.meta.url).pathname;
|
||||
const HA_CLIENTS_BRIDGE = new URL("../../scripts/deco_ha_bridge.py", import.meta.url).pathname;
|
||||
|
||||
export async function getDecoStats(action) {
|
||||
try {
|
||||
const args = action === "clients" ? [HA_CLIENTS_BRIDGE] : [BRIDGE, action];
|
||||
const { stdout } = await execFileAsync(PYTHON, args, {
|
||||
timeout: 90_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
env: process.env,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
} catch (error) {
|
||||
const detail = error.stderr?.trim() || error.message;
|
||||
throw new Error(`Deco stats request failed. ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDecoConfigStatus() {
|
||||
return {
|
||||
host: process.env.DECO_HOST || "(default gateway)",
|
||||
username: process.env.DECO_USERNAME || "admin",
|
||||
passwordConfigured: Boolean(process.env.DECO_PASSWORD),
|
||||
passwordLength: process.env.DECO_PASSWORD?.length || 0,
|
||||
verifySsl: process.env.DECO_VERIFY_SSL ?? "true",
|
||||
timeout: process.env.DECO_TIMEOUT || "10",
|
||||
};
|
||||
}
|
||||
+67
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
|
||||
const DEFAULT_KSAY_URL = process.env.KSAY_URL || "http://127.0.0.1:7332";
|
||||
|
||||
function cleanBaseUrl(url) {
|
||||
return String(url || DEFAULT_KSAY_URL).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function requestJson(path, { method = "GET", body } = {}) {
|
||||
const url = `${cleanBaseUrl()}${path}`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { "content-type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Kokoro ksay daemon is not reachable at ${cleanBaseUrl()}: ${error.message}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let value;
|
||||
try {
|
||||
value = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
throw new Error(`Kokoro ksay daemon returned non-JSON response: ${text.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
if (!response.ok || value.ok === false) {
|
||||
throw new Error(value.error || `Kokoro ksay daemon returned HTTP ${response.status}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSpeed(speed) {
|
||||
if (speed === undefined || speed === null) return 1.0;
|
||||
const n = Number(speed);
|
||||
if (!Number.isFinite(n) || n < 0.5 || n > 2.0) {
|
||||
throw new Error("speed must be a number between 0.5 and 2.0.");
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeVoice(voice) {
|
||||
return String(voice || process.env.KSAY_VOICE || "af_heart").trim().slice(0, 100);
|
||||
}
|
||||
|
||||
function normalizeLangCode(langCode) {
|
||||
return String(langCode || process.env.KSAY_LANG_CODE || "a").trim().slice(0, 8);
|
||||
}
|
||||
|
||||
export async function speechKokoroStatus() {
|
||||
return requestJson("/health");
|
||||
}
|
||||
|
||||
export async function speechKokoroSynthesize({ text, voice, speed, langCode, outputPath }) {
|
||||
if (!text || !String(text).trim()) throw new Error("text required");
|
||||
const cleanText = String(text).slice(0, 8000);
|
||||
const result = await requestJson("/say", {
|
||||
method: "POST",
|
||||
body: {
|
||||
text: cleanText,
|
||||
voice: normalizeVoice(voice),
|
||||
speed: normalizeSpeed(speed),
|
||||
langCode: normalizeLangCode(langCode),
|
||||
output: outputPath || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
text: cleanText,
|
||||
format: "wav 24kHz mono",
|
||||
fileSize: await stat(result.filePath).then((s) => s.size).catch(() => 0),
|
||||
note: "Uses the warm ksay Kokoro daemon. Use speech_kokoro_synthesize_base64 when the caller needs audio bytes.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function speechKokoroSynthesizeBase64({ text, voice, speed, langCode }) {
|
||||
const result = await speechKokoroSynthesize({ text, voice, speed, langCode });
|
||||
const buf = await readFile(result.filePath);
|
||||
const wavBase64 = buf.toString("base64");
|
||||
return {
|
||||
ok: true,
|
||||
text: result.text,
|
||||
voice: result.voice,
|
||||
speed: result.speed,
|
||||
langCode: result.langCode,
|
||||
model: result.model,
|
||||
filePath: result.filePath,
|
||||
wavBase64,
|
||||
size: buf.length,
|
||||
base64Length: wavBase64.length,
|
||||
sampleRate: result.sampleRate,
|
||||
seconds: result.seconds,
|
||||
format: "wav 24kHz mono",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { runJxa } from "../apple-events.js";
|
||||
|
||||
const MAX_MESSAGES = 50;
|
||||
|
||||
const ACCOUNTS_SCRIPT = String.raw`
|
||||
function stringList(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function run() {
|
||||
const app = Application("/System/Applications/Mail.app");
|
||||
return JSON.stringify(app.accounts().map(function (account) {
|
||||
let addresses = [];
|
||||
try { addresses = stringList(account.emailAddresses()); } catch (_) {}
|
||||
return { id: String(account.id()), name: String(account.name()), emailAddresses: addresses };
|
||||
}));
|
||||
}`;
|
||||
|
||||
const MAILBOXES_SCRIPT = String.raw`
|
||||
function addMailbox(found, role, mailbox) {
|
||||
try { found.push({ role: role, name: String(mailbox.name()) }); } catch (_) {}
|
||||
}
|
||||
|
||||
function run() {
|
||||
const app = Application("/System/Applications/Mail.app");
|
||||
const found = [];
|
||||
addMailbox(found, "inbox", app.inbox());
|
||||
addMailbox(found, "sent", app.sentMailbox());
|
||||
addMailbox(found, "drafts", app.draftsMailbox());
|
||||
addMailbox(found, "junk", app.junkMailbox());
|
||||
addMailbox(found, "trash", app.trashMailbox());
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const LIST_MESSAGES_SCRIPT = String.raw`
|
||||
function isoDate(value) {
|
||||
try { return value ? value.toISOString() : null; } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function globalMailbox(app, requestedName) {
|
||||
const candidates = [app.inbox(), app.sentMailbox(), app.draftsMailbox(), app.junkMailbox(), app.trashMailbox()];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
try { if (String(candidates[i].name()) === requestedName) return candidates[i]; } catch (_) {}
|
||||
}
|
||||
throw new Error("Mailbox not found; use mail_list_mailboxes first");
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Mail.app");
|
||||
const mailbox = globalMailbox(app, input.mailbox);
|
||||
const messages = mailbox.messages();
|
||||
const found = [];
|
||||
for (let i = 0; i < messages.length && found.length < input.limit; i++) {
|
||||
const message = messages[i];
|
||||
let account = null;
|
||||
try { account = message.mailbox().account(); } catch (_) { continue; }
|
||||
if (String(account.id()) !== input.accountId) continue;
|
||||
const read = Boolean(message.readStatus());
|
||||
if (input.unreadOnly && read) continue;
|
||||
found.push({
|
||||
id: String(message.id()),
|
||||
accountId: String(account.id()),
|
||||
account: String(account.name()),
|
||||
mailbox: String(mailbox.name()),
|
||||
subject: String(message.subject() || ""),
|
||||
sender: String(message.sender() || ""),
|
||||
dateSent: isoDate(message.dateSent()),
|
||||
read: read
|
||||
});
|
||||
}
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const READ_MESSAGE_SCRIPT = String.raw`
|
||||
function isoDate(value) {
|
||||
try { return value ? value.toISOString() : null; } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function globalMailbox(app, requestedName) {
|
||||
const candidates = [app.inbox(), app.sentMailbox(), app.draftsMailbox(), app.junkMailbox(), app.trashMailbox()];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
try { if (String(candidates[i].name()) === requestedName) return candidates[i]; } catch (_) {}
|
||||
}
|
||||
throw new Error("Mailbox not found; use mail_list_mailboxes first");
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Mail.app");
|
||||
const mailbox = globalMailbox(app, input.mailbox);
|
||||
let message;
|
||||
try { message = mailbox.messages.byId(Number(input.id)); } catch (_) { throw new Error("Message not found in the selected mailbox"); }
|
||||
let account;
|
||||
try { account = message.mailbox().account(); } catch (_) { throw new Error("Message not found in the selected mailbox"); }
|
||||
if (String(account.id()) !== input.accountId) throw new Error("Message does not belong to the selected account");
|
||||
return JSON.stringify({
|
||||
id: String(message.id()),
|
||||
accountId: String(account.id()),
|
||||
account: String(account.name()),
|
||||
mailbox: String(mailbox.name()),
|
||||
subject: String(message.subject() || ""),
|
||||
sender: String(message.sender() || ""),
|
||||
dateSent: isoDate(message.dateSent()),
|
||||
read: Boolean(message.readStatus()),
|
||||
body: String(message.content() || "")
|
||||
});
|
||||
}`;
|
||||
|
||||
function requireAccountId(value) {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error("accountId is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireMailbox(value) {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error("mailbox is required");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createMailClient(execute = runJxa) {
|
||||
return {
|
||||
accounts() {
|
||||
return execute(ACCOUNTS_SCRIPT, {});
|
||||
},
|
||||
mailboxes({ accountId }) {
|
||||
requireAccountId(accountId);
|
||||
return execute(MAILBOXES_SCRIPT, {});
|
||||
},
|
||||
listMessages({ accountId, mailbox, limit = 10, unreadOnly = false }) {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_MESSAGES) throw new Error(`limit must be between 1 and ${MAX_MESSAGES}`);
|
||||
return execute(LIST_MESSAGES_SCRIPT, { accountId: requireAccountId(accountId), mailbox: requireMailbox(mailbox), limit, unreadOnly: Boolean(unreadOnly) });
|
||||
},
|
||||
readMessage({ accountId, mailbox, id }) {
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("id is required");
|
||||
return execute(READ_MESSAGE_SCRIPT, { accountId: requireAccountId(accountId), mailbox: requireMailbox(mailbox), id });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mail = createMailClient();
|
||||
export const listMailAccounts = () => mail.accounts();
|
||||
export const listMailboxes = (input) => mail.mailboxes(input);
|
||||
export const listMailMessages = (input) => mail.listMessages(input);
|
||||
export const readMailMessage = (input) => mail.readMessage(input);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { plainTextToNoteHtml, runJxa } from "../apple-events.js";
|
||||
|
||||
const NOTES_LIST_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const query = (input.query || "").toLowerCase();
|
||||
const folderName = input.folder || "";
|
||||
const limit = input.limit;
|
||||
const found = [];
|
||||
const accounts = app.accounts();
|
||||
|
||||
for (let a = 0; a < accounts.length && found.length < limit; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length && found.length < limit; f++) {
|
||||
const folder = folders[f];
|
||||
const currentFolder = String(folder.name());
|
||||
if (folderName && currentFolder !== folderName) continue;
|
||||
const notes = folder.notes();
|
||||
for (let n = 0; n < notes.length && found.length < limit; n++) {
|
||||
const note = notes[n];
|
||||
let title = "";
|
||||
let text = "";
|
||||
try { title = String(note.name()); } catch (_) {}
|
||||
try { text = String(note.plaintext()); } catch (_) {}
|
||||
if (query && (title + "\n" + text).toLowerCase().indexOf(query) === -1) continue;
|
||||
found.push({
|
||||
id: String(note.id()),
|
||||
title: title,
|
||||
folder: currentFolder,
|
||||
modifiedAt: note.modificationDate().toISOString(),
|
||||
preview: input.includePreview ? text.slice(0, 180) : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const NOTES_READ_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const accounts = app.accounts();
|
||||
for (let a = 0; a < accounts.length; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length; f++) {
|
||||
const notes = folders[f].notes();
|
||||
for (let n = 0; n < notes.length; n++) {
|
||||
const note = notes[n];
|
||||
if (String(note.id()) === input.id) {
|
||||
return JSON.stringify({
|
||||
id: String(note.id()),
|
||||
title: String(note.name()),
|
||||
folder: String(folders[f].name()),
|
||||
bodyHtml: String(note.body()),
|
||||
plaintext: String(note.plaintext()),
|
||||
createdAt: note.creationDate().toISOString(),
|
||||
modifiedAt: note.modificationDate().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Note not found");
|
||||
}`;
|
||||
|
||||
const NOTES_CREATE_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Notes.app");
|
||||
const accounts = app.accounts();
|
||||
let destination = null;
|
||||
|
||||
for (let a = 0; a < accounts.length && !destination; a++) {
|
||||
const folders = accounts[a].folders();
|
||||
for (let f = 0; f < folders.length; f++) {
|
||||
if (!input.folder || String(folders[f].name()) === input.folder) {
|
||||
destination = folders[f];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!destination) throw new Error("Notes destination folder not found");
|
||||
|
||||
const note = app.Note({body: input.html});
|
||||
destination.notes.push(note);
|
||||
return JSON.stringify({
|
||||
id: String(note.id()),
|
||||
title: String(note.name()),
|
||||
folder: String(destination.name())
|
||||
});
|
||||
}`;
|
||||
|
||||
export function listNotes(input) {
|
||||
return runJxa(NOTES_LIST_SCRIPT, input);
|
||||
}
|
||||
|
||||
export function readNote(id) {
|
||||
return runJxa(NOTES_READ_SCRIPT, { id });
|
||||
}
|
||||
|
||||
export function createNote({ title, body, folder }) {
|
||||
return runJxa(NOTES_CREATE_SCRIPT, {
|
||||
folder,
|
||||
html: plainTextToNoteHtml(title, body),
|
||||
});
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { dateFromInput, runJxa } from "../apple-events.js";
|
||||
|
||||
const LISTS_SCRIPT = String.raw`
|
||||
function run() {
|
||||
const app = Application("/System/Applications/Reminders.app");
|
||||
function stringValue(value) {
|
||||
try {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function accountName(list) {
|
||||
try {
|
||||
const container = list.container();
|
||||
return stringValue(container.name ? container.name() : container);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(app.lists().map(function (list) {
|
||||
return {
|
||||
id: String(list.id()),
|
||||
name: String(list.name()),
|
||||
account: accountName(list),
|
||||
shared: null,
|
||||
assignmentMetadata: {
|
||||
available: null,
|
||||
note: "Apple Reminders automation does not expose shared-list participant metadata directly."
|
||||
}
|
||||
};
|
||||
}));
|
||||
}`;
|
||||
|
||||
const REMINDERS_LIST_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Reminders.app");
|
||||
const lists = app.lists();
|
||||
const found = [];
|
||||
function stringValue(value) {
|
||||
try {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function compact(value) {
|
||||
if (!value) return null;
|
||||
const text = String(value).trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
function propertyValue(object, names) {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
try {
|
||||
const getter = object[names[i]];
|
||||
if (typeof getter !== "function") continue;
|
||||
const value = getter.call(object);
|
||||
const text = compact(stringValue(value));
|
||||
if (text && !text.startsWith("[object ")) return text;
|
||||
if (value && typeof value.name === "function") {
|
||||
const name = compact(stringValue(value.name()));
|
||||
if (name) return name;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function assignmentHint(title, notes) {
|
||||
const titleMatch = title.match(/\(([^()\n]{2,80})\)\s*$/);
|
||||
if (titleMatch) return { assignee: titleMatch[1].trim(), source: "title" };
|
||||
|
||||
const explicit = notes.match(/\b(?:assigned to|assignee)\s*:\s*([^.;,\n]{2,80})/i);
|
||||
if (explicit) return { assignee: explicit[1].trim(), source: "notes" };
|
||||
|
||||
const captured = notes.match(/\bCaptured\s+\d{4}-\d{2}-\d{2},\s*([^.;,\n]{2,80})\s*[.;]/i);
|
||||
if (captured) return { assignee: captured[1].trim(), source: "notes" };
|
||||
|
||||
return { assignee: null, source: null };
|
||||
}
|
||||
function assignmentFor(reminder, title, notes) {
|
||||
const nativeAssignee = propertyValue(reminder, [
|
||||
"assignedTo",
|
||||
"assignee",
|
||||
"assignment",
|
||||
"responsiblePerson",
|
||||
"principal"
|
||||
]);
|
||||
if (nativeAssignee) {
|
||||
return {
|
||||
assignee: nativeAssignee,
|
||||
source: "remindersAutomation",
|
||||
available: true
|
||||
};
|
||||
}
|
||||
|
||||
const hint = assignmentHint(title, notes);
|
||||
return {
|
||||
assignee: hint.assignee,
|
||||
source: hint.source,
|
||||
available: hint.assignee !== null
|
||||
};
|
||||
}
|
||||
for (let l = 0; l < lists.length && found.length < input.limit; l++) {
|
||||
const list = lists[l];
|
||||
const name = String(list.name());
|
||||
if (input.list && name !== input.list) continue;
|
||||
const reminders = list.reminders();
|
||||
for (let r = 0; r < reminders.length && found.length < input.limit; r++) {
|
||||
const reminder = reminders[r];
|
||||
const completed = Boolean(reminder.completed());
|
||||
if (input.completed !== null && completed !== input.completed) continue;
|
||||
let due = null;
|
||||
try {
|
||||
const value = reminder.dueDate();
|
||||
due = value ? value.toISOString() : null;
|
||||
} catch (_) {}
|
||||
const title = String(reminder.name());
|
||||
const notes = String(reminder.body() || "");
|
||||
found.push({
|
||||
id: String(reminder.id()),
|
||||
list: name,
|
||||
title: title,
|
||||
completed: completed,
|
||||
due: due,
|
||||
notes: notes,
|
||||
assignment: assignmentFor(reminder, title, notes)
|
||||
});
|
||||
}
|
||||
}
|
||||
return JSON.stringify(found);
|
||||
}`;
|
||||
|
||||
const REMINDER_CREATE_SCRIPT = String.raw`
|
||||
function run(argv) {
|
||||
const input = JSON.parse(argv[0]);
|
||||
const app = Application("/System/Applications/Reminders.app");
|
||||
const lists = app.lists();
|
||||
let destination = null;
|
||||
for (let l = 0; l < lists.length; l++) {
|
||||
if (!input.list || String(lists[l].name()) === input.list) {
|
||||
destination = lists[l];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!destination) throw new Error("Reminders list not found");
|
||||
const properties = {name: input.title, body: input.notes || ""};
|
||||
if (input.due) properties.dueDate = new Date(input.due);
|
||||
const reminder = app.Reminder(properties);
|
||||
destination.reminders.push(reminder);
|
||||
return JSON.stringify({
|
||||
id: String(reminder.id()),
|
||||
list: String(destination.name()),
|
||||
title: String(reminder.name())
|
||||
});
|
||||
}`;
|
||||
|
||||
export function listReminderLists() {
|
||||
return runJxa(LISTS_SCRIPT);
|
||||
}
|
||||
|
||||
export function listReminders(input) {
|
||||
return runJxa(REMINDERS_LIST_SCRIPT, input);
|
||||
}
|
||||
|
||||
export function createReminder(input) {
|
||||
if (input.due) {
|
||||
dateFromInput(input.due, "due");
|
||||
}
|
||||
return runJxa(REMINDER_CREATE_SCRIPT, input);
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, readFile, stat, rm, mkdir, mkdtemp } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function swiftSourcePipeTranscriber() {
|
||||
return `
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
func parseArgs() -> (String, Bool) {
|
||||
var localeId = "en-US"
|
||||
var verbose = false
|
||||
var i = 1
|
||||
let raw = CommandLine.arguments
|
||||
while i < raw.count {
|
||||
let a = raw[i]
|
||||
if a == "--locale", i+1 < raw.count { localeId = raw[i+1]; i+=1 }
|
||||
else if a == "-v" || a == "--verbose" { verbose = true }
|
||||
i+=1
|
||||
}
|
||||
return (localeId, verbose)
|
||||
}
|
||||
func logv(_ msg: String, verbose: Bool) { if verbose { fputs("[apple-pipe] \\(msg)\\n", stderr) } }
|
||||
|
||||
@main
|
||||
struct ApplePipeCLI {
|
||||
static func main() async {
|
||||
let (localeId, verbose) = parseArgs()
|
||||
guard SpeechTranscriber.isAvailable else { fputs("Not available\\n", stderr); exit(1) }
|
||||
let reqLocale = Locale(identifier: localeId)
|
||||
let locale: Locale
|
||||
if let sup = await SpeechTranscriber.supportedLocale(equivalentTo: reqLocale) { locale = sup }
|
||||
else { locale = reqLocale }
|
||||
// warm asset check
|
||||
let warm = SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [])
|
||||
let status = await AssetInventory.status(forModules: [warm])
|
||||
switch status {
|
||||
case .installed: logv("Assets installed", verbose: verbose)
|
||||
case .unsupported: fputs("Locale unsupported\\n", stderr); exit(2)
|
||||
case .supported:
|
||||
logv("Downloading assets...", verbose: true)
|
||||
do { if let req = try await AssetInventory.assetInstallationRequest(supporting: [warm]) { try await req.downloadAndInstall() } }
|
||||
catch { fputs("Asset download failed: \\(error)\\n", stderr); exit(3) }
|
||||
case .downloading:
|
||||
logv("Waiting assets...", verbose: true)
|
||||
for _ in 0..<30 { try? await Task.sleep(nanoseconds: 1_000_000_000); if await AssetInventory.status(forModules: [warm]) == .installed { break } }
|
||||
@unknown default: break
|
||||
}
|
||||
logv("Pipe ready locale=\\(locale.identifier)", verbose: true)
|
||||
let stdinH = FileHandle.standardInput
|
||||
var leftover = Data()
|
||||
var chunkIdx = 0
|
||||
func readExact(_ n: Int) -> Data? {
|
||||
var out = Data(); out.reserveCapacity(n)
|
||||
if leftover.count >= n { let d = leftover.prefix(n); leftover = leftover.dropFirst(n); return Data(d) }
|
||||
if leftover.count > 0 { out.append(leftover); leftover = Data() }
|
||||
while out.count < n {
|
||||
let d = stdinH.readData(ofLength: n - out.count)
|
||||
if d.isEmpty { if out.count==0 { return nil }; return nil }
|
||||
out.append(d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
while true {
|
||||
guard let lenData = readExact(4) else { break }
|
||||
let length = lenData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
|
||||
if length == 0 { logv("Pipe EOF", verbose: true); break }
|
||||
if length > 20_000_000 { fputs("Chunk too large \\(length)\\n", stderr); break }
|
||||
guard let chunkData = readExact(Int(length)) else { fputs("Truncated expected \\(length)\\n", stderr); break }
|
||||
chunkIdx += 1
|
||||
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("apple-pipe-\\(ProcessInfo.processInfo.processIdentifier)-\\(chunkIdx).wav")
|
||||
do { try chunkData.write(to: tmpURL) } catch { fputs("Write err: \\(error)\\n", stderr); continue }
|
||||
guard let audioFile = try? AVAudioFile(forReading: tmpURL) else { try? FileManager.default.removeItem(at: tmpURL); fputs("Open fail \\(chunkIdx)\\n", stderr); continue }
|
||||
let t = SpeechTranscriber(locale: locale, transcriptionOptions: [], reportingOptions: [.volatileResults], attributeOptions: [.audioTimeRange])
|
||||
guard let analyzer = try? await SpeechAnalyzer(inputAudioFile: audioFile, modules: [t], finishAfterFile: true) else { try? FileManager.default.removeItem(at: tmpURL); continue }
|
||||
do {
|
||||
for try await res in t.results {
|
||||
let txt = String(res.text.characters).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if txt.isEmpty { continue }
|
||||
let d: [String: Any] = [
|
||||
"event": res.isFinal ? "final" : "draft",
|
||||
"text": txt, "isFinal": res.isFinal,
|
||||
"chunk": chunkIdx, "start": res.range.start.seconds,
|
||||
"duration": res.range.duration.seconds
|
||||
]
|
||||
if let jd = try? JSONSerialization.data(withJSONObject: d), let s = String(data: jd, encoding: .utf8) {
|
||||
print(s); fflush(stdout)
|
||||
}
|
||||
}
|
||||
} catch { fputs("Results err \\(chunkIdx): \\(error)\\n", stderr) }
|
||||
_ = analyzer
|
||||
try? FileManager.default.removeItem(at: tmpURL)
|
||||
}
|
||||
logv("Pipe done \\(chunkIdx)", verbose: true)
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
class ApplePipeSession {
|
||||
constructor(locale) {
|
||||
this.locale = locale;
|
||||
this.proc = null;
|
||||
this.tmpDir = null;
|
||||
this.binFile = null;
|
||||
this.ready = false;
|
||||
this.chunkIdx = 0;
|
||||
this.lastActivity = Date.now();
|
||||
this._lineCallback = null;
|
||||
}
|
||||
async ensureBuilt() {
|
||||
const tmpBase = path.join(os.tmpdir(), "speech-live-");
|
||||
this.tmpDir = await mkdtemp(tmpBase);
|
||||
const swiftFile = path.join(this.tmpDir, "Main.swift");
|
||||
this.binFile = path.join(this.tmpDir, "apple-pipe-transcribe");
|
||||
await writeFile(swiftFile, swiftSourcePipeTranscriber(), "utf8");
|
||||
try {
|
||||
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", this.binFile, "-framework", "AVFoundation", "-framework", "Speech"], { timeout: 60000, maxBuffer: 20*1024*1024 });
|
||||
} catch (e) {
|
||||
throw new Error(`swiftc build failed: ${e.stderr||e.message}`);
|
||||
}
|
||||
}
|
||||
async start() {
|
||||
if (this.proc && this.proc.exitCode === null && this.ready) return;
|
||||
if (!this.binFile) await this.ensureBuilt();
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = ["--locale", this.locale];
|
||||
this.proc = spawn(this.binFile, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stderrBuf = "";
|
||||
this.proc.stderr.on("data", d => { stderrBuf += d.toString(); });
|
||||
setTimeout(() => {
|
||||
if (this.proc.exitCode !== null) {
|
||||
reject(new Error(`apple-pipe exited early code=${this.proc.exitCode} stderr=${stderrBuf.slice(0,2000)}`));
|
||||
} else {
|
||||
this.ready = true;
|
||||
this._setupReader();
|
||||
resolve();
|
||||
}
|
||||
}, 800);
|
||||
this.proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
_setupReader() {
|
||||
let buf = "";
|
||||
this.proc.stdout.on("data", chunk => {
|
||||
buf += chunk.toString("utf8");
|
||||
let lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (this._lineCallback) this._lineCallback(obj);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
async transcribeChunk(wavBytes, { timeoutMs = 6000 } = {}) {
|
||||
await this.start();
|
||||
this.chunkIdx++;
|
||||
const myIdx = this.chunkIdx;
|
||||
return new Promise((resolve, reject) => {
|
||||
let drafts = [];
|
||||
let finals = [];
|
||||
let timer = null;
|
||||
let done = false;
|
||||
const cleanup = () => { done = true; if (timer) clearTimeout(timer); this._lineCallback = null; };
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
cleanup();
|
||||
const fullFinal = finals.map(f=>f.text).join(" ").trim();
|
||||
const lastDraft = drafts.length ? drafts[drafts.length-1].text : "";
|
||||
const text = fullFinal || lastDraft || "";
|
||||
resolve({ text, finals, drafts, chunk: myIdx, isFinal: finals.length>0 });
|
||||
};
|
||||
this._lineCallback = (obj) => {
|
||||
if (obj.chunk !== myIdx) return;
|
||||
if (obj.isFinal || obj.event==="final") {
|
||||
finals.push(obj);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(finish, 350);
|
||||
} else {
|
||||
drafts.push(obj);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(finish, 700);
|
||||
}
|
||||
};
|
||||
timer = setTimeout(() => { if (!done) finish(); }, timeoutMs);
|
||||
try {
|
||||
const lenBuf = Buffer.alloc(4);
|
||||
lenBuf.writeUInt32BE(wavBytes.length, 0);
|
||||
this.proc.stdin.write(Buffer.concat([lenBuf, Buffer.from(wavBytes)]));
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
async close() {
|
||||
try {
|
||||
if (this.proc && this.proc.stdin.writable) {
|
||||
const eof = Buffer.alloc(4); eof.writeUInt32BE(0,0);
|
||||
this.proc.stdin.write(eof);
|
||||
this.proc.stdin.end();
|
||||
}
|
||||
if (this.proc) {
|
||||
await new Promise(r => { this.proc.on("close", r); setTimeout(r, 1500); });
|
||||
try { this.proc.kill(); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
try { if (this.tmpDir) await rm(this.tmpDir, {recursive:true, force:true}); } catch {}
|
||||
this.proc = null;
|
||||
this.ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
async function getOrCreateSession(locale) {
|
||||
let s = sessions.get(locale);
|
||||
if (!s) {
|
||||
s = new ApplePipeSession(locale);
|
||||
sessions.set(locale, s);
|
||||
}
|
||||
await s.start();
|
||||
s.lastActivity = Date.now();
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function speechLiveTranscribe({ audioBase64, locale } = {}) {
|
||||
if (!audioBase64) throw new Error("audioBase64 required (WAV 16k mono base64)");
|
||||
const wavBytes = Buffer.from(audioBase64, "base64");
|
||||
const loc = locale || "en-US";
|
||||
const session = await getOrCreateSession(loc);
|
||||
const res = await session.transcribeChunk(wavBytes, { timeoutMs: 8000 });
|
||||
return {
|
||||
ok: true,
|
||||
engine: "ApplePipeTranscriber/macOS26.5 --pipe volatile drafts",
|
||||
locale: loc,
|
||||
text: res.text,
|
||||
isFinal: res.isFinal,
|
||||
drafts: res.drafts,
|
||||
finals: res.finals,
|
||||
chunk: res.chunk,
|
||||
realtime: true
|
||||
};
|
||||
}
|
||||
|
||||
export async function speechLiveClose({ locale } = {}) {
|
||||
const loc = locale || "en-US";
|
||||
const s = sessions.get(loc);
|
||||
if (s) {
|
||||
await s.close();
|
||||
sessions.delete(loc);
|
||||
}
|
||||
return { ok: true, closed: loc };
|
||||
}
|
||||
|
||||
export async function speechLiveStatus() {
|
||||
const info = [];
|
||||
for (let [loc, s] of sessions.entries()) {
|
||||
info.push({ locale: loc, ready: s.ready, chunkIdx: s.chunkIdx, lastActivity: new Date(s.lastActivity).toISOString(), pid: s.proc?.pid || null });
|
||||
}
|
||||
return { sessions: info, count: info.length };
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
const now = Date.now();
|
||||
for (let [loc, s] of sessions.entries()) {
|
||||
if (now - s.lastActivity > 60000) {
|
||||
try { await s.close(); } catch {}
|
||||
sessions.delete(loc);
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { writeFile, readFile, stat, rm, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OUTPUT_BASE = path.join(os.homedir(), "Projects", "MacMiniMCP", "generated-transcriptions");
|
||||
|
||||
// Swift source for file transcription using the REAL macOS 26.5 API
|
||||
// From swiftinterface: SpeechAnalyzer has init(inputAudioFile:modules:) and analyzeSequence(from:)
|
||||
// No AssetInputSequenceProvider - it's direct AVAudioFile
|
||||
|
||||
function swiftSourceFileTranscriber() {
|
||||
return `import Speech
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct TranscribeCLI {
|
||||
static func main() async {
|
||||
let args = CommandLine.arguments
|
||||
let audioPath = args.count > 1 ? args[1] : ""
|
||||
let localeId = args.count > 2 ? args[2] : "en-US"
|
||||
let jsonOut = args.count > 3 ? args[3] : ""
|
||||
|
||||
if audioPath.isEmpty {
|
||||
fputs("Usage: transcriber <audioPath> [locale] [jsonOut]\\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
guard FileManager.default.fileExists(atPath: audioPath) else {
|
||||
fputs("ERROR: File not found \\(audioPath)\\n", stderr)
|
||||
exit(3)
|
||||
}
|
||||
|
||||
let audioURL = URL(fileURLWithPath: audioPath)
|
||||
let requestedLocale = Locale(identifier: localeId)
|
||||
|
||||
let resolvedLocale: Locale
|
||||
if let l = await SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale) {
|
||||
resolvedLocale = l
|
||||
} else if let fb = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: "en-US")) {
|
||||
resolvedLocale = fb
|
||||
} else {
|
||||
fputs("ERROR: No supported locale for \\(localeId)\\n", stderr)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let isAvail = SpeechTranscriber.isAvailable
|
||||
fputs("Locale \\(resolvedLocale.identifier) isAvailable=\\(isAvail)\\n", stderr)
|
||||
|
||||
let transcriber = SpeechTranscriber(locale: resolvedLocale, preset: .transcription)
|
||||
|
||||
// Assets
|
||||
do {
|
||||
if let req = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||||
fputs("Downloading assets for \\(resolvedLocale.identifier)...\\n", stderr)
|
||||
try await req.downloadAndInstall()
|
||||
fputs("Assets ready\\n", stderr)
|
||||
} else {
|
||||
fputs("No asset download needed\\n", stderr)
|
||||
}
|
||||
} catch {
|
||||
fputs("Asset note (continuing): \\(error)\\n", stderr)
|
||||
}
|
||||
|
||||
let avFile: AVAudioFile
|
||||
do {
|
||||
avFile = try AVAudioFile(forReading: audioURL)
|
||||
fputs("File: frames=\\(avFile.length) sr=\\(avFile.processingFormat.sampleRate) fmt=\\(avFile.fileFormat)\\n", stderr)
|
||||
} catch {
|
||||
fputs("ERROR opening file: \\(error)\\n", stderr)
|
||||
exit(4)
|
||||
}
|
||||
|
||||
var allSegments: [String] = []
|
||||
|
||||
do {
|
||||
// Use the new convenience: analyzer from audio file
|
||||
let analyzer = try await SpeechAnalyzer(inputAudioFile: avFile, modules: [transcriber], finishAfterFile: true)
|
||||
|
||||
// Collect results - must be concurrent
|
||||
let collector = Task {
|
||||
do {
|
||||
for try await r in transcriber.results {
|
||||
let plain = String(r.text.characters)
|
||||
if !plain.isEmpty {
|
||||
allSegments.append(plain)
|
||||
fputs("[result] \\(plain)\\n", stderr)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
fputs("Results error: \\(error)\\n", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// analysis was already started by init with finishAfterFile=true, just wait
|
||||
// Alternatively use analyzeSequence(from:) pattern:
|
||||
// But since init already starts with file, we just wait for collector
|
||||
// The analyzer will finish automatically due to finishAfterFile:true
|
||||
|
||||
// Wait for collector - it finishes when analyzer finishes file and finalizes
|
||||
await collector.value
|
||||
|
||||
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
|
||||
let full = allSegments.joined(separator: " ")
|
||||
let durationSec = avFile.length > 0 ? Double(avFile.length) / avFile.processingFormat.sampleRate : 0
|
||||
let installed = await SpeechTranscriber.installedLocales.map { $0.identifier }.sorted()
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"ok": true,
|
||||
"engine": "SpeechAnalyzer+SpeechTranscriber/macOS26.5",
|
||||
"locale": resolvedLocale.identifier,
|
||||
"requestedLocale": localeId,
|
||||
"transcript": full,
|
||||
"segments": allSegments,
|
||||
"elapsedSeconds": elapsed,
|
||||
"audioPath": audioPath,
|
||||
"durationSeconds": durationSec,
|
||||
"realtimeFactor": durationSec > 0 ? elapsed / durationSec : 0,
|
||||
"rtfx": durationSec > 0 ? durationSec / elapsed : 0,
|
||||
"frames": Int(avFile.length),
|
||||
"sampleRate": avFile.processingFormat.sampleRate,
|
||||
"macOS": ProcessInfo.processInfo.operatingSystemVersionString,
|
||||
"isAvailable": isAvail,
|
||||
"installedLocales": installed
|
||||
]
|
||||
|
||||
let dataOut = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
||||
if !jsonOut.isEmpty {
|
||||
try dataOut.write(to: URL(fileURLWithPath: jsonOut))
|
||||
}
|
||||
FileHandle.standardOutput.write(dataOut)
|
||||
|
||||
} catch {
|
||||
fputs("Analysis failed: \\(error)\\n", stderr)
|
||||
// Dump chain
|
||||
var cur: Error? = error
|
||||
while let e = cur {
|
||||
fputs(" -> \\(e)\\n", stderr)
|
||||
cur = (e as NSError).userInfo[NSUnderlyingErrorKey] as? Error
|
||||
}
|
||||
exit(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
async function ensureOutputDir() {
|
||||
await mkdir(OUTPUT_BASE, { recursive: true });
|
||||
}
|
||||
|
||||
async function cleanup(dir) {
|
||||
try { await rm(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
async function buildAndRun({ audioPath, locale = "en-US" }) {
|
||||
const tmpDir = await (await import("node:fs/promises")).mkdtemp.call(null, path.join(os.tmpdir(), "speech-t-"));
|
||||
// compatible mkdtemp
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "speech-t-"));
|
||||
const swiftFile = path.join(dir, "Main.swift");
|
||||
const binFile = path.join(dir, "transcriber");
|
||||
const jsonOut = path.join(dir, "result.json");
|
||||
|
||||
await writeFile(swiftFile, swiftSourceFileTranscriber(), "utf8");
|
||||
|
||||
try {
|
||||
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", binFile, "-framework", "AVFoundation", "-framework", "Speech"], { timeout: 60_000, maxBuffer: 20*1024*1024 });
|
||||
} catch (e) {
|
||||
await cleanup(dir);
|
||||
throw new Error(`swiftc compile failed:\n${e.stderr || e.message}\n${e.stdout||""}`);
|
||||
}
|
||||
|
||||
try { await stat(binFile); } catch { await cleanup(dir); throw new Error("Binary not built"); }
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(binFile, [audioPath, locale, jsonOut], { timeout: 120_000, maxBuffer: 20*1024*1024 });
|
||||
if (stderr) { try { process.stderr.write(stderr.slice(0,4000)); } catch {} }
|
||||
let result;
|
||||
try { result = JSON.parse(await readFile(jsonOut, "utf8")); } catch { result = JSON.parse(stdout); }
|
||||
await ensureOutputDir();
|
||||
const persistPath = path.join(OUTPUT_BASE, `transcription-${Date.now()}.json`);
|
||||
try { await writeFile(persistPath, JSON.stringify(result, null, 2), "utf8"); result.persistedTo = persistPath; } catch {}
|
||||
await cleanup(dir);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const out = e.stdout || "";
|
||||
const er = e.stderr || e.message || "";
|
||||
try {
|
||||
const partial = JSON.parse(out);
|
||||
if (partial && partial.ok) { await cleanup(dir); return partial; }
|
||||
} catch {}
|
||||
await cleanup(dir);
|
||||
throw new Error(`Transcribe failed\nSTDOUT:${out.slice(0,4000)}\nSTDERR:${er.slice(0,6000)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function speechListLocales() {
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "speech-loc-"));
|
||||
const swiftFile = path.join(dir, "List.swift");
|
||||
const binFile = path.join(dir, "list");
|
||||
const swiftSrc = `import Speech
|
||||
import Foundation
|
||||
@main
|
||||
struct L {
|
||||
static func main() async {
|
||||
let isAvail = SpeechTranscriber.isAvailable
|
||||
let supported = await SpeechTranscriber.supportedLocales.map { $0.identifier }.sorted()
|
||||
let installed = await SpeechTranscriber.installedLocales.map { $0.identifier }.sorted()
|
||||
let reserved = await AssetInventory.reservedLocales.map { $0.identifier }
|
||||
let payload: [String: Any] = [
|
||||
"isAvailable": isAvail,
|
||||
"supported": supported,
|
||||
"installed": installed,
|
||||
"macOS": ProcessInfo.processInfo.operatingSystemVersionString,
|
||||
"maxReserved": AssetInventory.maximumReservedLocales,
|
||||
"reserved": reserved
|
||||
]
|
||||
let d = try! JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
||||
FileHandle.standardOutput.write(d)
|
||||
}
|
||||
}
|
||||
`;
|
||||
await writeFile(swiftFile, swiftSrc, "utf8");
|
||||
try {
|
||||
await execFileAsync("/usr/bin/swiftc", ["-O", "-parse-as-library", swiftFile, "-o", binFile, "-framework", "Speech"], { timeout: 30_000 });
|
||||
const { stdout } = await execFileAsync(binFile, [], { timeout: 15_000 });
|
||||
await cleanup(dir);
|
||||
return JSON.parse(stdout);
|
||||
} catch (e) {
|
||||
await cleanup(dir);
|
||||
throw new Error(`List locales failed: ${e.stderr||e.message}\n${e.stdout||""}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function speechTranscribeFile({ filePath, locale }) {
|
||||
if (!filePath) throw new Error("filePath required");
|
||||
const resolved = path.resolve(filePath.replace(/^~(?=$|\/)/, os.homedir()));
|
||||
try { await stat(resolved); } catch { throw new Error(`File not found: ${resolved}`); }
|
||||
return await buildAndRun({ audioPath: resolved, locale: locale || "en-US" });
|
||||
}
|
||||
|
||||
export async function speechQuickTest({ text, voice } = {}) {
|
||||
const testText = text || "Hello world this is a test of Apple SpeechAnalyzer on the Mac mini M four";
|
||||
const testVoice = voice || "Alex";
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "speech-qtest-"));
|
||||
const aiffPath = path.join(tmp, "test.aiff");
|
||||
try {
|
||||
await execFileAsync("/usr/bin/say", ["-v", testVoice, "-o", aiffPath, testText], { timeout: 15_000 });
|
||||
} catch {
|
||||
try { await execFileAsync("/usr/bin/say", ["-o", aiffPath, testText], { timeout: 15_000 }); }
|
||||
catch (e2) { await cleanup(tmp); throw new Error(`say failed: ${e2.stderr||e2.message}`); }
|
||||
}
|
||||
try { await stat(aiffPath); } catch { await cleanup(tmp); throw new Error("Generated audio not found"); }
|
||||
|
||||
let result;
|
||||
try { result = await buildAndRun({ audioPath: aiffPath, locale: "en-US" }); }
|
||||
catch (e) { await cleanup(tmp); throw e; }
|
||||
|
||||
result.testInputText = testText;
|
||||
result.testVoice = testVoice;
|
||||
try {
|
||||
await ensureOutputDir();
|
||||
const dest = path.join(OUTPUT_BASE, `qtest-${Date.now()}.aiff`);
|
||||
await execFileAsync("/bin/cp", [aiffPath, dest], { timeout: 5_000 });
|
||||
result.persistedAudio = dest;
|
||||
} catch {}
|
||||
await cleanup(tmp);
|
||||
return result;
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdir, stat, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const OUTPUT_BASE = path.join(os.homedir(), "Projects", "MacMiniMCP", "generated-audio");
|
||||
|
||||
async function ensureOutputDir() {
|
||||
await mkdir(OUTPUT_BASE, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeVoice(v) {
|
||||
if (!v) return null;
|
||||
return String(v).trim().slice(0, 100) || null;
|
||||
}
|
||||
|
||||
function listVoicesParse(stdout) {
|
||||
// format from `say -v ?` : "Alex en_US # Most people recognize me by my voice."
|
||||
const lines = stdout.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
const voices = [];
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\S+)\s+([a-z]{2}_[A-Z]{2}(?:_[A-Z]+)?)\s+#?\s*(.*)$/);
|
||||
if (match) {
|
||||
voices.push({ name: match[1], locale: match[2], description: match[3] || "" });
|
||||
} else {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 1 && parts[0]) {
|
||||
voices.push({ name: parts[0], locale: parts[1] || "", description: parts.slice(2).join(" ") });
|
||||
}
|
||||
}
|
||||
}
|
||||
return voices;
|
||||
}
|
||||
|
||||
export async function speechListVoices() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/say", ["-v", "?"], { timeout: 10000, maxBuffer: 10 * 1024 * 1024 });
|
||||
const voices = listVoicesParse(stdout);
|
||||
return { ok: true, count: voices.length, voices: voices.slice(0, 150) };
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to list voices: ${e.stderr || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function speechSynthesize({ text, voice, rate, outputFormat }) {
|
||||
if (!text || !String(text).trim()) throw new Error("text required");
|
||||
const cleanText = String(text).slice(0, 5000);
|
||||
const v = sanitizeVoice(voice);
|
||||
await ensureOutputDir();
|
||||
|
||||
const ts = Date.now();
|
||||
const rand = Math.random().toString(16).slice(2, 8);
|
||||
const aiffPath = path.join(OUTPUT_BASE, `tts-${ts}-${rand}.aiff`);
|
||||
const wavPath = path.join(OUTPUT_BASE, `tts-${ts}-${rand}.wav`);
|
||||
const finalWav16k = path.join(OUTPUT_BASE, `tts-${ts}-${rand}-16k.wav`);
|
||||
|
||||
const sayArgs = [];
|
||||
if (v) sayArgs.push("-v", v);
|
||||
if (rate) {
|
||||
const r = parseInt(String(rate), 10);
|
||||
if (!isNaN(r) && r >= 80 && r <= 500) {
|
||||
sayArgs.push("-r", String(r));
|
||||
}
|
||||
}
|
||||
sayArgs.push("-o", aiffPath, cleanText);
|
||||
|
||||
try {
|
||||
await execFileAsync("/usr/bin/say", sayArgs, { timeout: 30000, maxBuffer: 20 * 1024 * 1024 });
|
||||
} catch (e) {
|
||||
throw new Error(`say failed: ${e.stderr || e.message}\nOUT:${e.stdout||""}`);
|
||||
}
|
||||
|
||||
try { await stat(aiffPath); } catch { throw new Error("say output not created"); }
|
||||
|
||||
// Prefer afconvert to make 16k mono wav compatible with iPhone play_audio_base64 (expects 16k PCM s16le mono)
|
||||
// afconvert -f WAVE -d LEI16@16000 -c 1 in.aiff out.wav
|
||||
// Fallback to ffmpeg if afconvert not available is not ideal on Mac, but we try afconvert first.
|
||||
try {
|
||||
try {
|
||||
await execFileAsync("/usr/bin/afconvert", ["-f", "WAVE", "-d", "LEI16@16000", "-c", "1", aiffPath, finalWav16k], { timeout: 15000 });
|
||||
await stat(finalWav16k);
|
||||
// Also create regular wav for compatibility
|
||||
await execFileAsync("/usr/bin/afconvert", ["-f", "WAVE", "-d", "LEI16", aiffPath, wavPath], { timeout: 10000 }).catch(()=>{});
|
||||
} catch {
|
||||
// Fallback: try format without @ rate, then use afinfo, or just keep aiff path
|
||||
await execFileAsync("/usr/bin/afconvert", ["-f", "WAVE", "-d", "LEI16", "-c", "1", aiffPath, finalWav16k], { timeout: 15000 });
|
||||
}
|
||||
} catch (e) {
|
||||
// If afconvert fails, keep aiff and tell caller
|
||||
// We'll still return aiff path
|
||||
}
|
||||
|
||||
let chosenPath = finalWav16k;
|
||||
try { await stat(chosenPath); } catch {
|
||||
try { await stat(wavPath); chosenPath = wavPath; } catch { chosenPath = aiffPath; }
|
||||
}
|
||||
|
||||
let wavBase64 = null;
|
||||
let base64Len = 0;
|
||||
// For fastest iPhone playback, provide base64 of 16k wav
|
||||
try {
|
||||
// try finalWav16k first
|
||||
let b64Target = finalWav16k;
|
||||
try { await stat(b64Target); } catch { b64Target = chosenPath; }
|
||||
const buf = await readFile(b64Target);
|
||||
// If file > 2MB, we still encode but warn - iPhone can handle ~500KB typical
|
||||
if (buf.length < 4 * 1024 * 1024) {
|
||||
wavBase64 = buf.toString("base64");
|
||||
base64Len = wavBase64.length;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
text: cleanText,
|
||||
voice: v || "default",
|
||||
rate: rate || null,
|
||||
aiffPath,
|
||||
wavPath: finalWav16k,
|
||||
filePath: chosenPath,
|
||||
fallbackPath: aiffPath,
|
||||
wavBase64: wavBase64 ? wavBase64.slice(0, 50) + "...(truncated for display)" : null,
|
||||
wavBase64Full: wavBase64 ? "available" : null,
|
||||
base64Length: base64Len,
|
||||
fileSize: (await stat(chosenPath).then(s=>s.size).catch(()=>0)),
|
||||
note: "Use filePath on Mac. For iPhone play_audio_base64, use the base64 wav. Call speech_synthesize_file variant or read endpoint needs full base64."
|
||||
};
|
||||
}
|
||||
|
||||
export async function speechSynthesizeBase64({ text, voice, rate }) {
|
||||
if (!text) throw new Error("text required");
|
||||
const res = await speechSynthesize({ text, voice, rate });
|
||||
// read the 16k wav file full base64
|
||||
const target = res.wavPath || res.filePath;
|
||||
const buf = await readFile(target);
|
||||
const b64 = buf.toString("base64");
|
||||
return {
|
||||
ok: true,
|
||||
text: String(text).slice(0, 5000),
|
||||
voice: res.voice,
|
||||
filePath: target,
|
||||
wavBase64: b64,
|
||||
size: buf.length,
|
||||
base64Length: b64.length,
|
||||
format: "wav 16k mono s16le"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { execFile, execFile as execFileCb } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function getSystemInfo() {
|
||||
const results = {};
|
||||
// sw_vers for macOS version
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/sw_vers", { timeout: 5000 });
|
||||
const info = {};
|
||||
for (const line of stdout.split("\n")) {
|
||||
const [k, ...rest] = line.split(":");
|
||||
if (!k) continue;
|
||||
const key = k.trim();
|
||||
if (!key) continue;
|
||||
info[key] = rest.join(":").trim();
|
||||
}
|
||||
results.sw_vers = info;
|
||||
results.macos_version = info.ProductVersion || info.productVersion || "";
|
||||
results.build = info.BuildVersion || info.buildVersion || "";
|
||||
} catch (e) {
|
||||
results.sw_vers_error = e.message;
|
||||
}
|
||||
|
||||
// uname -a
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/uname", ["-a"], { timeout: 3000 });
|
||||
results.uname = stdout.trim();
|
||||
} catch (e) {
|
||||
results.uname_error = e.message;
|
||||
}
|
||||
|
||||
// Check for SpeechAnalyzer / SpeechTranscriber availability (macOS 26+)
|
||||
// These frameworks exist only on macOS 26+. We probe via swift/python check.
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/python3", ["-c", `
|
||||
import os, glob, sys
|
||||
# Check if Speech framework contains new symbols (macOS 26)
|
||||
frameworks = glob.glob('/System/Library/Frameworks/Speech.framework/*') + glob.glob('/System/Library/PrivateFrameworks/*Speech*')
|
||||
# Simplest: check macOS version parse
|
||||
import platform
|
||||
print(platform.mac_ver()[0])
|
||||
`], { timeout: 5000 });
|
||||
results.python_mac_ver = stdout.trim();
|
||||
} catch (e) {
|
||||
results.python_mac_ver_error = e.message;
|
||||
}
|
||||
|
||||
// Try to detect SpeechAnalyzer via file existence / Swift availability
|
||||
try {
|
||||
// On macOS 26, Speech.framework/Versions should have newer build
|
||||
const { stdout } = await execFileAsync("/bin/ls", ["-la", "/System/Library/Frameworks/Speech.framework/"], { timeout: 3000 });
|
||||
results.speech_framework_ls = stdout.trim().slice(0, 2000);
|
||||
} catch (e) {
|
||||
results.speech_framework_error = e.message;
|
||||
}
|
||||
|
||||
// Check hardware model
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/sbin/sysctl", ["-n", "hw.model"], { timeout: 2000 });
|
||||
results.hw_model = stdout.trim();
|
||||
} catch {}
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/sbin/sysctl", ["-n", "machdep.cpu.brand_string"], { timeout: 2000 });
|
||||
results.cpu_brand = stdout.trim();
|
||||
} catch {}
|
||||
|
||||
// Is this macOS 26+ ?
|
||||
const versionToCheck = results.macos_version || results.python_mac_ver || "";
|
||||
if (versionToCheck) {
|
||||
const major = parseInt(versionToCheck.split(".")[0], 10);
|
||||
results.is_macos_26_plus = major >= 26;
|
||||
results.speech_analyzer_expected = major >= 26 ? "likely available (macOS 26+)" : "not available - requires macOS 26+";
|
||||
} else if (results.python_mac_ver) {
|
||||
const major = parseInt(results.python_mac_ver.split(".")[0], 10);
|
||||
results.macos_version = results.python_mac_ver;
|
||||
results.is_macos_26_plus = major >= 26;
|
||||
results.speech_analyzer_expected = major >= 26 ? "likely available (macOS 26+)" : "not available";
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getSpeechApiStatus() {
|
||||
const sysInfo = await getSystemInfo();
|
||||
// Try to run a tiny Swift snippet that imports Speech and checks for SpeechAnalyzer
|
||||
// Fallback to reporting version info if swiftc not available
|
||||
let swiftCheck = null;
|
||||
try {
|
||||
// Write temp swift file that checks API availability
|
||||
const { stdout: swiftPath } = await execFileAsync("/usr/bin/which", ["swift"], { timeout: 2000 });
|
||||
const swiftBin = swiftPath.trim();
|
||||
if (swiftBin) {
|
||||
// Create a small swift program to test SpeechAnalyzer availability
|
||||
const swiftCode = `
|
||||
import Speech
|
||||
import Foundation
|
||||
#if canImport(Speech)
|
||||
if #available(macOS 26.0, *) {
|
||||
print("SpeechAnalyzer: available")
|
||||
// Try to reference the type
|
||||
let _ = SpeechTranscriber.self
|
||||
print("SpeechTranscriber: available")
|
||||
} else {
|
||||
print("SpeechAnalyzer: requires macOS 26")
|
||||
}
|
||||
#else
|
||||
print("Speech framework not importable")
|
||||
#endif
|
||||
`;
|
||||
const tmpFile = `/tmp/speech_check_${Date.now()}.swift`;
|
||||
const { writeFile, unlink } = await import("node:fs/promises");
|
||||
await writeFile(tmpFile, swiftCode, "utf8");
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(swiftBin, [tmpFile], { timeout: 15000, maxBuffer: 2 * 1024 * 1024 });
|
||||
swiftCheck = { stdout: stdout.trim(), stderr: stderr.trim(), ok: true };
|
||||
} catch (e) {
|
||||
swiftCheck = { stdout: e.stdout?.toString().trim() || "", stderr: (e.stderr?.toString() || e.message).trim().slice(0, 2000), ok: false };
|
||||
} finally {
|
||||
try { await unlink(tmpFile); } catch {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
swiftCheck = { error: e.message };
|
||||
}
|
||||
|
||||
return {
|
||||
system: sysInfo,
|
||||
swift_availability: swiftCheck,
|
||||
conclusion: sysInfo.is_macos_26_plus
|
||||
? "macOS 26+ detected — SpeechAnalyzer/SpeechTranscriber should be available per Apple docs."
|
||||
: `macOS ${sysInfo.macos_version || "unknown"} detected — SpeechAnalyzer requires macOS 26+. ${sysInfo.macos_version ? `You are on ${sysInfo.macos_version}, need to upgrade to 26.` : ""}`,
|
||||
};
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { mkdir, stat, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const VOICEBOX_URL = process.env.VOICEBOX_URL || "http://127.0.0.1:17493";
|
||||
const OUTPUT_BASE = path.join(os.homedir(), "Projects", "MacMiniMCP", "generated-audio");
|
||||
|
||||
const KNOWN_PROFILES = {
|
||||
Aiden: "ff624ec6-5485-4173-a4f0-2ec2196efd39",
|
||||
Adolfo: "0e042c6b-ae52-4f28-835b-528381ed60b4",
|
||||
Nicole: "52330098-6fc3-4e9c-a30c-11164869636e",
|
||||
Jessica: "579c7444-3905-4aab-8067-eb10a0b3e76f",
|
||||
Dora: "1862f224-fd47-4791-9f37-76f76ca0450c",
|
||||
Alex: "a0cf179a-c033-47e9-92b5-f61596f68adc",
|
||||
};
|
||||
|
||||
async function fetchJSON(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
const txt = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${txt.slice(0,500)}`);
|
||||
try { return JSON.parse(txt); } catch { return txt; }
|
||||
}
|
||||
|
||||
export async function voiceboxListProfiles() {
|
||||
try {
|
||||
const profiles = await fetchJSON(`${VOICEBOX_URL}/profiles`);
|
||||
const enriched = profiles.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
engine: p.default_engine,
|
||||
voice_type: p.voice_type,
|
||||
preset_voice_id: p.preset_voice_id,
|
||||
sample_count: p.sample_count,
|
||||
generation_count: p.generation_count,
|
||||
}));
|
||||
return { ok: true, url: VOICEBOX_URL, count: enriched.length, profiles: enriched, defaultBoyVoice: "Aiden", defaultGirlVoice: "Jessica", mapping: KNOWN_PROFILES };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message, url: VOICEBOX_URL };
|
||||
}
|
||||
}
|
||||
|
||||
export async function voiceboxHealth() {
|
||||
try {
|
||||
const health = await fetchJSON(`${VOICEBOX_URL}/health`);
|
||||
return { ok: true, ...health, url: VOICEBOX_URL };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message, url: VOICEBOX_URL };
|
||||
}
|
||||
}
|
||||
|
||||
async function pollGeneration(id, timeoutMs = 20000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const gen = await fetchJSON(`${VOICEBOX_URL}/history/${id}`);
|
||||
if (gen.status === "completed" && gen.audio_path) return gen;
|
||||
if (gen.status === "failed") throw new Error(gen.error || "generation failed");
|
||||
} catch (e) {
|
||||
if (e.message && e.message.toLowerCase().includes("failed")) throw e;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error("poll timeout");
|
||||
}
|
||||
|
||||
export async function voiceboxGenerate({ text, profile, engine, language, voice }) {
|
||||
if (!text) throw new Error("text required");
|
||||
let profileId = profile || voice || "Aiden";
|
||||
if (KNOWN_PROFILES[profileId]) profileId = KNOWN_PROFILES[profileId];
|
||||
if (!profileId.includes("-")) {
|
||||
try {
|
||||
const profiles = await fetchJSON(`${VOICEBOX_URL}/profiles`);
|
||||
const match = profiles.find(p => p.name.toLowerCase() === profileId.toLowerCase());
|
||||
if (match) profileId = match.id;
|
||||
} catch {}
|
||||
}
|
||||
let autoEngine = engine;
|
||||
if (!autoEngine && ["ff624ec6-5485-4173-a4f0-2ec2196efd39", "4d0ded93-3b12-465f-aeb4-aa4360f3dc5c", "d6c2e90f-ec01-4f5e-8efc-7822bd79ac56"].includes(profileId)) {
|
||||
autoEngine = "qwen_custom_voice";
|
||||
}
|
||||
const body = {
|
||||
profile_id: profileId,
|
||||
text: String(text).slice(0, 1000),
|
||||
language: language || "en",
|
||||
engine: autoEngine || undefined,
|
||||
};
|
||||
Object.keys(body).forEach(k => body[k] === undefined && delete body[k]);
|
||||
try {
|
||||
const gen = await fetchJSON(`${VOICEBOX_URL}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let finalGen = gen;
|
||||
if (gen.status === "generating" || !gen.audio_path) {
|
||||
try {
|
||||
finalGen = await pollGeneration(gen.id, 20000);
|
||||
} catch (pollErr) {
|
||||
return { ok: false, id: gen.id, status: gen.status, error: pollErr.message, url: VOICEBOX_URL, polling: true, profile_id: profileId, text: body.text };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
id: finalGen.id,
|
||||
profile_id: finalGen.profile_id,
|
||||
text: finalGen.text,
|
||||
audio_path: finalGen.audio_path,
|
||||
duration: finalGen.duration,
|
||||
engine: finalGen.engine,
|
||||
status: finalGen.status,
|
||||
url: VOICEBOX_URL,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(`voicebox generate failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function voiceboxGenerateBase64({ text, profile, voice, engine, language }) {
|
||||
const gen = await voiceboxGenerate({ text, profile, voice, engine, language });
|
||||
if (!gen.ok || !gen.audio_path) return { ...gen, ok: false, error: gen.error || `no audio_path, status ${gen.status}`, fileExists: false };
|
||||
|
||||
// audio_path may be relative "generations/xxx.wav" or absolute
|
||||
let filePath = gen.audio_path;
|
||||
let candidates = [filePath];
|
||||
if (!filePath.startsWith("/") && !filePath.startsWith("~")) {
|
||||
candidates.push(path.join(os.homedir(), "Library", "Application Support", "sh.voicebox.app", filePath));
|
||||
candidates.push(path.join(os.homedir(), "Library", "Application Support", "sh.voicebox.app", "generations", path.basename(filePath)));
|
||||
}
|
||||
let foundPath = null;
|
||||
for (const cand of candidates) {
|
||||
try { await stat(cand); foundPath = cand; break; } catch {}
|
||||
}
|
||||
if (!foundPath) {
|
||||
try { await stat(filePath); foundPath = filePath; } catch {}
|
||||
}
|
||||
if (!foundPath) {
|
||||
// try glob latest file matching id
|
||||
return { ...gen, ok: false, error: `audio_path not found on disk: ${filePath}, tried ${candidates.join(",")}`, fileExists: false };
|
||||
}
|
||||
filePath = foundPath;
|
||||
try {
|
||||
const buf = await readFile(filePath);
|
||||
const b64 = buf.toString("base64");
|
||||
return {
|
||||
ok: true,
|
||||
id: gen.id,
|
||||
profile_id: gen.profile_id,
|
||||
text: gen.text,
|
||||
filePath,
|
||||
duration: gen.duration,
|
||||
engine: gen.engine,
|
||||
wavBase64: b64,
|
||||
base64Length: b64.length,
|
||||
size: buf.length,
|
||||
format: filePath.endsWith(".wav") ? "wav" : "mp3",
|
||||
url: VOICEBOX_URL,
|
||||
profile: profile || voice || "Aiden",
|
||||
};
|
||||
} catch (e) {
|
||||
return { ...gen, ok: false, error: `read failed: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function voiceboxQuickReply({ text, voice, profile }) {
|
||||
const chosenProfile = profile || voice || "Aiden";
|
||||
const result = await voiceboxGenerateBase64({ text, profile: chosenProfile, language: "en", engine: undefined });
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
FOCUS_CALENDAR,
|
||||
createEvent,
|
||||
listCalendars,
|
||||
listEvents,
|
||||
} from "./integrations/calendar.js";
|
||||
import {
|
||||
createContact,
|
||||
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 {
|
||||
listMailAccounts,
|
||||
listMailboxes,
|
||||
listMailMessages,
|
||||
readMailMessage,
|
||||
} from "./integrations/mail.js";
|
||||
import {
|
||||
createReminder,
|
||||
listReminderLists,
|
||||
listReminders,
|
||||
} from "./integrations/reminders.js";
|
||||
import { getSystemInfo, getSpeechApiStatus } from "./integrations/system.js";
|
||||
import {
|
||||
speechListVoices,
|
||||
speechSynthesize,
|
||||
speechSynthesizeBase64,
|
||||
} from "./integrations/speech-tts.js";
|
||||
import {
|
||||
speechKokoroStatus,
|
||||
speechKokoroSynthesize,
|
||||
speechKokoroSynthesizeBase64,
|
||||
} from "./integrations/kokoro-tts.js";
|
||||
import {
|
||||
voiceboxListProfiles,
|
||||
voiceboxHealth,
|
||||
voiceboxGenerate,
|
||||
voiceboxGenerateBase64,
|
||||
voiceboxQuickReply,
|
||||
} from "./integrations/voicebox.js";
|
||||
import {
|
||||
speechTranscribeFile,
|
||||
speechListLocales,
|
||||
speechQuickTest,
|
||||
} from "./integrations/speech-transcribe.js";
|
||||
import {
|
||||
speechLiveTranscribe,
|
||||
speechLiveClose,
|
||||
speechLiveStatus,
|
||||
} from "./integrations/speech-live.js";
|
||||
import {
|
||||
appleLLMCheck,
|
||||
appleLLMPolish,
|
||||
appleLLMQuickReply,
|
||||
appleLLMChat,
|
||||
appleLLMClose,
|
||||
appleLLMStatus,
|
||||
} from "./integrations/apple-llm.js";
|
||||
|
||||
const jsonResult = (value) => ({
|
||||
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
||||
});
|
||||
|
||||
const handled = (operation) => async (input) => {
|
||||
try {
|
||||
return jsonResult(await operation(input));
|
||||
} catch (error) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: error.message }],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function withFocusedCalendar(input) {
|
||||
if (input.calendarIndex !== undefined) {
|
||||
return { ...input, calendarIndex: input.calendarIndex };
|
||||
}
|
||||
if (input.calendar !== undefined) {
|
||||
return { ...input, calendarIndex: null };
|
||||
}
|
||||
return { ...input, ...FOCUS_CALENDAR };
|
||||
}
|
||||
|
||||
export function createMacMiniMcpServer() {
|
||||
const server = new McpServer({
|
||||
name: "macmini-mcp",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"notes_list",
|
||||
"Find notes by optional text or folder filter. Titles and metadata are returned by default; request previews explicitly.",
|
||||
{
|
||||
query: z.string().optional().describe("Text to search in note title and plaintext body."),
|
||||
folder: z.string().optional().describe("Exact Notes folder name."),
|
||||
includePreview: z.boolean().default(false).describe("Include plaintext previews only when needed; note content can be sensitive."),
|
||||
limit: z.number().int().positive().max(100).default(20),
|
||||
},
|
||||
handled(listNotes),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"notes_read",
|
||||
"Read one Apple Note after locating its ID with notes_list.",
|
||||
{ id: z.string().min(1).describe("Notes unique ID.") },
|
||||
handled(({ id }) => readNote(id)),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"notes_create",
|
||||
"Create a new Apple Note from plaintext content.",
|
||||
{
|
||||
title: z.string().min(1).max(300),
|
||||
body: z.string().default(""),
|
||||
folder: z.string().optional().describe("Exact destination folder; defaults to the first available folder."),
|
||||
},
|
||||
handled(createNote),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"mail_accounts",
|
||||
"List configured Apple Mail accounts and their email addresses. Read-only.",
|
||||
{},
|
||||
handled(() => listMailAccounts()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"mail_list_mailboxes",
|
||||
"List mailboxes for one selected Apple Mail account. Call mail_accounts first. Read-only.",
|
||||
{
|
||||
accountId: z.string().min(1).describe("Account ID returned by mail_accounts."),
|
||||
},
|
||||
handled(listMailboxes),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"mail_list_messages",
|
||||
"List message metadata in one selected Apple Mail account and mailbox. Read-only; does not fetch message bodies.",
|
||||
{
|
||||
accountId: z.string().min(1).describe("Account ID returned by mail_accounts."),
|
||||
mailbox: z.string().min(1).describe("Exact mailbox name returned by mail_list_mailboxes."),
|
||||
unreadOnly: z.boolean().default(false).describe("Return only unread messages."),
|
||||
limit: z.number().int().positive().max(50).default(10),
|
||||
},
|
||||
handled(listMailMessages),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"mail_read_message",
|
||||
"Read one Apple Mail message after selecting it with mail_list_messages. Read-only.",
|
||||
{
|
||||
accountId: z.string().min(1).describe("Account ID returned by mail_accounts."),
|
||||
mailbox: z.string().min(1).describe("Exact mailbox name containing the selected message."),
|
||||
id: z.string().min(1).describe("Message ID returned by mail_list_messages."),
|
||||
},
|
||||
handled(readMailMessage),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"calendar_list_calendars",
|
||||
"List macOS Calendar calendars with indexes and indicate which accept new events. The server is focused on the event-rich Home calendar by default.",
|
||||
{},
|
||||
handled(listCalendars),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"calendar_list_events",
|
||||
"List events in the focused Home calendar over a required ISO-8601 time range. Optional selectors override the focus calendar.",
|
||||
{
|
||||
start: z.string().describe("Range start as an ISO-8601 datetime."),
|
||||
end: z.string().describe("Range end as an ISO-8601 datetime."),
|
||||
calendar: z.string().optional().describe("Advanced override: exact calendar name."),
|
||||
calendarIndex: z.number().int().nonnegative().optional().describe("Advanced override: index returned by calendar_list_calendars."),
|
||||
limit: z.number().int().positive().max(250).default(50),
|
||||
},
|
||||
handled((input) => listEvents(withFocusedCalendar(input))),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"calendar_create_event",
|
||||
"Create an event in the focused Home calendar. Optional selectors override the focus calendar.",
|
||||
{
|
||||
calendar: z.string().optional().describe("Advanced override: exact destination calendar name."),
|
||||
calendarIndex: z.number().int().nonnegative().optional().describe("Advanced override: destination index."),
|
||||
title: z.string().min(1).max(500),
|
||||
start: z.string().describe("Event start as an ISO-8601 datetime."),
|
||||
end: z.string().describe("Event end as an ISO-8601 datetime."),
|
||||
allDay: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
},
|
||||
handled((input) => createEvent(withFocusedCalendar(input))),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"reminders_list_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. 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."),
|
||||
limit: z.number().int().positive().max(100).default(25),
|
||||
},
|
||||
handled(listReminders),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"reminders_create",
|
||||
"Create a reminder in an optional named Reminders list.",
|
||||
{
|
||||
title: z.string().min(1).max(500),
|
||||
list: z.string().optional().describe("Exact destination list; defaults to the first available list."),
|
||||
notes: z.string().optional(),
|
||||
due: z.string().optional().describe("Optional due date as an ISO-8601 datetime."),
|
||||
},
|
||||
handled(createReminder),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"contacts_search",
|
||||
"Search Contacts by name or organization. Returns metadata only; use contacts_read for contact methods.",
|
||||
{
|
||||
query: z.string().optional().describe("Text to search in display name or organization."),
|
||||
limit: z.number().int().positive().max(100).default(20),
|
||||
},
|
||||
handled(searchContacts),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"contacts_read",
|
||||
"Read contact details, including email addresses and phone numbers, after selecting an ID with contacts_search.",
|
||||
{
|
||||
id: z.string().min(1).describe("Persistent Contacts person ID."),
|
||||
},
|
||||
handled(({ id }) => readContact(id)),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"contacts_create",
|
||||
"Create a new contact with optional email address and phone number.",
|
||||
{
|
||||
firstName: z.string().max(200).optional(),
|
||||
lastName: z.string().max(200).optional(),
|
||||
organization: z.string().max(300).optional(),
|
||||
jobTitle: z.string().max(300).optional(),
|
||||
note: z.string().max(2000).optional(),
|
||||
email: z.object({
|
||||
label: z.string().max(100).default("work"),
|
||||
value: z.string().email(),
|
||||
}).optional(),
|
||||
phone: z.object({
|
||||
label: z.string().max(100).default("mobile"),
|
||||
value: z.string().min(1).max(100),
|
||||
}).optional(),
|
||||
},
|
||||
handled((input) => {
|
||||
if (!input.firstName && !input.lastName && !input.organization) {
|
||||
throw new Error("firstName, lastName, or organization is required.");
|
||||
}
|
||||
return createContact(input);
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"deco_get_config_status",
|
||||
"Show TP-Link Deco connection configuration without revealing the password.",
|
||||
{},
|
||||
handled(getDecoConfigStatus),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"deco_get_overview",
|
||||
"Read TP-Link Deco overview stats: WAN/LAN addresses, CPU/memory usage, client counts, Wi-Fi enablement, and firmware.",
|
||||
{},
|
||||
handled(() => getDecoStats("overview")),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"deco_list_clients",
|
||||
"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")),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"deco_get_ipv4_status",
|
||||
"Read TP-Link Deco WAN/LAN IPv4 status including gateway, DNS, netmasks, and connection type.",
|
||||
{},
|
||||
handled(() => getDecoStats("ipv4")),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"deco_get_firmware",
|
||||
"Read TP-Link Deco model, hardware version, and firmware version.",
|
||||
{},
|
||||
handled(() => getDecoStats("firmware")),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"system_get_info",
|
||||
"Get Mac mini system info: macOS version (sw_vers), uname, hardware model, and whether macOS 26+ SpeechAnalyzer is available.",
|
||||
{},
|
||||
handled(() => getSystemInfo()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"system_speech_api_status",
|
||||
"Check if Apple's new SpeechAnalyzer / SpeechTranscriber (macOS 26+) is available — returns macOS version, build, and Swift availability probe.",
|
||||
{},
|
||||
handled(() => getSpeechApiStatus()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_list_locales",
|
||||
"List SpeechTranscriber locales: isAvailable, supported, installed, reserved, maxReserved. Use to check if your language model is ready before transcribing.",
|
||||
{},
|
||||
handled(() => speechListLocales()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_transcribe_file",
|
||||
"Transcribe an audio file using Apple's new SpeechAnalyzer + SpeechTranscriber (macOS 26+). Fastest and most accurate English engine on-device per Inscribe benchmark (2.12% WER). Supports m4a, wav, aiff, mp3, etc via AVFoundation. Returns transcript, segments, timing, realtime factor.",
|
||||
{
|
||||
filePath: z.string().min(1).describe("Absolute path to audio file on the Mac. Can be ~/path or /tmp/ etc."),
|
||||
locale: z.string().optional().describe("Locale like en-US, es-ES, etc. Defaults to en-US. Use speech_list_locales to see options."),
|
||||
},
|
||||
handled(({ filePath, locale }) => speechTranscribeFile({ filePath, locale })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_quick_test",
|
||||
"End-to-end loopback test: generates speech with macOS say command, then transcribes it with SpeechAnalyzer to verify the whole pipeline works. Returns both input text and transcript for comparison.",
|
||||
{
|
||||
text: z.string().optional().describe("Text to synthesize and transcribe. Default: hello world test."),
|
||||
voice: z.string().optional().describe("macOS say voice, e.g. Alex, Samantha. Default: Alex."),
|
||||
},
|
||||
handled(({ text, voice }) => speechQuickTest({ text, voice })),
|
||||
);
|
||||
|
||||
|
||||
server.tool(
|
||||
"speech_live_transcribe",
|
||||
"LIVE draft transcription via persistent Apple pipe (ApplePipeTranscriber macOS 26+). Reuses your whisper-translation pipe protocol: 4-byte BE len + WAV payload -> draft/final JSON streaming. Input: base64-encoded 16kHz mono WAV. Output: {text, drafts[], finals[], isFinal}. Keep session alive for <200ms incremental feedback while streaming ESP32 PCM. Auto-closes after 60s idle.",
|
||||
{
|
||||
audioBase64: z.string().min(100).describe("Base64-encoded WAV file (16kHz mono s16le). Use 0.5-3s chunks for draft streaming."),
|
||||
locale: z.string().optional().describe("Locale like en-US. Default en-US."),
|
||||
},
|
||||
handled(({ audioBase64, locale }) => speechLiveTranscribe({ audioBase64, locale })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_live_close",
|
||||
"Close a persistent live transcription pipe session (frees Swift process).",
|
||||
{
|
||||
locale: z.string().optional().describe("Locale to close. Default en-US."),
|
||||
},
|
||||
handled(({ locale }) => speechLiveClose({ locale })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_live_status",
|
||||
"Show active live pipe sessions: locale, pid, chunkIdx, lastActivity.",
|
||||
{},
|
||||
handled(() => speechLiveStatus()),
|
||||
);
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
|
||||
server.tool(
|
||||
"apple_llm_check",
|
||||
"Check if Apple on-device 3B LLM (FoundationModels SystemLanguageModel) is available. ANE-accelerated, offline, ~0.6s vs Ollama 2-3s. Required for polish/quick_reply/chat.",
|
||||
{},
|
||||
handled(() => appleLLMCheck()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"apple_llm_polish",
|
||||
"Polish a caption line/paragraph using Apple on-device 3B LLM ANE. From whisper-translation engine_apple_llm.py: same as AppleLLMPolish binary pipe. Fixes punctuation, casing, typos from live transcript. Mode: line (default) or paragraph. Reuses single warm session.",
|
||||
{
|
||||
text: z.string().min(1).describe("Text to polish (STT draft or final)."),
|
||||
prev1: z.string().optional().describe("Previous line for context (line mode)."),
|
||||
prev2: z.string().optional().describe("Second previous line."),
|
||||
mode: z.enum(["line", "paragraph"]).optional().describe("line (default) or paragraph."),
|
||||
},
|
||||
handled(({ text, prev1, prev2, mode }) => appleLLMPolish({ text, prev1, prev2, mode })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"apple_llm_quick_reply",
|
||||
"INSTANT smart reply from live draft — for voice gateway. Feed the volatile draft transcript from speech_live_transcribe while user is speaking. Apple 3B ANE returns <20-word ACK preview in ~0.6s, before final transcription. Use this to provide almost immediate response when voice stops: send draft to quick_reply, get instant text to speak/display, then full Hermes turn in background. Reuses same ANE session as polish.",
|
||||
{
|
||||
draft: z.string().min(1).describe("Live draft transcript (partial, may have typos) from speech_live_transcribe. Used to infer intent for instant preview reply."),
|
||||
context: z.string().optional().describe("Optional previous conversation context or last assistant reply."),
|
||||
instructions: z.string().optional().describe("Optional system instructions for quick reply persona."),
|
||||
},
|
||||
handled(({ draft, context, instructions }) => appleLLMQuickReply({ draft, context, instructions })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"apple_llm_chat",
|
||||
"Fast voice-assistant chat via Apple 3B ANE (FoundationModels). Under 40 words, warm, kid-safe, for ESP32. Use when you want Mac mini to directly answer draft/final without calling Hermes gateway. Accepts history array and current text. ~0.6-1.2s.",
|
||||
{
|
||||
text: z.string().min(1).describe("User message (draft or final transcript)."),
|
||||
history: z.array(z.object({ role: z.string(), text: z.string() })).optional().describe("Optional conversation history [{role, text}]."),
|
||||
instructions: z.string().optional().describe("Optional custom instructions."),
|
||||
},
|
||||
handled(({ text, history, instructions }) => appleLLMChat({ text, history, instructions })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"apple_llm_status",
|
||||
"Show Apple LLM session status: active, ready, pid, lastActivity.",
|
||||
{},
|
||||
handled(() => appleLLMStatus()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"apple_llm_close",
|
||||
"Close Apple LLM ANE session (frees Swift process + KV cache). Auto-closes after 2min idle anyway.",
|
||||
{},
|
||||
handled(() => appleLLMClose()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_list_voices",
|
||||
"List available macOS say voices with locale tags. Use for TTS voice selection.",
|
||||
{},
|
||||
handled(() => speechListVoices()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_synthesize",
|
||||
"Synthesize text to speech using macOS say command (native Apple Neural voices). Generates AIFF and 16k wav for iPhone playback. Returns file paths.",
|
||||
{
|
||||
text: z.string().min(1).max(5000).describe("Text to speak (max 5000 chars)"),
|
||||
voice: z.string().optional().describe("Voice name like Alex, Samantha, Ava, etc. Use speech_list_voices to see options"),
|
||||
rate: z.number().int().min(80).max(500).optional().describe("Speech rate in wpm, 80-500, default system"),
|
||||
},
|
||||
handled(({ text, voice, rate }) => speechSynthesize({ text, voice, rate })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_synthesize_base64",
|
||||
"Synthesize text to 16k mono WAV base64 using macOS say + afconvert. Ideal for sending to iPhone play_audio_base64. Returns full base64 payload.",
|
||||
{
|
||||
text: z.string().min(1).max(5000).describe("Text to speak"),
|
||||
voice: z.string().optional().describe("Voice name"),
|
||||
rate: z.number().int().min(80).max(500).optional(),
|
||||
},
|
||||
handled(({ text, voice, rate }) => speechSynthesizeBase64({ text, voice, rate })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_kokoro_status",
|
||||
"Check the warm Kokoro ksay TTS daemon status: loaded model, default voice, default language, and load timing.",
|
||||
{},
|
||||
handled(() => speechKokoroStatus()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_kokoro_synthesize",
|
||||
"Fast neural TTS using the warm mlx-audio Kokoro ksay daemon. Generates a 24kHz WAV and returns the local file path. Use this for low-latency local speech instead of macOS say.",
|
||||
{
|
||||
text: z.string().min(1).max(8000).describe("Text to speak."),
|
||||
voice: z.string().optional().describe("Kokoro voice preset, e.g. af_heart, af_bella, af_nova, am_adam, bf_alice. Default af_heart."),
|
||||
speed: z.number().min(0.5).max(2.0).optional().describe("Kokoro speed multiplier. Default 1.0."),
|
||||
langCode: z.string().max(8).optional().describe("Kokoro language code: a American English, b British English, j Japanese, z Mandarin, etc. Default a."),
|
||||
outputPath: z.string().optional().describe("Optional absolute output WAV path. Defaults to generated-audio/ksay-*.wav."),
|
||||
},
|
||||
handled(({ text, voice, speed, langCode, outputPath }) => speechKokoroSynthesize({ text, voice, speed, langCode, outputPath })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"speech_kokoro_synthesize_base64",
|
||||
"Fast neural TTS using the warm mlx-audio Kokoro ksay daemon. Returns full 24kHz WAV base64 for clients that need immediate audio bytes.",
|
||||
{
|
||||
text: z.string().min(1).max(3000).describe("Text to speak. Keep short for fast playback and smaller MCP payloads."),
|
||||
voice: z.string().optional().describe("Kokoro voice preset. Default af_heart."),
|
||||
speed: z.number().min(0.5).max(2.0).optional().describe("Kokoro speed multiplier. Default 1.0."),
|
||||
langCode: z.string().max(8).optional().describe("Kokoro language code. Default a."),
|
||||
},
|
||||
handled(({ text, voice, speed, langCode }) => speechKokoroSynthesizeBase64({ text, voice, speed, langCode })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"voicebox_list_profiles",
|
||||
"List Voicebox voice profiles on Mac mini (Qwen3-TTS 1.7B MPS). Includes Aiden boy voice, Jessica/Nicole girl, Adolfo cloned. Use for TTS.",
|
||||
{},
|
||||
handled(() => voiceboxListProfiles()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"voicebox_health",
|
||||
"Check Voicebox TTS health: model_loaded, gpu_available (MPS), backend mlx, version. Port 17493 Qwen3 1.7B.",
|
||||
{},
|
||||
handled(() => voiceboxHealth()),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"voicebox_generate",
|
||||
"Generate speech via Voicebox on Mac mini: text + profile (Aiden boy voice default, Jessica/Nicole girl, Adolfo cloned). Qwen3-TTS 1.7B MPS GPU. Returns audio_path, duration, engine. Use voicebox_generate_base64 for base64 audio to stream to iPhone/WiFi.",
|
||||
{
|
||||
text: z.string().min(1).max(1000).describe("Text to synthesize (max 1000 chars)."),
|
||||
profile: z.string().optional().describe("Profile name or id: Aiden (boy voice default), Adolfo (cloned), Jessica/Nicole girl, Dora, Alex. Default Aiden."),
|
||||
voice: z.string().optional().describe("Alias for profile — same as profile."),
|
||||
engine: z.string().optional().describe("Optional engine override: qwen, qwen_custom_voice, kokoro, etc."),
|
||||
language: z.string().optional().describe("Language code: en (default), es, ko, zh, etc."),
|
||||
},
|
||||
handled(({ text, profile, voice, engine, language }) => voiceboxGenerate({ text, profile, voice, engine, language })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"voicebox_generate_base64",
|
||||
"Generate speech via Voicebox and return base64 WAV audio for immediate playback on iPhone ESP32. Uses Mac mini Qwen3-TTS 1.7B boy voice Aiden by default. Ideal for instant fast reply audio: generate contextual reply with apple_llm_quick_reply, then speak it with this tool, then play_audio_base64 on ESP32 screen. Returns wavBase64 ready for play_audio_base64 MCP.",
|
||||
{
|
||||
text: z.string().min(1).max(500).describe("Text to speak (max 500 chars for fast instant reply, keep short)."),
|
||||
profile: z.string().optional().describe("Profile: Aiden (boy default for instant), Jessica/Nicole girl, Adolfo cloned. Default Aiden."),
|
||||
voice: z.string().optional().describe("Alias for profile"),
|
||||
engine: z.string().optional().describe("Engine override"),
|
||||
language: z.string().optional().describe("Language, default en"),
|
||||
},
|
||||
handled(({ text, profile, voice, engine, language }) => voiceboxGenerateBase64({ text, profile, voice, engine, language })),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"voicebox_quick_reply",
|
||||
"ONE-CALL fast instant reply audio: text -> Voicebox boy voice Aiden base64 wav. Combines quick text already generated. For voice gateway: after apple_llm_quick_reply gives text like Got it! Switching to boy voice, immediately call this with that text to get audio base64 to play on iPhone while full Hermes answer generates. Boy voice Aiden default as requested.",
|
||||
{
|
||||
text: z.string().min(1).max(300).describe("Instant reply text from apple_llm_quick_reply (max 300 chars)."),
|
||||
profile: z.string().optional().describe("Profile, default Aiden boy voice as requested for boys voice."),
|
||||
voice: z.string().optional().describe("Alias for profile"),
|
||||
},
|
||||
handled(({ text, profile, voice }) => voiceboxQuickReply({ text, voice, profile })),
|
||||
);
|
||||
|
||||
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { createMacMiniMcpServer } from "./server.js";
|
||||
|
||||
const server = createMacMiniMcpServer();
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
await server.connect(transport);
|
||||
Reference in New Issue
Block a user