diff --git a/LifeOS/install/LIFEOS/DOCUMENTATION/Pulse/PulseSystem.md b/LifeOS/install/LIFEOS/DOCUMENTATION/Pulse/PulseSystem.md index 2875d9e9f2..5558ef649b 100644 --- a/LifeOS/install/LIFEOS/DOCUMENTATION/Pulse/PulseSystem.md +++ b/LifeOS/install/LIFEOS/DOCUMENTATION/Pulse/PulseSystem.md @@ -781,7 +781,7 @@ Every Pulse page is two halves: a **data module** in the Bun server and a **stat 2. **Wire into `pulse.ts`** — module variable + `loadModules()` import/start + a route block `if (xModule && pathname.startsWith("/api/x"))` BEFORE the observability `/api/*` catch-all (mirror the books/synapse blocks). 3. **Page** — `Observability/src/app//page.tsx`, a `"use client"` component that fetches `/api/` and holds ZERO data. House style (unified design system, 2026-07-11): build the page from the chrome kit in `src/components/ui/chrome.tsx` — `` + `` (serif title + subtitle), `Panel`/`PanelHeader` for cards, `StatTile` for numbers, `TabBar`/`Pill` for switchers and chips. Colors come ONLY from the global tokens in `globals.css` (`--ground/--surface-1..3/--line-1..3/--ink-1..3/--accent-blue/--ok/--warn/--err` + the six dimension vars; Tailwind utilities `bg-surface-2`, `border-line-2`, `text-ink-2`, `text-dim-health`, …). No page defines its own palette; no slate/zinc/hex chrome. Token opacity modifiers (`bg-surface-2/50`) don't work — use solid classes or rgba(). 4. **Nav** — add to `src/lib/palette/nav-manifest.ts` (single source of truth for AppHeader AND the ⌘K palette): `tier1Nav` for Life sections, `systemNav` for machine pages (contextual second row, shows only inside System), `metaNav` for always-pinned meta tabs (currently AGENTS, pinned in the header's right cluster on every page; SYSTEM pinned tab lands on `systemHome` = `/assistant`). Rows `flex-wrap` — items roll to additional lines when they overflow; never clip behind hidden scroll. -5. **Freshness** — register the tab's data sources in `modules/tab-freshness.ts` REGISTRY so the freshness pill resolves. +5. **Freshness** — register the tab's data sources in `modules/tab-freshness.ts` REGISTRY so the freshness pill resolves; a machine-written source with a known cadence (a ledger a job rewrites) declares `expectedEveryMinutes` so the pill judges it against that cadence instead of the day scale. 6. **Deploy** — `bun run build` in Observability, then restart Pulse (Deployment Procedure above) to load the new module. Note: static catalogs baked into a page or module (e.g. Synapse's 11-input list mirroring `SynapseSystem.md` §Inputs) are manually synced — when the source doc gains an entry, the page needs the matching edit. diff --git a/LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.test.ts b/LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.test.ts new file mode 100644 index 0000000000..9ab9ef7f04 --- /dev/null +++ b/LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.test.ts @@ -0,0 +1,134 @@ +// Freshness of machine-written ledgers: the .jsonl expansion, cadence-aware +// tiers, and the unchanged day-scale behaviour for everything else. +// +// Fixtures are synthetic files in a temp tree whose mtimes are set with +// utimesSync (no sleeps). The module roots its registry at HOME when it is +// imported, so HOME is pointed at the temp tree before the dynamic import and +// the registry-level test below never reads a real install. + +import { test, expect, afterAll } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" + +const root = mkdtempSync(join(tmpdir(), "tab-freshness-")) +const home = join(root, "home") +const realHome = process.env.HOME +process.env.HOME = home +const { computeFreshness, handleRequest, invalidate } = await import("./tab-freshness") + +afterAll(() => { + process.env.HOME = realHome + rmSync(root, { recursive: true, force: true }) +}) + +const MINUTE = 60_000 +const LEDGER_ROW = JSON.stringify({ sessionId: "synthetic-0001" }) + "\n" +let cases = 0 + +function scratchDir(): string { + const dir = join(root, `case-${cases++}`) + mkdirSync(dir, { recursive: true }) + return dir +} + +/** Writes a synthetic file whose mtime is `ageMinutes` in the past. */ +function writeAged(path: string, ageMinutes: number, content = "{}\n"): string { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + const t = new Date(Date.now() - ageMinutes * MINUTE) + utimesSync(path, t, t) + return path +} + +async function freshnessOf(tab: string) { + invalidate() + const res = await handleRequest(new Request(`http://localhost/api/tab-freshness?tab=${tab}`), "/api/tab-freshness") + return res!.json() +} + +test("a ledger 14 hours behind a 15-minute cadence is stale even beside a fresh .json", () => { + const dir = scratchDir() + writeAged(join(dir, "unrelated.json"), 0) + const ledger = writeAged(join(dir, "session-costs.jsonl"), 14 * 60, LEDGER_ROW) + const payload = computeFreshness("performance", [ + { name: "OBSERVABILITY/", path: dir, expand: true }, + { name: "session-costs.jsonl", path: ledger, expectedEveryMinutes: 15 }, + ]) + expect(payload.tier).toBe("stale") + expect(payload.daysOld).toBe(0) + expect(payload.ageMinutes).toBeGreaterThanOrEqual(840) + expect(payload.ageMinutes).toBeLessThan(850) + expect(payload.expectedEveryMinutes).toBe(15) + expect(payload.label).toBe("session-costs.jsonl written 14 h ago, expected every 15 min") + // listed explicitly and reached through the expansion: counted once, with the cadence + const entries = payload.perFile.filter((p) => p.name.endsWith("session-costs.jsonl")) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ name: "session-costs.jsonl", source: "mtime", expectedEveryMinutes: 15 }) + expect(entries[0].ageMinutes).toBeGreaterThanOrEqual(840) +}) + +test("sources without a declared cadence keep the day scale, and .jsonl now enters the expansion", () => { + const dir = scratchDir() + writeAged(join(dir, "unrelated.json"), 0) + writeAged(join(dir, "session-costs.jsonl"), 14 * 60, LEDGER_ROW) + const payload = computeFreshness("performance", [{ name: "OBSERVABILITY/", path: dir, expand: true }]) + expect(payload.tier).toBe("fresh") + expect(payload.daysOld).toBe(0) + expect(payload.label).toBe("2 of 2 sources dated") + expect(payload.ageMinutes).toBeUndefined() + expect(payload.expectedEveryMinutes).toBeUndefined() + expect(payload.perFile.map((p) => p.name).sort()).toEqual(["OBSERVABILITY/session-costs.jsonl", "OBSERVABILITY/unrelated.json"]) + expect(payload.perFile.every((p) => p.ageMinutes === undefined)).toBe(true) +}) + +test.each([ + [30, "fresh"], + [150, "aging"], + [151, "stale"], +] as const)("a 15-minute source written %i minutes ago is %s", (age, tier) => { + const file = writeAged(join(scratchDir(), "heartbeat.json"), age) + expect(computeFreshness("t", [{ name: "heartbeat.json", path: file, expectedEveryMinutes: 15 }]).tier).toBe(tier) +}) + +test("a cadence source on time still carries the day-scale verdict", () => { + const file = writeAged(join(scratchDir(), "weekly.json"), 8 * 24 * 60) + const payload = computeFreshness("t", [{ name: "weekly.json", path: file, expectedEveryMinutes: 7 * 24 * 60 }]) + expect(payload.tier).toBe("aging") + expect(payload.daysOld).toBe(8) + expect(payload.label).toBe("1 of 1 sources dated") + expect(payload.expectedEveryMinutes).toBe(7 * 24 * 60) +}) + +test("an absent cadence source leaves the verdict to the sources on disk", () => { + const dir = scratchDir() + writeAged(join(dir, "notes.json"), 0) + const payload = computeFreshness("t", [ + { name: "notes.json", path: join(dir, "notes.json") }, + { name: "missing.jsonl", path: join(dir, "missing.jsonl"), expectedEveryMinutes: 15 }, + ]) + expect(payload.tier).toBe("fresh") + expect(payload.ageMinutes).toBeUndefined() + expect(payload.perFile).toContainEqual({ name: "missing.jsonl", date: null, source: "unknown" }) +}) + +test("the performance tab judges the cost-aggregation heartbeat against its 15-minute cadence", async () => { + const lifeos = join(home, ".claude", "LIFEOS") + writeAged(join(lifeos, "MEMORY", "OBSERVABILITY", "unrelated.json"), 0) + writeAged(join(lifeos, "MEMORY", "OBSERVABILITY", "session-costs.jsonl"), 14 * 60, LEDGER_ROW) + const heartbeat = join(lifeos, "PULSE", "Performance", "aggregator-state.json") + const state = JSON.stringify({ lastScanMs: 0, sessionsProcessed: 0 }) + + writeAged(heartbeat, 14 * 60, state) + const stale = await freshnessOf("performance") + expect(stale.tier).toBe("stale") + expect(stale.daysOld).toBe(0) + expect(stale.expectedEveryMinutes).toBe(15) + expect(stale.ageMinutes).toBeGreaterThanOrEqual(840) + expect(stale.perFile.map((p: { name: string }) => p.name)).toContain("MEMORY/OBSERVABILITY/session-costs.jsonl") + + writeAged(heartbeat, 1, state) + const fresh = await freshnessOf("performance") + expect(fresh.tier).toBe("fresh") + expect(fresh.label).toBe("3 of 4 sources dated") +}) diff --git a/LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.ts b/LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.ts index e0c593bbae..3f8c113184 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.ts @@ -17,10 +17,16 @@ * label: "TELOS context", * daysOld: 0, * tier: "fresh" | "aging" | "stale" | "unknown", - * perFile: [{ name, date, source }], + * perFile: [{ name, date, source, ageMinutes?, expectedEveryMinutes? }], + * ageMinutes?: 840, // present when a source declares a cadence: + * expectedEveryMinutes?: 15, // the source furthest behind its cadence * } * - * Tier mapping: ≤7d fresh · ≤30d aging · >30d stale · null unknown. + * Tier mapping: ≤7d fresh · ≤30d aging · >30d stale · null unknown, measured on + * the most recent source. A source that declares `expectedEveryMinutes` (a + * machine-written ledger with a known cadence) is also measured in minutes + * against that cadence (≤2× fresh · ≤10× aging · >10× stale), and the worse of + * the two verdicts is the tab's tier. * * Unregistered tabs return `tier: "unknown"` with HTTP 200 — never 404, so * the client pill stays visible across all routes. @@ -39,16 +45,24 @@ const TELOS_DIR = join(USER_DIR, "TELOS") // // Each entry lists files (or directories) whose mtime contributes to the // tab's freshness. Globs are expanded shallowly: a directory entry contributes -// every direct *.md file. Missing files are silently ignored — the tab can -// still render `unknown` rather than 500. +// every direct *.md, *.json, *.jsonl, *.yaml and *.yml file. Missing files are +// silently ignored — the tab can still render `unknown` rather than 500. -interface SourceSpec { +export interface SourceSpec { /** Display name shown in tooltip (short — under 30 chars). */ name: string /** Absolute path on disk. */ path: string - /** When `true`, expand to direct *.md children at runtime. */ + /** When `true`, expand to direct *.md / *.json / *.jsonl / *.yaml / *.yml children at runtime. */ expand?: boolean + /** + * Minutes between writes of a machine-written source (a ledger a job rewrites, + * a job's state file). When set, the source's age is also judged against this + * cadence via `tierFromCadence`. Applies to file entries, not to the children + * of an expanded directory; a path listed here and again through an expansion + * counts once, keeping the cadence. + */ + expectedEveryMinutes?: number } const REGISTRY: Record = { @@ -109,6 +123,13 @@ const REGISTRY: Record = { ], performance: [ { name: "MEMORY/OBSERVABILITY/", path: join(LIFEOS_DIR, "MEMORY", "OBSERVABILITY"), expand: true }, + // The tab's ledgers are machine-written, so they are also judged against the cadence of + // their writers. The cost-aggregation job (PULSE.toml, every 15 min) appends to + // session-costs.jsonl only when a new session appears but rewrites its state file on + // every run, so that file is the job's heartbeat; usage-daily.jsonl is rebuilt nightly by + // the opt-in com.lifeos.usage-aggregator launchd job and is absent until that is installed. + { name: "Performance/aggregator-state.json", path: join(LIFEOS_DIR, "PULSE", "Performance", "aggregator-state.json"), expectedEveryMinutes: 15 }, + { name: "OBSERVABILITY/usage-daily.jsonl", path: join(LIFEOS_DIR, "MEMORY", "OBSERVABILITY", "usage-daily.jsonl"), expectedEveryMinutes: 24 * 60 }, ], synapse: [ { name: "KNOWLEDGE/Ideas/", path: join(LIFEOS_DIR, "MEMORY", "KNOWLEDGE", "Ideas"), expand: true }, @@ -158,6 +179,7 @@ interface ResolvedSource { path: string exists: boolean mtime: Date | null + expectedEveryMinutes?: number } function resolveSpec(spec: SourceSpec): ResolvedSource[] { @@ -170,7 +192,7 @@ function resolveSpec(spec: SourceSpec): ResolvedSource[] { let entries: string[] = [] try { // ported from public PR #1741, @elhoim — `.yml` is as valid a YAML suffix as `.yaml` - entries = readdirSync(spec.path).filter((e) => e.endsWith(".md") || e.endsWith(".json") || e.endsWith(".yaml") || e.endsWith(".yml")) + entries = readdirSync(spec.path).filter((e) => e.endsWith(".md") || e.endsWith(".json") || e.endsWith(".jsonl") || e.endsWith(".yaml") || e.endsWith(".yml")) } catch { // unreadable dir — record as missing return [{ name: spec.name, path: spec.path, exists: false, mtime: null }] @@ -192,7 +214,7 @@ function resolveSpec(spec: SourceSpec): ResolvedSource[] { } return out } - return [{ name: spec.name, path: spec.path, exists: true, mtime: stat.mtime }] + return [{ name: spec.name, path: spec.path, exists: true, mtime: stat.mtime, expectedEveryMinutes: spec.expectedEveryMinutes }] } // Pull `last_reviewed` (preferred) or `last_updated` from a markdown file's @@ -229,6 +251,22 @@ function tierFromDays(daysOld: number | null): "fresh" | "aging" | "stale" | "un return "stale" } +// A machine-written source is judged against its own cadence: within twice the +// cadence it is on time, within ten times it is lagging, beyond that it stopped. +function tierFromCadence(ageMinutes: number, expectedEveryMinutes: number): "fresh" | "aging" | "stale" { + if (ageMinutes <= 2 * expectedEveryMinutes) return "fresh" + if (ageMinutes <= 10 * expectedEveryMinutes) return "aging" + return "stale" +} + +const TIER_RANK: Record = { unknown: 0, fresh: 1, aging: 2, stale: 3 } + +function formatMinutes(minutes: number): string { + if (minutes < 60) return `${minutes} min` + if (minutes < 1440) return `${Math.round(minutes / 60)} h` + return `${Math.round(minutes / 1440)} d` +} + function isoDate(d: Date): string { return d.toISOString().slice(0, 10) } @@ -237,15 +275,21 @@ interface FreshnessFilePayload { name: string date: string | null source: "state" | "content" | "filename" | "mtime" | "unknown" + /** Minutes since the last write and the declared cadence; only on sources that declare one. */ + ageMinutes?: number + expectedEveryMinutes?: number } -interface FreshnessPayload { +export interface FreshnessPayload { tabId: string dataDate: string | null label: string daysOld: number | null tier: "fresh" | "aging" | "stale" | "unknown" perFile: FreshnessFilePayload[] + /** The cadence source furthest behind its cadence; only when the tab declares one that exists. */ + ageMinutes?: number + expectedEveryMinutes?: number } function computeTabFreshness(tabId: string): FreshnessPayload { @@ -260,11 +304,24 @@ function computeTabFreshness(tabId: string): FreshnessPayload { perFile: [], } } - const resolved = specs.flatMap((s) => resolveSpec(s)) + return computeFreshness(tabId, specs) +} + +/** Freshness of an explicit source list; the registry-independent core, exported for tests. */ +export function computeFreshness(tabId: string, specs: SourceSpec[]): FreshnessPayload { + // A path listed explicitly and again through a directory expansion counts once, + // and the entry carrying a cadence wins whatever the registry order. + const byPath = new Map() + for (const r of specs.flatMap((s) => resolveSpec(s))) { + const prev = byPath.get(r.path) + if (!prev || (r.expectedEveryMinutes && !prev.expectedEveryMinutes)) byPath.set(r.path, r) + } const perFile: FreshnessFilePayload[] = [] let mostRecent: Date | null = null let anyExist = false - for (const r of resolved) { + let lagging: { name: string; ageMinutes: number; expectedEveryMinutes: number } | null = null + const now = Date.now() + for (const r of byPath.values()) { if (!r.exists || !r.mtime) { perFile.push({ name: r.name, date: null, source: "unknown" }) continue @@ -273,18 +330,34 @@ function computeTabFreshness(tabId: string): FreshnessPayload { // Prefer frontmatter date for .md files const fmDate = r.path.endsWith(".md") ? readFrontmatterDate(r.path) : null const effective = fmDate ?? r.mtime - perFile.push({ + const file: FreshnessFilePayload = { name: r.name, date: isoDate(effective), source: fmDate ? "content" : "mtime", - }) + } + if (r.expectedEveryMinutes) { + const ageMinutes = Math.floor((now - effective.getTime()) / 60_000) + file.ageMinutes = ageMinutes + file.expectedEveryMinutes = r.expectedEveryMinutes + if (!lagging || ageMinutes / r.expectedEveryMinutes > lagging.ageMinutes / lagging.expectedEveryMinutes) { + lagging = { name: r.name, ageMinutes, expectedEveryMinutes: r.expectedEveryMinutes } + } + } + perFile.push(file) if (!mostRecent || effective > mostRecent) mostRecent = effective } const daysOld = mostRecent - ? Math.floor((Date.now() - mostRecent.getTime()) / 86_400_000) + ? Math.floor((now - mostRecent.getTime()) / 86_400_000) : null - const tier = tierFromDays(anyExist ? daysOld : null) - const label = anyExist ? `${perFile.filter((p) => p.date).length} of ${perFile.length} sources dated` : "no sources on disk" + let tier = tierFromDays(anyExist ? daysOld : null) + let label = anyExist ? `${perFile.filter((p) => p.date).length} of ${perFile.length} sources dated` : "no sources on disk" + if (lagging) { + const cadenceTier = tierFromCadence(lagging.ageMinutes, lagging.expectedEveryMinutes) + if (TIER_RANK[cadenceTier] > TIER_RANK[tier]) { + tier = cadenceTier + label = `${lagging.name} written ${formatMinutes(lagging.ageMinutes)} ago, expected every ${formatMinutes(lagging.expectedEveryMinutes)}` + } + } return { tabId, dataDate: mostRecent ? isoDate(mostRecent) : null, @@ -292,6 +365,7 @@ function computeTabFreshness(tabId: string): FreshnessPayload { daysOld, tier, perFile, + ...(lagging ? { ageMinutes: lagging.ageMinutes, expectedEveryMinutes: lagging.expectedEveryMinutes } : {}), } }