diff --git a/README.md b/README.md
index 9061a7f..0b02f9b 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ Common reports:
codex-usage-profiler --days 7 --top 20
codex-usage-profiler --since 2026-06-24
codex-usage-profiler --format json --output reports/latest.json
+codex-usage-profiler --days 14 --monthly-plan-price-usd 200 --plan-price Plus=20 --plan-price Pro=200
codex-usage-dashboard --report reports/latest.json
```
@@ -65,9 +66,30 @@ codex-usage-profiler --no-codexbar --paths ~/.codex/sessions
Raw prompt and response bodies are not printed by default. Use `--include-snippets` only when you explicitly want bounded redacted task snippets in JSON/text output.
+## Network Collection
+
+For multi-machine setups, install the collector on each host and run the dashboard/report refresh on one always-on machine:
+
+```bash
+codex-usage-collector --print-default-config > ~/.config/codex-usage-profiler/collector.json
+codex-usage-collector --config ~/.config/codex-usage-profiler/collector.json --once
+```
+
+The collector scans configurable JSON/JSONL session globs for Codex, Claude, Pi, T3Chat, Kimi, droid, Cursor, OpenCode, and Paperclip, stages changed files with metadata sidecars, and pushes them to the configured central destination. Linux systemd user units, macOS launchd plist, LXSO report refresh, dashboard service, and a rootless high-port `.lan` proxy are under `ops/`.
+
+Central LXSO pattern:
+
+```bash
+ops/bin/codex-usage-run-report.sh
+codex-usage-dashboard --report ~/.local/state/codex-usage-profiler/latest-report.json --host 0.0.0.0 --port 8775
+```
+
+Collector, report-refresh, and dashboard access events are written as JSONL and can also be forwarded to Orange Pi syslog and LXSO2 Loki. In Grafana, query Loki with `{job="codex-usage-profiler"}` and narrow by `service="collector"`, `service="report"`, or `service="dashboard"`.
+
## What The Tool Produces
- Usage grouped by client, project, task, model, day, hour, Paperclip company, Paperclip staff, and Paperclip task.
+- Paperclip company spend by day/month, company 30-day projections, and configurable plan-price comparisons.
- Top sessions by observed tokens and rate-card cost.
- Current CodexBar quota windows when CodexBar is installed.
- CodexBar local-vs-cache ratio rows to reveal scope mismatch.
@@ -79,6 +101,7 @@ Raw prompt and response bodies are not printed by default. Use `--include-snippe
- `Tokens`: observed tokens found in local session logs.
- `Rate-card cost`: directional replacement-cost estimate from known model rates or CodexBar pricing caches.
- `Live quota now`: current CodexBar quota window, not a historical usage allocation.
+- `Plan comparison`: projected local rate-card replacement cost vs configured plan price; useful for value/scale decisions, not official quota reconstruction.
- `Observed share`: share of tokens within the scanned local logs.
- `Durable output`: sessions with observable output signals such as edits, tests, commits, PRs, or action tools. This does not prove end-user value.
- `Review candidates`: sessions matching patterns worth inspecting. These can overlap with durable-output sessions.
@@ -105,6 +128,7 @@ This project was built with OpenSpec as an explicit design trail:
- `openspec/changes/build-usage-profiler/`: ingestion, attribution, quota/cost estimates, outcome evidence, reporting.
- `openspec/changes/add-session-dashboard-ui/`: compact dashboard, user stories, mockup validation, interaction contract.
+- `openspec/changes/add-network-session-collection/`: multi-host collection, LXSO deployment, `.lan` access, and Grafana-visible logging.
- `docs/dashboard-final-interaction-contract.md`: card-by-card interaction expectations.
- `docs/critical-review.md`: subagent critique summary and which critical fixes were accepted.
diff --git a/openspec/changes/add-session-dashboard-ui/design.md b/openspec/changes/add-session-dashboard-ui/design.md
index 04f7ac6..412f0fd 100644
--- a/openspec/changes/add-session-dashboard-ui/design.md
+++ b/openspec/changes/add-session-dashboard-ui/design.md
@@ -58,6 +58,10 @@ Use CSS bars and inline SVG for timeline, heatmap, rankings, coverage, and spend
Alternative: Chart.js/D3. Rejected for dependency weight.
+### Card-local resets and responsive relayout
+
+Each filterable visualization exposes a local reset button in the panel header. Active chart items still toggle off directly, but reset buttons make recovery obvious when the active item is clipped, scrolled away, or visually subtle. The Sankey/alluvial flow re-renders on viewport changes because its SVG links and DOM nodes are positioned from the container width.
+
### Privacy-safe evidence drawer
Show structured evidence such as edits, tests, commits, PRs, commands, token counts, attribution confidence, session IDs, and local paths. Do not show raw prompts unless a report explicitly includes snippets.
diff --git a/openspec/changes/add-session-dashboard-ui/specs/dashboard-ui/spec.md b/openspec/changes/add-session-dashboard-ui/specs/dashboard-ui/spec.md
index 9eba65f..7585ea4 100644
--- a/openspec/changes/add-session-dashboard-ui/specs/dashboard-ui/spec.md
+++ b/openspec/changes/add-session-dashboard-ui/specs/dashboard-ui/spec.md
@@ -55,6 +55,12 @@ The dashboard SHALL visualize usage through an hourly timeline, day/hour heatmap
- **AND WHEN** the user clicks the active item again
- **THEN** the dashboard clears only that item's filter and returns the affected panel toward its default scope without clearing unrelated filters
+#### Scenario: Card-local reset
+- **WHEN** a visualization panel applies or reflects a local selection such as a flow selection, time brush, company/day selection, heatmap cell, review candidate, coverage bucket, or projection filter
+- **THEN** that panel shows an enabled reset control
+- **AND WHEN** the user activates the reset control
+- **THEN** the dashboard clears that panel's local selection, updates the permalink, and disables the reset control again
+
### Requirement: Session Table And Evidence Drawer
The dashboard SHALL provide a sortable compact session table and a session evidence drawer showing attribution, token/rate-card cost estimates, outcome, review pattern, edits, tests, command labels, and linked identifiers when available.
@@ -76,6 +82,11 @@ The dashboard SHALL provide keyboard-accessible controls, visible focus states,
- **WHEN** the user navigates the page with the keyboard
- **THEN** interactive controls, table rows, exports, and the evidence drawer are reachable and labelled
+#### Scenario: No unintended horizontal overflow
+- **WHEN** the dashboard is viewed with both sidebars open at desktop widths or on a narrow mobile viewport
+- **THEN** top-level regions, panel headers, heatmap, coverage, and flow panels fit within the viewport
+- **AND** only deliberate scroll containers such as the session table may scroll horizontally
+
### Requirement: Mockup Validation
The implementation SHALL preserve the core structure of the final imagegen mockup: filter rail, KPI strip, main graph grid, session table, and evidence drawer.
diff --git a/openspec/changes/add-session-dashboard-ui/tasks.md b/openspec/changes/add-session-dashboard-ui/tasks.md
index e6848d8..0c44fce 100644
--- a/openspec/changes/add-session-dashboard-ui/tasks.md
+++ b/openspec/changes/add-session-dashboard-ui/tasks.md
@@ -24,3 +24,11 @@
- [x] 4.2 Add data-function tests for filtering, sorting, summaries, CSV, and permalink state
- [x] 4.3 Run the unit and integration test suite
- [x] 4.4 Start the dashboard against the real report and verify local/Tailscale access details
+
+## 5. Interaction And Layout Hardening
+
+- [x] 5.1 Add card-local reset controls for all filterable visualization panels
+- [x] 5.2 Make company day selections visibly active and reversible
+- [x] 5.3 Re-render the Sankey flow after viewport/sidebar size changes
+- [x] 5.4 Fix desktop/sidebar and mobile text overflow in panel headers, heatmap, coverage, and topbar
+- [x] 5.5 Extend E2E tests for card resets, flow bounds, and viewport overflow
diff --git a/openspec/changes/archive/2026-07-04-add-network-session-collection/.openspec.yaml b/openspec/changes/archive/2026-07-04-add-network-session-collection/.openspec.yaml
new file mode 100644
index 0000000..43e65ca
--- /dev/null
+++ b/openspec/changes/archive/2026-07-04-add-network-session-collection/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-03
diff --git a/openspec/changes/archive/2026-07-04-add-network-session-collection/design.md b/openspec/changes/archive/2026-07-04-add-network-session-collection/design.md
new file mode 100644
index 0000000..ffd2793
--- /dev/null
+++ b/openspec/changes/archive/2026-07-04-add-network-session-collection/design.md
@@ -0,0 +1,73 @@
+## Context
+
+The profiler currently scans local paths. Alex's real usage spans several machines and harnesses: this Mac, LXSO1, LXSO2, LXSO3, NurseDroid, Codex, Claude, Pi, T3Chat, Kimi, droid, Cursor, OpenCode, and Paperclip. LXSO1 is the best central app host because it is always on and already runs agent services. `grafana.lan` and Loki now live on LXSO2, while Orange Pi still owns `.lan` DNS/Caddy and the syslog/Postgres observability path.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Run Codex Usage Profiler continuously on an LXSO machine.
+- Collect session files from all reachable hosts without requiring tool-specific daemons.
+- Make new tools easy to add with config globs.
+- Preserve source host/tool/path metadata.
+- Refresh the central report on a timer and serve the dashboard over LAN/Tailscale.
+- Emit collector, report-refresh, and dashboard events to local JSONL logs, Orange Pi syslog/Postgres, and LXSO2 Loki for Grafana.
+
+**Non-Goals:**
+
+- Exact billing reconstruction.
+- Parsing every proprietary chat database format in the collector.
+- Replacing existing Grafana/Loki infrastructure.
+- Copying auth files, caches, model downloads, browser profiles, or arbitrary app data.
+
+## Decisions
+
+1. Use push collectors over SSH/rsync.
+
+ Rationale: every machine can run a user-level timer and push changed files to LXSO1. No central credentials or inbound collector API are needed.
+
+2. Use config-driven globs with conservative defaults.
+
+ Rationale: Codex and Claude have known JSONL locations, but T3Chat, Kimi, and droid session paths can vary by install. Defaults catch common session directories; per-host config can add or remove paths.
+
+3. Stage files under `collector/tool/hash/basename` plus a metadata sidecar.
+
+ Rationale: this avoids path collisions, keeps source metadata, and lets the profiler infer client/host from central paths while source files stay untouched.
+
+3a. Snapshot lightweight Paperclip topology.
+
+ Rationale: central LXSO reports do not have direct access to the Mac's Paperclip API or local company/agent instruction tree. The collector can safely stage a small `paperclip-metadata.json` snapshot with company, agent, project, and workspace labels. This avoids opaque UUID-only reports while keeping raw prompts and source data out of the snapshot.
+
+4. Keep the collector dependency-free.
+
+ Rationale: LXSO, NurseDroid, Orange Pi, and macOS all have Python 3. A standard-library collector is easier to install than a daemon with a dependency tree.
+
+5. Log to JSONL, syslog, and Loki.
+
+ Rationale: JSONL gives local troubleshooting. Syslog matches the existing Orange Pi ingestion path. Direct Loki push makes the same events visible in canonical `grafana.lan` without requiring journald scraping on every host.
+
+6. Prefer LXSO1 for the dashboard; use a high-port `.lan` proxy if Caddy cannot be modified.
+
+ Rationale: Orange Pi Caddy is root-owned. When passwordless sudo is unavailable, `codex-usage.lan:8775` can still work through wildcard `.lan` DNS plus a user-level proxy.
+
+## Risks / Trade-offs
+
+- Tool paths can be wrong or incomplete -> keep config visible and log zero-match tools.
+- First collection can copy many files -> skip oversized files and copy only mtime/size changes after the first run.
+- Non-Codex logs can lack token fields -> central report labels token-known coverage rather than inventing numbers.
+- Remote forwarding can fail independently -> local JSONL logs remain authoritative and forwarding failures do not fail collection.
+- `.lan` without a port needs root Caddy changes -> provide a working high-port path and document the root-owned route.
+
+## Migration Plan
+
+1. Add collector, tests, and ops assets.
+2. Deploy the repo to LXSO1 and install dashboard/report systemd user units.
+3. Install collector configs/timers on this Mac, LXSO1, LXSO2, LXSO3, and NurseDroid.
+4. Start a user-level Orange Pi LAN proxy if Caddy cannot be patched.
+5. Run collectors once, refresh the report, check dashboard health, and query Loki/Grafana labels.
+6. Roll back by disabling the user timers/services and deleting the central collection directory.
+
+## Open Questions
+
+- Whether to add direct parsers for T3Chat/Kimi/droid browser storage formats after real files are observed.
+- Whether LXSO2 Loki should later scrape host journald directly in addition to direct push events.
diff --git a/openspec/changes/archive/2026-07-04-add-network-session-collection/proposal.md b/openspec/changes/archive/2026-07-04-add-network-session-collection/proposal.md
new file mode 100644
index 0000000..b705894
--- /dev/null
+++ b/openspec/changes/archive/2026-07-04-add-network-session-collection/proposal.md
@@ -0,0 +1,28 @@
+## Why
+
+Codex Usage Profiler is useful on one machine, but Alex's agent sessions are spread across the Mac, LXSO hosts, NurseDroid, and tool-specific homes. A network collector lets the profiler answer "what burned quota overnight?" from one LXSO-hosted dashboard instead of requiring ad hoc rsyncs and local-only scans.
+
+## What Changes
+
+- Add a portable session collector that scans configurable tool globs for Codex, Claude, Pi, T3Chat, Kimi, droid, Cursor, OpenCode, and Paperclip session files.
+- Stage changed files with host/tool metadata and push them to a central LXSO collection directory.
+- Add central LXSO report refresh and dashboard service templates.
+- Add JSONL/syslog/Loki logging for collectors, report refreshes, and dashboard access events so activity appears in Grafana.
+- Add deployment docs and service assets for Linux systemd user timers, macOS launchd, and the home `.lan` access path.
+
+## Capabilities
+
+### New Capabilities
+
+- `network-session-collection`: Collect, centralize, profile, serve, and log multi-host AI coding session files.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+- Adds a standard-library Python collector CLI and tests.
+- Adds optional dashboard syslog and Loki forwarding.
+- Adds ops files under `ops/` for systemd, launchd, report refresh, and LAN proxy deployment.
+- Reads local user-level session files and copies them to the configured central host; it does not mutate source session stores.
diff --git a/openspec/changes/archive/2026-07-04-add-network-session-collection/specs/network-session-collection/spec.md b/openspec/changes/archive/2026-07-04-add-network-session-collection/specs/network-session-collection/spec.md
new file mode 100644
index 0000000..fc39fa5
--- /dev/null
+++ b/openspec/changes/archive/2026-07-04-add-network-session-collection/specs/network-session-collection/spec.md
@@ -0,0 +1,76 @@
+## ADDED Requirements
+
+### Requirement: Configurable Multi-Tool Collection
+The system SHALL provide a collector that scans configured session-file globs for known AI coding/chat tools and skips missing paths without failing the run.
+
+#### Scenario: Missing tool paths
+- **WHEN** a host has no Kimi or T3Chat session directory
+- **THEN** the collector completes successfully and logs zero matches for those tools
+
+#### Scenario: Added custom path
+- **WHEN** a collector config adds a new glob for a tool
+- **THEN** matching files are included in the next collection run
+
+### Requirement: Incremental Central Push
+The system SHALL copy changed session files into a central collection directory with source host, tool, and original path metadata.
+
+#### Scenario: Changed file copied
+- **WHEN** a matched session file has a new size or modification time
+- **THEN** the collector stages the file, writes a metadata sidecar, and pushes it to the configured destination
+
+#### Scenario: Unchanged file skipped
+- **WHEN** a matched session file is unchanged from collector state
+- **THEN** the collector does not restage that file
+
+#### Scenario: Paperclip source path attribution
+- **WHEN** a collected session comes from a Paperclip company, agent, project, or workspace path
+- **THEN** the metadata sidecar preserves the original `source_path`
+- **AND** includes structured Paperclip identifiers when they can be parsed from the path
+
+### Requirement: Paperclip Metadata Snapshot
+The system SHALL optionally collect lightweight Paperclip topology metadata so the central report can map IDs to company, staff, project, and workspace names without direct access to the source machine's Paperclip API.
+
+#### Scenario: Collector snapshots Paperclip topology
+- **WHEN** the collector can reach the local Paperclip API or workspace directory
+- **THEN** it stages a `paperclip-metadata.json` snapshot containing company IDs/names, issue prefixes, agent IDs/titles, project IDs/names, and workspace hints
+
+#### Scenario: Central report consumes snapshot
+- **WHEN** the central profiler scans collected sessions and metadata snapshots
+- **THEN** Paperclip sessions are attributed by explicit metadata, original source path, agent/project indexes, and workspace hints in that precedence order
+
+### Requirement: LXSO Dashboard Deployment
+The system SHALL include service assets that run report refresh and dashboard serving on an LXSO host using the central collection directory.
+
+#### Scenario: Report refresh
+- **WHEN** the report refresh service runs
+- **THEN** it scans central collected sessions and writes the latest JSON report atomically
+
+#### Scenario: Dashboard health
+- **WHEN** the dashboard service is running
+- **THEN** `/healthz` returns `ok`
+
+### Requirement: LAN Access
+The system SHALL provide a documented `.lan` access path for the dashboard on the home network.
+
+#### Scenario: Caddy available
+- **WHEN** Orange Pi Caddy can be updated
+- **THEN** `codex-usage.lan` proxies to the LXSO dashboard service
+
+#### Scenario: Caddy not writable
+- **WHEN** Caddy cannot be updated non-interactively
+- **THEN** a user-level high-port `.lan` proxy can expose the dashboard without root access
+
+### Requirement: Observable Logs
+The system SHALL emit structured logs for collector runs, report refreshes, and dashboard HTTP access to local files and optionally to syslog and Loki.
+
+#### Scenario: Collector log forwarding
+- **WHEN** a collector run finishes
+- **THEN** a structured event includes host, matched files, staged files, pushed files, skipped files, and errors
+
+#### Scenario: Dashboard access log forwarding
+- **WHEN** the dashboard handles an HTTP request
+- **THEN** a structured access event can be forwarded to the configured syslog and Loki endpoints
+
+#### Scenario: Grafana log visibility
+- **WHEN** LXSO2 Grafana has a Loki datasource
+- **THEN** collector, report-refresh, and dashboard events can be queried by `job="codex-usage-profiler"` and `service`
diff --git a/openspec/changes/archive/2026-07-04-add-network-session-collection/tasks.md b/openspec/changes/archive/2026-07-04-add-network-session-collection/tasks.md
new file mode 100644
index 0000000..86f88db
--- /dev/null
+++ b/openspec/changes/archive/2026-07-04-add-network-session-collection/tasks.md
@@ -0,0 +1,38 @@
+## 1. OpenSpec
+
+- [x] 1.1 Write proposal, design, and network collection spec
+- [x] 1.2 Validate the OpenSpec change strictly
+
+## 2. Collector
+
+- [x] 2.1 Add a dependency-free collector CLI with default tool globs
+- [x] 2.2 Stage files with metadata sidecars and incremental state
+- [x] 2.3 Push to local or SSH destinations
+- [x] 2.4 Emit local JSONL and optional syslog events
+
+## 3. Central Services
+
+- [x] 3.1 Add report refresh and dashboard service assets
+- [x] 3.2 Add collector systemd and launchd service assets
+- [x] 3.3 Add high-port LAN proxy asset for rootless `.lan` access
+
+## 4. Tests
+
+- [x] 4.1 Add collector unit tests
+- [x] 4.2 Add dashboard syslog/access test coverage
+- [x] 4.3 Run unit, E2E, and OpenSpec validation
+
+## 5. Deployment
+
+- [x] 5.1 Deploy central profiler to LXSO1
+- [x] 5.2 Install collectors on reachable machines
+- [x] 5.3 Expose a `.lan` URL
+- [x] 5.4 Verify report refresh, dashboard health, and Grafana-visible logging
+
+## 6. Paperclip Attribution Hardening
+
+- [x] 6.1 Preserve collected `source_path` as first-class session metadata
+- [x] 6.2 Add structured Paperclip identifiers to collector sidecars
+- [x] 6.3 Add optional Paperclip topology snapshots for central reports
+- [x] 6.4 Attribute Paperclip company, project, staff, and task from explicit metadata, source paths, snapshots, and workspace hints
+- [x] 6.5 Add regression tests for collected sidecars, prompt preambles, and metadata snapshots
diff --git a/openspec/specs/network-session-collection/spec.md b/openspec/specs/network-session-collection/spec.md
new file mode 100644
index 0000000..3657c81
--- /dev/null
+++ b/openspec/specs/network-session-collection/spec.md
@@ -0,0 +1,80 @@
+# network-session-collection Specification
+
+## Purpose
+TBD - created by archiving change add-network-session-collection. Update Purpose after archive.
+## Requirements
+### Requirement: Configurable Multi-Tool Collection
+The system SHALL provide a collector that scans configured session-file globs for known AI coding/chat tools and skips missing paths without failing the run.
+
+#### Scenario: Missing tool paths
+- **WHEN** a host has no Kimi or T3Chat session directory
+- **THEN** the collector completes successfully and logs zero matches for those tools
+
+#### Scenario: Added custom path
+- **WHEN** a collector config adds a new glob for a tool
+- **THEN** matching files are included in the next collection run
+
+### Requirement: Incremental Central Push
+The system SHALL copy changed session files into a central collection directory with source host, tool, and original path metadata.
+
+#### Scenario: Changed file copied
+- **WHEN** a matched session file has a new size or modification time
+- **THEN** the collector stages the file, writes a metadata sidecar, and pushes it to the configured destination
+
+#### Scenario: Unchanged file skipped
+- **WHEN** a matched session file is unchanged from collector state
+- **THEN** the collector does not restage that file
+
+#### Scenario: Paperclip source path attribution
+- **WHEN** a collected session comes from a Paperclip company, agent, project, or workspace path
+- **THEN** the metadata sidecar preserves the original `source_path`
+- **AND** includes structured Paperclip identifiers when they can be parsed from the path
+
+### Requirement: Paperclip Metadata Snapshot
+The system SHALL optionally collect lightweight Paperclip topology metadata so the central report can map IDs to company, staff, project, and workspace names without direct access to the source machine's Paperclip API.
+
+#### Scenario: Collector snapshots Paperclip topology
+- **WHEN** the collector can reach the local Paperclip API or workspace directory
+- **THEN** it stages a `paperclip-metadata.json` snapshot containing company IDs/names, issue prefixes, agent IDs/titles, project IDs/names, and workspace hints
+
+#### Scenario: Central report consumes snapshot
+- **WHEN** the central profiler scans collected sessions and metadata snapshots
+- **THEN** Paperclip sessions are attributed by explicit metadata, original source path, agent/project indexes, and workspace hints in that precedence order
+
+### Requirement: LXSO Dashboard Deployment
+The system SHALL include service assets that run report refresh and dashboard serving on an LXSO host using the central collection directory.
+
+#### Scenario: Report refresh
+- **WHEN** the report refresh service runs
+- **THEN** it scans central collected sessions and writes the latest JSON report atomically
+
+#### Scenario: Dashboard health
+- **WHEN** the dashboard service is running
+- **THEN** `/healthz` returns `ok`
+
+### Requirement: LAN Access
+The system SHALL provide a documented `.lan` access path for the dashboard on the home network.
+
+#### Scenario: Caddy available
+- **WHEN** Orange Pi Caddy can be updated
+- **THEN** `codex-usage.lan` proxies to the LXSO dashboard service
+
+#### Scenario: Caddy not writable
+- **WHEN** Caddy cannot be updated non-interactively
+- **THEN** a user-level high-port `.lan` proxy can expose the dashboard without root access
+
+### Requirement: Observable Logs
+The system SHALL emit structured logs for collector runs, report refreshes, and dashboard HTTP access to local files and optionally to syslog and Loki.
+
+#### Scenario: Collector log forwarding
+- **WHEN** a collector run finishes
+- **THEN** a structured event includes host, matched files, staged files, pushed files, skipped files, and errors
+
+#### Scenario: Dashboard access log forwarding
+- **WHEN** the dashboard handles an HTTP request
+- **THEN** a structured access event can be forwarded to the configured syslog and Loki endpoints
+
+#### Scenario: Grafana log visibility
+- **WHEN** LXSO2 Grafana has a Loki datasource
+- **THEN** collector, report-refresh, and dashboard events can be queried by `job="codex-usage-profiler"` and `service`
+
diff --git a/ops/bin/codex-usage-run-report.sh b/ops/bin/codex-usage-run-report.sh
new file mode 100755
index 0000000..b81e71a
--- /dev/null
+++ b/ops/bin/codex-usage-run-report.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+APP_HOME="${CUP_APP_HOME:-$HOME/services/codex-usage-profiler}"
+DATA_HOME="${CUP_DATA_HOME:-$HOME/.local/share/codex-usage-profiler}"
+STATE_HOME="${CUP_STATE_HOME:-$HOME/.local/state/codex-usage-profiler}"
+LOG_HOME="${CUP_LOG_HOME:-$HOME/.local/log/codex-usage-profiler}"
+PYTHON="${CUP_PYTHON:-$APP_HOME/.venv/bin/python}"
+REPORT="$STATE_HOME/latest-report.json"
+TMP_REPORT="$STATE_HOME/latest-report.tmp.json"
+LOG_FILE="$LOG_HOME/report-refresh.jsonl"
+SYSLOG_HOST="${CUP_SYSLOG_HOST:-192.168.1.30}"
+SYSLOG_PORT="${CUP_SYSLOG_PORT:-514}"
+LOKI_URL="${CUP_LOKI_URL:-http://192.168.1.221:3100/loki/api/v1/push}"
+PORT="${CUP_PORT:-8775}"
+
+mkdir -p "$STATE_HOME" "$LOG_HOME" "$DATA_HOME/collected-sessions"
+exec 9>"$STATE_HOME/report-refresh.lock"
+if ! flock -n 9; then
+ echo '{"event":"report_refresh_skipped","reason":"already_running"}'
+ exit 0
+fi
+export CODEXBAR_COLLECTED_ROOT="$DATA_HOME/collected-sessions"
+
+paths=("$DATA_HOME/collected-sessions")
+
+started="$(date +%s)"
+"$PYTHON" -m codex_usage_profiler \
+ --codexbar-timeout 10 \
+ --paths "${paths[@]}" \
+ --format json \
+ --output "$TMP_REPORT" \
+ --monthly-plan-price-usd "${CUP_MONTHLY_PLAN_PRICE_USD:-200}" \
+ --plan-price Plus=20 \
+ --plan-price Pro=200
+mv "$TMP_REPORT" "$REPORT"
+
+"$PYTHON" - "$REPORT" "$LOG_FILE" "$SYSLOG_HOST" "$SYSLOG_PORT" "$LOKI_URL" "$started" "$PORT" <<'PY'
+import json
+import sys
+import time
+from pathlib import Path
+
+from codex_usage_profiler.syslog_util import emit_loki_json, emit_syslog_json
+
+report_path = Path(sys.argv[1])
+log_path = Path(sys.argv[2])
+syslog_host = sys.argv[3]
+syslog_port = int(sys.argv[4])
+loki_url = sys.argv[5]
+started = int(sys.argv[6])
+port = sys.argv[7]
+payload = json.loads(report_path.read_text(encoding="utf-8"))
+tokens = sum((s.get("usage") or {}).get("total_tokens", 0) for s in payload.get("sessions", []))
+cost = sum((s.get("estimate") or {}).get("cost_usd") or 0.0 for s in payload.get("sessions", []))
+event = {
+ "event": "report_refresh",
+ "sessions": len(payload.get("sessions", [])),
+ "tokens": tokens,
+ "cost_usd": round(cost, 6),
+ "report_path": str(report_path),
+ "dashboard_port": port,
+ "duration_seconds": round(time.time() - started, 3),
+ "warnings": payload.get("warnings", [])[:10],
+}
+log_path.parent.mkdir(parents=True, exist_ok=True)
+with log_path.open("a", encoding="utf-8") as fh:
+ fh.write(json.dumps(event, sort_keys=True) + "\n")
+emit_syslog_json(event, host=syslog_host, port=syslog_port, tag="codex-usage-profiler")
+emit_loki_json(event, url=loki_url, labels={"service": "report", "host": "lxso1"})
+print(json.dumps(event, sort_keys=True))
+PY
diff --git a/ops/bin/tcp_proxy.py b/ops/bin/tcp_proxy.py
new file mode 100755
index 0000000..01d335f
--- /dev/null
+++ b/ops/bin/tcp_proxy.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import selectors
+import socket
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Tiny TCP forwarder for rootless .lan access.")
+ parser.add_argument("--listen-host", default="0.0.0.0")
+ parser.add_argument("--listen-port", type=int, required=True)
+ parser.add_argument("--target-host", required=True)
+ parser.add_argument("--target-port", type=int, required=True)
+ args = parser.parse_args()
+
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind((args.listen_host, args.listen_port))
+ server.listen(50)
+ while True:
+ client, _ = server.accept()
+ target = socket.create_connection((args.target_host, args.target_port), timeout=10)
+ _bridge(client, target)
+
+
+def _bridge(left: socket.socket, right: socket.socket) -> None:
+ sel = selectors.DefaultSelector()
+ left.setblocking(False)
+ right.setblocking(False)
+ sel.register(left, selectors.EVENT_READ, right)
+ sel.register(right, selectors.EVENT_READ, left)
+ try:
+ while True:
+ for key, _ in sel.select():
+ src = key.fileobj
+ dst = key.data
+ data = src.recv(65536)
+ if not data:
+ return
+ dst.sendall(data)
+ finally:
+ sel.close()
+ left.close()
+ right.close()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/ops/config/collector.example.json b/ops/config/collector.example.json
new file mode 100644
index 0000000..14fc0d1
--- /dev/null
+++ b/ops/config/collector.example.json
@@ -0,0 +1,21 @@
+{
+ "collector_name": "HOSTNAME",
+ "destination": "saphid@lxso1:/home/saphid/.local/share/codex-usage-profiler/collected-sessions",
+ "include_defaults": true,
+ "syslog_host": "192.168.1.30",
+ "syslog_port": 514,
+ "syslog_protocol": "tcp",
+ "loki_url": "http://192.168.1.221:3100/loki/api/v1/push",
+ "tools": {
+ "t3chat": [
+ "~/.t3chat/**/*.jsonl",
+ "~/.config/t3chat/**/*.jsonl"
+ ],
+ "kimi": [
+ "~/.kimi/**/*.jsonl"
+ ],
+ "droid": [
+ "~/.droid/**/*.jsonl"
+ ]
+ }
+}
diff --git a/ops/grafana/loki-datasource.yml b/ops/grafana/loki-datasource.yml
new file mode 100644
index 0000000..889b930
--- /dev/null
+++ b/ops/grafana/loki-datasource.yml
@@ -0,0 +1,7 @@
+apiVersion: 1
+datasources:
+ - name: LXSO2-Loki
+ type: loki
+ access: proxy
+ url: http://loki:3100
+ editable: true
diff --git a/ops/launchd/com.alxs.codex-usage-collector.plist b/ops/launchd/com.alxs.codex-usage-collector.plist
new file mode 100644
index 0000000..26c8d14
--- /dev/null
+++ b/ops/launchd/com.alxs.codex-usage-collector.plist
@@ -0,0 +1,23 @@
+
+
+
+
+ Label
+ com.alxs.codex-usage-collector
+ ProgramArguments
+
+ /Users/saphid/Library/Application Support/PA-Agent/codex-usage-profiler-venv/bin/codex-usage-collector
+ --config
+ /Users/saphid/.config/codex-usage-profiler/collector.json
+ --once
+
+ StartInterval
+ 300
+ RunAtLoad
+
+ StandardOutPath
+ /Users/saphid/Library/Logs/codex-usage-collector.launchd.log
+ StandardErrorPath
+ /Users/saphid/Library/Logs/codex-usage-collector.launchd.log
+
+
diff --git a/ops/systemd/codex-usage-collector.service b/ops/systemd/codex-usage-collector.service
new file mode 100644
index 0000000..886db08
--- /dev/null
+++ b/ops/systemd/codex-usage-collector.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=Collect local AI session files for Codex Usage Profiler
+After=network-online.target
+
+[Service]
+Type=oneshot
+WorkingDirectory=%h/services/codex-usage-profiler
+ExecStart=%h/services/codex-usage-profiler/.venv/bin/codex-usage-collector --config %h/.config/codex-usage-profiler/collector.json --once
diff --git a/ops/systemd/codex-usage-collector.timer b/ops/systemd/codex-usage-collector.timer
new file mode 100644
index 0000000..22e89a8
--- /dev/null
+++ b/ops/systemd/codex-usage-collector.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Collect local AI session files for Codex Usage Profiler every five minutes
+
+[Timer]
+OnBootSec=90s
+OnUnitActiveSec=5min
+RandomizedDelaySec=60s
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/ops/systemd/codex-usage-dashboard.service b/ops/systemd/codex-usage-dashboard.service
new file mode 100644
index 0000000..5c921bc
--- /dev/null
+++ b/ops/systemd/codex-usage-dashboard.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=Codex Usage Profiler dashboard
+After=network-online.target
+
+[Service]
+Type=simple
+Environment=CUP_APP_HOME=%h/services/codex-usage-profiler
+Environment=CUP_STATE_HOME=%h/.local/state/codex-usage-profiler
+Environment=CUP_SYSLOG_HOST=192.168.1.30
+Environment=CUP_LOKI_URL=http://192.168.1.221:3100/loki/api/v1/push
+Environment=CUP_PORT=8775
+WorkingDirectory=%h/services/codex-usage-profiler
+ExecStart=%h/services/codex-usage-profiler/.venv/bin/codex-usage-dashboard --report %h/.local/state/codex-usage-profiler/latest-report.json --host 0.0.0.0 --port ${CUP_PORT} --syslog-host ${CUP_SYSLOG_HOST} --loki-url ${CUP_LOKI_URL}
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
diff --git a/ops/systemd/codex-usage-lan-proxy.service b/ops/systemd/codex-usage-lan-proxy.service
new file mode 100644
index 0000000..c99d1ac
--- /dev/null
+++ b/ops/systemd/codex-usage-lan-proxy.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Rootless codex-usage.lan high-port proxy to LXSO1
+After=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=%h/services/codex-usage-profiler
+ExecStart=/usr/bin/env python3 %h/services/codex-usage-profiler/ops/bin/tcp_proxy.py --listen-host 0.0.0.0 --listen-port 8775 --target-host 192.168.1.109 --target-port 8775
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
diff --git a/ops/systemd/codex-usage-report.service b/ops/systemd/codex-usage-report.service
new file mode 100644
index 0000000..ceaa94c
--- /dev/null
+++ b/ops/systemd/codex-usage-report.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Refresh Codex Usage Profiler report
+After=network-online.target
+
+[Service]
+Type=oneshot
+Environment=CUP_APP_HOME=%h/services/codex-usage-profiler
+Environment=CUP_SYSLOG_HOST=192.168.1.30
+WorkingDirectory=%h/services/codex-usage-profiler
+ExecStart=%h/services/codex-usage-profiler/ops/bin/codex-usage-run-report.sh
diff --git a/ops/systemd/codex-usage-report.timer b/ops/systemd/codex-usage-report.timer
new file mode 100644
index 0000000..e89f6b2
--- /dev/null
+++ b/ops/systemd/codex-usage-report.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Refresh Codex Usage Profiler report every fifteen minutes
+
+[Timer]
+OnBootSec=2min
+OnUnitActiveSec=15min
+RandomizedDelaySec=45s
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/pyproject.toml b/pyproject.toml
index de149ee..e6b7d7c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,8 @@ codex-usage-profiler = "codex_usage_profiler.cli:main"
cup = "codex_usage_profiler.cli:main"
codex-usage-dashboard = "codex_usage_profiler.dashboard:main"
cup-dashboard = "codex_usage_profiler.dashboard:main"
+codex-usage-collector = "codex_usage_profiler.collector:main"
+cup-collector = "codex_usage_profiler.collector:main"
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/src/codex_usage_profiler/analysis.py b/src/codex_usage_profiler/analysis.py
index 4cb8df0..2e99ca9 100644
--- a/src/codex_usage_profiler/analysis.py
+++ b/src/codex_usage_profiler/analysis.py
@@ -1,10 +1,11 @@
from __future__ import annotations
from collections import Counter, defaultdict
-from typing import Dict, Iterable, List, Optional, Tuple
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+from .config import Config
from .models import Attribution, Finding, SessionRecord
-from .util import day_key, hour_key
+from .util import day_key, hour_key, iso_to_datetime
def classify_outcomes(records: List[SessionRecord]) -> None:
@@ -104,6 +105,53 @@ def build_aggregates(records: List[SessionRecord]) -> Dict[str, List[Dict[str, o
}
+def build_paperclip_spend(records: List[SessionRecord], config: Config) -> Dict[str, Any]:
+ paperclip_records = [record for record in records if record.paperclip_company.label != "unknown"]
+ daily = _period_rows(paperclip_records, "day")
+ monthly = _period_rows(paperclip_records, "month")
+ totals = _company_totals(paperclip_records, config.projection_days)
+ return {
+ "projection_days": config.projection_days,
+ "attributed_sessions": len(paperclip_records),
+ "attributed_tokens": sum(record.usage.get("total_tokens", 0) for record in paperclip_records),
+ "attributed_cost_usd": sum(record.estimate.cost_usd or 0.0 for record in paperclip_records),
+ "company_totals": totals,
+ "daily": daily,
+ "monthly": monthly,
+ }
+
+
+def build_plan_analysis(records: List[SessionRecord], config: Config) -> Dict[str, Any]:
+ span_days = _observed_span_days(records)
+ observed_cost = sum(record.estimate.cost_usd or 0.0 for record in records)
+ projected = observed_cost / span_days * config.projection_days if span_days else 0.0
+ plans = dict(config.plan_prices_usd)
+ if config.monthly_plan_price_usd is not None:
+ plans.setdefault("configured_monthly_plan", config.monthly_plan_price_usd)
+ rows = []
+ for name, price in sorted(plans.items(), key=lambda item: item[1]):
+ ratio = projected / price if price else None
+ rows.append(
+ {
+ "plan": name,
+ "monthly_price_usd": price,
+ "projected_rate_card_cost_usd": projected,
+ "projected_vs_price_percent": ratio * 100 if ratio is not None else None,
+ "delta_usd": projected - price,
+ "note": "replacement_cost_above_plan_price" if projected > price else "replacement_cost_below_plan_price",
+ }
+ )
+ return {
+ "projection_days": config.projection_days,
+ "observed_span_days": span_days,
+ "observed_cost_usd": observed_cost,
+ "projected_cost_usd": projected,
+ "monthly_plan_price_usd": config.monthly_plan_price_usd,
+ "plans": rows,
+ "confidence_note": "Projected from local observed rate-card replacement cost. This compares value, not official subscription quota or invoice spend.",
+ }
+
+
def reconcile_codexbar(records: List[SessionRecord], cost_usage: Optional[Dict[str, object]]) -> List[Dict[str, object]]:
if not cost_usage:
return []
@@ -153,6 +201,128 @@ def reconcile_codexbar(records: List[SessionRecord], cost_usage: Optional[Dict[s
return sorted(rows, key=lambda row: abs((row.get("codexbar_tokens") or 0) - (row.get("local_tokens") or 0)), reverse=True)
+def _period_rows(records: List[SessionRecord], period: str) -> List[Dict[str, Any]]:
+ buckets: Dict[Tuple[str, str], Dict[str, Any]] = {}
+ for record in records:
+ key = _period_key(record.start_time, period)
+ company = record.paperclip_company.label
+ bucket = buckets.setdefault(
+ (key, company),
+ {
+ "period": key,
+ "company": company,
+ "sessions": 0,
+ "input_tokens": 0,
+ "cached_input_tokens": 0,
+ "output_tokens": 0,
+ "total_tokens": 0,
+ "estimated_cost_usd": 0.0,
+ "estimated_credits": 0.0,
+ "top_staff": Counter(),
+ "top_projects": Counter(),
+ "models": Counter(),
+ "outcomes": Counter(),
+ },
+ )
+ _add_record_to_bucket(bucket, record)
+ return [_finalize_counter_bucket(bucket) for bucket in sorted(buckets.values(), key=lambda row: (str(row["period"]), -float(row["estimated_cost_usd"]), str(row["company"])))]
+
+
+def _company_totals(records: List[SessionRecord], projection_days: int) -> List[Dict[str, Any]]:
+ buckets: Dict[str, Dict[str, Any]] = {}
+ dates_by_company: Dict[str, set[str]] = defaultdict(set)
+ for record in records:
+ company = record.paperclip_company.label
+ bucket = buckets.setdefault(
+ company,
+ {
+ "company": company,
+ "sessions": 0,
+ "input_tokens": 0,
+ "cached_input_tokens": 0,
+ "output_tokens": 0,
+ "total_tokens": 0,
+ "estimated_cost_usd": 0.0,
+ "estimated_credits": 0.0,
+ "top_staff": Counter(),
+ "top_projects": Counter(),
+ "models": Counter(),
+ "outcomes": Counter(),
+ "first_day": "unknown",
+ "last_day": "unknown",
+ "active_days": 0,
+ "observed_span_days": 1,
+ "avg_daily_cost_usd": 0.0,
+ "active_day_avg_cost_usd": 0.0,
+ "projected_cost_usd": 0.0,
+ },
+ )
+ _add_record_to_bucket(bucket, record)
+ day = day_key(record.start_time)
+ if day != "unknown":
+ dates_by_company[company].add(day)
+ rows: List[Dict[str, Any]] = []
+ for company, bucket in buckets.items():
+ dates = sorted(dates_by_company.get(company, set()))
+ active_days = len(dates)
+ span_days = _date_span_days(dates) if dates else 1
+ cost = float(bucket["estimated_cost_usd"])
+ bucket["first_day"] = dates[0] if dates else "unknown"
+ bucket["last_day"] = dates[-1] if dates else "unknown"
+ bucket["active_days"] = active_days
+ bucket["observed_span_days"] = span_days
+ bucket["avg_daily_cost_usd"] = cost / span_days if span_days else 0.0
+ bucket["active_day_avg_cost_usd"] = cost / active_days if active_days else 0.0
+ bucket["projected_cost_usd"] = (cost / span_days * projection_days) if span_days else 0.0
+ rows.append(_finalize_counter_bucket(bucket))
+ return sorted(rows, key=lambda row: float(row["estimated_cost_usd"]), reverse=True)
+
+
+def _add_record_to_bucket(bucket: Dict[str, Any], record: SessionRecord) -> None:
+ bucket["sessions"] = int(bucket["sessions"]) + 1
+ for token_key in ["input_tokens", "cached_input_tokens", "output_tokens", "total_tokens"]:
+ bucket[token_key] = int(bucket[token_key]) + record.usage.get(token_key, 0)
+ bucket["estimated_cost_usd"] = float(bucket["estimated_cost_usd"]) + (record.estimate.cost_usd or 0.0)
+ bucket["estimated_credits"] = float(bucket["estimated_credits"]) + (record.estimate.credits or 0.0)
+ bucket["top_staff"][record.paperclip_staff.label] += 1
+ bucket["top_projects"][record.paperclip_project.label] += 1
+ bucket["models"][record.model or "unknown"] += 1
+ bucket["outcomes"][record.outcome.label] += 1
+
+
+def _finalize_counter_bucket(bucket: Dict[str, Any]) -> Dict[str, Any]:
+ result = dict(bucket)
+ for key in ["top_staff", "top_projects", "models", "outcomes"]:
+ value = result.get(key)
+ if isinstance(value, Counter):
+ result[key] = dict(value.most_common(5))
+ return result
+
+
+def _period_key(value: Optional[str], period: str) -> str:
+ parsed = iso_to_datetime(value)
+ if not parsed:
+ return "unknown"
+ if period == "month":
+ return parsed.strftime("%Y-%m")
+ return parsed.date().isoformat()
+
+
+def _observed_span_days(records: List[SessionRecord]) -> int:
+ dates = sorted({day_key(record.start_time) for record in records if day_key(record.start_time) != "unknown"})
+ return _date_span_days(dates) if dates else 1
+
+
+def _date_span_days(dates: List[str]) -> int:
+ if not dates:
+ return 1
+ first = iso_to_datetime(dates[0] + "T00:00:00")
+ last = iso_to_datetime(dates[-1] + "T00:00:00")
+ if not first or not last:
+ return 1
+ return max(1, (last.date() - first.date()).days + 1)
+
+
def _key(record: SessionRecord, key: str) -> str:
if key == "client":
return record.client.label
diff --git a/src/codex_usage_profiler/attribution.py b/src/codex_usage_profiler/attribution.py
index 9845713..0447120 100644
--- a/src/codex_usage_profiler/attribution.py
+++ b/src/codex_usage_profiler/attribution.py
@@ -18,7 +18,7 @@ def attribute_sessions(records: list[SessionRecord], config: Config) -> None:
def attribute_client(record: SessionRecord, config: Config) -> Attribution:
haystack = " ".join(
value or ""
- for value in [record.originator, record.source, record.thread_source, record.cwd, record.path]
+ for value in [record.originator, record.source, record.thread_source, record.cwd, record.path, record.source_path]
).lower()
for pattern, label in config.client_aliases.items():
if pattern.lower() in haystack:
@@ -31,13 +31,23 @@ def attribute_client(record: SessionRecord, config: Config) -> Attribution:
return Attribution("Codex exec", "medium", ["originator/source=codex_exec"])
if "cursor" in haystack:
return Attribution("Cursor", "medium", ["cursor signal"])
+ if "/claude/" in haystack or "/.claude/" in haystack or "claude" in haystack:
+ return Attribution("Claude Code", "medium", ["claude path/signal"])
if "/.pi/" in haystack or "pi coding" in haystack:
return Attribution("Pi Coding Agent", "medium", ["pi path/signal"])
+ if "/t3chat/" in haystack or "t3chat" in haystack or "t3 chat" in haystack:
+ return Attribution("T3Chat", "medium", ["t3chat path/signal"])
+ if "/kimi/" in haystack or "kimi" in haystack:
+ return Attribution("Kimi", "medium", ["kimi path/signal"])
+ if "/droid/" in haystack or "droid" in haystack:
+ return Attribution("droid", "medium", ["droid path/signal"])
+ if "/opencode/" in haystack or "opencode" in haystack or "open-code" in haystack:
+ return Attribution("OpenCode", "medium", ["opencode path/signal"])
return Attribution("unknown", "low", ["no known client signal"])
def attribute_project(record: SessionRecord, config: Config) -> Attribution:
- candidates = [record.cwd] + record.workspace_roots + [record.path]
+ candidates = [record.cwd] + record.workspace_roots + [record.source_path, record.path]
for value in candidates:
if not value:
continue
@@ -61,6 +71,9 @@ def _project_from_path(value: str) -> Optional[str]:
match = re.search(r"/projects/([^/]+)", expanded)
if match:
return match.group(1)
+ match = re.search(r"/collected-sessions/([^/]+)/([^/]+)/", expanded)
+ if match:
+ return f"{match.group(1)}:{match.group(2)}"
path = Path(expanded)
if path.name and path.name not in {".codex", "sessions", "archived_sessions"}:
return path.name
@@ -83,4 +96,3 @@ def attribute_task(record: SessionRecord, config: Config) -> Attribution:
if record.path:
return Attribution(f"session:{Path(record.path).stem}", "low", ["session filename"])
return Attribution("unknown", "low", ["no task signal"])
-
diff --git a/src/codex_usage_profiler/cli.py b/src/codex_usage_profiler/cli.py
index 5374925..619451b 100644
--- a/src/codex_usage_profiler/cli.py
+++ b/src/codex_usage_profiler/cli.py
@@ -6,7 +6,14 @@
from pathlib import Path
from typing import Optional
-from .analysis import build_aggregates, classify_outcomes, find_waste_candidates, reconcile_codexbar
+from .analysis import (
+ build_aggregates,
+ build_paperclip_spend,
+ build_plan_analysis,
+ classify_outcomes,
+ find_waste_candidates,
+ reconcile_codexbar,
+)
from .attribution import attribute_sessions
from .codexbar import collect_codexbar
from .config import load_config
@@ -31,6 +38,9 @@ def main(argv: Optional[list[str]] = None) -> int:
parser.add_argument("--no-codexbar", action="store_true", help="Disable CodexBar CLI/cache import")
parser.add_argument("--codexbar-timeout", type=int, default=30)
parser.add_argument("--include-snippets", action="store_true", help="Include bounded redacted first-request snippets")
+ parser.add_argument("--monthly-plan-price-usd", type=float, help="Compare projected replacement cost to this monthly plan price")
+ parser.add_argument("--plan-price", action="append", default=[], metavar="NAME=USD", help="Add a named monthly plan price comparison")
+ parser.add_argument("--projection-days", type=int, help="Projection horizon for plan/company spend, default 30")
parser.add_argument("--report", default="all", choices=["all", "sessions", "summary"], help="Reserved report selector")
args = parser.parse_args(argv)
@@ -40,8 +50,20 @@ def main(argv: Optional[list[str]] = None) -> int:
since = since_date.isoformat()
config = load_config(args.config)
+ if args.monthly_plan_price_usd is not None:
+ config.monthly_plan_price_usd = args.monthly_plan_price_usd
+ if args.projection_days is not None:
+ config.projection_days = max(1, args.projection_days)
+ for item in args.plan_price:
+ name, sep, price = item.partition("=")
+ if not sep:
+ parser.error("--plan-price must be NAME=USD")
+ try:
+ config.plan_prices_usd[name] = float(price)
+ except ValueError:
+ parser.error("--plan-price price must be numeric")
telemetry = collect_codexbar(enabled=(not args.no_codexbar and config.codexbar_enabled), timeout=args.codexbar_timeout)
- paperclip_index = build_paperclip_index(config)
+ paperclip_index = build_paperclip_index(config, args.paths)
records, warnings = parse_logs(args.paths, since=since)
attribute_sessions(records, config)
apply_paperclip_attribution(records, paperclip_index)
@@ -49,13 +71,15 @@ def main(argv: Optional[list[str]] = None) -> int:
apply_estimates(records, config, telemetry)
classify_outcomes(records)
aggregates = build_aggregates(records)
+ paperclip_spend = build_paperclip_spend(records, config)
+ plan_analysis = build_plan_analysis(records, config)
findings = find_waste_candidates(records)
reconciliation = reconcile_codexbar(records, telemetry.cost_usage)
if args.format == "json":
- output = render_json(records, aggregates, findings, telemetry, reconciliation, warnings, args.include_snippets)
+ output = render_json(records, aggregates, findings, telemetry, reconciliation, warnings, paperclip_spend, plan_analysis, args.include_snippets)
else:
- output = render_text(records, aggregates, findings, telemetry, reconciliation, warnings, args.top, args.include_snippets)
+ output = render_text(records, aggregates, findings, telemetry, reconciliation, warnings, paperclip_spend, plan_analysis, args.top, args.include_snippets)
if args.output:
path = Path(args.output).expanduser()
diff --git a/src/codex_usage_profiler/codexbar.py b/src/codex_usage_profiler/codexbar.py
index e409ec0..9d066ec 100644
--- a/src/codex_usage_profiler/codexbar.py
+++ b/src/codex_usage_profiler/codexbar.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import os
import plistlib
import shutil
import subprocess
@@ -31,6 +32,7 @@ def collect_codexbar(enabled: bool = True, timeout: int = 30) -> CodexBarTelemet
telemetry.history_path = _existing_path(CODEXBAR_HISTORY)
telemetry.cost_cache_paths = _glob_existing(CODEXBAR_COST_DIR, "*.json")
telemetry.pricing_cache_paths = _glob_existing(CODEXBAR_PRICING_DIR, "*.json")
+ _merge_collected_codexbar(telemetry, os.environ.get("CODEXBAR_COLLECTED_ROOT"))
telemetry.available = bool(telemetry.cli_path or telemetry.cost_cache_paths or telemetry.history_path)
if telemetry.cli_path:
@@ -64,6 +66,37 @@ def _glob_existing(root: str, pattern: str) -> List[str]:
return [str(path) for path in sorted(expanded.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)]
+def _merge_collected_codexbar(telemetry: CodexBarTelemetry, root: Optional[str]) -> None:
+ if not root:
+ return
+ expanded = Path(root).expanduser()
+ if not expanded.exists():
+ return
+ paths = [path for path in expanded.rglob("*.json") if "/codexbar/" in str(path)]
+ histories = sorted(
+ [path for path in paths if path.name == "codex.json"],
+ key=lambda p: p.stat().st_mtime,
+ reverse=True,
+ )
+ if histories and not telemetry.history_path:
+ telemetry.history_path = str(histories[0])
+ cost_paths = [str(path) for path in paths if path.name.startswith(("codex-v", "pi-sessions-v"))]
+ pricing_paths = [str(path) for path in paths if path.name.startswith("models-")]
+ telemetry.cost_cache_paths = _dedupe_paths(telemetry.cost_cache_paths + sorted(cost_paths, reverse=True))
+ telemetry.pricing_cache_paths = _dedupe_paths(telemetry.pricing_cache_paths + sorted(pricing_paths, reverse=True))
+
+
+def _dedupe_paths(paths: List[str]) -> List[str]:
+ seen = set()
+ result = []
+ for path in paths:
+ if path in seen:
+ continue
+ seen.add(path)
+ result.append(path)
+ return result
+
+
def _app_version() -> Optional[str]:
info = CODEXBAR_APP / "Contents" / "Info.plist"
if not info.exists():
@@ -326,4 +359,3 @@ def _int(value: Any) -> int:
def _float(value: Any) -> Optional[float]:
return float(value) if isinstance(value, (int, float)) else None
-
diff --git a/src/codex_usage_profiler/collector.py b/src/codex_usage_profiler/collector.py
new file mode 100644
index 0000000..4a063aa
--- /dev/null
+++ b/src/codex_usage_profiler/collector.py
@@ -0,0 +1,583 @@
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import glob
+import hashlib
+import json
+import os
+import re
+import shutil
+import socket
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Optional, Tuple
+
+from .syslog_util import emit_loki_json, emit_syslog_json
+
+
+DEFAULT_TOOL_GLOBS: Dict[str, List[str]] = {
+ "codex": [
+ "~/.codex/sessions/**/*.jsonl",
+ "~/.codex/archived_sessions/**/*.jsonl",
+ "~/.paperclip/instances/default/companies/*/codex-home/sessions/**/*.jsonl",
+ "~/.paperclip/instances/default/companies/*/codex-home/archived_sessions/**/*.jsonl",
+ "~/.paperclip/instances/default/companies/*/agents/*/codex-home/sessions/**/*.jsonl",
+ "~/.paperclip/instances/default/companies/*/agents/*/codex-home/archived_sessions/**/*.jsonl",
+ ],
+ "claude": [
+ "~/.claude/projects/**/*.jsonl",
+ "~/.claude/sessions/**/*.jsonl",
+ "~/.claude/**/sessions/**/*.jsonl",
+ ],
+ "pi": [
+ "~/.pi/agent/sessions/**/*.jsonl",
+ "~/.pi/agent/**/sessions/**/*.jsonl",
+ "~/.pi/agent/projects/**/*.jsonl",
+ "~/.pi/agent/history/**/*.jsonl",
+ "~/.pi/agent/runs/**/*.jsonl",
+ ],
+ "t3chat": [
+ "~/.t3chat/**/*.jsonl",
+ "~/.config/t3chat/**/*.jsonl",
+ "~/.local/share/t3chat/**/*.jsonl",
+ "~/Library/Application Support/T3 Chat/**/*.jsonl",
+ ],
+ "kimi": [
+ "~/.kimi/**/*.jsonl",
+ "~/.config/kimi/**/*.jsonl",
+ "~/.local/share/kimi/**/*.jsonl",
+ ],
+ "droid": [
+ "~/.droid/**/*.jsonl",
+ "~/.config/droid/**/*.jsonl",
+ "~/.local/share/droid/**/*.jsonl",
+ "~/.nursedroid/**/*.jsonl",
+ ],
+ "cursor": [
+ "~/.cursor/**/*.jsonl",
+ "~/Library/Application Support/Cursor/User/workspaceStorage/**/*.jsonl",
+ ],
+ "opencode": [
+ "~/.opencode/**/*.jsonl",
+ "~/.local/share/opencode/**/*.jsonl",
+ ],
+ "codexbar": [
+ "~/Library/Application Support/com.steipete.codexbar/history/codex.json",
+ "~/Library/Caches/CodexBar/cost-usage/*.json",
+ "~/Library/Caches/CodexBar/model-pricing/*.json",
+ "~/Library/Group Containers/Y5PE65HELJ.com.steipete.codexbar/widget-snapshot.json",
+ ],
+}
+
+DEFAULT_CONFIG = {
+ "collector_name": None,
+ "destination": "saphid@lxso1:/home/saphid/.local/share/codex-usage-profiler/collected-sessions",
+ "include_defaults": True,
+ "tools": {},
+ "exclude": [
+ "**/node_modules/**",
+ "**/.venv/**",
+ "**/venv/**",
+ "**/auth.json",
+ "**/credentials.json",
+ "**/models_cache.json",
+ "**/cache/**",
+ "**/Caches/**",
+ ],
+ "max_file_bytes": 104857600,
+ "state_path": "~/.local/state/codex-usage-collector/state.json",
+ "staging_dir": "~/.local/share/codex-usage-collector/staging",
+ "log_path": "~/.local/log/codex-usage-collector/collector.jsonl",
+ "syslog_host": "192.168.1.30",
+ "syslog_port": 514,
+ "syslog_protocol": "tcp",
+ "loki_url": "http://192.168.1.221:3100/loki/api/v1/push",
+ "paperclip_metadata": True,
+ "paperclip_root": "~/.paperclip/instances/default",
+ "paperclip_context_path": "~/.paperclip/context.json",
+ "paperclip_api_base": None,
+ "paperclip_api_timeout": 1.5,
+}
+
+
+def main(argv: Optional[List[str]] = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="codex-usage-collector",
+ description="Collect changed AI coding session files and push them to a central Codex Usage Profiler host.",
+ )
+ parser.add_argument("--config", help="Collector JSON config")
+ parser.add_argument("--once", action="store_true", help="Run one collection pass")
+ parser.add_argument("--dry-run", action="store_true", help="Scan and log without staging or pushing")
+ parser.add_argument("--print-default-config", action="store_true", help="Print a starter config")
+ args = parser.parse_args(argv)
+
+ if args.print_default_config:
+ print(json.dumps(default_config(), indent=2, sort_keys=True))
+ return 0
+
+ config = load_collector_config(args.config)
+ result = run_collection(config, dry_run=args.dry_run)
+ print(json.dumps(result, sort_keys=True))
+ return 0 if not result.get("errors") else 2
+
+
+def default_config() -> Dict[str, Any]:
+ data = dict(DEFAULT_CONFIG)
+ data["tools"] = {tool: list(globs) for tool, globs in DEFAULT_TOOL_GLOBS.items()}
+ return data
+
+
+def load_collector_config(path: Optional[str]) -> Dict[str, Any]:
+ config = dict(DEFAULT_CONFIG)
+ if path:
+ loaded = _read_json(Path(path).expanduser())
+ if isinstance(loaded, dict):
+ config.update(loaded)
+ tools: Dict[str, List[str]] = {}
+ if config.get("include_defaults", True):
+ tools.update({tool: list(globs) for tool, globs in DEFAULT_TOOL_GLOBS.items()})
+ for tool, globs_value in dict(config.get("tools") or {}).items():
+ if isinstance(globs_value, str):
+ tools.setdefault(str(tool), []).append(globs_value)
+ elif isinstance(globs_value, list):
+ tools.setdefault(str(tool), []).extend(str(item) for item in globs_value)
+ config["tools"] = tools
+ config["collector_name"] = config.get("collector_name") or socket.gethostname().split(".")[0]
+ return config
+
+
+def run_collection(config: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]:
+ started = time.time()
+ collector = str(config.get("collector_name") or socket.gethostname().split(".")[0])
+ state_path = Path(str(config.get("state_path"))).expanduser()
+ staging_root = Path(str(config.get("staging_dir"))).expanduser()
+ log_path = Path(str(config.get("log_path"))).expanduser()
+ state = _read_json(state_path)
+ if not isinstance(state, dict):
+ state = {"files": {}}
+ state_files: Dict[str, Any] = dict(state.get("files") or {})
+ candidate_rows = discover_candidates(config)
+ staged = 0
+ skipped = 0
+ oversized = 0
+ vanished = 0
+ errors: List[str] = []
+ matched_by_tool: Dict[str, int] = {}
+ staged_by_tool: Dict[str, int] = {}
+
+ for tool, path in candidate_rows:
+ matched_by_tool[tool] = matched_by_tool.get(tool, 0) + 1
+ try:
+ stat = path.stat()
+ except FileNotFoundError:
+ vanished += 1
+ continue
+ except OSError as exc:
+ errors.append(f"stat_failed:{path}:{type(exc).__name__}")
+ continue
+ max_bytes = int(config.get("max_file_bytes") or 0)
+ if max_bytes and stat.st_size > max_bytes:
+ oversized += 1
+ continue
+ state_key = str(path)
+ fingerprint = {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
+ if state_files.get(state_key) == fingerprint:
+ skipped += 1
+ continue
+ if not dry_run:
+ try:
+ stage_file(staging_root, collector, tool, path, stat)
+ except FileNotFoundError:
+ vanished += 1
+ continue
+ except OSError as exc:
+ errors.append(f"stage_failed:{path}:{type(exc).__name__}")
+ continue
+ state_files[state_key] = fingerprint
+ staged += 1
+ staged_by_tool[tool] = staged_by_tool.get(tool, 0) + 1
+
+ if bool(config.get("paperclip_metadata", False)):
+ try:
+ metadata_result = stage_paperclip_metadata(staging_root, collector, config, dry_run=dry_run)
+ except OSError as exc:
+ errors.append(f"paperclip_metadata_failed:{type(exc).__name__}")
+ else:
+ if metadata_result:
+ metadata_key, metadata_fingerprint = metadata_result
+ if state_files.get(metadata_key) == metadata_fingerprint:
+ skipped += 1
+ else:
+ if not dry_run:
+ state_files[metadata_key] = metadata_fingerprint
+ staged += 1
+ staged_by_tool["paperclip_metadata"] = staged_by_tool.get("paperclip_metadata", 0) + 1
+
+ pushed = 0
+ if staged and not dry_run:
+ push_error = push_staged(staging_root / collector, str(config.get("destination") or ""))
+ if push_error:
+ errors.append(push_error)
+ else:
+ pushed = staged
+ state["files"] = state_files
+ _write_json(state_path, state)
+
+ event = {
+ "event": "collector_run",
+ "collector": collector,
+ "host": socket.gethostname(),
+ "matched_files": len(candidate_rows),
+ "matched_by_tool": matched_by_tool,
+ "staged_files": staged,
+ "staged_by_tool": staged_by_tool,
+ "skipped_files": skipped,
+ "oversized_files": oversized,
+ "vanished_files": vanished,
+ "pushed_files": pushed,
+ "dry_run": dry_run,
+ "duration_seconds": round(time.time() - started, 3),
+ "errors": errors,
+ }
+ write_event(log_path, event)
+ emit_syslog_json(
+ event,
+ host=str(config.get("syslog_host") or "") or None,
+ port=int(config.get("syslog_port") or 514),
+ tag="codex-usage-collector",
+ protocol=str(config.get("syslog_protocol") or "tcp"),
+ )
+ emit_loki_json(
+ event,
+ url=str(config.get("loki_url") or "") or None,
+ labels={"service": "collector", "host": collector},
+ )
+ return event
+
+
+def discover_candidates(config: Dict[str, Any]) -> List[Tuple[str, Path]]:
+ rows: List[Tuple[str, Path]] = []
+ seen: set[Path] = set()
+ excludes = [str(item) for item in config.get("exclude") or []]
+ for tool, patterns in dict(config.get("tools") or {}).items():
+ for pattern in patterns:
+ expanded = os.path.expanduser(os.path.expandvars(str(pattern)))
+ for match in glob.glob(expanded, recursive=True):
+ path = Path(match)
+ if not path.is_file():
+ continue
+ if path.suffix.lower() not in {".jsonl", ".json"}:
+ continue
+ if should_exclude(path, excludes):
+ continue
+ resolved = path.resolve()
+ if resolved in seen:
+ continue
+ seen.add(resolved)
+ rows.append((str(tool), resolved))
+ return sorted(rows, key=lambda item: (item[0], str(item[1])))
+
+
+def should_exclude(path: Path, patterns: Iterable[str]) -> bool:
+ text = str(path)
+ if "CodexBar" in text or "com.steipete.codexbar" in text:
+ return False
+ return any(fnmatch.fnmatch(text, os.path.expanduser(pattern)) for pattern in patterns)
+
+
+def stage_file(staging_root: Path, collector: str, tool: str, path: Path, stat: os.stat_result) -> Path:
+ digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
+ dest_dir = staging_root / collector / tool / digest
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ dest = dest_dir / path.name
+ shutil.copy2(path, dest)
+ meta = {
+ "collector": collector,
+ "host": socket.gethostname(),
+ "tool": tool,
+ "source_path": str(path),
+ "source_path_sha256": hashlib.sha256(str(path).encode("utf-8")).hexdigest(),
+ "size": stat.st_size,
+ "mtime": stat.st_mtime,
+ "staged_at": int(time.time()),
+ }
+ paperclip = _paperclip_context_from_path(str(path))
+ if paperclip:
+ meta["paperclip"] = paperclip
+ _write_json(dest.with_suffix(dest.suffix + ".meta.json"), meta)
+ return dest
+
+
+def stage_paperclip_metadata(
+ staging_root: Path,
+ collector: str,
+ config: Dict[str, Any],
+ dry_run: bool = False,
+) -> Optional[Tuple[str, Dict[str, str]]]:
+ snapshot = snapshot_paperclip_metadata(config)
+ if not snapshot:
+ return None
+ encoded = json.dumps(snapshot, sort_keys=True, separators=(",", ":")).encode("utf-8")
+ digest = hashlib.sha256(encoded).hexdigest()
+ state_key = f"paperclip_metadata:{snapshot.get('root') or collector}"
+ fingerprint = {"sha256": digest}
+ if not dry_run:
+ dest_dir = staging_root / collector / "paperclip_metadata" / digest[:16]
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ _write_json(dest_dir / "paperclip-metadata.json", snapshot)
+ return state_key, fingerprint
+
+
+def snapshot_paperclip_metadata(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ root = Path(str(config.get("paperclip_root") or "~/.paperclip/instances/default")).expanduser()
+ context_path = Path(str(config.get("paperclip_context_path") or "~/.paperclip/context.json")).expanduser()
+ context = _read_json(context_path)
+ if not root.exists() and not isinstance(context, dict):
+ return None
+ api_base = str(config.get("paperclip_api_base") or _api_base_from_context(context) or "http://127.0.0.1:3100").rstrip("/")
+ timeout = float(config.get("paperclip_api_timeout") or 1.5)
+ warnings: List[str] = []
+ companies = _fetch_json_list(f"{api_base}/api/companies", timeout, warnings, "companies")
+ agents: List[Dict[str, Any]] = []
+ projects: List[Dict[str, Any]] = []
+ for company in companies:
+ company_id = str(company.get("id") or "")
+ if not company_id:
+ continue
+ agents.extend(_fetch_json_list(f"{api_base}/api/companies/{company_id}/agents", timeout, warnings, f"agents:{company_id}"))
+ projects.extend(_fetch_json_list(f"{api_base}/api/companies/{company_id}/projects", timeout, warnings, f"projects:{company_id}"))
+ snapshot = {
+ "schema": "codex-usage-profiler.paperclip-metadata.v1",
+ "root": str(root),
+ "api_base": api_base,
+ "captured_at": int(time.time()),
+ "context": _sanitize_paperclip_context(context),
+ "companies": [_pick_fields(row, ["id", "name", "status", "issuePrefix"]) for row in companies],
+ "agents": [_sanitize_agent(row) for row in agents],
+ "projects": [_sanitize_project(row) for row in projects],
+ "workspaces": _paperclip_workspace_summaries(root, companies),
+ "warnings": warnings,
+ }
+ if not snapshot["companies"] and not snapshot["agents"] and not snapshot["projects"] and not snapshot["workspaces"]:
+ return None
+ return snapshot
+
+
+def push_staged(source: Path, destination: str) -> Optional[str]:
+ if not destination:
+ return "push_failed:missing_destination"
+ if not source.exists():
+ return None
+ if _is_remote_destination(destination):
+ dest = destination.rstrip("/") + f"/{source.name}/"
+ proc = subprocess.run(["rsync", "-a", str(source) + "/", dest], text=True, capture_output=True, check=False)
+ if proc.returncode != 0:
+ return f"push_failed:rsync:{proc.stderr.strip() or proc.stdout.strip()}"
+ return None
+ dest_path = Path(destination).expanduser() / source.name
+ shutil.copytree(source, dest_path, dirs_exist_ok=True)
+ return None
+
+
+def _api_base_from_context(context: Any) -> Optional[str]:
+ if not isinstance(context, dict):
+ return None
+ profiles = context.get("profiles")
+ current = context.get("currentProfile")
+ if isinstance(profiles, dict) and current in profiles and isinstance(profiles[current], dict):
+ value = profiles[current].get("apiBase")
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
+def _fetch_json_list(url: str, timeout: float, warnings: List[str], label: str) -> List[Dict[str, Any]]:
+ try:
+ with urllib.request.urlopen(url, timeout=timeout) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
+ warnings.append(f"{label}:{type(exc).__name__}")
+ return []
+ if not isinstance(payload, list):
+ return []
+ return [item for item in payload if isinstance(item, dict)]
+
+
+def _sanitize_paperclip_context(context: Any) -> Dict[str, Any]:
+ if not isinstance(context, dict):
+ return {}
+ return {
+ "currentProfile": context.get("currentProfile"),
+ "companyId": context.get("companyId"),
+ "agentId": context.get("agentId"),
+ "persona": context.get("persona"),
+ }
+
+
+def _sanitize_agent(row: Dict[str, Any]) -> Dict[str, Any]:
+ adapter = row.get("adapterConfig") if isinstance(row.get("adapterConfig"), dict) else {}
+ env = adapter.get("env") if isinstance(adapter.get("env"), dict) else {}
+ codex_home = env.get("CODEX_HOME") if isinstance(env.get("CODEX_HOME"), dict) else {}
+ return {
+ "id": row.get("id"),
+ "companyId": row.get("companyId"),
+ "name": row.get("name"),
+ "title": row.get("title"),
+ "role": row.get("role"),
+ "status": row.get("status"),
+ "reportsTo": row.get("reportsTo"),
+ "adapterType": row.get("adapterType"),
+ "cwd": adapter.get("cwd"),
+ "codexHome": codex_home.get("value"),
+ "instructionsFilePath": adapter.get("instructionsFilePath"),
+ }
+
+
+def _sanitize_project(row: Dict[str, Any]) -> Dict[str, Any]:
+ codebase = row.get("codebase") if isinstance(row.get("codebase"), dict) else {}
+ return {
+ "id": row.get("id"),
+ "companyId": row.get("companyId"),
+ "name": row.get("name"),
+ "status": row.get("status"),
+ "leadAgentId": row.get("leadAgentId"),
+ "urlKey": row.get("urlKey"),
+ "workspaceId": codebase.get("workspaceId"),
+ "localFolder": codebase.get("localFolder"),
+ "managedFolder": codebase.get("managedFolder"),
+ "effectiveLocalFolder": codebase.get("effectiveLocalFolder"),
+ }
+
+
+def _pick_fields(row: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
+ return {field: row.get(field) for field in fields if field in row}
+
+
+def _paperclip_workspace_summaries(root: Path, companies: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ workspaces = root / "workspaces"
+ if not workspaces.exists():
+ return []
+ prefix_company = {
+ str(company.get("issuePrefix") or "").upper(): str(company.get("id") or "")
+ for company in companies
+ if company.get("issuePrefix") and company.get("id")
+ }
+ rows: List[Dict[str, Any]] = []
+ for workspace in sorted(workspaces.iterdir()):
+ if not workspace.is_dir():
+ continue
+ names = [item.name for item in workspace.iterdir() if item.is_file()]
+ if not names:
+ continue
+ staff = _staff_from_workspace_names(names)
+ issue_prefixes = sorted(_issue_prefixes_from_names(names, set(prefix_company)))
+ company_id = prefix_company.get(issue_prefixes[0]) if issue_prefixes else None
+ task_ids = sorted(_task_ids_from_names(names))[:20]
+ rows.append(
+ {
+ "id": workspace.name,
+ "path": str(workspace),
+ "staffLabel": staff,
+ "companyId": company_id,
+ "issuePrefixes": issue_prefixes,
+ "taskIds": task_ids,
+ "fileCount": len(names),
+ }
+ )
+ return rows
+
+
+def _staff_from_workspace_names(names: List[str]) -> Optional[str]:
+ aliases = {
+ "ceo": "CEO",
+ "cto": "CTO",
+ "cmo": "CMO",
+ "cfo": "CFO",
+ "coo": "COO",
+ "sre": "SRE",
+ "qa": "QA",
+ "devops": "DevOps",
+ "security": "Security",
+ "growth": "Growth",
+ }
+ counts: Dict[str, int] = {}
+ for name in names:
+ match = re.match(r"([a-z][a-z0-9]+)[-_]", name.lower())
+ if match:
+ counts[match.group(1)] = counts.get(match.group(1), 0) + 1
+ if not counts:
+ return None
+ key = sorted(counts.items(), key=lambda item: (-item[1], item[0]))[0][0]
+ return aliases.get(key, key.replace("_", " ").replace("-", " ").title())
+
+
+def _issue_prefixes_from_names(names: List[str], known_prefixes: set[str]) -> set[str]:
+ prefixes = set()
+ for name in names:
+ for match in re.finditer(r"\b([a-z]{2,10})[-_]?(\d{1,6})\b", name, re.IGNORECASE):
+ prefix = match.group(1).upper()
+ if not known_prefixes or prefix in known_prefixes:
+ prefixes.add(prefix)
+ return prefixes
+
+
+def _task_ids_from_names(names: List[str]) -> set[str]:
+ ids = set()
+ for name in names:
+ for match in re.finditer(r"\b([A-Z]{2,10})[-_]?(\d{1,6})\b", name, re.IGNORECASE):
+ ids.add(f"{match.group(1).upper()}-{match.group(2)}")
+ return ids
+
+
+def _paperclip_context_from_path(path: str) -> Dict[str, str]:
+ result: Dict[str, str] = {}
+ match = re.search(r"/(?:\.paperclip|paperclip)/instances/([^/]+)/companies/([^/]+)(?:/agents/([^/]+))?/codex-home/", path)
+ if match:
+ result["instance_id"] = match.group(1)
+ result["company_id"] = match.group(2)
+ if match.group(3):
+ result["agent_id"] = match.group(3)
+ match = re.search(r"/(?:\.paperclip|paperclip)/instances/([^/]+)/projects/([^/]+)/([^/]+)/", path)
+ if match:
+ result["instance_id"] = match.group(1)
+ result["company_id"] = match.group(2)
+ result["project_id"] = match.group(3)
+ match = re.search(r"/(?:\.paperclip|paperclip)/instances/([^/]+)/workspaces/([^/]+)(?:/|$)", path)
+ if match:
+ result["instance_id"] = match.group(1)
+ result["workspace_id"] = match.group(2)
+ return result
+
+
+def write_event(log_path: Path, event: Dict[str, Any]) -> None:
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ with log_path.open("a", encoding="utf-8") as fh:
+ fh.write(json.dumps(event, sort_keys=True) + "\n")
+
+
+def _is_remote_destination(destination: str) -> bool:
+ head = destination.split("/", 1)[0]
+ return ":" in head
+
+
+def _read_json(path: Path) -> Any:
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {}
+
+
+def _write_json(path: Path, data: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
+ tmp.replace(path)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/codex_usage_profiler/config.py b/src/codex_usage_profiler/config.py
index 7ecb6e4..c944dd0 100644
--- a/src/codex_usage_profiler/config.py
+++ b/src/codex_usage_profiler/config.py
@@ -12,9 +12,14 @@ class Config:
path_aliases: Dict[str, str] = field(default_factory=dict)
client_aliases: Dict[str, str] = field(default_factory=dict)
task_aliases: Dict[str, str] = field(default_factory=dict)
+ paperclip_company_aliases: Dict[str, str] = field(default_factory=dict)
+ paperclip_project_aliases: Dict[str, str] = field(default_factory=dict)
+ paperclip_agent_aliases: Dict[str, str] = field(default_factory=dict)
rates: Dict[str, Dict[str, float]] = field(default_factory=dict)
+ plan_prices_usd: Dict[str, float] = field(default_factory=dict)
dollars_per_credit: Optional[float] = None
monthly_plan_price_usd: Optional[float] = None
+ projection_days: int = 30
codexbar_enabled: bool = True
paperclip_enabled: bool = True
paperclip_root: str = "~/.paperclip/instances/default"
@@ -33,9 +38,18 @@ def load_config(path: Optional[str]) -> Config:
path_aliases=dict(data.get("path_aliases") or {}),
client_aliases=dict(data.get("client_aliases") or {}),
task_aliases=dict(data.get("task_aliases") or {}),
+ paperclip_company_aliases=dict(data.get("paperclip_company_aliases") or {}),
+ paperclip_project_aliases=dict(data.get("paperclip_project_aliases") or {}),
+ paperclip_agent_aliases=dict(data.get("paperclip_agent_aliases") or {}),
rates=dict(data.get("rates") or {}),
+ plan_prices_usd={
+ str(name): float(value)
+ for name, value in dict(data.get("plan_prices_usd") or {}).items()
+ if isinstance(value, (int, float))
+ },
dollars_per_credit=_num(data.get("dollars_per_credit")),
monthly_plan_price_usd=_num(data.get("monthly_plan_price_usd")),
+ projection_days=int(data.get("projection_days") or 30),
codexbar_enabled=bool(data.get("codexbar_enabled", True)),
paperclip_enabled=bool(data.get("paperclip_enabled", True)),
paperclip_root=str(data.get("paperclip_root") or "~/.paperclip/instances/default"),
diff --git a/src/codex_usage_profiler/dashboard.py b/src/codex_usage_profiler/dashboard.py
index d07e8b4..5a67bfe 100644
--- a/src/codex_usage_profiler/dashboard.py
+++ b/src/codex_usage_profiler/dashboard.py
@@ -3,6 +3,7 @@
import argparse
import json
import mimetypes
+import socket
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import resources
@@ -10,6 +11,8 @@
from typing import Optional
from urllib.parse import unquote, urlparse
+from .syslog_util import emit_loki_json, emit_syslog_json
+
STATIC_PACKAGE = "codex_usage_profiler"
STATIC_DIR = "dashboard_static"
@@ -21,6 +24,9 @@ def _static_root():
class DashboardHandler(BaseHTTPRequestHandler):
report_path: Path
+ syslog_host: Optional[str] = None
+ syslog_port: int = 514
+ loki_url: Optional[str] = None
def do_GET(self) -> None:
parsed = urlparse(self.path)
@@ -36,7 +42,31 @@ def do_GET(self) -> None:
self._serve_static(path.lstrip("/"))
def log_message(self, format: str, *args) -> None: # noqa: A002
- sys.stderr.write("dashboard: " + (format % args) + "\n")
+ message = format % args
+ sys.stderr.write("dashboard: " + message + "\n")
+ emit_syslog_json(
+ {
+ "event": "dashboard_access",
+ "host": socket.gethostname(),
+ "client": self.client_address[0] if self.client_address else "unknown",
+ "request": getattr(self, "requestline", ""),
+ "message": message,
+ },
+ host=self.syslog_host,
+ port=self.syslog_port,
+ tag="codex-usage-dashboard",
+ )
+ emit_loki_json(
+ {
+ "event": "dashboard_access",
+ "host": socket.gethostname(),
+ "client": self.client_address[0] if self.client_address else "unknown",
+ "request": getattr(self, "requestline", ""),
+ "message": message,
+ },
+ url=self.loki_url,
+ labels={"service": "dashboard", "host": socket.gethostname()},
+ )
def _serve_report(self) -> None:
try:
@@ -85,13 +115,23 @@ def _send_error(self, status: int, message: str) -> None:
self.wfile.write(data)
-def make_server(report_path: str | Path, host: str = "127.0.0.1", port: int = 8765) -> ThreadingHTTPServer:
+def make_server(
+ report_path: str | Path,
+ host: str = "127.0.0.1",
+ port: int = 8765,
+ syslog_host: Optional[str] = None,
+ syslog_port: int = 514,
+ loki_url: Optional[str] = None,
+) -> ThreadingHTTPServer:
report = Path(report_path).expanduser().resolve()
class BoundDashboardHandler(DashboardHandler):
pass
BoundDashboardHandler.report_path = report
+ BoundDashboardHandler.syslog_host = syslog_host
+ BoundDashboardHandler.syslog_port = syslog_port
+ BoundDashboardHandler.loki_url = loki_url
return ThreadingHTTPServer((host, port), BoundDashboardHandler)
@@ -103,9 +143,12 @@ def main(argv: Optional[list[str]] = None) -> int:
parser.add_argument("--report", default="samples/demo-report.json", help="Profiler JSON report to serve")
parser.add_argument("--host", default="127.0.0.1", help="Bind host. Use 0.0.0.0 for Tailscale/LAN access")
parser.add_argument("--port", type=int, default=8765, help="Bind port")
+ parser.add_argument("--syslog-host", help="Optional syslog host for dashboard access events")
+ parser.add_argument("--syslog-port", type=int, default=514)
+ parser.add_argument("--loki-url", help="Optional Loki push API URL for dashboard access events")
args = parser.parse_args(argv)
- server = make_server(args.report, args.host, args.port)
+ server = make_server(args.report, args.host, args.port, args.syslog_host, args.syslog_port, args.loki_url)
bound_host, bound_port = server.server_address
print(f"Codex Usage Profiler dashboard: http://{bound_host}:{bound_port}/")
print(f"Report: {Path(args.report).expanduser().resolve()}")
diff --git a/src/codex_usage_profiler/dashboard_static/app.js b/src/codex_usage_profiler/dashboard_static/app.js
index af1e38e..518a7d2 100644
--- a/src/codex_usage_profiler/dashboard_static/app.js
+++ b/src/codex_usage_profiler/dashboard_static/app.js
@@ -46,6 +46,7 @@
"waste-drivers": "Ranked recurring review candidates. Click a row to filter the session table to that pattern. Candidate rows can overlap.",
coverage: "Attribution coverage shows how much filtered usage can be tied to client, project, staff, and task. Click unknown buckets to investigate gaps.",
confidence: "Confidence is the weakest key attribution signal across client, project, staff, and task. Lower confidence means the profiler needs better attribution evidence.",
+ "company-spend": "Paperclip company spend groups filtered sessions by Paperclip company and day. Projected cost uses the filtered observed span, so treat it as directional plan-selection evidence.",
projection: "Cleanup projection estimates directional savings from de-duplicated sessions in the top review-candidate drivers.",
notifications: "Notification hooks are not enabled in this local-only dashboard yet.",
settings: "Settings will hold report paths, privacy defaults, and quota assumptions in a later version."
@@ -504,6 +505,71 @@
return Object.keys(map).map(function (key) { return map[key]; }).sort(function (a, b) { return b.tokens - a.tokens; });
}
+ function dayString(session) {
+ var date = startDate(session);
+ return date ? date.toISOString().slice(0, 10) : "unknown";
+ }
+
+ function dateSpanDays(days) {
+ days = (days || []).filter(function (day) { return day !== "unknown"; }).sort();
+ if (!days.length) return 1;
+ var first = new Date(days[0] + "T00:00:00Z").getTime();
+ var last = new Date(days[days.length - 1] + "T00:00:00Z").getTime();
+ if (!isFinite(first) || !isFinite(last)) return 1;
+ return Math.max(1, Math.round((last - first) / 86400000) + 1);
+ }
+
+ function companySpendModel(sessions, report, metric) {
+ var valueFor = metric === "tokens" ? tokens : cost;
+ var projectionDays = Number((report.plan_analysis && report.plan_analysis.projection_days) || (report.paperclip_spend && report.paperclip_spend.projection_days) || 30);
+ var totals = {};
+ var days = {};
+ var dayCompany = {};
+ (sessions || []).forEach(function (session) {
+ var company = attrLabel(session.paperclip_company);
+ if (company === "unknown") return;
+ var day = dayString(session);
+ var value = valueFor(session);
+ if (!totals[company]) totals[company] = { company: company, sessions: 0, tokens: 0, cost: 0, days: {} };
+ totals[company].sessions += 1;
+ totals[company].tokens += tokens(session);
+ totals[company].cost += cost(session);
+ totals[company].days[day] = true;
+ days[day] = true;
+ var key = day + "|" + company;
+ if (!dayCompany[key]) dayCompany[key] = { day: day, company: company, sessions: 0, tokens: 0, cost: 0, value: 0 };
+ dayCompany[key].sessions += 1;
+ dayCompany[key].tokens += tokens(session);
+ dayCompany[key].cost += cost(session);
+ dayCompany[key].value += value;
+ });
+ var totalRows = Object.keys(totals).map(function (company) {
+ var row = totals[company];
+ var activeDays = Object.keys(row.days).length;
+ var span = dateSpanDays(Object.keys(row.days));
+ row.activeDays = activeDays;
+ row.observedSpanDays = span;
+ row.projectedCost = span ? row.cost / span * projectionDays : 0;
+ row.projectedTokens = span ? row.tokens / span * projectionDays : 0;
+ return row;
+ }).sort(function (a, b) { return b[metric] - a[metric]; });
+ var topCompanies = {};
+ totalRows.slice(0, 5).forEach(function (row) { topCompanies[row.company] = true; });
+ var dayRows = Object.keys(days).sort().slice(-14).map(function (day) {
+ var companies = {};
+ var total = 0;
+ Object.keys(dayCompany).forEach(function (key) {
+ var row = dayCompany[key];
+ if (row.day !== day) return;
+ var label = topCompanies[row.company] ? row.company : "Other";
+ companies[label] = (companies[label] || 0) + row.value;
+ total += row.value;
+ });
+ return { day: day, companies: companies, total: total };
+ });
+ return { metric: metric, projectionDays: projectionDays, totals: totalRows, days: dayRows };
+ }
+
function outcomeBucket(session, findingIndex) {
if (isWaste(session, findingIndex || {})) return "Waste";
if (isUseful(session)) return "Useful";
@@ -588,11 +654,11 @@
}
function layoutFlowModel(model, width, height) {
- width = Math.max(360, Number(width) || 520);
+ width = Math.max(300, Number(width) || 520);
var maxNodes = model.columns.reduce(function (max, column) { return Math.max(max, column.nodes.length); }, 0);
var requiredHeight = 46 + maxNodes * 36 + Math.max(0, maxNodes - 1) * 8;
height = Math.max(220, Number(height) || 300, requiredHeight);
- var nodeWidth = Math.min(144, Math.max(88, width * 0.16));
+ var nodeWidth = Math.min(144, Math.max(68, width * 0.17));
var stageGap = model.columns.length > 1 ? (width - nodeWidth) / (model.columns.length - 1) : 0;
var topPad = 34;
var bottomPad = 12;
@@ -1035,6 +1101,71 @@
commitFilterChange();
}
+ function isCardFilterActive(kind) {
+ var filters = app.state.filters;
+ if (kind === "flow") {
+ return Boolean(
+ filters.clients.length ||
+ filters.projects.length ||
+ filters.staff.length ||
+ filters.outcomes.length ||
+ filters.outcomeBucket ||
+ filters.sessionIds.length
+ );
+ }
+ if (kind === "timeline") return Boolean(filters.brushStartTime || filters.brushEndTime || app.state.hiddenOutcomes.length);
+ if (kind === "company") return Boolean(filters.companies.length || filters.brushStartTime || filters.brushEndTime);
+ if (kind === "heatmap") return Boolean(filters.weekdays.length || filters.hourStart !== "" || filters.hourEnd !== "");
+ if (kind === "waste") return Boolean(filters.waste !== "all" || filters.wasteKind);
+ if (kind === "coverage") return Boolean(filters.attributionCoverage);
+ if (kind === "projection") return Boolean(filters.waste === "any" && !filters.wasteKind);
+ return false;
+ }
+
+ function resetCardFilter(kind) {
+ var filters = app.state.filters;
+ if (kind === "flow") {
+ var hadOutcomeBucket = Boolean(filters.outcomeBucket);
+ filters.clients = [];
+ filters.projects = [];
+ filters.staff = [];
+ filters.outcomes = [];
+ filters.outcomeBucket = "";
+ filters.sessionIds = [];
+ if (hadOutcomeBucket && filters.waste === "any" && !filters.wasteKind) filters.waste = "all";
+ } else if (kind === "timeline") {
+ filters.brushStartTime = "";
+ filters.brushEndTime = "";
+ app.state.hiddenOutcomes = [];
+ } else if (kind === "company") {
+ filters.companies = [];
+ filters.brushStartTime = "";
+ filters.brushEndTime = "";
+ } else if (kind === "heatmap") {
+ filters.weekdays = [];
+ filters.hourStart = "";
+ filters.hourEnd = "";
+ } else if (kind === "waste") {
+ filters.waste = "all";
+ filters.wasteKind = "";
+ } else if (kind === "coverage") {
+ filters.attributionCoverage = "";
+ } else if (kind === "projection") {
+ if (filters.waste === "any" && !filters.wasteKind) filters.waste = "all";
+ }
+ commitFilterChange();
+ }
+
+ function updateCardResetButtons() {
+ Array.prototype.slice.call(document.querySelectorAll(".card-reset[data-reset-card]")).forEach(function (button) {
+ var kind = button.getAttribute("data-reset-card");
+ var active = isCardFilterActive(kind);
+ button.disabled = !active;
+ button.classList.toggle("active", active);
+ button.setAttribute("aria-disabled", active ? "false" : "true");
+ });
+ }
+
function renderFlow(sessions, state) {
var container = document.getElementById("spend-flow");
clear(container);
@@ -1369,6 +1500,82 @@
});
}
+ function renderCompanySpend(sessions, report, state) {
+ var container = document.getElementById("company-spend");
+ clear(container);
+ var metric = state.metric === "tokens" ? "tokens" : "cost";
+ var model = companySpendModel(sessions, report || {}, metric);
+ if (!model.totals.length) {
+ container.appendChild(el("p", { class: "muted" }, ["No Paperclip company sessions in this filter."]));
+ return;
+ }
+ var maxDay = Math.max.apply(Math, model.days.map(function (row) { return row.total; }).concat([1]));
+ var maxTotal = Math.max.apply(Math, model.totals.slice(0, 5).map(function (row) { return row[metric]; }).concat([1]));
+ var planPrice = Number((report.plan_analysis && report.plan_analysis.monthly_plan_price_usd) || 0);
+ var topWrap = el("div", { class: "company-spend-top" });
+ model.totals.slice(0, 5).forEach(function (row) {
+ var projected = metric === "tokens" ? row.projectedTokens : row.projectedCost;
+ var value = metric === "tokens" ? row.tokens : row.cost;
+ var button = el("button", {
+ type: "button",
+ class: sameArrayValues(app.state.filters.companies, [row.company]) ? "active" : "",
+ style: "--bar:" + Math.max(4, value / maxTotal * 100).toFixed(1) + "%",
+ "aria-pressed": sameArrayValues(app.state.filters.companies, [row.company]) ? "true" : "false",
+ "aria-label": "Filter Paperclip company " + row.company
+ }, [
+ el("strong", { title: row.company }, [row.company]),
+ el("span", {}, [formatMetricValue(value, metric) + " observed"]),
+ el("span", {}, [formatMetricValue(projected, metric) + " projected " + model.projectionDays + "d"])
+ ]);
+ button.addEventListener("click", function () {
+ app.state.filters.companies = sameArrayValues(app.state.filters.companies, [row.company]) ? [] : [row.company];
+ commitFilterChange();
+ });
+ topWrap.appendChild(button);
+ });
+ container.appendChild(topWrap);
+ var dayWrap = el("div", { class: "company-day-bars" });
+ model.days.forEach(function (row) {
+ var activeDay = app.state.filters.brushStartTime === row.day + "T00:00:00.000Z" && app.state.filters.brushEndTime === row.day + "T23:59:59.999Z";
+ var day = el("button", {
+ type: "button",
+ class: "company-day" + (activeDay ? " active" : ""),
+ title: row.day + " " + formatMetricValue(row.total, metric),
+ "aria-pressed": activeDay ? "true" : "false",
+ "aria-label": (activeDay ? "Clear day " : "Filter day ") + row.day
+ }, [
+ el("span", { class: "day-label" }, [row.day.slice(5)])
+ ]);
+ var stack = el("span", { class: "day-stack", style: "--height:" + Math.max(3, row.total / maxDay * 100).toFixed(1) + "%" });
+ Object.keys(row.companies).sort(function (a, b) { return row.companies[b] - row.companies[a]; }).forEach(function (company, idx) {
+ stack.appendChild(el("span", {
+ class: "company-segment seg-" + (idx % 6),
+ style: "height:" + (row.companies[company] / Math.max(1, row.total) * 100).toFixed(1) + "%",
+ title: company + " " + formatMetricValue(row.companies[company], metric)
+ }));
+ });
+ day.addEventListener("click", function () {
+ if (activeDay) {
+ app.state.filters.brushStartTime = "";
+ app.state.filters.brushEndTime = "";
+ } else {
+ app.state.filters.brushStartTime = row.day + "T00:00:00.000Z";
+ app.state.filters.brushEndTime = row.day + "T23:59:59.999Z";
+ }
+ commitFilterChange();
+ });
+ day.appendChild(stack);
+ dayWrap.appendChild(day);
+ });
+ container.appendChild(dayWrap);
+ if (planPrice) {
+ var projectedCost = model.totals.reduce(function (sum, row) { return sum + row.projectedCost; }, 0);
+ container.appendChild(el("p", { class: "company-plan-note" }, [
+ "Filtered projected rate-card cost " + formatCost(projectedCost) + " vs plan price " + formatCost(planPrice) + " (" + formatPercent(planPrice ? projectedCost / planPrice * 100 : null) + ")."
+ ]));
+ }
+ }
+
function renderWasteDrivers(sessions, report) {
var container = document.getElementById("waste-drivers");
clear(container);
@@ -1829,11 +2036,13 @@
renderKpis(summary);
renderFlow(flowSessions, app.state);
renderTimeline(app.filtered, app.report);
+ renderCompanySpend(app.filtered, app.report, app.state);
renderHeatmap(app.filtered);
renderWasteDrivers(app.filtered, app.report);
renderCoverage(app.filtered);
renderProjection(app.filtered, app.report);
renderChips(app.state);
+ updateCardResetButtons();
renderTable(app.filtered, app.state, app.report);
renderDrawer(app.filtered, app.state, app.report);
var query = encodeFilters(app.state);
@@ -1857,6 +2066,12 @@
});
document.getElementById("density-select").addEventListener("change", renderApp);
document.getElementById("reduction-select").addEventListener("change", renderApp);
+ Array.prototype.slice.call(document.querySelectorAll(".card-reset[data-reset-card]")).forEach(function (button) {
+ button.addEventListener("click", function () {
+ if (button.disabled) return;
+ resetCardFilter(button.getAttribute("data-reset-card"));
+ });
+ });
bindBrushEvents();
document.getElementById("prev-page").addEventListener("click", function () {
app.state.page = Math.max(1, app.state.page - 1);
@@ -1966,6 +2181,13 @@
document.body.classList.add("drawer-hidden");
renderApp();
});
+ var resizeTimer = null;
+ window.addEventListener("resize", function () {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(function () {
+ renderApp();
+ }, 60);
+ });
}
function copyText(text) {
@@ -2172,6 +2394,7 @@
wasteDrivers: wasteDrivers,
coverageStats: coverageStats,
hourlyBuckets: hourlyBuckets,
+ companySpendModel: companySpendModel,
timeExtent: timeExtent,
sortSessions: sortSessions,
normalizeSession: normalizeSession,
diff --git a/src/codex_usage_profiler/dashboard_static/index.html b/src/codex_usage_profiler/dashboard_static/index.html
index 20b6870..40ac8a8 100644
--- a/src/codex_usage_profiler/dashboard_static/index.html
+++ b/src/codex_usage_profiler/dashboard_static/index.html
@@ -128,13 +128,16 @@
Active filters
Spend Flow
Client -> Project -> Staff -> Outcome
-
+
+
+
+
@@ -154,6 +157,7 @@ Usage Over Time (Hourly)