@brimveyn/aimux 1.22.11 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +5 -0
  3. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  4. package/src/app.tsx +6 -0
  5. package/src/index.tsx +6 -0
  6. package/src/input/keymap/help-entries.ts +1 -0
  7. package/src/input/modes/bridge.ts +2 -1
  8. package/src/input/modes/transitions.ts +7 -3
  9. package/src/input/modes/types.ts +2 -1
  10. package/src/restart-daemon.ts +5 -0
  11. package/src/services/ai-usage/projection.ts +44 -0
  12. package/src/services/aimux-counters/index.ts +87 -0
  13. package/src/services/aimux-counters/observe.ts +39 -0
  14. package/src/services/aimux-counters/store.ts +150 -0
  15. package/src/services/aimux-counters/summary.ts +78 -0
  16. package/src/services/usage-history/cost.ts +149 -0
  17. package/src/services/usage-history/insights.ts +314 -0
  18. package/src/services/usage-history/rollup.ts +78 -6
  19. package/src/services/usage-history/stats.ts +66 -35
  20. package/src/services/usage-history/store.ts +128 -9
  21. package/src/settings/sections/about.ts +1 -0
  22. package/src/settings/sections/appearance.ts +1 -0
  23. package/src/settings/sections/automation.ts +1 -0
  24. package/src/settings/sections/commands.ts +1 -0
  25. package/src/settings/sections/editor.ts +1 -0
  26. package/src/settings/sections/experimental.ts +1 -0
  27. package/src/settings/sections/git.ts +1 -0
  28. package/src/settings/sections/integrations.ts +1 -0
  29. package/src/settings/sections/layout.ts +1 -0
  30. package/src/settings/sections/notifications.ts +1 -0
  31. package/src/settings/sections/setup.ts +1 -0
  32. package/src/settings/sections/status-bar.ts +1 -0
  33. package/src/settings/sections/workspace.ts +1 -0
  34. package/src/settings/types.ts +10 -0
  35. package/src/state/actions.ts +12 -1
  36. package/src/state/app-store.ts +12 -1
  37. package/src/state/reducers/modal-state.ts +12 -17
  38. package/src/state/reducers/stats-state.ts +56 -0
  39. package/src/state/stats-pages.ts +33 -0
  40. package/src/state/store.ts +5 -0
  41. package/src/state/types.ts +17 -4
  42. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  43. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  44. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  45. package/src/ui/components/settings/settings-view.tsx +6 -3
  46. package/src/ui/components/stats/aimux-page.tsx +245 -0
  47. package/src/ui/components/stats/chart.ts +157 -0
  48. package/src/ui/components/stats/day-facts.tsx +268 -0
  49. package/src/ui/components/stats/format.ts +98 -0
  50. package/src/ui/components/stats/heatmap.tsx +305 -0
  51. package/src/ui/components/stats/projects-page.tsx +293 -0
  52. package/src/ui/components/stats/quotas.tsx +210 -0
  53. package/src/ui/components/stats/shared.tsx +645 -0
  54. package/src/ui/components/stats/stats-view.tsx +153 -0
  55. package/src/ui/components/stats/usage-page.tsx +291 -0
  56. package/src/ui/components/stats/use-stats-data.ts +48 -0
  57. package/src/ui/root.tsx +7 -3
  58. package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.11",
3
+ "version": "1.23.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.10.0",
69
+ "@brimveyn/aimux-config": "0.10.2",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -8,6 +8,7 @@ import { logInputDebug } from '../debug/input-log'
8
8
  import { enqueueGitOp } from '../git/command-queue'
9
9
  import { countDirtyFiles } from '../git/move-workspace'
10
10
  import { getCurrentBranch, getDefaultBranch, listLocalBranches } from '../git/worktree'
11
+ import { countEffect } from '../services/aimux-counters/observe'
11
12
  import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
12
13
  import { saveCurrentProject } from '../state/project-save'
13
14
  import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
