@neuralmindlabs/tokn 0.1.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.
Files changed (119) hide show
  1. package/.github/workflows/ci.yml +22 -0
  2. package/.oxlintrc.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +71 -0
  5. package/dist/assets/index-Dq74RfU-.css +1 -0
  6. package/dist/assets/index-_9e8Z58Z.js +66 -0
  7. package/dist/favicon.svg +1 -0
  8. package/dist/icons.svg +24 -0
  9. package/dist/index.html +14 -0
  10. package/index.html +13 -0
  11. package/package.json +45 -0
  12. package/packages/tokn/LICENSE +21 -0
  13. package/packages/tokn/README.md +85 -0
  14. package/packages/tokn/package.json +56 -0
  15. package/packages/tokn/src/aggregate.ts +83 -0
  16. package/packages/tokn/src/cli.ts +181 -0
  17. package/packages/tokn/src/cursor.ts +85 -0
  18. package/packages/tokn/src/index.ts +6 -0
  19. package/packages/tokn/src/node.ts +50 -0
  20. package/packages/tokn/src/parsers/claude.ts +53 -0
  21. package/packages/tokn/src/parsers/codex.ts +74 -0
  22. package/packages/tokn/src/pricing/index.ts +106 -0
  23. package/packages/tokn/src/pricing/prices.json +948 -0
  24. package/packages/tokn/src/pricing/prices.ts +947 -0
  25. package/packages/tokn/src/types.ts +51 -0
  26. package/packages/tokn/tests/__snapshots__/eval.test.ts.snap +12 -0
  27. package/packages/tokn/tests/aggregate.test.ts +73 -0
  28. package/packages/tokn/tests/claude.test.ts +87 -0
  29. package/packages/tokn/tests/codex.test.ts +54 -0
  30. package/packages/tokn/tests/cursor.test.ts +126 -0
  31. package/packages/tokn/tests/eval.test.ts +69 -0
  32. package/packages/tokn/tests/fixtures/claude-session.jsonl +3 -0
  33. package/packages/tokn/tests/fixtures/codex-session.jsonl +5 -0
  34. package/packages/tokn/tests/pricing.test.ts +137 -0
  35. package/packages/tokn/tsconfig.json +21 -0
  36. package/public/favicon.svg +1 -0
  37. package/public/icons.svg +24 -0
  38. package/scripts/validate.ts +122 -0
  39. package/src/App.tsx +12 -0
  40. package/src/assets/hero.png +0 -0
  41. package/src/assets/vite.svg +1 -0
  42. package/src/index.css +250 -0
  43. package/src/lib/refresh.ts +134 -0
  44. package/src/lib/scanner.ts +108 -0
  45. package/src/main.tsx +7 -0
  46. package/src/ui/Dashboard.tsx +203 -0
  47. package/src/ui/Popover.tsx +91 -0
  48. package/src/ui/Settings.tsx +114 -0
  49. package/src/ui/useAppData.ts +71 -0
  50. package/src-tauri/Cargo.lock +5972 -0
  51. package/src-tauri/Cargo.toml +35 -0
  52. package/src-tauri/build.rs +3 -0
  53. package/src-tauri/capabilities/default.json +114 -0
  54. package/src-tauri/icons/128x128.png +0 -0
  55. package/src-tauri/icons/128x128@2x.png +0 -0
  56. package/src-tauri/icons/32x32.png +0 -0
  57. package/src-tauri/icons/64x64.png +0 -0
  58. package/src-tauri/icons/Square107x107Logo.png +0 -0
  59. package/src-tauri/icons/Square142x142Logo.png +0 -0
  60. package/src-tauri/icons/Square150x150Logo.png +0 -0
  61. package/src-tauri/icons/Square284x284Logo.png +0 -0
  62. package/src-tauri/icons/Square30x30Logo.png +0 -0
  63. package/src-tauri/icons/Square310x310Logo.png +0 -0
  64. package/src-tauri/icons/Square44x44Logo.png +0 -0
  65. package/src-tauri/icons/Square71x71Logo.png +0 -0
  66. package/src-tauri/icons/Square89x89Logo.png +0 -0
  67. package/src-tauri/icons/StoreLogo.png +0 -0
  68. package/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  69. package/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png +0 -0
  70. package/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  71. package/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png +0 -0
  72. package/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png +0 -0
  73. package/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  74. package/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png +0 -0
  75. package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png +0 -0
  76. package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  77. package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
  78. package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png +0 -0
  79. package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  80. package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  81. package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
  82. package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  83. package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  84. package/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
  85. package/src-tauri/icons/icon.icns +0 -0
  86. package/src-tauri/icons/icon.ico +0 -0
  87. package/src-tauri/icons/icon.png +0 -0
  88. package/src-tauri/icons/ios/AppIcon-20x20@1x.png +0 -0
  89. package/src-tauri/icons/ios/AppIcon-20x20@2x-1.png +0 -0
  90. package/src-tauri/icons/ios/AppIcon-20x20@2x.png +0 -0
  91. package/src-tauri/icons/ios/AppIcon-20x20@3x.png +0 -0
  92. package/src-tauri/icons/ios/AppIcon-29x29@1x.png +0 -0
  93. package/src-tauri/icons/ios/AppIcon-29x29@2x-1.png +0 -0
  94. package/src-tauri/icons/ios/AppIcon-29x29@2x.png +0 -0
  95. package/src-tauri/icons/ios/AppIcon-29x29@3x.png +0 -0
  96. package/src-tauri/icons/ios/AppIcon-40x40@1x.png +0 -0
  97. package/src-tauri/icons/ios/AppIcon-40x40@2x-1.png +0 -0
  98. package/src-tauri/icons/ios/AppIcon-40x40@2x.png +0 -0
  99. package/src-tauri/icons/ios/AppIcon-40x40@3x.png +0 -0
  100. package/src-tauri/icons/ios/AppIcon-512@2x.png +0 -0
  101. package/src-tauri/icons/ios/AppIcon-60x60@2x.png +0 -0
  102. package/src-tauri/icons/ios/AppIcon-60x60@3x.png +0 -0
  103. package/src-tauri/icons/ios/AppIcon-76x76@1x.png +0 -0
  104. package/src-tauri/icons/ios/AppIcon-76x76@2x.png +0 -0
  105. package/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png +0 -0
  106. package/src-tauri/icons/source/app-icon-1024.png +0 -0
  107. package/src-tauri/icons/source/app-icon.svg +74 -0
  108. package/src-tauri/icons/source/tray-22.png +0 -0
  109. package/src-tauri/icons/source/tray-44.png +0 -0
  110. package/src-tauri/icons/source/tray-icon.svg +14 -0
  111. package/src-tauri/permissions/allow-custom-commands.toml +11 -0
  112. package/src-tauri/src/lib.rs +238 -0
  113. package/src-tauri/src/main.rs +6 -0
  114. package/src-tauri/tauri.conf.json +57 -0
  115. package/tsconfig.app.json +27 -0
  116. package/tsconfig.json +7 -0
  117. package/tsconfig.node.json +23 -0
  118. package/vite.config.ts +7 -0
  119. package/vitest.config.ts +28 -0
