@arbiterforge/ca-pi 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — cost/token ledger subsystem for the statusline (extracted T-12).
|
|
3
|
+
#
|
|
4
|
+
# Owns the user-level token/cost accounting the statusline renders: an Anthropic
|
|
5
|
+
# API price table, transcript-tailing accumulation (deduped per requestId), the
|
|
6
|
+
# per-session JSON ledger (~/.codearbiter/ledger.json) with TTL pruning + an
|
|
7
|
+
# atomic write, and the per-call burn samples that feed the sparkline. The
|
|
8
|
+
# statusline imports this for its cost segment; it carries NO rendering concern
|
|
9
|
+
# (no ANSI, no box drawing) so the accounting is unit-testable in isolation.
|
|
10
|
+
#
|
|
11
|
+
# Design principles (mirroring _metricslib.py / _taskboardlib.py):
|
|
12
|
+
# - Stdlib only; no third-party imports ever — runs on stock Python.
|
|
13
|
+
# - Zero side effects at import time: no git calls, no file I/O.
|
|
14
|
+
# - Pure functions are fully testable with synthetic input. ledger_update()
|
|
15
|
+
# and pi_ledger_update() are the only filesystem entry points; everything
|
|
16
|
+
# else is pure or a private bounded persistence helper.
|
|
17
|
+
# - Never raise on malformed user input — every reader degrades to safe blanks.
|
|
18
|
+
#
|
|
19
|
+
# Public API:
|
|
20
|
+
# price_for(model) -> tuple (input,out,c5,c1,cr) USD per 1M tokens
|
|
21
|
+
# api_cost(tok) -> float estimated API-equivalent USD for {model: tokens}
|
|
22
|
+
# ledger_path() -> str resolved ledger file path (env-overridable)
|
|
23
|
+
# _tx_accumulate(rec, tx_path) -> bool tail a transcript into rec; True if offset advanced
|
|
24
|
+
# _agg_reqs(reqs, only=None) -> dict aggregate per-request map -> {model: tokens}
|
|
25
|
+
# _totals(models) -> dict {in,out,cost} display totals for a model map
|
|
26
|
+
# ledger_update(data, sid) -> tuple (rec, session_totals, today_totals)
|
|
27
|
+
# pi_ledger_path() -> str separate user-global Pi ledger path
|
|
28
|
+
# pi_ledger_update(session_key, scan_start, scan_end, facts, path=None) -> dict
|
|
29
|
+
# bounded Pi session/day snapshot
|
|
30
|
+
# burn_samples(rec) -> list[float] recent per-call token-burn values for the sparkline
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import stat
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
|
|
42
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
43
|
+
# _acquire_lock/_release_lock/LOCK_WAIT were hoisted to _hooklib (#271 C-2) so
|
|
44
|
+
# taskwrite.py's board writer can share ONE lock implementation instead of a
|
|
45
|
+
# second hand-rolled copy. Re-exported under their ORIGINAL private names so
|
|
46
|
+
# this module's own call sites (ledger_update/persist_sess_start) and the test
|
|
47
|
+
# suite's `mock.patch.object(L, "_acquire_lock", ...)` / `mock.patch.object(L,
|
|
48
|
+
# "LOCK_WAIT", ...)` seams keep working unchanged — no import-cycle risk:
|
|
49
|
+
# _hooklib imports only hostapi, never _ledgerlib.
|
|
50
|
+
from _hooklib import acquire_lock as _acquire_lock # noqa: E402
|
|
51
|
+
from _hooklib import release_lock as _release_lock # noqa: E402
|
|
52
|
+
from _hooklib import LOCK_WAIT # noqa: E402
|
|
53
|
+
# parse_iso lives in _fmtlib, which already documents it as part of its public
|
|
54
|
+
# API; this module carried a byte-identical second copy (#334 item 3). Same
|
|
55
|
+
# re-export pattern as the lock helpers above: one owner, and `L.parse_iso`
|
|
56
|
+
# stays importable for this module's call sites and the test suite. No cycle
|
|
57
|
+
# risk — _fmtlib imports only _colorlib, never _ledgerlib.
|
|
58
|
+
from _fmtlib import parse_iso # noqa: E402,F401
|
|
59
|
+
|
|
60
|
+
# Tunables (module constants; mirrored from the original inline statusline block).
|
|
61
|
+
SESSION_TTL = 36 * 3600 # prune sessions older than ~1.5 days
|
|
62
|
+
BURN_RING = 40 # recent per-call token-burn samples kept for the sparkline
|
|
63
|
+
TX_MAX_NEW_LINES = 20000 # hot-path bound: transcript lines parsed per render
|
|
64
|
+
# `last_ts` exists only to keep a live record inside SESSION_TTL. Stamping it on
|
|
65
|
+
# EVERY render made every render a writing render (#392) — the statusline runs in
|
|
66
|
+
# a fresh process per refresh, so that was two atomic replacements per refresh
|
|
67
|
+
# forever. Refresh it on a write we are making anyway, or at most once per
|
|
68
|
+
# heartbeat; 5 minutes is ~432x inside the 36-hour TTL.
|
|
69
|
+
LEDGER_HEARTBEAT = 300
|
|
70
|
+
|
|
71
|
+
# Pi's bridge sends only already-extracted usage facts. The bridge chunks long
|
|
72
|
+
# sessions so one call and one lock hold stay predictably small. `session_key` is
|
|
73
|
+
# a caller-derived SHA-256 digest of the stable Pi session identity, never the raw
|
|
74
|
+
# session name/path. These bounds mirror the footer's normalized numeric ceiling.
|
|
75
|
+
PI_MAX_SCAN_ENTRIES = 256
|
|
76
|
+
PI_MAX_SHARD_BYTES = 65_536
|
|
77
|
+
PI_MAX_POSITION = 2_147_483_647
|
|
78
|
+
PI_MAX_TOKENS = 1_000_000_000_000_000
|
|
79
|
+
PI_MAX_COST_USD = 1_000_000_000.0
|
|
80
|
+
PI_MAX_DAYS = 64
|
|
81
|
+
PI_MAX_SHARDS = 256
|
|
82
|
+
PI_MAX_DIRECTORY_ENTRIES = 8192
|
|
83
|
+
PI_MAX_UPDATED_AT = 9_999_999_999
|
|
84
|
+
PI_MAX_TIMESTAMP_CHARS = 64
|
|
85
|
+
PI_MAX_PATH_CHARS = 32_768
|
|
86
|
+
PI_MIN_YEAR = 2000
|
|
87
|
+
PI_MAX_YEAR = 2100
|
|
88
|
+
PI_LEDGER_SCHEMA = "codearbiter.pi-usage-ledger/v1"
|
|
89
|
+
PI_SESSION_SCHEMA = "codearbiter.pi-usage-session/v1"
|
|
90
|
+
PI_SESSION_KEY_RE = re.compile(r"[0-9a-f]{64}\Z")
|
|
91
|
+
PI_SHARD_NAME_RE = re.compile(r"([0-9a-f]{64})\.json\Z")
|
|
92
|
+
PI_DAY_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\Z")
|
|
93
|
+
PI_TOTAL_KEYS = frozenset({
|
|
94
|
+
"inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd",
|
|
95
|
+
})
|
|
96
|
+
PI_FACT_KEYS = frozenset({"position", "timestamp"}) | PI_TOTAL_KEYS
|
|
97
|
+
PI_STATUSES = frozenset({"ok", "invalid", "corrupt", "lock_failed", "write_failed"})
|
|
98
|
+
|
|
99
|
+
# API list prices, USD per 1M tokens (captured 2026-06-10 from Anthropic's
|
|
100
|
+
# pricing pages). Used ONLY to estimate the pay-as-you-go API-equivalent cost of
|
|
101
|
+
# this session's REAL tokens — the bar labels it "api≈"; it is not a bill.
|
|
102
|
+
# Per model family: (input, output, cache_write_5m, cache_write_1h, cache_read).
|
|
103
|
+
# Cache multipliers are the standard ones: write 1.25x/2x input, read 0.1x.
|
|
104
|
+
API_PRICES = {
|
|
105
|
+
"fable": (10.0, 50.0, 12.50, 20.0, 1.00),
|
|
106
|
+
"opus": (5.0, 25.0, 6.25, 10.0, 0.50),
|
|
107
|
+
"sonnet": (3.0, 15.0, 3.75, 6.0, 0.30),
|
|
108
|
+
"haiku": (1.0, 5.0, 1.25, 2.0, 0.10),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------- coercion
|
|
113
|
+
def num(x, default=0.0):
|
|
114
|
+
"""Coerce any host value to float; tolerate strings, None, and containers."""
|
|
115
|
+
try:
|
|
116
|
+
return float(x)
|
|
117
|
+
except (TypeError, ValueError):
|
|
118
|
+
return default
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def safe(fn, *a, **k):
|
|
122
|
+
"""Run fn; swallow any failure so one bad input can't break the ledger.
|
|
123
|
+
Returns None on error (caller treats it as a no-op / blank)."""
|
|
124
|
+
try:
|
|
125
|
+
return fn(*a, **k)
|
|
126
|
+
except Exception: # noqa: BLE001
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get(d, *path, default=None):
|
|
131
|
+
cur = d
|
|
132
|
+
for k in path:
|
|
133
|
+
if not isinstance(cur, dict) or k not in cur or cur[k] is None:
|
|
134
|
+
return default
|
|
135
|
+
cur = cur[k]
|
|
136
|
+
return cur
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --------------------------------------------------------------------------- pricing
|
|
140
|
+
def price_for(model):
|
|
141
|
+
ml = str(model).lower()
|
|
142
|
+
for fam, p in API_PRICES.items():
|
|
143
|
+
if fam in ml:
|
|
144
|
+
return p
|
|
145
|
+
return API_PRICES["sonnet"] # reasonable mid default for an unrecognized model
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def api_cost(tok):
|
|
149
|
+
"""Estimated pay-as-you-go API cost (USD) for accumulated per-model tokens."""
|
|
150
|
+
total = 0.0
|
|
151
|
+
for model, t in (tok or {}).items():
|
|
152
|
+
if not isinstance(t, dict):
|
|
153
|
+
continue
|
|
154
|
+
pin, pout, p5, p1, pr = price_for(model)
|
|
155
|
+
total += (num(t.get("in")) * pin + num(t.get("out")) * pout
|
|
156
|
+
+ num(t.get("c5")) * p5 + num(t.get("c1")) * p1
|
|
157
|
+
+ num(t.get("cr")) * pr) / 1e6
|
|
158
|
+
return total
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------- ledger
|
|
162
|
+
def ledger_path():
|
|
163
|
+
return os.environ.get("CODEARBITER_LEDGER") or \
|
|
164
|
+
os.path.join(os.path.expanduser("~"), ".codearbiter", "ledger.json")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_json(path, default=None):
|
|
168
|
+
try:
|
|
169
|
+
with open(path, encoding="utf-8") as f:
|
|
170
|
+
value = json.load(f)
|
|
171
|
+
return value
|
|
172
|
+
except (OSError, ValueError):
|
|
173
|
+
return default
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _atomic_json(path, value):
|
|
177
|
+
"""Atomically replace one JSON file without sharing a staging pathname."""
|
|
178
|
+
tmp = None
|
|
179
|
+
try:
|
|
180
|
+
parent = os.path.dirname(path)
|
|
181
|
+
if parent:
|
|
182
|
+
os.makedirs(parent, exist_ok=True)
|
|
183
|
+
tmp = f"{path}.{os.getpid()}.{time.time_ns()}.tmp"
|
|
184
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
185
|
+
json.dump(value, f)
|
|
186
|
+
os.replace(tmp, path)
|
|
187
|
+
return True
|
|
188
|
+
except OSError:
|
|
189
|
+
return False
|
|
190
|
+
finally:
|
|
191
|
+
if tmp:
|
|
192
|
+
try:
|
|
193
|
+
os.remove(tmp)
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _session_dir(path):
|
|
199
|
+
return f"{path}.sessions"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _session_key(sid):
|
|
203
|
+
return hashlib.sha256(str(sid).encode("utf-8", "replace")).hexdigest()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _session_file(path, sid):
|
|
207
|
+
return os.path.join(_session_dir(path), f"{_session_key(sid)}.json")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _start_file(path, sid):
|
|
211
|
+
return os.path.join(_session_dir(path), f"{_session_key(sid)}.start.json")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _merge_sessions(path):
|
|
215
|
+
"""Merge the legacy snapshot with authoritative independently-written shards.
|
|
216
|
+
|
|
217
|
+
Returns (sessions, legacy) where `legacy` is the compatibility snapshot's own
|
|
218
|
+
mapping exactly as it was read from disk (None when the file is absent or
|
|
219
|
+
malformed). Callers compare the merged result against `legacy` to tell whether
|
|
220
|
+
the snapshot on disk is already in sync, so the whole-snapshot rewrite happens
|
|
221
|
+
only when it would actually change bytes (#392). Session records are copied out
|
|
222
|
+
of `legacy` so later in-place edits (the .start.json overlay below, and the
|
|
223
|
+
caller's own record) cannot mutate the comparison baseline.
|
|
224
|
+
"""
|
|
225
|
+
led = _read_json(path, {})
|
|
226
|
+
legacy = led.get("sessions") if isinstance(led, dict) else None
|
|
227
|
+
if not isinstance(legacy, dict):
|
|
228
|
+
legacy = None
|
|
229
|
+
sessions = {} if legacy is None else {
|
|
230
|
+
sid: dict(rec) for sid, rec in legacy.items() if isinstance(rec, dict)}
|
|
231
|
+
directory = _session_dir(path)
|
|
232
|
+
try:
|
|
233
|
+
names = os.listdir(directory)
|
|
234
|
+
except OSError:
|
|
235
|
+
names = []
|
|
236
|
+
for name in names:
|
|
237
|
+
if not name.endswith(".json") or name.endswith(".start.json"):
|
|
238
|
+
continue
|
|
239
|
+
enumerated = os.path.join(directory, name)
|
|
240
|
+
item = _read_json(enumerated)
|
|
241
|
+
if not isinstance(item, dict) or not isinstance(item.get("rec"), dict):
|
|
242
|
+
try:
|
|
243
|
+
os.remove(enumerated)
|
|
244
|
+
except OSError:
|
|
245
|
+
pass
|
|
246
|
+
continue
|
|
247
|
+
sid = str(item.get("sid"))
|
|
248
|
+
if os.path.basename(_session_file(path, sid)) != name:
|
|
249
|
+
try:
|
|
250
|
+
os.remove(enumerated)
|
|
251
|
+
except OSError:
|
|
252
|
+
pass
|
|
253
|
+
continue
|
|
254
|
+
rec = item["rec"]
|
|
255
|
+
if time.time() - num(rec.get("last_ts")) > SESSION_TTL:
|
|
256
|
+
for stale in (enumerated, _start_file(path, sid)):
|
|
257
|
+
try:
|
|
258
|
+
os.remove(stale)
|
|
259
|
+
except OSError:
|
|
260
|
+
pass
|
|
261
|
+
sessions.pop(sid, None)
|
|
262
|
+
continue
|
|
263
|
+
sessions[sid] = rec
|
|
264
|
+
for name in names:
|
|
265
|
+
if not name.endswith(".start.json"):
|
|
266
|
+
continue
|
|
267
|
+
enumerated = os.path.join(directory, name)
|
|
268
|
+
item = _read_json(enumerated)
|
|
269
|
+
if not isinstance(item, dict):
|
|
270
|
+
try:
|
|
271
|
+
os.remove(enumerated)
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
continue
|
|
275
|
+
sid = str(item.get("sid"))
|
|
276
|
+
start = num(item.get("sess_start"), None)
|
|
277
|
+
valid_name = os.path.basename(_start_file(path, sid)) == name
|
|
278
|
+
if valid_name and sid in sessions and start is not None:
|
|
279
|
+
sessions[sid]["sess_start"] = float(start)
|
|
280
|
+
else:
|
|
281
|
+
try:
|
|
282
|
+
os.remove(enumerated)
|
|
283
|
+
except OSError:
|
|
284
|
+
pass
|
|
285
|
+
now = time.time()
|
|
286
|
+
sessions = {sid: rec for sid, rec in sessions.items()
|
|
287
|
+
if isinstance(rec, dict)
|
|
288
|
+
and now - num(rec.get("last_ts")) <= SESSION_TTL}
|
|
289
|
+
return sessions, legacy
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _load_sessions(path):
|
|
293
|
+
"""The merged live-session map only (see _merge_sessions for the baseline)."""
|
|
294
|
+
return _merge_sessions(path)[0]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _write_snapshot(path, sessions):
|
|
298
|
+
return _atomic_json(path, {"sessions": sessions})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _msg_date(ts):
|
|
302
|
+
"""Local calendar date (YYYY-MM-DD) of a transcript message's timestamp, so
|
|
303
|
+
tokens are attributed to the day they were actually burned (a session that
|
|
304
|
+
crosses midnight splits correctly across days)."""
|
|
305
|
+
e = parse_iso(ts) if isinstance(ts, str) else None
|
|
306
|
+
if e is None:
|
|
307
|
+
return datetime.now().strftime("%Y-%m-%d")
|
|
308
|
+
try:
|
|
309
|
+
return datetime.fromtimestamp(e).strftime("%Y-%m-%d")
|
|
310
|
+
except (OSError, OverflowError, ValueError):
|
|
311
|
+
return datetime.now().strftime("%Y-%m-%d")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _tx_accumulate(rec, tx_path):
|
|
315
|
+
"""Tail the session transcript JSONL from the stored byte offset, UPSERTING each
|
|
316
|
+
assistant message's usage into a per-requestId dedup map (the transcript logs a
|
|
317
|
+
single API call several times via streaming/replay; counting each request once
|
|
318
|
+
is what keeps tokens AND cost honest) and pushing a per-call burn sample. Append-
|
|
319
|
+
only -> O(new lines)/render. Returns True if the offset advanced."""
|
|
320
|
+
if not tx_path or not os.path.isfile(tx_path):
|
|
321
|
+
return False
|
|
322
|
+
try:
|
|
323
|
+
size = os.path.getsize(tx_path)
|
|
324
|
+
except OSError:
|
|
325
|
+
return False
|
|
326
|
+
if not isinstance(rec.get("reqs"), dict):
|
|
327
|
+
rec["reqs"] = {} # fresh record, or migrating from an earlier schema
|
|
328
|
+
rec["tx_off"] = 0
|
|
329
|
+
rec.pop("days", None)
|
|
330
|
+
rec.pop("tok", None)
|
|
331
|
+
if not isinstance(rec.get("burn"), list):
|
|
332
|
+
rec["burn"] = []
|
|
333
|
+
off = int(num(rec.get("tx_off")))
|
|
334
|
+
# New transcript for this session, or truncation/rotation -> reparse from start.
|
|
335
|
+
if rec.get("tx_path") != tx_path or off > size:
|
|
336
|
+
off, rec["reqs"], rec["burn"], rec["tx_path"] = 0, {}, [], tx_path
|
|
337
|
+
if off >= size:
|
|
338
|
+
return False
|
|
339
|
+
try:
|
|
340
|
+
with open(tx_path, "rb") as f:
|
|
341
|
+
f.seek(off)
|
|
342
|
+
chunk = f.read()
|
|
343
|
+
except OSError:
|
|
344
|
+
return False
|
|
345
|
+
new_off = size
|
|
346
|
+
# A writer may flush mid-line; keep a trailing partial line for next render.
|
|
347
|
+
if chunk and not chunk.endswith(b"\n"):
|
|
348
|
+
cut = chunk.rfind(b"\n")
|
|
349
|
+
if cut < 0:
|
|
350
|
+
return False # no complete line yet
|
|
351
|
+
new_off = off + cut + 1
|
|
352
|
+
chunk = chunk[:cut]
|
|
353
|
+
parsed = 0
|
|
354
|
+
for raw in chunk.split(b"\n"):
|
|
355
|
+
if not raw.strip():
|
|
356
|
+
continue
|
|
357
|
+
parsed += 1
|
|
358
|
+
if parsed > TX_MAX_NEW_LINES:
|
|
359
|
+
break
|
|
360
|
+
try:
|
|
361
|
+
o = json.loads(raw.decode("utf-8", "replace"))
|
|
362
|
+
except ValueError:
|
|
363
|
+
continue
|
|
364
|
+
if not isinstance(o, dict) or o.get("type") != "assistant":
|
|
365
|
+
continue
|
|
366
|
+
m = o.get("message")
|
|
367
|
+
u = m.get("usage") if isinstance(m, dict) else None
|
|
368
|
+
if not isinstance(u, dict):
|
|
369
|
+
continue
|
|
370
|
+
model = m.get("model") or "?"
|
|
371
|
+
i = num(u.get("input_tokens"))
|
|
372
|
+
cr = num(u.get("cache_read_input_tokens"))
|
|
373
|
+
cc = u.get("cache_creation")
|
|
374
|
+
if isinstance(cc, dict):
|
|
375
|
+
c5 = num(cc.get("ephemeral_5m_input_tokens"))
|
|
376
|
+
c1 = num(cc.get("ephemeral_1h_input_tokens"))
|
|
377
|
+
else:
|
|
378
|
+
c5 = c1 = 0.0
|
|
379
|
+
cw = num(u.get("cache_creation_input_tokens"))
|
|
380
|
+
if cw and not (c5 or c1): # 5m/1h split absent -> treat all as 5m
|
|
381
|
+
c5 = cw
|
|
382
|
+
out = num(u.get("output_tokens"))
|
|
383
|
+
ts = o.get("timestamp")
|
|
384
|
+
e = parse_iso(ts) if isinstance(ts, str) else None
|
|
385
|
+
if e is not None: # earliest message ts = true session start
|
|
386
|
+
t0 = rec.get("t0")
|
|
387
|
+
if not isinstance(t0, (int, float)) or e < t0:
|
|
388
|
+
rec["t0"] = e
|
|
389
|
+
# Dedupe by requestId: the transcript logs each API call multiple times
|
|
390
|
+
# (streaming/replay), so UPSERT each request's final usage exactly once
|
|
391
|
+
# instead of summing every line, which 2-3x over-counts BOTH tokens and cost.
|
|
392
|
+
key = o.get("requestId") or m.get("id") or f"_p{int(off) + parsed}"
|
|
393
|
+
is_new = key not in rec["reqs"]
|
|
394
|
+
rec["reqs"][key] = {"d": _msg_date(ts), "m": model,
|
|
395
|
+
"in": i, "cr": cr, "c5": c5, "c1": c1, "out": out}
|
|
396
|
+
if is_new:
|
|
397
|
+
rec["burn"].append(i + c5 + c1 + out) # fresh input + output (cache reads excluded)
|
|
398
|
+
if len(rec["burn"]) > BURN_RING:
|
|
399
|
+
rec["burn"] = rec["burn"][-BURN_RING:]
|
|
400
|
+
rec["tx_off"] = new_off
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _agg_reqs(reqs, only=None):
|
|
405
|
+
"""Aggregate the per-request dedup map into {model: tokens}; if `only` is a
|
|
406
|
+
date, include just that local-calendar-day's requests (for the Today totals)."""
|
|
407
|
+
out = {}
|
|
408
|
+
for r in (reqs or {}).values():
|
|
409
|
+
if not isinstance(r, dict):
|
|
410
|
+
continue
|
|
411
|
+
if only is not None and r.get("d") != only:
|
|
412
|
+
continue
|
|
413
|
+
o = out.setdefault(r.get("m") or "?",
|
|
414
|
+
{"in": 0.0, "cr": 0.0, "c5": 0.0, "c1": 0.0, "out": 0.0})
|
|
415
|
+
for k in o:
|
|
416
|
+
o[k] += num(r.get(k))
|
|
417
|
+
return out
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _totals(models):
|
|
421
|
+
"""Display totals + API-equivalent cost for a model map. The displayed "in" is
|
|
422
|
+
FRESH input (uncached input + cache writes) — cache READS are excluded from the
|
|
423
|
+
token count (they re-serve already-sent context every turn and would inflate it
|
|
424
|
+
30-100x), but they ARE still priced into the cost via api_cost()."""
|
|
425
|
+
tin = tout = 0.0
|
|
426
|
+
for t in (models or {}).values():
|
|
427
|
+
if isinstance(t, dict):
|
|
428
|
+
tin += num(t.get("in")) + num(t.get("c5")) + num(t.get("c1"))
|
|
429
|
+
tout += num(t.get("out"))
|
|
430
|
+
return {"in": tin, "out": tout, "cost": api_cost(models)}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def ledger_update(data, sid):
|
|
434
|
+
blank = {"in": 0.0, "out": 0.0, "cost": 0.0}
|
|
435
|
+
if not sid:
|
|
436
|
+
return {}, dict(blank), dict(blank)
|
|
437
|
+
path = ledger_path()
|
|
438
|
+
lock = _acquire_lock(path)
|
|
439
|
+
if lock is None:
|
|
440
|
+
return {}, dict(blank), dict(blank)
|
|
441
|
+
try:
|
|
442
|
+
return _ledger_update_unlocked(data, sid, path)
|
|
443
|
+
finally:
|
|
444
|
+
_release_lock(lock)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _ledger_update_unlocked(data, sid, path):
|
|
448
|
+
"""Read-modify-write the per-session ledger. Accumulate the session's TRUE token
|
|
449
|
+
COUNTS by tailing its transcript (deduped per requestId), take the COST from the
|
|
450
|
+
host's cost.total_cost_usd, and return (session record, this-session totals,
|
|
451
|
+
today's totals across sessions). Best-effort; safe blanks on any failure.
|
|
452
|
+
|
|
453
|
+
The statusline re-renders in a fresh process on every refresh, so this is the
|
|
454
|
+
product's hottest filesystem path: each write below is skipped unless it would
|
|
455
|
+
actually change bytes on disk (#392). The on-disk FORMAT is unchanged — the
|
|
456
|
+
per-session shard and the whole-ledger compatibility snapshot are both still
|
|
457
|
+
written exactly as before, just no longer unconditionally."""
|
|
458
|
+
blank = {"in": 0.0, "out": 0.0, "cost": 0.0}
|
|
459
|
+
now = time.time()
|
|
460
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
461
|
+
|
|
462
|
+
sessions, legacy = _merge_sessions(path)
|
|
463
|
+
|
|
464
|
+
rec = sessions.get(sid)
|
|
465
|
+
dirty = not isinstance(rec, dict)
|
|
466
|
+
if dirty:
|
|
467
|
+
rec = {}
|
|
468
|
+
stored_ts = num(rec.get("last_ts"))
|
|
469
|
+
if "first_ts" not in rec:
|
|
470
|
+
rec["first_ts"] = now
|
|
471
|
+
dirty = True
|
|
472
|
+
if rec.get("last_day") != today:
|
|
473
|
+
rec["last_day"] = today
|
|
474
|
+
dirty = True
|
|
475
|
+
host_cost = num(get(data, "cost", "total_cost_usd"))
|
|
476
|
+
if rec.get("host_cost") != host_cost:
|
|
477
|
+
rec["host_cost"] = host_cost
|
|
478
|
+
dirty = True
|
|
479
|
+
|
|
480
|
+
tx = data.get("transcript_path") if isinstance(data, dict) else None
|
|
481
|
+
if safe(_tx_accumulate, rec, tx):
|
|
482
|
+
dirty = True
|
|
483
|
+
sess = _totals(_agg_reqs(rec.get("reqs"))) # tokens: this session, all requests (deduped)
|
|
484
|
+
# Cost = Claude Code's authoritative cost.total_cost_usd — it already prices every
|
|
485
|
+
# call (including subagents in separate transcripts) exactly as your bill does, so
|
|
486
|
+
# it is far more accurate than recomputing tokens*price. api_cost is fallback only.
|
|
487
|
+
if rec["host_cost"] > 0:
|
|
488
|
+
sess["cost"] = rec["host_cost"]
|
|
489
|
+
bucket = dict(_totals(_agg_reqs(rec.get("reqs"), only=today)), date=today)
|
|
490
|
+
if rec.get("today") != bucket:
|
|
491
|
+
rec["today"] = bucket
|
|
492
|
+
dirty = True
|
|
493
|
+
if "tot" in rec: # retire the batch-1 whole-session cache key
|
|
494
|
+
del rec["tot"]
|
|
495
|
+
dirty = True
|
|
496
|
+
# Heartbeat: `last_ts` is pure liveness for the TTL sweep, so it rides along
|
|
497
|
+
# with a write we are already making rather than forcing one of its own.
|
|
498
|
+
if dirty or now - stored_ts >= LEDGER_HEARTBEAT:
|
|
499
|
+
rec["last_ts"] = now
|
|
500
|
+
dirty = True
|
|
501
|
+
sessions[sid] = rec
|
|
502
|
+
|
|
503
|
+
for k in list(sessions.keys()):
|
|
504
|
+
v = sessions[k]
|
|
505
|
+
if not isinstance(v, dict) or now - num(v.get("last_ts")) > SESSION_TTL:
|
|
506
|
+
del sessions[k]
|
|
507
|
+
# Each session owns one independently replaced shard. A writer for another
|
|
508
|
+
# session therefore cannot replace this record with an older snapshot. The
|
|
509
|
+
# merged map above already reflects every shard read under this same lock, so
|
|
510
|
+
# there is nothing a re-read could learn — no concurrent writer can have run.
|
|
511
|
+
if dirty:
|
|
512
|
+
_atomic_json(_session_file(path, sid), {"sid": sid, "rec": rec})
|
|
513
|
+
if sessions != legacy:
|
|
514
|
+
# Compatibility/readability cache; shards are truth. Rewritten only when
|
|
515
|
+
# it has drifted from the merged truth — which still covers TTL pruning,
|
|
516
|
+
# a new session, and a legacy-only record that a shard has superseded.
|
|
517
|
+
_write_snapshot(path, sessions)
|
|
518
|
+
|
|
519
|
+
# Today = each session's TODAY bucket (tokens whose transcript timestamp falls
|
|
520
|
+
# on the current local day), summed across sessions — not whole-session totals.
|
|
521
|
+
day = dict(blank)
|
|
522
|
+
for v in sessions.values():
|
|
523
|
+
if not isinstance(v, dict):
|
|
524
|
+
continue
|
|
525
|
+
t = v.get("today") if isinstance(v.get("today"), dict) else None
|
|
526
|
+
if t and t.get("date") == today: # tokens: true per-calendar-day buckets
|
|
527
|
+
day["in"] += num(t.get("in"))
|
|
528
|
+
day["out"] += num(t.get("out"))
|
|
529
|
+
if v.get("last_day") == today: # cost: host per-session total, day-attributed
|
|
530
|
+
day["cost"] += num(v.get("host_cost"))
|
|
531
|
+
return rec, sess, day
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def persist_sess_start(sid, value):
|
|
535
|
+
"""Write a resolved wall-clock session-start epoch into the ledger record for
|
|
536
|
+
`sid` so later renders read it from the ledger instead of re-scanning the host's
|
|
537
|
+
session-metadata directory (the statusline fast path). Best-effort and idempotent:
|
|
538
|
+
a no-op if already stored, and a silent skip on any I/O error — it must never
|
|
539
|
+
break a render. Returns True iff the ledger was written."""
|
|
540
|
+
if not sid or not value:
|
|
541
|
+
return False
|
|
542
|
+
path = ledger_path()
|
|
543
|
+
lock = _acquire_lock(path)
|
|
544
|
+
if lock is None:
|
|
545
|
+
return False
|
|
546
|
+
try:
|
|
547
|
+
return _persist_sess_start_unlocked(sid, value, path)
|
|
548
|
+
finally:
|
|
549
|
+
_release_lock(lock)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _persist_sess_start_unlocked(sid, value, path):
|
|
553
|
+
sessions, legacy = _merge_sessions(path)
|
|
554
|
+
rec = sessions.get(sid)
|
|
555
|
+
if not isinstance(rec, dict):
|
|
556
|
+
return False
|
|
557
|
+
if num(rec.get("sess_start"), None) == float(value):
|
|
558
|
+
return False # already cached — nothing to do
|
|
559
|
+
# Cache metadata has its own file, so it cannot overwrite a concurrently
|
|
560
|
+
# refreshed token/cost shard for the same session.
|
|
561
|
+
if not _atomic_json(_start_file(path, sid),
|
|
562
|
+
{"sid": sid, "sess_start": float(value)}):
|
|
563
|
+
return False
|
|
564
|
+
# Applying the value we just persisted is exactly what a re-merge would
|
|
565
|
+
# produce — the start-file overlay in _merge_sessions — without the reread.
|
|
566
|
+
rec["sess_start"] = float(value)
|
|
567
|
+
if sessions != legacy:
|
|
568
|
+
_write_snapshot(path, sessions)
|
|
569
|
+
return True
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# --------------------------------------------------------------------------- Pi usage ledger
|
|
573
|
+
def pi_ledger_path():
|
|
574
|
+
"""Return Pi's separate user-global usage ledger anchor.
|
|
575
|
+
|
|
576
|
+
This path is fixed: runtime environment cannot redirect Pi accounting into
|
|
577
|
+
Claude's `ledger.json` schema or session namespace. Tests inject an explicit
|
|
578
|
+
isolated anchor through `pi_ledger_update(..., path=...)` instead.
|
|
579
|
+
"""
|
|
580
|
+
return os.path.join(
|
|
581
|
+
os.path.expanduser("~"), ".codearbiter", "pi-usage-ledger.json"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def pi_blank_totals():
|
|
586
|
+
"""Fresh bounded empty Pi totals for fail-soft results."""
|
|
587
|
+
return {
|
|
588
|
+
"inputTokens": 0,
|
|
589
|
+
"outputTokens": 0,
|
|
590
|
+
"cacheReadTokens": 0,
|
|
591
|
+
"cacheWriteTokens": 0,
|
|
592
|
+
"costUsd": 0.0,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _pi_result(status, session=None, today=None, high_water=-1,
|
|
597
|
+
accepted_through=-1):
|
|
598
|
+
"""Return durable state plus a distinct acknowledgment for this call."""
|
|
599
|
+
if status not in PI_STATUSES:
|
|
600
|
+
status = "corrupt"
|
|
601
|
+
return {
|
|
602
|
+
"status": status,
|
|
603
|
+
"session": dict(session) if isinstance(session, dict) else pi_blank_totals(),
|
|
604
|
+
"today": dict(today) if isinstance(today, dict) else pi_blank_totals(),
|
|
605
|
+
"acceptedThrough": accepted_through if type(accepted_through) is int else -1,
|
|
606
|
+
"highWater": high_water if type(high_water) is int else -1,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _pi_session_file(path, session_key):
|
|
611
|
+
"""A validated digest is both the bounded identity and safe shard basename."""
|
|
612
|
+
return os.path.join(f"{path}.sessions", f"{session_key}.json")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _pi_canonical_path(path):
|
|
616
|
+
"""Resolve symlinks/junctions in the existing prefix of a future path."""
|
|
617
|
+
if not isinstance(path, str) or not path or len(path) > PI_MAX_PATH_CHARS \
|
|
618
|
+
or path != path.strip() \
|
|
619
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in path):
|
|
620
|
+
return None
|
|
621
|
+
try:
|
|
622
|
+
absolute = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
|
623
|
+
except (OSError, TypeError, ValueError):
|
|
624
|
+
return None
|
|
625
|
+
if len(absolute) > PI_MAX_PATH_CHARS:
|
|
626
|
+
return None
|
|
627
|
+
probe = absolute
|
|
628
|
+
suffix = []
|
|
629
|
+
try:
|
|
630
|
+
while not os.path.lexists(probe):
|
|
631
|
+
parent, name = os.path.split(probe)
|
|
632
|
+
if not name or parent == probe:
|
|
633
|
+
return None
|
|
634
|
+
suffix.append(name)
|
|
635
|
+
probe = parent
|
|
636
|
+
if suffix and not os.path.isdir(probe):
|
|
637
|
+
return None
|
|
638
|
+
resolved = os.path.realpath(probe)
|
|
639
|
+
except (OSError, TypeError, ValueError):
|
|
640
|
+
return None
|
|
641
|
+
for name in reversed(suffix):
|
|
642
|
+
resolved = os.path.join(resolved, name)
|
|
643
|
+
return os.path.normcase(os.path.normpath(resolved))
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _pi_paths_overlap(left, right):
|
|
647
|
+
try:
|
|
648
|
+
common = os.path.commonpath((left, right))
|
|
649
|
+
except (OSError, TypeError, ValueError):
|
|
650
|
+
return False
|
|
651
|
+
return common == left or common == right
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _pi_resolve_path(path):
|
|
655
|
+
"""Return an isolated canonical Pi anchor, or None before lock/write."""
|
|
656
|
+
explicit = path is not None
|
|
657
|
+
candidate = pi_ledger_path() if path is None else path
|
|
658
|
+
if explicit and (not isinstance(candidate, str) or not os.path.isabs(candidate)):
|
|
659
|
+
return None
|
|
660
|
+
try:
|
|
661
|
+
if os.path.isdir(candidate):
|
|
662
|
+
return None
|
|
663
|
+
except (OSError, TypeError, ValueError):
|
|
664
|
+
return None
|
|
665
|
+
pi_anchor = _pi_canonical_path(candidate)
|
|
666
|
+
pi_sessions = _pi_canonical_path(f"{candidate}.sessions") \
|
|
667
|
+
if isinstance(candidate, str) else None
|
|
668
|
+
pi_lock = _pi_canonical_path(f"{candidate}.lock") \
|
|
669
|
+
if isinstance(candidate, str) else None
|
|
670
|
+
claude = ledger_path()
|
|
671
|
+
claude_anchor = _pi_canonical_path(claude)
|
|
672
|
+
claude_sessions = _pi_canonical_path(f"{claude}.sessions") \
|
|
673
|
+
if isinstance(claude, str) else None
|
|
674
|
+
claude_lock = _pi_canonical_path(f"{claude}.lock") \
|
|
675
|
+
if isinstance(claude, str) else None
|
|
676
|
+
if None in (
|
|
677
|
+
pi_anchor, pi_sessions, pi_lock,
|
|
678
|
+
claude_anchor, claude_sessions, claude_lock):
|
|
679
|
+
return None
|
|
680
|
+
if any(_pi_paths_overlap(pi_path, claude_path)
|
|
681
|
+
for pi_path in (pi_anchor, pi_sessions, pi_lock)
|
|
682
|
+
for claude_path in (claude_anchor, claude_sessions, claude_lock)):
|
|
683
|
+
return None
|
|
684
|
+
return pi_anchor
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _pi_timestamp_day(value):
|
|
688
|
+
if not isinstance(value, str) or not value or len(value) > PI_MAX_TIMESTAMP_CHARS:
|
|
689
|
+
return None
|
|
690
|
+
if value != value.strip() \
|
|
691
|
+
or any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in value):
|
|
692
|
+
return None
|
|
693
|
+
source = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
694
|
+
try:
|
|
695
|
+
parsed = datetime.fromisoformat(source)
|
|
696
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
697
|
+
return None
|
|
698
|
+
local = parsed.astimezone()
|
|
699
|
+
except (OSError, OverflowError, ValueError):
|
|
700
|
+
return None
|
|
701
|
+
if not PI_MIN_YEAR <= parsed.year <= PI_MAX_YEAR \
|
|
702
|
+
or not PI_MIN_YEAR <= local.year <= PI_MAX_YEAR:
|
|
703
|
+
return None
|
|
704
|
+
return local.date().isoformat()
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _pi_token(value):
|
|
708
|
+
return type(value) is int and 0 <= value <= PI_MAX_TOKENS
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _pi_cost(value):
|
|
712
|
+
if type(value) is int:
|
|
713
|
+
return 0 <= value <= PI_MAX_COST_USD
|
|
714
|
+
return type(value) is float and math.isfinite(value) \
|
|
715
|
+
and 0 <= value <= PI_MAX_COST_USD
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _pi_totals_valid(value):
|
|
719
|
+
return isinstance(value, dict) and set(value) == PI_TOTAL_KEYS \
|
|
720
|
+
and all(_pi_token(value[key]) for key in PI_TOTAL_KEYS if key != "costUsd") \
|
|
721
|
+
and _pi_cost(value["costUsd"])
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _pi_day_valid(value):
|
|
725
|
+
if not isinstance(value, str) or PI_DAY_RE.fullmatch(value) is None:
|
|
726
|
+
return False
|
|
727
|
+
try:
|
|
728
|
+
parsed = datetime.strptime(value, "%Y-%m-%d")
|
|
729
|
+
except ValueError:
|
|
730
|
+
return False
|
|
731
|
+
return PI_MIN_YEAR <= parsed.year <= PI_MAX_YEAR
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _pi_updated_at(value):
|
|
735
|
+
return type(value) is int and 0 <= value <= PI_MAX_UPDATED_AT
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _pi_now():
|
|
739
|
+
try:
|
|
740
|
+
return max(0, min(PI_MAX_UPDATED_AT, int(time.time())))
|
|
741
|
+
except (OverflowError, TypeError, ValueError):
|
|
742
|
+
return 0
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _pi_normalize_chunk(session_key, scan_start, scan_end, facts):
|
|
746
|
+
"""Validate the entire boundary before any lock or filesystem operation."""
|
|
747
|
+
if not isinstance(session_key, str) \
|
|
748
|
+
or PI_SESSION_KEY_RE.fullmatch(session_key) is None \
|
|
749
|
+
or type(scan_start) is not int or type(scan_end) is not int \
|
|
750
|
+
or not 0 <= scan_start <= scan_end <= PI_MAX_POSITION \
|
|
751
|
+
or scan_end - scan_start + 1 > PI_MAX_SCAN_ENTRIES \
|
|
752
|
+
or not isinstance(facts, list) or len(facts) > PI_MAX_SCAN_ENTRIES:
|
|
753
|
+
return None
|
|
754
|
+
normalized = []
|
|
755
|
+
previous = -1
|
|
756
|
+
for fact in facts:
|
|
757
|
+
if not isinstance(fact, dict) or set(fact) != PI_FACT_KEYS:
|
|
758
|
+
return None
|
|
759
|
+
position = fact["position"]
|
|
760
|
+
if type(position) is not int or not 0 <= position <= PI_MAX_POSITION \
|
|
761
|
+
or not scan_start <= position <= scan_end or position <= previous:
|
|
762
|
+
return None
|
|
763
|
+
day = _pi_timestamp_day(fact["timestamp"])
|
|
764
|
+
totals = {key: fact[key] for key in PI_TOTAL_KEYS}
|
|
765
|
+
if day is None or not _pi_totals_valid(totals):
|
|
766
|
+
return None
|
|
767
|
+
totals["costUsd"] = round(float(totals["costUsd"]), 9)
|
|
768
|
+
normalized.append((position, day, totals))
|
|
769
|
+
previous = position
|
|
770
|
+
return normalized
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _pi_add_totals(left, right):
|
|
774
|
+
if not _pi_totals_valid(left) or not _pi_totals_valid(right):
|
|
775
|
+
return None
|
|
776
|
+
output = {}
|
|
777
|
+
for key in PI_TOTAL_KEYS:
|
|
778
|
+
if key == "costUsd":
|
|
779
|
+
total = round(float(left[key]) + float(right[key]), 9)
|
|
780
|
+
if not _pi_cost(total):
|
|
781
|
+
return None
|
|
782
|
+
else:
|
|
783
|
+
total = left[key] + right[key]
|
|
784
|
+
if not _pi_token(total):
|
|
785
|
+
return None
|
|
786
|
+
output[key] = total
|
|
787
|
+
return output
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _pi_shard_valid(value, session_key):
|
|
791
|
+
if not isinstance(value, dict) or set(value) != {
|
|
792
|
+
"schema", "sessionKey", "highWater", "updatedAt", "totals", "days"}:
|
|
793
|
+
return False
|
|
794
|
+
high_water = value.get("highWater")
|
|
795
|
+
days = value.get("days")
|
|
796
|
+
return value.get("schema") == PI_SESSION_SCHEMA \
|
|
797
|
+
and value.get("sessionKey") == session_key \
|
|
798
|
+
and type(high_water) is int and -1 <= high_water <= PI_MAX_POSITION \
|
|
799
|
+
and _pi_updated_at(value.get("updatedAt")) \
|
|
800
|
+
and _pi_totals_valid(value.get("totals")) \
|
|
801
|
+
and isinstance(days, dict) and len(days) <= PI_MAX_DAYS \
|
|
802
|
+
and all(_pi_day_valid(day) and _pi_totals_valid(totals)
|
|
803
|
+
for day, totals in days.items())
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _pi_file_kind(path):
|
|
807
|
+
"""Classify a path without following links or opening special objects."""
|
|
808
|
+
try:
|
|
809
|
+
info = os.lstat(path)
|
|
810
|
+
except FileNotFoundError:
|
|
811
|
+
return "missing"
|
|
812
|
+
except OSError:
|
|
813
|
+
return "error"
|
|
814
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x0400)
|
|
815
|
+
if not stat.S_ISREG(info.st_mode) \
|
|
816
|
+
or getattr(info, "st_file_attributes", 0) & reparse_flag:
|
|
817
|
+
return "special"
|
|
818
|
+
return "regular"
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _pi_read_shard(path, session_key):
|
|
822
|
+
kind = _pi_file_kind(path)
|
|
823
|
+
if kind == "missing":
|
|
824
|
+
return None, "missing"
|
|
825
|
+
if kind != "regular":
|
|
826
|
+
return None, "corrupt"
|
|
827
|
+
try:
|
|
828
|
+
with open(path, "rb") as stream:
|
|
829
|
+
raw = stream.read(PI_MAX_SHARD_BYTES + 1)
|
|
830
|
+
except OSError:
|
|
831
|
+
return None, "corrupt"
|
|
832
|
+
if len(raw) > PI_MAX_SHARD_BYTES:
|
|
833
|
+
return None, "corrupt"
|
|
834
|
+
try:
|
|
835
|
+
value = json.loads(raw.decode("utf-8", "strict"))
|
|
836
|
+
except (UnicodeDecodeError, ValueError, RecursionError):
|
|
837
|
+
return None, "corrupt"
|
|
838
|
+
if not _pi_shard_valid(value, session_key):
|
|
839
|
+
return None, "corrupt"
|
|
840
|
+
return value, "ok"
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _pi_apply_facts(shard, facts, scan_end):
|
|
844
|
+
"""Return a new shard, or None if bounded cumulative state would overflow."""
|
|
845
|
+
updated = {
|
|
846
|
+
"schema": shard["schema"],
|
|
847
|
+
"sessionKey": shard["sessionKey"],
|
|
848
|
+
"highWater": shard["highWater"],
|
|
849
|
+
"updatedAt": shard["updatedAt"],
|
|
850
|
+
"totals": dict(shard["totals"]),
|
|
851
|
+
"days": {day: dict(totals) for day, totals in shard["days"].items()},
|
|
852
|
+
}
|
|
853
|
+
for position, day, totals in facts:
|
|
854
|
+
if position <= updated["highWater"]:
|
|
855
|
+
continue
|
|
856
|
+
session = _pi_add_totals(updated["totals"], totals)
|
|
857
|
+
day_totals = _pi_add_totals(updated["days"].get(day, pi_blank_totals()), totals)
|
|
858
|
+
if session is None or day_totals is None:
|
|
859
|
+
return None
|
|
860
|
+
updated["totals"] = session
|
|
861
|
+
updated["days"][day] = day_totals
|
|
862
|
+
while len(updated["days"]) > PI_MAX_DAYS:
|
|
863
|
+
del updated["days"][min(updated["days"])]
|
|
864
|
+
updated["highWater"] = scan_end
|
|
865
|
+
return updated
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _pi_collect_shards(path):
|
|
869
|
+
"""Return valid regular Pi shards only; unrelated/temp entries are ignored."""
|
|
870
|
+
directory = f"{path}.sessions"
|
|
871
|
+
shards = []
|
|
872
|
+
corrupt = False
|
|
873
|
+
try:
|
|
874
|
+
with os.scandir(directory) as entries:
|
|
875
|
+
recognized = 0
|
|
876
|
+
for entry in entries:
|
|
877
|
+
match = PI_SHARD_NAME_RE.fullmatch(entry.name)
|
|
878
|
+
if match is None:
|
|
879
|
+
continue
|
|
880
|
+
recognized += 1
|
|
881
|
+
if recognized > PI_MAX_DIRECTORY_ENTRIES:
|
|
882
|
+
return [], True
|
|
883
|
+
kind = _pi_file_kind(entry.path)
|
|
884
|
+
if kind in {"missing", "special"}:
|
|
885
|
+
continue
|
|
886
|
+
if kind != "regular":
|
|
887
|
+
corrupt = True
|
|
888
|
+
continue
|
|
889
|
+
session_key = match.group(1)
|
|
890
|
+
shard, state = _pi_read_shard(entry.path, session_key)
|
|
891
|
+
if state != "ok":
|
|
892
|
+
corrupt = True
|
|
893
|
+
continue
|
|
894
|
+
shards.append((entry.path, shard))
|
|
895
|
+
except FileNotFoundError:
|
|
896
|
+
return [], False
|
|
897
|
+
except OSError:
|
|
898
|
+
return [], True
|
|
899
|
+
return shards, corrupt
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _pi_retain_shards(path, current_key):
|
|
903
|
+
"""Prune valid shards deterministically, always retaining the current shard."""
|
|
904
|
+
shards, corrupt = _pi_collect_shards(path)
|
|
905
|
+
ranked = sorted(
|
|
906
|
+
shards,
|
|
907
|
+
key=lambda item: (
|
|
908
|
+
0 if item[1]["sessionKey"] == current_key else 1,
|
|
909
|
+
-item[1]["updatedAt"],
|
|
910
|
+
item[1]["sessionKey"],
|
|
911
|
+
),
|
|
912
|
+
)
|
|
913
|
+
write_failed = False
|
|
914
|
+
for shard_path, _shard in ranked[max(1, PI_MAX_SHARDS):]:
|
|
915
|
+
try:
|
|
916
|
+
os.remove(shard_path)
|
|
917
|
+
except OSError:
|
|
918
|
+
write_failed = True
|
|
919
|
+
retained, after_corrupt = _pi_collect_shards(path)
|
|
920
|
+
return retained, corrupt or after_corrupt, write_failed
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _pi_today_from_shards(path, today, current_key):
|
|
924
|
+
"""Retain, reload, then sum today's authoritative fixed totals."""
|
|
925
|
+
shards, corrupt, write_failed = _pi_retain_shards(path, current_key)
|
|
926
|
+
total = pi_blank_totals()
|
|
927
|
+
for _shard_path, shard in shards:
|
|
928
|
+
day_totals = shard["days"].get(today)
|
|
929
|
+
if day_totals is None:
|
|
930
|
+
continue
|
|
931
|
+
added = _pi_add_totals(total, day_totals)
|
|
932
|
+
if added is None:
|
|
933
|
+
corrupt = True
|
|
934
|
+
continue
|
|
935
|
+
total = added
|
|
936
|
+
return total, corrupt, write_failed
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _write_pi_snapshot(path, today_date, today):
|
|
940
|
+
"""Atomically refresh Pi's bounded root readability cache."""
|
|
941
|
+
try:
|
|
942
|
+
return _atomic_json(path, {
|
|
943
|
+
"schema": PI_LEDGER_SCHEMA, "date": today_date, "today": today,
|
|
944
|
+
})
|
|
945
|
+
except Exception: # noqa: BLE001 - cache failures have one fixed status
|
|
946
|
+
return False
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _pi_finish_update(path, shard, current_key, accepted_through):
|
|
950
|
+
"""Prune, aggregate, cache, and shape one new-or-replayed result."""
|
|
951
|
+
today_date = datetime.now().astimezone().date().isoformat()
|
|
952
|
+
today, aggregate_corrupt, retain_failed = _pi_today_from_shards(
|
|
953
|
+
path, today_date, current_key
|
|
954
|
+
)
|
|
955
|
+
status = "write_failed" if retain_failed else (
|
|
956
|
+
"corrupt" if aggregate_corrupt else "ok"
|
|
957
|
+
)
|
|
958
|
+
if status == "ok" and not _write_pi_snapshot(path, today_date, today):
|
|
959
|
+
status = "write_failed"
|
|
960
|
+
return _pi_result(
|
|
961
|
+
status,
|
|
962
|
+
shard["totals"],
|
|
963
|
+
today,
|
|
964
|
+
shard["highWater"],
|
|
965
|
+
accepted_through if status == "ok" else -1,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def pi_ledger_update(session_key, scan_start, scan_end, facts, path=None):
|
|
970
|
+
"""Add one bounded, sorted Pi usage-fact chunk and return session/day totals.
|
|
971
|
+
|
|
972
|
+
`scan_start..scan_end` acknowledges one contiguous bounded slice of the raw
|
|
973
|
+
append-only Pi session-entry array. Usage facts are sparse within that range
|
|
974
|
+
because non-assistant entries are omitted. A successful atomic shard replace
|
|
975
|
+
advances the cursor to `scan_end`, including for an empty fact list. No
|
|
976
|
+
message, path, command, environment, output, or raw session identity is
|
|
977
|
+
accepted or persisted.
|
|
978
|
+
"""
|
|
979
|
+
normalized = _pi_normalize_chunk(session_key, scan_start, scan_end, facts)
|
|
980
|
+
if normalized is None:
|
|
981
|
+
return _pi_result("invalid")
|
|
982
|
+
path = _pi_resolve_path(path)
|
|
983
|
+
if path is None:
|
|
984
|
+
return _pi_result("invalid")
|
|
985
|
+
try:
|
|
986
|
+
lock = _acquire_lock(path)
|
|
987
|
+
except Exception: # noqa: BLE001 - lock/path failures are a fixed fail-soft status
|
|
988
|
+
return _pi_result("lock_failed")
|
|
989
|
+
if lock is None:
|
|
990
|
+
return _pi_result("lock_failed")
|
|
991
|
+
try:
|
|
992
|
+
return _pi_ledger_update_unlocked(
|
|
993
|
+
path, session_key, scan_start, scan_end, normalized
|
|
994
|
+
)
|
|
995
|
+
except Exception: # noqa: BLE001 - a footer usage snapshot is always fail-soft
|
|
996
|
+
return _pi_result("corrupt")
|
|
997
|
+
finally:
|
|
998
|
+
_release_lock(lock)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _pi_ledger_update_unlocked(path, session_key, scan_start, scan_end, facts):
|
|
1002
|
+
shard_path = _pi_session_file(path, session_key)
|
|
1003
|
+
shard, state = _pi_read_shard(shard_path, session_key)
|
|
1004
|
+
if state == "corrupt":
|
|
1005
|
+
return _pi_result("corrupt")
|
|
1006
|
+
if state == "missing":
|
|
1007
|
+
shard = {
|
|
1008
|
+
"schema": PI_SESSION_SCHEMA,
|
|
1009
|
+
"sessionKey": session_key,
|
|
1010
|
+
"highWater": -1,
|
|
1011
|
+
"updatedAt": 0,
|
|
1012
|
+
"totals": pi_blank_totals(),
|
|
1013
|
+
"days": {},
|
|
1014
|
+
}
|
|
1015
|
+
if scan_end <= shard["highWater"]:
|
|
1016
|
+
return _pi_finish_update(path, shard, session_key, scan_end)
|
|
1017
|
+
if scan_start != shard["highWater"] + 1:
|
|
1018
|
+
return _pi_result("invalid")
|
|
1019
|
+
updated = _pi_apply_facts(shard, facts, scan_end)
|
|
1020
|
+
if updated is None:
|
|
1021
|
+
return _pi_result("invalid")
|
|
1022
|
+
updated["updatedAt"] = _pi_now()
|
|
1023
|
+
if state == "missing" or updated != shard:
|
|
1024
|
+
if not _atomic_json(shard_path, updated):
|
|
1025
|
+
return _pi_result("write_failed")
|
|
1026
|
+
return _pi_finish_update(path, updated, session_key, scan_end)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def burn_samples(rec):
|
|
1030
|
+
"""Recent per-message token-burn values (most-recent window) for the sparkline —
|
|
1031
|
+
real per-API-call totals accumulated from the transcript, not a time-extrapolated
|
|
1032
|
+
estimate. Returns [] when there is too little data to draw a line. The statusline
|
|
1033
|
+
turns this list into a colored sparkline; this lib stays render-free."""
|
|
1034
|
+
b = [num(x) for x in (rec.get("burn") or []) if isinstance(x, (int, float))]
|
|
1035
|
+
return b[-24:] if len(b) >= 2 else []
|