@@ -212,6 +213,10 @@ function hoistBranch(branches: string[], first: string | undefined): string[] {
212
213
  export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): void {
213
214
  const { backend, dispatch, state } = ctx
214
215
 
216
+ // Every effect path funnels through here, including the mouse and IPC ones,
217
+ // so this is the one place that sees them all.
218
+ countEffect(effect)
219
+
215
220
  switch (effect.type) {
216
221
  case 'quit': {
217
222
  saveCurrentProject(effect.state)
@@ -12,6 +12,7 @@ import { logInputDebug } from '../debug/input-log'
12
12
  import { MultiClickDetector } from '../input/multi-click-detector'
13
13
  import { extractStreamText, getLineText } from '../input/terminal-text-extraction'
14
14
  import { copyToSystemClipboard } from '../platform/clipboard'
15
+ import { bump } from '../services/aimux-counters'
15
16
  import {
16
17
  type ClickSelectionResult,
17
18
  computeRangeFromLineText,
@@ -306,6 +307,7 @@ export function useMouseHandlers({
306
307
  return
307
308
  }
308
309
 
310
+ bump('scrollLines', Math.abs(delta))
309
311
  backend.scrollViewport(targetTabId, delta)
310
312
  }
311
313
 
package/src/app.tsx CHANGED
@@ -38,6 +38,7 @@ import { highlightSnapshot, warmClaudeSyntaxOverlay } from './integrations/claud
38
38
  import { ensureClaudeSettingsThemePref, syncClaudeTheme } from './integrations/claude-theme-sync'
39
39
  import { getProfileConfigDir, getProfileName } from './profile-paths'
40
40
  import { startAIUsageService } from './services/ai-usage/provider'
41
+ import { bump } from './services/aimux-counters'
41
42
  import {
42
43
  useAIUsageConfig,
43
44
  useAutoCommitConfig,
@@ -553,6 +554,11 @@ export function App({
553
554
  }, [flashPendingJump])
554
555
 
555
556
  useKeyboard((key) => {
557
+ // Every key in every mode passes here, terminal-input included, which makes
558
+ // it the one honest place to count them. A count and nothing else — no key
559
+ // identity is recorded anywhere.
560
+ bump('keys')
561
+
556
562
  const currentState = stateRef.current
557
563
  // Global quit: Ctrl+C in any mode except terminal-input
558
564
  if (key.ctrl && key.name === 'c' && currentState.focusMode !== 'terminal-input') {
package/src/index.tsx CHANGED
@@ -95,6 +95,7 @@ const [
95
95
  { createSessionBackend },
96
96
  { maybeAutoInstallCompletion },
97
97
  { maybeSpawnUsageRollup },
98
+ { startCounters },
98
99
  ] = await Promise.all([
99
100
  import('@opentui/react'),
100
101
  import('./app'),
@@ -104,6 +105,7 @@ const [
104
105
  import('./session-backend/bootstrap'),
105
106
  import('./cli/completion/install'),
106
107
  import('./services/usage-history/store'),
108
+ import('./services/aimux-counters'),
107
109
  ])
108
110
  const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
109
111
 
@@ -119,6 +121,10 @@ maybeAutoInstallCompletion()
119
121
  // blocking. Opt out with AIMUX_NO_USAGE_ROLLUP=1.
120
122
  maybeSpawnUsageRollup()
121
123
 
124
+ // Start counting what aimux knows about its own use — uptime, keystrokes,
125
+ // workspaces, tabs. Local file, counts only, flushed on a timer and on exit.
126
+ startCounters()
127
+
122
128
  const renderer = await createCliRenderer({
123
129
  autoFocus: true,
124
130
  // Transparent clear color so cells untouched by BoxRenderable paints (e.g.
@@ -12,6 +12,7 @@ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
12
12
  { label: 'Terminal input', modeId: 'terminal-input' },
13
13
  { label: 'Git mode', modeId: 'git-mode' },
14
14
  { label: 'Settings', modeId: 'settings' },
15
+ { label: 'Stats', modeId: 'stats' },
15
16
  { label: 'Git commit', modeId: 'modal.git-commit' },
16
17
  { label: 'New tab', modeId: 'modal.new-tab.command-edit' },
17
18
  { label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
@@ -7,6 +7,7 @@ const DIRECT_FOCUS_MODE_IDS: Partial<Record<FocusMode, ModeId>> = {
7
7
  'git': 'git-mode',
8
8
  'navigation': 'navigation',
9
9
  'settings': 'settings',
10
+ 'stats': 'stats',
10
11
  'terminal-input': 'terminal-input',
11
12
  }
12
13
 
@@ -29,7 +30,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
29
30
  }
30
31
 
31
32
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
32
- 'ai-usage': 'modal.ai-usage',
33
+ 'quotas': 'modal.quotas',
33
34
  'update-available': 'modal.update-available',
34
35
  'workspace-delete-confirm': 'modal.workspace-delete-confirm',
35
36
  'workspace-move': 'modal.workspace-move',
@@ -2,7 +2,6 @@ import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
4
  'git-mode': ['navigation', 'modal.git-commit', 'modal.workspace-move'],
5
- 'modal.ai-usage': ['navigation', 'terminal-input'],
6
5
  'modal.create-project': ['navigation', 'modal.project-picker.filtering'],
7
6
  'modal.create-workspace': ['navigation', 'terminal-input'],
8
7
  'modal.flash-jump': ['navigation'],
@@ -14,6 +13,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
14
13
  'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
15
14
  'modal.project-name': ['modal.project-picker.filtering', 'navigation'],
16
15
  'modal.project-picker.filtering': ['navigation', 'modal.project-name', 'modal.create-project'],
16
+ 'modal.quotas': ['navigation', 'terminal-input'],
17
17
  'modal.rename-tab': ['navigation'],
18
18
  'modal.rename-workspace': ['navigation'],
19
19
  'modal.setting-text': ['settings'],
@@ -40,12 +40,13 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
40
40
  'modal.rename-tab',
41
41
  'modal.rename-workspace',
42
42
  'modal.update-available',
43
- 'modal.ai-usage',
43
+ 'modal.quotas',
44
44
  'modal.workspace-delete-confirm',
45
45
  'modal.workspace-move-confirm',
46
46
  'modal.flash-jump',
47
47
  'git-mode',
48
48
  'settings',
49
+ 'stats',
49
50
  ],
50
51
  // The help overlay opens over settings without a transition (it leaves
51
52
  // focusMode alone). The two pickers an action row hands over to are dispatched
@@ -58,7 +59,10 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
58
59
  'modal.theme-picker.filtering',
59
60
  'modal.snippet-picker.filtering',
60
61
  ],
61
- 'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage', 'settings'],
62
+ // Read-only screen: the only way out is back to the panes. The help overlay
63
+ // opens on top of it without a transition, the same way it does over settings.
64
+ 'stats': ['navigation'],
65
+ 'terminal-input': ['navigation', 'modal.split-picker', 'modal.quotas', 'settings', 'stats'],
62
66
  }
63
67
 
64
68
  export function isValidTransition(from: ModeId, to: ModeId): boolean {
@@ -30,9 +30,10 @@ export type ModeId =
30
30
  | 'modal.update-available'
31
31
  | 'modal.workspace-move'
32
32
  | 'modal.workspace-move-confirm'
33
- | 'modal.ai-usage'
34
33
  | 'modal.flash-jump'
34
+ | 'modal.quotas'
35
35
  | 'settings'
36
+ | 'stats'
36
37
 
37
38
  export type SideEffect =
38
39
  | { type: 'quit'; state: AppState }
@@ -6,8 +6,13 @@ import {
6
6
  spawnDaemonReexec,
7
7
  spawnDetachedIpcDaemon,
8
8
  } from './platform/daemon-control'
9
+ import { recordOnce } from './services/aimux-counters'
9
10
 
10
11
  export async function runRestartDaemon(): Promise<number> {
12
+ // Written immediately: this runs as its own short-lived process, so there is
13
+ // no flush loop to hand it to.
14
+ recordOnce('daemonRestarts')
15
+
11
16
  const socketPath = getDaemonSocketPath()
12
17
  const pid = await findIpcDaemonPid()
13
18
 
@@ -0,0 +1,44 @@
1
+ import type { UsageWindow } from './types'
2
+
3
+ /**
4
+ * Where a quota window lands if the current rate holds.
5
+ *
6
+ * Pure, and derived only from what the provider already reports: `percent`,
7
+ * `windowSeconds` and `resetAt` give the elapsed fraction, which is all a linear
8
+ * projection needs. Nothing here parses `timeRemaining` — that is a display
9
+ * string, and a projection built on parsing one breaks the day its wording does.
10
+ */
11
+
12
+ export type ProjectionVerdict =
13
+ /** The window resets before the quota runs out at this rate. */
14
+ | { kind: 'lasts' }
15
+ /** Not enough of the window has elapsed to say anything honest. */
16
+ | { kind: 'unknown' }
17
+ | { kind: 'exhausted'; at: Date }
18
+
19
+ /** Below this, one burst early in a window projects to nonsense. */
20
+ const MIN_ELAPSED_FRACTION = 0.05
21
+
22
+ export function projectWindow(window: UsageWindow, now: Date): ProjectionVerdict {
23
+ const { percent, resetAt, windowSeconds } = window
24
+ if (percent === null || resetAt === null || windowSeconds === null || windowSeconds <= 0) {
25
+ return { kind: 'unknown' }
26
+ }
27
+
28
+ const resetMs = Date.parse(resetAt)
29
+ if (!Number.isFinite(resetMs)) return { kind: 'unknown' }
30
+
31
+ const remainingSeconds = (resetMs - now.getTime()) / 1000
32
+ const elapsedSeconds = windowSeconds - remainingSeconds
33
+ if (remainingSeconds <= 0) return { kind: 'unknown' }
34
+ if (elapsedSeconds / windowSeconds < MIN_ELAPSED_FRACTION) return { kind: 'unknown' }
35
+
36
+ if (percent >= 100) return { at: now, kind: 'exhausted' }
37
+ const perSecond = percent / elapsedSeconds
38
+ if (perSecond <= 0) return { kind: 'lasts' }
39
+
40
+ const secondsToFull = (100 - percent) / perSecond
41
+ if (secondsToFull >= remainingSeconds) return { kind: 'lasts' }
42
+
43
+ return { at: new Date(now.getTime() + secondsToFull * 1000), kind: 'exhausted' }
44
+ }
@@ -0,0 +1,87 @@
1
+ import {
2
+ type CounterDeltas,
3
+ type CounterKey,
4
+ type MaxCounters,
5
+ saveCounters,
6
+ todayKey,
7
+ } from './store'
8
+
9
+ /**
10
+ * The write side of the aimux counters: accumulate in memory, fold into the file
11
+ * on a timer.
12
+ *
13
+ * `bump` is on the keystroke path, so it does nothing but add to a plain object —
14
+ * no date formatting, no I/O, no allocation.
15
+ */
16
+
17
+ const FLUSH_INTERVAL_MS = 30_000
18
+
19
+ let pending: CounterDeltas = {}
20
+ let flushTimer: ReturnType<typeof setInterval> | null = null
21
+ let runStartMs = 0
22
+ let lastAccrualMs = 0
23
+
24
+ export function bump(key: CounterKey, amount = 1): void {
25
+ if (amount === 0) return
26
+ pending[key] = (pending[key] ?? 0) + amount
27
+ }
28
+
29
+ /**
30
+ * Folds everything pending into the file.
31
+ *
32
+ * On failure the pending deltas are kept rather than dropped, so a transient
33
+ * write error costs a flush interval instead of the counts.
34
+ *
35
+ * ponytail: the whole batch is attributed to the day it is flushed on, so up to
36
+ * one interval of activity can land on the wrong side of midnight. Per-bump day
37
+ * resolution would fix it and would put a date format on the keystroke path;
38
+ * tighten it only if a day boundary ever matters more than that.
39
+ */
40
+ export function flushCounters(now = Date.now()): void {
41
+ if (runStartMs === 0) return
42
+
43
+ const elapsed = Math.max(0, now - lastAccrualMs)
44
+ const deltas: CounterDeltas = { ...pending }
45
+ if (elapsed > 0) deltas.uptimeMs = (deltas.uptimeMs ?? 0) + elapsed
46
+ const maxima: MaxCounters = { longestRunMs: now - runStartMs }
47
+
48
+ // `elapsed` is already folded into `deltas` above, so this covers the idle
49
+ // case too: nothing pending and no time accrued means nothing to write.
50
+ if (!Object.values(deltas).some((value) => value !== 0)) return
51
+
52
+ if (saveCounters(todayKey(new Date(now)), deltas, maxima)) {
53
+ pending = {}
54
+ lastAccrualMs = now
55
+ }
56
+ }
57
+
58
+ /** Starts the flush loop and arranges a final flush on exit. Safe to call twice. */
59
+ export function startCounters(now = Date.now()): void {
60
+ if (flushTimer !== null) return
61
+ runStartMs = now
62
+ lastAccrualMs = now
63
+ bump('runsStarted')
64
+
65
+ flushTimer = setInterval(() => {
66
+ flushCounters()
67
+ }, FLUSH_INTERVAL_MS)
68
+ // Never hold the process open for a counter.
69
+ flushTimer.unref?.()
70
+
71
+ // Synchronous write, which is the only kind `exit` can run. A SIGKILL still
72
+ // costs at most one interval.
73
+ process.on('exit', () => {
74
+ flushCounters()
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Records one increment and writes it straight away.
80
+ *
81
+ * For the short-lived CLI subcommands (`aimux restart-daemon`), which have no
82
+ * flush loop and exit before any timer would fire. The write is additive like
83
+ * every other, so it composes with a TUI running at the same time.
84
+ */
85
+ export function recordOnce(key: CounterKey, amount = 1, now = Date.now()): void {
86
+ saveCounters(todayKey(new Date(now)), { [key]: amount }, {})
87
+ }
@@ -0,0 +1,39 @@
1
+ import type { SideEffect } from '../../input/modes/types'
2
+ import type { AppAction } from '../../state/actions'
3
+
4
+ import { bump } from './index'
5
+
6
+ /**
7
+ * Translates dispatched actions and side effects into counters.
8
+ *
9
+ * One place rather than a `bump()` sprinkled through the reducers: reducers stay
10
+ * pure, and what aimux counts about itself is readable in a single switch
11
+ * instead of being spread across the state layer.
12
+ */
13
+
14
+ export function countAction(action: AppAction): void {
15
+ switch (action.type) {
16
+ case 'add-tab':
17
+ bump('tabsOpened')
18
+ break
19
+ case 'add-workspace-record':
20
+ bump('workspacesCreated')
21
+ break
22
+ case 'split-pane':
23
+ bump(action.direction === 'vertical' ? 'splitsVertical' : 'splitsHorizontal')
24
+ break
25
+ default:
26
+ break
27
+ }
28
+ }
29
+
30
+ export function countEffect(effect: SideEffect): void {
31
+ switch (effect.type) {
32
+ case 'paste-selected-snippet':
33
+ case 'paste-snippet-to-group':
34
+ bump('snippetsFired')
35
+ break
36
+ default:
37
+ break
38
+ }
39
+ }
@@ -0,0 +1,150 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { logDebug } from '../../debug/input-log'
6
+ import { localDay } from '../usage-history/store'
7
+
8
+ /**
9
+ * What aimux knows about its own use, per local calendar day.
10
+ *
11
+ * Deliberately counts and nothing else. `keys` is a number of key presses with
12
+ * no key identity and no content attached — which is also why there is no
13
+ * per-keybind breakdown: that would mean recording *which* key, a different
14
+ * privacy posture for a stat nobody asked for.
15
+ *
16
+ * Nothing here leaves the machine.
17
+ */
18
+
19
+ export const COUNTERS_VERSION = 1
20
+ const UNREADABLE_VERSION = -1
21
+
22
+ export type CounterKey =
23
+ | 'daemonRestarts'
24
+ | 'keys'
25
+ /** aimux launches. Distinct from `tabsOpened`: a tab is a session inside one run. */
26
+ | 'runsStarted'
27
+ | 'scrollLines'
28
+ | 'snippetsFired'
29
+ | 'splitsHorizontal'
30
+ | 'splitsVertical'
31
+ | 'tabsOpened'
32
+ | 'uptimeMs'
33
+ | 'workspacesCreated'
34
+
35
+ /**
36
+ * Counters that take the larger of the two values instead of the sum.
37
+ *
38
+ * `longestRunMs` is the length of a single uninterrupted aimux run, so adding
39
+ * two instances' values would invent a run neither of them had.
40
+ */
41
+ export type MaxCounterKey = 'longestRunMs'
42
+
43
+ export type CounterDeltas = Partial<Record<CounterKey, number>>
44
+ export type MaxCounters = Partial<Record<MaxCounterKey, number>>
45
+
46
+ export type CounterDay = Partial<Record<CounterKey | MaxCounterKey, number>>
47
+ /** 'YYYY-MM-DD' in the machine's local calendar -> that day's counters. */
48
+ export type CounterDays = Record<string, CounterDay>
49
+
50
+ export interface CountersFile {
51
+ days: CounterDays
52
+ version: number
53
+ }
54
+
55
+ const MAX_KEYS: readonly MaxCounterKey[] = ['longestRunMs']
56
+
57
+ function isMaxKey(key: string): key is MaxCounterKey {
58
+ return (MAX_KEYS as readonly string[]).includes(key)
59
+ }
60
+
61
+ /** Resolved per call, not at module scope, so a `HOME` override in tests reaches it. */
62
+ export function countersPath(): string {
63
+ const home = process.env.HOME ?? homedir()
64
+ return join(home, '.config', 'aimux', 'usage-counters.json')
65
+ }
66
+
67
+ export function readCounters(): CountersFile {
68
+ let raw: string
69
+ try {
70
+ raw = readFileSync(countersPath(), 'utf8')
71
+ } catch {
72
+ return { days: {}, version: COUNTERS_VERSION } // no file yet; safe to create
73
+ }
74
+
75
+ try {
76
+ const file = JSON.parse(raw) as Partial<CountersFile>
77
+ if (typeof file.version !== 'number') return { days: {}, version: UNREADABLE_VERSION }
78
+ if (typeof file.days !== 'object' || file.days === null) {
79
+ return { days: {}, version: UNREADABLE_VERSION }
80
+ }
81
+ return { days: file.days, version: file.version }
82
+ } catch {
83
+ return { days: {}, version: UNREADABLE_VERSION }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Folds one day's pending changes into what is already on disk.
89
+ *
90
+ * Additive for every ordinary counter, which is what makes two aimux instances
91
+ * safe to run at once: each flush contributes its own delta rather than
92
+ * overwriting a total it computed from a stale read.
93
+ */
94
+ export function mergeCounterDay(
95
+ stored: CounterDay | undefined,
96
+ deltas: CounterDeltas,
97
+ maxima: MaxCounters
98
+ ): CounterDay {
99
+ const merged: CounterDay = { ...stored }
100
+ for (const [key, value] of Object.entries(deltas)) {
101
+ if (value === 0) continue
102
+ merged[key as CounterKey] = (merged[key as CounterKey] ?? 0) + value
103
+ }
104
+ for (const [key, value] of Object.entries(maxima)) {
105
+ if (!isMaxKey(key)) continue
106
+ merged[key] = Math.max(merged[key] ?? 0, value)
107
+ }
108
+ return merged
109
+ }
110
+
111
+ /**
112
+ * False when nothing was written. The caller keeps its pending deltas in that
113
+ * case, so a transient failure costs a flush interval rather than the counts.
114
+ */
115
+ export function saveCounters(day: string, deltas: CounterDeltas, maxima: MaxCounters): boolean {
116
+ const stored = readCounters()
117
+
118
+ // Same posture as the usage history: a newer aimux owns a shape this build
119
+ // cannot round-trip, and an unreadable file may hold counts nothing can
120
+ // regenerate. Neither gets written over.
121
+ if (stored.version > COUNTERS_VERSION || stored.version < 1) {
122
+ logDebug('counters.refusedWrite', { version: stored.version })
123
+ return false
124
+ }
125
+
126
+ const path = countersPath()
127
+ const file: CountersFile = {
128
+ days: { ...stored.days, [day]: mergeCounterDay(stored.days[day], deltas, maxima) },
129
+ version: COUNTERS_VERSION,
130
+ }
131
+
132
+ try {
133
+ mkdirSync(dirname(path), { recursive: true })
134
+ // pid-suffixed: two instances can flush at once, and a shared tmp name would
135
+ // let one truncate the other's half-written file.
136
+ const tmpPath = `${path}.${process.pid}.tmp`
137
+ writeFileSync(tmpPath, `${JSON.stringify(file, null, 2)}\n`)
138
+ renameSync(tmpPath, path)
139
+ return true
140
+ } catch (error) {
141
+ logDebug('counters.writeError', {
142
+ error: error instanceof Error ? error.message : String(error),
143
+ })
144
+ return false
145
+ }
146
+ }
147
+
148
+ export function todayKey(now: Date = new Date()): string {
149
+ return localDay(now)
150
+ }
@@ -0,0 +1,78 @@
1
+ import type { CounterDays, CounterKey, MaxCounterKey } from './store'
2
+
3
+ import { localDay } from '../usage-history/store'
4
+
5
+ /** Pure shaping of the stored counter days into what the aimux page renders. */
6
+
7
+ export interface CounterPeak {
8
+ day: string
9
+ value: number
10
+ }
11
+
12
+ export interface CounterSummary {
13
+ best: CounterPeak
14
+ today: number
15
+ total: number
16
+ /** Days carrying a non-zero value — the denominator for a daily average. */
17
+ days: number
18
+ }
19
+
20
+ export function summarizeCounter(
21
+ days: CounterDays,
22
+ key: CounterKey,
23
+ today: string
24
+ ): CounterSummary {
25
+ let best: CounterPeak = { day: '', value: 0 }
26
+ let dayCount = 0
27
+ let total = 0
28
+
29
+ for (const [date, day] of Object.entries(days)) {
30
+ const value = day[key] ?? 0
31
+ if (value <= 0) continue
32
+ dayCount += 1
33
+ total += value
34
+ if (value > best.value) best = { day: date, value }
35
+ }
36
+
37
+ return { best, days: dayCount, today: days[today]?.[key] ?? 0, total }
38
+ }
39
+
40
+ /**
41
+ * The largest value ever recorded for a max-merged counter.
42
+ *
43
+ * Not a sum: `longestRunMs` is the length of one uninterrupted aimux run, so the
44
+ * answer across days is the biggest of them, never their total.
45
+ */
46
+ export function peakOf(days: CounterDays, key: MaxCounterKey): CounterPeak {
47
+ let best: CounterPeak = { day: '', value: 0 }
48
+ for (const [date, day] of Object.entries(days)) {
49
+ const value = day[key] ?? 0
50
+ if (value > best.value) best = { day: date, value }
51
+ }
52
+ return best
53
+ }
54
+
55
+ /**
56
+ * One value per calendar day for the `count` days ending today, oldest first.
57
+ *
58
+ * The counters' twin of `lastDays` over the usage history, and unrecorded days
59
+ * are zero for the same reason: a day aimux never ran is a real zero, not a hole.
60
+ */
61
+ export function lastCounterDays(
62
+ days: CounterDays,
63
+ count: number,
64
+ today: Date,
65
+ key: CounterKey | MaxCounterKey
66
+ ): number[] {
67
+ return Array.from({ length: count }, (_, index) => {
68
+ const date = new Date(today)
69
+ date.setDate(date.getDate() - (count - 1 - index))
70
+ return days[localDay(date)]?.[key] ?? 0
71
+ })
72
+ }
73
+
74
+ export function sumOf(days: CounterDays, key: CounterKey): number {
75
+ let total = 0
76
+ for (const day of Object.values(days)) total += day[key] ?? 0
77
+ return total
78
+ }