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
13 changes: 8 additions & 5 deletions LifeOS/install/LIFEOS/PULSE/Performance/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<string, number> = {}
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)
}

Expand Down Expand Up @@ -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<string, number> = {}
const dailyTotal: Record<string, number> = {}
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
}

Expand Down
8 changes: 5 additions & 3 deletions LifeOS/install/LIFEOS/PULSE/modules/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions LifeOS/install/LIFEOS/PULSE/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
174 changes: 174 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/test/day-key.test.ts
Original file line number Diff line number Diff line change
@@ -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] });
});
});
41 changes: 41 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,47 @@ export function paiUserDir(): string {
}
}

// ─────────── Calendar days in the principal's timezone ───────────

const dayFormatters = new Map<string, Intl.DateTimeFormat>();
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 {
Expand Down
13 changes: 6 additions & 7 deletions LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,22 @@
* 1. MEMORY/OBSERVABILITY/session-costs.jsonl — historical per-session rollup (has real cost); froze ~2026-04-16
* 2. ~/.claude/projects/<proj>/*.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: { <model>: { 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).
Expand Down Expand Up @@ -63,10 +66,6 @@ interface DayAgg {
const byDay = new Map<string, DayAgg>();
const countedSessions = new Set<string>();

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) {
Expand All @@ -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++;
Expand Down Expand Up @@ -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;
Expand Down