meter-ai 0.1.2 → 0.3.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 (44) hide show
  1. package/dist/commands/init.d.ts.map +1 -1
  2. package/dist/commands/init.js +74 -3
  3. package/dist/commands/init.js.map +1 -1
  4. package/dist/commands/wrap.d.ts.map +1 -1
  5. package/dist/commands/wrap.js +23 -2
  6. package/dist/commands/wrap.js.map +1 -1
  7. package/dist/pty/wrapper.d.ts +9 -8
  8. package/dist/pty/wrapper.d.ts.map +1 -1
  9. package/dist/pty/wrapper.js +39 -14
  10. package/dist/pty/wrapper.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/auth/credentials.ts +44 -0
  13. package/src/auth/detect.ts +24 -0
  14. package/src/commands/config.ts +19 -0
  15. package/src/commands/history.ts +16 -0
  16. package/src/commands/init.ts +149 -0
  17. package/src/commands/report.ts +27 -0
  18. package/src/commands/status.ts +16 -0
  19. package/src/commands/uninstall.ts +20 -0
  20. package/src/commands/wrap.ts +235 -0
  21. package/src/constants.ts +52 -0
  22. package/src/estimation/heuristics.ts +36 -0
  23. package/src/estimation/history-matcher.ts +43 -0
  24. package/src/estimation/llm-precheck.ts +27 -0
  25. package/src/estimation/pipeline.ts +67 -0
  26. package/src/hooks/on-prompt.js +92 -0
  27. package/src/hooks/statusline.js +36 -0
  28. package/src/index.ts +50 -0
  29. package/src/pty/resize.ts +15 -0
  30. package/src/pty/screen.ts +15 -0
  31. package/src/pty/wrapper.ts +143 -0
  32. package/src/shell/binary-resolver.ts +21 -0
  33. package/src/shell/detect.ts +33 -0
  34. package/src/shell/path-inject.ts +31 -0
  35. package/src/shell/shim-writer.ts +28 -0
  36. package/src/storage/config-store.ts +46 -0
  37. package/src/storage/db.ts +63 -0
  38. package/src/tracking/cost.ts +7 -0
  39. package/src/tracking/plan-usage.ts +57 -0
  40. package/src/tracking/tokens.ts +16 -0
  41. package/src/types.ts +73 -0
  42. package/src/ui/keypress.ts +27 -0
  43. package/src/ui/notification.ts +31 -0
  44. package/src/ui/statusbar.ts +74 -0
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { runInit } from './commands/init.js'
3
+ import { runStatus } from './commands/status.js'
4
+ import { runReport } from './commands/report.js'
5
+ import { runHistory } from './commands/history.js'
6
+ import { runConfig } from './commands/config.js'
7
+ import { runUninstall } from './commands/uninstall.js'
8
+ import { runWrap } from './commands/wrap.js'
9
+ import { METER_DIR } from './constants.js'
10
+
11
+ const [,, command, ...args] = process.argv
12
+
13
+ const commands: Record<string, () => Promise<void>> = {
14
+ init: () => runInit({ meterDir: METER_DIR }),
15
+ status: runStatus,
16
+ report: runReport,
17
+ history: () => runHistory(args),
18
+ config: () => runConfig(args),
19
+ uninstall: runUninstall,
20
+ wrap: () => runWrap(args),
21
+ }
22
+
23
+ async function main() {
24
+ if (!command || command === '--help' || command === '-h') {
25
+ console.log(`
26
+ ◆ meter — intelligent wrapper for Claude Code
27
+
28
+ meter init Set up PATH shim and config
29
+ meter status Show current mode, usage, and config
30
+ meter report Weekly digest of usage and costs
31
+ meter history Browse past task records
32
+ meter config View and set configuration values
33
+ meter uninstall Remove meter completely
34
+ `)
35
+ return
36
+ }
37
+
38
+ const handler = commands[command]
39
+ if (!handler) {
40
+ console.error(`Unknown command: ${command}`)
41
+ process.exit(1)
42
+ }
43
+
44
+ await handler()
45
+ }
46
+
47
+ main().catch(err => {
48
+ console.error('[meter error]', err.message)
49
+ process.exit(1)
50
+ })
@@ -0,0 +1,15 @@
1
+ export interface TerminalSize {
2
+ cols: number
3
+ rows: number
4
+ }
5
+
6
+ export function getTerminalSize(): TerminalSize {
7
+ return {
8
+ cols: process.stdout.columns ?? 80,
9
+ rows: process.stdout.rows ?? 24,
10
+ }
11
+ }
12
+
13
+ export function calculateStatusBarLines(statusBarCharCount: number, cols: number): number {
14
+ return Math.ceil(statusBarCharCount / cols)
15
+ }
@@ -0,0 +1,15 @@
1
+ const ENTER_ALT_SCREEN = '\x1b[?1049h'
2
+ const EXIT_ALT_SCREEN = '\x1b[?1049l'
3
+
4
+ export class AlternateScreenTracker {
5
+ private _isActive = false
6
+
7
+ get isActive(): boolean {
8
+ return this._isActive
9
+ }
10
+
11
+ process(chunk: string): void {
12
+ if (chunk.includes(ENTER_ALT_SCREEN)) this._isActive = true
13
+ if (chunk.includes(EXIT_ALT_SCREEN)) this._isActive = false
14
+ }
15
+ }
@@ -0,0 +1,143 @@
1
+ import { EventEmitter } from 'events'
2
+ import { spawn as cpSpawn, type ChildProcess } from 'child_process'
3
+ import { AlternateScreenTracker } from './screen.js'
4
+ import { getTerminalSize } from './resize.js'
5
+
6
+ export interface WrapperEvents {
7
+ data: (chunk: string) => void
8
+ input: (prompt: string) => void
9
+ exit: (code: number, signal: number) => void
10
+ }
11
+
12
+ type PtyModule = typeof import('node-pty')
13
+
14
+ /**
15
+ * PtyWrapper with automatic fallback and input capture.
16
+ *
17
+ * In PTY mode:
18
+ * - Captures all user input keystroke by keystroke
19
+ * - Buffers input and emits 'input' event on Enter (prompt submission)
20
+ * - This enables per-prompt estimation during interactive sessions
21
+ *
22
+ * In fallback mode:
23
+ * - stdio is inherited, no input capture possible
24
+ */
25
+ export class PtyWrapper extends EventEmitter {
26
+ private ptyProcess: import('node-pty').IPty | null = null
27
+ private childProcess: ChildProcess | null = null
28
+ private screenTracker = new AlternateScreenTracker()
29
+ private _usingFallback = false
30
+ private inputBuffer = ''
31
+
32
+ get isInAlternateScreen(): boolean {
33
+ return this.screenTracker.isActive
34
+ }
35
+
36
+ get usingFallback(): boolean {
37
+ return this._usingFallback
38
+ }
39
+
40
+ spawn(binary: string, args: string[], env: NodeJS.ProcessEnv): void {
41
+ try {
42
+ const pty: PtyModule = require('node-pty')
43
+ this._spawnWithPty(pty, binary, args, env)
44
+ } catch {
45
+ this._usingFallback = true
46
+ this._spawnWithChildProcess(binary, args, env)
47
+ }
48
+ }
49
+
50
+ private _spawnWithPty(pty: PtyModule, binary: string, args: string[], env: NodeJS.ProcessEnv): void {
51
+ const size = getTerminalSize()
52
+
53
+ this.ptyProcess = pty.spawn(binary, args, {
54
+ name: process.env.TERM ?? 'xterm-256color',
55
+ cols: size.cols,
56
+ rows: size.rows,
57
+ env: { ...env },
58
+ cwd: process.cwd(),
59
+ })
60
+
61
+ this.ptyProcess.onData((data: string) => {
62
+ this.screenTracker.process(data)
63
+ this.emit('data', data)
64
+ process.stdout.write(data)
65
+ })
66
+
67
+ this.ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
68
+ this.emit('exit', exitCode, signal ?? 0)
69
+ })
70
+
71
+ // Forward stdin to PTY and capture input for per-prompt estimation
72
+ if (process.stdin.isTTY) {
73
+ process.stdin.setRawMode(true)
74
+ }
75
+ process.stdin.on('data', (data: Buffer) => {
76
+ const str = data.toString()
77
+ this.ptyProcess?.write(str)
78
+
79
+ // Track user input for prompt detection
80
+ for (const char of str) {
81
+ if (char === '\r' || char === '\n') {
82
+ // Enter pressed — emit the buffered input as a prompt
83
+ const prompt = this.inputBuffer.trim()
84
+ if (prompt.length > 0) {
85
+ this.emit('input', prompt)
86
+ }
87
+ this.inputBuffer = ''
88
+ } else if (char === '\x7f' || char === '\b') {
89
+ // Backspace — remove last char from buffer
90
+ this.inputBuffer = this.inputBuffer.slice(0, -1)
91
+ } else if (char === '\x03') {
92
+ // Ctrl+C — clear buffer
93
+ this.inputBuffer = ''
94
+ } else if (char === '\x15') {
95
+ // Ctrl+U — clear line
96
+ this.inputBuffer = ''
97
+ } else if (char.charCodeAt(0) >= 32) {
98
+ // Printable character — append to buffer
99
+ this.inputBuffer += char
100
+ }
101
+ // Ignore other control characters (arrows, escape sequences, etc.)
102
+ }
103
+ })
104
+
105
+ // Handle terminal resize
106
+ process.on('SIGWINCH', () => {
107
+ const newSize = getTerminalSize()
108
+ this.ptyProcess?.resize(newSize.cols, newSize.rows)
109
+ })
110
+ }
111
+
112
+ private _spawnWithChildProcess(binary: string, args: string[], env: NodeJS.ProcessEnv): void {
113
+ this.childProcess = cpSpawn(binary, args, {
114
+ env: { ...env },
115
+ cwd: process.cwd(),
116
+ stdio: 'inherit',
117
+ })
118
+
119
+ this.childProcess.on('exit', (code, signal) => {
120
+ this.emit('exit', code ?? 1, signal ? 1 : 0)
121
+ })
122
+ }
123
+
124
+ write(data: string): void {
125
+ if (this.ptyProcess) {
126
+ this.ptyProcess.write(data)
127
+ } else if (this.childProcess) {
128
+ this.childProcess.stdin?.write(data)
129
+ }
130
+ }
131
+
132
+ kill(signal: NodeJS.Signals = 'SIGTERM'): void {
133
+ if (this.ptyProcess) {
134
+ this.ptyProcess.kill(signal)
135
+ } else if (this.childProcess) {
136
+ this.childProcess.kill(signal)
137
+ }
138
+ }
139
+
140
+ resize(cols: number, rows: number): void {
141
+ this.ptyProcess?.resize(cols, rows)
142
+ }
143
+ }
@@ -0,0 +1,21 @@
1
+ import { access, constants } from 'fs/promises'
2
+ import { join } from 'path'
3
+
4
+ export async function resolveTrueBinary(
5
+ name: string,
6
+ skipDir: string
7
+ ): Promise<string | null> {
8
+ const pathEnv = process.env.PATH ?? ''
9
+ const dirs = pathEnv.split(':').filter(d => d !== skipDir && d !== '')
10
+
11
+ for (const dir of dirs) {
12
+ const fullPath = join(dir, name)
13
+ try {
14
+ await access(fullPath, constants.X_OK)
15
+ return fullPath
16
+ } catch {
17
+ // not found or not executable, continue
18
+ }
19
+ }
20
+ return null
21
+ }
@@ -0,0 +1,33 @@
1
+ import { homedir } from 'os'
2
+ import { join } from 'path'
3
+
4
+ export type ShellType = 'zsh' | 'bash' | 'fish' | 'nushell'
5
+
6
+ export function detectShell(): ShellType {
7
+ const shell = process.env.SHELL ?? ''
8
+ if (shell.includes('zsh')) return 'zsh'
9
+ if (shell.includes('fish')) return 'fish'
10
+ if (shell.includes('nu')) return 'nushell'
11
+ return 'bash'
12
+ }
13
+
14
+ export function getShellConfigPath(shell: ShellType): string {
15
+ const home = homedir()
16
+ const paths: Record<ShellType, string> = {
17
+ zsh: join(home, '.zshrc'),
18
+ bash: join(home, '.bashrc'),
19
+ fish: join(home, '.config', 'fish', 'config.fish'),
20
+ nushell: join(home, '.config', 'nushell', 'env.nu'),
21
+ }
22
+ return paths[shell]
23
+ }
24
+
25
+ export function getPathInjectLine(shell: ShellType): string {
26
+ const lines: Record<ShellType, string> = {
27
+ zsh: `export PATH="$HOME/.meter/bin:$PATH" # added by meter`,
28
+ bash: `export PATH="$HOME/.meter/bin:$PATH" # added by meter`,
29
+ fish: `fish_add_path ~/.meter/bin # added by meter`,
30
+ nushell: `$env.PATH = ($env.PATH | prepend ($env.HOME + "/.meter/bin")) # added by meter`,
31
+ }
32
+ return lines[shell]
33
+ }
@@ -0,0 +1,31 @@
1
+ import { readFile, writeFile, appendFile } from 'fs/promises'
2
+ import type { ShellType } from './detect.js'
3
+ import { getPathInjectLine } from './detect.js'
4
+
5
+ const METER_MARKER = '# added by meter'
6
+
7
+ export async function isPathAlreadyInjected(configPath: string): Promise<boolean> {
8
+ try {
9
+ const content = await readFile(configPath, 'utf-8')
10
+ return content.includes(METER_MARKER)
11
+ } catch {
12
+ return false
13
+ }
14
+ }
15
+
16
+ export async function injectPath(shell: ShellType, configPath: string): Promise<void> {
17
+ const already = await isPathAlreadyInjected(configPath)
18
+ if (already) return
19
+ const line = getPathInjectLine(shell)
20
+ await appendFile(configPath, `\n${line}\n`, 'utf-8')
21
+ }
22
+
23
+ export async function removePath(configPath: string): Promise<void> {
24
+ try {
25
+ const content = await readFile(configPath, 'utf-8')
26
+ const cleaned = content.split('\n').filter(line => !line.includes(METER_MARKER)).join('\n')
27
+ await writeFile(configPath, cleaned, 'utf-8')
28
+ } catch {
29
+ // file may not exist, that's fine
30
+ }
31
+ }
@@ -0,0 +1,28 @@
1
+ import { writeFile, mkdir, chmod, access } from 'fs/promises'
2
+ import { dirname } from 'path'
3
+
4
+ const SHIM_EXEC_TEMPLATE = (trueBinary: string, meterBin: string) =>
5
+ `#!/bin/sh
6
+ # meter shim — do not edit manually
7
+ exec "${meterBin}" wrap "${trueBinary}" "$@"
8
+ `
9
+
10
+ export async function writeShim(
11
+ shimPath: string,
12
+ trueBinary: string,
13
+ meterEntrypoint?: string
14
+ ): Promise<void> {
15
+ await mkdir(dirname(shimPath), { recursive: true })
16
+ const content = SHIM_EXEC_TEMPLATE(trueBinary, meterEntrypoint ?? 'meter')
17
+ await writeFile(shimPath, content, 'utf-8')
18
+ await chmod(shimPath, 0o755)
19
+ }
20
+
21
+ export async function shimExists(shimPath: string): Promise<boolean> {
22
+ try {
23
+ await access(shimPath)
24
+ return true
25
+ } catch {
26
+ return false
27
+ }
28
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises'
2
+ import { dirname } from 'path'
3
+ import { DEFAULT_CONFIG_VALUES } from '../constants.js'
4
+ import type { MeterConfig, UserMode } from '../types.js'
5
+
6
+ export async function readConfig(path: string): Promise<MeterConfig | null> {
7
+ try {
8
+ const raw = await readFile(path, 'utf-8')
9
+ return JSON.parse(raw) as MeterConfig
10
+ } catch {
11
+ return null
12
+ }
13
+ }
14
+
15
+ export async function writeConfig(path: string, config: MeterConfig): Promise<void> {
16
+ await mkdir(dirname(path), { recursive: true })
17
+ await writeFile(path, JSON.stringify(config, null, 2), 'utf-8')
18
+ }
19
+
20
+ export function ensureConfigDefaults(
21
+ partial: Partial<MeterConfig> & { mode: UserMode; resolved_binaries: { claude: string } }
22
+ ): MeterConfig {
23
+ return {
24
+ version: 1,
25
+ org_id: partial.org_id,
26
+ budget: {
27
+ per_task_usd: DEFAULT_CONFIG_VALUES.budget_per_task_usd,
28
+ threshold_pct: DEFAULT_CONFIG_VALUES.threshold_pct,
29
+ action: 'notify',
30
+ },
31
+ plan: {
32
+ window_threshold_pct: DEFAULT_CONFIG_VALUES.window_threshold_pct,
33
+ action: 'notify',
34
+ },
35
+ models: {
36
+ claude_chain: DEFAULT_CONFIG_VALUES.claude_chain,
37
+ },
38
+ estimation: {
39
+ use_llm_precheck: true,
40
+ llm_precheck_model: DEFAULT_CONFIG_VALUES.llm_precheck_model,
41
+ min_confidence_to_skip_llm: DEFAULT_CONFIG_VALUES.min_confidence_to_skip_llm,
42
+ },
43
+ poll_interval_seconds: DEFAULT_CONFIG_VALUES.poll_interval_seconds,
44
+ ...partial,
45
+ }
46
+ }
@@ -0,0 +1,63 @@
1
+ import { mkdirSync } from 'fs'
2
+ import { dirname } from 'path'
3
+ import type { TaskRecord } from '../types.js'
4
+ import Database from 'better-sqlite3'
5
+
6
+ export type DB = import('better-sqlite3').Database
7
+
8
+ export function initDb(path: string): DB {
9
+ mkdirSync(dirname(path), { recursive: true })
10
+ const db = new Database(path)
11
+
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS tasks (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ created_at INTEGER NOT NULL,
16
+ repo TEXT,
17
+ prompt_hash TEXT NOT NULL,
18
+ prompt_text TEXT NOT NULL,
19
+ model TEXT NOT NULL,
20
+ complexity TEXT NOT NULL,
21
+ est_layer INTEGER NOT NULL,
22
+ est_cost REAL,
23
+ actual_tokens_in INTEGER,
24
+ actual_tokens_out INTEGER,
25
+ actual_cost REAL,
26
+ window_pct_start REAL,
27
+ window_pct_end REAL,
28
+ model_switched INTEGER DEFAULT 0,
29
+ exit_code INTEGER
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at DESC);
32
+ CREATE INDEX IF NOT EXISTS idx_tasks_repo ON tasks(repo);
33
+ CREATE INDEX IF NOT EXISTS idx_tasks_prompt_hash ON tasks(prompt_hash);
34
+ `)
35
+
36
+ return db
37
+ }
38
+
39
+ export function insertTask(db: DB, task: Omit<TaskRecord, 'id'>): number {
40
+ const stmt = db.prepare(`
41
+ INSERT INTO tasks (
42
+ created_at, repo, prompt_hash, prompt_text, model, complexity,
43
+ est_layer, est_cost, actual_tokens_in, actual_tokens_out, actual_cost,
44
+ window_pct_start, window_pct_end, model_switched, exit_code
45
+ ) VALUES (
46
+ @created_at, @repo, @prompt_hash, @prompt_text, @model, @complexity,
47
+ @est_layer, @est_cost, @actual_tokens_in, @actual_tokens_out, @actual_cost,
48
+ @window_pct_start, @window_pct_end, @model_switched, @exit_code
49
+ )
50
+ `)
51
+ const result = stmt.run(task)
52
+ return result.lastInsertRowid as number
53
+ }
54
+
55
+ export function getRecentTasks(db: DB, limit: number): TaskRecord[] {
56
+ return db.prepare(
57
+ 'SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?'
58
+ ).all(limit) as TaskRecord[]
59
+ }
60
+
61
+ export async function openDb(path: string): Promise<DB> {
62
+ return initDb(path)
63
+ }
@@ -0,0 +1,7 @@
1
+ import type { ModelPricing } from '../types.js'
2
+
3
+ export function calculateCost(tokens: { input: number; output: number }, pricing: ModelPricing): number {
4
+ const inputCost = (tokens.input / 1_000_000) * pricing.input_per_million
5
+ const outputCost = (tokens.output / 1_000_000) * pricing.output_per_million
6
+ return inputCost + outputCost
7
+ }
@@ -0,0 +1,57 @@
1
+ import { appendFile, mkdir } from 'fs/promises'
2
+ import { dirname } from 'path'
3
+ import { ERRORS_LOG_PATH, CLAUDE_BOOTSTRAP_API, CLAUDE_USAGE_API } from '../constants.js'
4
+ import type { PlanUsage } from '../types.js'
5
+ import type { ClaudeCredentials } from '../auth/credentials.js'
6
+
7
+ async function logError(message: string): Promise<void> {
8
+ await mkdir(dirname(ERRORS_LOG_PATH), { recursive: true })
9
+ await appendFile(ERRORS_LOG_PATH, `[${new Date().toISOString()}] ${message}\n`)
10
+ }
11
+
12
+ export async function resolveOrgId(creds: ClaudeCredentials): Promise<string | null> {
13
+ try {
14
+ const res = await fetch(CLAUDE_BOOTSTRAP_API, {
15
+ headers: { Authorization: `Bearer ${creds.accessToken}` }
16
+ })
17
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
18
+ const data = await res.json() as { account?: { memberships?: Array<{ organization?: { id: string } }> } }
19
+ return data?.account?.memberships?.[0]?.organization?.id ?? null
20
+ } catch (err) {
21
+ await logError(`bootstrap failed: ${err}`)
22
+ return null
23
+ }
24
+ }
25
+
26
+ export async function fetchPlanUsage(orgId: string, creds: ClaudeCredentials): Promise<PlanUsage | null> {
27
+ try {
28
+ const res = await fetch(`${CLAUDE_USAGE_API}/${orgId}/usage`, {
29
+ headers: { Authorization: `Bearer ${creds.accessToken}` }
30
+ })
31
+ if (!res.ok) {
32
+ await logError(`usage API returned ${res.status}`)
33
+ return null
34
+ }
35
+ const data = await res.json() as {
36
+ five_hour?: { utilization_pct: number; reset_at: string }
37
+ seven_day?: { utilization_pct: number }
38
+ }
39
+ return {
40
+ five_hour_pct: data.five_hour?.utilization_pct ?? 0,
41
+ five_hour_reset_at: data.five_hour?.reset_at ?? '',
42
+ seven_day_pct: data.seven_day?.utilization_pct ?? 0,
43
+ fetched_at: Date.now(),
44
+ }
45
+ } catch (err) {
46
+ await logError(`fetchPlanUsage failed: ${err}`)
47
+ return null
48
+ }
49
+ }
50
+
51
+ export function formatResetCountdown(resetAt: string): string {
52
+ const ms = new Date(resetAt).getTime() - Date.now()
53
+ if (ms <= 0) return 'resetting...'
54
+ const h = Math.floor(ms / 3_600_000)
55
+ const m = Math.floor((ms % 3_600_000) / 60_000)
56
+ return h > 0 ? `${h}h ${m}m` : `${m}m`
57
+ }
@@ -0,0 +1,16 @@
1
+ export interface TokenCounts { input: number; output: number }
2
+
3
+ const TOKEN_PATTERN = /tokens?:\s*([\d,]+)\s*input,\s*([\d,]+)\s*output/i
4
+
5
+ export function parseTokensFromOutput(output: string): TokenCounts | null {
6
+ const match = TOKEN_PATTERN.exec(output)
7
+ if (!match) return null
8
+ return {
9
+ input: parseInt(match[1].replace(/,/g, ''), 10),
10
+ output: parseInt(match[2].replace(/,/g, ''), 10),
11
+ }
12
+ }
13
+
14
+ export function estimateInputTokens(charCount: number): number {
15
+ return Math.ceil(charCount / 4)
16
+ }
package/src/types.ts ADDED
@@ -0,0 +1,73 @@
1
+ export type UserMode = 'api' | 'plan'
2
+ export type Complexity = 'low' | 'medium' | 'heavy' | 'critical'
3
+ export type EstimationLayer = 1 | 2 | 3
4
+
5
+ export interface MeterConfig {
6
+ version: number
7
+ mode: UserMode
8
+ resolved_binaries: { claude: string }
9
+ org_id?: string
10
+ budget: {
11
+ per_task_usd: number
12
+ threshold_pct: number
13
+ action: 'notify'
14
+ }
15
+ plan: {
16
+ window_threshold_pct: number
17
+ action: 'notify'
18
+ }
19
+ models: {
20
+ claude_chain: string[]
21
+ }
22
+ estimation: {
23
+ use_llm_precheck: boolean
24
+ llm_precheck_model: string
25
+ min_confidence_to_skip_llm: number
26
+ }
27
+ poll_interval_seconds: number
28
+ }
29
+
30
+ export interface TaskRecord {
31
+ id?: number
32
+ created_at: number
33
+ repo: string | null
34
+ prompt_hash: string
35
+ prompt_text: string
36
+ model: string
37
+ complexity: Complexity
38
+ est_layer: EstimationLayer
39
+ est_cost: number | null
40
+ actual_tokens_in: number | null
41
+ actual_tokens_out: number | null
42
+ actual_cost: number | null
43
+ window_pct_start: number | null
44
+ window_pct_end: number | null
45
+ model_switched: number
46
+ exit_code: number | null
47
+ }
48
+
49
+ export interface EstimationResult {
50
+ complexity: Complexity
51
+ confidence: number
52
+ estimated_cost: number | null
53
+ layer_used: EstimationLayer
54
+ }
55
+
56
+ export interface PlanUsage {
57
+ five_hour_pct: number
58
+ five_hour_reset_at: string
59
+ seven_day_pct: number
60
+ fetched_at: number
61
+ }
62
+
63
+ export interface ModelPricing {
64
+ model: string
65
+ input_per_million: number
66
+ output_per_million: number
67
+ updated_at: number
68
+ }
69
+
70
+ export interface TokenCounts {
71
+ input: number
72
+ output: number
73
+ }
@@ -0,0 +1,27 @@
1
+ export type KeypressAction = 's' | 'd' | 'c'
2
+
3
+ export function waitForKeypress(
4
+ validKeys: KeypressAction[],
5
+ timeoutMs: number
6
+ ): Promise<KeypressAction | 'timeout'> {
7
+ return new Promise(resolve => {
8
+ const cleanup = () => {
9
+ process.stdin.removeListener('data', onData)
10
+ clearTimeout(timer)
11
+ }
12
+
13
+ const timer = timeoutMs > 0
14
+ ? setTimeout(() => { cleanup(); resolve('timeout') }, timeoutMs)
15
+ : undefined
16
+
17
+ const onData = (data: Buffer) => {
18
+ const key = data.toString().toLowerCase().trim() as KeypressAction
19
+ if (validKeys.includes(key)) {
20
+ cleanup()
21
+ resolve(key)
22
+ }
23
+ }
24
+
25
+ process.stdin.on('data', onData)
26
+ })
27
+ }
@@ -0,0 +1,31 @@
1
+ import type { UserMode } from '../types.js'
2
+
3
+ export interface NotificationState {
4
+ mode: UserMode
5
+ thresholdPct: number
6
+ elapsedCost: number | null
7
+ budgetUsd: number | null
8
+ windowPct: number | null
9
+ nextModel: string | null
10
+ }
11
+
12
+ const YELLOW = '\x1b[33m'
13
+ const BOLD = '\x1b[1m'
14
+ const RESET = '\x1b[0m'
15
+ const DIM = '\x1b[2m'
16
+
17
+ export function renderNotification(state: NotificationState): string {
18
+ let message: string
19
+
20
+ if (state.mode === 'api' && state.elapsedCost !== null && state.budgetUsd !== null) {
21
+ message = `${YELLOW}⚠ ${state.thresholdPct}% budget hit${RESET} ($${state.elapsedCost.toFixed(2)} / $${state.budgetUsd.toFixed(2)})`
22
+ } else {
23
+ message = `${YELLOW}⚠ ${state.windowPct}% window used${RESET}`
24
+ }
25
+
26
+ const actions = state.nextModel
27
+ ? ` ${BOLD}[s]${RESET}${DIM}witch to ${state.nextModel}${RESET} ${BOLD}[d]${RESET}${DIM}ismiss${RESET} ${BOLD}[c]${RESET}${DIM}ancel${RESET}`
28
+ : ` ${BOLD}[d]${RESET}${DIM}ismiss${RESET} ${BOLD}[c]${RESET}${DIM}ancel${RESET}`
29
+
30
+ return message + actions
31
+ }