tokentracker-cli 0.81.3 → 0.82.1
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/{AchievementBadge-DiSoAalB.js → AchievementBadge-CkmyCQlm.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-DI_cvzqy.js → AchievementsPage-B4WDkj4Q.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-ClWZZNvc.js → AchievementsSection-CLA812Vd.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-DpQeG_6H.js → BadgeDetailModal-rPrkKVBY.js} +1 -1
- package/dashboard/dist/assets/{Card-B0hyofoy.js → Card-D4wKdGxL.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-DZJGQ3-9.js → ClawdAnimated-BzfSSyf4.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-Dcikt0kT.js → CommunityStatsModal-BDgJ6Rdv.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-DCfRP4oI.js → ConfirmModal-CjKotIxk.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-C61HPDUg.js +1 -0
- package/dashboard/dist/assets/{DevicePage-De6KuFaJ.js → DevicePage-Bwrgs9bf.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-D0X0v38O.js +1 -0
- package/dashboard/dist/assets/DialogTitle-qsUpNlJa.js +1 -0
- package/dashboard/dist/assets/{FadeIn-DZoN7vbQ.js → FadeIn-D_skMXdS.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-C9HQnEsI.js → HeaderGithubStar-Er3o6s3b.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-82zniXwO.js → IpCheckPage-CVelrxeM.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-Df9fdR0n.js → LandingPage-0YKN8fUt.js} +14 -14
- package/dashboard/dist/assets/{LeaderboardAvatar-CjfEgoR8.js → LeaderboardAvatar-BTIhp_Eo.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-BdbE6Nhj.js → LeaderboardPage-bGkIx65M.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-CDTK21dp.js → LeaderboardProfileModal-HATS5y64.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-B6QWnwB1.js → LeaderboardProfilePage-mnQke6q-.js} +3 -3
- package/dashboard/dist/assets/LimitsPage-OyBAsn4S.js +4 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-DOcSDrOy.js → LocalOnlyNotice-Dc1CtgPN.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-WY_sRA7I.js → LoginPage-BXJQ9slu.js} +1 -1
- package/dashboard/dist/assets/{PetPage-CNbgNE6f.js → PetPage-DsjNXLBM.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-Cv525-dF.js → PopoverPopup-BuSDh9-C.js} +1 -1
- package/dashboard/dist/assets/ResetPasswordPage-Bc15F6XB.js +1 -0
- package/dashboard/dist/assets/{Select-3HkgkO8y.js → Select-DE6DMSrS.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Bz8FAKEv.js → SelectItemText-Ca7gf5A0.js} +1 -1
- package/dashboard/dist/assets/SettingsPage-C_c9z42f.js +1 -0
- package/dashboard/dist/assets/{SkillsPage-Dnz3eGcE.js → SkillsPage-iR9Slxl2.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-OopQ06B8.js → TrendMonitor-C-acyVP7.js} +2 -2
- package/dashboard/dist/assets/WidgetsPage-DmiqtvrV.js +1 -0
- package/dashboard/dist/assets/{WrappedPage-Dbd-8EKD.js → WrappedPage-wnw_Eknp.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-CxN2E6q5.js → __vite-browser-external-CHbEWSwX.js} +48 -18
- package/dashboard/dist/assets/__vite-browser-external-Cd947_ni.css +1 -0
- package/dashboard/dist/assets/agent-logos-DF4o0Ebm.js +1 -0
- package/dashboard/dist/assets/{arrow-up-right-CGnVz9Km.js → arrow-up-right-B1yj6nxk.js} +1 -1
- package/dashboard/dist/assets/{check-CkI3WNti.js → check-Dvngccc0.js} +1 -1
- package/dashboard/dist/assets/{copy-Bh3g2_O-.js → copy-D592E6jX.js} +1 -1
- package/dashboard/dist/assets/{download-BITw7nKX.js → download-HHcFs0Ff.js} +1 -1
- package/dashboard/dist/assets/{flame-ByAsnNar.js → flame-DCV1yyCQ.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-Cu8JtuN4.js → format-tokens-BN3gD2gw.js} +1 -1
- package/dashboard/dist/assets/{icons-D9OB0N43.js → icons-DhvcZGBX.js} +1 -1
- package/dashboard/dist/assets/{info-Mu2CLf-A.js → info-DoeeLajS.js} +1 -1
- package/dashboard/dist/assets/limits-providers-Db3iK5TG.js +1 -0
- package/dashboard/dist/assets/{link-2-CSlVaUw9.js → link-2-C4RnhbaI.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-CYck0YaS.js → loader-circle-Cz7QNoOj.js} +1 -1
- package/dashboard/dist/assets/{provider-display-BB925tPa.js → provider-display-BvvK0p2x.js} +1 -1
- package/dashboard/dist/assets/{terminal-4YysOnTe.js → terminal-Bwl-eNY6.js} +1 -1
- package/dashboard/dist/assets/{trash-2-C8-ZfrZG.js → trash-2-Ceor8aI2.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-Cxt-otko.js → use-community-stats-C0cLdfhQ.js} +1 -1
- package/dashboard/dist/assets/use-limits-display-prefs-B-zzSXHf.js +1 -0
- package/dashboard/dist/assets/{use-native-settings-DbdPXxGf.js → use-native-settings-CIHg16YZ.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-BZMM-iIj.js → use-ordered-list-XWE9dLyL.js} +1 -1
- package/dashboard/dist/assets/use-session-efficiency-pref-DUe7bWPZ.js +1 -0
- package/dashboard/dist/assets/use-transform-DgEhM1tE.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-B_NbJCPh.js +1 -0
- package/dashboard/dist/assets/{useCurrency-CpDnFC0N.js → useCurrency-QqSMR7Ba.js} +1 -1
- package/dashboard/dist/assets/{useScrollLock-Vyaif-7C.js → useScrollLock-DdUQVKDA.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-B8FrUarf.js → useTokenFormat-BLfmNxg5.js} +1 -1
- package/dashboard/dist/assets/{zap-CdeKJ6HL.js → zap-DjGiVr-_.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/cli.js +6 -0
- package/src/commands/sessions.js +29 -0
- package/src/lib/context-health.js +124 -0
- package/src/lib/git-outcomes.js +147 -0
- package/src/lib/local-api.js +45 -3
- package/src/lib/outcomes-engine.js +23 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/session-analytics.js +612 -0
- package/src/lib/usage-limits.js +21 -0
- package/dashboard/dist/assets/DashboardPage-DBajsWEp.js +0 -1
- package/dashboard/dist/assets/DialogDescription-uQ4gVIv9.js +0 -1
- package/dashboard/dist/assets/DialogTitle-BvElfi0r.js +0 -1
- package/dashboard/dist/assets/LimitsPage-f0L0d15j.js +0 -4
- package/dashboard/dist/assets/ResetPasswordPage-vrcj6bBP.js +0 -1
- package/dashboard/dist/assets/SettingsPage-CVldAt69.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-4dWjZkc9.js +0 -1
- package/dashboard/dist/assets/__vite-browser-external-CQxW1DuO.css +0 -1
- package/dashboard/dist/assets/agent-logos-CT9fumzx.js +0 -1
- package/dashboard/dist/assets/use-limits-display-prefs-BUFZh6fe.js +0 -1
- package/dashboard/dist/assets/use-quality-per-dollar-pref-DXoW5A0_.js +0 -1
- package/dashboard/dist/assets/use-transform-CuAoUb8H.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-BEc3QMzR.js +0 -1
|
@@ -249,8 +249,8 @@
|
|
|
249
249
|
]
|
|
250
250
|
}
|
|
251
251
|
</script>
|
|
252
|
-
<script type="module" crossorigin src="/assets/__vite-browser-external-
|
|
253
|
-
<link rel="stylesheet" crossorigin href="/assets/__vite-browser-external-
|
|
252
|
+
<script type="module" crossorigin src="/assets/__vite-browser-external-CHbEWSwX.js"></script>
|
|
253
|
+
<link rel="stylesheet" crossorigin href="/assets/__vite-browser-external-Cd947_ni.css">
|
|
254
254
|
</head>
|
|
255
255
|
<body>
|
|
256
256
|
<main class="aeo-seed-content" aria-label="Token Tracker AI-readable summary">
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
"description": "Shareable Token Tracker dashboard snapshot."
|
|
76
76
|
}
|
|
77
77
|
</script>
|
|
78
|
-
<script type="module" crossorigin src="/assets/__vite-browser-external-
|
|
79
|
-
<link rel="stylesheet" crossorigin href="/assets/__vite-browser-external-
|
|
78
|
+
<script type="module" crossorigin src="/assets/__vite-browser-external-CHbEWSwX.js"></script>
|
|
79
|
+
<link rel="stylesheet" crossorigin href="/assets/__vite-browser-external-Cd947_ni.css">
|
|
80
80
|
</head>
|
|
81
81
|
<body>
|
|
82
82
|
<main class="aeo-seed-content" aria-label="Token Tracker share page summary">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokentracker-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.1",
|
|
4
4
|
"description": "Local-first AI coding token usage and cost tracker for 27 tools, with a dashboard, desktop pet, widgets, achievements, and native macOS/Windows apps.",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ const { cmdUninstall } = require("./commands/uninstall");
|
|
|
7
7
|
const { cmdServe } = require("./commands/serve");
|
|
8
8
|
const { cmdDeviceLogin } = require("./commands/device-login");
|
|
9
9
|
const { cmdWrapped } = require("./commands/wrapped");
|
|
10
|
+
const { cmdSessions } = require("./commands/sessions");
|
|
10
11
|
|
|
11
12
|
async function run(argv) {
|
|
12
13
|
const [command, ...rest] = argv;
|
|
@@ -56,6 +57,9 @@ async function run(argv) {
|
|
|
56
57
|
case "wrapped":
|
|
57
58
|
await cmdWrapped(rest);
|
|
58
59
|
return;
|
|
60
|
+
case "sessions":
|
|
61
|
+
await cmdSessions(rest);
|
|
62
|
+
return;
|
|
59
63
|
default:
|
|
60
64
|
throw new Error(`Unknown command: ${command}`);
|
|
61
65
|
}
|
|
@@ -79,6 +83,7 @@ function printHelp() {
|
|
|
79
83
|
" npx tokentracker [--debug] uninstall [--purge]",
|
|
80
84
|
" npx tokentracker [--debug] device-login [--json] [--base-url <url>]",
|
|
81
85
|
" npx tokentracker [--debug] wrapped [--year 2026] [--json]",
|
|
86
|
+
" npx tokentracker sessions [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--format json|csv] [--out file] [--refresh] [--no-git]",
|
|
82
87
|
"",
|
|
83
88
|
"Notes:",
|
|
84
89
|
" - init: consent first, local setup next, browser sign-in last.",
|
|
@@ -93,6 +98,7 @@ function printHelp() {
|
|
|
93
98
|
" - --from-openclaw marks sync runs triggered by the OpenClaw session plugin.",
|
|
94
99
|
" - --debug shows original backend errors.",
|
|
95
100
|
" - device-login pairs a headless CLI / SSH session with a browser sign-in (15-min code).",
|
|
101
|
+
" - sessions exports metadata-only Claude/Codex efficiency analytics; no prompt or response text is retained.",
|
|
96
102
|
"",
|
|
97
103
|
].join("\n"),
|
|
98
104
|
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const { buildSessionAnalytics, summarizeSessions, sessionsToCsv } = require("../lib/session-analytics");
|
|
5
|
+
const { buildGitOutcomes } = require("../lib/git-outcomes");
|
|
6
|
+
|
|
7
|
+
function option(args, name) {
|
|
8
|
+
const index = args.indexOf(name);
|
|
9
|
+
return index >= 0 ? args[index + 1] || "" : "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function cmdSessions(args = []) {
|
|
13
|
+
const format = option(args, "--format") || (args.includes("--csv") ? "csv" : "json");
|
|
14
|
+
const out = option(args, "--out");
|
|
15
|
+
const sessions = await buildSessionAnalytics({ force: args.includes("--refresh") });
|
|
16
|
+
if (!args.includes("--no-git")) await buildGitOutcomes(sessions);
|
|
17
|
+
const summary = summarizeSessions(sessions, { from: option(args, "--from"), to: option(args, "--to") });
|
|
18
|
+
const content = format === "csv"
|
|
19
|
+
? sessionsToCsv(summary.sessions)
|
|
20
|
+
: `${JSON.stringify(summary, null, 2)}\n`;
|
|
21
|
+
if (out) {
|
|
22
|
+
fs.writeFileSync(out, content, { encoding: "utf8", mode: 0o600 });
|
|
23
|
+
process.stdout.write(`${out}\n`);
|
|
24
|
+
} else {
|
|
25
|
+
process.stdout.write(content);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { cmdSessions };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Estimates fixed context overhead without returning file contents, prompts,
|
|
4
|
+
// tool schemas, secrets, or command arguments.
|
|
5
|
+
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
const contextCache = new Map();
|
|
10
|
+
|
|
11
|
+
function estimateTokens(text) {
|
|
12
|
+
const value = String(text || "");
|
|
13
|
+
let cjk = 0;
|
|
14
|
+
for (const char of value) if (/\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}/u.test(char)) cjk += 1;
|
|
15
|
+
return Math.ceil(cjk + (value.length - cjk) / 4);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readMetadata(filePath, kind, root) {
|
|
19
|
+
try {
|
|
20
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
21
|
+
return { kind, name: path.relative(root, filePath) || path.basename(filePath), bytes: Buffer.byteLength(text), estimated_tokens: estimateTokens(text) };
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function walkNamedFiles(root, targetName, limit = 500) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const stack = [root];
|
|
28
|
+
while (stack.length && out.length < limit) {
|
|
29
|
+
const dir = stack.pop();
|
|
30
|
+
let entries;
|
|
31
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if ([".git", "node_modules", "dist", "build"].includes(entry.name)) continue;
|
|
34
|
+
const filePath = path.join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) stack.push(filePath);
|
|
36
|
+
else if (entry.isFile() && entry.name === targetName) out.push(filePath);
|
|
37
|
+
if (out.length >= limit) break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function countMcpServers(home, cwd) {
|
|
44
|
+
const files = [
|
|
45
|
+
path.join(home, ".claude.json"),
|
|
46
|
+
path.join(home, ".claude", "settings.json"),
|
|
47
|
+
path.join(cwd, ".mcp.json"),
|
|
48
|
+
];
|
|
49
|
+
const names = new Set();
|
|
50
|
+
for (const filePath of files) {
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
53
|
+
const servers = data.mcpServers || data.mcp_servers || {};
|
|
54
|
+
for (const name of Object.keys(servers)) names.add(name);
|
|
55
|
+
} catch { /* absent or not JSON */ }
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const toml = fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8");
|
|
59
|
+
for (const match of toml.matchAll(/^\s*\[mcp_servers\.([^\]]+)\]/gm)) names.add(match[1]);
|
|
60
|
+
} catch { /* absent */ }
|
|
61
|
+
return names.size;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function computeContextHealth({ home = os.homedir(), cwd = process.cwd(), env = process.env, cacheTtlMs = 5 * 60_000 } = {}) {
|
|
65
|
+
const cacheKey = `${home}\0${cwd}\0${env.TOKENTRACKER_MCP_TOOL_SCHEMA_TOKENS || ""}\0${env.TOKENTRACKER_MCP_TOOLS_PER_SERVER || ""}`;
|
|
66
|
+
const cached = contextCache.get(cacheKey);
|
|
67
|
+
if (cached && Date.now() - cached.at < cacheTtlMs) return cached.value;
|
|
68
|
+
const files = [];
|
|
69
|
+
const known = [
|
|
70
|
+
[path.join(home, ".claude", "CLAUDE.md"), "instruction", home],
|
|
71
|
+
[path.join(home, ".codex", "AGENTS.md"), "instruction", home],
|
|
72
|
+
[path.join(cwd, "CLAUDE.md"), "instruction", cwd],
|
|
73
|
+
[path.join(cwd, "AGENTS.md"), "instruction", cwd],
|
|
74
|
+
];
|
|
75
|
+
for (const [filePath, kind, root] of known) {
|
|
76
|
+
const item = readMetadata(filePath, kind, root);
|
|
77
|
+
if (item) files.push(item);
|
|
78
|
+
}
|
|
79
|
+
const skillRoots = [
|
|
80
|
+
path.join(home, ".claude", "skills"),
|
|
81
|
+
path.join(home, ".codex", "skills"),
|
|
82
|
+
path.join(cwd, ".claude", "skills"),
|
|
83
|
+
path.join(cwd, ".agents", "skills"),
|
|
84
|
+
];
|
|
85
|
+
for (const root of skillRoots) {
|
|
86
|
+
for (const filePath of walkNamedFiles(root, "SKILL.md")) {
|
|
87
|
+
const item = readMetadata(filePath, "skill", root);
|
|
88
|
+
if (item) files.push(item);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const instructionTokens = files.filter((row) => row.kind === "instruction").reduce((sum, row) => sum + row.estimated_tokens, 0);
|
|
92
|
+
const skillTokens = files.filter((row) => row.kind === "skill").reduce((sum, row) => sum + row.estimated_tokens, 0);
|
|
93
|
+
const mcpServers = countMcpServers(home, cwd);
|
|
94
|
+
const perToolSchemaTokens = Math.max(0, Number(env.TOKENTRACKER_MCP_TOOL_SCHEMA_TOKENS || 400));
|
|
95
|
+
const assumedToolsPerServer = Math.max(1, Number(env.TOKENTRACKER_MCP_TOOLS_PER_SERVER || 5));
|
|
96
|
+
const mcpSchemaTokens = mcpServers * perToolSchemaTokens * assumedToolsPerServer;
|
|
97
|
+
const total = instructionTokens + skillTokens + mcpSchemaTokens;
|
|
98
|
+
const severity = total >= 50_000 ? "high" : total >= 20_000 ? "medium" : "low";
|
|
99
|
+
const result = {
|
|
100
|
+
generated_at: new Date().toISOString(),
|
|
101
|
+
estimated_fixed_tokens: total,
|
|
102
|
+
severity,
|
|
103
|
+
breakdown: {
|
|
104
|
+
instruction_files: files.filter((row) => row.kind === "instruction").length,
|
|
105
|
+
instruction_tokens: instructionTokens,
|
|
106
|
+
skills: files.filter((row) => row.kind === "skill").length,
|
|
107
|
+
skill_tokens: skillTokens,
|
|
108
|
+
mcp_servers: mcpServers,
|
|
109
|
+
mcp_schema_tokens: mcpSchemaTokens,
|
|
110
|
+
},
|
|
111
|
+
largest_items: files.sort((a, b) => b.estimated_tokens - a.estimated_tokens).slice(0, 10),
|
|
112
|
+
provenance: {
|
|
113
|
+
confidence: "inferred",
|
|
114
|
+
source: "local-config-metadata",
|
|
115
|
+
mcp_assumption: { tools_per_server: assumedToolsPerServer, tokens_per_tool_schema: perToolSchemaTokens },
|
|
116
|
+
content_retained: false,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
contextCache.set(cacheKey, { at: Date.now(), value: result });
|
|
120
|
+
if (contextCache.size > 8) contextCache.delete(contextCache.keys().next().value);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { estimateTokens, computeContextHealth, countMcpServers };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Conservative, metadata-only Git attribution. A commit is attributed only
|
|
4
|
+
// when exactly one local AI session overlaps its author timestamp.
|
|
5
|
+
|
|
6
|
+
const { execFile } = require("node:child_process");
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const fsp = require("node:fs/promises");
|
|
9
|
+
const os = require("node:os");
|
|
10
|
+
const path = require("node:path");
|
|
11
|
+
const crypto = require("node:crypto");
|
|
12
|
+
|
|
13
|
+
function resolveAutoOutcomesPath(home = os.homedir()) {
|
|
14
|
+
return path.join(home, ".tokentracker", "tracker", "auto-outcomes.jsonl");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJsonl(filePath) {
|
|
18
|
+
try { return fs.readFileSync(filePath, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
|
|
19
|
+
catch { return []; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Async on purpose: this runs inside the local API request path, where a
|
|
23
|
+
// synchronous spawn would freeze every other endpoint for the duration of
|
|
24
|
+
// each git invocation.
|
|
25
|
+
function runGit(cwd, args) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
execFile(
|
|
28
|
+
"git",
|
|
29
|
+
args,
|
|
30
|
+
{ cwd, encoding: "utf8", timeout: 15_000, maxBuffer: 32 * 1024 * 1024 },
|
|
31
|
+
(error, stdout) => resolve(error ? "" : String(stdout).trim()),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function repoRoot(cwd) {
|
|
37
|
+
if (!cwd || !fs.existsSync(cwd)) return null;
|
|
38
|
+
return (await runGit(cwd, ["rev-parse", "--show-toplevel"])) || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function commitsForWindow(root, from, to) {
|
|
42
|
+
const format = "%H%x1f%aI%x1f%P%x1f%s%x1e";
|
|
43
|
+
const raw = await runGit(root, ["log", "--all", `--since=${from}`, `--until=${to}`, `--format=${format}`]);
|
|
44
|
+
if (!raw) return [];
|
|
45
|
+
return raw.split("\x1e").map((item) => item.trim()).filter(Boolean).map((item) => {
|
|
46
|
+
const [sha, timestamp, parents, subject] = item.split("\x1f");
|
|
47
|
+
return {
|
|
48
|
+
sha,
|
|
49
|
+
timestamp,
|
|
50
|
+
parent_count: String(parents || "").split(/\s+/).filter(Boolean).length,
|
|
51
|
+
reverted: /^revert\b/i.test(subject || ""),
|
|
52
|
+
};
|
|
53
|
+
}).filter((row) => row.sha && Number.isFinite(Date.parse(row.timestamp)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function writeAtomic(filePath, content) {
|
|
57
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
58
|
+
const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
59
|
+
await fsp.writeFile(temp, content, { encoding: "utf8", mode: 0o600 });
|
|
60
|
+
await fsp.rename(temp, filePath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function buildGitOutcomesInternal(sessions, { home = os.homedir(), force = false, maxAgeDays = 90 } = {}) {
|
|
64
|
+
const groups = new Map();
|
|
65
|
+
const rootCache = new Map();
|
|
66
|
+
const cutoff = Date.now() - Math.max(1, Number(maxAgeDays) || 90) * 86400_000;
|
|
67
|
+
for (const session of sessions || []) {
|
|
68
|
+
if (!session?.project_ref || !session.started_at || !session.ended_at) continue;
|
|
69
|
+
if (Date.parse(session.ended_at) < cutoff) continue;
|
|
70
|
+
let root = rootCache.get(session.project_ref);
|
|
71
|
+
if (root === undefined) {
|
|
72
|
+
root = await repoRoot(session.project_ref);
|
|
73
|
+
rootCache.set(session.project_ref, root);
|
|
74
|
+
}
|
|
75
|
+
if (!root) continue;
|
|
76
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
77
|
+
groups.get(root).push(session);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const outputPath = resolveAutoOutcomesPath(home);
|
|
81
|
+
const metaPath = `${outputPath}.meta.json`;
|
|
82
|
+
const signatureHash = crypto.createHash("sha256");
|
|
83
|
+
for (const [root, repoSessions] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
84
|
+
signatureHash.update(`${root}\0${await runGit(root, ["for-each-ref", "--format=%(objectname)"])}\n`);
|
|
85
|
+
for (const session of repoSessions) signatureHash.update(`${session.session_hash}\0${session.ended_at}\n`);
|
|
86
|
+
}
|
|
87
|
+
const signature = signatureHash.digest("hex");
|
|
88
|
+
if (!force) {
|
|
89
|
+
try {
|
|
90
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
91
|
+
if (meta.version === 1 && meta.signature === signature) return readJsonl(outputPath);
|
|
92
|
+
} catch { /* first run */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const outcomes = [];
|
|
96
|
+
for (const [root, repoSessions] of groups) {
|
|
97
|
+
const start = repoSessions.reduce((min, row) => !min || row.started_at < min ? row.started_at : min, "");
|
|
98
|
+
const endMs = Math.max(...repoSessions.map((row) => Date.parse(row.ended_at))) + 60 * 60 * 1000;
|
|
99
|
+
const commits = await commitsForWindow(root, start, new Date(endMs).toISOString());
|
|
100
|
+
for (const commit of commits) {
|
|
101
|
+
const commitMs = Date.parse(commit.timestamp);
|
|
102
|
+
const candidates = repoSessions.filter((session) => {
|
|
103
|
+
const sessionStart = Date.parse(session.started_at) - 10 * 60 * 1000;
|
|
104
|
+
const sessionEnd = Date.parse(session.ended_at) + 60 * 60 * 1000;
|
|
105
|
+
return commitMs >= sessionStart && commitMs <= sessionEnd;
|
|
106
|
+
});
|
|
107
|
+
if (candidates.length !== 1) continue;
|
|
108
|
+
const session = candidates[0];
|
|
109
|
+
outcomes.push({
|
|
110
|
+
timestamp: commit.timestamp,
|
|
111
|
+
model: session.model || "unknown",
|
|
112
|
+
tool: session.source || "unknown",
|
|
113
|
+
accepted: !commit.reverted,
|
|
114
|
+
task_type: "git_commit",
|
|
115
|
+
status: commit.reverted ? "reverted" : "committed",
|
|
116
|
+
session_hash: session.session_hash,
|
|
117
|
+
commit_hash: commit.sha.slice(0, 12),
|
|
118
|
+
parent_count: commit.parent_count,
|
|
119
|
+
confidence: "heuristic",
|
|
120
|
+
methodology: "single-overlapping-session",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
outcomes.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
125
|
+
await writeAtomic(outputPath, outcomes.map((row) => JSON.stringify(row)).join("\n") + (outcomes.length ? "\n" : ""));
|
|
126
|
+
await writeAtomic(metaPath, `${JSON.stringify({ version: 1, signature, generated_at: new Date().toISOString(), max_age_days: maxAgeDays })}\n`);
|
|
127
|
+
return outcomes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Overlapping outcomes/session-insights requests must not spawn duplicate git
|
|
131
|
+
// scans nor race the atomic sidecar write; share one build per home.
|
|
132
|
+
const gitOutcomesBuilds = new Map();
|
|
133
|
+
|
|
134
|
+
function buildGitOutcomes(sessions, options = {}) {
|
|
135
|
+
const home = path.resolve(String(options.home || os.homedir()));
|
|
136
|
+
const existing = gitOutcomesBuilds.get(home);
|
|
137
|
+
if (existing) return existing;
|
|
138
|
+
const promise = buildGitOutcomesInternal(sessions, { ...options, home });
|
|
139
|
+
gitOutcomesBuilds.set(home, promise);
|
|
140
|
+
const clear = () => {
|
|
141
|
+
if (gitOutcomesBuilds.get(home) === promise) gitOutcomesBuilds.delete(home);
|
|
142
|
+
};
|
|
143
|
+
promise.then(clear, clear);
|
|
144
|
+
return promise;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { resolveAutoOutcomesPath, buildGitOutcomes, repoRoot, commitsForWindow };
|
package/src/lib/local-api.js
CHANGED
|
@@ -1949,11 +1949,20 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1949
1949
|
const from = url.searchParams.get("from") || "";
|
|
1950
1950
|
const to = url.searchParams.get("to") || "";
|
|
1951
1951
|
const {
|
|
1952
|
-
|
|
1953
|
-
resolveOutcomesPath,
|
|
1952
|
+
readAllOutcomesData,
|
|
1954
1953
|
computeQualityPerDollar,
|
|
1955
1954
|
} = require("./outcomes-engine");
|
|
1956
|
-
|
|
1955
|
+
// Refresh the independent metadata-only sidecars before joining. Any
|
|
1956
|
+
// scanner failure degrades to the existing manual outcomes file.
|
|
1957
|
+
try {
|
|
1958
|
+
const { buildSessionAnalytics } = require("./session-analytics");
|
|
1959
|
+
const { buildGitOutcomes } = require("./git-outcomes");
|
|
1960
|
+
const sessions = await buildSessionAnalytics();
|
|
1961
|
+
await buildGitOutcomes(sessions);
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
console.error("[outcomes] automatic Git attribution failed:", error?.message || error);
|
|
1964
|
+
}
|
|
1965
|
+
const outcomes = readAllOutcomesData();
|
|
1957
1966
|
if (!outcomes.length) {
|
|
1958
1967
|
json(res, { available: false, from, to, by_model: [], by_tool: [], totals: null });
|
|
1959
1968
|
return true;
|
|
@@ -1964,6 +1973,39 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1964
1973
|
return true;
|
|
1965
1974
|
}
|
|
1966
1975
|
|
|
1976
|
+
// --- metadata-only Claude/Codex session efficiency ---
|
|
1977
|
+
if (p === "/functions/tokentracker-session-insights") {
|
|
1978
|
+
const from = url.searchParams.get("from") || "";
|
|
1979
|
+
const to = url.searchParams.get("to") || "";
|
|
1980
|
+
const refresh = ["1", "true"].includes(url.searchParams.get("refresh"));
|
|
1981
|
+
try {
|
|
1982
|
+
const { buildSessionAnalytics, summarizeSessions, sessionsToCsv } = require("./session-analytics");
|
|
1983
|
+
const sessions = await buildSessionAnalytics({ force: refresh });
|
|
1984
|
+
const wantsCsv = url.searchParams.get("format") === "csv";
|
|
1985
|
+
const includeSessions = wantsCsv || ["1", "true"].includes(url.searchParams.get("include_sessions"));
|
|
1986
|
+
const result = summarizeSessions(sessions, { from, to, includeSessions });
|
|
1987
|
+
if (wantsCsv) {
|
|
1988
|
+
const content = sessionsToCsv(result.sessions);
|
|
1989
|
+
res.statusCode = 200;
|
|
1990
|
+
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
|
1991
|
+
res.setHeader("Content-Disposition", "attachment; filename=tokentracker-sessions.csv");
|
|
1992
|
+
res.end(content);
|
|
1993
|
+
return true;
|
|
1994
|
+
}
|
|
1995
|
+
json(res, { from, to, ...result });
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
json(res, { available: false, error: error?.message || "Session analytics failed" }, 500);
|
|
1998
|
+
}
|
|
1999
|
+
return true;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// --- fixed context overhead audit (counts and estimates only) ---
|
|
2003
|
+
if (p === "/functions/tokentracker-context-health") {
|
|
2004
|
+
const { computeContextHealth } = require("./context-health");
|
|
2005
|
+
json(res, computeContextHealth({ home: os.homedir(), cwd: process.cwd(), env: process.env }));
|
|
2006
|
+
return true;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1967
2009
|
// --- usage-heatmap ---
|
|
1968
2010
|
if (p === "/functions/tokentracker-usage-heatmap") {
|
|
1969
2011
|
const weeks = parseInt(url.searchParams.get("weeks") || "52", 10);
|
|
@@ -27,6 +27,10 @@ function resolveOutcomesPath() {
|
|
|
27
27
|
return path.join(os.homedir(), ".tokentracker", "tracker", "outcomes.jsonl");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function resolveAutoOutcomesPath() {
|
|
31
|
+
return path.join(os.homedir(), ".tokentracker", "tracker", "auto-outcomes.jsonl");
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
// The ONLY fields ever lifted off an outcome record. Anything not on this list
|
|
31
35
|
// — bodies, diffs, messages, arbitrary blobs — is dropped at read time. This is
|
|
32
36
|
// the enforcement point for the metadata-only privacy invariant.
|
|
@@ -54,6 +58,10 @@ function sanitizeOutcome(raw) {
|
|
|
54
58
|
if (typeof raw.task_type === "string" && raw.task_type.trim()) {
|
|
55
59
|
out.task_type = raw.task_type.trim();
|
|
56
60
|
}
|
|
61
|
+
for (const key of ["status", "session_hash", "commit_hash", "confidence", "methodology"]) {
|
|
62
|
+
if (typeof raw[key] === "string" && raw[key].trim()) out[key] = raw[key].trim();
|
|
63
|
+
}
|
|
64
|
+
if (Number.isFinite(Number(raw.parent_count))) out.parent_count = Number(raw.parent_count);
|
|
57
65
|
return out;
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -91,6 +99,19 @@ function readOutcomesData(outcomesPath) {
|
|
|
91
99
|
return out;
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
function readAllOutcomesData(paths = [resolveOutcomesPath(), resolveAutoOutcomesPath()]) {
|
|
103
|
+
const dedup = new Map();
|
|
104
|
+
for (const outcomePath of paths) {
|
|
105
|
+
for (const row of readOutcomesData(outcomePath)) {
|
|
106
|
+
const key = row.commit_hash
|
|
107
|
+
? `commit:${row.commit_hash}`
|
|
108
|
+
: `${row.timestamp}|${row.model}|${row.tool}|${row.task_type || ""}`;
|
|
109
|
+
dedup.set(key, row);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [...dedup.values()];
|
|
113
|
+
}
|
|
114
|
+
|
|
94
115
|
function dayOf(value) {
|
|
95
116
|
return typeof value === "string" ? value.slice(0, 10) : "";
|
|
96
117
|
}
|
|
@@ -224,7 +245,9 @@ function computeQualityPerDollar(queueRows, outcomes, { from = "", to = "" } = {
|
|
|
224
245
|
|
|
225
246
|
module.exports = {
|
|
226
247
|
resolveOutcomesPath,
|
|
248
|
+
resolveAutoOutcomesPath,
|
|
227
249
|
sanitizeOutcome,
|
|
228
250
|
readOutcomesData,
|
|
251
|
+
readAllOutcomesData,
|
|
229
252
|
computeQualityPerDollar,
|
|
230
253
|
};
|