meter-ai 0.2.0 → 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.
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +74 -3
- package/dist/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/credentials.ts +44 -0
- package/src/auth/detect.ts +24 -0
- package/src/commands/config.ts +19 -0
- package/src/commands/history.ts +16 -0
- package/src/commands/init.ts +149 -0
- package/src/commands/report.ts +27 -0
- package/src/commands/status.ts +16 -0
- package/src/commands/uninstall.ts +20 -0
- package/src/commands/wrap.ts +235 -0
- package/src/constants.ts +52 -0
- package/src/estimation/heuristics.ts +36 -0
- package/src/estimation/history-matcher.ts +43 -0
- package/src/estimation/llm-precheck.ts +27 -0
- package/src/estimation/pipeline.ts +67 -0
- package/src/hooks/on-prompt.js +92 -0
- package/src/hooks/statusline.js +36 -0
- package/src/index.ts +50 -0
- package/src/pty/resize.ts +15 -0
- package/src/pty/screen.ts +15 -0
- package/src/pty/wrapper.ts +143 -0
- package/src/shell/binary-resolver.ts +21 -0
- package/src/shell/detect.ts +33 -0
- package/src/shell/path-inject.ts +31 -0
- package/src/shell/shim-writer.ts +28 -0
- package/src/storage/config-store.ts +46 -0
- package/src/storage/db.ts +63 -0
- package/src/tracking/cost.ts +7 -0
- package/src/tracking/plan-usage.ts +57 -0
- package/src/tracking/tokens.ts +16 -0
- package/src/types.ts +73 -0
- package/src/ui/keypress.ts +27 -0
- package/src/ui/notification.ts +31 -0
- package/src/ui/statusbar.ts +74 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import ansiEscapes from 'ansi-escapes'
|
|
2
|
+
import type { Complexity, UserMode } from '../types.js'
|
|
3
|
+
|
|
4
|
+
export interface StatusBarState {
|
|
5
|
+
model: string
|
|
6
|
+
estimatedCost: number | null
|
|
7
|
+
complexity: Complexity | null
|
|
8
|
+
mode: UserMode
|
|
9
|
+
elapsedCost: number | null
|
|
10
|
+
budgetUsd: number | null
|
|
11
|
+
windowPct: number | null
|
|
12
|
+
windowResetIn: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RESET = '\x1b[0m'
|
|
16
|
+
const DIM = '\x1b[2m'
|
|
17
|
+
const BOLD = '\x1b[1m'
|
|
18
|
+
const YELLOW = '\x1b[33m'
|
|
19
|
+
const RED = '\x1b[31m'
|
|
20
|
+
const GREEN = '\x1b[32m'
|
|
21
|
+
const CYAN = '\x1b[36m'
|
|
22
|
+
|
|
23
|
+
function progressBar(pct: number, width = 12): string {
|
|
24
|
+
const filled = Math.round((pct / 100) * width)
|
|
25
|
+
const empty = width - filled
|
|
26
|
+
const color = pct >= 80 ? RED : pct >= 60 ? YELLOW : GREEN
|
|
27
|
+
return `${color}${'█'.repeat(filled)}${DIM}${'░'.repeat(empty)}${RESET}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderStatusBar(state: StatusBarState): string {
|
|
31
|
+
const parts: string[] = [`${CYAN}◆ meter${RESET}`, `${BOLD}${state.model}${RESET}`]
|
|
32
|
+
|
|
33
|
+
if (state.estimatedCost !== null) {
|
|
34
|
+
parts.push(`${DIM}~$${state.estimatedCost.toFixed(2)}${RESET}`)
|
|
35
|
+
}
|
|
36
|
+
if (state.complexity) {
|
|
37
|
+
parts.push(`${DIM}${state.complexity}${RESET}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
parts.push('│')
|
|
41
|
+
|
|
42
|
+
if (state.mode === 'api' && state.elapsedCost !== null && state.budgetUsd !== null) {
|
|
43
|
+
const pct = (state.elapsedCost / state.budgetUsd) * 100
|
|
44
|
+
parts.push(progressBar(pct))
|
|
45
|
+
parts.push(`$${state.elapsedCost.toFixed(2)} / $${state.budgetUsd.toFixed(2)}`)
|
|
46
|
+
} else if (state.mode === 'plan' && state.windowPct !== null) {
|
|
47
|
+
parts.push(progressBar(state.windowPct))
|
|
48
|
+
parts.push(`${state.windowPct}% 5hr window`)
|
|
49
|
+
if (state.windowResetIn) parts.push(`${DIM}reset in ${state.windowResetIn}${RESET}`)
|
|
50
|
+
} else {
|
|
51
|
+
parts.push(`${DIM}usage unavailable${RESET}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return parts.join(' ')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function injectStatusBar(content: string, linesAbove: number): void {
|
|
58
|
+
process.stdout.write(
|
|
59
|
+
ansiEscapes.cursorSavePosition +
|
|
60
|
+
ansiEscapes.cursorUp(linesAbove) +
|
|
61
|
+
ansiEscapes.eraseLine +
|
|
62
|
+
content +
|
|
63
|
+
ansiEscapes.cursorRestorePosition
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function clearStatusBar(linesAbove: number): void {
|
|
68
|
+
process.stdout.write(
|
|
69
|
+
ansiEscapes.cursorSavePosition +
|
|
70
|
+
ansiEscapes.cursorUp(linesAbove) +
|
|
71
|
+
ansiEscapes.eraseLine +
|
|
72
|
+
ansiEscapes.cursorRestorePosition
|
|
73
|
+
)
|
|
74
|
+
}
|