mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
refactor(statusline): read from SessionDB instead of per-PID sidecar JSON
The statusline was reading per-PID stats-pid-*.json sidecars written by
persistStats() (src/server.ts:546). Three problems:
1. Eventually-consistent — 500ms+30s persist throttle made the
statusline lag the actual session state.
2. PID-scoped — multiple Claude sessions sharing a shell ppid collided
on the same sidecar.
3. Missing the multi-adapter aggregation that ctx_stats just gained
(B3a/B3b commits) so cross-platform users couldn't see it.
Refactor the statusline to read directly from SessionDB
(session_events + session_resume) via the same getRealBytesStats() and
getMultiAdapterLifetimeStats() calls ctx_stats uses (src/server.ts:2807-
2891). The two surfaces now share one source of truth.
What's preserved unchanged:
- Cross-OS PID resolver (linux /proc, darwin ps, win32 fallback) at
bin/statusline.mjs:105-200 — that's the session-id source still.
- CONTEXT_MODE_SESSION_DIR env var override.
- Substantiated headline fallback when no data exists.
New: bin/statusline.mjs imports analytics from build/session/analytics.js
lazily so a missing build degrades to the headline rather than crashing.
Multi-adapter $ surfaces as "across N tools" when the multi-adapter walk
finds 2+ real adapters (passing the isReal filter at analytics.ts:1300).
Sidecar persistStats() in src/server.ts:546 stays for now (other readers
may depend on it) — flagging for follow-up removal.
Tests: tests/statusline-sqlite.test.ts seeds a real SessionDB fixture
(no mocks of the analytics layer — that would couple to implementation)
and asserts the public output reflects DB state.
This commit is contained in:
+137
-127
@@ -2,9 +2,12 @@
|
||||
/**
|
||||
* context-mode status line — Claude Code statusLine integration.
|
||||
*
|
||||
* Reads the persisted stats file written by the MCP server and prints a
|
||||
* single-line, value-first status string designed for enterprise dev
|
||||
* surfaces (Loom demos, Slack screen shares, over-the-shoulder closes).
|
||||
* Reads stats DIRECTLY from SessionDB (`session_events` + `session_resume`),
|
||||
* mirroring the `ctx_stats` MCP handler at src/server.ts:2807-2891 so the
|
||||
* statusline and ctx_stats never drift. The legacy per-PID sidecar JSON
|
||||
* (`stats-pid-*.json`) is no longer the source of truth — sidecars were
|
||||
* eventually-consistent (500ms+30s throttles) and PID-scoped (multiple
|
||||
* Claude sessions colliding on the same shell ppid).
|
||||
*
|
||||
* Discipline (Datadog / Stripe / Vercel pattern):
|
||||
* - "context-mode" full brand label, never abbreviated
|
||||
@@ -13,32 +16,39 @@
|
||||
* - No counts (calls / tokens / events) — only $ and % pass the
|
||||
* value-per-pixel test
|
||||
*
|
||||
* Wire it up in ~/.claude/settings.json (path-free — uses the bundled CLI
|
||||
* forwarder so users don't have to know the absolute install path):
|
||||
* Wire it up in ~/.claude/settings.json:
|
||||
* {
|
||||
* "statusLine": {
|
||||
* "type": "command",
|
||||
* "command": "context-mode statusline"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Or, if you prefer to skip the CLI shim, point directly at this file:
|
||||
* "command": "node /absolute/path/to/context-mode/bin/statusline.mjs"
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
// ── Schema versioning ───────────────────────────────────────────────────
|
||||
// Bumped by the MCP writer (src/server.ts) when the persisted stats payload
|
||||
// shape changes. Statusline reads `schemaVersion` from the payload:
|
||||
// - missing → legacy v1.0.103 era, proceed with sensible defaults
|
||||
// - <= KNOWN → safe to render fully
|
||||
// - > KNOWN → newer writer than this reader; warn once + render what we
|
||||
// still understand (graceful degrade rather than blank bar)
|
||||
const KNOWN_SCHEMA_VERSION = 1;
|
||||
// ── Analytics import — resolved relative to this script ─────────────────
|
||||
// statusline.mjs ships in `bin/`; the compiled analytics module lives in
|
||||
// `build/session/analytics.js`. Import lazily so a missing build doesn't
|
||||
// crash the renderer — degrade to the substantiated headline instead.
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const ANALYTICS_PATH = resolve(__dirname, "..", "build", "session", "analytics.js");
|
||||
|
||||
let _analytics = null;
|
||||
async function loadAnalytics() {
|
||||
if (_analytics) return _analytics;
|
||||
try {
|
||||
_analytics = await import(ANALYTICS_PATH);
|
||||
} catch {
|
||||
_analytics = null;
|
||||
}
|
||||
return _analytics;
|
||||
}
|
||||
|
||||
// Test seams — keep production behaviour identical when env vars unset.
|
||||
// CTX_TEST_PLATFORM — override process.platform for cross-OS resolver tests
|
||||
@@ -69,7 +79,7 @@ const yellow = (t) => ansi("33", t); // degraded dot
|
||||
const red = (t) => ansi("31", t); // stale dot
|
||||
const SEP = dim("·");
|
||||
|
||||
// ── Stats file lookup ────────────────────────────────────────────────────
|
||||
// ── Stdin drain ─────────────────────────────────────────────────────────
|
||||
function readStdinJson() {
|
||||
try {
|
||||
const raw = readFileSync(0, "utf-8");
|
||||
@@ -100,7 +110,7 @@ function resolveSessionDir() {
|
||||
* - win32: degraded — process.ppid only, with a one-shot stderr warning
|
||||
*
|
||||
* Without this walk, multiple concurrent Claude sessions all see the same
|
||||
* shell ppid and collide on the fuzzy mtime fallback in findStatsFile.
|
||||
* shell ppid and collide on per-PID stats lookup.
|
||||
*/
|
||||
function findClaudePid() {
|
||||
const plat = platform();
|
||||
@@ -137,7 +147,6 @@ function findClaudePidDarwin() {
|
||||
let pid = process.ppid;
|
||||
for (let i = 0; i < 8 && pid && pid > 1; i++) {
|
||||
try {
|
||||
// `ps -o ppid=,comm= -p <pid>` → " 12345 /path/to/claude"
|
||||
const out = execFileSync(
|
||||
"ps",
|
||||
["-o", "ppid=,comm=", "-p", String(pid)],
|
||||
@@ -148,7 +157,6 @@ function findClaudePidDarwin() {
|
||||
if (!m) return process.ppid;
|
||||
const parentPid = Number(m[1]);
|
||||
const comm = m[2].trim();
|
||||
// comm may be a path; check basename for claude
|
||||
const base = comm.split("/").pop() || comm;
|
||||
if (/claude/i.test(base)) return pid;
|
||||
pid = parentPid;
|
||||
@@ -164,158 +172,160 @@ function resolveSessionId() {
|
||||
return `pid-${findClaudePid()}`;
|
||||
}
|
||||
|
||||
function findStatsFile(sessionDir, sessionId) {
|
||||
const direct = join(sessionDir, `stats-${sessionId}.json`);
|
||||
if (existsSync(direct)) return direct;
|
||||
|
||||
try {
|
||||
const candidates = readdirSync(sessionDir)
|
||||
.filter((f) => f.startsWith("stats-") && f.endsWith(".json"))
|
||||
.map((f) => {
|
||||
const full = join(sessionDir, f);
|
||||
try {
|
||||
return { full, mtime: statSync(full).mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
// Only fall back to a file modified within the last 30 minutes —
|
||||
// older files almost always belong to a stopped MCP server.
|
||||
const fresh = candidates.find(
|
||||
(c) => Date.now() - c.mtime < 30 * 60 * 1000,
|
||||
);
|
||||
if (fresh) return fresh.full;
|
||||
} catch { /* ignore — sessionDir might not exist yet */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadStats(path) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
||||
if (parsed && typeof parsed === "object") {
|
||||
// schemaVersion is optional — legacy v1.0.103 payloads omit it.
|
||||
// Default to 0 so unknown-newer detection still has a clean compare.
|
||||
const version = Number.isFinite(parsed.schemaVersion)
|
||||
? parsed.schemaVersion
|
||||
: 0;
|
||||
if (version > KNOWN_SCHEMA_VERSION) {
|
||||
try {
|
||||
process.stderr.write(
|
||||
`context-mode statusline: stats schemaVersion=${version} newer than known=${KNOWN_SCHEMA_VERSION}; rendering known fields only. Upgrade context-mode to suppress this warning.\n`,
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Formatters ───────────────────────────────────────────────────────────
|
||||
function fmtUsd(n) {
|
||||
const safe = Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
if (safe >= 100) return `$${safe.toFixed(0)}`;
|
||||
if (safe >= 10) return `$${safe.toFixed(2)}`;
|
||||
return `$${safe.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtUptime(ms) {
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
const remMin = min % 60;
|
||||
return remMin > 0 ? `${hr}h${remMin}m` : `${hr}h`;
|
||||
}
|
||||
|
||||
// ── Status dot — the ONE accent ──────────────────────────────────────────
|
||||
function statusDot(pct, isStale) {
|
||||
if (isStale) return red("●");
|
||||
function statusDot(pct) {
|
||||
if (pct >= 50) return green("●");
|
||||
if (pct >= 1) return yellow("●");
|
||||
return green("●");
|
||||
}
|
||||
|
||||
// ── Main render ──────────────────────────────────────────────────────────
|
||||
function main() {
|
||||
async function main() {
|
||||
readStdinJson(); // drain stdin even if unused, keeps Claude Code happy
|
||||
const sessionDir = resolveSessionDir();
|
||||
const sessionsDir = resolveSessionDir();
|
||||
const sessionId = resolveSessionId();
|
||||
const statsFile = findStatsFile(sessionDir, sessionId);
|
||||
|
||||
// BRAND-NEW — no stats file. Use only the substantiated README headline
|
||||
// claim ("saves ~98% of context window"). No fabricated $/dev/month or
|
||||
// social-proof numbers we cannot back with data.
|
||||
if (!statsFile) {
|
||||
const analytics = await loadAnalytics();
|
||||
|
||||
// BRAND-NEW / build missing — substantiated headline only
|
||||
if (!analytics) {
|
||||
process.stdout.write(
|
||||
`${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = loadStats(statsFile);
|
||||
if (!stats) {
|
||||
const {
|
||||
getRealBytesStats,
|
||||
getMultiAdapterLifetimeStats,
|
||||
OPUS_INPUT_PRICE_PER_TOKEN,
|
||||
} = analytics;
|
||||
|
||||
// Sessions dir doesn't exist yet — first ever launch
|
||||
if (!existsSync(sessionsDir)) {
|
||||
process.stdout.write(
|
||||
`${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// STALE — stats file >30min old, MCP likely stopped
|
||||
const ageMs = Date.now() - (stats.updated_at || 0);
|
||||
const stale = ageMs > 30 * 60 * 1000;
|
||||
if (stale) {
|
||||
// Lifetime real-bytes across this adapter's sessions dir.
|
||||
// Mirrors src/server.ts:2860 — the same call ctx_stats uses.
|
||||
let lifetime;
|
||||
try {
|
||||
lifetime = getRealBytesStats({ sessionsDir });
|
||||
} catch {
|
||||
lifetime = null;
|
||||
}
|
||||
|
||||
// Per-conversation real-bytes for the session $ KPI.
|
||||
// Statusline doesn't know the worktree hash, so scan every db in the
|
||||
// dir and let getRealBytesStats filter by sessionId.
|
||||
let conversation;
|
||||
try {
|
||||
conversation = getRealBytesStats({ sessionsDir, sessionId });
|
||||
} catch {
|
||||
conversation = null;
|
||||
}
|
||||
|
||||
// Cross-adapter lifetime — drives the "across N tools" headline when
|
||||
// 2+ real adapters are present. Mirrors src/server.ts:2840.
|
||||
let multi;
|
||||
try {
|
||||
multi = getMultiAdapterLifetimeStats();
|
||||
} catch {
|
||||
multi = null;
|
||||
}
|
||||
|
||||
const PRICE = OPUS_INPUT_PRICE_PER_TOKEN ?? (15 / 1_000_000);
|
||||
const lifetimeTokens = lifetime?.totalSavedTokens ?? 0;
|
||||
const sessionTokens = conversation?.totalSavedTokens ?? 0;
|
||||
const lifetimeUsd = lifetimeTokens * PRICE;
|
||||
const sessionUsd = sessionTokens * PRICE;
|
||||
|
||||
// Reduction % — bytes avoided + snapshot bytes vs returned bytes.
|
||||
// Mirrors persistStats() math in src/server.ts:565-568.
|
||||
const totalReturned = lifetime?.bytesReturned ?? 0;
|
||||
const totalKept =
|
||||
(lifetime?.bytesAvoided ?? 0)
|
||||
+ (lifetime?.snapshotBytes ?? 0)
|
||||
+ (lifetime?.eventDataBytes ?? 0);
|
||||
const totalProcessed = totalKept + totalReturned;
|
||||
const pct = totalProcessed > 0
|
||||
? Math.round((totalKept / totalProcessed) * 100)
|
||||
: 0;
|
||||
|
||||
const dot = statusDot(pct);
|
||||
|
||||
// Multi-adapter aggregation. Real adapters = those passing the isReal
|
||||
// filter (>=100 events, >=5 distinct projects, recent activity, avg
|
||||
// bytes >= 50). When 2+ real adapters exist, surface a cross-tool $.
|
||||
// multi.totalBytes is dataBytes + rescueBytes, NOT bytes-avoided — so
|
||||
// it's a different (and typically smaller) lens than getRealBytesStats.
|
||||
// Render the multi $ alongside lifetime $ rather than instead of it.
|
||||
const realAdapters = (multi?.perAdapter ?? []).filter((a) => a?.isReal);
|
||||
const multiTotalTokens = (multi?.totalBytes ?? 0) / 4;
|
||||
const multiUsd = multiTotalTokens * PRICE;
|
||||
const showMultiAdapter = realAdapters.length >= 2 && multiUsd > 0;
|
||||
|
||||
// BRAND-NEW: no local SessionDB data at all → headline.
|
||||
// Multi-adapter alone (without local data) means another tool has
|
||||
// history but THIS Claude session is fresh — still show headline,
|
||||
// not someone else's lifetime $, to avoid surprising users with a
|
||||
// number they can't trace to their current adapter.
|
||||
if (lifetimeTokens === 0 && sessionTokens === 0) {
|
||||
process.stdout.write(
|
||||
`${brand("context-mode")} ${red("●")} ${dim("stale — restart to resume saving")}`,
|
||||
`${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionUsd = stats.dollars_saved_session ?? 0;
|
||||
const lifetimeUsd = stats.dollars_saved_lifetime ?? 0;
|
||||
const pct = stats.reduction_pct ?? 0;
|
||||
const uptime = fmtUptime(stats.uptime_ms ?? 0);
|
||||
const dot = statusDot(pct, false);
|
||||
|
||||
// FRESH — no session $ yet, lead with persistence value
|
||||
if (sessionUsd === 0) {
|
||||
if (lifetimeUsd > 0) {
|
||||
// Lifetime $ exists — persistence as primary value, brand-poem echo
|
||||
process.stdout.write(
|
||||
`${brand("context-mode")} ${dot} ${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")} ${SEP} ${dim("preserved across compact, restart & upgrade")}`,
|
||||
);
|
||||
} else {
|
||||
// First-ever session, no lifetime data yet — substantiated headline only
|
||||
process.stdout.write(
|
||||
`${brand("context-mode")} ${dot} ${dim("ready — saves ~98% of context window")}`,
|
||||
);
|
||||
// FRESH session, no session $ yet — lead with persistence value.
|
||||
if (sessionUsd === 0 && lifetimeUsd > 0) {
|
||||
const blocks = [
|
||||
`${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")}`,
|
||||
];
|
||||
if (showMultiAdapter) {
|
||||
blocks.push(`${bold(fmtUsd(multiUsd))} ${dim(`across ${realAdapters.length} tools`)}`);
|
||||
}
|
||||
blocks.push(dim("preserved across compact, restart & upgrade"));
|
||||
process.stdout.write(
|
||||
`${brand("context-mode")} ${dot} ${blocks.join(` ${SEP} `)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACTIVE / DEGRADED — session $ · [lifetime $ when present] · % efficient · uptime
|
||||
// Status dot color encodes degraded vs healthy via pct.
|
||||
// Lifetime block is conditional: persistStats omits dollars_saved_lifetime
|
||||
// when no analytics aggregator is available, so we degrade gracefully to
|
||||
// a session-only render rather than printing "$0.00 saved across sessions".
|
||||
// ACTIVE: session $ · lifetime $ · [multi $] · % efficient
|
||||
const valueBlocks = [
|
||||
`${bold(fmtUsd(sessionUsd))} ${dim("saved this session")}`,
|
||||
];
|
||||
if (lifetimeUsd > 0) {
|
||||
valueBlocks.push(`${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")}`);
|
||||
}
|
||||
valueBlocks.push(`${bold(`${pct}%`)} ${dim("efficient")}`);
|
||||
valueBlocks.push(dim(uptime));
|
||||
if (showMultiAdapter) {
|
||||
valueBlocks.push(`${bold(fmtUsd(multiUsd))} ${dim(`across ${realAdapters.length} tools`)}`);
|
||||
}
|
||||
if (pct > 0) {
|
||||
valueBlocks.push(`${bold(`${pct}%`)} ${dim("efficient")}`);
|
||||
}
|
||||
|
||||
const head = `${brand("context-mode")} ${dot} `;
|
||||
const tail = valueBlocks.join(` ${SEP} `);
|
||||
process.stdout.write(head + tail);
|
||||
}
|
||||
|
||||
main();
|
||||
main().catch(() => {
|
||||
// Last-resort fallback — a thrown error must never produce a blank statusline.
|
||||
try {
|
||||
process.stdout.write(
|
||||
`${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Behavioral tests for the SessionDB-backed statusline pipeline.
|
||||
*
|
||||
* Until v1.0.111 the statusline read per-PID `stats-pid-*.json` sidecars
|
||||
* written by `persistStats()` (src/server.ts:546). Sidecars are
|
||||
* eventually-consistent (500ms+30s throttles), PID-scoped (multiple Claude
|
||||
* sessions collide on shared shell ppid), and don't carry the multi-adapter
|
||||
* aggregation `ctx_stats` already exposes.
|
||||
*
|
||||
* These tests pin the new contract: statusline reads directly from the
|
||||
* same SessionDB (`session_events` + `session_resume`) that powers the
|
||||
* `ctx_stats` MCP handler at src/server.ts:2807-2891. This means:
|
||||
* - statusline reflects the current state, no sidecar lag
|
||||
* - multiple sessions don't collide
|
||||
* - multi-adapter aggregation works for cross-tool users
|
||||
*
|
||||
* Strategy: seed a real SessionDB fixture (no mocks of the analytics
|
||||
* layer — that would couple tests to implementation). Drive the statusline
|
||||
* end-to-end via spawnSync and assert on its public output.
|
||||
*/
|
||||
|
||||
import { describe, test, beforeEach, afterEach } from "vitest";
|
||||
import { strict as assert } from "node:assert";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
existsSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir, homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const STATUSLINE = resolve(process.cwd(), "bin", "statusline.mjs");
|
||||
|
||||
function runStatusline(env: Record<string, string>) {
|
||||
const result = spawnSync("node", [STATUSLINE], {
|
||||
input: "{}",
|
||||
env: { ...process.env, NO_COLOR: "1", ...env },
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout.trim(),
|
||||
stderr: result.stderr ?? "",
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SessionDB sidecar matching the schema used by src/session/db.ts:273.
|
||||
* `worktreeHash` defaults to a deterministic dummy — the statusline doesn't
|
||||
* filter by worktree, so any 16-hex value works.
|
||||
*/
|
||||
function seedSessionDb(opts: {
|
||||
dir: string;
|
||||
worktreeHash?: string;
|
||||
events: Array<{
|
||||
sessionId?: string;
|
||||
type?: string;
|
||||
category?: string;
|
||||
data?: string;
|
||||
bytesAvoided?: number;
|
||||
bytesReturned?: number;
|
||||
}>;
|
||||
resume?: { sessionId: string; snapshotBytes: number; eventCount?: number };
|
||||
}): string {
|
||||
const hash = opts.worktreeHash ?? "a".repeat(16);
|
||||
const dbPath = join(opts.dir, `${hash}.db`);
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 2,
|
||||
data TEXT NOT NULL,
|
||||
project_dir TEXT NOT NULL DEFAULT '',
|
||||
attribution_source TEXT NOT NULL DEFAULT 'unknown',
|
||||
attribution_confidence REAL NOT NULL DEFAULT 0,
|
||||
bytes_avoided INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_returned INTEGER NOT NULL DEFAULT 0,
|
||||
source_hook TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
data_hash TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS session_meta (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
project_dir TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_event_at TEXT,
|
||||
event_count INTEGER NOT NULL DEFAULT 0,
|
||||
compact_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS session_resume (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
snapshot TEXT NOT NULL,
|
||||
event_count INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
consumed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO session_events
|
||||
(session_id, type, category, data, bytes_avoided, bytes_returned, source_hook)
|
||||
VALUES (?, ?, ?, ?, ?, ?, '')`
|
||||
);
|
||||
const seenSessions = new Set<string>();
|
||||
for (const ev of opts.events) {
|
||||
const sid = ev.sessionId ?? "default-session";
|
||||
insert.run(
|
||||
sid,
|
||||
ev.type ?? "tool_use",
|
||||
ev.category ?? "tool",
|
||||
ev.data ?? "x".repeat(256),
|
||||
ev.bytesAvoided ?? 0,
|
||||
ev.bytesReturned ?? 0,
|
||||
);
|
||||
seenSessions.add(sid);
|
||||
}
|
||||
const insertMeta = db.prepare(
|
||||
`INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, '/tmp/test')`
|
||||
);
|
||||
for (const sid of seenSessions) insertMeta.run(sid);
|
||||
if (opts.resume) {
|
||||
db.prepare(
|
||||
`INSERT INTO session_resume (session_id, snapshot, event_count) VALUES (?, ?, ?)`
|
||||
).run(
|
||||
opts.resume.sessionId,
|
||||
"x".repeat(opts.resume.snapshotBytes),
|
||||
opts.resume.eventCount ?? 1,
|
||||
);
|
||||
}
|
||||
db.close();
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
describe("statusline.mjs — SessionDB-backed reads", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "ctx-statusline-sqlite-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// SLICE 1: lifetime $ comes from SessionDB, not from sidecar JSON.
|
||||
// Seed a SessionDB with substantial event data → statusline must render
|
||||
// a lifetime $ derived from those bytes (NOT $0.00, NOT a stale sidecar).
|
||||
test("renders lifetime $ from SessionDB session_events bytes", () => {
|
||||
// 1000 events × ~256 bytes data = ~256KB → ~64K tokens → ~$0.96
|
||||
// Use bytes_avoided so it counts as keptOut savings.
|
||||
const events = Array.from({ length: 1000 }, () => ({
|
||||
bytesAvoided: 1024, // 1KB avoided per event
|
||||
data: "x".repeat(64),
|
||||
}));
|
||||
seedSessionDb({ dir, events });
|
||||
|
||||
const { stdout } = runStatusline({
|
||||
CONTEXT_MODE_SESSION_DIR: dir,
|
||||
CLAUDE_SESSION_ID: "any-session-id",
|
||||
});
|
||||
|
||||
assert.match(stdout, /context-mode/, "brand visible");
|
||||
// 1MB avoided ÷ 4 bytes/token = 256K tokens × $15/1M = $3.84 (and bytes_returned=0)
|
||||
// Substantively positive lifetime $ — proves SessionDB is the source.
|
||||
assert.match(
|
||||
stdout,
|
||||
/\$([1-9]\d*|0\.\d*[1-9])/,
|
||||
"non-zero $ derived from SessionDB rows",
|
||||
);
|
||||
assert.doesNotMatch(stdout, /NaN/);
|
||||
});
|
||||
|
||||
// SLICE 1 cont: no SessionDB → headline fallback (substantiated, no $).
|
||||
test("empty sessionsDir falls back to substantiated headline", () => {
|
||||
// dir exists but has no .db files
|
||||
const { stdout } = runStatusline({
|
||||
CONTEXT_MODE_SESSION_DIR: dir,
|
||||
CLAUDE_SESSION_ID: "any-session-id",
|
||||
});
|
||||
assert.match(stdout, /context-mode/);
|
||||
assert.match(stdout, /saves ~98% of context window/);
|
||||
assert.doesNotMatch(stdout, /\$\d+\/dev\/month/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Slice 2: multi-adapter aggregation ───────────────────────────────────
|
||||
// When 2+ real adapters are detected on disk, the statusline shows the
|
||||
// multi-adapter total instead of just the active adapter's $. This mirrors
|
||||
// the `multiAdapter` block ctx_stats already renders (src/server.ts:2840).
|
||||
//
|
||||
// `getMultiAdapterLifetimeStats({ home })` walks every adapter dir under
|
||||
// `home`. We seed two adapter dirs with enough events to cross the
|
||||
// `isReal` threshold (>=100 events, >=5 distinct projects, recent,
|
||||
// avg bytes >= 50 — see DEFAULT_REAL_USAGE_FILTER at analytics.ts:1162).
|
||||
describe("statusline.mjs — multi-adapter aggregation", () => {
|
||||
let home: string;
|
||||
let claudeSessionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "ctx-statusline-multi-"));
|
||||
// Mirror real adapter layout: ~/.claude/context-mode/sessions for
|
||||
// claude-code, ~/.gemini/context-mode/sessions for gemini-cli, etc.
|
||||
claudeSessionsDir = join(home, ".claude", "context-mode", "sessions");
|
||||
mkdirSync(claudeSessionsDir, { recursive: true });
|
||||
mkdirSync(join(home, ".gemini", "context-mode", "sessions"), {
|
||||
recursive: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedRealAdapter(sessionsDir: string, projectSeed: string) {
|
||||
// 200 events across 6 distinct project_dirs, recent created_at, avg bytes ~256.
|
||||
// Crosses the isReal filter at analytics.ts:1300-1304.
|
||||
const dbPath = join(sessionsDir, `${createHash("sha256").update(projectSeed).digest("hex").slice(0, 16)}.db`);
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 2,
|
||||
data TEXT NOT NULL,
|
||||
project_dir TEXT NOT NULL DEFAULT '',
|
||||
attribution_source TEXT NOT NULL DEFAULT 'unknown',
|
||||
attribution_confidence REAL NOT NULL DEFAULT 0,
|
||||
bytes_avoided INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_returned INTEGER NOT NULL DEFAULT 0,
|
||||
source_hook TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
data_hash TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS session_meta (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
project_dir TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_event_at TEXT,
|
||||
event_count INTEGER NOT NULL DEFAULT 0,
|
||||
compact_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS session_resume (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
snapshot TEXT NOT NULL,
|
||||
event_count INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
consumed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO session_events (session_id, type, category, data, project_dir, bytes_avoided, source_hook)
|
||||
VALUES (?, 'tool_use', 'tool', ?, ?, 1024, '')`,
|
||||
);
|
||||
const meta = db.prepare(
|
||||
`INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`,
|
||||
);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const proj = `/p/${projectSeed}/${i % 6}`;
|
||||
ins.run(`sid-${projectSeed}-${i}`, "x".repeat(256), proj);
|
||||
meta.run(`sid-${projectSeed}-${i}`, proj);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Slice 2 RED: with TWO real adapters seeded under HOME, the statusline
|
||||
// surfaces the cross-tool aggregate. Counts adapters via "across N tools".
|
||||
test("renders 'across N tools' when 2+ real adapters detected", () => {
|
||||
seedRealAdapter(join(home, ".claude", "context-mode", "sessions"), "claude");
|
||||
seedRealAdapter(join(home, ".gemini", "context-mode", "sessions"), "gemini");
|
||||
|
||||
const { stdout } = runStatusline({
|
||||
// statusline must use HOME for multi-adapter walk
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
// active adapter dir is the claude one (matches getSessionDir() default)
|
||||
CONTEXT_MODE_SESSION_DIR: claudeSessionsDir,
|
||||
CLAUDE_SESSION_ID: "any-session-id",
|
||||
});
|
||||
|
||||
assert.match(stdout, /context-mode/);
|
||||
assert.match(
|
||||
stdout,
|
||||
/across\s+\d+\s+tools?/i,
|
||||
"shows multi-adapter aggregate when 2+ real adapters",
|
||||
);
|
||||
});
|
||||
|
||||
// Slice 2 cont: with only ONE real adapter, do NOT show "across N tools".
|
||||
test("single real adapter: no 'across N tools' suffix", () => {
|
||||
seedRealAdapter(join(home, ".claude", "context-mode", "sessions"), "claude");
|
||||
|
||||
const { stdout } = runStatusline({
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CONTEXT_MODE_SESSION_DIR: claudeSessionsDir,
|
||||
CLAUDE_SESSION_ID: "any-session-id",
|
||||
});
|
||||
|
||||
assert.match(stdout, /context-mode/);
|
||||
assert.doesNotMatch(
|
||||
stdout,
|
||||
/across\s+\d+\s+tools?/i,
|
||||
"single adapter must not advertise multi-tool",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user