tokentracker-cli 0.54.0 → 0.56.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-B_tfEHcm.js} +1 -1
- package/dashboard/dist/assets/{Card-0V2Ex9cw.js → Card-D-kLfqyr.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-DLo34VPW.js → DashboardPage-C97dctZq.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-QNynrWNU.js → DevicePage-B36WBvsn.js} +1 -1
- package/dashboard/dist/assets/{DialogTitle-BY1-42yj.js → DialogTitle--VIyiTEE.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-D59fo8Dn.js → FadeIn-B8pvdF9J.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-DJlMAZns.js → HeaderGithubStar-B0gHTi9N.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-DVMBkpmm.js → IpCheckPage-Bq6MMJHQ.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DMM05RF0.js → LandingPage-CZIEkNWt.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardAvatar-BG0bFShj.js → LeaderboardAvatar-BMRcGGFZ.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-Dy5UfAYd.js → LeaderboardPage-B5ffZ11y.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfileModal-BylAxWKx.js → LeaderboardProfileModal-Bz2n03P4.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfilePage-MUET5vln.js → LeaderboardProfilePage-BP5loLjy.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-DzJSi6xG.js +2 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-BUYlymbq.js → LocalOnlyNotice-CvfM7Yeu.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-CKCLLD_7.js → LoginPage-D2LALlWv.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-DLaSmwv9.js → PopoverPopup-BAT6qwPl.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-B-XkZjqd.js → ResetPasswordPage-s76Qggro.js} +1 -1
- package/dashboard/dist/assets/{Select-DoNgnzPY.js → Select-CMEwM-Mo.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-KYJX0YNl.js → SelectItemText-s-8ZSDQn.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-Bt0lteef.js → SettingsPage-BKzrvCej.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-CqYjPOpv.js → SkillsPage-BX5iuYSx.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-C-dqSkpr.js → WidgetsPage-CBD2lBZ1.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-gOOrJixn.js → WrappedPage-DpNAeMIM.js} +1 -1
- package/dashboard/dist/assets/{agent-logos-vN1kmaBa.js → agent-logos-Bggjr2yj.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-DdyabaUL.js → arrow-up-right-C6z7x7NL.js} +1 -1
- package/dashboard/dist/assets/{download-BHKReypS.js → download-DBjVOuOZ.js} +1 -1
- package/dashboard/dist/assets/{info-DjLLVV9a.js → info-DJ0Ty3Yt.js} +1 -1
- package/dashboard/dist/assets/main-Bb0Bwbp7.css +1 -0
- package/dashboard/dist/assets/{main-BOS2AECp.js → main-Cqhrkqr2.js} +17 -14
- package/dashboard/dist/assets/{use-limits-display-prefs-BVcWtHtV.js → use-limits-display-prefs-DddAHmHH.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CM4dizvm.js → use-native-settings-Cha6She4.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-BS52iVYf.js → use-usage-limits-BJXjE59K.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-C94ZEUyU.js → useCurrency-C63XmlQt.js} +1 -1
- package/dashboard/dist/assets/{useScrollLock-k9nwyt1S.js → useScrollLock-CHF80tR1.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- 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/lib/usage-limits.js +139 -15
- package/dashboard/dist/assets/LimitsPage-fYoLqW5m.js +0 -2
- package/dashboard/dist/assets/main-DCfktJsK.css +0 -1
package/src/lib/rollout.js
CHANGED
|
@@ -4958,6 +4958,313 @@ async function parseCodebuddyIncremental({
|
|
|
4958
4958
|
return { recordsProcessed, eventsAggregated, bucketsQueued };
|
|
4959
4959
|
}
|
|
4960
4960
|
|
|
4961
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4962
|
+
// WorkBuddy — passive JSONL reader (~/.workbuddy/projects/<cwd>/**/*.jsonl)
|
|
4963
|
+
//
|
|
4964
|
+
// Tencent's WorkBuddy is a Claude-Code fork in the same "buddy" family as
|
|
4965
|
+
// CodeBuddy, but it differs from CodeBuddy's reader in three load-bearing ways
|
|
4966
|
+
// (each verified against real ~/.workbuddy logs, NOT assumed from CodeBuddy):
|
|
4967
|
+
//
|
|
4968
|
+
// 1. Usage lives on `function_call` records too — not only on
|
|
4969
|
+
// `type=="message" && role=="assistant"`. Each LLM round-trip (whether it
|
|
4970
|
+
// ends in a tool call or a text reply) carries its own providerData.rawUsage.
|
|
4971
|
+
// We therefore aggregate EVERY record that has providerData.rawUsage and
|
|
4972
|
+
// dedup per response id, instead of filtering by record type.
|
|
4973
|
+
//
|
|
4974
|
+
// 2. Sub-agent traffic is nested two levels deeper:
|
|
4975
|
+
// ~/.workbuddy/projects/<cwd>/<sessionId>.jsonl (main)
|
|
4976
|
+
// ~/.workbuddy/projects/<cwd>/<sessionId>/subagents/agent-*.jsonl (sub)
|
|
4977
|
+
// CodeBuddy's resolver only globs the top level, so we recurse to pick up
|
|
4978
|
+
// sub-agent usage (tool-results/*.txt are naturally skipped — not .jsonl).
|
|
4979
|
+
//
|
|
4980
|
+
// 3. rawUsage is OpenAI/DeepSeek-shaped and prompt_tokens is the FULL prompt
|
|
4981
|
+
// (cache reads + cache writes + genuinely-new input). The cache split is
|
|
4982
|
+
// mirrored two ways depending on which upstream the auto-router picked:
|
|
4983
|
+
// • Anthropic-style: cache_read_input_tokens / cache_creation_input_tokens
|
|
4984
|
+
// • DeepSeek/OpenAI-style: prompt_tokens_details.cached_tokens /
|
|
4985
|
+
// prompt_cache_hit_tokens (cache_creation_input_tokens then 0)
|
|
4986
|
+
// Reasoning is reported inside completion_tokens (verified:
|
|
4987
|
+
// rawUsage.total_tokens === prompt_tokens + completion_tokens).
|
|
4988
|
+
//
|
|
4989
|
+
// Token math (matches the repo's queue convention; subtract BOTH cache reads
|
|
4990
|
+
// AND cache writes from prompt_tokens — CodeBuddy's "prompt_tokens - cacheRead"
|
|
4991
|
+
// only works because its cache_creation is always 0; WorkBuddy writes cache
|
|
4992
|
+
// heavily, so the naive formula double-counts cache writes ~2x):
|
|
4993
|
+
// cacheRead = max(cache_read_input_tokens, prompt_tokens_details.cached_tokens,
|
|
4994
|
+
// prompt_cache_hit_tokens)
|
|
4995
|
+
// cacheCreate = cache_creation_input_tokens
|
|
4996
|
+
// input_tokens = prompt_tokens - cacheRead - cacheCreate
|
|
4997
|
+
// cached_input_tokens = cacheRead
|
|
4998
|
+
// cache_creation_input_tokens = cacheCreate
|
|
4999
|
+
// reasoning_output_tokens = completion_tokens_details.reasoning_tokens
|
|
5000
|
+
// output_tokens = completion_tokens - reasoning_output_tokens
|
|
5001
|
+
// total_tokens = sum of the above (== prompt_tokens + completion_tokens)
|
|
5002
|
+
//
|
|
5003
|
+
// model is the auto-router placeholder ("auto") — WorkBuddy does not expose
|
|
5004
|
+
// the underlying model in the log, so we emit it verbatim.
|
|
5005
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5006
|
+
|
|
5007
|
+
function resolveWorkbuddyHome(env = process.env) {
|
|
5008
|
+
const home = env.HOME || require("node:os").homedir();
|
|
5009
|
+
return env.WORKBUDDY_HOME || path.join(home, ".workbuddy");
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
function resolveWorkbuddyDefaultModel(env = process.env) {
|
|
5013
|
+
const fallback = "auto";
|
|
5014
|
+
try {
|
|
5015
|
+
const settingsPath = path.join(resolveWorkbuddyHome(env), "settings.json");
|
|
5016
|
+
const raw = fssync.readFileSync(settingsPath, "utf8");
|
|
5017
|
+
const parsed = JSON.parse(raw);
|
|
5018
|
+
if (parsed && typeof parsed === "object" && typeof parsed.model === "string" && parsed.model.trim()) {
|
|
5019
|
+
return parsed.model.trim();
|
|
5020
|
+
}
|
|
5021
|
+
} catch (_e) {
|
|
5022
|
+
// settings missing or malformed — fall through
|
|
5023
|
+
}
|
|
5024
|
+
return fallback;
|
|
5025
|
+
}
|
|
5026
|
+
|
|
5027
|
+
// Recursively collect every *.jsonl under ~/.workbuddy/projects so that
|
|
5028
|
+
// per-session conversation logs AND their nested subagents/agent-*.jsonl files
|
|
5029
|
+
// are both discovered. Non-.jsonl artefacts (tool-results/*.txt) are ignored.
|
|
5030
|
+
function resolveWorkbuddyProjectFiles(env = process.env) {
|
|
5031
|
+
const projectsDir = path.join(resolveWorkbuddyHome(env), "projects");
|
|
5032
|
+
if (!fssync.existsSync(projectsDir)) return [];
|
|
5033
|
+
const files = [];
|
|
5034
|
+
const walk = (dir) => {
|
|
5035
|
+
let entries;
|
|
5036
|
+
try { entries = fssync.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
5037
|
+
for (const entry of entries) {
|
|
5038
|
+
const full = path.join(dir, entry.name);
|
|
5039
|
+
let isDir = entry.isDirectory();
|
|
5040
|
+
let isFile = entry.isFile();
|
|
5041
|
+
// Resolve symlinks defensively (Dirent flags are false for symlinks).
|
|
5042
|
+
if (!isDir && !isFile) {
|
|
5043
|
+
try {
|
|
5044
|
+
const st = fssync.statSync(full);
|
|
5045
|
+
isDir = st.isDirectory();
|
|
5046
|
+
isFile = st.isFile();
|
|
5047
|
+
} catch { continue; }
|
|
5048
|
+
}
|
|
5049
|
+
if (isDir) walk(full);
|
|
5050
|
+
else if (isFile && entry.name.endsWith(".jsonl")) files.push(full);
|
|
5051
|
+
}
|
|
5052
|
+
};
|
|
5053
|
+
walk(projectsDir);
|
|
5054
|
+
files.sort((a, b) => a.localeCompare(b));
|
|
5055
|
+
return files;
|
|
5056
|
+
}
|
|
5057
|
+
|
|
5058
|
+
async function parseWorkbuddyIncremental({
|
|
5059
|
+
projectFiles,
|
|
5060
|
+
cursors,
|
|
5061
|
+
queuePath,
|
|
5062
|
+
onProgress,
|
|
5063
|
+
env,
|
|
5064
|
+
defaultModel,
|
|
5065
|
+
} = {}) {
|
|
5066
|
+
await ensureDir(path.dirname(queuePath));
|
|
5067
|
+
const workbuddyState =
|
|
5068
|
+
cursors.workbuddy && typeof cursors.workbuddy === "object" ? cursors.workbuddy : {};
|
|
5069
|
+
const seenIds = new Set(
|
|
5070
|
+
Array.isArray(workbuddyState.seenIds) ? workbuddyState.seenIds : [],
|
|
5071
|
+
);
|
|
5072
|
+
const fileOffsets =
|
|
5073
|
+
workbuddyState.fileOffsets && typeof workbuddyState.fileOffsets === "object"
|
|
5074
|
+
? { ...workbuddyState.fileOffsets }
|
|
5075
|
+
: {};
|
|
5076
|
+
|
|
5077
|
+
const files = Array.isArray(projectFiles)
|
|
5078
|
+
? projectFiles
|
|
5079
|
+
: resolveWorkbuddyProjectFiles(env || process.env);
|
|
5080
|
+
const fallbackModel = defaultModel || resolveWorkbuddyDefaultModel(env || process.env);
|
|
5081
|
+
|
|
5082
|
+
if (files.length === 0) {
|
|
5083
|
+
cursors.workbuddy = {
|
|
5084
|
+
...workbuddyState,
|
|
5085
|
+
seenIds: Array.from(seenIds),
|
|
5086
|
+
fileOffsets,
|
|
5087
|
+
updatedAt: new Date().toISOString(),
|
|
5088
|
+
};
|
|
5089
|
+
return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
5090
|
+
}
|
|
5091
|
+
|
|
5092
|
+
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
5093
|
+
const touchedBuckets = new Set();
|
|
5094
|
+
const cb = typeof onProgress === "function" ? onProgress : null;
|
|
5095
|
+
let recordsProcessed = 0;
|
|
5096
|
+
let eventsAggregated = 0;
|
|
5097
|
+
|
|
5098
|
+
for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
|
|
5099
|
+
const filePath = files[fileIdx];
|
|
5100
|
+
let stat;
|
|
5101
|
+
try { stat = fssync.statSync(filePath); } catch { continue; }
|
|
5102
|
+
|
|
5103
|
+
const prevEntry = fileOffsets[filePath] || {};
|
|
5104
|
+
const prevSize = Number(prevEntry.size) || 0;
|
|
5105
|
+
const prevIno = prevEntry.ino;
|
|
5106
|
+
// Re-read from start if file shrunk (truncate/rewrite) or inode changed
|
|
5107
|
+
// (file deleted + recreated). Otherwise pick up after the last read offset.
|
|
5108
|
+
const inodeChanged = typeof prevIno === "number" && prevIno !== stat.ino;
|
|
5109
|
+
const startOffset = stat.size < prevSize || inodeChanged ? 0 : prevSize;
|
|
5110
|
+
if (stat.size <= startOffset) continue;
|
|
5111
|
+
|
|
5112
|
+
let stream;
|
|
5113
|
+
try {
|
|
5114
|
+
stream = fssync.createReadStream(filePath, {
|
|
5115
|
+
encoding: "utf8",
|
|
5116
|
+
start: startOffset,
|
|
5117
|
+
});
|
|
5118
|
+
} catch { continue; }
|
|
5119
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
5120
|
+
|
|
5121
|
+
for await (const line of rl) {
|
|
5122
|
+
if (!line || !line.trim()) continue;
|
|
5123
|
+
let entry;
|
|
5124
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
5125
|
+
if (!entry || typeof entry !== "object") continue;
|
|
5126
|
+
|
|
5127
|
+
// Usage is carried on ANY record with providerData.rawUsage — assistant
|
|
5128
|
+
// messages AND function_call records. Aggregate them all; dedup per id.
|
|
5129
|
+
const provider = entry.providerData;
|
|
5130
|
+
const rawUsage = provider && typeof provider === "object" ? provider.rawUsage : null;
|
|
5131
|
+
if (!rawUsage || typeof rawUsage !== "object") continue;
|
|
5132
|
+
|
|
5133
|
+
const sessionId =
|
|
5134
|
+
typeof entry.sessionId === "string" && entry.sessionId
|
|
5135
|
+
? entry.sessionId
|
|
5136
|
+
: path.basename(filePath, ".jsonl");
|
|
5137
|
+
const tsMs =
|
|
5138
|
+
Number.isFinite(Number(entry.timestamp)) && Number(entry.timestamp) > 0
|
|
5139
|
+
? Number(entry.timestamp)
|
|
5140
|
+
: null;
|
|
5141
|
+
// One usage record per LLM round-trip; the response id is the most stable
|
|
5142
|
+
// dedup key, then providerData.messageId, then session+timestamp.
|
|
5143
|
+
const messageId =
|
|
5144
|
+
typeof entry.id === "string" && entry.id
|
|
5145
|
+
? entry.id
|
|
5146
|
+
: typeof provider.messageId === "string" && provider.messageId
|
|
5147
|
+
? provider.messageId
|
|
5148
|
+
: tsMs != null
|
|
5149
|
+
? `${sessionId}:${tsMs}`
|
|
5150
|
+
: null;
|
|
5151
|
+
if (!messageId) continue;
|
|
5152
|
+
if (seenIds.has(messageId)) continue;
|
|
5153
|
+
|
|
5154
|
+
recordsProcessed++;
|
|
5155
|
+
|
|
5156
|
+
const promptTokens = toNonNegativeInt(rawUsage.prompt_tokens);
|
|
5157
|
+
const completionTokens = toNonNegativeInt(rawUsage.completion_tokens);
|
|
5158
|
+
const promptDetails =
|
|
5159
|
+
rawUsage.prompt_tokens_details && typeof rawUsage.prompt_tokens_details === "object"
|
|
5160
|
+
? rawUsage.prompt_tokens_details
|
|
5161
|
+
: {};
|
|
5162
|
+
const completionDetails =
|
|
5163
|
+
rawUsage.completion_tokens_details && typeof rawUsage.completion_tokens_details === "object"
|
|
5164
|
+
? rawUsage.completion_tokens_details
|
|
5165
|
+
: {};
|
|
5166
|
+
|
|
5167
|
+
// Cache reads are mirrored across up to three fields depending on which
|
|
5168
|
+
// upstream the auto-router used; take the largest non-zero mirror.
|
|
5169
|
+
const cacheRead = Math.max(
|
|
5170
|
+
toNonNegativeInt(rawUsage.cache_read_input_tokens),
|
|
5171
|
+
toNonNegativeInt(promptDetails.cached_tokens),
|
|
5172
|
+
toNonNegativeInt(rawUsage.prompt_cache_hit_tokens),
|
|
5173
|
+
);
|
|
5174
|
+
const cacheCreation = toNonNegativeInt(rawUsage.cache_creation_input_tokens);
|
|
5175
|
+
// prompt_tokens is the FULL prompt: subtract BOTH reads and writes so
|
|
5176
|
+
// input_tokens is pure non-cached input (no double-counting cache writes).
|
|
5177
|
+
const inputTokens = Math.max(0, promptTokens - cacheRead - cacheCreation);
|
|
5178
|
+
// completion_tokens INCLUDES reasoning (verified: total == prompt+completion).
|
|
5179
|
+
const reasoningTokens = Math.min(completionTokens, toNonNegativeInt(completionDetails.reasoning_tokens));
|
|
5180
|
+
const outputTokens = Math.max(0, completionTokens - reasoningTokens);
|
|
5181
|
+
|
|
5182
|
+
if (
|
|
5183
|
+
inputTokens === 0 &&
|
|
5184
|
+
outputTokens === 0 &&
|
|
5185
|
+
cacheRead === 0 &&
|
|
5186
|
+
cacheCreation === 0 &&
|
|
5187
|
+
reasoningTokens === 0
|
|
5188
|
+
) {
|
|
5189
|
+
seenIds.add(messageId);
|
|
5190
|
+
continue;
|
|
5191
|
+
}
|
|
5192
|
+
|
|
5193
|
+
if (tsMs == null) {
|
|
5194
|
+
seenIds.add(messageId);
|
|
5195
|
+
continue;
|
|
5196
|
+
}
|
|
5197
|
+
const tsIso = new Date(tsMs).toISOString();
|
|
5198
|
+
const bucketStart = toUtcHalfHourStart(tsIso);
|
|
5199
|
+
if (!bucketStart) continue;
|
|
5200
|
+
|
|
5201
|
+
const model =
|
|
5202
|
+
normalizeModelInput(provider.model) ||
|
|
5203
|
+
normalizeModelInput(provider.requestModelId) ||
|
|
5204
|
+
normalizeModelInput(entry.model) ||
|
|
5205
|
+
fallbackModel;
|
|
5206
|
+
|
|
5207
|
+
const delta = {
|
|
5208
|
+
input_tokens: inputTokens,
|
|
5209
|
+
cached_input_tokens: cacheRead,
|
|
5210
|
+
cache_creation_input_tokens: cacheCreation,
|
|
5211
|
+
output_tokens: outputTokens,
|
|
5212
|
+
reasoning_output_tokens: reasoningTokens,
|
|
5213
|
+
total_tokens:
|
|
5214
|
+
inputTokens + outputTokens + cacheRead + cacheCreation + reasoningTokens,
|
|
5215
|
+
conversation_count: 1,
|
|
5216
|
+
};
|
|
5217
|
+
|
|
5218
|
+
const bucket = getHourlyBucket(hourlyState, "workbuddy", model, bucketStart);
|
|
5219
|
+
addTotals(bucket.totals, delta);
|
|
5220
|
+
touchedBuckets.add(bucketKey("workbuddy", model, bucketStart));
|
|
5221
|
+
seenIds.add(messageId);
|
|
5222
|
+
eventsAggregated++;
|
|
5223
|
+
|
|
5224
|
+
if (cb) {
|
|
5225
|
+
cb({
|
|
5226
|
+
index: fileIdx + 1,
|
|
5227
|
+
total: files.length,
|
|
5228
|
+
recordsProcessed,
|
|
5229
|
+
eventsAggregated,
|
|
5230
|
+
bucketsQueued: touchedBuckets.size,
|
|
5231
|
+
});
|
|
5232
|
+
}
|
|
5233
|
+
}
|
|
5234
|
+
|
|
5235
|
+
let postStat = stat;
|
|
5236
|
+
try { postStat = fssync.statSync(filePath); } catch {}
|
|
5237
|
+
fileOffsets[filePath] = {
|
|
5238
|
+
size: postStat.size,
|
|
5239
|
+
mtimeMs: postStat.mtimeMs,
|
|
5240
|
+
ino: postStat.ino,
|
|
5241
|
+
};
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5244
|
+
// Cap dedup set to last 10k IDs to bound cursor state size — same convention
|
|
5245
|
+
// as CodeBuddy/Kimi/Copilot so cursors.json doesn't grow unbounded.
|
|
5246
|
+
const seenArr = Array.from(seenIds);
|
|
5247
|
+
const cappedSeen =
|
|
5248
|
+
seenArr.length > 10_000 ? seenArr.slice(seenArr.length - 10_000) : seenArr;
|
|
5249
|
+
|
|
5250
|
+
const bucketsQueued = await enqueueTouchedBuckets({
|
|
5251
|
+
queuePath,
|
|
5252
|
+
hourlyState,
|
|
5253
|
+
touchedBuckets,
|
|
5254
|
+
});
|
|
5255
|
+
const updatedAt = new Date().toISOString();
|
|
5256
|
+
hourlyState.updatedAt = updatedAt;
|
|
5257
|
+
cursors.hourly = hourlyState;
|
|
5258
|
+
cursors.workbuddy = {
|
|
5259
|
+
...workbuddyState,
|
|
5260
|
+
seenIds: cappedSeen,
|
|
5261
|
+
fileOffsets,
|
|
5262
|
+
updatedAt,
|
|
5263
|
+
};
|
|
5264
|
+
|
|
5265
|
+
return { recordsProcessed, eventsAggregated, bucketsQueued };
|
|
5266
|
+
}
|
|
5267
|
+
|
|
4961
5268
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
4962
5269
|
// oh-my-pi (omp) — passive JSONL reader (~/.omp/agent/sessions/**/*.jsonl)
|
|
4963
5270
|
//
|
|
@@ -6283,6 +6590,101 @@ function droidSessionIdFromPath(filePath) {
|
|
|
6283
6590
|
return base.slice(0, -".settings.json".length);
|
|
6284
6591
|
}
|
|
6285
6592
|
|
|
6593
|
+
// Resolve a Droid bucket model id. ccusage's chain: settings.model → sidecar
|
|
6594
|
+
// <id>.jsonl scrape → `<provider>-unknown` derived from providerLock or inferred
|
|
6595
|
+
// from the model fragment. Extracted so the dup-session repair migration can
|
|
6596
|
+
// reproduce the exact same bucket key a settings file would have emitted under.
|
|
6597
|
+
function resolveDroidModel(settings, filePath) {
|
|
6598
|
+
let model = normalizeDroidModelName(settings.model);
|
|
6599
|
+
if (!model) model = extractDroidModelFromSidecarJsonl(filePath);
|
|
6600
|
+
if (!model) {
|
|
6601
|
+
let provider = normalizeDroidProvider(settings.providerLock);
|
|
6602
|
+
if (provider === "unknown") {
|
|
6603
|
+
provider = inferDroidProviderFromModel(settings.model || "");
|
|
6604
|
+
}
|
|
6605
|
+
model = defaultDroidModelForProvider(provider);
|
|
6606
|
+
}
|
|
6607
|
+
return model;
|
|
6608
|
+
}
|
|
6609
|
+
|
|
6610
|
+
// When the SAME Droid session id (the basename, which is the cursor key) appears
|
|
6611
|
+
// in more than one folder under ~/.factory/sessions, every such file shares one
|
|
6612
|
+
// sessionTotals[sessionId] entry. Processing them in a single parse loop makes the
|
|
6613
|
+
// lower-count file look like a session reset and re-emit the full cumulative on
|
|
6614
|
+
// every sync — unbounded inflation (issue #204). De-dupe to ONE canonical file per
|
|
6615
|
+
// session id BEFORE the loop. Canonical = the most complete cumulative snapshot:
|
|
6616
|
+
// largest max(five-field sum, totalTokens) (applyDroidTotalFallback already spills
|
|
6617
|
+
// totalTokens into the five fields, so summing the filled fields IS that max);
|
|
6618
|
+
// ties go to the newest mtime, then the lexicographically smaller path. A session
|
|
6619
|
+
// id with a single file passes through untouched (no disk read).
|
|
6620
|
+
function dedupeDroidSettingsFilesBySession(files) {
|
|
6621
|
+
const list = Array.isArray(files) ? files : [];
|
|
6622
|
+
const groups = new Map();
|
|
6623
|
+
for (const filePath of list) {
|
|
6624
|
+
if (typeof filePath !== "string") continue;
|
|
6625
|
+
const sessionId = droidSessionIdFromPath(filePath);
|
|
6626
|
+
if (!sessionId) continue;
|
|
6627
|
+
if (!groups.has(sessionId)) groups.set(sessionId, []);
|
|
6628
|
+
groups.get(sessionId).push(filePath);
|
|
6629
|
+
}
|
|
6630
|
+
const out = [];
|
|
6631
|
+
for (const group of groups.values()) {
|
|
6632
|
+
if (group.length === 1) {
|
|
6633
|
+
out.push(group[0]);
|
|
6634
|
+
continue;
|
|
6635
|
+
}
|
|
6636
|
+
let best = null;
|
|
6637
|
+
let bestMetric = -1;
|
|
6638
|
+
let bestMtime = -1;
|
|
6639
|
+
for (const filePath of group) {
|
|
6640
|
+
let mtimeMs = 0;
|
|
6641
|
+
try {
|
|
6642
|
+
mtimeMs = fssync.statSync(filePath).mtimeMs;
|
|
6643
|
+
} catch {
|
|
6644
|
+
continue;
|
|
6645
|
+
}
|
|
6646
|
+
let settings;
|
|
6647
|
+
try {
|
|
6648
|
+
settings = JSON.parse(fssync.readFileSync(filePath, "utf8"));
|
|
6649
|
+
} catch {
|
|
6650
|
+
continue;
|
|
6651
|
+
}
|
|
6652
|
+
const usage =
|
|
6653
|
+
settings && typeof settings === "object" && settings.tokenUsage
|
|
6654
|
+
? settings.tokenUsage
|
|
6655
|
+
: {};
|
|
6656
|
+
const filled = applyDroidTotalFallback({
|
|
6657
|
+
input: Math.max(0, Number(usage.inputTokens || 0)),
|
|
6658
|
+
output: Math.max(0, Number(usage.outputTokens || 0)),
|
|
6659
|
+
cacheCreation: Math.max(0, Number(usage.cacheCreationTokens || 0)),
|
|
6660
|
+
cacheRead: Math.max(0, Number(usage.cacheReadTokens || 0)),
|
|
6661
|
+
thinking: Math.max(0, Number(usage.thinkingTokens || 0)),
|
|
6662
|
+
totalTokens: Math.max(0, Number(usage.totalTokens || 0)),
|
|
6663
|
+
});
|
|
6664
|
+
const metric =
|
|
6665
|
+
filled.input +
|
|
6666
|
+
filled.output +
|
|
6667
|
+
filled.cacheCreation +
|
|
6668
|
+
filled.cacheRead +
|
|
6669
|
+
filled.thinking;
|
|
6670
|
+
const better =
|
|
6671
|
+
metric > bestMetric ||
|
|
6672
|
+
(metric === bestMetric && mtimeMs > bestMtime) ||
|
|
6673
|
+
(metric === bestMetric &&
|
|
6674
|
+
mtimeMs === bestMtime &&
|
|
6675
|
+
(best === null || filePath.localeCompare(best) < 0));
|
|
6676
|
+
if (better) {
|
|
6677
|
+
best = filePath;
|
|
6678
|
+
bestMetric = metric;
|
|
6679
|
+
bestMtime = mtimeMs;
|
|
6680
|
+
}
|
|
6681
|
+
}
|
|
6682
|
+
out.push(best || group[0]);
|
|
6683
|
+
}
|
|
6684
|
+
out.sort((a, b) => a.localeCompare(b));
|
|
6685
|
+
return out;
|
|
6686
|
+
}
|
|
6687
|
+
|
|
6286
6688
|
async function parseDroidIncremental({
|
|
6287
6689
|
settingsFiles,
|
|
6288
6690
|
cursors,
|
|
@@ -6304,9 +6706,11 @@ async function parseDroidIncremental({
|
|
|
6304
6706
|
? { ...droidState.sessionTotals }
|
|
6305
6707
|
: {};
|
|
6306
6708
|
|
|
6307
|
-
const files =
|
|
6308
|
-
|
|
6309
|
-
|
|
6709
|
+
const files = dedupeDroidSettingsFilesBySession(
|
|
6710
|
+
Array.isArray(settingsFiles)
|
|
6711
|
+
? settingsFiles
|
|
6712
|
+
: listDroidSettingsFiles(env || process.env),
|
|
6713
|
+
);
|
|
6310
6714
|
|
|
6311
6715
|
if (files.length === 0) {
|
|
6312
6716
|
cursors.droid = {
|
|
@@ -6455,15 +6859,7 @@ async function parseDroidIncremental({
|
|
|
6455
6859
|
// inferred from the model fragment we did find. Same fallback string set
|
|
6456
6860
|
// (claude-unknown / gpt-unknown / gemini-unknown / grok-unknown) so
|
|
6457
6861
|
// empty-model sessions bucket identically across both tools.
|
|
6458
|
-
|
|
6459
|
-
if (!model) model = extractDroidModelFromSidecarJsonl(filePath);
|
|
6460
|
-
if (!model) {
|
|
6461
|
-
let provider = normalizeDroidProvider(settings.providerLock);
|
|
6462
|
-
if (provider === "unknown") {
|
|
6463
|
-
provider = inferDroidProviderFromModel(settings.model || "");
|
|
6464
|
-
}
|
|
6465
|
-
model = defaultDroidModelForProvider(provider);
|
|
6466
|
-
}
|
|
6862
|
+
const model = resolveDroidModel(settings, filePath);
|
|
6467
6863
|
|
|
6468
6864
|
// Token normalization: inputTokens already excludes cache reads (matches
|
|
6469
6865
|
// Anthropic API convention), so cache columns slot in directly. Thinking
|
|
@@ -8663,6 +9059,10 @@ module.exports = {
|
|
|
8663
9059
|
resolveCodebuddyProjectFiles,
|
|
8664
9060
|
resolveCodebuddyDefaultModel,
|
|
8665
9061
|
parseCodebuddyIncremental,
|
|
9062
|
+
resolveWorkbuddyHome,
|
|
9063
|
+
resolveWorkbuddyProjectFiles,
|
|
9064
|
+
resolveWorkbuddyDefaultModel,
|
|
9065
|
+
parseWorkbuddyIncremental,
|
|
8666
9066
|
resolveKiroCliSessionFiles,
|
|
8667
9067
|
resolveKiroCliDbPath,
|
|
8668
9068
|
parseKiroCliIncremental,
|
|
@@ -8699,6 +9099,8 @@ module.exports = {
|
|
|
8699
9099
|
droidSessionIdFromPath,
|
|
8700
9100
|
extractDroidModelFromSidecarJsonl,
|
|
8701
9101
|
applyDroidTotalFallback,
|
|
9102
|
+
resolveDroidModel,
|
|
9103
|
+
dedupeDroidSettingsFilesBySession,
|
|
8702
9104
|
parseDroidIncremental,
|
|
8703
9105
|
resolvePiHome,
|
|
8704
9106
|
resolvePiAgentDir,
|
|
@@ -8719,6 +9121,7 @@ module.exports = {
|
|
|
8719
9121
|
// Exposed so the queue-repair migration can mutate cursors state in the
|
|
8720
9122
|
// same key format sync uses elsewhere.
|
|
8721
9123
|
bucketKey,
|
|
9124
|
+
toUtcHalfHourStart,
|
|
8722
9125
|
totalsKey,
|
|
8723
9126
|
claudeMessageDedupKey,
|
|
8724
9127
|
groupBucketKey,
|
package/src/lib/usage-limits.js
CHANGED
|
@@ -5,6 +5,7 @@ const os = require("node:os");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const http = require("node:http");
|
|
7
7
|
const https = require("node:https");
|
|
8
|
+
const { performance } = require("node:perf_hooks");
|
|
8
9
|
|
|
9
10
|
const {
|
|
10
11
|
detectClaudeCodeSubscriptionDetails,
|
|
@@ -174,6 +175,8 @@ async function fetchClaudeUsageLimits(accessToken, { fetchImpl = fetch, maxAttem
|
|
|
174
175
|
// reading mislabels it as "5h". Aligned with steipete/CodexBar's rate-window normalizer.
|
|
175
176
|
const CODEX_SESSION_WINDOW_SECONDS = 18000;
|
|
176
177
|
const CODEX_WEEKLY_WINDOW_SECONDS = 604800;
|
|
178
|
+
const CODEX_RESET_CREDIT_LIST_TIMEOUT_MS = 3000;
|
|
179
|
+
const CODEX_RESET_CREDIT_LIST_TIMEOUT_GUARD_MS = 25;
|
|
177
180
|
|
|
178
181
|
function classifyCodexWindow(window) {
|
|
179
182
|
if (!window || typeof window !== "object") return null;
|
|
@@ -291,9 +294,103 @@ function normalizeCodexSparkRateWindows(additionalRateLimits) {
|
|
|
291
294
|
};
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
function normalizeCodexResetCreditCount(value) {
|
|
298
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeCodexResetCredit(row, nowMs) {
|
|
302
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
303
|
+
if (row.status !== "available") return null;
|
|
304
|
+
|
|
305
|
+
const hasResetType = row.reset_type !== undefined && row.reset_type !== null;
|
|
306
|
+
if (hasResetType && row.reset_type !== "codex_rate_limits") return null;
|
|
307
|
+
|
|
308
|
+
const expiresAt = row.expires_at;
|
|
309
|
+
if (typeof expiresAt !== "string") return null;
|
|
310
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
311
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs < nowMs) return null;
|
|
312
|
+
|
|
313
|
+
const credit = {
|
|
314
|
+
status: row.status,
|
|
315
|
+
expires_at: expiresAt,
|
|
316
|
+
};
|
|
317
|
+
if (typeof row.reset_type === "string") {
|
|
318
|
+
credit.reset_type = row.reset_type;
|
|
319
|
+
}
|
|
320
|
+
if (typeof row.granted_at === "string") {
|
|
321
|
+
credit.granted_at = row.granted_at;
|
|
322
|
+
}
|
|
323
|
+
return { credit, expiresAtMs };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function normalizeCodexResetCredits(resetCredits, nowMs = Date.now()) {
|
|
327
|
+
if (!resetCredits || typeof resetCredits !== "object" || Array.isArray(resetCredits)) return null;
|
|
328
|
+
|
|
329
|
+
const availableCount = normalizeCodexResetCreditCount(resetCredits.available_count);
|
|
330
|
+
const totalEarnedCount = normalizeCodexResetCreditCount(resetCredits.total_earned_count);
|
|
331
|
+
const normalized = [];
|
|
332
|
+
if (Array.isArray(resetCredits.credits) && availableCount !== 0) {
|
|
333
|
+
for (const row of resetCredits.credits) {
|
|
334
|
+
const entry = normalizeCodexResetCredit(row, nowMs);
|
|
335
|
+
if (entry) normalized.push(entry);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const credits = normalized
|
|
340
|
+
.sort((a, b) => a.expiresAtMs - b.expiresAtMs)
|
|
341
|
+
.slice(0, 50)
|
|
342
|
+
.map((entry) => entry.credit);
|
|
343
|
+
|
|
344
|
+
if (availableCount === null && totalEarnedCount === null && credits.length === 0) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
available_count: availableCount,
|
|
350
|
+
total_earned_count: totalEarnedCount,
|
|
351
|
+
credits: availableCount === 0 ? [] : credits,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function codexResetCreditListTimeoutMs(remainingProviderBudgetMs) {
|
|
356
|
+
if (!Number.isFinite(remainingProviderBudgetMs)) {
|
|
357
|
+
return CODEX_RESET_CREDIT_LIST_TIMEOUT_MS;
|
|
358
|
+
}
|
|
359
|
+
if (remainingProviderBudgetMs <= 0) return 0;
|
|
360
|
+
const guardedBudgetMs = Math.floor(remainingProviderBudgetMs - CODEX_RESET_CREDIT_LIST_TIMEOUT_GUARD_MS);
|
|
361
|
+
if (guardedBudgetMs <= 0) return 0;
|
|
362
|
+
return Math.min(CODEX_RESET_CREDIT_LIST_TIMEOUT_MS, guardedBudgetMs);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function fetchCodexResetCreditList(fetchImpl, headers, timeoutMs = CODEX_RESET_CREDIT_LIST_TIMEOUT_MS) {
|
|
366
|
+
let timer = null;
|
|
367
|
+
try {
|
|
368
|
+
const request = Promise.resolve()
|
|
369
|
+
.then(() => fetchImpl("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", {
|
|
370
|
+
method: "GET",
|
|
371
|
+
headers,
|
|
372
|
+
}))
|
|
373
|
+
.then(async (res) => {
|
|
374
|
+
if (!res.ok) return null;
|
|
375
|
+
return normalizeCodexResetCredits(await res.json());
|
|
376
|
+
});
|
|
377
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
378
|
+
return await request;
|
|
379
|
+
}
|
|
380
|
+
const timeout = new Promise((resolve) => {
|
|
381
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
382
|
+
});
|
|
383
|
+
return await Promise.race([request, timeout]);
|
|
384
|
+
} catch (_err) {
|
|
385
|
+
return null;
|
|
386
|
+
} finally {
|
|
387
|
+
if (timer) clearTimeout(timer);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
294
391
|
async function fetchCodexUsageLimits(
|
|
295
392
|
accessToken,
|
|
296
|
-
{ fetchImpl = fetch, accountId = null } = {},
|
|
393
|
+
{ fetchImpl = fetch, accountId = null, providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS } = {},
|
|
297
394
|
) {
|
|
298
395
|
const headers = {
|
|
299
396
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -305,27 +402,53 @@ async function fetchCodexUsageLimits(
|
|
|
305
402
|
headers["ChatGPT-Account-Id"] = accountId;
|
|
306
403
|
}
|
|
307
404
|
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
405
|
+
const startedAtMs = performance.now();
|
|
406
|
+
const usage = await withProviderTimeout(Promise.resolve()
|
|
407
|
+
.then(() => fetchImpl("https://chatgpt.com/backend-api/wham/usage", {
|
|
408
|
+
method: "GET",
|
|
409
|
+
headers,
|
|
410
|
+
}))
|
|
411
|
+
.then(async (res) => {
|
|
412
|
+
// 401/403/404 from wham means "no usage data available for this auth state" — render
|
|
413
|
+
// a neutral empty state instead of a red "Fetch failed" error.
|
|
414
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
415
|
+
return { body: null };
|
|
416
|
+
}
|
|
417
|
+
if (res.status !== 200) {
|
|
418
|
+
throw new Error(`Codex API returned ${res.status}`);
|
|
419
|
+
}
|
|
420
|
+
return { body: await res.json() };
|
|
421
|
+
}), "Codex", providerTimeoutMs);
|
|
422
|
+
if (!usage.body) {
|
|
315
423
|
return {
|
|
316
424
|
primary_window: null,
|
|
317
425
|
secondary_window: null,
|
|
318
426
|
spark_primary_window: null,
|
|
319
427
|
spark_secondary_window: null,
|
|
428
|
+
reset_credits: null,
|
|
320
429
|
};
|
|
321
430
|
}
|
|
322
|
-
|
|
323
|
-
|
|
431
|
+
const body = usage.body;
|
|
432
|
+
let resetCredits = normalizeCodexResetCredits(body.rate_limit_reset_credits);
|
|
433
|
+
// This semi-private sibling endpoint is read-only; /wham/usage remains the stable count fallback.
|
|
434
|
+
const remainingProviderBudgetMs = Number.isFinite(providerTimeoutMs) && providerTimeoutMs > 0
|
|
435
|
+
? providerTimeoutMs - (performance.now() - startedAtMs)
|
|
436
|
+
: CODEX_RESET_CREDIT_LIST_TIMEOUT_MS;
|
|
437
|
+
const resetCreditListTimeoutMs = codexResetCreditListTimeoutMs(remainingProviderBudgetMs);
|
|
438
|
+
if (resetCreditListTimeoutMs > 0) {
|
|
439
|
+
const resetCreditsList = await fetchCodexResetCreditList(
|
|
440
|
+
fetchImpl,
|
|
441
|
+
headers,
|
|
442
|
+
resetCreditListTimeoutMs,
|
|
443
|
+
);
|
|
444
|
+
if (resetCreditsList) {
|
|
445
|
+
resetCredits = resetCreditsList;
|
|
446
|
+
}
|
|
324
447
|
}
|
|
325
|
-
const body = await res.json();
|
|
326
448
|
return {
|
|
327
449
|
...normalizeCodexRateWindows(body.rate_limit || {}),
|
|
328
450
|
...normalizeCodexSparkRateWindows(body.additional_rate_limits),
|
|
451
|
+
reset_credits: resetCredits,
|
|
329
452
|
};
|
|
330
453
|
}
|
|
331
454
|
|
|
@@ -2174,11 +2297,11 @@ async function fetchUsageLimitsUncached({
|
|
|
2174
2297
|
)
|
|
2175
2298
|
: Promise.resolve(null),
|
|
2176
2299
|
codexToken
|
|
2177
|
-
?
|
|
2178
|
-
|
|
2179
|
-
|
|
2300
|
+
? fetchCodexUsageLimits(codexToken, {
|
|
2301
|
+
fetchImpl: providerFetch,
|
|
2302
|
+
accountId: codexAccountId,
|
|
2180
2303
|
providerTimeoutMs,
|
|
2181
|
-
).then(
|
|
2304
|
+
}).then(
|
|
2182
2305
|
(value) => ({ status: "fulfilled", value }),
|
|
2183
2306
|
(reason) => ({ status: "rejected", reason }),
|
|
2184
2307
|
)
|
|
@@ -2257,6 +2380,7 @@ async function fetchUsageLimitsUncached({
|
|
|
2257
2380
|
secondary_window: codexResult.value.secondary_window,
|
|
2258
2381
|
spark_primary_window: codexResult.value.spark_primary_window,
|
|
2259
2382
|
spark_secondary_window: codexResult.value.spark_secondary_window,
|
|
2383
|
+
reset_credits: codexResult.value.reset_credits,
|
|
2260
2384
|
};
|
|
2261
2385
|
}
|
|
2262
2386
|
|