Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
135 commits
Select commit Hold shift + click to select a range
71f0569
feat: add Work insights with a local Jet classifier
bhavsarpratik Sep 30, 2026
ac6b99b
fix: harden Work insights after independent review
bhavsarpratik Sep 30, 2026
aac2f36
fix: stop Work insights pacing spin and keep scheduled retries
bhavsarpratik Sep 30, 2026
3106858
fix: never delete a locked Work insights ledger
bhavsarpratik Sep 30, 2026
0a60ecb
fix: close Work insights clear and cloud-model windows
bhavsarpratik Sep 30, 2026
1137e02
feat: add outcome insights to Work and fix failed label deletes
bhavsarpratik Sep 30, 2026
108afdf
fix: make Work label deletes durable and outcome insights exact
bhavsarpratik Sep 30, 2026
2a30dd6
fix: keep Work label deletes pending without a marker and classify by…
bhavsarpratik Sep 30, 2026
d16d5ed
feat: drill into Work sessions and improve label quality from audit
bhavsarpratik Sep 30, 2026
ec962fc
feat: redesign Work as spend in, outcomes out
bhavsarpratik Sep 30, 2026
67133b1
feat: make Work show fewer, sharper insights
bhavsarpratik Sep 30, 2026
b27aa21
fix: harden Work insights outcomes, openers, and drill-downs after re…
bhavsarpratik Sep 30, 2026
1c8eb48
fix: pick Work session labels by opener position and use calendar win…
bhavsarpratik Sep 30, 2026
aa71239
fix: keep one All sessions row per trace file
bhavsarpratik Sep 30, 2026
635bca2
fix: close delete, highlight, and Work opener gaps from review
bhavsarpratik Sep 30, 2026
950fed2
fix: keep earlier Work labels until a moved opener is labeled
bhavsarpratik Sep 30, 2026
daa6ce2
fix: show Unclear when a moved Work opener fails permanently
bhavsarpratik Sep 30, 2026
a6c14b4
feat: add short Work ranges and visual model and sizing stats
bhavsarpratik Oct 1, 2026
142e56c
fix: count mismatched Work spend once and keep the full daily trend
bhavsarpratik Oct 1, 2026
02f02d1
fix: say when Work tiers are unknown and label sizing denominators
bhavsarpratik Oct 1, 2026
78965e7
feat: simplify the Work page and drop hard-to-read modules
bhavsarpratik Oct 1, 2026
e1a898e
fix: keep one All sessions row per trace file
bhavsarpratik Oct 1, 2026
75b76b2
feat: default Work insights to a gentle 5 labels per minute
bhavsarpratik Oct 1, 2026
04476f1
fix: name the session header from the opened trace file
bhavsarpratik Oct 1, 2026
2e1a539
feat: make Work macOS-only, number it, and explain right-sizing flags
bhavsarpratik Oct 1, 2026
0a34ec2
chore: drop Work styles for removed modules
bhavsarpratik Oct 1, 2026
78cc8f8
fix: find older trace titles for the session header
bhavsarpratik Oct 1, 2026
b4e72d0
fix: declare the All sessions inventory before the first render
bhavsarpratik Oct 1, 2026
811b20f
Merge branch 'main' into feat/work-insights-v2
bhavsarpratik Oct 1, 2026
aa53e89
Merge branch 'fix/all-sessions-trace-rows' into feat/work-insights-v2
bhavsarpratik Oct 1, 2026
fe83170
fix: declare Work support state before the first route
bhavsarpratik Oct 1, 2026
49746c8
feat: add Work highlights, session tags, work rhythm, and area trends
bhavsarpratik Oct 1, 2026
ad244ca
feat: set up Work insights automatically and lead with model right-si…
bhavsarpratik Oct 1, 2026
b0374be
feat: classify Work sessions with developer work types and stack areas
bhavsarpratik Oct 1, 2026
db76c9b
fix: make Work setup cancellable and proxy-safe, keep stale area labe…
bhavsarpratik Oct 1, 2026
a44fe88
fix: base model suggestions on per-token price and suggest newer same…
bhavsarpratik Oct 5, 2026
96ae093
fix: suggest only newer same-family models, scope routine targets by …
bhavsarpratik Oct 5, 2026
a72e92c
docs: add Work insights logic reference and infographic; fix setup re…
bhavsarpratik Oct 5, 2026
fd1b046
fix: explain unlabeled sessions, lower the area Unclear cutoff, and s…
bhavsarpratik Oct 5, 2026
8392e90
fix: judge labeling history by latest activity and count unsplit spen…
bhavsarpratik Oct 5, 2026
2fb613d
feat: live suggestions with menu bar notifications, and subagent comp…
bhavsarpratik Oct 5, 2026
b65fd2a
fix: keep live notifications opt-in and make subagent completion hone…
bhavsarpratik Oct 5, 2026
87e5edd
fix: back off failed subagent outcome loads, open Issues from the car…
bhavsarpratik Oct 5, 2026
6f5d4f2
fix: keep the last good subagent comparison through a failed refresh …
bhavsarpratik Oct 5, 2026
eee50ff
fix: drop cached subagent outcomes when Work insights is off or has n…
bhavsarpratik Oct 5, 2026
f396e94
feat: remove the subagent completion card, add model trends, and keep…
bhavsarpratik Oct 5, 2026
ef49027
fix: make Right-sizing colours readable and show each model once in m…
bhavsarpratik Oct 5, 2026
7bfa1f5
fix: mark low-confidence area guesses as likely in session chips and …
bhavsarpratik Oct 5, 2026
17f3af0
test: include area_guess in the session tag key set
bhavsarpratik Oct 5, 2026
24ef824
Merge origin/main into feat/work-insights-v2
bhavsarpratik Oct 5, 2026
a81ff07
Merge origin/main (last month range) into feat/work-insights-v2
bhavsarpratik Oct 5, 2026
c014f26
Add a This month range to every date filter
bhavsarpratik Oct 5, 2026
9ca0707
Label the current-month range "Month" everywhere
bhavsarpratik Oct 5, 2026
e9f5c92
Label Work per request and split each session's cost across its requests
bhavsarpratik Oct 5, 2026
9349f51
Show requests left to label while Work relabels
bhavsarpratik Oct 5, 2026
dffa731
Keep Work spend on the day it happened and require evidence across se…
bhavsarpratik Oct 5, 2026
7d4a7e7
Replace Jet with Winnow-E4B for Work insights and ask before the new …
bhavsarpratik Oct 7, 2026
fb669b5
Merge main into feat/work-insights-v2
bhavsarpratik Oct 7, 2026
568708f
Show Right-sizing as model, reasoning, and pushback cards; unload the…
bhavsarpratik Oct 7, 2026
c30f724
Show the vs-yesterday change on partial days and drop the estimate su…
bhavsarpratik Oct 8, 2026
0bfcba2
Count a session as live by its last model reply, not its trace file time
bhavsarpratik Oct 8, 2026
babfb85
Credit each request's pushback to the model that answered it everywhere
bhavsarpratik Oct 8, 2026
b35b184
Merge origin/main into feat/work-insights-v2
bhavsarpratik Oct 8, 2026
898e93c
Merge fix/current-day-strip into feat/work-insights-v2
bhavsarpratik Oct 8, 2026
aa6e88f
Merge fix/live-session-activity into feat/work-insights-v2
bhavsarpratik Oct 8, 2026
c12f385
feat: show model pushback on the Models page
bhavsarpratik Oct 8, 2026
fd21aff
fix: match Models projects in Work and refresh model pushback
bhavsarpratik Oct 8, 2026
3e3ac8f
fix: show Work in the performance page sidebar
bhavsarpratik Oct 8, 2026
a6fdefc
feat: label work with Google's Gemma 4 E4B QAT and guard memory
bhavsarpratik Oct 9, 2026
9401c4d
fix: check memory before every model call and refit unclear cutoffs
bhavsarpratik Oct 9, 2026
cb47e9f
fix: count the model as loaded from its first answer
bhavsarpratik Oct 9, 2026
20f7758
fix: keep low-memory backoff until an item finishes
bhavsarpratik Oct 9, 2026
1bb98e8
docs: drop the stale Work insights flow diagram
bhavsarpratik Oct 9, 2026
aa3a675
fix: give a fresh Ollama time to start and add a turn-on button
bhavsarpratik Oct 9, 2026
b864d38
fix: retry at once when setup finishes and never strand an idle Ollama
bhavsarpratik Oct 9, 2026
c4fe1fd
fix: fall back to the managed Ollama only at the default address
bhavsarpratik Oct 9, 2026
1e889d4
fix: make the turn-on button green and hide it once Work insights are on
bhavsarpratik Oct 9, 2026
f4e2300
fix: give the first request time to load the model
bhavsarpratik Oct 9, 2026
bfa2842
fix: keep cold calls out of the slow-model baseline
bhavsarpratik Oct 9, 2026
28a0da0
feat: make Work insights setup survive weak networks and explain itself
bhavsarpratik Oct 13, 2026
756dada
fix: stop curl on turn-off and close the review findings on setup ret…
bhavsarpratik Oct 13, 2026
772e31e
fix: give up on a stalled curl download so setup retries it
bhavsarpratik Oct 13, 2026
96c71a6
fix: a turn-off-and-on restart also starts retry waits from the shortest
bhavsarpratik Oct 13, 2026
7375109
fix: show setup sizes under 1 GB in MB and today's retry time without…
bhavsarpratik Oct 13, 2026
05d5f5a
fix: pass the worker's error site through to the Work page
bhavsarpratik Oct 13, 2026
62a1473
Merge origin/main into feat/work-insights-v2
bhavsarpratik Oct 13, 2026
f95da4a
fix: restore the current-session hint styles the main merge dropped
bhavsarpratik Oct 13, 2026
c5abf3f
Merge origin/main into feat/work-insights-v2
bhavsarpratik Oct 13, 2026
cb9e113
fix: land Settings deep links on their card after a fresh load
bhavsarpratik Oct 13, 2026
c3d6bae
fix: keep Settings deep links working from the command palette
bhavsarpratik Oct 13, 2026
74e3641
feat: show pushback and cost by reasoning effort for each model
bhavsarpratik Oct 13, 2026
973727d
fix: make effort verdicts need real evidence
bhavsarpratik Oct 13, 2026
e569b2c
fix: make effort model chips switch and keep the architecture guard
bhavsarpratik Oct 13, 2026
dd14b16
feat: trim the Work page and simplify pushback by reasoning effort
bhavsarpratik Oct 13, 2026
83b1145
Merge fix/no-card-spotlight into feat/work-insights-v2
bhavsarpratik Oct 13, 2026
ab2add4
fix(work): make effort cell evidence reachable by keyboard and touch
bhavsarpratik Oct 13, 2026
9177fd9
fix(work): keep effort cell focus through the Work refresh
bhavsarpratik Oct 13, 2026
07b777f
fix(work): keep area labels from reworded default areas until relabeled
bhavsarpratik Oct 13, 2026
d0061e5
feat(work): show requests with no recorded effort as Default
bhavsarpratik Oct 13, 2026
ee59e99
fix(work): limit Default effort to apps that record effort
bhavsarpratik Oct 13, 2026
678118c
Merge remote-tracking branch 'origin/main' into HEAD
bhavsarpratik Oct 13, 2026
06b8bfd
fix(work): label never-labeled requests before relabels; show default…
bhavsarpratik Oct 13, 2026
456e87f
fix(work): base routine live hints on most of the session's spend
bhavsarpratik Oct 13, 2026
d9a3e47
fix(work): count only own-labeled requests in routine live hints
bhavsarpratik Oct 13, 2026
22c5226
docs(work): keep the live suggestions table in one piece
bhavsarpratik Oct 13, 2026
ac51206
fix(work): label a live request after its work, and judge busy by CPU…
bhavsarpratik Oct 13, 2026
19c1d3d
fix(work): hold the newest substantive request, not a short reply
bhavsarpratik Oct 13, 2026
07cc027
fix(work): count a held request only while its row still waits
bhavsarpratik Oct 13, 2026
d2afdd0
fix(settings): enable Work insights Save only for unsaved changes; sp…
bhavsarpratik Oct 13, 2026
aeaccf3
feat(work): say that results fill in as labeling finishes
bhavsarpratik Oct 13, 2026
f3d26e5
fix(work): keep unsaved edits across toggle saves; clearer progress n…
bhavsarpratik Oct 13, 2026
8793fe5
fix(settings): keep unsaved-change notes accurate in Work insights
bhavsarpratik Oct 13, 2026
1f188d0
feat(work): make Automatic the default labeling pace
bhavsarpratik Oct 13, 2026
35ebbc4
fix(work): keep Automatic pace gentle on battery and explain it on sc…
bhavsarpratik Oct 13, 2026
496670e
Merge main into feat/work-insights-v2
bhavsarpratik Oct 13, 2026
83e3612
Merge main into feat/work-insights-v2
bhavsarpratik Oct 15, 2026
8276b02
fix(opencode): read the V2 user prompt from data.text for Work insights
animetauren Oct 14, 2026
dabec87
refactor(opencode): harden the Work insights request read and its tests
animetauren Oct 14, 2026
d7c7c75
docs(architecture): carve the Work request text out of the content-fr…
animetauren Oct 14, 2026
f9bfab3
feat(pi): wire Work insights request and action extraction
animetauren Oct 14, 2026
7df410b
fix(opencode): strip injected harness text from Work insight requests
animetauren Oct 14, 2026
a2cf880
Merge remote-tracking branch 'origin/main' into feat/work-insights-v2
bhavsarpratik Oct 15, 2026
0c22321
fix(opencode): gate Work request extraction on the feature flag
animetauren Oct 15, 2026
711700f
test(opencode): pin that a disabled Work insights never reads the req…
animetauren Oct 15, 2026
4895256
fix(opencode): stop attachment-notice strip from deleting user text
animetauren Oct 15, 2026
4e139a0
fix(opencode): gate Work-insight requests against secrets and accounts
animetauren Oct 15, 2026
906efd6
docs(runtime): describe the redaction gate as it actually behaves
animetauren Oct 15, 2026
3e3db52
fix(pi): bound the account-shape check to the address token
animetauren Oct 15, 2026
062a4dd
fix(pi): keep an explicit account rule on the model-id path
animetauren Oct 15, 2026
026f88e
Merge origin/main into feat/work-insights-v2
bhavsarpratik Oct 22, 2026
c06e09e
test(index): cover work-turn handoff from cold-index workers
bhavsarpratik Oct 22, 2026
ff9953a
Merge remote-tracking branch 'origin/main' into pr63-merge-main-fix
bhavsarpratik Oct 23, 2026
86bd209
test(work): pin the macOS gate in Work tests so Linux CI passes
bhavsarpratik Oct 23, 2026
e495b07
Merge remote-tracking branch 'origin/main' into feat/work-insights-v2
bhavsarpratik Oct 23, 2026
1b5fd2a
Merge origin/main into feat/work-insights-v2
bhavsarpratik Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates
| `tests/contracts/test_design_system.py` | Design-system contract: tokens, scales, contrast, components, dead CSS, provider colors |
| `scripts/style-snapshot.js` | Browser computed-style before/after diff for stylesheet-wide changes |
| `menubar/TokenMeterMenuBar.swift` | Native AppKit companion, preferences, notifications |
| `token_meter/services/work_insights.py` | Opt-in local classifier: queue, pacing, pause, loopback Ollama client, label ledger |
| `token_meter/domain/work.py` | Work page aggregation over cached summaries and content-free labels |
| `token_meter/domain/work_evidence.py` | Per-request cost slices and action evidence for Work insights |
| `token_meter_mcp.py` | Bounded MCP interface; evidence is read-only except explicit confirmed session-budget changes |
| `tests/test_meter.py` | Server, parser, UI-contract, installer, and Swift-source tests |
| `tests/test_mcp_server.py` | MCP contract and privacy tests |
Expand All @@ -54,7 +57,7 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates
| `python3 -m unittest tests.contracts.test_design_system -v` | Check dashboard styling against the design-system contract |
| `PYTHONPYCACHEPREFIX=/private/tmp/token-meter-pycache python3 -m py_compile meter.py token_meter_mcp.py $(find token_meter -type f -name '*.py' -print)` | Compile Python without polluting the repo |
| `node -e "const fs=require('fs');const h=fs.readFileSync('page.html','utf8');const m=h.match(/<script>([\\s\\S]*)<\\/script>/);new Function(m[1]);console.log('js ok')"` | Parse embedded dashboard JavaScript |
| `bash -n scripts/install scripts/install-linux scripts/install-launch-agent scripts/install-systemd-user scripts/run-menubar scripts/run-token-meter-mcp scripts/start-token-meter scripts/uninstall-launch-agent scripts/uninstall-systemd-user scripts/update scripts/update-linux` | Check shell syntax |
| `bash -n scripts/install scripts/install-linux scripts/install-launch-agent scripts/install-systemd-user scripts/run-menubar scripts/run-token-meter-mcp scripts/start-token-meter scripts/uninstall-launch-agent scripts/uninstall-systemd-user scripts/update scripts/update-linux scripts/setup-work-classifier` | Check shell syntax |
| `swiftc menubar/TokenMeterMenuBar.swift -o /private/tmp/token-meter-menubar` | Compile the native companion |
| `TOKEN_METER_MENUBAR_SMOKE=1 /private/tmp/token-meter-menubar` | Run deterministic native smoke output |
| `powershell -NoProfile -Command "[void] [scriptblock]::Create((Get-Content -Raw scripts/install-windows.ps1))"` | Parse a Windows script on a Windows host |
Expand All @@ -74,14 +77,15 @@ Token Meter reads local agent traces, calculates clearly labeled usage estimates
- Keep `meter.py` and `token_meter_mcp.py` on the Python standard library.
- Keep the dashboard local-only; do not add hosted assets, analytics, or telemetry.
- Never output, commit, persist, or transmit prompts, responses, reasoning, tool contents, credentials, account data, or raw traces.
- Work insights are the only sanctioned consumer of typed user-turn text, the preceding assistant-reply tail (600 characters), and, per request, the basenames of files the agent changed: they stay in the bounded in-memory queue, are sent only to the validated loopback Ollama URL, and only salted keys, labels, and content-free counts (costs, tool and command kinds, file categories) persist. Do not add other consumers.
- Provider-account requests must remain narrow, bounded, sanitized, and read-only.
- Cursor databases, transcripts, and request logs are read-only inputs.
- Label estimates as estimates. Unavailable evidence must not become a measured zero.
- Scope models by runtime when aggregating; identical model names in different clients are not one history.
- Persist machine-wide settings through the existing atomic JSON-write path and action-token-protected HTTP endpoints.
- New settings require validation, idempotent writes, migration behavior, and tests.
- Preserve legacy hash routes and stored preferences when changing navigation or native settings.
- Keep the top-level dashboard order `Sessions → Spend → Models → Subagents → Efficiency → Git → Learn → Tools → Settings`. The top-level Subagents page owns only role economics. Sessions contains `Current sessions`, `All sessions`, `Compare`, and `Subagents`; Compare (`#sessions-compare`) owns side-by-side comparison of up to four selected sessions, and Subagents owns child-run `Sessions` and `Issues` investigation. Preserve both `#subagents` and `#sessions-subagents` routes.
- Keep the top-level dashboard order `Sessions → Spend → Models → Subagents → Efficiency → Work → Git → Learn → Tools → Settings`. The top-level Subagents page owns only role economics. Sessions contains `Current sessions`, `All sessions`, `Compare`, and `Subagents`; Compare (`#sessions-compare`) owns side-by-side comparison of up to four selected sessions, and Subagents owns child-run `Sessions` and `Issues` investigation. Preserve both `#subagents` and `#sessions-subagents` routes.
- Global is not a dashboard surface. Keep cross-session aggregation as shared backend data for Sessions All, Daily, Models, Tools, Efficiency, MCP, and the menu bar.
- Keep the complete machine-wide monthly budget status and editor on the Spend page (`#spend-budgets`). The native companion deep-links there; preserve `#settings-budgets` and `#budgets` as compatibility redirects.
- Use macOS labels such as `⌥`, never `Alt`, in user-facing copy.
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,48 @@ Spot high-output, failing, repeated, or unused tools and skill packs.
<img src="images/tool-analytics.png" alt="Token Meter capability evidence and skill-pack review" width="900">
</p>