@@ -0,0 +1,50 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { parseClaudeSession } from './parsers/claude.ts';
5
+ import { parseCodexSession } from './parsers/codex.ts';
6
+ import { eventsToDays, type SourceContribution } from './aggregate.ts';
7
+
8
+ export const CLAUDE_ROOT = join(homedir(), '.claude', 'projects');
9
+ export const CODEX_ROOT = join(homedir(), '.codex', 'sessions');
10
+
11
+ function* jsonlFiles(dir: string): Generator<string> {
12
+ let entries;
13
+ try {
14
+ entries = readdirSync(dir, { withFileTypes: true });
15
+ } catch {
16
+ return;
17
+ }
18
+ for (const entry of entries) {
19
+ const p = join(dir, entry.name);
20
+ if (entry.isDirectory()) yield* jsonlFiles(p);
21
+ else if (entry.name.endsWith('.jsonl')) yield p;
22
+ }
23
+ }
24
+
25
+ /** Scan local Claude Code + Codex logs synchronously (CLI / Node use). */
26
+ export function scanLocalSources(): SourceContribution[] {
27
+ const sources: SourceContribution[] = [];
28
+ const dedupe = new Set<string>();
29
+ for (const file of [...jsonlFiles(CLAUDE_ROOT)].sort()) {
30
+ try {
31
+ const parsed = parseClaudeSession(readFileSync(file, 'utf8'), dedupe);
32
+ if (parsed.events.length > 0) {
33
+ sources.push({ tool: 'claude', project: parsed.project, days: eventsToDays(parsed.events) });
34
+ }
35
+ } catch {
36
+ /* unreadable file — skip */
37
+ }
38
+ }
39
+ for (const file of jsonlFiles(CODEX_ROOT)) {
40
+ try {
41
+ const parsed = parseCodexSession(readFileSync(file, 'utf8'));
42
+ if (parsed.events.length > 0) {
43
+ sources.push({ tool: 'codex', project: parsed.project, days: eventsToDays(parsed.events) });
44
+ }
45
+ } catch {
46
+ /* unreadable file — skip */
47
+ }
48
+ }
49
+ return sources;
50
+ }
@@ -0,0 +1,53 @@
1
+ import type { UsageEvent } from '../types.ts';
2
+
3
+ export interface ParsedSession {
4
+ events: UsageEvent[];
5
+ project?: string;
6
+ }
7
+
8
+ /**
9
+ * Parse one Claude Code session transcript (~/.claude/projects/** /*.jsonl).
10
+ * Assistant messages carry message.usage; the same API response can appear in
11
+ * multiple lines/files, so callers pass a shared dedupe set keyed by
12
+ * message.id + requestId.
13
+ */
14
+ export function parseClaudeSession(content: string, dedupe: Set<string>): ParsedSession {
15
+ const events: UsageEvent[] = [];
16
+ let project: string | undefined;
17
+
18
+ for (const line of content.split('\n')) {
19
+ if (!line.trim()) continue;
20
+ let entry: any;
21
+ try {
22
+ entry = JSON.parse(line);
23
+ } catch {
24
+ continue;
25
+ }
26
+ if (!project && typeof entry.cwd === 'string') project = entry.cwd;
27
+ if (entry.type !== 'assistant') continue;
28
+ const msg = entry.message;
29
+ const usage = msg?.usage;
30
+ if (!usage || !entry.timestamp) continue;
31
+ const model = msg.model;
32
+ if (!model || model === '<synthetic>') continue;
33
+
34
+ if (msg.id && entry.requestId) {
35
+ const key = `${msg.id}:${entry.requestId}`;
36
+ if (dedupe.has(key)) continue;
37
+ dedupe.add(key);
38
+ }
39
+
40
+ events.push({
41
+ tool: 'claude',
42
+ timestamp: entry.timestamp,
43
+ model,
44
+ project,
45
+ inputTokens: usage.input_tokens ?? 0,
46
+ outputTokens: usage.output_tokens ?? 0,
47
+ cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
48
+ cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? 0,
49
+ cacheReadTokens: usage.cache_read_input_tokens ?? 0,
50
+ });
51
+ }
52
+ return { events, project };
53
+ }
@@ -0,0 +1,74 @@
1
+ import type { UsageEvent } from '../types.ts';
2
+ import type { ParsedSession } from './claude.ts';
3
+
4
+ interface TokenTotals {
5
+ input_tokens: number;
6
+ cached_input_tokens: number;
7
+ output_tokens: number;
8
+ }
9
+
10
+ /**
11
+ * Parse one Codex rollout file (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl).
12
+ *
13
+ * token_count events carry a session-cumulative total_token_usage. We take
14
+ * consecutive deltas of the cumulative counter rather than summing
15
+ * last_token_usage, which stays correct if an event is emitted twice for the
16
+ * same API call. A counter that shrinks means it was reset (e.g. compaction);
17
+ * the new value is taken as a fresh delta.
18
+ */
19
+ export function parseCodexSession(content: string): ParsedSession {
20
+ const events: UsageEvent[] = [];
21
+ let project: string | undefined;
22
+ let model = 'unknown';
23
+ let prev: TokenTotals | null = null;
24
+
25
+ for (const line of content.split('\n')) {
26
+ if (!line.trim()) continue;
27
+ let entry: any;
28
+ try {
29
+ entry = JSON.parse(line);
30
+ } catch {
31
+ continue;
32
+ }
33
+ const payload = entry.payload;
34
+ if (!payload) continue;
35
+
36
+ if (entry.type === 'session_meta') {
37
+ if (typeof payload.cwd === 'string') project ??= payload.cwd;
38
+ continue;
39
+ }
40
+ if (entry.type === 'turn_context') {
41
+ if (typeof payload.model === 'string') model = payload.model;
42
+ if (typeof payload.cwd === 'string') project ??= payload.cwd;
43
+ continue;
44
+ }
45
+ if (entry.type !== 'event_msg' || payload.type !== 'token_count') continue;
46
+ const total: TokenTotals | undefined = payload.info?.total_token_usage;
47
+ if (!total || !entry.timestamp) continue;
48
+
49
+ let d: TokenTotals;
50
+ if (prev === null || total.input_tokens < prev.input_tokens) {
51
+ d = total;
52
+ } else {
53
+ d = {
54
+ input_tokens: total.input_tokens - prev.input_tokens,
55
+ cached_input_tokens: Math.max(0, (total.cached_input_tokens ?? 0) - (prev.cached_input_tokens ?? 0)),
56
+ output_tokens: Math.max(0, total.output_tokens - prev.output_tokens),
57
+ };
58
+ }
59
+ prev = total;
60
+ if (d.input_tokens === 0 && d.output_tokens === 0) continue;
61
+
62
+ events.push({
63
+ tool: 'codex',
64
+ timestamp: entry.timestamp,
65
+ model,
66
+ project,
67
+ inputTokens: d.input_tokens,
68
+ outputTokens: d.output_tokens,
69
+ cacheWriteTokens: 0,
70
+ cacheReadTokens: d.cached_input_tokens ?? 0,
71
+ });
72
+ }
73
+ return { events, project };
74
+ }
@@ -0,0 +1,106 @@
1
+ import vendored from './prices.ts';
2
+ import type { UsageEvent } from '../types.ts';
3
+
4
+ export interface PriceEntry {
5
+ provider: string;
6
+ input_cost_per_token?: number;
7
+ output_cost_per_token?: number;
8
+ cache_creation_input_token_cost?: number;
9
+ cache_read_input_token_cost?: number;
10
+ }
11
+
12
+ let table: Record<string, PriceEntry> = vendored as Record<string, PriceEntry>;
13
+ let lastFetchedAt = 0;
14
+
15
+ /** Replace the vendored table with a freshly fetched one (same LiteLLM shape). */
16
+ export function setPriceTable(t: Record<string, PriceEntry>) {
17
+ table = t;
18
+ }
19
+
20
+ /** Fetch up-to-date pricing from NeuralMind Labs API, throttled to at most once per 10 minutes. */
21
+ export async function fetchPrices(
22
+ opts: { fetchImpl?: typeof globalThis.fetch; force?: boolean } = {},
23
+ ): Promise<boolean> {
24
+ const { fetchImpl = globalThis.fetch, force = false } = opts;
25
+ const now = Date.now();
26
+ if (!force && now - lastFetchedAt < 10 * 60 * 1000) {
27
+ return true;
28
+ }
29
+
30
+ try {
31
+ const res = await fetchImpl('https://www.neuralmindlabs.com/api/models');
32
+ if (!res.ok) return false;
33
+ const body = await res.json();
34
+ if (!body || !Array.isArray(body.data)) return false;
35
+
36
+ const newTable: Record<string, PriceEntry> = { ...table };
37
+ for (const m of body.data) {
38
+ if (!m.id) continue;
39
+ const input = (m.inputPricePer1M ?? 0) / 1000000;
40
+ const output = (m.outputPricePer1M ?? 0) / 1000000;
41
+ const provider = m.provider ? m.provider.toLowerCase() : 'openai';
42
+
43
+ const entry: PriceEntry = {
44
+ provider,
45
+ input_cost_per_token: input,
46
+ output_cost_per_token: output,
47
+ };
48
+
49
+ if (provider === 'anthropic') {
50
+ entry.cache_creation_input_token_cost = input * 1.25;
51
+ entry.cache_read_input_token_cost = input * 0.1;
52
+ } else if (provider === 'openai') {
53
+ entry.cache_read_input_token_cost = input * 0.5;
54
+ }
55
+
56
+ newTable[m.id] = entry;
57
+
58
+ const parts = m.id.split('/');
59
+ if (parts.length > 1) {
60
+ const bare = parts.slice(1).join('/');
61
+ newTable[bare] = entry;
62
+ }
63
+ }
64
+ table = newTable;
65
+ lastFetchedAt = now;
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export function findPrice(model: string): PriceEntry | undefined {
73
+ if (table[model]) return table[model];
74
+ const noDate = model.replace(/-\d{8}$/, '');
75
+ if (table[noDate]) return table[noDate];
76
+ const noLatest = model.replace(/-latest$/, '');
77
+ if (table[noLatest]) return table[noLatest];
78
+ return undefined;
79
+ }
80
+
81
+ /**
82
+ * Cost of one API call. Anthropic reports input/cache-write/cache-read
83
+ * disjointly; OpenAI's input_tokens includes cached_input_tokens, which are
84
+ * billed at the cache-read rate instead of the input rate.
85
+ */
86
+ export function eventCostUSD(e: UsageEvent): number | undefined {
87
+ const p = findPrice(e.model);
88
+ if (!p) return undefined;
89
+ const inRate = p.input_cost_per_token ?? 0;
90
+ const outRate = p.output_cost_per_token ?? 0;
91
+ const writeRate = p.cache_creation_input_token_cost ?? inRate * 1.25;
92
+ const readRate = p.cache_read_input_token_cost ?? inRate * 0.1;
93
+ if (p.provider === 'openai') {
94
+ const uncached = Math.max(0, e.inputTokens - e.cacheReadTokens);
95
+ return uncached * inRate + e.cacheReadTokens * readRate + e.outputTokens * outRate;
96
+ }
97
+ const write1h = e.cacheWrite1hTokens ?? 0;
98
+ const write5m = e.cacheWriteTokens - write1h;
99
+ return (
100
+ e.inputTokens * inRate +
101
+ write5m * writeRate +
102
+ write1h * inRate * 2 +
103
+ e.cacheReadTokens * readRate +
104
+ e.outputTokens * outRate
105
+ );
106
+ }