Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ import { TabFreshnessPill } from "@/components/TabFreshnessPill";
// meta view of the system working on itself, never part of the scrolling row.
// SYSTEM is the mode-switch into the machine plane (lands on systemHome).
import { tier1Nav, systemNav, metaNav, systemHome } from "@/lib/palette/nav-manifest";
import { useEnabledModules } from "@/lib/use-enabled-modules";

const systemPaths = [...systemNav.map((i) => i.href), "/system"];

export default function AppHeader() {
const pathname = usePathname();
// A tab whose module is switched off in PULSE.toml would open a page with no
// backend answering it, so drop it from both rows and the mobile menu.
const isEnabled = useEnabledModules();
const tier1 = tier1Nav.filter(isEnabled);
const system = systemNav.filter(isEnabled);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const { observerMode, toggleObserverMode } = useObserverMode();
Expand Down Expand Up @@ -81,7 +87,7 @@ export default function AppHeader() {
<nav
className="hidden md:flex flex-wrap flex-1 items-center justify-start xl:justify-center gap-1 gap-y-1 min-w-0"
>
{tier1Nav.map((item) => {
{tier1.map((item) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
Expand Down Expand Up @@ -174,7 +180,7 @@ export default function AppHeader() {
System
</span>
<nav className="flex flex-wrap flex-1 items-center justify-start gap-1 gap-y-1.5 min-w-0">
{systemNav.map((item) => {
{system.map((item) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
Expand Down Expand Up @@ -204,7 +210,7 @@ export default function AppHeader() {
<div ref={mobileMenuRef} className="md:hidden border-b border-line-1 backdrop-blur-md" style={{ background: "rgba(6, 11, 26, 0.95)" }}>
<nav className="flex flex-col px-4 py-3 gap-1">
<div className="text-xs uppercase tracking-wider text-ink-3 px-3 py-1">Sections</div>
{tier1Nav.map((item) => {
{tier1.map((item) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
Expand All @@ -230,7 +236,7 @@ export default function AppHeader() {
);
})}
<div className="text-xs uppercase tracking-wider text-ink-3 px-3 py-1 mt-2">System</div>
{systemNav.map((item) => {
{system.map((item) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "lucide-react";
import { wikiPageUrl } from "@/lib/wiki-links";
import { paletteEntries, type NavItem } from "@/lib/palette/nav-manifest";
import { useEnabledModules } from "@/lib/use-enabled-modules";
import { fuzzyScore } from "@/lib/palette/fuzzy";
import { recordSelection, frecencyBoost, topRecents, loadStore } from "@/lib/palette/frecency";
import { PALETTE_OPEN_EVENT, type PaletteScope } from "@/lib/palette/events";
Expand Down Expand Up @@ -155,22 +156,27 @@ export default function CommandPalette() {
};
}, [open, query, scope]);

// Jumping to a page whose module is switched off lands on an empty view, so
// the palette searches the same filtered set the nav renders.
const isEnabled = useEnabledModules();
const entries = useMemo(() => paletteEntries.filter(isEnabled), [isEnabled]);

// Local lane + merge.
const rows: Row[] = useMemo(() => {
const out: Row[] = [];
const q = query.trim();
if (scope !== "wiki") {
if (!q) {
const recents = topRecents(6)
.map((id) => paletteEntries.find((e) => e.href === id))
.map((id) => entries.find((e) => e.href === id))
.filter((e): e is NavItem => Boolean(e));
recents.forEach((entry) => out.push({ kind: "page", entry, recent: true }));
paletteEntries
entries
.filter((e) => !recents.includes(e))
.forEach((entry) => out.push({ kind: "page", entry }));
} else {
const store = loadStore();
paletteEntries
entries
.map((entry) => ({
entry,
score: fuzzyScore(q, entry.label, entry.keywords) + frecencyBoost(entry.href, store),
Expand All @@ -185,7 +191,7 @@ export default function CommandPalette() {
wikiResults.forEach((result) => out.push({ kind: "wiki", result }));
}
return out;
}, [query, scope, wikiResults]);
}, [query, scope, wikiResults, entries]);

useEffect(() => {
setSelectedIndex(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,30 @@ export interface NavItem {
icon: LucideIcon;
/** Palette-only match aliases; the nav bar ignores this field. */
keywords?: string[];
/**
* PULSE.toml [modules] key backing this page. When that module is switched
* off its tab disappears from the nav and the palette instead of opening a
* page with no data behind it. Entries with no key are always shown — either
* they are infrastructure, or Pulse cannot serve the dashboard without them.
*/
module?: string;
}

// ── Tier 1 — the persistent global nav (Life sections + System home).
// This is the ONLY always-visible menu. Everything else is contextual.
export const tier1Nav: NavItem[] = [
{ href: "/telos", label: "TELOS", icon: Target, keywords: ["goals", "mission", "problems"] },
{ href: "/work", label: "WORK", icon: FolderKanban, keywords: ["kanban", "sessions", "tasks", "projects", "repos", "sites"] },
{ href: "/content", label: "CONTENT", icon: Clapperboard, keywords: ["videos", "pipeline", "conveyor"] },
{ href: "/health", label: "HEALTH", icon: Activity, keywords: ["sleep", "fitness"] },
{ href: "/finances", label: "FINANCES", icon: DollarSign, keywords: ["money", "burn", "expenses", "revenue"] },
{ href: "/business", label: "BUSINESS", icon: Briefcase, keywords: ["company", "newsletter"] },
{ href: "/growth", label: "GROWTH", icon: TrendingUp, keywords: ["metrics", "subscribers", "audience"] },
{ href: "/local", label: "LOCAL", icon: MapPin, keywords: ["civic", "crime", "city"] },
{ href: "/gear", label: "GEAR", icon: Boxes, keywords: ["assets", "inventory", "own", "stuff"] },
{ href: "/atlas", label: "ATLAS", icon: Waypoints, keywords: ["graph", "assets", "estate", "infrastructure", "blast radius", "domains", "workers", "current state"] },
{ href: "/memory", label: "CORTEX", icon: Brain, keywords: ["memory", "knowledge", "wiki", "notes", "archive", "graph"] },
{ href: "/synapse", label: "SYNAPSE", icon: Share2, keywords: ["ideas", "capture", "router", "amber"] },
{ href: "/telos", label: "TELOS", icon: Target, keywords: ["goals", "mission", "problems"], module: "telos" },
{ href: "/work", label: "WORK", icon: FolderKanban, keywords: ["kanban", "sessions", "tasks", "projects", "repos", "sites"], module: "work" },
{ href: "/content", label: "CONTENT", icon: Clapperboard, keywords: ["videos", "pipeline", "conveyor"], module: "content" },
{ href: "/health", label: "HEALTH", icon: Activity, keywords: ["sleep", "fitness"], module: "health" },
{ href: "/finances", label: "FINANCES", icon: DollarSign, keywords: ["money", "burn", "expenses", "revenue"], module: "finances" },
{ href: "/business", label: "BUSINESS", icon: Briefcase, keywords: ["company", "newsletter"], module: "business" },
{ href: "/growth", label: "GROWTH", icon: TrendingUp, keywords: ["metrics", "subscribers", "audience"], module: "growth" },
{ href: "/local", label: "LOCAL", icon: MapPin, keywords: ["civic", "crime", "city"], module: "local" },
{ href: "/gear", label: "GEAR", icon: Boxes, keywords: ["assets", "inventory", "own", "stuff"], module: "gear" },
{ href: "/atlas", label: "ATLAS", icon: Waypoints, keywords: ["graph", "assets", "estate", "infrastructure", "blast radius", "domains", "workers", "current state"], module: "atlas" },
{ href: "/memory", label: "CORTEX", icon: Brain, keywords: ["memory", "knowledge", "wiki", "notes", "archive", "graph"], module: "memory" },
{ href: "/synapse", label: "SYNAPSE", icon: Share2, keywords: ["ideas", "capture", "router", "amber"], module: "synapse" },
];

// ── Meta — pinned in the header's right cluster on EVERY page, outside both
Expand All @@ -70,19 +77,19 @@ export const systemHome = "/assistant";
// ── Tier 2 — contextual. These machine pages render as a second row ONLY when
// you are inside System. They used to be a permanent second global menu.
export const systemNav: NavItem[] = [
{ href: "/assistant", label: "Assistant", icon: Bot, keywords: ["chat", "ask"] },
{ href: "/bunker", label: "Bunker", icon: Container, keywords: ["apps", "chassis"] },
{ href: "/algorithm", label: "Algorithm", icon: Workflow, keywords: ["thinking", "doctrine", "rules", "loop"] },
{ href: "/assistant", label: "Assistant", icon: Bot, keywords: ["chat", "ask"], module: "da" },
{ href: "/bunker", label: "Bunker", icon: Container, keywords: ["apps", "chassis"], module: "bunker" },
{ href: "/algorithm", label: "Algorithm", icon: Workflow, keywords: ["thinking", "doctrine", "rules", "loop"], module: "algorithm" },
{ href: "/skills", label: "Skills", icon: Zap },
{ href: "/hooks", label: "Hooks", icon: Webhook },
{ href: "/conduit", label: "Conduit", icon: Radar, keywords: ["sensors"] },
{ href: "/upgrades", label: "Upgrades", icon: Sparkles, keywords: ["hypotheses", "recommendations", "improvements", "directives", "proposals"] },
{ href: "/conduit", label: "Conduit", icon: Radar, keywords: ["sensors"], module: "conduit" },
{ href: "/upgrades", label: "Upgrades", icon: Sparkles, keywords: ["hypotheses", "recommendations", "improvements", "directives", "proposals"], module: "upgrades" },
{ href: "/arbol", label: "Arbol", icon: TreePine, keywords: ["workers", "pipeline"] },
{ href: "/security", label: "Security", icon: ShieldCheck, keywords: ["monitoring"] },
{ href: "/ledger", label: "Ledger", icon: ScrollText, keywords: ["versions", "updates", "deploys", "drift", "integrity", "registry", "changelog"] },
{ href: "/performance", label: "Perf", icon: BarChart3, keywords: ["performance", "latency"] },
{ href: "/usage", label: "Usage", icon: Gauge, keywords: ["tokens", "cost"] },
{ href: "/docs", label: "Docs", icon: BookOpen, keywords: ["documentation", "wiki"] },
{ href: "/ledger", label: "Ledger", icon: ScrollText, keywords: ["versions", "updates", "deploys", "drift", "integrity", "registry", "changelog"], module: "ledger" },
{ href: "/performance", label: "Perf", icon: BarChart3, keywords: ["performance", "latency"], module: "performance" },
{ href: "/usage", label: "Usage", icon: Gauge, keywords: ["tokens", "cost"], module: "usage" },
{ href: "/docs", label: "Docs", icon: BookOpen, keywords: ["documentation", "wiki"], module: "docs" },
];

const homeEntry: NavItem = { href: "/", label: "Home", icon: Home, keywords: ["dashboard", "pulse"] };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";
import { useCallback, useEffect, useState } from "react";

/**
* Reads the resolved PULSE.toml [modules] map from the daemon and returns a
* predicate for filtering nav entries.
*
* Fails open on purpose. Until the fetch lands — and permanently if it errors,
* or if the daemon predates /api/config/modules — every entry stays visible.
* A dashboard briefly showing a tab it will hide a moment later is a much
* smaller problem than a nav that empties itself because one request failed.
*/
export function useEnabledModules(): (item: { module?: string }) => boolean {
const [modules, setModules] = useState<Record<string, boolean> | null>(null);

useEffect(() => {
let cancelled = false;
fetch("/api/config/modules")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!cancelled && data?.modules) setModules(data.modules);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);

// Stable identity while `modules` is unchanged, so callers can memoise the
// filtered list on this predicate instead of rebuilding it every render.
return useCallback(
(item: { module?: string }) => {
if (!modules || !item.module) return true;
return modules[item.module] !== false;
},
[modules],
);
}
48 changes: 48 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/PULSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions LifeOS/install/LIFEOS/PULSE/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,3 +582,4 @@ export async function spawnClaude(prompt: string, opts: { model: string; timeout

return output.trim()
}
export { MODULE_DEFAULTS, resolveModules } from "./lib/modules"
47 changes: 47 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/lib/modules.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean> = {
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<string, string> = {
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<string, unknown>): Record<string, boolean> {
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<string, unknown> | undefined
if (table) {
for (const [key, value] of Object.entries(table)) {
if (typeof value === "boolean") modules[key] = value
}
}
return modules
}
Loading