tokentracker-cli 0.54.0 → 0.55.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/dashboard/dist/assets/{ActivityHeatmap-DIsZx2z4.js → ActivityHeatmap-f31ixbyV.js} +1 -1
- package/dashboard/dist/assets/{Card-0V2Ex9cw.js → Card-P4pcpfgk.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-DLo34VPW.js → DashboardPage-CbKBu564.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-QNynrWNU.js → DevicePage-BYPTGtFL.js} +1 -1
- package/dashboard/dist/assets/{DialogTitle-BY1-42yj.js → DialogTitle-BZf_YGYe.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-D59fo8Dn.js → FadeIn-CchoMg8O.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-DJlMAZns.js → HeaderGithubStar-CCp4lXQc.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-DVMBkpmm.js → IpCheckPage-FFn_rJ1E.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DMM05RF0.js → LandingPage-Deu1ltMr.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardAvatar-BG0bFShj.js → LeaderboardAvatar-R5cgtwIk.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-Dy5UfAYd.js → LeaderboardPage-CNfXjJ_i.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfileModal-BylAxWKx.js → LeaderboardProfileModal-Bg5oiqmZ.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfilePage-MUET5vln.js → LeaderboardProfilePage-DTE6Jfwq.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-fYoLqW5m.js → LimitsPage-plnNcsI8.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-BUYlymbq.js → LocalOnlyNotice-DboBMrlb.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-CKCLLD_7.js → LoginPage-DtILt4Dm.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-DLaSmwv9.js → PopoverPopup-B_qSI_Kj.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-B-XkZjqd.js → ResetPasswordPage-C-PiZJ1Q.js} +1 -1
- package/dashboard/dist/assets/{Select-DoNgnzPY.js → Select-CXseeIjs.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-KYJX0YNl.js → SelectItemText-DpEjTcov.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-Bt0lteef.js → SettingsPage-pOpyqaby.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-CqYjPOpv.js → SkillsPage-DdIi6lYW.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-C-dqSkpr.js → WidgetsPage-Chb3bxVH.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-gOOrJixn.js → WrappedPage-DdmCoaz8.js} +1 -1
- package/dashboard/dist/assets/{agent-logos-vN1kmaBa.js → agent-logos-YpISvAMw.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-DdyabaUL.js → arrow-up-right-ThOp2PrF.js} +1 -1
- package/dashboard/dist/assets/{download-BHKReypS.js → download-H-ks1oyK.js} +1 -1
- package/dashboard/dist/assets/{info-DjLLVV9a.js → info-D3b_SGE-.js} +1 -1
- package/dashboard/dist/assets/{main-BOS2AECp.js → main-CZFzpc5d.js} +14 -14
- package/dashboard/dist/assets/{use-limits-display-prefs-BVcWtHtV.js → use-limits-display-prefs-BzB-pF4J.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CM4dizvm.js → use-native-settings-BrJdMq29.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-BS52iVYf.js → use-usage-limits-CcbaY6uV.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-C94ZEUyU.js → useCurrency-Bf7zAMJX.js} +1 -1
- package/dashboard/dist/assets/{useScrollLock-k9nwyt1S.js → useScrollLock-BC_qj4dr.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +2 -2
- package/src/commands/init.js +37 -1
- package/src/commands/status.js +27 -0
- package/src/commands/sync.js +284 -0
- package/src/commands/uninstall.js +17 -0
- package/src/lib/passive-mode.js +11 -1
- package/src/lib/pricing/curated-overrides.json +2 -2
- package/src/lib/pricing/matcher.js +17 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +415 -12
package/src/commands/sync.js
CHANGED
|
@@ -45,6 +45,8 @@ const {
|
|
|
45
45
|
parseAntigravityIncremental,
|
|
46
46
|
resolveCodebuddyProjectFiles,
|
|
47
47
|
parseCodebuddyIncremental,
|
|
48
|
+
resolveWorkbuddyProjectFiles,
|
|
49
|
+
parseWorkbuddyIncremental,
|
|
48
50
|
resolveKiroCliSessionFiles,
|
|
49
51
|
resolveKiroCliDbPath,
|
|
50
52
|
parseKiroCliIncremental,
|
|
@@ -58,7 +60,10 @@ const {
|
|
|
58
60
|
parseGooseIncremental,
|
|
59
61
|
listDroidSettingsFiles,
|
|
60
62
|
parseDroidIncremental,
|
|
63
|
+
droidSessionIdFromPath,
|
|
64
|
+
resolveDroidModel,
|
|
61
65
|
bucketKey,
|
|
66
|
+
toUtcHalfHourStart,
|
|
62
67
|
totalsKey,
|
|
63
68
|
claudeMessageDedupKey,
|
|
64
69
|
} = require("../lib/rollout");
|
|
@@ -144,6 +149,7 @@ const CLOUD_CONVERSATIONS_BACKFILL_KEY = "cloudConversationsBackfill_2026_06";
|
|
|
144
149
|
// ~/.codex/archived_sessions/ which sync does not scan) — clearing its bucket
|
|
145
150
|
// would lose that history (ref the v6 ground-truth-repair data-loss incident).
|
|
146
151
|
const CODEX_RESCAN_DEDUP_REPAIR_KEY = "codexRescanDedupRepair_2026_06";
|
|
152
|
+
const DROID_DUP_SESSION_REPAIR_KEY = "droidDupSessionInflationRepair_2026_06";
|
|
147
153
|
|
|
148
154
|
function warnProviderParseFailure(label, err, opts) {
|
|
149
155
|
if (opts?.auto) return;
|
|
@@ -237,6 +243,7 @@ async function cmdSync(argv) {
|
|
|
237
243
|
|
|
238
244
|
await migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rolloutFiles });
|
|
239
245
|
await repairCodexRescanInflation({ cursors, queuePath, queueStatePath, rolloutFiles });
|
|
246
|
+
await repairDroidDuplicateSessionInflation({ cursors, queuePath, queueStatePath });
|
|
240
247
|
|
|
241
248
|
const openclawFiles = openclawSignal?.sessionFile
|
|
242
249
|
? [{ path: openclawSignal.sessionFile, source: "openclaw" }]
|
|
@@ -859,6 +866,36 @@ async function cmdSync(argv) {
|
|
|
859
866
|
}
|
|
860
867
|
}
|
|
861
868
|
|
|
869
|
+
// ── WorkBuddy (passive ~/.workbuddy/projects/**/*.jsonl reader) ──
|
|
870
|
+
// Tencent's WorkBuddy is a Claude Code fork in the same family as CodeBuddy;
|
|
871
|
+
// usage rides on function_call records too (not only assistant messages) and
|
|
872
|
+
// sub-agent logs nest one level deeper, so the resolver recurses. See the
|
|
873
|
+
// parser comment in rollout.js for the cache-aware token math.
|
|
874
|
+
let workbuddyResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
875
|
+
const workbuddyFiles = resolveWorkbuddyProjectFiles(process.env);
|
|
876
|
+
if (workbuddyFiles.length > 0) {
|
|
877
|
+
if (progress?.enabled) {
|
|
878
|
+
progress.start(`Parsing WorkBuddy ${renderBar(0)} | buckets 0`);
|
|
879
|
+
}
|
|
880
|
+
try {
|
|
881
|
+
workbuddyResult = await parseWorkbuddyIncremental({
|
|
882
|
+
projectFiles: workbuddyFiles,
|
|
883
|
+
cursors,
|
|
884
|
+
queuePath,
|
|
885
|
+
env: process.env,
|
|
886
|
+
onProgress: (p) => {
|
|
887
|
+
if (!progress?.enabled) return;
|
|
888
|
+
const pct = p.total > 0 ? p.index / p.total : 1;
|
|
889
|
+
progress.update(
|
|
890
|
+
`Parsing WorkBuddy ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
|
|
891
|
+
);
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
} catch (err) {
|
|
895
|
+
warnProviderParseFailure("WorkBuddy", err, opts);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
862
899
|
// ── oh-my-pi (passive ~/.omp/agent/sessions/**/*.jsonl reader) ──
|
|
863
900
|
let ompResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
864
901
|
const ompFiles = resolveOmpSessionFiles(process.env);
|
|
@@ -1155,6 +1192,7 @@ async function cmdSync(argv) {
|
|
|
1155
1192
|
kimiResult.recordsProcessed +
|
|
1156
1193
|
kimiCodeResult.recordsProcessed +
|
|
1157
1194
|
codebuddyResult.recordsProcessed +
|
|
1195
|
+
workbuddyResult.recordsProcessed +
|
|
1158
1196
|
ompResult.recordsProcessed +
|
|
1159
1197
|
piResult.recordsProcessed +
|
|
1160
1198
|
craftResult.recordsProcessed +
|
|
@@ -1180,6 +1218,7 @@ async function cmdSync(argv) {
|
|
|
1180
1218
|
kimiResult.bucketsQueued +
|
|
1181
1219
|
kimiCodeResult.bucketsQueued +
|
|
1182
1220
|
codebuddyResult.bucketsQueued +
|
|
1221
|
+
workbuddyResult.bucketsQueued +
|
|
1183
1222
|
ompResult.bucketsQueued +
|
|
1184
1223
|
piResult.bucketsQueued +
|
|
1185
1224
|
craftResult.bucketsQueued +
|
|
@@ -1242,12 +1281,14 @@ module.exports = {
|
|
|
1242
1281
|
migrateCursorUnknownBuckets,
|
|
1243
1282
|
migrateRolloutCumulativeDeltaBuckets,
|
|
1244
1283
|
repairCodexRescanInflation,
|
|
1284
|
+
repairDroidDuplicateSessionInflation,
|
|
1245
1285
|
reincludeClaudeMemObserverFiles,
|
|
1246
1286
|
repairGrokQueueFromSessionSnapshots,
|
|
1247
1287
|
applyCloudConversationsBackfill,
|
|
1248
1288
|
CURSOR_UNKNOWN_MIGRATION_KEY,
|
|
1249
1289
|
ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY,
|
|
1250
1290
|
CODEX_RESCAN_DEDUP_REPAIR_KEY,
|
|
1291
|
+
DROID_DUP_SESSION_REPAIR_KEY,
|
|
1251
1292
|
CLAUDE_MEM_OBSERVER_REINCLUDE_KEY,
|
|
1252
1293
|
GROK_APPEND_ONLY_REPAIR_MIGRATION_KEY,
|
|
1253
1294
|
CLOUD_CONVERSATIONS_BACKFILL_KEY,
|
|
@@ -2300,6 +2341,249 @@ async function repairCodexRescanInflation({ cursors, queuePath, queueStatePath,
|
|
|
2300
2341
|
return true;
|
|
2301
2342
|
}
|
|
2302
2343
|
|
|
2344
|
+
// One-time repair (#204): when the SAME Droid session id existed in two folders
|
|
2345
|
+
// under ~/.factory/sessions, parseDroidIncremental's cumulative-delta loop made the
|
|
2346
|
+
// lower-count file look like a reset and re-emitted each duplicate's full total on
|
|
2347
|
+
// EVERY sync, inflating one (droid, model, hour) bucket without bound (a real ~10M
|
|
2348
|
+
// session showed as 40.06B). The forward fix is dedupeDroidSettingsFilesBySession
|
|
2349
|
+
// inside the parser; this migration repairs already-polluted installs.
|
|
2350
|
+
//
|
|
2351
|
+
// SCOPE — strictly the duplicate sessions' buckets, and ONLY when duplicate files
|
|
2352
|
+
// still exist on disk:
|
|
2353
|
+
// * A from-zero rebuild cannot reconstruct Droid's historical per-sync bucket
|
|
2354
|
+
// distribution — settings.json carries only the CURRENT mtime, not per-turn
|
|
2355
|
+
// timestamps like Codex's jsonl. So we rebuild over the DUPLICATE files only,
|
|
2356
|
+
// and overwrite only the bucket keys those files map to (pollutedKeys) plus
|
|
2357
|
+
// the duplicate sessions' cursor entries. Every other droid bucket and cursor
|
|
2358
|
+
// — clean sessions AND deleted-session history — is left byte-for-byte intact,
|
|
2359
|
+
// so healthy history is never collapsed into the current half-hour.
|
|
2360
|
+
// * No session id has >1 file on disk → this bug never fired here: set the
|
|
2361
|
+
// sentinel, touch nothing.
|
|
2362
|
+
// * Fire only when live > rebuilt over pollutedKeys (actual inflation), so a
|
|
2363
|
+
// fresh install (live empty) is left to the normal same-sync parse.
|
|
2364
|
+
// Droid has no project dimension, so project.queue.jsonl is never involved.
|
|
2365
|
+
async function repairDroidDuplicateSessionInflation({ cursors, queuePath, queueStatePath } = {}) {
|
|
2366
|
+
if (!cursors || typeof cursors !== "object") return false;
|
|
2367
|
+
const migrations = (cursors.migrations ||= {});
|
|
2368
|
+
// Completed run → truthy non-skip sentinel (final). A {skipped:true} object would
|
|
2369
|
+
// retry (codex skip-retry semantics; this repair only ever writes done/none).
|
|
2370
|
+
const prior = migrations[DROID_DUP_SESSION_REPAIR_KEY];
|
|
2371
|
+
if (prior && !(typeof prior === "object" && prior.skipped)) return false;
|
|
2372
|
+
|
|
2373
|
+
// Group current on-disk settings files by session id; collect duplicates.
|
|
2374
|
+
let onDisk;
|
|
2375
|
+
try {
|
|
2376
|
+
onDisk = listDroidSettingsFiles(process.env);
|
|
2377
|
+
} catch {
|
|
2378
|
+
onDisk = [];
|
|
2379
|
+
}
|
|
2380
|
+
const bySession = new Map();
|
|
2381
|
+
for (const fp of onDisk) {
|
|
2382
|
+
const sid = droidSessionIdFromPath(fp);
|
|
2383
|
+
if (!sid) continue;
|
|
2384
|
+
if (!bySession.has(sid)) bySession.set(sid, []);
|
|
2385
|
+
bySession.get(sid).push(fp);
|
|
2386
|
+
}
|
|
2387
|
+
const dupFiles = [];
|
|
2388
|
+
const cleanFiles = [];
|
|
2389
|
+
for (const group of bySession.values()) {
|
|
2390
|
+
if (group.length > 1) dupFiles.push(...group);
|
|
2391
|
+
else cleanFiles.push(...group);
|
|
2392
|
+
}
|
|
2393
|
+
if (dupFiles.length === 0) {
|
|
2394
|
+
migrations[DROID_DUP_SESSION_REPAIR_KEY] = new Date().toISOString();
|
|
2395
|
+
return false;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// Map on-disk settings files to the (droid, model, half-hour) bucket keys they
|
|
2399
|
+
// emit under the parser's own keying (mirrors parseDroidIncremental).
|
|
2400
|
+
const bucketKeysForFiles = (files) => {
|
|
2401
|
+
const keys = new Set();
|
|
2402
|
+
for (const fp of files) {
|
|
2403
|
+
let mtimeMs = 0;
|
|
2404
|
+
try {
|
|
2405
|
+
mtimeMs = fssync.statSync(fp).mtimeMs;
|
|
2406
|
+
} catch {
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
let settings;
|
|
2410
|
+
try {
|
|
2411
|
+
settings = JSON.parse(fssync.readFileSync(fp, "utf8"));
|
|
2412
|
+
} catch {
|
|
2413
|
+
continue;
|
|
2414
|
+
}
|
|
2415
|
+
if (!settings || typeof settings !== "object" || !settings.tokenUsage) continue;
|
|
2416
|
+
const bucketStart = toUtcHalfHourStart(
|
|
2417
|
+
new Date(mtimeMs || Date.now()).toISOString(),
|
|
2418
|
+
);
|
|
2419
|
+
if (!bucketStart) continue;
|
|
2420
|
+
keys.add(bucketKey("droid", resolveDroidModel(settings, fp), bucketStart));
|
|
2421
|
+
}
|
|
2422
|
+
return keys;
|
|
2423
|
+
};
|
|
2424
|
+
|
|
2425
|
+
// A bucket key is (source, model, half-hour) — it carries NO session identity.
|
|
2426
|
+
// pollutedKeys are the buckets the duplicate files emit to; cleanKeys are buckets
|
|
2427
|
+
// a NON-duplicate on-disk session emits to. When a clean session resolves to the
|
|
2428
|
+
// same (model, half-hour) as a duplicate file, they collide on one key. We must
|
|
2429
|
+
// NOT delete-and-replace such a shared bucket: the rebuild runs over duplicate
|
|
2430
|
+
// files only, so replacing the bucket would erase the clean session's tokens
|
|
2431
|
+
// (silent data loss). So repair ONLY buckets owned exclusively by duplicate
|
|
2432
|
+
// sessions; leave shared buckets intact. Residual inflation in a rare shared
|
|
2433
|
+
// bucket is visible and recoverable; destroying real data is not (this is the
|
|
2434
|
+
// dedup-needs-identity-proof rule).
|
|
2435
|
+
const pollutedKeys = bucketKeysForFiles(dupFiles);
|
|
2436
|
+
const cleanKeys = bucketKeysForFiles(cleanFiles);
|
|
2437
|
+
const repairKeys = new Set();
|
|
2438
|
+
for (const k of pollutedKeys) if (!cleanKeys.has(k)) repairKeys.add(k);
|
|
2439
|
+
if (repairKeys.size === 0) {
|
|
2440
|
+
migrations[DROID_DUP_SESSION_REPAIR_KEY] = new Date().toISOString();
|
|
2441
|
+
return false;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// Ground-truth rebuild into throwaway state over the DUPLICATE files only
|
|
2445
|
+
// (parseDroidIncremental de-dupes its own input → canonical per session). On any
|
|
2446
|
+
// throw, leave all state untouched and do NOT set the sentinel (retry next sync).
|
|
2447
|
+
let rebuilt;
|
|
2448
|
+
const tmpQueue = `${queuePath}.droidrebuild.${process.pid}.${Date.now()}`;
|
|
2449
|
+
try {
|
|
2450
|
+
const tmpCursors = { hourly: { buckets: {}, groupQueued: {} }, droid: {} };
|
|
2451
|
+
await parseDroidIncremental({
|
|
2452
|
+
settingsFiles: dupFiles,
|
|
2453
|
+
cursors: tmpCursors,
|
|
2454
|
+
queuePath: tmpQueue,
|
|
2455
|
+
env: process.env,
|
|
2456
|
+
prune: true,
|
|
2457
|
+
});
|
|
2458
|
+
let tmpRaw = "";
|
|
2459
|
+
try {
|
|
2460
|
+
tmpRaw = await fs.readFile(tmpQueue, "utf8");
|
|
2461
|
+
} catch (e) {
|
|
2462
|
+
if (e?.code !== "ENOENT") throw e;
|
|
2463
|
+
}
|
|
2464
|
+
rebuilt = {
|
|
2465
|
+
buckets: tmpCursors.hourly.buckets || {},
|
|
2466
|
+
sessionTotals: (tmpCursors.droid && tmpCursors.droid.sessionTotals) || {},
|
|
2467
|
+
queueRows: tmpRaw.split("\n").filter((l) => l.trim()),
|
|
2468
|
+
};
|
|
2469
|
+
} catch (e) {
|
|
2470
|
+
console.error(
|
|
2471
|
+
"[sync] droid dup-session repair: rebuild failed, leaving all data untouched:",
|
|
2472
|
+
e?.message || e,
|
|
2473
|
+
);
|
|
2474
|
+
return false;
|
|
2475
|
+
} finally {
|
|
2476
|
+
await fs.rm(tmpQueue, { force: true }).catch(() => {});
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// Inflation present? Compare live vs rebuilt totals over the repair-scoped keys
|
|
2480
|
+
// only. Fire only on live > rebuilt (real inflation) — never on a fresh install
|
|
2481
|
+
// (live 0).
|
|
2482
|
+
const liveBuckets = (cursors.hourly && cursors.hourly.buckets) || {};
|
|
2483
|
+
let liveScoped = 0;
|
|
2484
|
+
let rebuiltScoped = 0;
|
|
2485
|
+
for (const key of repairKeys) {
|
|
2486
|
+
liveScoped += Number(liveBuckets[key]?.totals?.total_tokens || 0);
|
|
2487
|
+
rebuiltScoped += Number(rebuilt.buckets[key]?.totals?.total_tokens || 0);
|
|
2488
|
+
}
|
|
2489
|
+
if (liveScoped <= rebuiltScoped) {
|
|
2490
|
+
migrations[DROID_DUP_SESSION_REPAIR_KEY] = new Date().toISOString();
|
|
2491
|
+
return false;
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
// ── COMMIT (atomic) ──
|
|
2495
|
+
await ensureDir(path.dirname(queuePath));
|
|
2496
|
+
await backupExistingFile(queuePath);
|
|
2497
|
+
|
|
2498
|
+
// 1. queue.jsonl: keep every non-droid line verbatim (incl. unparseable) and
|
|
2499
|
+
// every droid row whose bucket key is NOT in repairKeys (clean + shared +
|
|
2500
|
+
// deleted-session history). Drop droid rows in repairKeys; append rebuilt rows.
|
|
2501
|
+
let raw = "";
|
|
2502
|
+
try {
|
|
2503
|
+
raw = await fs.readFile(queuePath, "utf8");
|
|
2504
|
+
} catch (e) {
|
|
2505
|
+
if (e?.code !== "ENOENT") throw e;
|
|
2506
|
+
}
|
|
2507
|
+
// A queue line is in scope if it's a droid row whose bucket is in repairKeys.
|
|
2508
|
+
// Unparseable / non-droid / clean / shared droid rows are kept verbatim.
|
|
2509
|
+
const isRepairDroidRow = (line) => {
|
|
2510
|
+
let row;
|
|
2511
|
+
try {
|
|
2512
|
+
row = JSON.parse(line);
|
|
2513
|
+
} catch {
|
|
2514
|
+
return false;
|
|
2515
|
+
}
|
|
2516
|
+
return (
|
|
2517
|
+
row?.source === "droid" &&
|
|
2518
|
+
repairKeys.has(bucketKey("droid", row.model, row.hour_start))
|
|
2519
|
+
);
|
|
2520
|
+
};
|
|
2521
|
+
const kept = raw
|
|
2522
|
+
.split("\n")
|
|
2523
|
+
.filter((line) => line.trim() && !isRepairDroidRow(line));
|
|
2524
|
+
const rebuiltRepairRows = rebuilt.queueRows.filter(isRepairDroidRow);
|
|
2525
|
+
const tmp = `${queuePath}.tmp.${process.pid}.${Date.now()}`;
|
|
2526
|
+
await fs.writeFile(
|
|
2527
|
+
tmp,
|
|
2528
|
+
kept.concat(rebuiltRepairRows).join("\n") + "\n",
|
|
2529
|
+
"utf8",
|
|
2530
|
+
);
|
|
2531
|
+
await fs.rename(tmp, queuePath);
|
|
2532
|
+
|
|
2533
|
+
// 2. live hourly buckets: delete repair-scoped droid keys, install the rebuilt
|
|
2534
|
+
// buckets for those keys. Other droid buckets untouched.
|
|
2535
|
+
const hourly = (cursors.hourly ||= { version: 3, buckets: {}, groupQueued: {} });
|
|
2536
|
+
hourly.buckets ||= {};
|
|
2537
|
+
hourly.groupQueued ||= {};
|
|
2538
|
+
for (const key of repairKeys) {
|
|
2539
|
+
delete hourly.buckets[key];
|
|
2540
|
+
if (rebuilt.buckets[key]) hourly.buckets[key] = rebuilt.buckets[key];
|
|
2541
|
+
}
|
|
2542
|
+
// Defensive: droid never uses the legacy aggregate path, but drop any stale droid
|
|
2543
|
+
// group markers so a repaired hour can't re-emit as model=unknown.
|
|
2544
|
+
for (const gk of Object.keys(hourly.groupQueued)) {
|
|
2545
|
+
if (gk.startsWith("droid|")) delete hourly.groupQueued[gk];
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// 3. session cursor: overwrite ONLY the duplicate sessions with the ground-truth
|
|
2549
|
+
// rebuild so the later same-sync droid parse short-circuits (mtime match) and
|
|
2550
|
+
// emits nothing. Clean sessions' cursor entries are correct already — leave
|
|
2551
|
+
// them, or the later parse would re-emit them from zero.
|
|
2552
|
+
const droidState = (cursors.droid ||= {});
|
|
2553
|
+
if (!droidState.sessionTotals || typeof droidState.sessionTotals !== "object") {
|
|
2554
|
+
droidState.sessionTotals = {};
|
|
2555
|
+
}
|
|
2556
|
+
for (const sid of Object.keys(rebuilt.sessionTotals)) {
|
|
2557
|
+
droidState.sessionTotals[sid] = rebuilt.sessionTotals[sid];
|
|
2558
|
+
}
|
|
2559
|
+
droidState.updatedAt = new Date().toISOString();
|
|
2560
|
+
|
|
2561
|
+
// 4. reset the cloud upload offset so corrected rows re-upload (idempotent upsert).
|
|
2562
|
+
if (typeof queueStatePath === "string" && queueStatePath) {
|
|
2563
|
+
let uploadState = {};
|
|
2564
|
+
try {
|
|
2565
|
+
uploadState = JSON.parse(await fs.readFile(queueStatePath, "utf8"));
|
|
2566
|
+
} catch {
|
|
2567
|
+
uploadState = {};
|
|
2568
|
+
}
|
|
2569
|
+
uploadState.offset = 0;
|
|
2570
|
+
uploadState.updatedAt = new Date().toISOString();
|
|
2571
|
+
uploadState.note = "reset_after_droid_dup_session_2026_06";
|
|
2572
|
+
await fs.writeFile(queueStatePath, JSON.stringify(uploadState));
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
migrations[DROID_DUP_SESSION_REPAIR_KEY] = {
|
|
2576
|
+
status: "done",
|
|
2577
|
+
at: new Date().toISOString(),
|
|
2578
|
+
keysRepaired: repairKeys.size,
|
|
2579
|
+
keysSkippedSharedWithCleanSession: pollutedKeys.size - repairKeys.size,
|
|
2580
|
+
liveBefore: liveScoped,
|
|
2581
|
+
rebuiltAfter: rebuiltScoped,
|
|
2582
|
+
deltaReclaimed: liveScoped - rebuiltScoped,
|
|
2583
|
+
};
|
|
2584
|
+
return true;
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2303
2587
|
// One-time repair migration: rebuild source=claude rows in queue.jsonl from
|
|
2304
2588
|
// the actual jsonl files using ccusage's algorithm (msgId+reqId global
|
|
2305
2589
|
// dedup). Earlier `reincludeClaudeMemObserverFiles` versions (v1/v2/v3) each
|
|
@@ -27,6 +27,8 @@ async function cmdUninstall(argv) {
|
|
|
27
27
|
const claudeSettingsPath = path.join(home, ".claude", "settings.json");
|
|
28
28
|
const codebuddyDir = process.env.CODEBUDDY_HOME || path.join(home, ".codebuddy");
|
|
29
29
|
const codebuddySettingsPath = path.join(codebuddyDir, "settings.json");
|
|
30
|
+
const workbuddyDir = process.env.WORKBUDDY_HOME || path.join(home, ".workbuddy");
|
|
31
|
+
const workbuddySettingsPath = path.join(workbuddyDir, "settings.json");
|
|
30
32
|
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
31
33
|
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
32
34
|
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
@@ -37,12 +39,14 @@ async function cmdUninstall(argv) {
|
|
|
37
39
|
const codeNotifyCmd = ["/usr/bin/env", "node", notifyPath, "--source=every-code"];
|
|
38
40
|
const claudeHookCommand = buildClaudeHookCommand(notifyPath);
|
|
39
41
|
const codebuddyHookCommand = buildHookCommand(notifyPath, "codebuddy");
|
|
42
|
+
const workbuddyHookCommand = buildHookCommand(notifyPath, "workbuddy");
|
|
40
43
|
const geminiHookCommand = buildGeminiHookCommand(notifyPath);
|
|
41
44
|
|
|
42
45
|
const codexConfigExists = await isFile(codexConfigPath);
|
|
43
46
|
const codeConfigExists = await isFile(codeConfigPath);
|
|
44
47
|
const claudeConfigExists = await isFile(claudeSettingsPath);
|
|
45
48
|
const codebuddyConfigExists = await isFile(codebuddySettingsPath);
|
|
49
|
+
const workbuddyConfigExists = await isFile(workbuddySettingsPath);
|
|
46
50
|
const geminiConfigExists = await isDir(geminiConfigDir);
|
|
47
51
|
const opencodeConfigExists = await isDir(opencodeConfigDir);
|
|
48
52
|
const codexRestore = codexConfigExists
|
|
@@ -68,6 +72,12 @@ async function cmdUninstall(argv) {
|
|
|
68
72
|
hookCommand: codebuddyHookCommand,
|
|
69
73
|
})
|
|
70
74
|
: { removed: false, skippedReason: "config-missing" };
|
|
75
|
+
const workbuddyRemove = workbuddyConfigExists
|
|
76
|
+
? await removeClaudeHook({
|
|
77
|
+
settingsPath: workbuddySettingsPath,
|
|
78
|
+
hookCommand: workbuddyHookCommand,
|
|
79
|
+
})
|
|
80
|
+
: { removed: false, skippedReason: "config-missing" };
|
|
71
81
|
const geminiRemove = geminiConfigExists
|
|
72
82
|
? await removeGeminiHook({ settingsPath: geminiSettingsPath, hookCommand: geminiHookCommand })
|
|
73
83
|
: { removed: false, skippedReason: "config-missing" };
|
|
@@ -132,6 +142,13 @@ async function cmdUninstall(argv) {
|
|
|
132
142
|
? "- CodeBuddy hooks: no change"
|
|
133
143
|
: "- CodeBuddy hooks: skipped"
|
|
134
144
|
: "- CodeBuddy hooks: skipped (settings.json not found)",
|
|
145
|
+
workbuddyConfigExists
|
|
146
|
+
? workbuddyRemove?.removed
|
|
147
|
+
? `- WorkBuddy hooks removed: ${workbuddySettingsPath}`
|
|
148
|
+
: workbuddyRemove?.skippedReason === "hook-missing"
|
|
149
|
+
? "- WorkBuddy hooks: no change"
|
|
150
|
+
: "- WorkBuddy hooks: skipped"
|
|
151
|
+
: "- WorkBuddy hooks: skipped (settings.json not found)",
|
|
135
152
|
geminiConfigExists
|
|
136
153
|
? geminiRemove?.removed
|
|
137
154
|
? `- Gemini hooks removed: ${geminiSettingsPath}`
|
package/src/lib/passive-mode.js
CHANGED
|
@@ -86,7 +86,7 @@ function classifyWritableFailure(filePath) {
|
|
|
86
86
|
* @param {string} opts.home - user home directory
|
|
87
87
|
* @param {Object} opts.hookStatus - per-provider hook-installed booleans
|
|
88
88
|
* (already collected by status.js); shape: { claude, gemini, codex,
|
|
89
|
-
* every_code, opencode, openclaw, codebuddy, grok }
|
|
89
|
+
* every_code, opencode, openclaw, codebuddy, workbuddy, grok }
|
|
90
90
|
* @returns {PassiveProvider[]}
|
|
91
91
|
*/
|
|
92
92
|
function detectPassiveProviders({ home, hookStatus }) {
|
|
@@ -149,6 +149,16 @@ function detectPassiveProviders({ home, hookStatus }) {
|
|
|
149
149
|
settingsPath: path.join(home, ".codebuddy", "settings.json"),
|
|
150
150
|
}));
|
|
151
151
|
|
|
152
|
+
// WorkBuddy — sibling Claude-fork; hook in ~/.workbuddy/settings.json
|
|
153
|
+
out.push(buildEntry({
|
|
154
|
+
name: "workbuddy",
|
|
155
|
+
hookExpected: true,
|
|
156
|
+
hookInstalled: Boolean(hookStatus?.workbuddy),
|
|
157
|
+
logsDir: path.join(home, ".workbuddy"),
|
|
158
|
+
logsPredicate: (_full, name) => name === "projects" || name.endsWith(".jsonl"),
|
|
159
|
+
settingsPath: path.join(home, ".workbuddy", "settings.json"),
|
|
160
|
+
}));
|
|
161
|
+
|
|
152
162
|
return out;
|
|
153
163
|
}
|
|
154
164
|
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"claude-opus-4-8": { "input": 5, "output": 25, "cache_read": 0.5, "cache_write": 6.25, "note": "Opus 4.8 not yet in LiteLLM. Pin to the standard Opus tier (same as 4.6/4.7); remove once LiteLLM carries it." },
|
|
10
10
|
"kiro-agent": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 },
|
|
11
11
|
"kiro-cli-agent": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 },
|
|
12
|
-
"hy3-preview-agent":{ "input": 0,
|
|
13
|
-
"hy3-preview": { "input": 0,
|
|
12
|
+
"hy3-preview-agent":{ "input": 0.167, "output": 0.556, "cache_read": 0.056, "cache_write": 0.167, "note": "Tencent Hunyuan Hy3 preview (CodeBuddy/WorkBuddy backend). Official TokenHub rate: 1.2 / 0.4 (cache hit) / 4.0 RMB per MTok in/read/out, converted at ~7.2 RMB/USD. DeepSeek-style cache: no write surcharge, so cache_write = input." },
|
|
13
|
+
"hy3-preview": { "input": 0.167, "output": 0.556, "cache_read": 0.056, "cache_write": 0.167, "note": "See hy3-preview-agent." },
|
|
14
14
|
"composer-1": { "input": 1.25, "output": 10, "cache_read": 0.125 },
|
|
15
15
|
"composer-1.5": { "input": 3.5, "output": 17.5, "cache_read": 0.35 },
|
|
16
16
|
"composer-2": { "input": 0.5, "output": 2.5, "cache_read": 0.2 },
|
|
@@ -104,6 +104,22 @@ function normalizeClaudeModel(model) {
|
|
|
104
104
|
return m;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// WorkBuddy's auto-router records the model as the literal "auto" and never
|
|
108
|
+
// exposes the underlying model it picked. That collides with Cursor's curated
|
|
109
|
+
// alias ("auto" -> "composer-1"), which would misprice WorkBuddy usage as
|
|
110
|
+
// Cursor's Composer. Map it instead to hy3-preview-agent — WorkBuddy's default
|
|
111
|
+
// Tencent Hunyuan model — mirroring how Cursor's "auto" maps to its own default
|
|
112
|
+
// (composer-1). The auto-router can pick pricier models, so this can slightly
|
|
113
|
+
// under-count, but it tracks the token cost of WorkBuddy's representative model
|
|
114
|
+
// rather than an unrelated vendor's. (The raw "auto" string is still
|
|
115
|
+
// stored/displayed; only the pricing lookup is remapped.)
|
|
116
|
+
function normalizeWorkbuddyModel(model) {
|
|
117
|
+
if (typeof model === "string" && model.trim().toLowerCase() === "auto") {
|
|
118
|
+
return "hy3-preview-agent";
|
|
119
|
+
}
|
|
120
|
+
return model;
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
// Per-source model-name normalizers, applied at pricing-lookup time only (the
|
|
108
124
|
// raw model name is preserved for storage/display). Add a source here when its
|
|
109
125
|
// model strings don't match the LiteLLM/curated keys verbatim.
|
|
@@ -111,6 +127,7 @@ const SOURCE_MODEL_NORMALIZERS = {
|
|
|
111
127
|
antigravity: normalizeAntigravityModel,
|
|
112
128
|
claude: normalizeClaudeModel,
|
|
113
129
|
zed: normalizeZedModel,
|
|
130
|
+
workbuddy: normalizeWorkbuddyModel,
|
|
114
131
|
};
|
|
115
132
|
|
|
116
133
|
// Memoise the sorted-by-length LiteLLM key list. Reverse-substring scan walks
|