tokentracker-cli 0.5.91 → 0.5.93

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.
@@ -4073,6 +4073,286 @@ async function parseKimiIncremental({ wireFiles, cursors, queuePath, onProgress,
4073
4073
  return { recordsProcessed, eventsAggregated, bucketsQueued };
4074
4074
  }
4075
4075
 
4076
+ // ─────────────────────────────────────────────────────────────────────────────
4077
+ // CodeBuddy CLI — passive JSONL reader (~/.codebuddy/projects/<cwd>/<sid>.jsonl)
4078
+ //
4079
+ // Tencent's CodeBuddy CLI is structurally cloned from Claude Code:
4080
+ // ~/.codebuddy/projects/<encoded-cwd>/<sessionId>.jsonl — conversation log
4081
+ // ~/.codebuddy/sessions/<pid>.json — session metadata
4082
+ // ~/.codebuddy/settings.json — `{"model": "..."}`
4083
+ //
4084
+ // CodeBuddy ships NO hook system — we incrementally tail the JSONL files on
4085
+ // each sync (passive scan only, same shape as Kimi's wire.jsonl reader).
4086
+ //
4087
+ // Per-line record types: message, reasoning, topic, file-history-snapshot.
4088
+ // Only `type=="message" && role=="assistant"` carry token usage. The shape:
4089
+ //
4090
+ // providerData.rawUsage = {
4091
+ // prompt_tokens: 22223, // OpenAI-style — INCLUDES cached
4092
+ // completion_tokens: 250,
4093
+ // prompt_tokens_details: { cached_tokens: 512, reasoning_tokens?: number },
4094
+ // cache_read_input_tokens: 0, // Anthropic-style mirror (often 0)
4095
+ // cache_creation_input_tokens: 0,
4096
+ // }
4097
+ //
4098
+ // Token math (matches the repo's queue convention; do NOT pass prompt_tokens
4099
+ // through unchanged — that double-counts cached input):
4100
+ // input_tokens = prompt_tokens - prompt_tokens_details.cached_tokens
4101
+ // cached_input_tokens = prompt_tokens_details.cached_tokens
4102
+ // cache_creation_input_tokens = cache_creation_input_tokens (often 0)
4103
+ // output_tokens = completion_tokens
4104
+ // reasoning_output_tokens = prompt_tokens_details.reasoning_tokens || 0
4105
+ // total_tokens = sum of the above
4106
+ // ─────────────────────────────────────────────────────────────────────────────
4107
+
4108
+ function resolveCodebuddyHome(env = process.env) {
4109
+ const home = env.HOME || require("node:os").homedir();
4110
+ return env.CODEBUDDY_HOME || path.join(home, ".codebuddy");
4111
+ }
4112
+
4113
+ function resolveCodebuddyDefaultModel(env = process.env) {
4114
+ const fallback = "codebuddy-unknown";
4115
+ try {
4116
+ const settingsPath = path.join(resolveCodebuddyHome(env), "settings.json");
4117
+ const raw = fssync.readFileSync(settingsPath, "utf8");
4118
+ const parsed = JSON.parse(raw);
4119
+ if (parsed && typeof parsed === "object" && typeof parsed.model === "string" && parsed.model.trim()) {
4120
+ return parsed.model.trim();
4121
+ }
4122
+ } catch (_e) {
4123
+ // settings missing or malformed — fall through
4124
+ }
4125
+ return fallback;
4126
+ }
4127
+
4128
+ function resolveCodebuddyProjectFiles(env = process.env) {
4129
+ const projectsDir = path.join(resolveCodebuddyHome(env), "projects");
4130
+ if (!fssync.existsSync(projectsDir)) return [];
4131
+ const files = [];
4132
+ try {
4133
+ for (const cwd of fssync.readdirSync(projectsDir)) {
4134
+ const cwdDir = path.join(projectsDir, cwd);
4135
+ let stat;
4136
+ try { stat = fssync.statSync(cwdDir); } catch { continue; }
4137
+ if (!stat.isDirectory()) continue;
4138
+ let entries;
4139
+ try { entries = fssync.readdirSync(cwdDir); } catch { continue; }
4140
+ for (const entry of entries) {
4141
+ if (!entry.endsWith(".jsonl")) continue;
4142
+ files.push(path.join(cwdDir, entry));
4143
+ }
4144
+ }
4145
+ } catch {
4146
+ // ignore — return what we have
4147
+ }
4148
+ files.sort((a, b) => a.localeCompare(b));
4149
+ return files;
4150
+ }
4151
+
4152
+ async function parseCodebuddyIncremental({
4153
+ projectFiles,
4154
+ cursors,
4155
+ queuePath,
4156
+ onProgress,
4157
+ env,
4158
+ defaultModel,
4159
+ } = {}) {
4160
+ await ensureDir(path.dirname(queuePath));
4161
+ const codebuddyState =
4162
+ cursors.codebuddy && typeof cursors.codebuddy === "object" ? cursors.codebuddy : {};
4163
+ const seenIds = new Set(
4164
+ Array.isArray(codebuddyState.seenIds) ? codebuddyState.seenIds : [],
4165
+ );
4166
+ const fileOffsets =
4167
+ codebuddyState.fileOffsets && typeof codebuddyState.fileOffsets === "object"
4168
+ ? { ...codebuddyState.fileOffsets }
4169
+ : {};
4170
+
4171
+ const files = Array.isArray(projectFiles)
4172
+ ? projectFiles
4173
+ : resolveCodebuddyProjectFiles(env || process.env);
4174
+ const fallbackModel = defaultModel || resolveCodebuddyDefaultModel(env || process.env);
4175
+
4176
+ if (files.length === 0) {
4177
+ cursors.codebuddy = {
4178
+ ...codebuddyState,
4179
+ seenIds: Array.from(seenIds),
4180
+ fileOffsets,
4181
+ updatedAt: new Date().toISOString(),
4182
+ };
4183
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
4184
+ }
4185
+
4186
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
4187
+ const touchedBuckets = new Set();
4188
+ const cb = typeof onProgress === "function" ? onProgress : null;
4189
+ let recordsProcessed = 0;
4190
+ let eventsAggregated = 0;
4191
+
4192
+ for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
4193
+ const filePath = files[fileIdx];
4194
+ let stat;
4195
+ try { stat = fssync.statSync(filePath); } catch { continue; }
4196
+
4197
+ const prevEntry = fileOffsets[filePath] || {};
4198
+ const prevSize = Number(prevEntry.size) || 0;
4199
+ const prevIno = prevEntry.ino;
4200
+ // Re-read from start if file shrunk (truncate/rewrite) or inode changed
4201
+ // (file deleted + recreated). Otherwise pick up after the last read offset.
4202
+ const inodeChanged = typeof prevIno === "number" && prevIno !== stat.ino;
4203
+ const startOffset = stat.size < prevSize || inodeChanged ? 0 : prevSize;
4204
+ if (stat.size <= startOffset) continue;
4205
+
4206
+ let stream;
4207
+ try {
4208
+ stream = fssync.createReadStream(filePath, {
4209
+ encoding: "utf8",
4210
+ start: startOffset,
4211
+ });
4212
+ } catch { continue; }
4213
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
4214
+
4215
+ for await (const line of rl) {
4216
+ if (!line || !line.trim()) continue;
4217
+ let entry;
4218
+ try { entry = JSON.parse(line); } catch { continue; }
4219
+
4220
+ // Only assistant message events carry token usage.
4221
+ if (!entry || entry.type !== "message" || entry.role !== "assistant") continue;
4222
+
4223
+ const provider = entry.providerData;
4224
+ const rawUsage = provider && typeof provider === "object" ? provider.rawUsage : null;
4225
+ if (!rawUsage || typeof rawUsage !== "object") continue;
4226
+
4227
+ // Dedup per-message — message id (uuid) is most stable, then session +
4228
+ // timestamp as fallback.
4229
+ const sessionId =
4230
+ typeof entry.sessionId === "string" && entry.sessionId
4231
+ ? entry.sessionId
4232
+ : path.basename(filePath, ".jsonl");
4233
+ const tsMs =
4234
+ Number.isFinite(Number(entry.timestamp)) && Number(entry.timestamp) > 0
4235
+ ? Number(entry.timestamp)
4236
+ : null;
4237
+ const messageId =
4238
+ typeof entry.uuid === "string" && entry.uuid
4239
+ ? entry.uuid
4240
+ : typeof entry.id === "string" && entry.id
4241
+ ? entry.id
4242
+ : tsMs != null
4243
+ ? `${sessionId}:${tsMs}`
4244
+ : null;
4245
+ if (!messageId) continue;
4246
+ if (seenIds.has(messageId)) continue;
4247
+
4248
+ recordsProcessed++;
4249
+
4250
+ const promptTokens = toNonNegativeInt(rawUsage.prompt_tokens);
4251
+ const completionTokens = toNonNegativeInt(rawUsage.completion_tokens);
4252
+ const details =
4253
+ rawUsage.prompt_tokens_details && typeof rawUsage.prompt_tokens_details === "object"
4254
+ ? rawUsage.prompt_tokens_details
4255
+ : {};
4256
+ const cachedTokens = toNonNegativeInt(details.cached_tokens);
4257
+ // Anthropic-style mirror; CodeBuddy emits these too even if usually 0.
4258
+ const cacheReadAlt = toNonNegativeInt(rawUsage.cache_read_input_tokens);
4259
+ const cacheCreation = toNonNegativeInt(rawUsage.cache_creation_input_tokens);
4260
+ const reasoningTokens = toNonNegativeInt(details.reasoning_tokens);
4261
+
4262
+ // CRITICAL: prompt_tokens is OpenAI-style and INCLUDES cached.
4263
+ // Subtract cached so input_tokens is pure non-cached input — matches
4264
+ // the repo's normalization convention (see CLAUDE.md "Token
4265
+ // Normalization Convention"). cache_read takes the larger of the two
4266
+ // mirrored fields (rawUsage.cache_read_input_tokens vs
4267
+ // prompt_tokens_details.cached_tokens) since CodeBuddy populates one
4268
+ // or the other depending on upstream provider.
4269
+ const cacheRead = Math.max(cachedTokens, cacheReadAlt);
4270
+ const inputTokens = Math.max(0, promptTokens - cacheRead);
4271
+
4272
+ if (
4273
+ inputTokens === 0 &&
4274
+ completionTokens === 0 &&
4275
+ cacheRead === 0 &&
4276
+ cacheCreation === 0
4277
+ ) {
4278
+ seenIds.add(messageId);
4279
+ continue;
4280
+ }
4281
+
4282
+ if (tsMs == null) {
4283
+ seenIds.add(messageId);
4284
+ continue;
4285
+ }
4286
+ const tsIso = new Date(tsMs).toISOString();
4287
+ const bucketStart = toUtcHalfHourStart(tsIso);
4288
+ if (!bucketStart) continue;
4289
+
4290
+ const model =
4291
+ normalizeModelInput(provider?.model) ||
4292
+ normalizeModelInput(entry.model) ||
4293
+ fallbackModel;
4294
+
4295
+ const delta = {
4296
+ input_tokens: inputTokens,
4297
+ cached_input_tokens: cacheRead,
4298
+ cache_creation_input_tokens: cacheCreation,
4299
+ output_tokens: completionTokens,
4300
+ reasoning_output_tokens: reasoningTokens,
4301
+ total_tokens:
4302
+ inputTokens + completionTokens + cacheRead + cacheCreation + reasoningTokens,
4303
+ conversation_count: 1,
4304
+ };
4305
+
4306
+ const bucket = getHourlyBucket(hourlyState, "codebuddy", model, bucketStart);
4307
+ addTotals(bucket.totals, delta);
4308
+ touchedBuckets.add(bucketKey("codebuddy", model, bucketStart));
4309
+ seenIds.add(messageId);
4310
+ eventsAggregated++;
4311
+
4312
+ if (cb) {
4313
+ cb({
4314
+ index: fileIdx + 1,
4315
+ total: files.length,
4316
+ recordsProcessed,
4317
+ eventsAggregated,
4318
+ bucketsQueued: touchedBuckets.size,
4319
+ });
4320
+ }
4321
+ }
4322
+
4323
+ let postStat = stat;
4324
+ try { postStat = fssync.statSync(filePath); } catch {}
4325
+ fileOffsets[filePath] = {
4326
+ size: postStat.size,
4327
+ mtimeMs: postStat.mtimeMs,
4328
+ ino: postStat.ino,
4329
+ };
4330
+ }
4331
+
4332
+ // Cap dedup set to last 10k IDs to bound cursor state size — same convention
4333
+ // as Kimi/Copilot so cursors.json doesn't grow unbounded.
4334
+ const seenArr = Array.from(seenIds);
4335
+ const cappedSeen =
4336
+ seenArr.length > 10_000 ? seenArr.slice(seenArr.length - 10_000) : seenArr;
4337
+
4338
+ const bucketsQueued = await enqueueTouchedBuckets({
4339
+ queuePath,
4340
+ hourlyState,
4341
+ touchedBuckets,
4342
+ });
4343
+ const updatedAt = new Date().toISOString();
4344
+ hourlyState.updatedAt = updatedAt;
4345
+ cursors.hourly = hourlyState;
4346
+ cursors.codebuddy = {
4347
+ ...codebuddyState,
4348
+ seenIds: cappedSeen,
4349
+ fileOffsets,
4350
+ updatedAt,
4351
+ };
4352
+
4353
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
4354
+ }
4355
+
4076
4356
  // ─────────────────────────────────────────────────────────────────────────────
4077
4357
  // GitHub Copilot CLI — OpenTelemetry JSONL exporter
4078
4358
  // User must opt in by setting:
@@ -4286,6 +4566,10 @@ module.exports = {
4286
4566
  resolveKimiWireFiles,
4287
4567
  resolveKimiDefaultModel,
4288
4568
  parseKimiIncremental,
4569
+ resolveCodebuddyHome,
4570
+ resolveCodebuddyProjectFiles,
4571
+ resolveCodebuddyDefaultModel,
4572
+ parseCodebuddyIncremental,
4289
4573
  resolveKiroCliSessionFiles,
4290
4574
  resolveKiroCliDbPath,
4291
4575
  parseKiroCliIncremental,
@@ -7,6 +7,7 @@ async function resolveTrackerPaths({ home = os.homedir() } = {}) {
7
7
  rootDir,
8
8
  trackerDir: path.join(rootDir, "tracker"),
9
9
  binDir: path.join(rootDir, "bin"),
10
+ cacheDir: path.join(rootDir, "cache"),
10
11
  };
11
12
  }
12
13