ccc-notifier 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.
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ aggregateNewTurn
4
+ } from "./chunk-LHKBGA5K.js";
5
+ import {
6
+ loadCursor,
7
+ logError,
8
+ sanitizeCursor
9
+ } from "./chunk-ECADO26T.js";
10
+
11
+ // src/codex/transcript.ts
12
+ import { readFile } from "fs/promises";
13
+ import { basename } from "path";
14
+ var NEWLINE = 10;
15
+ function isRecord(v) {
16
+ return typeof v === "object" && v !== null && !Array.isArray(v);
17
+ }
18
+ function numOf(v) {
19
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
20
+ }
21
+ function strOrNull(v) {
22
+ return typeof v === "string" ? v : null;
23
+ }
24
+ function zeroTotals() {
25
+ return { input: 0, cached: 0, output: 0 };
26
+ }
27
+ function isZeroTotals(t) {
28
+ return t.input === 0 && t.cached === 0 && t.output === 0;
29
+ }
30
+ function addTotals(target, d) {
31
+ target.input += d.input;
32
+ target.cached += d.cached;
33
+ target.output += d.output;
34
+ }
35
+ function readTotals(v) {
36
+ if (!isRecord(v)) return null;
37
+ return {
38
+ input: numOf(v.input_tokens),
39
+ cached: numOf(v.cached_input_tokens),
40
+ output: numOf(v.output_tokens)
41
+ };
42
+ }
43
+ function totalsToBuckets(acc) {
44
+ return {
45
+ input: Math.max(0, acc.input - acc.cached),
46
+ output: acc.output,
47
+ cacheWrite5m: 0,
48
+ cacheWrite1h: 0,
49
+ cacheRead: acc.cached
50
+ };
51
+ }
52
+ function sessionIdFromFilename(rolloutPath) {
53
+ const m = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(
54
+ basename(rolloutPath)
55
+ );
56
+ return m !== null ? m[1] : "";
57
+ }
58
+ async function readAll(path) {
59
+ try {
60
+ return await readFile(path);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+ function newSegmentBuf() {
66
+ return {
67
+ acc: zeroTotals(),
68
+ apiCalls: 0,
69
+ prompt: null,
70
+ turnCtxCwd: null,
71
+ firstTs: null,
72
+ endTs: null,
73
+ hasLines: false
74
+ };
75
+ }
76
+ async function scanWindow(rolloutPath, cursor) {
77
+ const buffer = await readAll(rolloutPath);
78
+ if (buffer === null) return null;
79
+ const fileSize = buffer.length;
80
+ let startOffset;
81
+ let rescan;
82
+ if (cursor !== null && cursor.offset > 0 && cursor.offset <= fileSize && buffer[cursor.offset - 1] === NEWLINE) {
83
+ startOffset = cursor.offset;
84
+ rescan = false;
85
+ } else {
86
+ startOffset = 0;
87
+ rescan = cursor !== null;
88
+ }
89
+ const tsFloor = cursor?.lastTs ?? null;
90
+ const initTotals = cursor?.codexTotals;
91
+ let prev = initTotals !== void 0 ? { ...initTotals } : zeroTotals();
92
+ const acc = zeroTotals();
93
+ let apiCalls = 0;
94
+ let lastModel = null;
95
+ let windowPrompt = null;
96
+ let windowTurnCtxCwd = null;
97
+ let sessionMetaCwd = null;
98
+ let sessionMetaSid = null;
99
+ let firstTs = null;
100
+ let lastTs = null;
101
+ const segments = [];
102
+ let seg = newSegmentBuf();
103
+ const snapshotSegment = (endOffset) => ({
104
+ acc: seg.acc,
105
+ apiCalls: seg.apiCalls,
106
+ prompt: seg.prompt,
107
+ model: lastModel,
108
+ cwd: seg.turnCtxCwd ?? sessionMetaCwd,
109
+ firstTs: seg.firstTs,
110
+ endTs: seg.endTs,
111
+ endOffset,
112
+ prevAtEnd: { ...prev },
113
+ lastTsAtEnd: lastTs
114
+ });
115
+ const handleLine = (raw, endOffset) => {
116
+ if (raw.trim().length === 0) return;
117
+ let obj;
118
+ try {
119
+ obj = JSON.parse(raw);
120
+ } catch {
121
+ return;
122
+ }
123
+ if (!isRecord(obj)) return;
124
+ const ts = strOrNull(obj.timestamp);
125
+ if (rescan && tsFloor !== null && ts !== null && ts <= tsFloor) return;
126
+ if (ts !== null) {
127
+ if (firstTs === null || ts < firstTs) firstTs = ts;
128
+ if (lastTs === null || ts > lastTs) lastTs = ts;
129
+ if (seg.firstTs === null || ts < seg.firstTs) seg.firstTs = ts;
130
+ seg.endTs = ts;
131
+ }
132
+ seg.hasLines = true;
133
+ const payload = isRecord(obj.payload) ? obj.payload : null;
134
+ if (payload === null) return;
135
+ const type = obj.type;
136
+ if (type === "session_meta") {
137
+ const sid = strOrNull(payload.session_id);
138
+ if (sid !== null) sessionMetaSid = sid;
139
+ const c = strOrNull(payload.cwd);
140
+ if (c !== null) sessionMetaCwd = c;
141
+ return;
142
+ }
143
+ if (type === "turn_context") {
144
+ const m = strOrNull(payload.model);
145
+ if (m !== null) lastModel = m;
146
+ const c = strOrNull(payload.cwd);
147
+ if (c !== null) {
148
+ seg.turnCtxCwd = c;
149
+ windowTurnCtxCwd = c;
150
+ }
151
+ return;
152
+ }
153
+ if (type !== "event_msg") return;
154
+ const kind = payload.type;
155
+ if (kind === "user_message") {
156
+ const msg = strOrNull(payload.message);
157
+ if (msg !== null) {
158
+ seg.prompt = msg;
159
+ windowPrompt = msg;
160
+ }
161
+ return;
162
+ }
163
+ if (kind === "token_count") {
164
+ const info = isRecord(payload.info) ? payload.info : null;
165
+ if (info === null) return;
166
+ const total = readTotals(info.total_token_usage);
167
+ if (total === null) return;
168
+ let step = {
169
+ input: total.input - prev.input,
170
+ cached: total.cached - prev.cached,
171
+ output: total.output - prev.output
172
+ };
173
+ if (step.input < 0 || step.cached < 0 || step.output < 0) {
174
+ step = readTotals(info.last_token_usage) ?? zeroTotals();
175
+ }
176
+ addTotals(acc, step);
177
+ addTotals(seg.acc, step);
178
+ prev = total;
179
+ if (!isZeroTotals(step)) {
180
+ apiCalls++;
181
+ seg.apiCalls++;
182
+ }
183
+ return;
184
+ }
185
+ if (kind === "task_complete") {
186
+ segments.push(snapshotSegment(endOffset));
187
+ seg = newSegmentBuf();
188
+ }
189
+ };
190
+ let lineStart = startOffset;
191
+ for (let pos = startOffset; pos < fileSize; pos++) {
192
+ if (buffer[pos] !== NEWLINE) continue;
193
+ handleLine(buffer.toString("utf8", lineStart, pos), pos + 1);
194
+ lineStart = pos + 1;
195
+ }
196
+ const newOffset = lineStart;
197
+ const open = seg.hasLines ? snapshotSegment(newOffset) : null;
198
+ return {
199
+ segments,
200
+ open,
201
+ acc,
202
+ prev,
203
+ apiCalls,
204
+ model: lastModel,
205
+ prompt: windowPrompt,
206
+ cwd: windowTurnCtxCwd ?? sessionMetaCwd,
207
+ sessionId: sessionMetaSid ?? sessionIdFromFilename(rolloutPath),
208
+ firstTs,
209
+ lastTs,
210
+ newOffset
211
+ };
212
+ }
213
+ function windowCursor(scan) {
214
+ return {
215
+ offset: scan.newOffset,
216
+ lastUuid: null,
217
+ // rollout に uuid 行は無い
218
+ lastTs: scan.lastTs,
219
+ seenMessageKeys: [],
220
+ // 去重は codexTotals の差分方式が担う
221
+ codexTotals: { ...scan.prev }
222
+ };
223
+ }
224
+ async function aggregateCodexTurn(rolloutPath, cursor) {
225
+ const scan = await scanWindow(rolloutPath, cursor);
226
+ if (scan === null || isZeroTotals(scan.acc)) return null;
227
+ return {
228
+ sessionId: scan.sessionId,
229
+ main: { [scan.model ?? "unknown"]: totalsToBuckets(scan.acc) },
230
+ sidechain: {},
231
+ // Codex にサブエージェント概念は無い
232
+ apiCalls: scan.apiCalls,
233
+ prompt: scan.prompt,
234
+ cwd: scan.cwd,
235
+ gitBranch: null,
236
+ // rollout に無い
237
+ firstTs: scan.firstTs,
238
+ lastTs: scan.lastTs,
239
+ newCursor: windowCursor(scan)
240
+ };
241
+ }
242
+ async function splitIntoCodexTurnDrafts(rolloutPath, cursor) {
243
+ const scan = await scanWindow(rolloutPath, cursor);
244
+ if (scan === null || isZeroTotals(scan.acc)) return null;
245
+ const picked = scan.segments.filter((s) => !isZeroTotals(s.acc));
246
+ if (scan.open !== null && !isZeroTotals(scan.open.acc)) {
247
+ const last = picked[picked.length - 1];
248
+ if (last !== void 0) {
249
+ addTotals(last.acc, scan.open.acc);
250
+ last.apiCalls += scan.open.apiCalls;
251
+ if (scan.open.endTs !== null) last.endTs = scan.open.endTs;
252
+ } else {
253
+ picked.push(scan.open);
254
+ }
255
+ }
256
+ const lastIndex = picked.length - 1;
257
+ return picked.map((s, i) => ({
258
+ agg: {
259
+ sessionId: scan.sessionId,
260
+ // session_meta はファイル先頭にしか無いので全ドラフト共通
261
+ main: { [s.model ?? "unknown"]: totalsToBuckets(s.acc) },
262
+ sidechain: {},
263
+ apiCalls: s.apiCalls,
264
+ prompt: s.prompt,
265
+ cwd: s.cwd,
266
+ gitBranch: null,
267
+ firstTs: s.firstTs,
268
+ lastTs: s.endTs,
269
+ // 最後のドラフトはウィンドウ全体を消費した状態(= aggregateCodexTurn の newCursor と同一。
270
+ // 末尾の usage ゼロな行の読み捨てもここに含まれる)。途中のドラフトはそのセグメント末尾を
271
+ // 指す有効な再開点(そこから読み直せば残りが差分になる)。
272
+ newCursor: i === lastIndex ? windowCursor(scan) : {
273
+ offset: s.endOffset,
274
+ lastUuid: null,
275
+ lastTs: s.lastTsAtEnd,
276
+ seenMessageKeys: [],
277
+ codexTotals: { ...s.prevAtEnd }
278
+ }
279
+ },
280
+ endTs: s.endTs
281
+ }));
282
+ }
283
+
284
+ // src/subagents.ts
285
+ import { promises as fs } from "fs";
286
+ import { join } from "path";
287
+ var MAX_AGENT_FILES = 200;
288
+ function emptyBuckets() {
289
+ return { input: 0, output: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0 };
290
+ }
291
+ function addToModel(target, model, b) {
292
+ const cur = target[model] ?? emptyBuckets();
293
+ cur.input += b.input;
294
+ cur.output += b.output;
295
+ cur.cacheWrite5m += b.cacheWrite5m;
296
+ cur.cacheWrite1h += b.cacheWrite1h;
297
+ cur.cacheRead += b.cacheRead;
298
+ target[model] = cur;
299
+ }
300
+ function mergeUsage(target, src) {
301
+ for (const [model, b] of Object.entries(src)) addToModel(target, model, b);
302
+ }
303
+ function subagentsDirOf(mainTranscriptPath) {
304
+ const base = mainTranscriptPath.endsWith(".jsonl") ? mainTranscriptPath.slice(0, -".jsonl".length) : mainTranscriptPath;
305
+ return join(base, "subagents");
306
+ }
307
+ async function listAgentFiles(dir, entries) {
308
+ const files = entries.filter((e) => e.isFile() && e.name.startsWith("agent-") && e.name.endsWith(".jsonl")).map((e) => join(dir, e.name));
309
+ if (files.length <= MAX_AGENT_FILES) return files;
310
+ const withMtime = [];
311
+ for (const p of files) {
312
+ let mtime = 0;
313
+ try {
314
+ mtime = (await fs.stat(p)).mtimeMs;
315
+ } catch {
316
+ mtime = 0;
317
+ }
318
+ withMtime.push({ path: p, mtime });
319
+ }
320
+ withMtime.sort((a, b) => b.mtime - a.mtime);
321
+ return withMtime.slice(0, MAX_AGENT_FILES).map((x) => x.path);
322
+ }
323
+ async function collectSubagentUsage(mainTranscriptPath) {
324
+ const dir = subagentsDirOf(mainTranscriptPath);
325
+ let entries;
326
+ try {
327
+ entries = await fs.readdir(dir, { withFileTypes: true });
328
+ } catch {
329
+ return null;
330
+ }
331
+ const files = await listAgentFiles(dir, entries);
332
+ const perModel = {};
333
+ let apiCalls = 0;
334
+ let agentFiles = 0;
335
+ const newCursors = [];
336
+ for (const filePath of files) {
337
+ try {
338
+ const cursor = sanitizeCursor(loadCursor(filePath));
339
+ const agg = await aggregateNewTurn(filePath, cursor);
340
+ if (agg === null) continue;
341
+ mergeUsage(perModel, agg.main);
342
+ mergeUsage(perModel, agg.sidechain);
343
+ apiCalls += agg.apiCalls;
344
+ agentFiles += 1;
345
+ newCursors.push({ path: filePath, cursor: agg.newCursor });
346
+ } catch (err) {
347
+ logError("subagents:file", err);
348
+ }
349
+ }
350
+ return { perModel, apiCalls, agentFiles, newCursors };
351
+ }
352
+
353
+ export {
354
+ aggregateCodexTurn,
355
+ splitIntoCodexTurnDrafts,
356
+ collectSubagentUsage
357
+ };
@@ -170,7 +170,7 @@ function loadCursor(transcriptPath) {
170
170
  }
171
171
  function sanitizeCursor(raw) {
172
172
  if (!isPlainObject(raw)) return null;
173
- const { offset, lastUuid, lastTs, seenMessageKeys } = raw;
173
+ const { offset, lastUuid, lastTs, seenMessageKeys, codexTotals } = raw;
174
174
  if (typeof offset !== "number" || !Number.isFinite(offset)) return null;
175
175
  if (lastUuid !== null && typeof lastUuid !== "string") return null;
176
176
  if (lastTs !== null && typeof lastTs !== "string") return null;
@@ -180,7 +180,14 @@ function sanitizeCursor(raw) {
180
180
  if (typeof key !== "string") return null;
181
181
  keys.push(key);
182
182
  }
183
- return { offset, lastUuid, lastTs, seenMessageKeys: keys };
183
+ const cursor = { offset, lastUuid, lastTs, seenMessageKeys: keys };
184
+ if (isPlainObject(codexTotals)) {
185
+ const { input, cached, output } = codexTotals;
186
+ if (typeof input === "number" && Number.isFinite(input) && input >= 0 && typeof cached === "number" && Number.isFinite(cached) && cached >= 0 && typeof output === "number" && Number.isFinite(output) && output >= 0) {
187
+ cursor.codexTotals = { input, cached, output };
188
+ }
189
+ }
190
+ return cursor;
184
191
  }
185
192
  function saveCursor(transcriptPath, c) {
186
193
  const p = paths();
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/codex/env.ts
4
+ import { statSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ function codexHome() {
8
+ return process.env.CCCN_CODEX_HOME || join(homedir(), ".codex");
9
+ }
10
+ function detectCodex() {
11
+ try {
12
+ return statSync(codexHome()).isDirectory();
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export {
19
+ codexHome,
20
+ detectCodex
21
+ };
@@ -29,6 +29,9 @@ function capitalize(token) {
29
29
  var ALPHA_ONLY = /^[A-Za-z]+$/;
30
30
  var NUMERIC_ONLY = /^\d+$/;
31
31
  function modelDisplayName(id) {
32
+ if (id.startsWith("gpt-")) {
33
+ return `GPT-${id.slice(4)}`.replace(/-codex$/, " Codex");
34
+ }
32
35
  let s = id;
33
36
  s = s.replace(/^claude-/, "");
34
37
  s = s.replace(/-20\d{6}/, "");
@@ -29,7 +29,14 @@ function builtinPriceTable() {
29
29
  "claude-3-5-sonnet": price(3, 15, 3.75, 6, 0.3, "builtin"),
30
30
  "claude-haiku-4-5": price(1, 5, 1.25, 2, 0.1, "builtin"),
31
31
  "claude-3-5-haiku": price(0.8, 4, 1, 1.6, 0.08, "builtin"),
32
- "claude-3-haiku": price(0.25, 1.25, 0.3125, 0.5, 0.025, "builtin")
32
+ "claude-3-haiku": price(0.25, 1.25, 0.3125, 0.5, 0.025, "builtin"),
33
+ // OpenAI Codex CLI 対応(公式レートに基づく単価。キャッシュ書き込み課金は無いため 0)
34
+ "gpt-5.5": price(5, 30, 0, 0, 0.5, "builtin"),
35
+ "gpt-5.1": price(1.25, 10, 0, 0, 0.125, "builtin"),
36
+ "gpt-5": price(1.25, 10, 0, 0, 0.125, "builtin"),
37
+ "gpt-5-codex": price(1.25, 10, 0, 0, 0.125, "builtin"),
38
+ "gpt-5.1-codex": price(1.25, 10, 0, 0, 0.125, "builtin"),
39
+ "o3": price(2, 8, 0, 0, 0.5, "builtin")
33
40
  };
34
41
  }
35
42
  function normalizeModelId(modelId) {
@@ -104,6 +111,7 @@ function isCacheFresh(fetchedAt) {
104
111
  function toFiniteNumber(v) {
105
112
  return typeof v === "number" && Number.isFinite(v) ? v : null;
106
113
  }
114
+ var LITELLM_OPENAI_KEY_RE = /^(gpt-|o3($|-)|codex-)/;
107
115
  function convertLiteLLMPayload(payload) {
108
116
  if (payload === null || typeof payload !== "object") {
109
117
  throw new Error("invalid litellm payload: not an object");
@@ -113,6 +121,24 @@ function convertLiteLLMPayload(payload) {
113
121
  if (rawEntry === null || typeof rawEntry !== "object") continue;
114
122
  const entry = rawEntry;
115
123
  const provider = entry.litellm_provider;
124
+ if (provider === "openai") {
125
+ const key2 = rawKey.toLowerCase();
126
+ if (!LITELLM_OPENAI_KEY_RE.test(key2)) continue;
127
+ const inputRaw2 = toFiniteNumber(entry.input_cost_per_token);
128
+ const outputRaw2 = toFiniteNumber(entry.output_cost_per_token);
129
+ if (inputRaw2 === null || inputRaw2 <= 0) continue;
130
+ if (outputRaw2 === null || outputRaw2 <= 0) continue;
131
+ const cacheReadRaw2 = toFiniteNumber(entry.cache_read_input_token_cost);
132
+ table[key2] = {
133
+ input: inputRaw2 * 1e6,
134
+ output: outputRaw2 * 1e6,
135
+ cacheRead: cacheReadRaw2 !== null ? cacheReadRaw2 * 1e6 : 0,
136
+ cacheWrite5m: 0,
137
+ cacheWrite1h: 0,
138
+ source: "litellm"
139
+ };
140
+ continue;
141
+ }
116
142
  if (typeof provider === "string" && provider !== "anthropic") continue;
117
143
  let key = rawKey.toLowerCase();
118
144
  if (key.startsWith("anthropic/")) key = key.slice("anthropic/".length);
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-DGXUSPS4.js";
5
5
  import {
6
6
  formatSummary
7
- } from "./chunk-TBFKGFZX.js";
7
+ } from "./chunk-J5QAYTFE.js";
8
8
 
9
9
  // src/notify/os.ts
10
10
  import { spawn } from "child_process";