@thispointon/kondi-chat 0.1.2

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync, execSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const projectRoot = join(__dirname, "..");
10
+ const version = "0.1.2";
11
+
12
+ const arg = process.argv[2];
13
+
14
+ if (arg === "--version" || arg === "-V") {
15
+ console.log(`kondi-chat ${version}`);
16
+ process.exit(0);
17
+ }
18
+
19
+ if (arg === "--help" || arg === "-h") {
20
+ console.log(`kondi-chat ${version} — terminal coding agent that picks a different model per phase`);
21
+ console.log("");
22
+ console.log("Usage:");
23
+ console.log(" kondi-chat Launch the TUI (default)");
24
+ console.log(" kondi-chat --prompt \"…\" Run a single turn non-interactively");
25
+ console.log(" kondi-chat --resume Resume the latest session in this dir");
26
+ console.log(" kondi-chat --sessions List saved sessions for this dir");
27
+ console.log("");
28
+ console.log("Non-interactive flags:");
29
+ console.log(" --prompt \"…\" Prompt text (required for non-interactive)");
30
+ console.log(" --pipe Read additional context from stdin");
31
+ console.log(" --json Emit structured JSON output instead of text");
32
+ console.log(" --max-iterations N Cap agent-loop iterations (overrides profile)");
33
+ console.log(" --max-cost N Cap per-turn USD (overrides profile)");
34
+ console.log(" --auto-approve TOOL Auto-approve a specific tool (e.g. run_command)");
35
+ console.log(" Can be repeated. Chained shell commands still");
36
+ console.log(" drop to confirm; always-confirm patterns still");
37
+ console.log(" block.");
38
+ console.log(" --dangerously-skip-permissions Bypass all permission gates. Be sure.");
39
+ console.log("");
40
+ console.log("Session:");
41
+ console.log(" --resume [ID] Resume latest or specific session");
42
+ console.log(" --sessions List sessions");
43
+ console.log(" --cwd PATH Operate as if launched from PATH");
44
+ console.log("");
45
+ console.log("Inside the TUI: /help, /mode, /use, /cost, /routing, /undo, /loop, /council");
46
+ console.log("Exit codes: 0 ok · 1 error · 2 max iterations · 3 max cost · 5 permission denied");
47
+ console.log("");
48
+ console.log("Docs: https://github.com/thisPointOn/kondi-chat#readme");
49
+ process.exit(0);
50
+ }
51
+
52
+ const tuiBinary = join(projectRoot, "tui", "target", "release", "kondi-tui");
53
+
54
+ if (existsSync(tuiBinary)) {
55
+ try {
56
+ execFileSync(tuiBinary, process.argv.slice(2), { stdio: "inherit" });
57
+ } catch (e) {
58
+ process.exit(e.status ?? 1);
59
+ }
60
+ } else {
61
+ // Run the Node backend from the user's current working directory — NOT from
62
+ // the install dir. Setting cwd: projectRoot here would make the agent operate
63
+ // on the kondi-chat install instead of the user's project, which was the
64
+ // common failure mode for any install where the TUI binary download failed.
65
+ try {
66
+ execSync(`npx tsx ${join(projectRoot, "src", "cli", "backend.ts")} ${process.argv.slice(2).join(" ")}`, {
67
+ stdio: "inherit",
68
+ });
69
+ } catch (e) {
70
+ process.exit(e.status ?? 1);
71
+ }
72
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@thispointon/kondi-chat",
3
+ "version": "0.1.2",
4
+ "description": "Multi-model AI coding CLI with intelligent routing, budget profiles, and council deliberation",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "kondi-chat": "./bin/kondi-chat.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "bin/",
13
+ "scripts/",
14
+ "package.json",
15
+ "LICENSE",
16
+ "!src/**/*.test.ts"
17
+ ],
18
+ "scripts": {
19
+ "postinstall": "node scripts/postinstall.cjs",
20
+ "chat:tui": "cd tui && cargo run --release",
21
+ "build:tui": "cd tui && cargo build --release",
22
+ "start": "npx tsx src/cli/backend.ts",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/thisPointOn/kondi-chat"
30
+ },
31
+ "keywords": [
32
+ "ai",
33
+ "cli",
34
+ "coding",
35
+ "multi-model",
36
+ "llm",
37
+ "agent",
38
+ "claude",
39
+ "gpt",
40
+ "deepseek"
41
+ ],
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "tsx": "^4.0.0",
48
+ "zod": "^4.4.3"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^25.5.0",
52
+ "typescript": "^6.0.2",
53
+ "vitest": "^4.1.2"
54
+ }
55
+ }
@@ -0,0 +1,49 @@
1
+ # kondi-chat demo — record with: vhs scripts/demo.tape
2
+ #
3
+ # Produces demo.gif: one coding turn routed across multiple models,
4
+ # then /cost and /routing to show what happened.
5
+ #
6
+ # Requires: vhs (https://github.com/charmbracelet/vhs) and `kondi-chat`
7
+ # on PATH (npm i -g, or alias it to the built binary). Run from the repo
8
+ # root. Sleep values are first guesses — record once, then tune them to
9
+ # your actual turn latency before committing the GIF.
10
+
11
+ Output demo.gif
12
+
13
+ Set Shell "bash"
14
+ Set FontSize 14
15
+ Set Width 1200
16
+ Set Height 720
17
+ Set Theme "Dracula"
18
+ Set TypingSpeed 55ms
19
+ Set Padding 24
20
+
21
+ # ── Launch the TUI ──────────────────────────────────────────────
22
+ Hide
23
+ Type "kondi-chat"
24
+ Enter
25
+ Sleep 5s
26
+ Show
27
+ Sleep 2s
28
+
29
+ # ── One coding turn — should route plan / execute / reflect ─────
30
+ Type "add a /health endpoint to the server with a test"
31
+ Sleep 800ms
32
+ Enter
33
+
34
+ # The turn: investigation → plan → execute → reflect, each phase
35
+ # routed to a different model. Tune to your real turn time.
36
+ Sleep 45s
37
+
38
+ # ── Show the cost breakdown ─────────────────────────────────────
39
+ Type "/cost"
40
+ Enter
41
+ Sleep 6s
42
+
43
+ # ── Show the per-tier routing decisions ─────────────────────────
44
+ Type "/routing"
45
+ Enter
46
+ Sleep 7s
47
+
48
+ # Tail padding so the GIF doesn't cut off abruptly.
49
+ Sleep 2s
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * postinstall — downloads the prebuilt Rust TUI binary for the current
5
+ * platform from the matching GitHub release.
6
+ *
7
+ * Runs automatically on `npm install -g @thispointon/kondi-chat`. If the download
8
+ * fails (no internet, unsupported platform, no matching release), the
9
+ * package still works — bin/kondi-chat.js falls back to running the
10
+ * Node backend directly (no TUI, just stdio). The binary is optional
11
+ * for functionality; it's required for the terminal UI.
12
+ *
13
+ * Platform → artifact name mapping matches the release workflow matrix
14
+ * in .github/workflows/release.yml.
15
+ */
16
+
17
+ const { execSync } = require('child_process');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const https = require('https');
21
+ const http = require('http');
22
+
23
+ const REPO = 'thisPointOn/kondi-chat';
24
+
25
+ // Map Node's os.platform()+os.arch() to the GitHub release artifact name.
26
+ const PLATFORM_MAP = {
27
+ 'linux-x64': 'kondi-tui-linux-x64',
28
+ 'linux-arm64': 'kondi-tui-linux-arm64',
29
+ 'darwin-x64': 'kondi-tui-darwin-x64',
30
+ 'darwin-arm64': 'kondi-tui-darwin-arm64',
31
+ 'win32-x64': 'kondi-tui-win32-x64.exe',
32
+ };
33
+
34
+ function getPlatformKey() {
35
+ return `${process.platform}-${process.arch}`;
36
+ }
37
+
38
+ function getVersion() {
39
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
40
+ return pkg.version;
41
+ }
42
+
43
+ function download(url) {
44
+ return new Promise((resolve, reject) => {
45
+ const get = url.startsWith('https') ? https.get : http.get;
46
+ get(url, { headers: { 'User-Agent': 'kondi-chat-postinstall' } }, (res) => {
47
+ // Follow redirects (GitHub sends 302 to CDN).
48
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
49
+ return download(res.headers.location).then(resolve, reject);
50
+ }
51
+ if (res.statusCode !== 200) {
52
+ return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
53
+ }
54
+ const chunks = [];
55
+ res.on('data', (c) => chunks.push(c));
56
+ res.on('end', () => resolve(Buffer.concat(chunks)));
57
+ res.on('error', reject);
58
+ }).on('error', reject);
59
+ });
60
+ }
61
+
62
+ async function main() {
63
+ const key = getPlatformKey();
64
+ const artifact = PLATFORM_MAP[key];
65
+
66
+ if (!artifact) {
67
+ console.log(`[kondi-chat] No prebuilt TUI binary for ${key}. The Node backend will run without the TUI.`);
68
+ console.log(`[kondi-chat] To build from source: cd tui && cargo build --release`);
69
+ return;
70
+ }
71
+
72
+ const version = getVersion();
73
+ const tag = `v${version}`;
74
+ const url = `https://github.com/${REPO}/releases/download/${tag}/${artifact}`;
75
+ const destDir = path.join(__dirname, '..', 'tui', 'target', 'release');
76
+ const isWindows = process.platform === 'win32';
77
+ const destFile = path.join(destDir, isWindows ? 'kondi-tui.exe' : 'kondi-tui');
78
+
79
+ // Skip if the binary already exists (source build or prior install).
80
+ if (fs.existsSync(destFile)) {
81
+ console.log(`[kondi-chat] TUI binary already exists at ${destFile}, skipping download.`);
82
+ return;
83
+ }
84
+
85
+ console.log(`[kondi-chat] Downloading TUI binary for ${key}...`);
86
+ console.log(`[kondi-chat] ${url}`);
87
+
88
+ try {
89
+ const data = await download(url);
90
+ fs.mkdirSync(destDir, { recursive: true });
91
+ fs.writeFileSync(destFile, data);
92
+ if (!isWindows) {
93
+ fs.chmodSync(destFile, 0o755);
94
+ }
95
+ console.log(`[kondi-chat] TUI binary installed (${(data.length / 1024 / 1024).toFixed(1)} MB).`);
96
+ } catch (err) {
97
+ console.log(`[kondi-chat] Could not download TUI binary: ${err.message}`);
98
+ console.log(`[kondi-chat] The Node backend will run without the TUI.`);
99
+ console.log(`[kondi-chat] To build from source: cd tui && cargo build --release`);
100
+ }
101
+ }
102
+
103
+ main();
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Analytics — aggregated cost and usage data across sessions.
3
+ *
4
+ * Reads all ledger files from .kondi-chat/ and builds summaries
5
+ * by day, model, provider, and phase. Persists a rolling summary
6
+ * so it doesn't have to re-read old files.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import type { LedgerEntry } from '../types.ts';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface DailySummary {
18
+ date: string; // YYYY-MM-DD
19
+ totalCalls: number;
20
+ totalInputTokens: number;
21
+ totalOutputTokens: number;
22
+ totalCostUsd: number;
23
+ byModel: Record<string, ModelDaySummary>;
24
+ byProvider: Record<string, ProviderDaySummary>;
25
+ }
26
+
27
+ export interface ModelDaySummary {
28
+ calls: number;
29
+ inputTokens: number;
30
+ outputTokens: number;
31
+ costUsd: number;
32
+ }
33
+
34
+ export interface ProviderDaySummary {
35
+ calls: number;
36
+ inputTokens: number;
37
+ outputTokens: number;
38
+ costUsd: number;
39
+ models: string[];
40
+ }
41
+
42
+ export interface AnalyticsSummary {
43
+ period: string;
44
+ days: number;
45
+ totalCalls: number;
46
+ totalInputTokens: number;
47
+ totalOutputTokens: number;
48
+ totalCostUsd: number;
49
+ byModel: Record<string, ModelDaySummary>;
50
+ byProvider: Record<string, ProviderDaySummary>;
51
+ dailyBreakdown: DailySummary[];
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Analytics
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export class Analytics {
59
+ private storageDir: string;
60
+ private summaryPath: string;
61
+ private dailyData: Map<string, DailySummary> = new Map();
62
+
63
+ constructor(storageDir: string) {
64
+ this.storageDir = storageDir;
65
+ this.summaryPath = join(storageDir, 'analytics.json');
66
+ this.load();
67
+ }
68
+
69
+ /** Rebuild analytics from all ledger files */
70
+ rebuild(): void {
71
+ this.dailyData.clear();
72
+
73
+ const files = readdirSync(this.storageDir)
74
+ .filter(f => f.endsWith('-ledger.json'));
75
+
76
+ for (const file of files) {
77
+ try {
78
+ const entries: LedgerEntry[] = JSON.parse(
79
+ readFileSync(join(this.storageDir, file), 'utf-8')
80
+ );
81
+ for (const entry of entries) {
82
+ this.addEntry(entry);
83
+ }
84
+ } catch {
85
+ // Skip corrupt files
86
+ }
87
+ }
88
+
89
+ this.save();
90
+ }
91
+
92
+ /** Add a single entry (for live updates during a session) */
93
+ addEntry(entry: LedgerEntry): void {
94
+ const date = entry.timestamp.slice(0, 10); // YYYY-MM-DD
95
+ let day = this.dailyData.get(date);
96
+ if (!day) {
97
+ day = {
98
+ date,
99
+ totalCalls: 0,
100
+ totalInputTokens: 0,
101
+ totalOutputTokens: 0,
102
+ totalCostUsd: 0,
103
+ byModel: {},
104
+ byProvider: {},
105
+ };
106
+ this.dailyData.set(date, day);
107
+ }
108
+
109
+ day.totalCalls++;
110
+ day.totalInputTokens += entry.inputTokens;
111
+ day.totalOutputTokens += entry.outputTokens;
112
+ day.totalCostUsd += entry.costUsd;
113
+
114
+ // By model
115
+ if (!day.byModel[entry.model]) {
116
+ day.byModel[entry.model] = { calls: 0, inputTokens: 0, outputTokens: 0, costUsd: 0 };
117
+ }
118
+ day.byModel[entry.model].calls++;
119
+ day.byModel[entry.model].inputTokens += entry.inputTokens;
120
+ day.byModel[entry.model].outputTokens += entry.outputTokens;
121
+ day.byModel[entry.model].costUsd += entry.costUsd;
122
+
123
+ // By provider
124
+ if (!day.byProvider[entry.provider]) {
125
+ day.byProvider[entry.provider] = { calls: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, models: [] };
126
+ }
127
+ day.byProvider[entry.provider].calls++;
128
+ day.byProvider[entry.provider].inputTokens += entry.inputTokens;
129
+ day.byProvider[entry.provider].outputTokens += entry.outputTokens;
130
+ day.byProvider[entry.provider].costUsd += entry.costUsd;
131
+ if (!day.byProvider[entry.provider].models.includes(entry.model)) {
132
+ day.byProvider[entry.provider].models.push(entry.model);
133
+ }
134
+ }
135
+
136
+ /** Get summary for the last N days (default 30) */
137
+ getSummary(days = 30): AnalyticsSummary {
138
+ const now = new Date();
139
+ const cutoff = new Date(now.getTime() - days * 86400000);
140
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
141
+
142
+ const filtered = [...this.dailyData.values()]
143
+ .filter(d => d.date >= cutoffStr)
144
+ .sort((a, b) => a.date.localeCompare(b.date));
145
+
146
+ const totals: AnalyticsSummary = {
147
+ period: `Last ${days} days`,
148
+ days: filtered.length,
149
+ totalCalls: 0,
150
+ totalInputTokens: 0,
151
+ totalOutputTokens: 0,
152
+ totalCostUsd: 0,
153
+ byModel: {},
154
+ byProvider: {},
155
+ dailyBreakdown: filtered,
156
+ };
157
+
158
+ for (const day of filtered) {
159
+ totals.totalCalls += day.totalCalls;
160
+ totals.totalInputTokens += day.totalInputTokens;
161
+ totals.totalOutputTokens += day.totalOutputTokens;
162
+ totals.totalCostUsd += day.totalCostUsd;
163
+
164
+ for (const [model, data] of Object.entries(day.byModel)) {
165
+ if (!totals.byModel[model]) {
166
+ totals.byModel[model] = { calls: 0, inputTokens: 0, outputTokens: 0, costUsd: 0 };
167
+ }
168
+ totals.byModel[model].calls += data.calls;
169
+ totals.byModel[model].inputTokens += data.inputTokens;
170
+ totals.byModel[model].outputTokens += data.outputTokens;
171
+ totals.byModel[model].costUsd += data.costUsd;
172
+ }
173
+
174
+ for (const [provider, data] of Object.entries(day.byProvider)) {
175
+ if (!totals.byProvider[provider]) {
176
+ totals.byProvider[provider] = { calls: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, models: [] };
177
+ }
178
+ totals.byProvider[provider].calls += data.calls;
179
+ totals.byProvider[provider].inputTokens += data.inputTokens;
180
+ totals.byProvider[provider].outputTokens += data.outputTokens;
181
+ totals.byProvider[provider].costUsd += data.costUsd;
182
+ for (const m of data.models) {
183
+ if (!totals.byProvider[provider].models.includes(m)) {
184
+ totals.byProvider[provider].models.push(m);
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ return totals;
191
+ }
192
+
193
+ /** Format for display */
194
+ format(days = 30): string {
195
+ const s = this.getSummary(days);
196
+ if (s.totalCalls === 0) return 'No usage data yet.';
197
+
198
+ const lines: string[] = [
199
+ `═══ Usage Analytics (${s.period}) ═══`,
200
+ `Total: ${s.totalCalls} calls | ${s.totalInputTokens.toLocaleString()}in / ${s.totalOutputTokens.toLocaleString()}out | $${s.totalCostUsd.toFixed(4)}`,
201
+ '',
202
+ 'By Provider:',
203
+ ];
204
+
205
+ for (const [provider, data] of Object.entries(s.byProvider).sort((a, b) => b[1].costUsd - a[1].costUsd)) {
206
+ lines.push(` ${provider.padEnd(15)} ${data.calls} calls ${data.inputTokens.toLocaleString().padStart(10)}in ${data.outputTokens.toLocaleString().padStart(10)}out $${data.costUsd.toFixed(4)}`);
207
+ }
208
+
209
+ lines.push('', 'By Model:');
210
+ for (const [model, data] of Object.entries(s.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd)) {
211
+ lines.push(` ${model.slice(0, 30).padEnd(32)} ${data.calls} calls ${data.inputTokens.toLocaleString().padStart(10)}in ${data.outputTokens.toLocaleString().padStart(10)}out $${data.costUsd.toFixed(4)}`);
212
+ }
213
+
214
+ if (s.dailyBreakdown.length > 0) {
215
+ lines.push('', 'Daily (by model):');
216
+ for (const day of s.dailyBreakdown.slice(-7)) { // Last 7 days
217
+ lines.push(` ${day.date} ${day.totalCalls} calls $${day.totalCostUsd.toFixed(4)}`);
218
+ const models = Object.entries(day.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
219
+ for (const [model, data] of models) {
220
+ lines.push(` ${model.slice(0, 26).padEnd(28)} ${data.calls} calls ${data.inputTokens.toLocaleString().padStart(9)}in ${data.outputTokens.toLocaleString().padStart(7)}out $${data.costUsd.toFixed(4)}`);
221
+ }
222
+ }
223
+ if (s.dailyBreakdown.length > 7) {
224
+ lines.push(` ... ${s.dailyBreakdown.length - 7} earlier days`);
225
+ }
226
+ }
227
+
228
+ return lines.join('\n');
229
+ }
230
+
231
+ /** Export all data as JSON */
232
+ exportAll(): string {
233
+ return JSON.stringify({
234
+ exportedAt: new Date().toISOString(),
235
+ daily: [...this.dailyData.values()].sort((a, b) => a.date.localeCompare(b.date)),
236
+ }, null, 2);
237
+ }
238
+
239
+ // ── Persistence ──────────────────────────────────────────────────
240
+
241
+ private load(): void {
242
+ if (!existsSync(this.summaryPath)) {
243
+ this.rebuild();
244
+ return;
245
+ }
246
+ try {
247
+ const data = JSON.parse(readFileSync(this.summaryPath, 'utf-8'));
248
+ for (const day of data.daily || []) {
249
+ this.dailyData.set(day.date, day);
250
+ }
251
+ } catch {
252
+ this.rebuild();
253
+ }
254
+ }
255
+
256
+ save(): void {
257
+ writeFileSync(this.summaryPath, JSON.stringify({
258
+ daily: [...this.dailyData.values()].sort((a, b) => a.date.localeCompare(b.date)),
259
+ }, null, 2));
260
+ }
261
+ }