@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.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/bin/kondi-chat +56 -0
- package/bin/kondi-chat.js +72 -0
- package/package.json +55 -0
- package/scripts/demo.tape +49 -0
- package/scripts/postinstall.cjs +103 -0
- package/src/audit/analytics.ts +261 -0
- package/src/audit/ledger.ts +253 -0
- package/src/audit/telemetry.ts +165 -0
- package/src/cli/backend.ts +675 -0
- package/src/cli/commands.ts +419 -0
- package/src/cli/help.ts +182 -0
- package/src/cli/submit-helpers.ts +159 -0
- package/src/cli/submit.ts +539 -0
- package/src/cli/wizard.ts +121 -0
- package/src/context/bootstrap.ts +138 -0
- package/src/context/budget.ts +100 -0
- package/src/context/manager.ts +666 -0
- package/src/context/memory.ts +160 -0
- package/src/context/preflight.ts +176 -0
- package/src/context/project-brain.ts +101 -0
- package/src/context/receipts.ts +108 -0
- package/src/context/skills.ts +154 -0
- package/src/context/symbol-index.ts +240 -0
- package/src/council/profiles.ts +137 -0
- package/src/council/tool.ts +138 -0
- package/src/council-engine/cli/council-artifacts.ts +230 -0
- package/src/council-engine/cli/council-config.ts +178 -0
- package/src/council-engine/cli/council-session-export.ts +116 -0
- package/src/council-engine/cli/kondi.ts +98 -0
- package/src/council-engine/cli/llm-caller.ts +229 -0
- package/src/council-engine/cli/localStorage-shim.ts +119 -0
- package/src/council-engine/cli/node-platform.ts +68 -0
- package/src/council-engine/cli/run-council.ts +481 -0
- package/src/council-engine/cli/run-pipeline.ts +772 -0
- package/src/council-engine/cli/session-export.ts +153 -0
- package/src/council-engine/configs/councils/analysis.json +101 -0
- package/src/council-engine/configs/councils/code-planning.json +86 -0
- package/src/council-engine/configs/councils/coding.json +89 -0
- package/src/council-engine/configs/councils/debate.json +97 -0
- package/src/council-engine/configs/councils/solo-claude.json +34 -0
- package/src/council-engine/configs/councils/solo-gpt.json +34 -0
- package/src/council-engine/council/coding-orchestrator.ts +1205 -0
- package/src/council-engine/council/context-bootstrap.ts +147 -0
- package/src/council-engine/council/context-inspection.ts +42 -0
- package/src/council-engine/council/context-store.ts +763 -0
- package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
- package/src/council-engine/council/factory.ts +164 -0
- package/src/council-engine/council/index.ts +201 -0
- package/src/council-engine/council/ledger-store.ts +438 -0
- package/src/council-engine/council/prompts.ts +1689 -0
- package/src/council-engine/council/storage-cleanup.ts +164 -0
- package/src/council-engine/council/store.ts +1110 -0
- package/src/council-engine/council/synthesis.ts +291 -0
- package/src/council-engine/council/types.ts +845 -0
- package/src/council-engine/council/validation.ts +613 -0
- package/src/council-engine/pipeline/build-detect.ts +73 -0
- package/src/council-engine/pipeline/executor.ts +1048 -0
- package/src/council-engine/pipeline/index.ts +9 -0
- package/src/council-engine/pipeline/install-detect.ts +84 -0
- package/src/council-engine/pipeline/memory-store.ts +182 -0
- package/src/council-engine/pipeline/output-parsers.ts +146 -0
- package/src/council-engine/pipeline/run-output.ts +149 -0
- package/src/council-engine/pipeline/session-import.ts +177 -0
- package/src/council-engine/pipeline/store.ts +753 -0
- package/src/council-engine/pipeline/test-detect.ts +82 -0
- package/src/council-engine/pipeline/types.ts +401 -0
- package/src/council-engine/services/deliberationSummary.ts +114 -0
- package/src/council-engine/tsconfig.json +16 -0
- package/src/council-engine/types/mcp.ts +122 -0
- package/src/council-engine/utils/filterTools.ts +73 -0
- package/src/engine/apply.ts +238 -0
- package/src/engine/checkpoints.ts +237 -0
- package/src/engine/consultants.ts +347 -0
- package/src/engine/diff.ts +171 -0
- package/src/engine/errors.ts +102 -0
- package/src/engine/git-tools.ts +246 -0
- package/src/engine/hooks.ts +181 -0
- package/src/engine/loop-guard.ts +155 -0
- package/src/engine/permissions.ts +293 -0
- package/src/engine/pipeline.ts +376 -0
- package/src/engine/sub-agents.ts +133 -0
- package/src/engine/task-card.ts +185 -0
- package/src/engine/task-router.ts +256 -0
- package/src/engine/task-store.ts +86 -0
- package/src/engine/tools.ts +783 -0
- package/src/engine/verify.ts +111 -0
- package/src/mcp/client.ts +225 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/tool-manager.ts +192 -0
- package/src/mcp/types.ts +61 -0
- package/src/providers/llm-caller.ts +943 -0
- package/src/providers/rate-limiter.ts +238 -0
- package/src/router/NOTES.md +28 -0
- package/src/router/collector.ts +474 -0
- package/src/router/embeddings.ts +286 -0
- package/src/router/index.ts +299 -0
- package/src/router/intent-router.ts +225 -0
- package/src/router/nn-router.ts +205 -0
- package/src/router/profiles.ts +309 -0
- package/src/router/registry.ts +565 -0
- package/src/router/rules.ts +274 -0
- package/src/router/train.py +408 -0
- package/src/session/store.ts +211 -0
- package/src/test-utils/mock-llm.ts +39 -0
- package/src/types.ts +322 -0
- 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
|
+
}
|