import fs from "node:fs"; import path from "node:path"; import { z } from "zod"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { expandUserPath } from "../utils/path-normalization.js"; export const NEXUS_MODES = ["personal_ai", "project_builder", "both"] as const; export type NexusMode = (typeof NEXUS_MODES)[number]; export const VOICE_MODES = ["text", "voice_input", "full_voice"] as const; export type VoiceMode = (typeof VOICE_MODES)[number]; const paletteRoleSchema = z.object({ name: z.string(), dark: z.object({ oklch: z.string(), hex: z.string(), wcagAA: z.boolean() }), light: z.object({ oklch: z.string(), hex: z.string(), wcagAA: z.boolean() }), }); export const nexusSettingsSchema = z.object({ mode: z.enum(NEXUS_MODES).default("both"), voiceEnabled: z.boolean().default(false), voiceMode: z.enum(VOICE_MODES).default("text"), telegramToken: z.string().optional(), piperBinaryPath: z.string().optional(), whisperBinaryPath: z.string().optional(), customTheme: z .object({ seedHex: z.string(), palette: z.array(paletteRoleSchema), }) .optional(), }); type NexusSettings = z.infer; function resolveNexusSettingsPath(): string { return path.resolve(resolvePaperclipInstanceRoot(), "data", "nexus-settings.json"); } export function nexusSettingsService() { async function get(): Promise { const filePath = resolveNexusSettingsPath(); try { const raw = fs.readFileSync(filePath, "utf-8"); const parsed = nexusSettingsSchema.safeParse(JSON.parse(raw)); if (parsed.success) { return parsed.data; } return nexusSettingsSchema.parse({}); } catch { return nexusSettingsSchema.parse({}); } } async function set(patch: Partial): Promise { const current = await get(); // [nexus] Zero-terminal: expand ~ in user-supplied binary paths before // persisting. These are executable paths (voice pipeline binaries), so // we expand but don't mkdir — caller still has to install the binary // itself, but at least the stored path is resolvable by the server. const pathNormalizedPatch: Partial = { ...patch }; if (typeof patch.piperBinaryPath === "string" && patch.piperBinaryPath.trim().length > 0) { pathNormalizedPatch.piperBinaryPath = expandUserPath(patch.piperBinaryPath) ?? patch.piperBinaryPath; } if (typeof patch.whisperBinaryPath === "string" && patch.whisperBinaryPath.trim().length > 0) { pathNormalizedPatch.whisperBinaryPath = expandUserPath(patch.whisperBinaryPath) ?? patch.whisperBinaryPath; } const merged = { ...current, ...pathNormalizedPatch }; // Validate — will throw ZodError if invalid const validated = nexusSettingsSchema.parse(merged); const filePath = resolveNexusSettingsPath(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(validated, null, 2), "utf-8"); return validated; } return { get, set }; }