@sejuneoh/aitop 0.0.0-stage → 0.21.2
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/LICENSE +21 -0
- package/README.md +40 -2
- package/bin/aitop.mjs +360 -0
- package/package.json +36 -4
- package/src/attention.mjs +70 -0
- package/src/brand.mjs +30 -0
- package/src/budget.mjs +29 -0
- package/src/cache.mjs +78 -0
- package/src/collect.mjs +51 -0
- package/src/config.mjs +169 -0
- package/src/doctor.mjs +218 -0
- package/src/frames.mjs +24 -0
- package/src/help.mjs +111 -0
- package/src/json.mjs +57 -0
- package/src/keys.mjs +154 -0
- package/src/log.mjs +130 -0
- package/src/notify.mjs +251 -0
- package/src/order.mjs +69 -0
- package/src/pricing.mjs +130 -0
- package/src/procs.mjs +205 -0
- package/src/sources/agy-ledger.mjs +153 -0
- package/src/sources/antigravity.mjs +763 -0
- package/src/sources/claude-ledger.mjs +181 -0
- package/src/sources/claude-tracker.mjs +183 -0
- package/src/sources/claude.mjs +352 -0
- package/src/sources/codex-ledger.mjs +141 -0
- package/src/sources/codex.mjs +374 -0
- package/src/sources/common.mjs +228 -0
- package/src/sources/files.mjs +116 -0
- package/src/sources/gemini.mjs +26 -0
- package/src/sources/patch.mjs +78 -0
- package/src/sqlite.mjs +50 -0
- package/src/tail.mjs +109 -0
- package/src/text.mjs +256 -0
- package/src/turns.mjs +79 -0
- package/src/ui/chrome.mjs +163 -0
- package/src/ui/detail.mjs +360 -0
- package/src/ui/draw.mjs +152 -0
- package/src/ui/glyphs.mjs +58 -0
- package/src/ui/table.mjs +425 -0
- package/src/ui/theme.mjs +137 -0
- package/src/ui.mjs +99 -0
package/src/pricing.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Claude API 정가 기준 환산 비용 (USD / 100만 토큰). 구독 요금제 사용분도 "API 로 썼다면" 기준으로 계산한다.
|
|
2
|
+
// 출처: Anthropic 모델 가격표(2026-10). 캐시 읽기는 입력가의 0.1배, 캐시 쓰기는 5분 1.25배·1시간 2배.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const PRICES = {
|
|
6
|
+
'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25 },
|
|
7
|
+
'claude-mythos-5-1': { input: 10, output: 50, cacheRead: 0.25 },
|
|
8
|
+
'claude-fable-5': { input: 10, output: 50 },
|
|
9
|
+
'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, fast: 2 },
|
|
10
|
+
'claude-opus-5': { input: 5, output: 25, fast: 2 },
|
|
11
|
+
'claude-opus-4-8': { input: 5, output: 25, fast: 2 },
|
|
12
|
+
'claude-opus-4-7': { input: 5, output: 25 },
|
|
13
|
+
'claude-opus-4-6': { input: 5, output: 25 },
|
|
14
|
+
'claude-sonnet-5-5': { input: 2, output: 10, cacheRead: 0.2 },
|
|
15
|
+
'claude-sonnet-5': { input: 2, output: 10 },
|
|
16
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
17
|
+
'claude-haiku-5-5': { input: 0.1, output: 0.5 },
|
|
18
|
+
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Gemini API 정가 (USD / 100만 토큰, 유료 등급 standard). Antigravity CLI(agy) 사용분을 "API 로 썼다면" 기준으로 계산한다.
|
|
22
|
+
// 출처: Google 공식 가격표 https://aistudio.google.com/docs/pricing (2026-11-06 확인). 긴 컨텍스트 구간은 따로 없다.
|
|
23
|
+
// 출력에는 생각 토큰이 들어 있고, cacheRead 는 context caching 가격이다. 가격이 바뀌는 모델은 날짜순 목록이며,
|
|
24
|
+
// from 은 그 가격이 시작되는 날(UTC 0시)이다.
|
|
25
|
+
const GEMINI_PRICES = {
|
|
26
|
+
'gemini-4-argon': [
|
|
27
|
+
{ input: 2, output: 10, cacheRead: 0.1 },
|
|
28
|
+
{ from: '2026-12-01', input: 4, output: 20, cacheRead: 0.2 },
|
|
29
|
+
],
|
|
30
|
+
'gemini-3.8-flash': [
|
|
31
|
+
{ input: 0.75, output: 3.75, cacheRead: 0.075 },
|
|
32
|
+
{ from: '2027-01-01', input: 1.5, output: 7.5, cacheRead: 0.15 },
|
|
33
|
+
],
|
|
34
|
+
'gemini-3.6-flash': [
|
|
35
|
+
{ input: 0.75, output: 3.75, cacheRead: 0.075 },
|
|
36
|
+
{ from: '2027-01-01', input: 1.5, output: 7.5, cacheRead: 0.15 },
|
|
37
|
+
],
|
|
38
|
+
'gemini-3.5-flash-lite': [{ input: 0.3, output: 2.5, cacheRead: 0.03 }],
|
|
39
|
+
'gemini-3.1-flash-lite': [{ input: 0.25, output: 1.5, cacheRead: 0.025 }],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// 가격표가 바뀌면 이 값이 바뀌고, 캐시 폴더도 바뀌어 예전 비용이 다시 계산된다.
|
|
43
|
+
export const PRICE_TAG = createHash('sha1').update(JSON.stringify([PRICES, GEMINI_PRICES])).digest('hex').slice(0, 8);
|
|
44
|
+
|
|
45
|
+
// 모델별 컨텍스트 창 크기(토큰). 출처: Anthropic 모델 표(2026-10). 표에 없는 모델은 contextWindow 가 추측한다.
|
|
46
|
+
const WINDOWS = {
|
|
47
|
+
// Gemini 계열의 입력 한도(1,048,576 토큰). Antigravity 의 내부 모델 이름(gemini-4-argon 등)도 같다고 본다.
|
|
48
|
+
'gemini-': 1_048_576,
|
|
49
|
+
'claude-fable-5-1': 1_000_000,
|
|
50
|
+
'claude-mythos-5-1': 1_000_000,
|
|
51
|
+
'claude-fable-5': 1_000_000,
|
|
52
|
+
'claude-opus-5-5': 1_000_000,
|
|
53
|
+
'claude-opus-5': 1_000_000,
|
|
54
|
+
'claude-opus-4-8': 1_000_000,
|
|
55
|
+
'claude-opus-4-7': 1_000_000,
|
|
56
|
+
'claude-opus-4-6': 1_000_000,
|
|
57
|
+
'claude-sonnet-5-5': 1_000_000,
|
|
58
|
+
'claude-sonnet-5': 1_000_000,
|
|
59
|
+
'claude-sonnet-4-6': 1_000_000,
|
|
60
|
+
'claude-haiku-5-5': 1_000_000,
|
|
61
|
+
'claude-haiku-4-5': 200_000,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function lookup(table, model) {
|
|
65
|
+
if (!model) return null;
|
|
66
|
+
if (table[model]) return table[model];
|
|
67
|
+
const key = Object.keys(table)
|
|
68
|
+
.sort((a, b) => b.length - a.length)
|
|
69
|
+
.find((k) => model.startsWith(k));
|
|
70
|
+
return key ? table[key] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 모르는 모델은 사용량으로 추측한다: 200k 를 넘었으면 1M 창, 아니면 200k 창.
|
|
74
|
+
export function contextWindow(model, used = 0) {
|
|
75
|
+
return lookup(WINDOWS, model) ?? (used > 200_000 ? 1_000_000 : 200_000);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function priceOf(model) {
|
|
79
|
+
return lookup(PRICES, model);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Claude 메시지 usage 한 건의 환산 비용. total 은 캐시 포함, noCache 는 입력·출력만. 가격표에 없는 모델이면 null.
|
|
83
|
+
export function claudeCost(model, u) {
|
|
84
|
+
const p = priceOf(model);
|
|
85
|
+
if (!p || !u) return null;
|
|
86
|
+
const m = u.speed === 'fast' && p.fast ? p.fast : 1;
|
|
87
|
+
const input = p.input * m;
|
|
88
|
+
const write1h = u.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
89
|
+
const write5m = u.cache_creation ? (u.cache_creation.ephemeral_5m_input_tokens ?? 0) : (u.cache_creation_input_tokens ?? 0);
|
|
90
|
+
const base = (u.input_tokens ?? 0) * input + (u.output_tokens ?? 0) * p.output * m;
|
|
91
|
+
const cache = (u.cache_read_input_tokens ?? 0) * (p.cacheRead ?? p.input * 0.1) * m + write5m * input * 1.25 + write1h * input * 2;
|
|
92
|
+
return { total: (base + cache) / 1e6, noCache: base / 1e6 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Codex(OpenAI) API 정가 기준 환산 비용 (USD / 100만 토큰). Claude 와 같이 "API 로 썼다면" 기준이다.
|
|
96
|
+
// 출처: 2026-11 가격 비교 사이트(pricepertoken.com, computeprices.com)의 OpenAI 정가. 추정값이라 README 에 적어 둔다.
|
|
97
|
+
const OPENAI_PRICES = {
|
|
98
|
+
'gpt-5.3-codex': { input: 1.75, cacheRead: 0.175, output: 14 },
|
|
99
|
+
'gpt-5.1-codex-mini': { input: 0.25, cacheRead: 0.025, output: 2 },
|
|
100
|
+
'gpt-5.1-codex': { input: 1.25, cacheRead: 0.125, output: 10 },
|
|
101
|
+
'codex-mini': { input: 1.5, cacheRead: 0.375, output: 6 },
|
|
102
|
+
'gpt-4o-mini': { input: 0.15, cacheRead: 0.075, output: 0.6 },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Codex 세션 누적 토큰(tokens: input 은 캐시 제외, cacheRead, output)의 환산 비용. 가격표에 없는 모델이면 null.
|
|
106
|
+
export function codexCost(model, t) {
|
|
107
|
+
const p = lookup(OPENAI_PRICES, model);
|
|
108
|
+
if (!p || !t) return null;
|
|
109
|
+
const base = (t.input ?? 0) * p.input + (t.output ?? 0) * p.output;
|
|
110
|
+
return { total: (base + (t.cacheRead ?? 0) * p.cacheRead) / 1e6, noCache: base / 1e6 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 시각 at(ms)에 쓰던 Gemini 가격. 모델 이름이 표와 똑같을 때만 찾는다:
|
|
114
|
+
// 앞부분만 같은 다른 모델(gemini-3.8-flash-lite 와 gemini-3.8-flash 등)에 엉뚱한 가격을 매기지 않기 위해서다.
|
|
115
|
+
export function geminiPrice(model, at = Date.now()) {
|
|
116
|
+
const list = model ? GEMINI_PRICES[model] : null;
|
|
117
|
+
if (!list) return null;
|
|
118
|
+
let p = list[0];
|
|
119
|
+
for (const x of list) if (x.from && at >= Date.parse(x.from)) p = x;
|
|
120
|
+
return p;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// agy 호출 한 번(t: input 은 캐시 제외, cacheRead, output 은 생각 포함)을 at 시각의 가격으로 환산한다. 가격표에 없는 모델이면 null.
|
|
124
|
+
// agy 는 암시적 캐시만 써서 캐시 쓰기 요금이 없다.
|
|
125
|
+
export function geminiCost(model, t, at) {
|
|
126
|
+
const p = geminiPrice(model, at);
|
|
127
|
+
if (!p || !t) return null;
|
|
128
|
+
const base = (t.input ?? 0) * p.input + (t.output ?? 0) * p.output;
|
|
129
|
+
return { total: (base + (t.cacheRead ?? 0) * p.cacheRead) / 1e6, noCache: base / 1e6 };
|
|
130
|
+
}
|
package/src/procs.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// 실행 중인 AI CLI 프로세스와 그 작업 폴더를 찾는다.
|
|
2
|
+
// Linux·WSL 은 /proc 를 읽고, macOS 는 ps 와 lsof 를 부른다. 그 밖의 OS 는 프로세스를 세지 못한다.
|
|
3
|
+
import { readdirSync, readFileSync, readlinkSync, existsSync } from 'node:fs';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { platform } from 'node:os';
|
|
6
|
+
|
|
7
|
+
const OS = platform();
|
|
8
|
+
const HAS_PROC = existsSync('/proc/self');
|
|
9
|
+
|
|
10
|
+
export function canListProcesses() {
|
|
11
|
+
return HAS_PROC || OS === 'darwin';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 신호 0 은 보내지 않고 프로세스가 있는지만 본다. 다른 사용자 프로세스면 EPERM 이지만 살아 있다.
|
|
15
|
+
export function isAlive(pid) {
|
|
16
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return err.code === 'EPERM';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ps 는 인자를 공백으로만 이어 보여 준다. 앞 두 칸(실행 파일, node 스크립트)이 없는 절대 경로면
|
|
26
|
+
// 실제로 있는 경로가 될 때까지 뒤 토큰을 이어 붙인다 (예: ~/Library/Application Support/…/codex.js).
|
|
27
|
+
export function joinPaths(tokens, exists = existsSync) {
|
|
28
|
+
const out = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < tokens.length) {
|
|
31
|
+
let cur = tokens[i++];
|
|
32
|
+
if (out.length < 2 && cur.startsWith('/') && !exists(cur)) {
|
|
33
|
+
let j = i;
|
|
34
|
+
let joined = cur;
|
|
35
|
+
while (j < tokens.length && j - i < 8) {
|
|
36
|
+
joined += ' ' + tokens[j++];
|
|
37
|
+
if (exists(joined)) {
|
|
38
|
+
cur = joined;
|
|
39
|
+
i = j;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
out.push(cur);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ps -axo pid=,command= 출력을 { pid, argv } 목록으로.
|
|
50
|
+
export function parsePs(text, exists = existsSync) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const line of String(text).split('\n')) {
|
|
53
|
+
const m = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
54
|
+
if (m) out.push({ pid: Number(m[1]), argv: joinPaths(m[2].trim().split(/\s+/), exists) });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// lsof -a -p PID -d cwd -Fn 출력에서 n 으로 시작하는 줄이 작업 폴더다.
|
|
60
|
+
export function parseLsofCwd(text) {
|
|
61
|
+
return /^n(.+)$/m.exec(String(text))?.[1] ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const run = (cmd, args, env) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, env });
|
|
65
|
+
|
|
66
|
+
function listProc() {
|
|
67
|
+
const list = [];
|
|
68
|
+
let pids = [];
|
|
69
|
+
try {
|
|
70
|
+
pids = readdirSync('/proc').filter((n) => /^\d+$/.test(n));
|
|
71
|
+
} catch {}
|
|
72
|
+
for (const pid of pids) {
|
|
73
|
+
let argv;
|
|
74
|
+
try {
|
|
75
|
+
argv = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean);
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (argv.length) list.push({ pid: Number(pid), argv });
|
|
80
|
+
}
|
|
81
|
+
return list;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function listPs() {
|
|
85
|
+
try {
|
|
86
|
+
return parsePs(run('ps', ['-axww', '-o', 'pid=,command=']));
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 한 번의 갱신 안에서 여러 도구가 각각 훑지 않도록 결과를 재사용한다. ps 는 프로세스를 띄우므로 더 길게 둔다.
|
|
93
|
+
const SCAN_TTL_MS = HAS_PROC ? 500 : 2000;
|
|
94
|
+
let scan = { at: 0, list: [] };
|
|
95
|
+
|
|
96
|
+
function scanAll() {
|
|
97
|
+
if (Date.now() - scan.at < SCAN_TTL_MS) return scan.list;
|
|
98
|
+
const list = (HAS_PROC ? listProc() : OS === 'darwin' ? listPs() : []).filter((p) => p.pid !== process.pid);
|
|
99
|
+
scan = { at: Date.now(), list };
|
|
100
|
+
return list;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// macOS 의 작업 폴더는 lsof 로 얻는다. 프로세스마다 바뀌는 일이 드물어 pid 별로 기억한다.
|
|
104
|
+
const cwdCache = new Map();
|
|
105
|
+
|
|
106
|
+
function cwdOf(pid) {
|
|
107
|
+
if (HAS_PROC) {
|
|
108
|
+
try {
|
|
109
|
+
return readlinkSync(`/proc/${pid}/cwd`);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (OS !== 'darwin') return null;
|
|
115
|
+
if (cwdCache.has(pid)) return cwdCache.get(pid);
|
|
116
|
+
let cwd = null;
|
|
117
|
+
try {
|
|
118
|
+
cwd = parseLsofCwd(run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']));
|
|
119
|
+
} catch {}
|
|
120
|
+
// 실패(lsof 없음, 시간 초과)는 기억하지 않고 다음 갱신에 다시 묻는다.
|
|
121
|
+
if (cwd) cwdCache.set(pid, cwd);
|
|
122
|
+
return cwd;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 작업 폴더는 일치한 프로세스만 읽는다.
|
|
126
|
+
export function findProcesses(match) {
|
|
127
|
+
const all = scanAll();
|
|
128
|
+
const found = all.filter((p) => match(p.argv));
|
|
129
|
+
// 끝난 프로세스의 pid 는 다른 프로세스가 다시 쓸 수 있으니, 지금 목록에 없는 pid 의 작업 폴더는 잊는다.
|
|
130
|
+
const alive = new Set(all.map((p) => p.pid));
|
|
131
|
+
for (const pid of cwdCache.keys()) if (!alive.has(pid)) cwdCache.delete(pid);
|
|
132
|
+
for (const pid of startCache.keys()) if (!alive.has(pid)) startCache.delete(pid);
|
|
133
|
+
return found.map((p) => ({ ...p, cwd: cwdOf(p.pid) }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const base = (p) => p.split('/').pop();
|
|
137
|
+
|
|
138
|
+
// `codex` 는 node 스크립트(…/bin/codex.js)이거나 네이티브 바이너리(codex-*)로 뜬다.
|
|
139
|
+
export function isCodex(argv) {
|
|
140
|
+
return argv.slice(0, 2).some((a) => /^codex(\.js)?$/.test(base(a)) || /^codex-[\w-]+$/.test(base(a)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isGemini(argv) {
|
|
144
|
+
return argv.slice(0, 2).some((a) => /^gemini(\.js)?$/.test(base(a)));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Antigravity CLI 는 Go 실행 파일 하나(`agy`)로 뜬다.
|
|
148
|
+
export function isAgy(argv) {
|
|
149
|
+
return argv.length > 0 && base(argv[0]) === 'agy';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 프로세스가 열어 둔 파일 경로들. Linux·WSL 의 /proc 에서만 알 수 있고, 그 밖의 OS 나 읽을 권한이 없으면 null.
|
|
153
|
+
export function openFiles(pid) {
|
|
154
|
+
if (!HAS_PROC) return null;
|
|
155
|
+
let fds;
|
|
156
|
+
try {
|
|
157
|
+
fds = readdirSync(`/proc/${pid}/fd`);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const fd of fds) {
|
|
163
|
+
try {
|
|
164
|
+
out.push(readlinkSync(`/proc/${pid}/fd/${fd}`));
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let ticksPerSec = null;
|
|
171
|
+
let bootMs = null;
|
|
172
|
+
|
|
173
|
+
// /proc/<pid>/stat 의 22번째 칸은 부팅 뒤 지난 시간(클럭 틱)이다. 명령 이름에 공백·괄호가 들 수 있어 마지막 ')' 뒤부터 센다.
|
|
174
|
+
function procStartLinux(pid) {
|
|
175
|
+
if (bootMs === null) {
|
|
176
|
+
const m = /^btime (\d+)$/m.exec(readFileSync('/proc/stat', 'utf8'));
|
|
177
|
+
if (!m) return null;
|
|
178
|
+
bootMs = Number(m[1]) * 1000;
|
|
179
|
+
}
|
|
180
|
+
if (ticksPerSec === null) {
|
|
181
|
+
try {
|
|
182
|
+
ticksPerSec = Number(run('getconf', ['CLK_TCK']).trim()) || 100;
|
|
183
|
+
} catch {
|
|
184
|
+
ticksPerSec = 100;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
188
|
+
const ticks = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]);
|
|
189
|
+
return Number.isFinite(ticks) ? bootMs + (ticks / ticksPerSec) * 1000 : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 프로세스가 시작한 시각(ms). 알 수 없으면 null. 시작 시각은 바뀌지 않으므로 pid 별로 기억한다.
|
|
193
|
+
const startCache = new Map();
|
|
194
|
+
|
|
195
|
+
export function processStart(pid) {
|
|
196
|
+
if (startCache.has(pid)) return startCache.get(pid);
|
|
197
|
+
let at = null;
|
|
198
|
+
try {
|
|
199
|
+
if (HAS_PROC) at = procStartLinux(pid);
|
|
200
|
+
// lstart 는 로케일에 따라 요일·달 이름이 바뀌므로 영어(C) 로케일로 받는다.
|
|
201
|
+
else if (OS === 'darwin') at = Date.parse(run('ps', ['-o', 'lstart=', '-p', String(pid)], { ...process.env, LC_ALL: 'C', LANG: 'C' }).trim()) || null;
|
|
202
|
+
} catch {}
|
|
203
|
+
if (at !== null) startCache.set(pid, at);
|
|
204
|
+
return at;
|
|
205
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// 날짜별 사용량 장부의 Antigravity CLI(agy) 몫: 최근 7일 안에 바뀐 conversations/<id>.db 의 gen_metadata 를
|
|
2
|
+
// 호출 시각(호출 직전 단계의 만든 시각)의 날짜와 가격으로 나눠 모은다. 서브에이전트도 대화 기록이 따로 있어 함께 센다.
|
|
3
|
+
// 세션 화면과 상관없이 끝난 대화까지 세야 해서 토큰만 가볍게 따로 읽고(진행 기록은 읽지 않는다), 어디까지 읽었는지는 캐시에 둔다.
|
|
4
|
+
// 기록은 늘 immutable 로 열어 agy 폴더에 아무것도 만들거나 고치지 않는다.
|
|
5
|
+
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { AGY_ROOT, readCalls, sigOf, maxIdx } from './antigravity.mjs';
|
|
8
|
+
import { openReadOnly, loadSqlite } from '../sqlite.mjs';
|
|
9
|
+
import { loadCache, markDirty } from '../cache.mjs';
|
|
10
|
+
import { dayKey, lastDays, addSlot } from './common.mjs';
|
|
11
|
+
import { debugIo } from '../log.mjs';
|
|
12
|
+
|
|
13
|
+
const KIND = 'agy-ledger';
|
|
14
|
+
const DAYS = 7;
|
|
15
|
+
const KEEP_DAYS = 10;
|
|
16
|
+
const LIST_TTL_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
const outOf = (budget) => budget.left <= 0 || (budget.read > 0 && Date.now() >= (budget.deadline ?? Infinity));
|
|
19
|
+
const copyDays = (days) => Object.fromEntries(Object.entries(days).map(([k, v]) => [k, { ...v }]));
|
|
20
|
+
|
|
21
|
+
export class AgyBook {
|
|
22
|
+
constructor(path) {
|
|
23
|
+
this.path = path;
|
|
24
|
+
this.sig = null;
|
|
25
|
+
this.idx = -1;
|
|
26
|
+
this.model = null;
|
|
27
|
+
this.days = {};
|
|
28
|
+
// 지출 속도용 5분 칸 (common.mjs addSlot).
|
|
29
|
+
this.slots = {};
|
|
30
|
+
// 한 번이라도 끝까지 읽었는지. 처음 읽는 동안만 '집계 중' 진행률에 들어간다.
|
|
31
|
+
this.complete = false;
|
|
32
|
+
const c = loadCache(path, KIND);
|
|
33
|
+
if (c) {
|
|
34
|
+
this.idx = c.idx ?? -1;
|
|
35
|
+
this.model = c.model ?? null;
|
|
36
|
+
this.days = c.days ?? {};
|
|
37
|
+
this.slots = c.slots ?? {};
|
|
38
|
+
this.complete = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get size() {
|
|
43
|
+
try {
|
|
44
|
+
return statSync(this.path).size;
|
|
45
|
+
} catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
update(budget) {
|
|
51
|
+
const sig = sigOf(this.path);
|
|
52
|
+
if (sig === null || sig === this.sig) return;
|
|
53
|
+
if (outOf(budget)) {
|
|
54
|
+
budget.exhausted = true;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const mainSig = sigOf(this.path, false);
|
|
58
|
+
const mtime = statSync(this.path).mtimeMs;
|
|
59
|
+
let n = { idx: this.idx, model: this.model, days: copyDays(this.days), slots: copyDays(this.slots) };
|
|
60
|
+
let db;
|
|
61
|
+
let result = null;
|
|
62
|
+
try {
|
|
63
|
+
db = openReadOnly(this.path);
|
|
64
|
+
if (!db) return;
|
|
65
|
+
const has = db.prepare("select 1 from sqlite_master where type = 'table' and name = 'gen_metadata'").get();
|
|
66
|
+
const last = has ? maxIdx(db, 'gen_metadata') : -1;
|
|
67
|
+
// 마지막 줄 번호가 줄었으면 기록이 새로 쓰인 것이니 처음부터 다시 센다.
|
|
68
|
+
if (last < n.idx) n = { idx: -1, model: null, days: {}, slots: {} };
|
|
69
|
+
result = has
|
|
70
|
+
? readCalls(db, n, last, budget, mtime, ({ usage: u, cost, at }) => {
|
|
71
|
+
const day = (n.days[dayKey(at)] ??= { tokens: 0, cost: 0, costNoCache: 0, unknown: false });
|
|
72
|
+
const tokens = u.input + u.output + u.cacheRead + u.cacheWrite;
|
|
73
|
+
day.tokens += tokens;
|
|
74
|
+
day.cost += cost?.total ?? 0;
|
|
75
|
+
day.costNoCache += cost?.noCache ?? 0;
|
|
76
|
+
if (!cost && tokens > 0) day.unknown = true;
|
|
77
|
+
addSlot(n.slots, at, cost?.total ?? 0, cost?.noCache ?? 0, !cost && tokens > 0);
|
|
78
|
+
})
|
|
79
|
+
: 'done';
|
|
80
|
+
// 읽는 동안 agy 가 본 파일에 썼으면 읽은 내용을 버리고 다음 갱신에 다시 읽는다.
|
|
81
|
+
if (sigOf(this.path, false) !== mainSig) result = null;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
debugIo('ledger', 'agy conversation read failed', e, this.path);
|
|
84
|
+
result = null;
|
|
85
|
+
} finally {
|
|
86
|
+
db?.close();
|
|
87
|
+
}
|
|
88
|
+
if (!result) return;
|
|
89
|
+
const changed = n.idx !== this.idx;
|
|
90
|
+
Object.assign(this, n);
|
|
91
|
+
if (result === 'done') {
|
|
92
|
+
this.sig = sig;
|
|
93
|
+
this.complete = true;
|
|
94
|
+
}
|
|
95
|
+
if (result === 'exhausted') budget.exhausted = true;
|
|
96
|
+
if (changed) markDirty(this.path, () => this.snapshot(), KIND);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
snapshot() {
|
|
100
|
+
const keep = new Set(lastDays(KEEP_DAYS));
|
|
101
|
+
const days = Object.fromEntries(Object.entries(this.days).filter(([k]) => keep.has(k)));
|
|
102
|
+
return { offset: 0, idx: this.idx, model: this.model, days, slots: this.slots };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let listed = { at: 0, files: [] };
|
|
107
|
+
|
|
108
|
+
function recentFiles() {
|
|
109
|
+
if (Date.now() - listed.at < LIST_TTL_MS) return listed.files;
|
|
110
|
+
const dir = join(AGY_ROOT, 'conversations');
|
|
111
|
+
const cutoff = Date.now() - DAYS * 86400_000;
|
|
112
|
+
const files = [];
|
|
113
|
+
let names = [];
|
|
114
|
+
try {
|
|
115
|
+
names = readdirSync(dir);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
debugIo('ledger', 'agy conversations read failed', e);
|
|
118
|
+
}
|
|
119
|
+
for (const f of names) {
|
|
120
|
+
if (!f.endsWith('.db')) continue;
|
|
121
|
+
const p = join(dir, f);
|
|
122
|
+
try {
|
|
123
|
+
if (statSync(p).mtimeMs >= cutoff) files.push(p);
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
listed = { at: Date.now(), files };
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const books = new Map();
|
|
131
|
+
|
|
132
|
+
// 대화마다 날짜별 합계(days)와 5분 칸(slots), 처음 읽기가 덜 끝난 기록의 크기(pending)·전체 크기(size).
|
|
133
|
+
// node:sqlite 를 못 쓰는 Node 에서는 비어 있다 (agy 는 프로세스만 보인다).
|
|
134
|
+
export function collectAgyLedger({ budget } = {}) {
|
|
135
|
+
const out = { days: [], slots: [], pending: 0, size: 0 };
|
|
136
|
+
if (!loadSqlite() || !existsSync(join(AGY_ROOT, 'conversations'))) return out;
|
|
137
|
+
const files = recentFiles();
|
|
138
|
+
const keep = new Set(files);
|
|
139
|
+
for (const p of books.keys()) if (!keep.has(p)) books.delete(p);
|
|
140
|
+
for (const p of files) {
|
|
141
|
+
let b = books.get(p);
|
|
142
|
+
if (!b) books.set(p, (b = new AgyBook(p)));
|
|
143
|
+
b.update(budget);
|
|
144
|
+
const size = b.size;
|
|
145
|
+
out.size += size;
|
|
146
|
+
if (!b.complete) out.pending += size;
|
|
147
|
+
out.days.push(b.days);
|
|
148
|
+
out.slots.push(b.slots);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const _test = { reset: () => ((listed = { at: 0, files: [] }), books.clear()) };
|