diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5534f44 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + defaults: + run: + working-directory: plugins/session-intelligence + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: plugins/session-intelligence/package-lock.json + - run: npm ci + - run: npm test diff --git a/AGENTS.md b/AGENTS.md index 8dcd1cb..a4072f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus MCP -This project is indexed by GitNexus as **claude-session-intelligence** (668 symbols, 2033 relationships, 55 execution flows). +This project is indexed by GitNexus as **claude-session-intelligence** (698 symbols, 2165 relationships, 57 execution flows). ## Always Start Here diff --git a/CLAUDE.md b/CLAUDE.md index 1a951ff..f790620 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Always update `MEMORY.md` with a one-line pointer. Don't duplicate prior memory Disable either surface with `compact.memoryOffload: false` in `~/.claude/session-intelligence.json` or `CLAUDE_COMPACT_MEMORY_OFFLOAD=0`. #### Tool-Response Archive (post-compact retrieval) -`si-tool-archive.js` snapshots any tool response larger than `toolArchive.thresholdChars` (default 4096) to `${TMPDIR}/claude-tool-archive-/.json`. After `/compact` erases the body from context, replay it with `/si expand ` instead of re-running the tool. +`si-tool-archive.js` snapshots any tool response larger than `toolArchive.thresholdChars` (default 4096) to `~/.claude/state/claude-tool-archive-/.json`. After `/compact` erases the body from context, replay it with `/si expand ` instead of re-running the tool. When to reach for this: - After `/compact`, when you need the full body of a `Read`/`Bash`/`Grep` result that just got summarised away. @@ -71,7 +71,7 @@ Disable with `toolArchive.enabled=false` or `CLAUDE_TOOL_ARCHIVE=0` if you don't # GitNexus MCP -This project is indexed by GitNexus as **claude-session-intelligence** (668 symbols, 2033 relationships, 55 execution flows). +This project is indexed by GitNexus as **claude-session-intelligence** (698 symbols, 2165 relationships, 57 execution flows). ## Always Start Here diff --git a/README.md b/README.md index 02a9581..3f9379c 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ Opus 4.7 · claude-session-intelligence · main · (+235,-16) · feat — status ### Quota & reset timers (line 3) -`blockUsage` and `weekUsage` surface Claude's 5-hour block and 7-day rolling quota with reset countdowns, fed from the same `api.anthropic.com/api/oauth/usage` endpoint ccstatusline uses. A detached background worker (`lib/usage-refresh.js`) refreshes a disk cache every 180 s — the statusline hot path only reads the cache, so redraws stay sub-100 ms. On macOS the OAuth token is read from the `Claude Code-credentials` keychain service; elsewhere (or as fallback) from `~/.claude/.credentials.json`. Any failure (no creds, network down, API error) silently renders an empty cell — never blocks. +`blockUsage` and `weekUsage` surface Claude's 5-hour block and 7-day rolling quota with reset countdowns. On Claude Code ≥2.1.90 the data comes straight from the statusline stdin JSON (`rate_limits.five_hour` / `.seven_day`) — fresh every redraw, zero API requests. Older builds fall back to the `api.anthropic.com/api/oauth/usage` poller: a detached background worker (`lib/usage-refresh.js`) refreshes a disk cache (180 s TTL, honoring 429 `Retry-After` with a 5-minute error backoff), and while the endpoint errors the cache carries the last good values, rendered with a `~` stale marker. On macOS the OAuth token is read from the `Claude Code-credentials` keychain service; elsewhere (or as fallback) from `~/.claude/.credentials.json`. Any hard failure silently renders an empty cell — never blocks. ### Real token count @@ -291,8 +291,11 @@ Set `fields` in `~/.claude/statusline-intel.json` to the list + order you want. | `cacheSaved` | `saved:$2.83` | **Cumulative** USD saved across the session by cache hits vs. paying the uncached input rate for the same tokens. Hidden when savings are under $0.10 (not worth the field) (dim) | | `compactAge` | `compact:2hr 13m ago` | Time since last `/compact` event. Dim when <2h, **red** when ≥2h — the only line-2 field that escalates, because it's the one line-2 signal that says "you should act" | | `compactCost` | `c$12.34 / s$68.20` | Cost + cache-savings accrued **since last /compact**. Incrementally cached at `/tmp/claude-cost-sincecompact-`, auto-invalidated when compact mtime advances. Shows `c$` only when ≥$0.01, `s$` only when ≥$0.10; hidden when both trivial (dim) | -| `blockUsage` | `b:47% r:1hr 12m` | Claude's 5-hour block quota utilisation + time until reset. Data from cached `/api/oauth/usage` response (180s TTL, refreshed in background). % zone-coloured: green <60%, yellow 60-85%, orange 85-95%, red ≥95%. Empty cell when the cache is absent or errored | -| `weekUsage` | `w:31% r:4d 12hr` | Weekly (7-day) quota utilisation + time until reset. Same colour escalation as `blockUsage` | +| `blockUsage` | `b:47% r:1hr 12m` | Claude's 5-hour block quota used + reset countdown; leads line 2 next to session duration + time until reset. Sourced from statusline stdin `rate_limits` (CC ≥2.1.90); cached `/api/oauth/usage` fallback for older builds, rendering last-good values with a `~` marker during API outages | +| `weekUsage` | `w:31% r:4d 12hr` | Weekly (7-day) quota used + time until reset. Same sourcing as `blockUsage`; % zone-coloured: green <60%, yellow 60-85%, orange 85-95%, red ≥95% | +| `modelSplit` | `fable-5:sc$80·sp99%·wr:~85%` | Per model: session cost (`sc$`), share of session spend (`sp%`), and estimated weekly window remaining (`wr:~%`). `wr` attributes the account weekly used% across models by their share of this week's tracked spend — an SI estimate (hence `~`), since Anthropic exposes no per-model weekly quota. Parent-session splits are recorded to `session_model_costs` on each redraw, so the weekly attribution gets richer as sessions run | +| `tokenSpeed` | `tps:238` | Output-token generation speed (tokens/sec) — median of the last few streamed turns, measured from per-message chunk-timestamp spans (pure generation time). Falls back to session average (total output / total API time) when the tail has no streamed turn | +| `modelQuota` | `opus:12/88%` | Per-model QUOTA used/available — dormant until Anthropic populates per-model limits (stdin extra `rate_limits` keys or `seven_day_` buckets in the usage payload; both are null on plans without per-model limits) | | `cwd` | `~/DWS/claude-session-intelligence` | Full working directory, ccstatusline-style. `$HOME` collapses to `~`; middle-ellipsis when longer than 60 chars (keeps the leaf) (dim) | | `activeRoot` | `→plugins/session-intelligence` | "Where Claude is touching files now" — latest non-blank `root` from the per-session shape log, complements `cwd`. Hidden when the latest root collapses to `.` (Claude is parked at cwd root) or matches `basename(cwd)` — those add no signal beyond `cwd`. To force a `→.` "still alive" indicator on flat-layout repos where everything is at the root, set `statusline.perProject[""].activeRootShowAtRoot: true` (dim) | | `siHealth` | `⚠ si-off (run /si doctor)` | **Self-diagnosis tag.** Empty when SI is healthy on this project (silence = success), red warning when the project's `enabledPlugins` whitelist is suppressing SI. Without this the dark state is invisible — the bar still renders (statusline is wired separately) but every SI-fed field stays blank forever and there's no way to distinguish "nothing happened yet" from "hooks are off." The tag points users at `/si doctor` for the verbose remediation block | @@ -312,7 +315,7 @@ Set `fields` in `~/.claude/statusline-intel.json` to the list + order you want. |---|---| | `minimal` | `tokens` | | `standard` | `model`, `project`, `tokens`, `newline`, `task` | -| `verbose` (default) | 4 lines. L1: `tokens`, `compactAge`, `compactCost`, `cacheHit`, `siHealth` — colour-escalating warning row (`siHealth` is silent when SI is wired up correctly; flags `⚠ si-off` only when the project's `enabledPlugins` whitelist is suppressing the plugin) · L2: `session`, `blockUsage`, `sessionId`, `costSaved`, `tools`, `tokenFlow` — live activity · L3: `branch`, `diffstat`, `cwd`, `activeRoot` — git + working dir delta · L4: `model`, `weekUsage`, `outputStyle`, `thinking`, `task` — dim reference context | +| `verbose` (default) | 5 lines. L1: `tokens`, `compactAge`, `compactCost`, `cacheHit`, `siHealth` — colour-escalating warning row (`siHealth` is silent when SI is wired up correctly; flags `⚠ si-off` only when the project's `enabledPlugins` whitelist is suppressing the plugin) · L2: `blockUsage`, `session`, `sessionId`, `costSaved`, `tools`, `tokenFlow`, `tokenSpeed` — live activity · L3: `branch`, `diffstat`, `cwd`, `activeRoot` — git + working dir delta · L4: `model`, `outputStyle`, `thinking`, `task` — dim reference context · L5: `weekUsage`, `modelQuota`, `modelSplit` — weekly quota, per-model quota (dormant until plans have per-model limits), per-model spend | | `verbose-cache` | 4 lines, token-economics-focused. Same shape as `verbose` with `cacheTokens` appended to L2; L4 trimmed to model/project/task | Switch via `/si set statusline.preset minimal` or override one session with `CLAUDE_STATUSLINE_PRESET=minimal`. @@ -856,7 +859,7 @@ Large tool responses (Read on a big file, Bash log dump, Grep with many hits) si `si-tool-archive.js` is a **PostToolUse** hook that snapshots tool responses larger than `toolArchive.thresholdChars` (default 4096) to: ``` -${TMPDIR}/claude-tool-archive-/.json +~/.claude/state/claude-tool-archive-/.json ``` After `/compact` wipes the body from context, replay it with: diff --git a/plugins/session-intelligence/CLAUDE.md b/plugins/session-intelligence/CLAUDE.md index 9f2ffad..029b530 100644 --- a/plugins/session-intelligence/CLAUDE.md +++ b/plugins/session-intelligence/CLAUDE.md @@ -58,7 +58,7 @@ Always update `MEMORY.md` with a one-line pointer. Don't duplicate prior memory Disable either surface with `compact.memoryOffload: false` in `~/.claude/session-intelligence.json` or `CLAUDE_COMPACT_MEMORY_OFFLOAD=0`. #### Tool-Response Archive (post-compact retrieval) -`si-tool-archive.js` snapshots any tool response larger than `toolArchive.thresholdChars` (default 4096) to `${TMPDIR}/claude-tool-archive-/.json`. After `/compact` erases the body from context, replay it with `/si expand ` instead of re-running the tool. +`si-tool-archive.js` snapshots any tool response larger than `toolArchive.thresholdChars` (default 4096) to `~/.claude/state/claude-tool-archive-/.json`. After `/compact` erases the body from context, replay it with `/si expand ` instead of re-running the tool. When to reach for this: - After `/compact`, when you need the full body of a `Read`/`Bash`/`Grep` result that just got summarised away. diff --git a/plugins/session-intelligence/hooks/si-bootstrap.js b/plugins/session-intelligence/hooks/si-bootstrap.js index 90807e4..14abbe6 100644 --- a/plugins/session-intelligence/hooks/si-bootstrap.js +++ b/plugins/session-intelligence/hooks/si-bootstrap.js @@ -914,6 +914,14 @@ function main() { const wfAgents = require(path.join(SI_LIB, 'events')).reconcileWorkflowAgents({ cwd }); if (wfAgents > 0) intelLog('bootstrap', 'info', 'workflow agents reconciled', { recorded: wfAgents }); } catch { /* best effort */ } + // Backfill usage on rows the live tracker recorded blind — background-run + // Agent/Task calls fire PostToolUse before the subagent transcript has + // flushed usage, so model/tokens/cost land NULL. The transcripts are + // complete by now; fill the gaps from them. + try { + const filled = require(path.join(SI_LIB, 'events')).reconcileSubagentUsage({ cwd }); + if (filled > 0) intelLog('bootstrap', 'info', 'subagent usage backfilled', { updated: filled }); + } catch { /* best effort */ } const locked = acquireStateLock(); try { const state = loadState(); diff --git a/plugins/session-intelligence/lib/agent-usage.js b/plugins/session-intelligence/lib/agent-usage.js index 650247a..1ba7622 100644 --- a/plugins/session-intelligence/lib/agent-usage.js +++ b/plugins/session-intelligence/lib/agent-usage.js @@ -183,6 +183,95 @@ function listWorkflowAgentTranscripts({ cwd, projectsRoot: projectsRootOverride return out; } +/** + * List every plain (non-workflow) subagent transcript for a project: + * ~/.claude/projects///subagents/agent-.jsonl + * + * Companion to listWorkflowAgentTranscripts, which only walks the + * `subagents/workflows/` layer below this one. Needed because Agent/Task + * calls now run in the background by default: at PostToolUse time the + * transcript usually hasn't flushed any usage yet, so si-agent-tracker + * records the row with NULL model/tokens/cost. A SessionStart reconcile + * re-reads these completed transcripts to fill the gaps. + * + * `sinceMs` (ms epoch) skips files whose mtime is older — the reconcile + * only targets recent unfilled rows, so there's no reason to re-parse + * months of transcript history every session. Returns `[{ path, sid, + * mtimeMs }]`, newest first. Same cwd-encoding caveat as the other walkers. + */ +function listPlainSubagentTranscripts({ cwd, projectsRoot: projectsRootOverride, sinceMs = 0 } = {}) { + const enc = encodeProjectPath(cwd); + if (!enc) return []; + const projDir = path.join(projectsRoot(projectsRootOverride), enc); + let sidEntries; + try { sidEntries = fs.readdirSync(projDir, { withFileTypes: true }); } catch { return []; } + + const out = []; + for (const sidEnt of sidEntries) { + if (!sidEnt.isDirectory()) continue; + const dir = path.join(projDir, sidEnt.name, 'subagents'); + for (const c of listCandidates(dir)) { + if (c.mtimeMs < sinceMs) continue; + out.push({ path: c.path, sid: sidEnt.name, mtimeMs: c.mtimeMs }); + } + } + out.sort((a, b) => b.mtimeMs - a.mtimeMs); + return out; +} + +/** Path of the PARENT session's transcript for a sid under this project. */ +function parentTranscriptPathFor(cwd, sid, projectsRootOverride) { + const enc = encodeProjectPath(cwd); + if (!enc || !sid) return null; + return path.join(projectsRoot(projectsRootOverride), enc, `${sid}.jsonl`); +} + +function _blockText(block) { + if (typeof block.content === 'string') return block.content; + if (Array.isArray(block.content)) { + return block.content.map((c) => (c && typeof c.text === 'string' ? c.text : '')).join('\n'); + } + return ''; +} + +/** + * Map subagent agentId → launching Agent-tool tool_use_id by scanning the + * PARENT session transcript. Newer Claude Code builds (2.1.x) stopped + * writing `parentToolUseId` into subagent transcript lines, so the pairing + * has to come from the parent side, where it appears twice per agent: + * - the launch ack — a tool_result block for the tool_use_id whose text + * carries "agentId: " + * - the completion notification — "" and + * "" inside one message + * A cheap substring prefilter keeps this fast on multi-MB transcripts. + */ +function mapAgentToolUseIds(parentTranscriptPath) { + const map = new Map(); + if (!parentTranscriptPath) return map; + let raw; + try { raw = fs.readFileSync(parentTranscriptPath, 'utf8'); } catch { return map; } + for (const line of raw.split('\n')) { + if (!line) continue; + if (!line.includes('agentId') && !line.includes('task-id')) continue; + let d; + try { d = JSON.parse(line); } catch { continue; } + const content = d && d.message && d.message.content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (!block) continue; + if (block.type === 'tool_result' && block.tool_use_id) { + const m = /\bagentId:\s*([A-Za-z0-9_-]+)/.exec(_blockText(block)); + if (m) map.set(m[1], block.tool_use_id); + } else if (block.type === 'text' && typeof block.text === 'string') { + const tid = /([A-Za-z0-9_-]+)<\/task-id>/.exec(block.text); + const tuid = /(toolu_[A-Za-z0-9_-]+)<\/tool-use-id>/.exec(block.text); + if (tid && tuid) map.set(tid[1], tuid[1]); + } + } + } + return map; +} + /** * Find the subagent transcript most likely to belong to the Task call we * just observed. `windowMs` is how far back to look; the default is wide @@ -219,4 +308,7 @@ module.exports = { readSubagentTranscript, findUsageForTask, listWorkflowAgentTranscripts, + listPlainSubagentTranscripts, + parentTranscriptPathFor, + mapAgentToolUseIds, }; diff --git a/plugins/session-intelligence/lib/config.js b/plugins/session-intelligence/lib/config.js index d3f743d..393cfad 100644 --- a/plugins/session-intelligence/lib/config.js +++ b/plugins/session-intelligence/lib/config.js @@ -38,28 +38,34 @@ const STATUSLINE_PRESETS = { // `93.0%` signal so we can retire the 1.5s npx spend. // Line 2: session activity + token economics — live "what's happening // right now" row at eye level, just below the warning bar. - // Line 3: quota + cwd — Claude Code 5h / 7d usage plus working dir. - // Data comes from the cached usage API (180 s TTL, refreshed - // by a detached worker so the hot path never blocks on HTTPS). - // Line 4: identity / repo / task — dim reference context at the bottom. + // Line 3: git + working dir delta. + // Line 4: identity / repo / task — dim reference context. + // Line 5: usage & models row — 5h/7d quota as used/available, per-model + // quota (dormant until plans have per-model limits), and the + // per-model spend split. All the "how much have I used / what + // did it go to" signals in one bottom line. verbose: [ 'tokens', 'compactAge', 'compactCost', 'cacheHit', 'siHealth', 'newline', - 'session', 'blockUsage', 'sessionId', 'costSaved', 'tools', 'tokenFlow', + 'blockUsage', 'session', 'sessionId', 'costSaved', 'tools', 'tokenFlow', 'tokenSpeed', 'newline', 'branch', 'diffstat', 'cwd', 'activeRoot', 'newline', - 'model', 'weekUsage', 'outputStyle', 'thinking', 'task', + 'model', 'outputStyle', 'thinking', 'task', + 'newline', + 'weekUsage', 'modelQuota', 'modelSplit', ], - // Token-economics focus — same 4-line skeleton, adds cacheTokens. + // Token-economics focus — same skeleton, adds cacheTokens. 'verbose-cache': [ 'tokens', 'compactAge', 'compactCost', 'newline', - 'session', 'sessionId', 'tools', 'costSaved', 'tokenFlow', 'cacheHit', 'cacheTokens', + 'blockUsage', 'session', 'sessionId', 'tools', 'costSaved', 'tokenFlow', 'cacheHit', 'cacheTokens', 'tokenSpeed', 'newline', - 'weekUsage', 'blockUsage', 'cwd', + 'cwd', 'newline', 'model', 'project', 'task', + 'newline', + 'weekUsage', 'modelQuota', 'modelSplit', ], }; diff --git a/plugins/session-intelligence/lib/cost-estimation.js b/plugins/session-intelligence/lib/cost-estimation.js index d4d3e4c..9c98ce6 100644 --- a/plugins/session-intelligence/lib/cost-estimation.js +++ b/plugins/session-intelligence/lib/cost-estimation.js @@ -17,9 +17,10 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -// Per-million-token prices (USD). Loose defaults; the caller can override -// via a config-loaded price list so pricing can be updated without a code -// change. +// Per-million-token prices (USD). Loose conservative default (legacy Opus +// rates — an upper bound for any model we don't recognize); the caller can +// override via a config-loaded price list so pricing can be updated without +// a code change. const DEFAULT_PRICES = { input: 15, cache_creation: 18.75, @@ -27,24 +28,53 @@ const DEFAULT_PRICES = { output: 75, }; -// Per-model price tables. Lookup is by family prefix (the date suffix on -// model IDs like `claude-haiku-4-5-20251001` varies). Subagents commonly -// run on a different model than the parent (Haiku for Explore-class agents, -// Sonnet for reviewers), so a per-model lookup is needed for honest cost. +// Per-model price tables (platform.claude.com/docs/en/about-claude/pricing). +// Lookup is by family prefix (the date suffix on model IDs like +// `claude-haiku-4-5-20251001` varies), longest prefix first. Subagents +// commonly run on a different model than the parent (Haiku for Explore-class +// agents, Sonnet for reviewers), so a per-model lookup is needed for honest +// cost. cache_creation is the 5-minute write rate (1.25x input) — what +// Claude Code sessions use. const PRICING_BY_MODEL = { - 'claude-opus': { input: 15, cache_creation: 18.75, cache_read: 1.50, output: 75 }, - 'claude-sonnet': { input: 3, cache_creation: 3.75, cache_read: 0.30, output: 15 }, - 'claude-haiku': { input: 1, cache_creation: 1.25, cache_read: 0.10, output: 5 }, + // Claude 5 family — Fable 5 / Mythos 5 share pricing. + 'claude-fable': { input: 10, cache_creation: 12.50, cache_read: 1.00, output: 50 }, + 'claude-mythos': { input: 10, cache_creation: 12.50, cache_read: 1.00, output: 50 }, + // Opus 4.5+ repriced to $5/$25 (Nov 2025). Opus 4.1 and the dated Opus 4 + // base id (claude-opus-4-20250514) stay on legacy $15/$75 — pinned with + // longer prefixes so they win over the family entry below. + 'claude-opus-4-1': { input: 15, cache_creation: 18.75, cache_read: 1.50, output: 75 }, + 'claude-opus-4-2025': { input: 15, cache_creation: 18.75, cache_read: 1.50, output: 75 }, + 'claude-opus': { input: 5, cache_creation: 6.25, cache_read: 0.50, output: 25 }, + // Sonnet 5 introductory pricing runs through 2026-08-31; priceForModel + // swaps to the standard sonnet family entry from 2026-09-01. + 'claude-sonnet-5': { input: 2, cache_creation: 2.50, cache_read: 0.20, output: 10 }, + 'claude-sonnet': { input: 3, cache_creation: 3.75, cache_read: 0.30, output: 15 }, + 'claude-haiku': { input: 1, cache_creation: 1.25, cache_read: 0.10, output: 5 }, }; -function priceForModel(modelId) { - if (!modelId || typeof modelId !== 'string') return DEFAULT_PRICES; +const SONNET5_INTRO_END_MS = Date.parse('2026-09-01T00:00:00Z'); + +/** + * Price table for a recognized model family, or null when the model id + * matches no known family. `at` (ms epoch) selects date-dependent pricing — + * pass a row's own timestamp when repricing historical usage. + */ +function knownPriceForModel(modelId, at = Date.now()) { + if (!modelId || typeof modelId !== 'string') return null; // Match longest family prefix first. const keys = Object.keys(PRICING_BY_MODEL).sort((a, b) => b.length - a.length); for (const k of keys) { - if (modelId.startsWith(k)) return PRICING_BY_MODEL[k]; + if (!modelId.startsWith(k)) continue; + if (k === 'claude-sonnet-5' && at >= SONNET5_INTRO_END_MS) { + return PRICING_BY_MODEL['claude-sonnet']; + } + return PRICING_BY_MODEL[k]; } - return DEFAULT_PRICES; + return null; +} + +function priceForModel(modelId, at = Date.now()) { + return knownPriceForModel(modelId, at) || DEFAULT_PRICES; } /** Cost of one usage block in USD. */ @@ -100,18 +130,19 @@ function totalCacheSavedFromTranscript(transcriptPath, sessionId, prices = DEFAU * read picks up from the offset, so only tail-bytes get re-counted for saved). */ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES) { - const empty = { cost: 0, saved: 0, input: 0, output: 0, cached: 0, creation: 0 }; + const empty = { cost: 0, saved: 0, input: 0, output: 0, cached: 0, creation: 0, byModel: {} }; if (!transcriptPath || !fs.existsSync(transcriptPath)) return empty; let stat; try { stat = fs.statSync(transcriptPath); } catch { return empty; } const sid = String(sessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, ''); - // v2: includes seenIds for streaming-snapshot dedupe. The transcript - // emits one row per stream chunk update with the same `message.id`, and - // the prior schema double-counted every snapshot (typically 4-7×). - // Old v1 cache files are abandoned (TMPDIR cleanup handles eviction). - const cacheFile = path.join(os.tmpdir(), `claude-cost-${sid}.v2`); + // v4: adds per-model cost buckets (`byModel`) for the modelSplit field. + // v3 introduced per-turn model pricing (each line's `message.model` + // looked up via knownPriceForModel; the `prices` arg is only the + // unknown-model fallback). Older cache files are abandoned (TMPDIR + // cleanup handles eviction). + const cacheFile = path.join(os.tmpdir(), `claude-cost-${sid}.v4`); let cachedOffset = 0; let cachedCost = 0; @@ -121,6 +152,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES let cachedCacheRead = 0; let cachedCreation = 0; let cachedSeenIds = []; + let cachedByModel = {}; try { const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); if (cached && typeof cached.offset === 'number' && typeof cached.cost === 'number' @@ -133,6 +165,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES cachedCacheRead = typeof cached.cached === 'number' ? cached.cached : 0; cachedCreation = typeof cached.creation === 'number' ? cached.creation : 0; cachedSeenIds = Array.isArray(cached.seenIds) ? cached.seenIds : []; + if (cached.byModel && typeof cached.byModel === 'object') cachedByModel = cached.byModel; } } catch { /* cache miss or corrupt — read from 0 */ } const seen = new Set(cachedSeenIds); @@ -141,6 +174,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES if (stat.size < cachedOffset) { cachedOffset = 0; cachedCost = 0; cachedSaved = 0; cachedInput = 0; cachedOutput = 0; cachedCacheRead = 0; cachedCreation = 0; + cachedByModel = {}; seen.clear(); } if (stat.size === cachedOffset) { @@ -148,6 +182,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES cost: cachedCost, saved: cachedSaved, input: cachedInput, output: cachedOutput, cached: cachedCacheRead, creation: cachedCreation, + byModel: cachedByModel, }; } @@ -158,6 +193,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES let newCacheRead = cachedCacheRead; let newCreation = cachedCreation; let newOffset = cachedOffset; + const byModel = { ...cachedByModel }; try { const fd = fs.openSync(transcriptPath, 'r'); try { @@ -182,8 +218,16 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES if (seen.has(mid)) continue; seen.add(mid); } - newCost += costFromUsage(u, prices); - newSaved += savedFromUsage(u, prices); + // Per-turn model pricing: a session can mix models (plan-mode + // Opus turns inside a Sonnet session, model switches). Fall + // back to the caller's price list only when the turn's model + // is absent or unrecognized. + const lineModel = (d.message && d.message.model) || 'other'; + const linePrices = knownPriceForModel(lineModel) || prices; + const lineCost = costFromUsage(u, linePrices); + byModel[lineModel] = (byModel[lineModel] || 0) + lineCost; + newCost += lineCost; + newSaved += savedFromUsage(u, linePrices); newInput += u.input_tokens || 0; newOutput += u.output_tokens || 0; newCacheRead += u.cache_read_input_tokens || 0; @@ -199,6 +243,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES cost: cachedCost, saved: cachedSaved, input: cachedInput, output: cachedOutput, cached: cachedCacheRead, creation: cachedCreation, + byModel: cachedByModel, }; } @@ -208,6 +253,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES input: newInput, output: newOutput, cached: newCacheRead, creation: newCreation, seenIds: Array.from(seen), + byModel, }), 'utf8'); } catch { /* best effort */ } @@ -215,6 +261,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES cost: newCost, saved: newSaved, input: newInput, output: newOutput, cached: newCacheRead, creation: newCreation, + byModel, }; } @@ -298,6 +345,7 @@ function formatUsd(n) { module.exports = { DEFAULT_PRICES, PRICING_BY_MODEL, + knownPriceForModel, priceForModel, costFromUsage, savedFromUsage, diff --git a/plugins/session-intelligence/lib/events.js b/plugins/session-intelligence/lib/events.js index bbb9e7c..f5cc635 100644 --- a/plugins/session-intelligence/lib/events.js +++ b/plugins/session-intelligence/lib/events.js @@ -162,6 +162,15 @@ function initSchema(db) { CREATE INDEX IF NOT EXISTS agent_invocations_sid_idx ON agent_invocations(sid); CREATE INDEX IF NOT EXISTS agent_invocations_t_idx ON agent_invocations(t); CREATE INDEX IF NOT EXISTS agent_invocations_type_idx ON agent_invocations(subagent_type); + + CREATE TABLE IF NOT EXISTS session_model_costs ( + sid TEXT NOT NULL, + model TEXT NOT NULL, + cost_usd REAL, + t INTEGER NOT NULL, + PRIMARY KEY (sid, model) + ); + CREATE INDEX IF NOT EXISTS session_model_costs_t_idx ON session_model_costs(t); `); // Additive migration: new columns for derived usage/cost on agent_invocations. @@ -205,6 +214,40 @@ function initSchema(db) { db.pragma('user_version = 1'); } } catch { /* backfill is best-effort — the sid-join read path still works */ } + + // One-time cost reprice (user_version 1 → 2). The pricing table shipped + // with only opus/sonnet/haiku family entries at legacy Opus 4.1-era rates; + // Opus 4.5+ actually bills at $5/$25 (rows were ~3x overstated) and the + // Claude 5 family (Fable/Mythos) fell through to the Opus default. + // Token counts on the rows are authoritative, so recompute cost_usd from + // the current per-model table. Rows without a model or tokens are skipped. + try { + const ver = db.pragma('user_version', { simple: true }); + if (ver < 2) { + const { priceForModel, costFromUsage } = require('./cost-estimation'); + const rows = db.prepare(` + SELECT id, model, t, input_tokens, output_tokens, + cache_creation_tokens, cache_read_tokens + FROM agent_invocations + WHERE model IS NOT NULL AND model != '' + AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL) + `).all(); + const upd = db.prepare('UPDATE agent_invocations SET cost_usd = ? WHERE id = ?'); + const reprice = db.transaction((all) => { + for (const r of all) { + const cost = costFromUsage({ + input_tokens: r.input_tokens, + output_tokens: r.output_tokens, + cache_creation_input_tokens: r.cache_creation_tokens, + cache_read_input_tokens: r.cache_read_tokens, + }, priceForModel(r.model, r.t || Date.now())); + upd.run(cost, r.id); + } + }); + reprice(rows); + db.pragma('user_version = 2'); + } + } catch { /* best-effort — unpriced rows are still better than a crash */ } } // ─── Writers ──────────────────────────────────────────────────────────────── @@ -394,6 +437,164 @@ function reconcileWorkflowAgents({ cwd, projectsRoot } = {}) { return inserted; } +/** + * Backfill usage columns on agent_invocations rows the live tracker recorded + * blind. Agent/Task calls run in the background by default now, so when + * PostToolUse fires the subagent transcript usually hasn't flushed any usage + * yet — si-agent-tracker records the row with NULL model/tokens/cost. By the + * next SessionStart the transcripts are complete; re-read them and fill the + * gaps by matching transcript.parentToolUseId to the row's tool_use_id. + * + * Bounded on both sides: only rows from the last `maxAgeDays` are candidates, + * and only transcripts modified in that window are parsed. Idempotent — a + * filled row leaves the candidate set, and rows whose transcript never + * surfaces (evicted, launched from a subdir cwd) age out of the window. + * Returns the number of rows updated. + */ +function reconcileSubagentUsage({ cwd, projectsRoot, maxAgeDays = 45 } = {}) { + const db = openDb(); + if (!db || !cwd) return 0; + let agentUsage; + try { agentUsage = require('./agent-usage'); } catch { return 0; } + + const sinceMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000; + let rows; + try { + rows = db.prepare(` + SELECT id, tool_use_id FROM agent_invocations + WHERE (model IS NULL OR model = '') + AND tool_use_id IS NOT NULL + AND t > ? + `).all(sinceMs); + } catch { return 0; } + if (!rows.length) return 0; + const wanted = new Map(rows.map((r) => [r.tool_use_id, r.id])); + + let transcripts; + try { transcripts = agentUsage.listPlainSubagentTranscripts({ cwd, projectsRoot, sinceMs }); } + catch { return 0; } + if (!transcripts || !transcripts.length) return 0; + + let updated = 0; + try { + const upd = db.prepare(` + UPDATE agent_invocations + SET model = ?, input_tokens = ?, output_tokens = ?, + cache_creation_tokens = ?, cache_read_tokens = ?, cost_usd = ?, + duration_ms = COALESCE(?, duration_ms) + WHERE id = ? + `); + // agentId → tool_use_id pair maps, built lazily one per parent sid. + // Newer Claude Code builds dropped parentToolUseId from subagent + // transcripts, so the pairing comes from the parent transcript instead. + const pairMaps = new Map(); + for (const tr of transcripts) { + if (!wanted.size) break; + let parsed; + try { parsed = agentUsage.readSubagentTranscript(tr.path); } catch { continue; } + if (!parsed || !parsed.model) continue; + let toolUseId = parsed.parentToolUseId || null; + if (!toolUseId && parsed.agentId) { + let pm = pairMaps.get(tr.sid); + if (pm === undefined) { + try { + pm = agentUsage.mapAgentToolUseIds( + agentUsage.parentTranscriptPathFor(cwd, tr.sid, projectsRoot), + ); + } catch { pm = new Map(); } + pairMaps.set(tr.sid, pm); + } + toolUseId = pm.get(parsed.agentId) || null; + } + if (!toolUseId) continue; + const rowId = wanted.get(toolUseId); + if (rowId === undefined) continue; + const u = parsed.usage || {}; + const num = (n) => (Number.isFinite(n) ? n : null); + upd.run( + parsed.model, + num(u.input_tokens), num(u.output_tokens), + num(u.cache_creation_input_tokens), num(u.cache_read_input_tokens), + num(parsed.costUsd), num(parsed.durationMs), + rowId, + ); + wanted.delete(toolUseId); + updated += 1; + } + } catch { /* best-effort — partial backfill is fine, next session retries */ } + return updated; +} + +/** + * Per-model subagent spend for one session — feeds the statusline + * modelSplit field so the "all models" line includes agent runs, whose + * usage lives in their own transcripts and never appears in the parent + * session transcript. Returns [{ model, cost_usd, n }] (rows with usage + * only; background agents not yet reconciled contribute nothing until the + * next SessionStart backfill). Never throws. + */ +function agentModelCosts(sid) { + const db = openDb(); + if (!db || !sid) return []; + try { + return db.prepare(` + SELECT model, SUM(cost_usd) AS cost_usd, COUNT(*) AS n + FROM agent_invocations + WHERE sid = ? AND model IS NOT NULL AND model != '' AND cost_usd IS NOT NULL + GROUP BY model + `).all(sid); + } catch { return []; } +} + +/** + * Upsert the CURRENT per-model transcript spend for a session — written by + * the statusline each redraw (cheap: one WAL upsert per model). This is the + * cross-session record that weeklyModelSpend aggregates; parent-session + * per-model splits exist nowhere else once the transcript ages out. + */ +function upsertSessionModelCosts(sid, byModel) { + const db = openDb(); + if (!db || !sid || !byModel || typeof byModel !== 'object') return false; + try { + const up = db.prepare(` + INSERT INTO session_model_costs (sid, model, cost_usd, t) + VALUES (?, ?, ?, ?) + ON CONFLICT(sid, model) DO UPDATE SET cost_usd = excluded.cost_usd, t = excluded.t + `); + const now = Date.now(); + const tx = db.transaction((entries) => { + for (const [model, cost] of entries) { + if (!model || !(cost > 0)) continue; + up.run(sid, model, cost, now); + } + }); + tx(Object.entries(byModel)); + return true; + } catch { return false; } +} + +/** + * Per-model tracked spend over the trailing window: parent-session splits + * (session_model_costs) + subagent runs (agent_invocations). Feeds the + * statusline's per-model weekly-remaining estimate. [{ model, cost_usd }], + * highest spend first. Never throws. + */ +function weeklyModelSpend({ days = 7 } = {}) { + const db = openDb(); + if (!db) return []; + try { + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + return db.prepare(` + SELECT model, SUM(cost_usd) AS cost_usd FROM ( + SELECT model, cost_usd FROM session_model_costs WHERE t > ? AND cost_usd > 0 + UNION ALL + SELECT model, cost_usd FROM agent_invocations + WHERE t > ? AND model IS NOT NULL AND model != '' AND cost_usd > 0 + ) GROUP BY model ORDER BY cost_usd DESC + `).all(cutoff, cutoff); + } catch { return []; } +} + function markArchiveRecalled(toolUseId) { const db = openDb(); if (!db || !toolUseId) return false; @@ -697,6 +898,10 @@ module.exports = { deleteToolArchives, recordAgentInvocation, reconcileWorkflowAgents, + reconcileSubagentUsage, + agentModelCosts, + upsertSessionModelCosts, + weeklyModelSpend, aggregateStats, listRecentCompacts, _setDbPathForTest, diff --git a/plugins/session-intelligence/lib/session-context.js b/plugins/session-intelligence/lib/session-context.js index 71a4faa..19758df 100644 --- a/plugins/session-intelligence/lib/session-context.js +++ b/plugins/session-intelligence/lib/session-context.js @@ -79,6 +79,25 @@ function parseSessionContext(content) { return sections; } +/** + * Fetch a section body by title, tolerating annotated headings. Hand- + * maintained files often extend the heading in place — e.g. + * `## Current Task — #1695 Phase 2 (import-duration)` — which an exact-key + * lookup misses entirely (observed: a fresh repo-root file parsed as + * empty, pinning the statusline task cell to a 3-month-old value). + * Exact title wins; otherwise the first section whose title starts with + * `title` followed by a non-word boundary is used. + */ +function sectionByTitle(sections, title) { + if (sections[title] !== undefined) return sections[title] || ''; + for (const k of Object.keys(sections)) { + if (k.startsWith(title) && !/\w/.test(k.charAt(title.length))) { + return sections[k] || ''; + } + } + return ''; +} + /** * Read + parse `session-context.md` from a project directory. * @@ -119,8 +138,8 @@ function readSessionContext(projectDir, opts) { } const sections = parseSessionContext(content); - const rawTask = sections['Current Task'] || ''; - const rawKeyFiles = sections['Key Files'] || ''; + const rawTask = sectionByTitle(sections, 'Current Task'); + const rawKeyFiles = sectionByTitle(sections, 'Key Files'); const isAutofill = AUTOFILL_SENTINEL_RE.test(rawTask); return { diff --git a/plugins/session-intelligence/lib/token-speed.js b/plugins/session-intelligence/lib/token-speed.js new file mode 100644 index 0000000..cf800a1 --- /dev/null +++ b/plugins/session-intelligence/lib/token-speed.js @@ -0,0 +1,93 @@ +/** + * Output-token generation speed (tokens/sec) from the transcript tail. + * + * The transcript writes one row per stream-chunk update, all sharing the + * assistant message's id with a growing usage block. For one message the + * span (last chunk ts − first chunk ts) is pure generation wall-clock — + * no tool execution or user latency inside it — so + * `final output_tokens / span` is an honest turn-level speed. + * + * Messages flushed as a single row (span 0) carry no timing and are + * skipped. The estimate is the median of the last few usable turns so one + * throttled or bursty turn doesn't whipsaw the cell. + */ + +'use strict'; + +const fs = require('fs'); + +const TAIL_BYTES = 512 * 1024; +const MIN_SPAN_MS = 800; // sub-second spans quantize too coarsely to trust +const MIN_OUT_TOKENS = 50; // tiny turns divide noise by noise +const SAMPLE_TURNS = 3; + +/** + * @returns {{ tps: number, samples: number } | null} median speed across + * the most recent usable turns, or null when nothing in the tail is usable. + */ +function estimateTokenSpeed(transcriptPath, opts = {}) { + const tailBytes = opts.tailBytes || TAIL_BYTES; + const minSpanMs = opts.minSpanMs !== undefined ? opts.minSpanMs : MIN_SPAN_MS; + const minOut = opts.minOut !== undefined ? opts.minOut : MIN_OUT_TOKENS; + const sampleTurns = opts.sampleTurns || SAMPLE_TURNS; + + if (!transcriptPath) return null; + let stat; + try { stat = fs.statSync(transcriptPath); } catch { return null; } + + let text; + try { + const fd = fs.openSync(transcriptPath, 'r'); + try { + const start = Math.max(0, stat.size - tailBytes); + const buf = Buffer.alloc(stat.size - start); + fs.readSync(fd, buf, 0, buf.length, start); + text = buf.toString('utf8'); + // Drop the first line when we started mid-file — it's a partial row. + if (start > 0) { + const nl = text.indexOf('\n'); + text = nl >= 0 ? text.slice(nl + 1) : ''; + } + } finally { fs.closeSync(fd); } + } catch { return null; } + + // message.id → { firstTs, lastTs, out, order } across its chunk rows. + const byMsg = new Map(); + let order = 0; + for (const line of text.split('\n')) { + if (!line) continue; + let d; + try { d = JSON.parse(line); } catch { continue; } + if (!d || d.type !== 'assistant') continue; + const m = d.message; + if (!m || !m.id || !m.usage) continue; + const ts = d.timestamp ? Date.parse(d.timestamp) : NaN; + if (!Number.isFinite(ts)) continue; + const out = m.usage.output_tokens || 0; + const cur = byMsg.get(m.id); + if (!cur) { + byMsg.set(m.id, { firstTs: ts, lastTs: ts, out, order: order++ }); + } else { + if (ts < cur.firstTs) cur.firstTs = ts; + if (ts > cur.lastTs) cur.lastTs = ts; + if (out > cur.out) cur.out = out; + } + } + + const usable = []; + for (const v of byMsg.values()) { + const spanMs = v.lastTs - v.firstTs; + if (spanMs >= minSpanMs && v.out >= minOut) { + usable.push({ order: v.order, tps: v.out / (spanMs / 1000) }); + } + } + if (!usable.length) return null; + + usable.sort((a, b) => a.order - b.order); + const recent = usable.slice(-sampleTurns).map((u) => u.tps).sort((a, b) => a - b); + const mid = Math.floor(recent.length / 2); + const tps = recent.length % 2 ? recent[mid] : (recent[mid - 1] + recent[mid]) / 2; + return { tps, samples: recent.length }; +} + +module.exports = { estimateTokenSpeed }; diff --git a/plugins/session-intelligence/lib/usage-api.js b/plugins/session-intelligence/lib/usage-api.js index 2724027..398502c 100644 --- a/plugins/session-intelligence/lib/usage-api.js +++ b/plugins/session-intelligence/lib/usage-api.js @@ -64,13 +64,25 @@ function readUsageCache() { extraUsageLimit: typeof data.extraUsageLimit === 'number' ? data.extraUsageLimit : null, extraUsageUsed: typeof data.extraUsageUsed === 'number' ? data.extraUsageUsed : null, extraUsageUtilization: typeof data.extraUsageUtilization === 'number' ? data.extraUsageUtilization : null, + modelQuotas: (data.modelQuotas && typeof data.modelQuotas === 'object') ? data.modelQuotas : null, error: typeof data.error === 'string' ? data.error : null, fetchedAt: typeof data.fetchedAt === 'number' ? data.fetchedAt : null, + // When these numbers were last actually true — error caches carry the + // previous good values forward, so goodAt can be much older than + // fetchedAt during an API outage. + goodAt: typeof data.goodAt === 'number' ? data.goodAt : null, + retryAfterMs: typeof data.retryAfterMs === 'number' ? data.retryAfterMs : null, age, stale: age > CACHE_MAX_AGE_MS, }; } +// Minimum wait after a failed fetch before trying again. A 429's +// Retry-After extends this; without the floor, an erroring cache retried +// on every redraw (gated only by the 30 s spawn lock), which hammered a +// rate-limited endpoint for the whole outage. +const ERROR_BACKOFF_MS = 5 * 60 * 1000; + /** * Spawn the refresh worker (usage-refresh.js) in a detached child. Returns * true if a worker was spawned, false if skipped (cache fresh, lock held, @@ -80,6 +92,13 @@ function triggerRefresh({ force = false } = {}) { const cached = readUsageCache(); if (!force && cached && !cached.stale && !cached.error) return false; + // Back off after errors: wait out max(Retry-After, ERROR_BACKOFF_MS) + // from the failed fetch before spawning another worker. `force` bypasses. + if (!force && cached && cached.error && Number.isFinite(cached.fetchedAt)) { + const backoff = Math.max(cached.retryAfterMs || 0, ERROR_BACKOFF_MS); + if (Date.now() < cached.fetchedAt + backoff) return false; + } + // Respect an active lock so we don't spawn a pile of workers when the // statusline redraws five times in a second. try { @@ -116,4 +135,5 @@ module.exports = { cacheFilePath, lockFilePath, CACHE_MAX_AGE_MS, + ERROR_BACKOFF_MS, }; diff --git a/plugins/session-intelligence/lib/usage-refresh.js b/plugins/session-intelligence/lib/usage-refresh.js index 57cd1c7..ec15199 100644 --- a/plugins/session-intelligence/lib/usage-refresh.js +++ b/plugins/session-intelligence/lib/usage-refresh.js @@ -98,7 +98,15 @@ function fetchUsage(token) { res.setEncoding('utf8'); res.on('data', (chunk) => { body += chunk; }); res.on('end', () => { - if (res.statusCode === 429) return resolve({ error: 'rate-limited' }); + if (res.statusCode === 429) { + // Honor the server's backoff hint — usage-api.js suppresses + // refresh attempts until fetchedAt + retryAfterMs. Without this + // we re-hit the endpoint every ~30s for the whole outage. + return resolve({ + error: 'rate-limited', + retryAfterMs: parseRetryAfterMs(res.headers['retry-after']), + }); + } if (res.statusCode !== 200) return resolve({ error: 'api-error' }); try { const parsed = JSON.parse(body); @@ -107,6 +115,11 @@ function fetchUsage(token) { sessionResetAt: parsed && parsed.five_hour && parsed.five_hour.resets_at || null, weeklyUsage: numOrNull(parsed && parsed.seven_day && parsed.seven_day.utilization), weeklyResetAt: parsed && parsed.seven_day && parsed.seven_day.resets_at || null, + // Per-model weekly buckets (seven_day_opus, seven_day_sonnet, + // seven_day_cowork, ...). Present in the schema but null unless + // the plan has per-model limits — captured so modelQuota lights + // up the moment Anthropic populates them. + modelQuotas: extractModelQuotas(parsed), extraUsageEnabled: !!(parsed && parsed.extra_usage && parsed.extra_usage.is_enabled), extraUsageLimit: numOrNull(parsed && parsed.extra_usage && parsed.extra_usage.monthly_limit), extraUsageUsed: numOrNull(parsed && parsed.extra_usage && parsed.extra_usage.used_credits), @@ -125,16 +138,78 @@ function numOrNull(v) { return typeof v === 'number' && Number.isFinite(v) ? v : null; } +/** + * Collect non-null per-model weekly buckets from the raw payload: + * `seven_day_` keys with a numeric utilization. Returns e.g. + * `{ opus: { utilization: 12, resetsAt: '...' } }`, or null when the plan + * has no per-model limits (the common case — buckets exist but are null). + */ +function extractModelQuotas(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + const out = {}; + for (const [k, v] of Object.entries(parsed)) { + const m = /^seven_day_([a-z0-9_]+)$/.exec(k); + if (!m || !v || typeof v !== 'object') continue; + const util = numOrNull(v.utilization); + if (util === null) continue; + out[m[1]] = { utilization: util, resetsAt: v.resets_at || null }; + } + return Object.keys(out).length ? out : null; +} + +/** `Retry-After` is either delta-seconds or an HTTP-date. Null when absent/garbage. */ +function parseRetryAfterMs(header) { + if (header === undefined || header === null || header === '') return null; + const secs = Number(header); + if (Number.isFinite(secs) && secs >= 0) return Math.round(secs * 1000); + const date = Date.parse(String(header)); + if (Number.isFinite(date)) return Math.max(0, date - Date.now()); + return null; +} + +// Fields worth surviving an API outage — the statusline renders these (with +// a stale marker) instead of blanking the usage cells for the whole outage. +const CARRY_FIELDS = [ + 'sessionUsage', 'sessionResetAt', 'weeklyUsage', 'weeklyResetAt', + 'extraUsageEnabled', 'extraUsageLimit', 'extraUsageUsed', 'extraUsageUtilization', + 'modelQuotas', +]; + +/** + * Build the cache payload for a fetch result. Success stamps `goodAt` (when + * these numbers were actually true). Errors carry the previous cache's good + * values forward — `goodAt` survives from the last success (older caches + * that predate the field fall back to the previous `fetchedAt`). + * Pure; exported for tests. + */ +function buildCachePayload(result, prev, now = Date.now()) { + if (!result || !result.error) return { ...result, goodAt: now }; + const carried = {}; + if (prev && typeof prev === 'object') { + for (const k of CARRY_FIELDS) { + if (prev[k] !== undefined && prev[k] !== null) carried[k] = prev[k]; + } + const prevGoodAt = numOrNull(prev.goodAt) ?? (prev.error ? null : numOrNull(prev.fetchedAt)); + if (prevGoodAt !== null) carried.goodAt = prevGoodAt; + } + return { ...carried, ...result }; +} + +function readPrevCache() { + try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } + catch { return null; } +} + async function main() { if (isLockActive()) return; writeLock(); try { const token = readToken(); - if (!token) { writeCache({ error: 'no-credentials' }); return; } + if (!token) { writeCache(buildCachePayload({ error: 'no-credentials' }, readPrevCache())); return; } const result = await fetchUsage(token); - writeCache(result); + writeCache(buildCachePayload(result, readPrevCache())); } finally { clearLock(); } @@ -144,4 +219,4 @@ if (require.main === module) { main().catch(() => clearLock()); } -module.exports = { readToken, fetchUsage }; +module.exports = { readToken, fetchUsage, parseRetryAfterMs, buildCachePayload, extractModelQuotas }; diff --git a/plugins/session-intelligence/statusline/statusline-intel.js b/plugins/session-intelligence/statusline/statusline-intel.js index 7df62c4..8d9bd5e 100755 --- a/plugins/session-intelligence/statusline/statusline-intel.js +++ b/plugins/session-intelligence/statusline/statusline-intel.js @@ -107,6 +107,67 @@ function loadUsageApiLib() { return null; } +function loadTokenSpeedLib() { + const dir = resolveLibDir(); + if (!dir) return null; + const p = path.join(dir, 'token-speed.js'); + try { + if (fs.existsSync(p)) return require(p); + } catch { /* optional */ } + return null; +} + +// Per-session subagent spend by model, from the events DB. Lazy + cached +// per process; returns [] when the lib or DB is unavailable so the +// statusline never blocks on sqlite. Subagents bill in their own +// transcripts — without this the modelSplit line only ever showed the +// parent conversation's model. +let _eventsLibCached = undefined; +function loadEventsLib() { + if (_eventsLibCached !== undefined) return _eventsLibCached; + _eventsLibCached = null; + const dir = resolveLibDir(); + if (!dir) return null; + try { + const p = path.join(dir, 'events.js'); + if (fs.existsSync(p)) _eventsLibCached = require(p); + } catch { _eventsLibCached = null; } + return _eventsLibCached; +} + +let _agentCostsCached = undefined; +function agentModelCostsSafe(sid) { + if (_agentCostsCached !== undefined) return _agentCostsCached; + _agentCostsCached = []; + if (!sid) return _agentCostsCached; + const events = loadEventsLib(); + try { + if (events && typeof events.agentModelCosts === 'function') { + _agentCostsCached = events.agentModelCosts(sid) || []; + } + } catch { _agentCostsCached = []; } + return _agentCostsCached; +} + +// Per-model price lookup from lib/cost-estimation.js — keeps the pricing +// table in ONE place. Optional like every lib load here: when absent, the +// accumulators fall back to the flat `prices` argument for every turn. +let _costLibCached = undefined; +function knownPriceForModelSafe(modelId) { + if (_costLibCached === undefined) { + const dir = resolveLibDir(); + _costLibCached = null; + if (dir) { + const p = path.join(dir, 'cost-estimation.js'); + try { if (fs.existsSync(p)) _costLibCached = require(p); } + catch { _costLibCached = null; } + } + } + if (!_costLibCached || typeof _costLibCached.knownPriceForModel !== 'function') return null; + try { return _costLibCached.knownPriceForModel(modelId); } + catch { return null; } +} + // Cached per-process so blockUsage + weekUsage share one disk read + one // "should we kick off a background refresh" decision per render. let _usageCached = undefined; @@ -124,7 +185,7 @@ function loadUsage() { // soon-to-exhaust quota reads at a glance (weekly, where the signal is // load-bearing). 'dim' keeps the cell muted to match the rest of the row // (5h block, where the percent matters less than the countdown). -function _renderUsageCellImpl(label, pctRaw, resetAt, C, colourMode = 'zone') { +function _renderUsageCellImpl(label, pctRaw, resetAt, C, colourMode = 'zone', stale = false) { if (typeof pctRaw !== 'number' || !Number.isFinite(pctRaw)) return ''; const pct = Math.round(pctRaw); let resetStr = ''; @@ -132,15 +193,18 @@ function _renderUsageCellImpl(label, pctRaw, resetAt, C, colourMode = 'zone') { const ms = resetAtMs(resetAt) - Date.now(); if (Number.isFinite(ms) && ms > 0) resetStr = fmtDurationTight(ms); } + // `~` marks a value carried over from the last successful fetch while + // the usage API is erroring (rate-limited etc.) — stale, better than blank. + const pctStr = `${stale ? '~' : ''}${pct}%`; let head; if (colourMode === 'dim') { - head = `${C.dim}${label}:${pct}%${C.reset}`; + head = `${C.dim}${label}:${pctStr}${C.reset}`; } else { let col = C.green; if (pct >= 95) col = C.red; else if (pct >= 85) col = C.orange; else if (pct >= 60) col = C.yellow; - head = `${col}${label}:${pct}%${C.reset}`; + head = `${col}${label}:${pctStr}${C.reset}`; } // `r:` prefix on the reset duration keeps the cell self-explanatory. // Plain-space separator inside the cell — the outer `·` between fields @@ -451,7 +515,7 @@ function readSessionContextTask(projectDir) { } const firstLine = body.split('\n').find((l) => l.trim().length > 0) || ''; if (isPlaceholder(firstLine)) return null; - const cleaned = firstLine.replace(/^[-*#\s]+/, '').trim(); + const cleaned = firstLine.replace(/^[-*#\s]+/, '').replace(/\*\*/g, '').trim(); return cleaned ? { text: cleaned, mtimeMs } : null; } @@ -487,14 +551,35 @@ function readSessionContextTask(projectDir) { const firstLine = body.split('\n').find((l) => l.trim().length > 0) || ''; if (isPlaceholder(firstLine)) return null; - const cleaned = firstLine.replace(/^[-*#\s]+/, '').trim(); + const cleaned = firstLine.replace(/^[-*#\s]+/, '').replace(/\*\*/g, '').trim(); return cleaned ? { text: cleaned, mtimeMs } : null; } +// Nearest ancestor of cwd (inclusive) holding a session-context.md — some +// projects maintain the file at the repo root by hand instead of (or as +// well as) the ~/.claude/projects// copy SI seeds. Bounded walk so +// a deep cwd can't turn the statusline hot path into a filesystem crawl. +function findCwdContextDir(cwd) { + if (!cwd) return null; + const home = os.homedir(); + let dir = cwd; + for (let i = 0; i < 8; i++) { + try { if (fs.existsSync(path.join(dir, 'session-context.md'))) return dir; } catch { /* ignore */ } + const parent = path.dirname(dir); + if (!dir || dir === home || parent === dir) break; + dir = parent; + } + return null; +} + // The `task` field answers the question "what am I working on?" in order // of freshness: -// 1. session-context.md, if real content AND recent (mtime < staleHours). -// 2. session-context.md flagged as (stale) when older than that. +// 1. session-context.md — the FRESHER of the ~/.claude/projects// +// copy and a repo-root copy under cwd, if real content AND recent +// (mtime < staleHours). Projects that hand-maintain the repo-root file +// (long-lived orchestrator sessions) otherwise pin the cell to +// whatever the projects-dir copy last said — observed 3 months stale. +// 2. Same, flagged as (stale) when older than that. // 3. Last git commit subject — auto-refreshes every commit so the bar // keeps reflecting actual work even when the file is never updated. // 4. empty. @@ -504,7 +589,11 @@ function loadCurrentTask(projectDir, cwd, maxLen = 40, staleHours = 12) { // no room for the single-char ellipsis either. Treat bad input as "use // default" rather than producing garbage output. const safeMax = Number.isFinite(maxLen) && maxLen > 3 ? Math.floor(maxLen) : 40; - const fromFile = readSessionContextTask(projectDir); + const fromProj = readSessionContextTask(projectDir); + const cwdDir = findCwdContextDir(cwd); + const fromRepo = cwdDir ? readSessionContextTask(cwdDir) : null; + let fromFile = fromProj; + if (fromRepo && (!fromProj || fromRepo.mtimeMs > fromProj.mtimeMs)) fromFile = fromRepo; if (fromFile && fromFile.text) { const ageMs = Date.now() - fromFile.mtimeMs; const stale = ageMs > staleHours * 60 * 60 * 1000; @@ -522,21 +611,27 @@ function loadCurrentTask(projectDir, cwd, maxLen = 40, staleHours = 12) { * Claude's effective context cap for the active model. * * 1. Explicit `statusline.contextCap` in config wins (escape hatch). - * 2. Legacy `[1m]` / `-1m` marker in the model id → 1M. - * 3. Any Opus 4.x / 5.x or Sonnet 4.x — 1M. Newer Claude Code builds - * dropped the `[1m]` marker once 1M became the default for these - * models, so key off the family name instead. - * 4. Fallback 200k. + * 2. `context_window.context_window_size` from the statusline stdin + * JSON — Claude Code ≥2.1.90 reports the session's real window, which + * beats any name-based guess. + * 3. Legacy `[1m]` / `-1m` marker in the model id → 1M. + * 4. Any Opus 4.x / 5.x, Sonnet 4.x / 5.x, or the Claude 5 family + * (Fable / Mythos) — 1M. Newer Claude Code builds dropped the `[1m]` + * marker once 1M became the default for these models, so key off the + * family name instead. + * 5. Fallback 200k (Haiku-class models). */ function contextCap(input, cfg) { if (cfg && Number.isFinite(cfg.contextCap) && cfg.contextCap > 0) { return cfg.contextCap; } + const cw = input && input.context_window && input.context_window.context_window_size; + if (Number.isFinite(cw) && cw > 0) return cw; const id = String( (input && input.model && (input.model.id || input.model.display_name)) || '' ).toLowerCase(); if (/\[1m\]|-1m|\b1m\b|1000k|1000000/.test(id)) return 1000000; - if (/opus[\s-]?[4-9]|sonnet[\s-]?[4-9]/.test(id)) return 1000000; + if (/opus[\s-]?[4-9]|sonnet[\s-]?[4-9]|fable|mythos/.test(id)) return 1000000; return 200000; } @@ -587,6 +682,7 @@ function zoneFor(tokens, zones) { } function fmtTokens(n) { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`; // 2.42B if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; // 1.25M if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; // 425.3k return String(n); @@ -594,8 +690,12 @@ function fmtTokens(n) { // Tight token formatter for cells that need every column (bar numerics, // tokenFlow). Drops decimals when the value is large enough that they read -// as noise — `124k` instead of `124.2k`, `26M` instead of `26.08M`. +// as noise — `124k` instead of `124.2k`, `26M` instead of `26.08M`, and +// rolls to B past a billion — `2.4B` instead of `2422M` (10-day sessions +// really do get there). function fmtTokensTight(n) { + if (n >= 10_000_000_000) return `${Math.round(n / 1_000_000_000)}B`; + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; if (n >= 10_000_000) return `${Math.round(n / 1_000_000)}M`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 10_000) return `${Math.round(n / 1_000)}k`; @@ -760,11 +860,11 @@ function costsSinceCompact(transcriptPath, sessionId, prices = DEFAULT_PRICES) { try { stat = fs.statSync(transcriptPath); } catch { return empty; } const sid = String(sessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, ''); - // v2: includes seenIds for streaming-snapshot dedupe. Same assistant - // message.id appears N times in the transcript (one row per stream chunk - // update), and the prior schema double-counted every snapshot. Old v1 - // cache files are abandoned (TMPDIR cleanup handles eviction). - const cacheFile = path.join(os.tmpdir(), `claude-cost-sincecompact-${sid}.v2`); + // v3: per-turn model pricing (each line's message.model is looked up; + // the flat `prices` arg only covers unknown models). v2 accumulated at + // one flat price list — mixing modes in one cache would blend pricing. + // Old v1/v2 cache files are abandoned (TMPDIR cleanup handles eviction). + const cacheFile = path.join(os.tmpdir(), `claude-cost-sincecompact-${sid}.v3`); let cache = null; try { @@ -805,8 +905,9 @@ function costsSinceCompact(transcriptPath, sessionId, prices = DEFAULT_PRICES) { if (seen.has(mid)) continue; seen.add(mid); } - cache.cost += costFromUsage(u, prices); - cache.saved += savedFromUsage(u, prices); + const linePrices = knownPriceForModelSafe(d.message && d.message.model) || prices; + cache.cost += costFromUsage(u, linePrices); + cache.saved += savedFromUsage(u, linePrices); } catch { /* skip bad line */ } } cache.offset += lastNl + 1; @@ -1333,10 +1434,14 @@ function buildRenderers(C) { /** Percentage of the context window consumed. Replaces ccstatusline's * `93.0%` signal so we can retire the 1.5 s `npx` spend. Uses zones.red - * as the 100% reference — matches the token-bar cap. Zone-colored: - * <60% green · 60-85% yellow · 85-95% orange · >=95% red */ - contextPct: (_input, ctx) => { - const max = (ctx.cfg.zones && ctx.cfg.zones.red) || 400000; + * as the 100% reference (the compact-advisory ceiling from context-rot + * research), clamped to 90% of the model's real window so a 200k-cap + * model can actually reach 100% — with a 400k red line it never would. + * Zone-colored: <60% green · 60-85% yellow · 85-95% orange · >=95% red */ + contextPct: (input, ctx) => { + const zoneRed = (ctx.cfg.zones && ctx.cfg.zones.red) || 400000; + const cap = contextCap(input, ctx.cfg); + const max = Math.min(zoneRed, Math.round(cap * 0.9)); if (!ctx.tokens || ctx.tokens <= 0) return ''; const pct = Math.round((ctx.tokens / max) * 100); let col = C.green; @@ -1346,22 +1451,161 @@ function buildRenderers(C) { return `${col}ctx:${pct}%${C.reset}`; }, - /** Claude Code 5-hour block usage + time until reset. Data from the - * cached usage API (180 s TTL, refreshed by a detached worker). Shown - * as `5h:47% · 3hr 12m` — percent then "resets in" duration. Empty - * when the cache is absent or has an error marker. */ - blockUsage: (_input, _ctx) => { + /** Claude Code 5-hour block usage + time until reset, as `b:47% · r:3hr12m`. + * + * Source order: + * 1. `rate_limits.five_hour` from the statusline stdin JSON — Claude + * Code ≥2.1.90 hands us the quota directly, fresh every redraw, + * zero API requests. + * 2. Fallback for older builds: the cached /api/oauth/usage poller. + * That endpoint aggressively 429s pollers (anthropic/claude-code + * #31637, closed not-planned), so it's strictly a fallback; while + * it errors the cache carries the last good values and the cell + * renders them with a `~` marker instead of vanishing. */ + blockUsage: (input, _ctx) => { + const fh = input && input.rate_limits && input.rate_limits.five_hour; + if (fh && typeof fh.used_percentage === 'number') { + return _renderUsageCellImpl('b', fh.used_percentage, fh.resets_at, C, 'dim', false); + } const u = loadUsage(); if (!u || typeof u.sessionUsage !== 'number') return ''; - return _renderUsageCellImpl('b', u.sessionUsage, u.sessionResetAt, C, 'dim'); + return _renderUsageCellImpl('b', u.sessionUsage, u.sessionResetAt, C, 'dim', !!u.error); }, - /** Weekly (7-day) usage + time until reset. Same shape as blockUsage - * with a `w` prefix. Format: `w:31% · r:4d 12hr`. */ - weekUsage: (_input, _ctx) => { + /** Weekly (7-day) usage + time until reset. Same shape and source + * order as blockUsage with a `w` prefix. Format: `w:31% · r:4d12hr`. */ + weekUsage: (input, _ctx) => { + const sd = input && input.rate_limits && input.rate_limits.seven_day; + if (sd && typeof sd.used_percentage === 'number') { + return _renderUsageCellImpl('w', sd.used_percentage, sd.resets_at, C, 'zone', false); + } const u = loadUsage(); if (!u || typeof u.weeklyUsage !== 'number') return ''; - return _renderUsageCellImpl('w', u.weeklyUsage, u.weeklyResetAt, C, 'zone'); + return _renderUsageCellImpl('w', u.weeklyUsage, u.weeklyResetAt, C, 'zone', !!u.error); + }, + + /** Per-model share of this session's transcript spend, e.g. + * `fable-5:72% opus-4-8:21% haiku-4-5:7%`. Answers "which models are + * burning the budget" at a glance. Cost share (not token share) so a + * cheap-but-chatty Haiku doesn't dwarf an expensive Fable turn. There + * is no per-model QUOTA — stdin rate_limits only carries the two + * account-wide windows — so share-of-session-spend is the honest + * per-model percentage. Top 4 models; empty until usage exists. */ + /** Output-token generation speed, `tps:235` (tokens/sec). Median of + * the last few streamed turns — per-message chunk-timestamp spans are + * pure generation time, so this tracks the model's real throughput + * (fast mode, load, long-output turns all show up). Falls back to the + * whole-session average (total output / total API time from stdin) + * when no streamed turn in the tail is usable. */ + tokenSpeed: (input, ctx) => { + let tps = null; + const lib = loadTokenSpeedLib(); + if (lib && typeof lib.estimateTokenSpeed === 'function') { + try { + const est = lib.estimateTokenSpeed(input.transcript_path || input.transcriptPath); + if (est && Number.isFinite(est.tps) && est.tps > 0) tps = est.tps; + } catch { /* degrade to fallback */ } + } + if (tps === null) { + const apiMs = input && input.cost && input.cost.total_api_duration_ms; + const outTok = (ctx.tokenTotals && ctx.tokenTotals.output) + || (input && input.context_window && input.context_window.total_output_tokens); + if (Number.isFinite(apiMs) && apiMs > 0 && Number.isFinite(outTok) && outTok > 0) { + tps = outTok / (apiMs / 1000); + } + } + if (tps === null || !(tps > 0)) return ''; + const v = tps >= 10 ? String(Math.round(tps)) : tps.toFixed(1); + return `${C.dim}tps:${v}${C.reset}`; + }, + + modelSplit: (input, ctx) => { + // Parent-conversation turns from the transcript accumulator... + const byModel = (ctx.tokenTotals && ctx.tokenTotals.byModel) || {}; + const merged = { ...byModel }; + // ...plus subagent runs from the events DB (their usage lives in + // separate transcripts and never appears in the parent transcript). + for (const row of agentModelCostsSafe(ctx.sessionId)) { + if (!row || !row.model || !(row.cost_usd > 0)) continue; + merged[row.model] = (merged[row.model] || 0) + row.cost_usd; + } + const entries = Object.entries(merged).filter(([, c]) => c > 0); + const total = entries.reduce((sum, [, c]) => sum + c, 0); + if (!entries.length || total <= 0) return ''; + entries.sort((a, b) => b[1] - a[1]); + + const events = loadEventsLib(); + // Persist this session's per-model split so weeklyModelSpend can see + // parent-session spend across sessions (agents are already in the DB). + try { + if (events && typeof events.upsertSessionModelCosts === 'function' && ctx.sessionId) { + events.upsertSessionModelCosts(ctx.sessionId, byModel); + } + } catch { /* telemetry is best-effort */ } + + // Per-model weekly-remaining ESTIMATE (`wr:~61%`): Anthropic exposes + // no per-model weekly quota (seven_day_ buckets are null on + // plans without per-model limits — verified 2026-07-17), so attribute + // the account weekly used% across models by their share of this + // week's tracked spend: wr(model) = 100 − weeklyUsed × spendShare. + // `~` marks it as SI's estimate, not an Anthropic number. + let weeklyUsed = null; + const sd = input && input.rate_limits && input.rate_limits.seven_day; + if (sd && typeof sd.used_percentage === 'number') weeklyUsed = sd.used_percentage; + let weekSpend = null; + if (weeklyUsed !== null && events && typeof events.weeklyModelSpend === 'function') { + try { weekSpend = events.weeklyModelSpend(); } catch { weekSpend = null; } + } + const weekTotal = weekSpend ? weekSpend.reduce((sum, r) => sum + (r.cost_usd || 0), 0) : 0; + const weekByModel = weekSpend + ? Object.fromEntries(weekSpend.map((r) => [r.model, r.cost_usd || 0])) + : {}; + + const parts = entries.slice(0, 4).map(([m, c]) => { + const name = m.replace(/^claude-/, '').replace(/-20\d{6,}$/, ''); + const usd = fmtUsd(c); + let cell = `${name}:sc${usd || '$0'}\u00b7sp${Math.round((c / total) * 100)}%`; + if (weeklyUsed !== null && weekTotal > 0) { + const share = (weekByModel[m] || 0) / weekTotal; + const wr = Math.max(0, Math.min(100, Math.round(100 - weeklyUsed * share))); + cell += `\u00b7wr:~${wr}%`; + } + return cell; + }); + return `${C.dim}${parts.join(' ')}${C.reset}`; + }, + + /** Per-model QUOTA used/available — dormant until real data exists. + * Neither stdin rate_limits (only five_hour/seven_day account windows) + * nor this account's oauth payload carries per-model limits today: the + * schema HAS seven_day_opus/_sonnet/_cowork buckets but they're null + * on plans without per-model limits (verified 2026-07-17 via a raw + * 200). Sources, both future-proof: (1) any extra rate_limits key on + * stdin with a used_percentage, (2) modelQuotas captured by the usage + * worker from non-null seven_day_ buckets. Renders e.g. + * `opus:12/88% sonnet:4/96%` the day either source lights up. */ + modelQuota: (input, _ctx) => { + const parts = []; + const rl = (input && input.rate_limits) || {}; + for (const [k, v] of Object.entries(rl)) { + if (k === 'five_hour' || k === 'seven_day') continue; + if (v && typeof v.used_percentage === 'number') { + const name = k.replace(/^(five_hour|seven_day)_/, ''); + const used = Math.round(v.used_percentage); + parts.push(`${name}:${used}/${Math.max(0, 100 - used)}%`); + } + } + if (!parts.length) { + const u = loadUsage(); + if (u && u.modelQuotas) { + for (const [name, q] of Object.entries(u.modelQuotas)) { + if (!q || typeof q.utilization !== 'number') continue; + const used = Math.round(q.utilization); + parts.push(`${name}:${u.error ? '~' : ''}${used}/${Math.max(0, 100 - used)}%`); + } + } + } + return parts.length ? `${C.dim}${parts.join(' ')}${C.reset}` : ''; }, /** Full working directory, ccstatusline-style. Collapses $HOME to `~` @@ -1546,9 +1790,10 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES try { stat = fs.statSync(transcriptPath); } catch { return empty; } const sid = String(sessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, ''); - // v2: includes seenIds for streaming-snapshot dedupe. See note in - // costsSinceCompact() — old v1 cache files are abandoned. - const cacheFile = path.join(os.tmpdir(), `claude-cost-${sid}.v2`); + // v4: adds per-model cost buckets (byModel) for the modelSplit field. + // Shares the cache name with lib/cost-estimation.js so both renderers see + // the same offsets; the version MUST bump in both files together. + const cacheFile = path.join(os.tmpdir(), `claude-cost-${sid}.v4`); let cachedOffset = 0; let cachedCost = 0; @@ -1558,6 +1803,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES let cachedCacheRead = 0; let cachedCreation = 0; let cachedSeenIds = []; + let cachedByModel = {}; try { const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); if (cached && typeof cached.offset === 'number' && typeof cached.cost === 'number' @@ -1570,6 +1816,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES cachedCacheRead = typeof cached.cached === 'number' ? cached.cached : 0; cachedCreation = typeof cached.creation === 'number' ? cached.creation : 0; cachedSeenIds = Array.isArray(cached.seenIds) ? cached.seenIds : []; + if (cached.byModel && typeof cached.byModel === 'object') cachedByModel = cached.byModel; } } catch { /* cache miss or corrupt — read from 0 */ } const seen = new Set(cachedSeenIds); @@ -1578,6 +1825,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES if (stat.size < cachedOffset) { cachedOffset = 0; cachedCost = 0; cachedSaved = 0; cachedInput = 0; cachedOutput = 0; cachedCacheRead = 0; cachedCreation = 0; + cachedByModel = {}; } // No new bytes — short-circuit the I/O entirely. @@ -1586,6 +1834,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES cost: cachedCost, saved: cachedSaved, input: cachedInput, output: cachedOutput, cached: cachedCacheRead, creation: cachedCreation, + byModel: cachedByModel, }; } @@ -1596,6 +1845,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES let newCacheRead = cachedCacheRead; let newCreation = cachedCreation; let newOffset = cachedOffset; + const byModel = { ...cachedByModel }; try { const fd = fs.openSync(transcriptPath, 'r'); try { @@ -1617,8 +1867,12 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES if (seen.has(mid)) continue; seen.add(mid); } - newCost += costFromUsage(u, prices); - newSaved += savedFromUsage(u, prices); + const lineModel = (d.message && d.message.model) || 'other'; + const linePrices = knownPriceForModelSafe(lineModel) || prices; + const lineCost = costFromUsage(u, linePrices); + byModel[lineModel] = (byModel[lineModel] || 0) + lineCost; + newCost += lineCost; + newSaved += savedFromUsage(u, linePrices); newInput += u.input_tokens || 0; newOutput += u.output_tokens || 0; newCacheRead += u.cache_read_input_tokens || 0; @@ -1634,6 +1888,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES cost: cachedCost, saved: cachedSaved, input: cachedInput, output: cachedOutput, cached: cachedCacheRead, creation: cachedCreation, + byModel: cachedByModel, }; } @@ -1643,6 +1898,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES input: newInput, output: newOutput, cached: newCacheRead, creation: newCreation, seenIds: Array.from(seen), + byModel, }), 'utf8'); } catch { /* best effort */ } @@ -1650,6 +1906,7 @@ function totalsFromTranscript(transcriptPath, sessionId, prices = DEFAULT_PRICES cost: newCost, saved: newSaved, input: newInput, output: newOutput, cached: newCacheRead, creation: newCreation, + byModel, }; } diff --git a/plugins/session-intelligence/tests/agent-usage.test.js b/plugins/session-intelligence/tests/agent-usage.test.js index 0547c5b..962c547 100644 --- a/plugins/session-intelligence/tests/agent-usage.test.js +++ b/plugins/session-intelligence/tests/agent-usage.test.js @@ -211,3 +211,62 @@ test('listWorkflowAgentTranscripts returns [] when no workflow dirs exist', () = try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } } }); + +test('listPlainSubagentTranscripts finds plain agent files, skips workflow layer, honors sinceMs', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'si-plain-list-')); + try { + const cwd = '/Users/x/DWS/CSM'; + const enc = agentUsage.encodeProjectPath(cwd); + const subDir = path.join(root, enc, 'sid-1', 'subagents'); + fs.mkdirSync(path.join(subDir, 'workflows', 'wf_a'), { recursive: true }); + fs.writeFileSync(path.join(subDir, 'agent-fresh.jsonl'), '{}\n'); + fs.writeFileSync(path.join(subDir, 'agent-old.jsonl'), '{}\n'); + // Workflow-layer transcript must NOT appear — that's the other lister's job. + fs.writeFileSync(path.join(subDir, 'workflows', 'wf_a', 'agent-wf.jsonl'), '{}\n'); + // Age one file out of the window. + const old = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + fs.utimesSync(path.join(subDir, 'agent-old.jsonl'), old, old); + + const all = agentUsage.listPlainSubagentTranscripts({ cwd, projectsRoot: root }); + assert.deepEqual(all.map((t) => path.basename(t.path)).sort(), ['agent-fresh.jsonl', 'agent-old.jsonl']); + assert.equal(all[0].sid, 'sid-1'); + + const recent = agentUsage.listPlainSubagentTranscripts({ + cwd, projectsRoot: root, sinceMs: Date.now() - 60 * 60 * 1000, + }); + assert.deepEqual(recent.map((t) => path.basename(t.path)), ['agent-fresh.jsonl']); + } finally { + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +test('mapAgentToolUseIds pairs agentId to tool_use_id from launch ack and task notification', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'si-pairmap-')); + const p = path.join(dir, 'parent.jsonl'); + try { + fs.writeFileSync(p, [ + // Launch ack: tool_result whose text carries the agentId. + JSON.stringify({ type: 'user', message: { content: [{ + type: 'tool_result', tool_use_id: 'toolu_ack_1', + content: [{ type: 'text', text: 'Async agent launched successfully. agentId: abc123 (internal ID)' }], + }] } }), + // Completion notification: task-id + tool-use-id in one text block. + JSON.stringify({ type: 'user', message: { content: [{ + type: 'text', + text: 'def456toolu_notif_2', + }] } }), + // Noise lines the prefilter must skip without throwing. + '{broken json with agentId inside', + JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'no ids here' }] } }), + ].join('\n') + '\n'); + + const map = agentUsage.mapAgentToolUseIds(p); + assert.equal(map.get('abc123'), 'toolu_ack_1'); + assert.equal(map.get('def456'), 'toolu_notif_2'); + assert.equal(map.size, 2); + // Missing file → empty map, no throw. + assert.equal(agentUsage.mapAgentToolUseIds(path.join(dir, 'nope.jsonl')).size, 0); + } finally { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); diff --git a/plugins/session-intelligence/tests/cost-estimation.test.js b/plugins/session-intelligence/tests/cost-estimation.test.js index 8396aa1..bb65831 100644 --- a/plugins/session-intelligence/tests/cost-estimation.test.js +++ b/plugins/session-intelligence/tests/cost-estimation.test.js @@ -131,3 +131,78 @@ test('totalsFromTranscript accumulates cost + saved incrementally', () => { fs.unlinkSync(path_); }); + +test('knownPriceForModel covers Claude 5 family and Opus reprice split', () => { + // Claude 5 family — Fable / Mythos at $10/$50. + assert.equal(costEst.knownPriceForModel('claude-fable-5').input, 10); + assert.equal(costEst.knownPriceForModel('claude-fable-5').output, 50); + assert.equal(costEst.knownPriceForModel('claude-mythos-5').input, 10); + // Opus 4.5+ repriced to $5/$25. + assert.equal(costEst.knownPriceForModel('claude-opus-4-8').input, 5); + assert.equal(costEst.knownPriceForModel('claude-opus-4-8').output, 25); + assert.equal(costEst.knownPriceForModel('claude-opus-4-5-20251101').input, 5); + // Opus 4.1 and the dated Opus 4 base id stay on legacy $15/$75. + assert.equal(costEst.knownPriceForModel('claude-opus-4-1-20250805').input, 15); + assert.equal(costEst.knownPriceForModel('claude-opus-4-20250514').input, 15); + // Haiku unchanged. + assert.equal(costEst.knownPriceForModel('claude-haiku-4-5-20251001').input, 1); + // Unknown family → null; priceForModel falls back to DEFAULT_PRICES. + assert.equal(costEst.knownPriceForModel('some-other-model'), null); + assert.equal(costEst.priceForModel('some-other-model'), costEst.DEFAULT_PRICES); + assert.equal(costEst.knownPriceForModel(null), null); +}); + +test('sonnet-5 introductory pricing flips to standard on 2026-09-01', () => { + const during = Date.parse('2026-07-17T00:00:00Z'); + const after = Date.parse('2026-09-02T00:00:00Z'); + assert.equal(costEst.knownPriceForModel('claude-sonnet-5', during).input, 2); + assert.equal(costEst.knownPriceForModel('claude-sonnet-5', during).output, 10); + assert.equal(costEst.knownPriceForModel('claude-sonnet-5', after).input, 3); + assert.equal(costEst.knownPriceForModel('claude-sonnet-5', after).output, 15); + // Non-5 sonnets are date-independent. + assert.equal(costEst.knownPriceForModel('claude-sonnet-4-6', during).input, 3); +}); + +test('totalsFromTranscript prices each turn by its own model', () => { + const now = '2026-07-17T10:00:00.000Z'; + const path_ = writeTranscript([ + // Haiku turn: 1M output at $5/MTok = $5. + { type: 'assistant', timestamp: now, message: { id: 'pm1', model: 'claude-haiku-4-5-20251001', usage: { + input_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 1_000_000, + } } }, + // Fable turn: 1M output at $50/MTok = $50. + { type: 'assistant', timestamp: now, message: { id: 'pm2', model: 'claude-fable-5', usage: { + input_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 1_000_000, + } } }, + // Unknown model → falls back to the flat price list arg (default $75/MTok). + { type: 'assistant', timestamp: now, message: { id: 'pm3', model: 'mystery-model', usage: { + input_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 1_000_000, + } } }, + ]); + const out = costEst.totalsFromTranscript(path_, 'permodel-test-' + Date.now()); + assert.ok(Math.abs(out.cost - 130) < 1e-6, `expected 5 + 50 + 75 = 130, got ${out.cost}`); + fs.unlinkSync(path_); +}); + +test('totalsFromTranscript accumulates per-model cost buckets', () => { + const now = '2026-07-17T10:00:00.000Z'; + const path_ = writeTranscript([ + { type: 'assistant', timestamp: now, message: { id: 'bm1', model: 'claude-fable-5', usage: { + input_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 1_000_000, + } } }, // $50 + { type: 'assistant', timestamp: now, message: { id: 'bm2', model: 'claude-haiku-4-5-20251001', usage: { + input_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 1_000_000, + } } }, // $5 + { type: 'assistant', timestamp: now, message: { id: 'bm3', model: 'claude-fable-5', usage: { + input_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 1_000_000, + } } }, // $50 more fable + ]); + const sid = 'bymodel-test-' + Date.now(); + const out = costEst.totalsFromTranscript(path_, sid); + assert.ok(Math.abs(out.byModel['claude-fable-5'] - 100) < 1e-6, `fable=${out.byModel['claude-fable-5']}`); + assert.ok(Math.abs(out.byModel['claude-haiku-4-5-20251001'] - 5) < 1e-6); + // Cached second read returns the same buckets (round-trips through the v4 cache). + const out2 = costEst.totalsFromTranscript(path_, sid); + assert.deepEqual(out2.byModel, out.byModel); + fs.unlinkSync(path_); +}); diff --git a/plugins/session-intelligence/tests/events.test.js b/plugins/session-intelligence/tests/events.test.js index 59900d0..d2588f8 100644 --- a/plugins/session-intelligence/tests/events.test.js +++ b/plugins/session-intelligence/tests/events.test.js @@ -346,3 +346,181 @@ test('agent stats respect project filter via session join', () => { assert.equal(b.agentTypes[0].type, 'code-reviewer'); } finally { cleanup(sb); } }); + +test('reconcileSubagentUsage backfills blind tracker rows by parentToolUseId', () => { + const sb = mkSandboxDb(); + const agentUsage = require('../lib/agent-usage'); + const projRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'si-plain-recon-')); + try { + const cwd = '/Users/x/DWS/CSM'; + const enc = agentUsage.encodeProjectPath(cwd); + events.recordSessionStart({ sid: 'sid-1', project: 'CSM', cwd, startedAt: Date.now() }); + + // What si-agent-tracker records for a background-run agent: the row + // exists but the transcript hadn't flushed, so usage columns are NULL. + assert.ok(events.recordAgentInvocation({ + sid: 'sid-1', toolUseId: 'toolu_bg_1', + subagentType: 'Explore', description: 'sweep', t: Date.now(), + })); + + // By next SessionStart the transcript is complete on disk. + const tPath = path.join(projRoot, enc, 'sid-1', 'subagents', 'agent-777.jsonl'); + fs.mkdirSync(path.dirname(tPath), { recursive: true }); + fs.writeFileSync(tPath, JSON.stringify({ + type: 'assistant', + agentId: 'agent-777', + parentToolUseId: 'toolu_bg_1', + timestamp: '2026-07-17T09:00:00.000Z', + message: { + id: 'm1', model: 'claude-haiku-4-5-20251001', + usage: { input_tokens: 2000, cache_creation_input_tokens: 100, cache_read_input_tokens: 50000, output_tokens: 400 }, + }, + }) + '\n'); + + assert.equal(events.reconcileSubagentUsage({ cwd, projectsRoot: projRoot }), 1); + // Second run: the row is filled, nothing left to reconcile. + assert.equal(events.reconcileSubagentUsage({ cwd, projectsRoot: projRoot }), 0); + + const stats = events.aggregateStats({ sinceDays: 30, project: 'CSM' }); + assert.equal(stats.agents.n, 1); + assert.equal(stats.agents.input_tokens, 2000); + assert.equal(stats.agents.cache_read_tokens, 50000); + assert.ok(stats.agents.cost_usd > 0, 'cost filled from haiku pricing'); + } finally { + events._resetForTest(); + try { fs.rmSync(projRoot, { recursive: true, force: true }); } catch { /* ignore */ } + cleanup(sb); + } +}); + +test('user_version 2 migration reprices agent costs from the per-model table', () => { + const sb = mkSandboxDb(); + try { + const Sqlite = require('better-sqlite3'); + // First open creates schema and lands on the current user_version. + assert.equal(events.isAvailable(), true); + events._resetForTest(); + + // Seed a row costed at the old flat Opus table ($15/MTok input), then + // rewind user_version so the reprice migration re-runs on next open. + const raw = new Sqlite(sb.dbPath); + raw.prepare(` + INSERT INTO agent_invocations + (sid, tool_use_id, subagent_type, t, model, input_tokens, output_tokens, + cache_creation_tokens, cache_read_tokens, cost_usd) + VALUES (?,?,?,?,?,?,?,?,?,?) + `).run('s1', 'toolu_rp_1', 'Explore', Date.now(), 'claude-opus-4-8', 1000000, 0, 0, 0, 15.0); + // A row without tokens must be left untouched. + raw.prepare(` + INSERT INTO agent_invocations (sid, tool_use_id, subagent_type, t, model, cost_usd) + VALUES (?,?,?,?,?,?) + `).run('s1', 'toolu_rp_2', 'Explore', Date.now(), 'claude-opus-4-8', 9.99); + raw.pragma('user_version = 1'); + raw.close(); + + events._setDbPathForTest(sb.dbPath); + assert.equal(events.isAvailable(), true); + + const verify = new Sqlite(sb.dbPath); + const repriced = verify.prepare("SELECT cost_usd FROM agent_invocations WHERE tool_use_id='toolu_rp_1'").get(); + const untouched = verify.prepare("SELECT cost_usd FROM agent_invocations WHERE tool_use_id='toolu_rp_2'").get(); + const ver = verify.pragma('user_version', { simple: true }); + verify.close(); + // 1M input tokens at Opus 4.8's real $5/MTok — was $15 under the old table. + assert.ok(Math.abs(repriced.cost_usd - 5.0) < 1e-9, `repriced=${repriced.cost_usd}`); + assert.equal(untouched.cost_usd, 9.99, 'token-less row not touched'); + assert.ok(ver >= 2, 'user_version advanced'); + } finally { cleanup(sb); } +}); + +test('reconcileSubagentUsage pairs via parent transcript when parentToolUseId is absent', () => { + const sb = mkSandboxDb(); + const agentUsage = require('../lib/agent-usage'); + const projRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'si-pair-recon-')); + try { + const cwd = '/Users/x/DWS/CSM'; + const enc = agentUsage.encodeProjectPath(cwd); + events.recordSessionStart({ sid: 'sid-9', project: 'CSM', cwd, startedAt: Date.now() }); + assert.ok(events.recordAgentInvocation({ + sid: 'sid-9', toolUseId: 'toolu_pair_1', + subagentType: 'Explore', t: Date.now(), + })); + + // Modern (2.1.x) subagent transcript: has agentId but NO parentToolUseId. + const tPath = path.join(projRoot, enc, 'sid-9', 'subagents', 'agent-zzz9.jsonl'); + fs.mkdirSync(path.dirname(tPath), { recursive: true }); + fs.writeFileSync(tPath, JSON.stringify({ + type: 'assistant', agentId: 'zzz9', timestamp: '2026-07-17T09:00:00.000Z', + message: { id: 'm1', model: 'claude-fable-5', + usage: { input_tokens: 1000, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 100000 } }, + }) + '\n'); + // Parent transcript carries the pairing via the launch ack. + fs.writeFileSync(path.join(projRoot, enc, 'sid-9.jsonl'), JSON.stringify({ + type: 'user', message: { content: [{ + type: 'tool_result', tool_use_id: 'toolu_pair_1', + content: [{ type: 'text', text: 'Async agent launched successfully. agentId: zzz9 (internal)' }], + }] }, + }) + '\n'); + + assert.equal(events.reconcileSubagentUsage({ cwd, projectsRoot: projRoot }), 1); + const stats = events.aggregateStats({ sinceDays: 30, project: 'CSM' }); + assert.equal(stats.agents.input_tokens, 1000); + // 100k output tokens at Fable's $50/MTok = $5.01 with the input cents. + assert.ok(Math.abs(stats.agents.cost_usd - 5.01) < 1e-6, `cost=${stats.agents.cost_usd}`); + } finally { + events._resetForTest(); + try { fs.rmSync(projRoot, { recursive: true, force: true }); } catch { /* ignore */ } + cleanup(sb); + } +}); + +test('agentModelCosts aggregates per-model subagent spend for one session', () => { + const sb = mkSandboxDb(); + try { + const now = Date.now(); + events.recordSessionStart({ sid: 's1', project: 'p', startedAt: now }); + events.recordAgentInvocation({ sid: 's1', toolUseId: 't1', subagentType: 'Explore', t: now, + model: 'claude-opus-4-8', inputTokens: 1000, outputTokens: 100, costUsd: 0.5 }); + events.recordAgentInvocation({ sid: 's1', toolUseId: 't2', subagentType: 'Explore', t: now, + model: 'claude-opus-4-8', inputTokens: 1000, outputTokens: 100, costUsd: 0.25 }); + events.recordAgentInvocation({ sid: 's1', toolUseId: 't3', subagentType: 'reviewer', t: now, + model: 'claude-haiku-4-5-20251001', costUsd: 0.05 }); + // Blind row (no usage yet) and another session's row must not appear. + events.recordAgentInvocation({ sid: 's1', toolUseId: 't4', subagentType: 'bg', t: now }); + events.recordAgentInvocation({ sid: 's2', toolUseId: 't5', subagentType: 'x', t: now, + model: 'claude-fable-5', costUsd: 9.9 }); + const rows = events.agentModelCosts('s1'); + const byModel = Object.fromEntries(rows.map((r) => [r.model, r.cost_usd])); + assert.ok(Math.abs(byModel['claude-opus-4-8'] - 0.75) < 1e-9); + assert.ok(Math.abs(byModel['claude-haiku-4-5-20251001'] - 0.05) < 1e-9); + assert.equal(rows.length, 2); + assert.deepEqual(events.agentModelCosts(null), []); + } finally { cleanup(sb); } +}); + +test('upsertSessionModelCosts + weeklyModelSpend aggregate parent and agent spend', () => { + const sb = mkSandboxDb(); + try { + const now = Date.now(); + events.recordSessionStart({ sid: 's1', project: 'p', startedAt: now }); + // Parent-session splits — second upsert replaces, not accumulates. + assert.ok(events.upsertSessionModelCosts('s1', { 'claude-fable-5': 50 })); + assert.ok(events.upsertSessionModelCosts('s1', { 'claude-fable-5': 78, 'claude-opus-4-8': 2 })); + // Agent spend joins the weekly view. + events.recordAgentInvocation({ sid: 's1', toolUseId: 'a1', subagentType: 'Explore', t: now, + model: 'claude-opus-4-8', costUsd: 0.5 }); + const week = events.weeklyModelSpend(); + const byModel = Object.fromEntries(week.map((r) => [r.model, r.cost_usd])); + assert.ok(Math.abs(byModel['claude-fable-5'] - 78) < 1e-9, `fable=${byModel['claude-fable-5']}`); + assert.ok(Math.abs(byModel['claude-opus-4-8'] - 2.5) < 1e-9, `opus=${byModel['claude-opus-4-8']}`); + assert.equal(week[0].model, 'claude-fable-5', 'sorted by spend desc'); + // Old rows age out of the window. + const Sqlite = require('better-sqlite3'); + const raw = new Sqlite(sb.dbPath); + raw.prepare('UPDATE session_model_costs SET t = ? WHERE model = ?') + .run(now - 10 * 24 * 60 * 60 * 1000, 'claude-fable-5'); + raw.close(); + const week2 = events.weeklyModelSpend(); + assert.ok(!week2.find((r) => r.model === 'claude-fable-5'), 'aged-out row excluded'); + } finally { cleanup(sb); } +}); diff --git a/plugins/session-intelligence/tests/session-context.test.js b/plugins/session-intelligence/tests/session-context.test.js index cc8cf8f..4bc2075 100644 --- a/plugins/session-intelligence/tests/session-context.test.js +++ b/plugins/session-intelligence/tests/session-context.test.js @@ -201,3 +201,27 @@ test('AUTOFILL_SENTINEL_RE matches a range of SHA lengths', () => { assert.doesNotMatch('', AUTOFILL_SENTINEL_RE); // non-hex assert.doesNotMatch('', AUTOFILL_SENTINEL_RE); // missing prefix }); + +test('readSessionContext matches annotated Current Task headings', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'si-ctx-annot-')); + try { + fs.writeFileSync(path.join(dir, 'session-context.md'), [ + '# Session Context', + '', + '## Current Task — #1695 Phase 2 (import-duration) — scope on start', + '**NEXT: #1695 P2 import-dur** — plan doc first.', + '', + '## Current Taskforce notes', + 'must NOT be picked up — different word, not an annotation', + '', + '## Key Files (auto)', + '- products/mm/app/import.py', + ].join('\n')); + const r = readSessionContext(dir); + assert.match(r.currentTask, /NEXT: #1695 P2 import-dur/); + assert.doesNotMatch(r.currentTask, /Taskforce/); + assert.match(r.keyFiles, /import\.py/); + } finally { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); diff --git a/plugins/session-intelligence/tests/token-speed.test.js b/plugins/session-intelligence/tests/token-speed.test.js new file mode 100644 index 0000000..b2f70fd --- /dev/null +++ b/plugins/session-intelligence/tests/token-speed.test.js @@ -0,0 +1,62 @@ +/** + * Tests for lib/token-speed.js — tokens/sec from per-message chunk spans. + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { estimateTokenSpeed } = require('../lib/token-speed'); + +function writeTranscript(rows) { + const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'si-tps-')), 't.jsonl'); + fs.writeFileSync(p, rows.map((r) => JSON.stringify(r)).join('\n') + '\n'); + return p; +} + +function chunkRows(id, startIso, spanMs, finalOut, chunks = 3) { + const start = Date.parse(startIso); + const rows = []; + for (let i = 0; i < chunks; i++) { + rows.push({ + type: 'assistant', + timestamp: new Date(start + (spanMs * i) / (chunks - 1)).toISOString(), + message: { id, usage: { output_tokens: Math.round((finalOut * (i + 1)) / chunks) } }, + }); + } + return rows; +} + +test('estimateTokenSpeed computes tokens/sec from chunk spans (median of recent turns)', () => { + const p = writeTranscript([ + // 1000 tokens over 10s = 100 tps + ...chunkRows('m1', '2026-07-17T10:00:00.000Z', 10000, 1000), + // 1000 tokens over 5s = 200 tps + ...chunkRows('m2', '2026-07-17T10:01:00.000Z', 5000, 1000), + // 1000 tokens over 2s = 500 tps + ...chunkRows('m3', '2026-07-17T10:02:00.000Z', 2000, 1000), + ]); + const est = estimateTokenSpeed(p); + assert.ok(est, 'estimate returned'); + assert.equal(est.samples, 3); + assert.ok(Math.abs(est.tps - 200) < 1, `median of 100/200/500 → 200, got ${est.tps}`); +}); + +test('estimateTokenSpeed skips single-row messages and tiny turns', () => { + const p = writeTranscript([ + // Single row — no span, no timing signal. + { type: 'assistant', timestamp: '2026-07-17T10:00:00.000Z', message: { id: 's1', usage: { output_tokens: 900 } } }, + // Big span but trivial output — noise. + ...chunkRows('s2', '2026-07-17T10:01:00.000Z', 5000, 10), + ]); + assert.equal(estimateTokenSpeed(p), null); +}); + +test('estimateTokenSpeed returns null for missing file', () => { + assert.equal(estimateTokenSpeed('/nonexistent/t.jsonl'), null); + assert.equal(estimateTokenSpeed(null), null); +}); diff --git a/plugins/session-intelligence/tests/usage-api.test.js b/plugins/session-intelligence/tests/usage-api.test.js index c13fb3a..a498b2e 100644 --- a/plugins/session-intelligence/tests/usage-api.test.js +++ b/plugins/session-intelligence/tests/usage-api.test.js @@ -143,3 +143,50 @@ test('readAndRefreshIfStale returns whatever cache currently holds', () => { assert.equal(res.weeklyUsage, 10); }); }); + +test('readUsageCache passes through goodAt, retryAfterMs, and carried values on error caches', () => { + withTempCache(({ api }) => { + fs.writeFileSync(api.cacheFilePath(), JSON.stringify({ + error: 'rate-limited', retryAfterMs: 3111000, fetchedAt: Date.now(), + sessionUsage: 42, weeklyUsage: 61, goodAt: Date.now() - 60 * 60 * 1000, + })); + const got = api.readUsageCache(); + assert.equal(got.error, 'rate-limited'); + assert.equal(got.retryAfterMs, 3111000); + assert.equal(got.sessionUsage, 42, 'carried value still renders'); + assert.equal(got.weeklyUsage, 61); + assert.ok(typeof got.goodAt === 'number'); + }); +}); + +test('triggerRefresh backs off while an error cache is inside its retry window', () => { + withTempCache(({ api }) => { + // Recent 429 with a long Retry-After — a refresh now would just hammer + // the endpoint again. Must return false even though error caches are + // normally refresh-eligible. + fs.writeFileSync(api.cacheFilePath(), JSON.stringify({ + error: 'rate-limited', retryAfterMs: 60 * 60 * 1000, fetchedAt: Date.now() - 1000, + })); + assert.equal(api.triggerRefresh(), false); + }); +}); + +test('triggerRefresh applies the default backoff to errors without Retry-After', () => { + withTempCache(({ api }) => { + fs.writeFileSync(api.cacheFilePath(), JSON.stringify({ + error: 'network', fetchedAt: Date.now() - 60 * 1000, // 1 min ago < 5 min floor + })); + assert.equal(api.triggerRefresh(), false); + }); +}); + +test('readUsageCache passes through modelQuotas', () => { + withTempCache(({ api }) => { + fs.writeFileSync(api.cacheFilePath(), JSON.stringify({ + sessionUsage: 7, weeklyUsage: 38, fetchedAt: Date.now(), + modelQuotas: { opus: { utilization: 12, resetsAt: null } }, + })); + const got = api.readUsageCache(); + assert.equal(got.modelQuotas.opus.utilization, 12); + }); +}); diff --git a/plugins/session-intelligence/tests/usage-refresh.test.js b/plugins/session-intelligence/tests/usage-refresh.test.js new file mode 100644 index 0000000..fb11f0f --- /dev/null +++ b/plugins/session-intelligence/tests/usage-refresh.test.js @@ -0,0 +1,87 @@ +/** + * Tests for lib/usage-refresh.js — the detached worker's pure helpers. + * Network + keychain paths are not exercised here; what matters at this + * layer is (1) Retry-After parsing and (2) that an error fetch carries the + * previous cache's good values forward instead of blanking the statusline + * usage cells for the whole outage. + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseRetryAfterMs, buildCachePayload } = require('../lib/usage-refresh'); + +test('parseRetryAfterMs handles delta-seconds, HTTP-date, and garbage', () => { + assert.equal(parseRetryAfterMs('3111'), 3111000); + assert.equal(parseRetryAfterMs(0), 0); + const inTenMin = new Date(Date.now() + 10 * 60 * 1000).toUTCString(); + const parsed = parseRetryAfterMs(inTenMin); + assert.ok(parsed > 9 * 60 * 1000 && parsed <= 10 * 60 * 1000, `date form → ~10min, got ${parsed}`); + assert.equal(parseRetryAfterMs('soon'), null); + assert.equal(parseRetryAfterMs(undefined), null); + assert.equal(parseRetryAfterMs(''), null); +}); + +test('buildCachePayload stamps goodAt on success', () => { + const out = buildCachePayload({ sessionUsage: 40, weeklyUsage: 55 }, null, 1000); + assert.equal(out.goodAt, 1000); + assert.equal(out.sessionUsage, 40); +}); + +test('buildCachePayload carries last good values through an error', () => { + const prev = { sessionUsage: 40, sessionResetAt: 'r1', weeklyUsage: 55, weeklyResetAt: 'r2', goodAt: 500, fetchedAt: 500 }; + const out = buildCachePayload({ error: 'rate-limited', retryAfterMs: 3111000 }, prev, 2000); + assert.equal(out.error, 'rate-limited'); + assert.equal(out.retryAfterMs, 3111000); + assert.equal(out.sessionUsage, 40, 'good value survives the error'); + assert.equal(out.weeklyUsage, 55); + assert.equal(out.goodAt, 500, 'goodAt stays at the last SUCCESSFUL fetch'); +}); + +test('buildCachePayload falls back to prev.fetchedAt as goodAt for legacy success caches', () => { + const prev = { sessionUsage: 30, weeklyUsage: 60, fetchedAt: 700 }; // pre-goodAt cache, no error + const out = buildCachePayload({ error: 'api-error' }, prev, 2000); + assert.equal(out.goodAt, 700); + assert.equal(out.weeklyUsage, 60); +}); + +test('buildCachePayload chains through consecutive errors without losing values', () => { + const first = buildCachePayload( + { error: 'rate-limited' }, + { sessionUsage: 40, weeklyUsage: 55, goodAt: 500, fetchedAt: 500 }, + 1000, + ); + const second = buildCachePayload({ error: 'rate-limited' }, { ...first, fetchedAt: 1000 }, 2000); + assert.equal(second.sessionUsage, 40, 'values survive error → error'); + assert.equal(second.goodAt, 500, 'goodAt still the original success'); +}); + +test('buildCachePayload with no previous cache is just the error', () => { + const out = buildCachePayload({ error: 'no-credentials' }, null, 1000); + assert.equal(out.error, 'no-credentials'); + assert.equal(out.sessionUsage, undefined); +}); + +test('extractModelQuotas returns null when all per-model buckets are null', () => { + const { extractModelQuotas } = require('../lib/usage-refresh'); + assert.equal(extractModelQuotas({ + five_hour: { utilization: 7 }, seven_day: { utilization: 38 }, + seven_day_opus: null, seven_day_sonnet: null, seven_day_cowork: null, + }), null); + assert.equal(extractModelQuotas(null), null); +}); + +test('extractModelQuotas captures populated seven_day_ buckets', () => { + const { extractModelQuotas } = require('../lib/usage-refresh'); + const out = extractModelQuotas({ + seven_day: { utilization: 38 }, + seven_day_opus: { utilization: 12.4, resets_at: '2026-07-20T05:00:00Z' }, + seven_day_sonnet: { utilization: 3 }, + seven_day_cowork: null, + }); + assert.deepEqual(Object.keys(out).sort(), ['opus', 'sonnet']); + assert.equal(out.opus.utilization, 12.4); + assert.equal(out.opus.resetsAt, '2026-07-20T05:00:00Z'); +});