diff --git a/LifeOS/install/LIFEOS/PULSE/Performance/module.ts b/LifeOS/install/LIFEOS/PULSE/Performance/module.ts index 79d7c3a09b..38d446262a 100644 --- a/LifeOS/install/LIFEOS/PULSE/Performance/module.ts +++ b/LifeOS/install/LIFEOS/PULSE/Performance/module.ts @@ -13,6 +13,7 @@ import { join } from "path" import { existsSync, readFileSync } from "fs" import { homedir } from "node:os"; +import { dayKey, principalTimeZone } from "../../TOOLS/LifeosConfig" const HOME = process.env.HOME ?? process.env.USERPROFILE ?? homedir() const LIFEOS_DIR = join(HOME, ".claude", "LIFEOS") @@ -91,10 +92,11 @@ function handleCostApi(url: URL): Response { totalTokens += s.totalTokens ?? 0 } - // Aggregate by day + // Aggregate by day (cut in the principal's timezone) + const tz = principalTimeZone() const dailyCosts: Record = {} for (const s of filtered) { - const day = (s.lastTimestamp || s.firstTimestamp || "").slice(0, 10) + const day = dayKey(s.lastTimestamp || s.firstTimestamp, tz) if (day) dailyCosts[day] = (dailyCosts[day] ?? 0) + (s.costTotal ?? 0) } @@ -187,15 +189,16 @@ function handleFailuresApi(): Response { const totalCalls = activity.length + failures.length const overallRate = totalCalls > 0 ? Math.round((totalFailures / totalCalls) * 10000) / 100 : 0 - // Daily failure trend (last 7 days) + // Daily failure trend (last 7 days, cut in the principal's timezone) + const tz = principalTimeZone() const dailyFailures: Record = {} const dailyTotal: Record = {} for (const f of failures) { - const day = (f.timestamp || "").slice(0, 10) + const day = dayKey(f.timestamp, tz) if (day) dailyFailures[day] = (dailyFailures[day] ?? 0) + 1 } for (const a of activity) { - const day = (a.timestamp || "").slice(0, 10) + const day = dayKey(a.timestamp, tz) if (day) dailyTotal[day] = (dailyTotal[day] ?? 0) + 1 } diff --git a/LifeOS/install/LIFEOS/PULSE/modules/usage.ts b/LifeOS/install/LIFEOS/PULSE/modules/usage.ts index e17f12dca1..7666c23d9a 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/usage.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/usage.ts @@ -16,6 +16,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { dayKey, principalTimeZone } from "../../TOOLS/LifeosConfig"; const MODULE_NAME = "usage"; const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); @@ -67,10 +68,11 @@ function sumDays(days: DayAgg[]): Totals { } function round2(t: Totals): Totals { return { ...t, costUsd: Math.round(t.costUsd * 100) / 100 }; } -/** ISO date string N days before today (UTC). */ +/** Date key N days before today, where "today" is the principal's local day (see dayKey). */ function daysAgo(n: number): string { - const ms = Date.now() - n * 86_400_000; - return new Date(ms).toISOString().slice(0, 10); + const d = new Date(`${dayKey(new Date().toISOString(), principalTimeZone())}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() - n); + return d.toISOString().slice(0, 10); } function isoWeek(date: string): string { const d = new Date(date + "T00:00:00Z"); diff --git a/LifeOS/install/LIFEOS/PULSE/package.json b/LifeOS/install/LIFEOS/PULSE/package.json index 154cd6c157..6f469e382a 100644 --- a/LifeOS/install/LIFEOS/PULSE/package.json +++ b/LifeOS/install/LIFEOS/PULSE/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "start": "bun run pulse.ts", + "test": "bun test", "install-service": "bash manage.sh install", "status": "bash manage.sh status" }, diff --git a/LifeOS/install/LIFEOS/PULSE/test/day-key.test.ts b/LifeOS/install/LIFEOS/PULSE/test/day-key.test.ts new file mode 100644 index 0000000000..1331536309 --- /dev/null +++ b/LifeOS/install/LIFEOS/PULSE/test/day-key.test.ts @@ -0,0 +1,174 @@ +/** + * Usage days are cut in the principal's timezone, consistently across the + * UsageAggregator (usage-daily.jsonl), the Usage tab and the Performance tab. + * + * One synthetic instant, 2026-03-09T03:00:00Z: March 9 in UTC, still March 8 in + * America/Bogota (UTC-5, no DST). Every site must agree on the local day when a + * zone is configured, and must reproduce the UTC day when no config is found. + * + * Run: cd LIFEOS/PULSE && bun test (bun:test + node builtins only; writes to a temp dir) + */ +import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const INSTANT = "2026-03-09T03:00:00.000Z"; +const UTC_DAY = "2026-03-09"; +const BOGOTA_DAY = "2026-03-08"; + +const PULSE_DIR = join(import.meta.dir, ".."); +const TOOLS_DIR = join(PULSE_DIR, "..", "TOOLS"); +const AGGREGATOR = join(TOOLS_DIR, "UsageAggregator.ts"); + +const root = mkdtempSync(join(tmpdir(), "lifeos-day-key-")); +// Pulse modules read HOME / CLAUDE_CONFIG_DIR at import time; the aggregator gets its own tree +// because it overwrites usage-daily.jsonl, which the Usage tab fixture must keep. +const home = join(root, "home"); +const pulseObsDir = join(home, ".claude", "LIFEOS", "MEMORY", "OBSERVABILITY"); +const aggDir = join(root, "agg"); +const aggObsDir = join(aggDir, "LIFEOS", "MEMORY", "OBSERVABILITY"); +const configs = { + bogota: join(root, "bogota.toml"), + invalid: join(root, "invalid-zone.toml"), + missing: join(root, "does-not-exist.toml"), +}; +const originalEnv = { + HOME: process.env.HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + LIFEOS_CONFIG_PATH: process.env.LIFEOS_CONFIG_PATH, +}; + +const configToml = (timezone: string) => + `[principal]\nname = "Test Principal"\ntimezone = "${timezone}"\n\n[da]\nname = "Test DA"\n\n[da.voices.main]\nvoice_id = "voice-test"\n`; +const jsonl = (rows: object[]) => rows.map((r) => JSON.stringify(r)).join("\n") + "\n"; +const session = { + sessionId: "session-a", firstTimestamp: INSTANT, lastTimestamp: INSTANT, messageCount: 1, + totalTokens: 1000, costTotal: 1, primaryModel: "test-model", models: { "test-model": 1 }, +}; + +beforeAll(() => { + mkdirSync(pulseObsDir, { recursive: true }); + mkdirSync(aggObsDir, { recursive: true }); + mkdirSync(join(aggDir, "projects", "proj-test"), { recursive: true }); + writeFileSync(configs.bogota, configToml("America/Bogota")); + writeFileSync(configs.invalid, configToml("Not/AZone")); + + // Aggregator sources: a session rollup and a live transcript message, both at INSTANT. + writeFileSync(join(aggObsDir, "session-costs.jsonl"), jsonl([session])); + writeFileSync(join(aggDir, "projects", "proj-test", "session-b.jsonl"), jsonl([ + { type: "assistant", sessionId: "session-b", timestamp: INSTANT, message: { model: "test-model", usage: { input_tokens: 10, output_tokens: 5 } } }, + ])); + + // Performance tab sources at the same instant. + writeFileSync(join(pulseObsDir, "session-costs.jsonl"), jsonl([session])); + writeFileSync(join(pulseObsDir, "tool-failures.jsonl"), jsonl([{ timestamp: INSTANT, tool_name: "Bash" }])); + writeFileSync(join(pulseObsDir, "tool-activity.jsonl"), jsonl([{ timestamp: INSTANT, tool_name: "Bash" }])); + + // Usage tab store: which rows count as "today" and "this week" depends on the zone. + writeFileSync(join(pulseObsDir, "usage-daily.jsonl"), jsonl([ + { date: "2026-03-01", messages: 1, totalTokens: 1, costUsd: 1, models: {} }, + { date: BOGOTA_DAY, messages: 1, totalTokens: 10, costUsd: 10, models: {} }, + { date: UTC_DAY, messages: 1, totalTokens: 100, costUsd: 100, models: {} }, + ])); + + process.env.HOME = home; + process.env.CLAUDE_CONFIG_DIR = join(home, ".claude"); + setSystemTime(new Date(INSTANT)); +}); + +afterAll(() => { + setSystemTime(); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); +}); + +function runAggregator(configPath: string): Array<{ date: string; messages: number }> { + const env = { ...process.env, HOME: originalEnv.HOME ?? "", CLAUDE_CONFIG_DIR: aggDir, LIFEOS_CONFIG_PATH: configPath }; + const run = Bun.spawnSync([process.execPath, AGGREGATOR], { env, cwd: TOOLS_DIR, stdout: "pipe", stderr: "pipe" }); + if (run.exitCode !== 0) throw new Error(`UsageAggregator exited ${run.exitCode}: ${run.stderr.toString()}`); + return readFileSync(join(aggObsDir, "usage-daily.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)); +} + +async function usageSummary() { + const usage = await import("../modules/usage.ts"); + const res = await usage.handleRequest(new Request("http://localhost/api/usage/summary"), "/api/usage/summary"); + return (await res!.json()) as { today: { costUsd: number }; week: { costUsd: number } }; +} + +async function performanceDays() { + const perf = await import("../Performance/module.ts"); + perf.startPerformance({ enabled: true }); + const get = async (path: string) => (await perf.handlePerformanceRequest(new Request(`http://localhost${path}`)))!.json(); + const cost = await get("/api/performance/cost"); + const failures = await get("/api/performance/failures"); + return { + dailyCosts: cost.dailyCosts.map((d: { day: string }) => d.day), + trend: failures.trend.map((d: { day: string }) => d.day), + }; +} + +describe("LifeosConfig day helpers", () => { + test("dayKey cuts an instant in the given zone and rejects what does not parse", async () => { + const { dayKey } = await import("../../TOOLS/LifeosConfig"); + expect(dayKey(INSTANT, "America/Bogota")).toBe(BOGOTA_DAY); + expect(dayKey(INSTANT, "UTC")).toBe(UTC_DAY); + expect(dayKey("2026-03-08T22:00:00-05:00", "America/Bogota")).toBe(BOGOTA_DAY); // hook-style offset stamp + expect(dayKey("", "UTC")).toBeNull(); + expect(dayKey("not a timestamp", "UTC")).toBeNull(); + }); + + test("principalTimeZone reads the config and falls back to UTC on a missing or unknown zone", async () => { + const { principalTimeZone } = await import("../../TOOLS/LifeosConfig"); + process.env.LIFEOS_CONFIG_PATH = configs.bogota; + expect(principalTimeZone()).toBe("America/Bogota"); + process.env.LIFEOS_CONFIG_PATH = configs.invalid; + expect(principalTimeZone()).toBe("UTC"); + process.env.LIFEOS_CONFIG_PATH = configs.missing; + expect(principalTimeZone()).toBe("UTC"); + }); +}); + +describe("UsageAggregator day bucket", () => { + test("lands on the local day with a configured zone", () => { + const rows = runAggregator(configs.bogota); + expect(rows.map((r) => r.date)).toEqual([BOGOTA_DAY]); + expect(rows[0].messages).toBe(2); // the rollup session and the live transcript share the day + }); + + test("keeps the UTC day without a config", () => { + const rows = runAggregator(configs.missing); + expect(rows.map((r) => r.date)).toEqual([INSTANT.slice(0, 10)]); + }); +}); + +describe("Usage tab (modules/usage.ts)", () => { + test("today and the week cut follow the local day", async () => { + process.env.LIFEOS_CONFIG_PATH = configs.bogota; + const summary = await usageSummary(); + expect(summary.today.costUsd).toBe(10); // March 8 locally + expect(summary.week.costUsd).toBe(111); // March 1 is within 7 days of March 8 + }); + + test("keeps the UTC day without a config", async () => { + process.env.LIFEOS_CONFIG_PATH = configs.missing; + const summary = await usageSummary(); + expect(summary.today.costUsd).toBe(100); + expect(summary.week.costUsd).toBe(110); + }); +}); + +describe("Performance tab (Performance/module.ts)", () => { + test("daily costs and the failure trend follow the local day", async () => { + process.env.LIFEOS_CONFIG_PATH = configs.bogota; + expect(await performanceDays()).toEqual({ dailyCosts: [BOGOTA_DAY], trend: [BOGOTA_DAY] }); + }); + + test("keeps the UTC day without a config", async () => { + process.env.LIFEOS_CONFIG_PATH = configs.missing; + expect(await performanceDays()).toEqual({ dailyCosts: [UTC_DAY], trend: [UTC_DAY] }); + }); +}); diff --git a/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts b/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts index 5b47d9018d..4bc0351be9 100644 --- a/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts +++ b/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts @@ -143,6 +143,47 @@ export function paiUserDir(): string { } } +// ─────────── Calendar days in the principal's timezone ─────────── + +const dayFormatters = new Map(); +function dayFormatter(tz: string): Intl.DateTimeFormat { + let f = dayFormatters.get(tz); + if (!f) { + f = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }); + dayFormatters.set(tz, f); + } + return f; +} + +/** + * The principal's IANA timezone from LIFEOS_CONFIG.toml, or "UTC" when the config is + * missing/invalid or names a zone Intl does not know. Never throws, so Pulse request + * paths and launchd jobs can call it unguarded (same shape as paiUserDir()). + */ +export function principalTimeZone(): string { + try { + const tz = loadLifeosConfig().principal.timezone; + dayFormatter(tz); // RangeError on an unknown zone, caught below + return tz; + } catch { + return "UTC"; + } +} + +/** + * Calendar day ("YYYY-MM-DD") of an instant in the given zone, or null when the + * timestamp does not parse. Pass a full timestamp (offset or Z), never a date-only + * key: a bare "YYYY-MM-DD" parses as UTC midnight and lands a day early west of UTC. + */ +export function dayKey(timestamp: string, tz: string): string | null { + if (typeof timestamp !== "string" || !timestamp) return null; + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return null; + const parts = dayFormatter(tz).formatToParts(d); + const part = (type: string) => parts.find((p) => p.type === type)?.value ?? ""; + return `${part("year")}-${part("month")}-${part("day")}`; +} + // ─────────── Validation ─────────── function validateAndNormalize(raw: unknown, path: string): LifeosConfig { diff --git a/LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts b/LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts index 6f31051a35..e0434f6962 100755 --- a/LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts +++ b/LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts @@ -19,19 +19,22 @@ * 1. MEMORY/OBSERVABILITY/session-costs.jsonl — historical per-session rollup (has real cost); froze ~2026-04-16 * 2. ~/.claude/projects//*.jsonl (+ subagents/) — live raw transcripts (~5-day window); cost via price table * - * OUTPUT: MEMORY/OBSERVABILITY/usage-daily.jsonl — one line per day, sorted: + * OUTPUT: MEMORY/OBSERVABILITY/usage-daily.jsonl — one line per day (cut in the principal's timezone), sorted: * { date, messages, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, * totalTokens, costUsd, models: { : { messages, totalTokens, costUsd } } } */ import { existsSync, readFileSync, readdirSync, writeFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { dayKey, principalTimeZone } from "./LifeosConfig"; const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); const OBS_DIR = join(CLAUDE_DIR, "LIFEOS", "MEMORY", "OBSERVABILITY"); const SESSION_COSTS = join(OBS_DIR, "session-costs.jsonl"); const PROJECTS_DIR = join(CLAUDE_DIR, "projects"); const OUT_PATH = join(OBS_DIR, "usage-daily.jsonl"); +// Day boundaries follow [principal].timezone in LIFEOS_CONFIG.toml (UTC when unset or unknown). +const TIME_ZONE = principalTimeZone(); // Price per MILLION tokens (USD). Approximate list prices; used only for the live // transcript window (historical days carry real cost from session-costs.jsonl). @@ -63,10 +66,6 @@ interface DayAgg { const byDay = new Map(); const countedSessions = new Set(); -function dayOf(ts: string): string | null { - const m = /^(\d{4}-\d{2}-\d{2})/.exec(ts || ""); - return m ? m[1] : null; -} function ensureDay(date: string): DayAgg { let d = byDay.get(date); if (!d) { @@ -89,7 +88,7 @@ function ingestSessionCosts(): number { if (!line.trim()) continue; let r: any; try { r = JSON.parse(line); } catch { continue; } - const date = dayOf(r.firstTimestamp); + const date = dayKey(r.firstTimestamp, TIME_ZONE); if (!date) continue; if (r.sessionId) countedSessions.add(r.sessionId); sessions++; @@ -157,7 +156,7 @@ function ingestTranscripts(): { files: number; messages: number } { try { r = JSON.parse(line); } catch { continue; } if (r.type !== "assistant" || !r.message?.usage) continue; if (r.sessionId && countedSessions.has(r.sessionId)) continue; // de-dup vs session-costs - const date = dayOf(r.timestamp); + const date = dayKey(r.timestamp, TIME_ZONE); if (!date) continue; const model: string = r.message.model || "unknown"; const u = r.message.usage;