### Work

**Work** shows what the spend went into. It is available on macOS only. When
you turn on **Settings → Work insights**, a local model
([Gemma 4 E4B](https://huggingface.co/google/gemma-4-E4B-it-qat-q4_0-gguf), Google's
official 4-bit quantization-aware build, Apache-2.0) running in your own Ollama labels each request's area,
work type, and complexity, and flags follow-up turns where you pushed back on
the previous work. The page shows:

- **Right-sizing**: suggestions for spending less on models without losing
results, such as a cheaper model that resolves the same kind of work as often,
a standard model for routine work, lower reasoning effort, or starting fresh
sessions sooner. Each shows an estimated saving.
- **Where the spend went** by area, with a trend line per area, and **how
sessions ended**: accepted, recovered after pushback, or ended on pushback.
- **Session tags** such as Marathon, Long thread, Big spender, Subagent team, Overkill,
Rescued, and One-shot, each with its spend and resolved share.
- **When you work**: session starts by weekday and hour, and pushback by time
of day.
- **Pushback over time** and **cost per resolved session** by kind of work.
- **Model choices**: a scorecard of your top models by spend.

Turning Work insights on sets everything up in the background. If this Mac has
no Ollama 0.34 or newer running, Token Meter downloads a pinned, signed Ollama
0.34.4 into its own Application Support folder and runs it on 127.0.0.1 only.
It then downloads the Gemma 4 E4B model (a 4-bit file of about 5 GB, checked
against a pinned hash), adds it to Ollama, and deletes the download. Setup needs
about 11 GB free while it runs and about 5 GB after, and the model uses about
5.5 GB of memory while it labels. When memory runs low, labeling unloads the
model and waits until there is room again; Macs with less than about 10 GB of
memory do not load it at all. If an update changes the model,
Token Meter asks on the Work page before downloading it. Progress shows on the
Work page; turning Work insights off stops the managed Ollama, and uninstalling
Token Meter removes it.

Labeling is off by default. When on, it runs in the background at an automatic
pace: up to 60 model calls a minute while the CPU is mostly idle and your Mac is
plugged in, and 5 a minute otherwise (or pick a fixed pace). Long fast stretches
keep the GPU busy, so the fans may run. It pauses on battery, and can be paused from the page,
Settings, or the menu bar. Labels are estimates; low-confidence answers show as
Unclear.

### Git

Pairs local pushes with spend to show code changed per project and day, using
Expand Down Expand Up @@ -178,6 +220,8 @@ See the current run without opening the dashboard. Updates are checked every
- Traces, prompts, responses, paths, and analytics never leave your machine.
- The MCP returns derived numbers only. Agents you connect may send them to their model provider.
- Costs are estimates. Codex uses public API rates, which can differ from subscription billing.
- Work insights, when on, send typed prompts, the end of the reply before each one, uploaded and
changed file names only to the local Ollama you choose; Token Meter stores labels and counts, never text.

Full details: [User guide](specs/USER_GUIDE.md#data-and-evidence) ·
[Security policy](specs/SECURITY.md).
Expand Down
122 changes: 122 additions & 0 deletions menubar/TokenMeterMenuBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ private let tokenMeterBudgetSettingsURL = URL(string: "http://127.0.0.1:8722/#sp
private let tokenMeterGoalsURL = URL(string: "http://127.0.0.1:8722/#efficiency-goals")!
private let tokenMeterUpdateSettingsURL = URL(string: "http://127.0.0.1:8722/#settings-updates")!
private let tokenMeterInstallUpdateURL = URL(string: "http://127.0.0.1:8722/updates/install")!
private let tokenMeterWorkPauseURL = URL(string: "http://127.0.0.1:8722/work-insights/pause")!
private let tokenMeterWorkSettingsURL = URL(string: "http://127.0.0.1:8722/#settings-work-insights")!
private let tokenMeterEnterpriseTokenomicsURL = URL(string: "https://www.splunk.com/en_us/products/tokenomics.html")!
private let pinnedSessionDefaultsKey = "TokenMeterPinnedSessionID"
private let titleModeDefaultsKey = "TokenMeterTitleMode"
Expand All @@ -18,6 +20,8 @@ private let quotaAlertThresholdDefaultsKey = "TokenMeterQuotaAlertThreshold"
private let quotaNotificationStatesDefaultsKey = "TokenMeterQuotaNotificationStates"
private let budgetNotificationStatesDefaultsKey = "TokenMeterBudgetNotificationStates"
private let budgetExceededMonthsDefaultsKey = "TokenMeterBudgetExceededNotificationMonths"
private let liveHintNotificationIDsDefaultsKey = "TokenMeterLiveHintNotificationIDs"
private let maxRememberedLiveHintIDs = 200
private let statusDisplayModeDefaultsKey = "TokenMeterStatusDisplayMode"
private let globalShortcutDefaultsKey = "TokenMeterGlobalShortcut"
private let customShortcutKeyCodeDefaultsKey = "TokenMeterCustomShortcutKeyCode"
Expand Down Expand Up @@ -757,6 +761,9 @@ struct MeterSnapshot {
var todaySpendAvailable: Bool
var todaySpendTotalCost: Double
var todaySpendEstimated: Bool
var workInsightsEnabled: Bool
var workInsightsPaused: Bool
var workInsightsState: String
var estimatedCost: Bool
var estimatedTokens: Bool
var totalTokens: Int
Expand Down Expand Up @@ -805,6 +812,9 @@ struct MeterSnapshot {
todaySpendAvailable: false,
todaySpendTotalCost: 0,
todaySpendEstimated: false,
workInsightsEnabled: false,
workInsightsPaused: false,
workInsightsState: "",
estimatedCost: false,
estimatedTokens: false,
totalTokens: 0,
Expand Down Expand Up @@ -857,6 +867,10 @@ struct MeterSnapshot {
let todaySpendAvailable = bool(todaySpend["available"])
let todaySpendTotalCost = double(todaySpend["total_cost"])
let todaySpendEstimated = bool(todaySpend["estimated"])
let workInsights = dict["work_insights"] as? [String: Any] ?? [:]
let workInsightsEnabled = bool(workInsights["enabled"])
let workInsightsPaused = bool(workInsights["paused"])
let workInsightsState = workInsights["state"] as? String ?? ""
let estimatedCost = bool(dict["cost_approx"]) || bool(source["approximate_cost"])
let estimatedTokens = bool(source["token_estimate"])
let totalTokens = int(dict["total_tokens"])
Expand Down Expand Up @@ -945,6 +959,9 @@ struct MeterSnapshot {
todaySpendAvailable: todaySpendAvailable,
todaySpendTotalCost: todaySpendTotalCost,
todaySpendEstimated: todaySpendEstimated,
workInsightsEnabled: workInsightsEnabled,
workInsightsPaused: workInsightsPaused,
workInsightsState: workInsightsState,
estimatedCost: estimatedCost,
estimatedTokens: estimatedTokens,
totalTokens: totalTokens,
Expand Down Expand Up @@ -1153,6 +1170,11 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var menuRefreshPending = false
private var snapshot = MeterSnapshot.disconnected("Waiting for http://127.0.0.1:8722/menubar")
private var monthlyBudget: MonthlyBudget?
private var deliveredLiveHintIDs: [String] =
tokenMeterDefaults.stringArray(forKey: liveHintNotificationIDsDefaultsKey) ?? []
// The first poll ever only records what is already showing, so an update never sends a burst.
private var liveHintsSeeded = tokenMeterDefaults.object(forKey: liveHintNotificationIDsDefaultsKey) != nil
private var liveHintsWereEnabled = false
private var goals: GoalsSnapshot?
private var softwareUpdate = SoftwareUpdateSnapshot.waiting

Expand Down Expand Up @@ -1252,6 +1274,10 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
)
self.evaluateQuotaNotifications()
self.evaluateBudgetNotifications()
self.evaluateLiveHintNotifications(
dict["live_hints"] as? [[String: Any]] ?? [],
enabled: dict["live_hints_enabled"] as? Bool ?? false
)
self.refreshMenu()
}
}.resume()
Expand Down Expand Up @@ -1311,6 +1337,10 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
budgetsItem.submenu = makeBudgetsMenu()
menu.addItem(budgetsItem)

if snapshot.workInsightsEnabled {
menu.addItem(makeWorkInsightsItem())
}

let goalsItem = NSMenuItem(title: "Goals", action: nil, keyEquivalent: "")
goalsItem.image = menuSymbol("target", description: "Goals")
goalsItem.submenu = makeGoalsMenu()
Expand Down Expand Up @@ -2375,6 +2405,35 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}

/// One notification per running session and suggestion; the server only sends hints when
/// "Notify me about live suggestions" is on in Settings.
private func evaluateLiveHintNotifications(_ hints: [[String: Any]], enabled: Bool) {
// Turning notifications on (or Work insights) records what is already showing instead of
// announcing every running session at once.
if enabled && !liveHintsWereEnabled { liveHintsSeeded = false }
liveHintsWereEnabled = enabled
var changed = !liveHintsSeeded
for hint in hints.prefix(12) {
guard let id = hint["id"] as? String, !id.isEmpty,
let title = hint["title"] as? String,
let body = hint["body"] as? String,
!deliveredLiveHintIDs.contains(id)
else { continue }
if liveHintsSeeded {
deliverQuotaNotification(title: "Token Meter: \(title)", body: body)
}
deliveredLiveHintIDs.append(id)
changed = true
}
if deliveredLiveHintIDs.count > maxRememberedLiveHintIDs {
deliveredLiveHintIDs.removeFirst(deliveredLiveHintIDs.count - maxRememberedLiveHintIDs)
}
liveHintsSeeded = true
if changed {
tokenMeterDefaults.set(deliveredLiveHintIDs, forKey: liveHintNotificationIDsDefaultsKey)
}
}

private func deliverQuotaNotification(title: String, body: String) {
if ProcessInfo.processInfo.environment["TOKEN_METER_MENUBAR_SMOKE"] == "1" { return }
let process = Process()
Expand Down Expand Up @@ -2412,6 +2471,68 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
NSWorkspace.shared.open(tokenMeterUpdateSettingsURL)
}

private func makeWorkInsightsItem() -> NSMenuItem {
if snapshot.workInsightsPaused {
let item = NSMenuItem(title: "Resume work insights", action: #selector(resumeWorkInsights), keyEquivalent: "")
item.image = menuSymbol("play.circle", description: "Resume work insights")
item.target = self
return item
}
let item = NSMenuItem(title: "Pause work insights", action: nil, keyEquivalent: "")
item.image = menuSymbol("pause.circle", description: "Pause work insights")
let submenu = NSMenu()
for (title, duration) in [("For 1 hour", "1h"), ("Until tomorrow", "tomorrow"), ("Until I resume", "indefinite")] {
let option = NSMenuItem(title: title, action: #selector(pauseWorkInsights(_:)), keyEquivalent: "")
option.representedObject = duration
option.target = self
submenu.addItem(option)
}
submenu.addItem(.separator())
let settings = NSMenuItem(title: "Work insights settings", action: #selector(openWorkInsightsSettings), keyEquivalent: "")
settings.target = self
submenu.addItem(settings)
item.submenu = submenu
return item
}

@objc private func pauseWorkInsights(_ sender: NSMenuItem) {
guard let duration = sender.representedObject as? String else { return }
postWorkInsightsPause(duration)
}

@objc private func resumeWorkInsights() {
postWorkInsightsPause("resume")
}

@objc private func openWorkInsightsSettings() {
NSWorkspace.shared.open(tokenMeterWorkSettingsURL)
}

private func postWorkInsightsPause(_ duration: String) {
guard !softwareUpdate.actionToken.isEmpty,
let body = try? JSONSerialization.data(withJSONObject: ["duration": duration]) else {
openWorkInsightsSettings()
return
}
var request = URLRequest(url: tokenMeterWorkPauseURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 5.0)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(softwareUpdate.actionToken, forHTTPHeaderField: "X-Token-Meter-Action")
request.httpBody = body
URLSession.shared.dataTask(with: request) { [weak self] _, response, error in
DispatchQueue.main.async {
guard let self = self else { return }
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
if error == nil && (200..<300).contains(statusCode) {
self.snapshot.workInsightsPaused = duration != "resume"
self.refreshMenu()
} else {
self.openWorkInsightsSettings()
}
}
}.resume()
}

@objc private func openEnterpriseTokenomics() {
NSWorkspace.shared.open(tokenMeterEnterpriseTokenomicsURL)
}
Expand Down Expand Up @@ -2691,6 +2812,7 @@ if ProcessInfo.processInfo.environment["TOKEN_METER_MENUBAR_SMOKE"] == "1" {
print(snapshot.outputSpeedLabel)
print("active-title=\(activeTitle)")
print("budget-state=\(budget?.compactLabel ?? "unconfigured") exceeded=\(budget?.anyExceeded == true)")
print("work-insights=\(snapshot.workInsightsEnabled ? (snapshot.workInsightsPaused ? "paused" : "active") : "off") state=\(snapshot.workInsightsState.isEmpty ? "none" : snapshot.workInsightsState)")
let goals = GoalsSnapshot.fromJSON(dict["goals"] as? [String: Any])
print("goals=\(goals?.items.count ?? 0) more=\(goals?.additionalCount ?? 0) ready=\(goals?.ready == true) statuses=\(goals?.items.map(\.status).joined(separator: ",") ?? "")")
print("title-metrics=\(TitleMetric.allCases.filter(savedMetrics.contains).map(\.title).joined(separator: ","))")
Expand Down
Loading
Loading