diff --git a/LifeOS/install/LIFEOS/PULSE/PULSE.toml b/LifeOS/install/LIFEOS/PULSE/PULSE.toml index 22b26132f2..e704568416 100644 --- a/LifeOS/install/LIFEOS/PULSE/PULSE.toml +++ b/LifeOS/install/LIFEOS/PULSE/PULSE.toml @@ -10,6 +10,54 @@ # ── Module Configuration ── +# [modules] is the one place to switch a Pulse surface off. Every key defaults +# to the value shown; delete a line and you get that default back. Disabling a +# module stops it loading, stops its API routes answering, and (via +# /api/config/modules) hides its tab in the dashboard instead of leaving a nav +# entry that opens an empty page. +# +# Infrastructure Pulse needs to serve anything at all — observability, hooks, +# tab-freshness, menubar, siri, doctor — is deliberately not listed: those are +# how Pulse runs, not modules you use. +# +# The older `[section].enabled` flags below still work and still win over these +# defaults, so an existing config needs no changes. +[modules] +# Life surfaces (the top nav) +telos = true +work = true +content = true +health = true +finances = true +business = true +growth = true # audience metrics; needs USER/CUSTOMIZATIONS/TOOLS/Growth.ts +local = true # LocalIntelligence civic digest; needs a Hometown set +gear = true +atlas = true +memory = true +synapse = true +projects = true +books = true +# System surfaces +algorithm = true +bunker = true +conduit = true +upgrades = true +hypotheses = true +ledger = true +usage = true +performance = true +evals = true +threatmodel = true +hermes = true +docs = true +da = true +# Opt-in surfaces (off unless you turn them on) +voice = true +imessage = false +syslog = false + + [voice] enabled = true diff --git a/LifeOS/install/LIFEOS/PULSE/lib.ts b/LifeOS/install/LIFEOS/PULSE/lib.ts index aa2f570356..b98c4244e0 100644 --- a/LifeOS/install/LIFEOS/PULSE/lib.ts +++ b/LifeOS/install/LIFEOS/PULSE/lib.ts @@ -582,3 +582,4 @@ export async function spawnClaude(prompt: string, opts: { model: string; timeout return output.trim() } +export { MODULE_DEFAULTS, resolveModules } from "./lib/modules" diff --git a/LifeOS/install/LIFEOS/PULSE/lib/modules.ts b/LifeOS/install/LIFEOS/PULSE/lib/modules.ts new file mode 100644 index 0000000000..467737ae62 --- /dev/null +++ b/LifeOS/install/LIFEOS/PULSE/lib/modules.ts @@ -0,0 +1,47 @@ +// ── Module registry ── +// Every Pulse surface a human can switch off, and the default they ship with. +// Infrastructure the daemon needs in order to serve anything at all — +// observability (the dashboard itself), hooks, tab-freshness, menubar, siri, +// doctor — is deliberately absent: those are not modules you use, they are how +// Pulse runs, and a "disabled" one would leave a dashboard that cannot render. +// +// Keys are the tab/module name. `resolveModules()` layers three sources so +// existing installs keep working untouched: these defaults, then the legacy +// per-section `[x].enabled` flags, then the `[modules]` table (which wins). +export const MODULE_DEFAULTS: Record = { + telos: true, work: true, content: true, health: true, finances: true, + business: true, growth: true, local: true, gear: true, atlas: true, + memory: true, synapse: true, books: true, conduit: true, projects: true, + ledger: true, upgrades: true, hypotheses: true, usage: true, + performance: true, bunker: true, algorithm: true, evals: true, + threatmodel: true, hermes: true, docs: true, + voice: true, imessage: false, syslog: false, da: true, +} + +// Legacy `[section].enabled` flags that predate the `[modules]` table, mapped to +// their module key. Honouring these is what keeps a pre-existing PULSE.toml +// working after an upgrade — notably `[local_intelligence]`, whose flag was +// read by loadModules() but never plumbed through loadPulseConfig(), so setting +// it to false silently did nothing. +const LEGACY_SECTION_KEYS: Record = { + local_intelligence: "local", hypotheses: "hypotheses", upgrades: "upgrades", + telos: "telos", work: "work", content: "content", bunker: "bunker", + performance: "performance", syslog: "syslog", voice: "voice", + imessage: "imessage", da: "da", +} + +/** Merge defaults ← legacy section flags ← `[modules]` table into one map. */ +export function resolveModules(parsed: Record): Record { + const modules = { ...MODULE_DEFAULTS } + for (const [section, key] of Object.entries(LEGACY_SECTION_KEYS)) { + const enabled = (parsed[section] as { enabled?: boolean } | undefined)?.enabled + if (typeof enabled === "boolean") modules[key] = enabled + } + const table = parsed.modules as Record | undefined + if (table) { + for (const [key, value] of Object.entries(table)) { + if (typeof value === "boolean") modules[key] = value + } + } + return modules +} diff --git a/LifeOS/install/LIFEOS/PULSE/pulse.ts b/LifeOS/install/LIFEOS/PULSE/pulse.ts index 8ca1242eb0..20657fcd52 100755 --- a/LifeOS/install/LIFEOS/PULSE/pulse.ts +++ b/LifeOS/install/LIFEOS/PULSE/pulse.ts @@ -67,6 +67,7 @@ import { spawnScript, spawnClaude, parseConfigToml, + resolveModules, } from "./lib" import { startHooks, handleHooksRequestAsync, hooksHealth } from "./modules/hooks" @@ -105,7 +106,7 @@ let evalsModule: any = null let hermesModule: any = null async function loadModules(config: PulseConfig) { - if (config.voice?.enabled !== false) { + if (config.modules.voice) { try { voiceModule = await import("./VoiceServer/voice") } catch (err) { @@ -120,12 +121,14 @@ async function loadModules(config: PulseConfig) { } } // Wiki module — always load (no config gate) - try { - wikiModule = await import("./modules/wiki") - } catch (err) { - log("warn", "Wiki module not available", { error: String(err) }) + if (config.modules.docs) { + try { + wikiModule = await import("./modules/wiki") + } catch (err) { + log("warn", "Wiki module not available", { error: String(err) }) + } } - if (config.imessage?.enabled) { + if (config.modules.imessage) { try { imessageModule = await import("./modules/imessage") } catch (err) { @@ -141,49 +144,49 @@ async function loadModules(config: PulseConfig) { // Assistant (DA subsystem) is a private module stripped from the public // release payload. Existence-check before importing so a fresh public install // boots cleanly and simply omits the /assistant routes. #1419. - if (config.da?.enabled && existsSync(join(PULSE_DIR, "Assistant", "module.ts"))) { + if (config.modules.da && existsSync(join(PULSE_DIR, "Assistant", "module.ts"))) { try { assistantModule = await import("./Assistant/module") } catch (err) { log("warn", "Assistant module not available", { error: String(err) }) } } - if (config.performance?.enabled !== false) { + if (config.modules.performance) { try { performanceModule = await import("./Performance/module") } catch (err) { log("warn", "Performance module not available", { error: String(err) }) } } - if (config.syslog?.enabled) { + if (config.modules.syslog) { try { syslogModule = await import("./modules/syslog") } catch (err) { log("warn", "Syslog module not available", { error: String(err) }) } } - if (config.work?.enabled !== false) { + if (config.modules.work) { try { workModule = await import("./modules/work") } catch (err) { log("warn", "Work module not available", { error: String(err) }) } } - if (config.content?.enabled !== false) { + if (config.modules.content) { try { contentModule = await import("./modules/content") } catch (err) { log("warn", "Content module not available", { error: String(err) }) } } - if (config.local_intelligence?.enabled !== false) { + if (config.modules.local) { try { localIntelligenceModule = await import("./modules/local-intelligence") } catch (err) { log("warn", "LocalIntelligence module not available", { error: String(err) }) } } - if (config.telos?.enabled !== false) { + if (config.modules.telos) { try { telosModule = await import("./modules/telos") // Without this, state.running stays false and /api/telos/health reports @@ -194,7 +197,7 @@ async function loadModules(config: PulseConfig) { log("warn", "Telos freshness module not available", { error: String(err) }) } } - if (config.hypotheses?.enabled !== false) { + if (config.modules.hypotheses) { try { hypothesesModule = await import("./modules/hypotheses") if (hypothesesModule.start) hypothesesModule.start() @@ -202,7 +205,7 @@ async function loadModules(config: PulseConfig) { log("warn", "Hypotheses module not available", { error: String(err) }) } } - if (config.upgrades?.enabled !== false) { + if (config.modules.upgrades) { try { upgradesModule = await import("./modules/upgrades") if (upgradesModule.start) upgradesModule.start() @@ -217,18 +220,22 @@ async function loadModules(config: PulseConfig) { log("warn", "Tab freshness module not available", { error: String(err) }) } // Memory — autonomic-memory subsystem state surface (always loaded). - try { - memoryModule = await import("./modules/memory") - if (memoryModule.start) memoryModule.start() - } catch (err) { - log("warn", "Memory module not available", { error: String(err) }) + if (config.modules.memory) { + try { + memoryModule = await import("./modules/memory") + if (memoryModule.start) memoryModule.start() + } catch (err) { + log("warn", "Memory module not available", { error: String(err) }) + } } // Conduit — sensory layer daily-record surface (read-only; capture is launchd). - try { - conduitModule = await import("./modules/conduit") - if (conduitModule.start) conduitModule.start() - } catch (err) { - log("warn", "Conduit module not available", { error: String(err) }) + if (config.modules.conduit) { + try { + conduitModule = await import("./modules/conduit") + if (conduitModule.start) conduitModule.start() + } catch (err) { + log("warn", "Conduit module not available", { error: String(err) }) + } } // Menu bar — cross-subsystem aggregator behind the rich native menu bar dropdown. try { @@ -238,64 +245,80 @@ async function loadModules(config: PulseConfig) { log("warn", "Menubar module not available", { error: String(err) }) } // Books — favorite-books surface over USER/BOOKS.md. - try { - booksModule = await import("./modules/books") - if (booksModule.start) booksModule.start() - } catch (err) { - log("warn", "Books module not available", { error: String(err) }) + if (config.modules.books) { + try { + booksModule = await import("./modules/books") + if (booksModule.start) booksModule.start() + } catch (err) { + log("warn", "Books module not available", { error: String(err) }) + } } // Synapse — input routing & capture surface (ledger, knowledge, bookmarks, flows). - try { - synapseModule = await import("./modules/synapse") - if (synapseModule.start) synapseModule.start() - } catch (err) { - log("warn", "Synapse module not available", { error: String(err) }) + if (config.modules.synapse) { + try { + synapseModule = await import("./modules/synapse") + if (synapseModule.start) synapseModule.start() + } catch (err) { + log("warn", "Synapse module not available", { error: String(err) }) + } } // Ledger — change-tracking surface (versions, update registry, deploys, integrity, drift). - try { - ledgerModule = await import("./modules/ledger") - if (ledgerModule.start) ledgerModule.start() - } catch (err) { - log("warn", "Ledger module not available", { error: String(err) }) + if (config.modules.ledger) { + try { + ledgerModule = await import("./modules/ledger") + if (ledgerModule.start) ledgerModule.start() + } catch (err) { + log("warn", "Ledger module not available", { error: String(err) }) + } } // Projects — project routing-table surface over USER/PROJECTS.md. - try { - projectsModule = await import("./modules/projects") - if (projectsModule.start) await projectsModule.start() - } catch (err) { - log("warn", "Projects module not available", { error: String(err) }) + if (config.modules.projects) { + try { + projectsModule = await import("./modules/projects") + if (projectsModule.start) await projectsModule.start() + } catch (err) { + log("warn", "Projects module not available", { error: String(err) }) + } } // Assets — unified read-only inventory over USER/GEAR.md + network topology. - try { - assetsModule = await import("./modules/assets") - if (assetsModule.start) await assetsModule.start() - } catch (err) { - log("warn", "Assets module not available", { error: String(err) }) + if (config.modules.gear) { + try { + assetsModule = await import("./modules/assets") + if (assetsModule.start) await assetsModule.start() + } catch (err) { + log("warn", "Assets module not available", { error: String(err) }) + } } // Atlas — read-only surface over the asset-graph snapshot (LIFEOS/ATLAS). - try { - atlasModule = await import("./modules/atlas") - if (atlasModule.start) atlasModule.start() - } catch (err) { - log("warn", "Atlas module not available", { error: String(err) }) + if (config.modules.atlas) { + try { + atlasModule = await import("./modules/atlas") + if (atlasModule.start) atlasModule.start() + } catch (err) { + log("warn", "Atlas module not available", { error: String(err) }) + } } // ThreatModel — read-only surface over the private risk register // (skills/ThreatModel; data in LIFEOS/USER/SECURITY/THREATMODEL). - try { - threatModelModule = await import("./modules/threatmodel") - if (threatModelModule.start) threatModelModule.start() - } catch (err) { - log("warn", "ThreatModel module not available", { error: String(err) }) + if (config.modules.threatmodel) { + try { + threatModelModule = await import("./modules/threatmodel") + if (threatModelModule.start) threatModelModule.start() + } catch (err) { + log("warn", "ThreatModel module not available", { error: String(err) }) + } } // Usage — Anthropic subscription + durable token/cost/model usage surface. - try { - usageModule = await import("./modules/usage") - if (usageModule.start) await usageModule.start() - } catch (err) { - log("warn", "Usage module not available", { error: String(err) }) + if (config.modules.usage) { + try { + usageModule = await import("./modules/usage") + if (usageModule.start) await usageModule.start() + } catch (err) { + log("warn", "Usage module not available", { error: String(err) }) + } } // Bunker — application-harness registry surface (reads ~/.claude/LIFEOS/PULSE/Bunker via its CLI). - if (config.bunker?.enabled !== false) { + if (config.modules.bunker) { try { bunkerModule = await import("./modules/bunker") } catch (err) { @@ -312,25 +335,31 @@ async function loadModules(config: PulseConfig) { } // Hermes — the sidecar's core files: SOUL, config, guard policy, and the code // that generates them. Read/edit surface behind the Assistant tab. - try { - hermesModule = await import("./modules/hermes") - if (hermesModule.start) hermesModule.start() - } catch (err) { - log("warn", "Hermes module not available", { error: String(err) }) + if (config.modules.hermes) { + try { + hermesModule = await import("./modules/hermes") + if (hermesModule.start) hermesModule.start() + } catch (err) { + log("warn", "Hermes module not available", { error: String(err) }) + } } // Algorithm — the thinking chain surface: doctrine (versioned edits), rules // files, AI-generated workflow summary for the /algorithm tab. - try { - algorithmTabModule = await import("./modules/algorithm-tab") - if (algorithmTabModule.start) algorithmTabModule.start() - } catch (err) { - log("warn", "AlgorithmTab module not available", { error: String(err) }) + if (config.modules.algorithm) { + try { + algorithmTabModule = await import("./modules/algorithm-tab") + if (algorithmTabModule.start) algorithmTabModule.start() + } catch (err) { + log("warn", "AlgorithmTab module not available", { error: String(err) }) + } } // Evals — standing eval-suite status (pass^k, regressions) for the /algorithm tab. - try { - evalsModule = await import("./modules/evals") - } catch (err) { - log("warn", "Evals module not available", { error: String(err) }) + if (config.modules.evals) { + try { + evalsModule = await import("./modules/evals") + } catch (err) { + log("warn", "Evals module not available", { error: String(err) }) + } } } @@ -338,6 +367,8 @@ async function loadModules(config: PulseConfig) { interface PulseConfig { port: number + /** Resolved on/off state for every switchable surface. See MODULE_DEFAULTS. */ + modules: Record tls?: { enabled: boolean; cert: string; key: string } // unused — TLS removed voice?: { enabled: boolean; [key: string]: unknown } imessage?: { enabled: boolean; [key: string]: unknown } @@ -389,6 +420,7 @@ async function loadPulseConfig(): Promise { return { port: (parsed.port as number) ?? parseInt(process.env.PULSE_PORT || "31337", 10), + modules: resolveModules(parsed), tls: (parsed.tls as PulseConfig["tls"]) ?? undefined, voice: (parsed.voice as PulseConfig["voice"]) ?? { enabled: true }, imessage: (parsed.imessage as PulseConfig["imessage"]) ?? { enabled: false }, @@ -740,6 +772,13 @@ async function main() { return buildHealthResponse(state, config) } + // Which surfaces are switched on. The dashboard reads this to avoid + // rendering a tab whose backend was never loaded — without it, disabling a + // module leaves a nav entry that opens an empty page. + if (req.method === "GET" && pathname === "/api/config/modules") { + return Response.json({ modules: config.modules }) + } + // Voice routes: /notify, /notify/personality, /voice, /voice/health // (/voice/health is implemented and advertised by the module but was never // forwarded, so it 404'd — public PR #1621, @elhoim) @@ -925,6 +964,20 @@ async function main() { if (resp) return resp } + // The HEALTH / FINANCES / BUSINESS / GROWTH surfaces have no module of + // their own — observability.ts serves them directly — so switching them + // off has to happen here, at the route, rather than at module load. + const LIFE_ROUTE_MODULES: Record = { + "/api/life/health": "health", + "/api/life/finances": "finances", + "/api/life/business": "business", + "/api/life/growth": "growth", + } + const lifeModule = LIFE_ROUTE_MODULES[pathname] + if (lifeModule && !config.modules[lifeModule]) { + return Response.json({ error: "module disabled", module: lifeModule }, { status: 404 }) + } + // Observability routes: /api/*, /dashboard/* if (observabilityModule && (pathname.startsWith("/api/") || pathname.startsWith("/dashboard") || pathname.startsWith("/_next/") || pathname === "/favicon.ico")) { const resp = await observabilityModule.handleObservabilityRequest(req, pathname)