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
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/DOCUMENTATION/Pulse/PulseSystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/page.tsx`, a `"use client"` component that fetches `/api/<name>` 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` — `<PageShell>` + `<PageHeader>` (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.
Expand Down
134 changes: 134 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
106 changes: 90 additions & 16 deletions LifeOS/install/LIFEOS/PULSE/modules/tab-freshness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, SourceSpec[]> = {
Expand Down Expand Up @@ -109,6 +123,13 @@ const REGISTRY: Record<string, SourceSpec[]> = {
],
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 },
Expand Down Expand Up @@ -158,6 +179,7 @@ interface ResolvedSource {
path: string
exists: boolean
mtime: Date | null
expectedEveryMinutes?: number
}

function resolveSpec(spec: SourceSpec): ResolvedSource[] {
Expand All @@ -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 }]
Expand All @@ -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
Expand Down Expand Up @@ -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<FreshnessPayload["tier"], number> = { 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)
}
Expand All @@ -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 {
Expand All @@ -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<string, ResolvedSource>()
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
Expand All @@ -273,25 +330,42 @@ 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,
label,
daysOld,
tier,
perFile,
...(lagging ? { ageMinutes: lagging.ageMinutes, expectedEveryMinutes: lagging.expectedEveryMinutes } : {}),
}
}

Expand Down