@gabrihhh/jarvis 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # jarvis
2
+
3
+ Terminal dashboard, status bar e grafo de memória semântica para o **Claude Code** — 100% local.
4
+
5
+ ---
6
+
7
+ ## Instalação
8
+
9
+ ```bash
10
+ npm install -g @gabrihhh/jarvis
11
+ jarvis --setup
12
+ ```
13
+
14
+ Reinicie o Claude Code após o setup.
15
+
16
+ ---
17
+
18
+ ## Comandos
19
+
20
+ | Comando | Descrição |
21
+ |---|---|
22
+ | `jarvis` | Mostra a versão |
23
+ | `jarvis --usage` | Dashboard completo de uso (tokens, custo, contexto) |
24
+ | `jarvis --watch` | Dashboard com auto-refresh a cada 30s |
25
+ | `jarvis --setup` | Configura status bar e instala slash commands |
26
+ | `jarvis --graph` | Abre o Neo4j Browser em localhost:7474 |
27
+ | `jarvis --line` | Saída de uma linha usada internamente pela status bar |
28
+ | `jarvis --help` | Lista todos os comandos |
29
+ | `/setup-memory` | *(Claude Code)* Sobe Neo4j via Docker e registra o MCP server |
30
+ | `/memory-index` | *(Claude Code)* Indexa um repositório no grafo de memória |
31
+
32
+ ---
33
+
34
+ ## Exemplos
35
+
36
+ **Dashboard completo** (`jarvis --usage`):
37
+ ```
38
+ ╭──────────────────────────────────────────────────────────────╮
39
+ │ ◈ Claude Code · Usage Dashboard │
40
+ │ 08 de abr. de 2026, 17:14 │
41
+ ├──────────────────────────────────────────────────────────────┤
42
+ │ │
43
+ │ ◷ Token Usage │
44
+ │ │
45
+ │ Period Activity Tokens Cost Requests │
46
+ │ Monthly ████████████████ 245.33M $124.99 4810 req │
47
+ │ Weekly ██████░░░░░░░░░░ 93.19M $55.50 2050 req │
48
+ │ Today ███░░░░░░░░░░░░░ 42.78M $21.60 767 req │
49
+ │ │
50
+ ├──────────────────────────────────────────────────────────────┤
51
+ │ │
52
+ │ ⬡ Context Window (current session) │
53
+ │ │
54
+ │ ████████████░░░░░░░░░░░░ 52% 103.6K / 200.0K │
55
+ │ 146 turns · model: sonnet-4-6 │
56
+ │ │
57
+ ├──────────────────────────────────────────────────────────────┤
58
+ │ │
59
+ │ Monthly breakdown │
60
+ │ Input: 62.5K Output: 1.53M │
61
+ │ Cache read: 232.48M Cache write: 11.25M │
62
+ │ │
63
+ ╰──────────────────────────────────────────────────────────────╯
64
+ ```
65
+
66
+ **Status bar** (rodapé de cada sessão no Claude Code):
67
+ ```
68
+ ╭──────────────────────╮
69
+ │ CONTEXT ████░░░░ 52% │
70
+ ╰──────────────────────╯
71
+ ```
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { startServer } from '../src/memory/mcp-server.js';
3
+ startServer();
package/bin/jarvis.js ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/index.js';
3
+ import { renderLine } from '../src/statusline.js';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
5
+ import { join, dirname } from 'path';
6
+ import { homedir } from 'os';
7
+ import { exec } from 'child_process';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const args = process.argv.slice(2);
11
+
12
+ if (args.length === 0) {
13
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf-8'));
14
+ console.log(`\n jarvis v${pkg.version}\n`);
15
+ process.exit(0);
16
+ }
17
+
18
+ if (args.includes('--help') || args.includes('-h')) {
19
+ console.log(`
20
+ jarvis — Claude Code terminal dashboard + semantic memory graph
21
+
22
+ Usage:
23
+ jarvis Show version
24
+ jarvis --usage Show full usage dashboard
25
+ jarvis --watch Refresh dashboard every 30s
26
+ jarvis --setup Install status bar into ~/.claude/settings.json
27
+ jarvis --graph Open Neo4j browser at http://localhost:7474
28
+ jarvis --line Single-line status (for Claude Code status bar)
29
+ jarvis --help Show this help
30
+
31
+ Slash commands (inside Claude Code):
32
+ /setup-memory Setup Docker + Neo4j + register MCP server
33
+ /memory-index Index a repository into the memory graph
34
+
35
+ Data source: ~/.claude/projects/
36
+ `);
37
+ process.exit(0);
38
+ }
39
+
40
+ if (args.includes('--graph')) {
41
+ const url = 'http://localhost:7474';
42
+ const opener =
43
+ process.platform === 'darwin' ? 'open' :
44
+ process.platform === 'win32' ? 'start' :
45
+ 'xdg-open';
46
+ console.log(` Opening Neo4j Browser at ${url} ...\n`);
47
+ exec(`${opener} ${url}`, (err) => {
48
+ if (err) console.error(` Could not open browser automatically. Visit ${url} manually.\n`);
49
+ });
50
+ } else if (args.includes('--setup')) {
51
+ const __dir = dirname(fileURLToPath(import.meta.url));
52
+
53
+ // Status bar
54
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
55
+ let settings = {};
56
+ if (existsSync(settingsPath)) {
57
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { /* keep empty */ }
58
+ }
59
+ settings.statusLine = { type: 'command', command: 'jarvis --line' };
60
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
61
+ console.log(' ✓ Status bar configured');
62
+
63
+ // Slash commands → ~/.claude/commands/
64
+ const commandsDir = join(homedir(), '.claude', 'commands');
65
+ mkdirSync(commandsDir, { recursive: true });
66
+ const srcCommands = join(__dir, '../.claude/commands');
67
+ for (const file of ['setup-memory.md', 'memory-index.md']) {
68
+ copyFileSync(join(srcCommands, file), join(commandsDir, file));
69
+ console.log(` ✓ Slash command /${file.replace('.md', '')} installed`);
70
+ }
71
+
72
+ console.log('\n Restart Claude Code to activate.\n');
73
+ } else if (args.includes('--line') || args.includes('-l')) {
74
+ renderLine();
75
+ } else if (args.includes('--watch') || args.includes('-w')) {
76
+ async function loop() {
77
+ console.clear();
78
+ await run();
79
+ console.log(' Auto-refresh in 30s · Ctrl+C to exit\n');
80
+ }
81
+ await loop();
82
+ setInterval(loop, 30_000);
83
+ } else if (args.includes('--usage')) {
84
+ await run();
85
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@gabrihhh/jarvis",
3
+ "version": "2.0.0",
4
+ "description": "Claude Code terminal dashboard + semantic memory graph via Neo4j",
5
+ "bin": {
6
+ "jarvis": "bin/jarvis.js",
7
+ "jarvis-memory": "bin/jarvis-memory.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "scripts": {
11
+ "start": "node bin/jarvis.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "src/"
16
+ ],
17
+ "keywords": [
18
+ "claude",
19
+ "anthropic",
20
+ "claude-code",
21
+ "usage",
22
+ "stats",
23
+ "cli",
24
+ "status-bar",
25
+ "neo4j",
26
+ "memory-graph",
27
+ "mcp"
28
+ ],
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.29.0",
32
+ "chalk": "^5.3.0",
33
+ "neo4j-driver": "^6.0.1"
34
+ },
35
+ "type": "module",
36
+ "engines": {
37
+ "node": ">=18.0.0"
38
+ }
39
+ }
@@ -0,0 +1,116 @@
1
+ // Pricing per million tokens (USD) - April 2026
2
+ const PRICING = {
3
+ 'claude-opus-4-6': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 },
4
+ 'claude-sonnet-4-6': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
5
+ 'claude-haiku-4-5': { input: 0.25, output: 1.25, cacheRead: 0.025, cacheWrite: 0.30 },
6
+ 'default': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
7
+ };
8
+
9
+ // Context window sizes per model
10
+ export const CONTEXT_WINDOWS = {
11
+ 'claude-opus-4-6': 200000,
12
+ 'claude-sonnet-4-6': 200000,
13
+ 'claude-haiku-4-5': 200000,
14
+ 'default': 200000,
15
+ };
16
+
17
+ function getPrice(model) {
18
+ for (const [key, price] of Object.entries(PRICING)) {
19
+ if (model.startsWith(key)) return price;
20
+ }
21
+ return PRICING.default;
22
+ }
23
+
24
+ export function calcCost(entry) {
25
+ const price = getPrice(entry.model);
26
+ const M = 1_000_000;
27
+ return (
28
+ (entry.inputTokens / M) * price.input +
29
+ (entry.outputTokens / M) * price.output +
30
+ (entry.cacheReadTokens / M) * price.cacheRead +
31
+ (entry.cacheWriteTokens / M) * price.cacheWrite
32
+ );
33
+ }
34
+
35
+ export function totalTokens(entry) {
36
+ return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens;
37
+ }
38
+
39
+ function withinDays(entry, days) {
40
+ const cutoff = new Date();
41
+ cutoff.setDate(cutoff.getDate() - days);
42
+ return entry.timestamp >= cutoff;
43
+ }
44
+
45
+ export function aggregateStats(entries) {
46
+ const now = new Date();
47
+
48
+ const monthly = entries.filter(e => withinDays(e, 30));
49
+ const weekly = entries.filter(e => withinDays(e, 7));
50
+ const daily = entries.filter(e => withinDays(e, 1));
51
+
52
+ function sum(arr) {
53
+ return arr.reduce((acc, e) => ({
54
+ inputTokens: acc.inputTokens + e.inputTokens,
55
+ outputTokens: acc.outputTokens + e.outputTokens,
56
+ cacheReadTokens: acc.cacheReadTokens + e.cacheReadTokens,
57
+ cacheWriteTokens: acc.cacheWriteTokens + e.cacheWriteTokens,
58
+ cost: acc.cost + calcCost(e),
59
+ count: acc.count + 1,
60
+ }), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0, count: 0 });
61
+ }
62
+
63
+ const monthlyStats = sum(monthly);
64
+ const weeklyStats = sum(weekly);
65
+ const dailyStats = sum(daily);
66
+
67
+ // total tokens for display
68
+ monthlyStats.total = monthlyStats.inputTokens + monthlyStats.outputTokens + monthlyStats.cacheReadTokens + monthlyStats.cacheWriteTokens;
69
+ weeklyStats.total = weeklyStats.inputTokens + weeklyStats.outputTokens + weeklyStats.cacheReadTokens + weeklyStats.cacheWriteTokens;
70
+ dailyStats.total = dailyStats.inputTokens + dailyStats.outputTokens + dailyStats.cacheReadTokens + dailyStats.cacheWriteTokens;
71
+
72
+ // dominant model
73
+ const modelCount = {};
74
+ for (const e of entries.slice(0, 50)) {
75
+ modelCount[e.model] = (modelCount[e.model] || 0) + 1;
76
+ }
77
+ const dominantModel = Object.entries(modelCount).sort((a, b) => b[1] - a[1])[0]?.[0] || 'claude-sonnet-4-6';
78
+
79
+ return { monthly: monthlyStats, weekly: weeklyStats, daily: dailyStats, dominantModel };
80
+ }
81
+
82
+ export function aggregateSession(entries) {
83
+ if (!entries.length) return null;
84
+
85
+ const last = entries[entries.length - 1];
86
+ const model = last?.model || 'claude-sonnet-4-6';
87
+ const contextWindow = CONTEXT_WINDOWS[model] || CONTEXT_WINDOWS.default;
88
+
89
+ // last assistant turn shows cumulative context usage via cache tokens
90
+ // We use the most recent entry's tokens as current context position
91
+ const latestEntry = entries[entries.length - 1];
92
+ if (!latestEntry) return null;
93
+
94
+ // total tokens in last exchange approximates context usage
95
+ const contextUsed = latestEntry.inputTokens + latestEntry.cacheReadTokens + latestEntry.cacheWriteTokens;
96
+
97
+ return {
98
+ model,
99
+ contextUsed,
100
+ contextWindow,
101
+ percent: Math.min(100, Math.round((contextUsed / contextWindow) * 100)),
102
+ turns: entries.length,
103
+ };
104
+ }
105
+
106
+ export function formatTokens(n) {
107
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
108
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
109
+ return `${n}`;
110
+ }
111
+
112
+ export function formatCost(n) {
113
+ if (n >= 1) return `$${n.toFixed(2)}`;
114
+ if (n >= 0.01) return `$${n.toFixed(3)}`;
115
+ return `$${n.toFixed(4)}`;
116
+ }
package/src/display.js ADDED
@@ -0,0 +1,150 @@
1
+ import chalk from 'chalk';
2
+ import { formatTokens, formatCost } from './calculator.js';
3
+
4
+ const W = 62;
5
+ const PURPLE = '#7c3aed';
6
+ const WHITE = '#ffffff';
7
+ const DIM = '#555555';
8
+
9
+ // ─── Box drawing ────────────────────────────────────────────
10
+ const TOP = chalk.hex(PURPLE)(`╭${'─'.repeat(W)}╮`);
11
+ const BOT = chalk.hex(PURPLE)(`╰${'─'.repeat(W)}╯`);
12
+ const DIV = chalk.hex(PURPLE)(`├${'─'.repeat(W)}┤`);
13
+
14
+ function stripAnsi(str) {
15
+ return str.replace(/\u001b\[[0-9;]*m/g, '');
16
+ }
17
+
18
+ function pad(text, width) {
19
+ return text + ' '.repeat(Math.max(0, width - stripAnsi(text).length));
20
+ }
21
+
22
+ const row = (text) => {
23
+ const len = stripAnsi(text).length;
24
+ const pipe = chalk.hex(PURPLE)('│');
25
+ return `${pipe} ${text}${' '.repeat(Math.max(0, W - 1 - len))}${pipe}`;
26
+ };
27
+
28
+ // ─── Progress bar ────────────────────────────────────────────
29
+ function bar(percent, width = 16) {
30
+ const filled = Math.round((percent / 100) * width);
31
+ const empty = width - filled;
32
+ const color = percent >= 85 ? chalk.red : percent >= 60 ? chalk.yellow : chalk.hex('#00e5ff');
33
+ return color('█'.repeat(filled)) + chalk.hex('#2a2a2a')('░'.repeat(empty));
34
+ }
35
+
36
+ // ─── Title ───────────────────────────────────────────────────
37
+ function title() {
38
+ const t = ' ◈ Claude Code · Usage Dashboard';
39
+ return chalk.bold.hex(WHITE)(t) + ' '.repeat(W - 1 - t.length);
40
+ }
41
+
42
+ function subtitle() {
43
+ const now = new Date();
44
+ const s = now.toLocaleString('pt-BR', {
45
+ day: '2-digit', month: 'short', year: 'numeric',
46
+ hour: '2-digit', minute: '2-digit'
47
+ });
48
+ const t = ` ${s}`;
49
+ return chalk.hex(DIM)(t) + ' '.repeat(Math.max(0, W - 1 - t.length));
50
+ }
51
+
52
+ // ─── Token row ───────────────────────────────────────────────
53
+ function tokenRow(label, stats, maxTokens) {
54
+ const pct = maxTokens > 0 ? Math.min(100, Math.round((stats.total / maxTokens) * 100)) : 0;
55
+ const b = bar(pct, 16);
56
+ const tok = pad(chalk.hex(WHITE)(formatTokens(stats.total)), 8);
57
+ const cost = pad(chalk.hex('#a78bfa')(formatCost(stats.cost)), 9);
58
+ const cnt = chalk.hex(DIM)(pad(`${stats.count} req`, 9));
59
+ const text = ` ${label} ${b} ${tok} ${cost} ${cnt}`;
60
+ return row(text);
61
+ }
62
+
63
+ // ─── Context window row ──────────────────────────────────────
64
+ function contextRow(session) {
65
+ if (!session) {
66
+ return row(` ${chalk.hex(DIM)('No active session detected')}`);
67
+ }
68
+ const { contextUsed, contextWindow, percent, model, turns } = session;
69
+ const b = bar(percent, 24);
70
+ const pct = chalk.bold.hex(WHITE)(`${percent}%`);
71
+ const info = chalk.hex(WHITE)(`${formatTokens(contextUsed)} / ${formatTokens(contextWindow)}`);
72
+ const meta = chalk.hex(DIM)(`${turns} turn${turns !== 1 ? 's' : ''} · model: ${model.replace('claude-', '')}`);
73
+ return [
74
+ row(` ${b} ${pct} ${info}`),
75
+ row(` ${meta}`),
76
+ ].join('\n');
77
+ }
78
+
79
+ // ─── Section header ──────────────────────────────────────────
80
+ function sectionHeader(icon, label) {
81
+ return row(` ${chalk.hex(WHITE)(icon)} ${chalk.bold.hex(WHITE)(label)}`);
82
+ }
83
+
84
+ // ─── Full render ─────────────────────────────────────────────
85
+ export function render(stats, session) {
86
+ const { monthly, weekly, daily } = stats;
87
+ const maxTokens = monthly.total || 1;
88
+
89
+ const colHeader =
90
+ ` ${chalk.hex(WHITE)(pad('Period', 10))}` +
91
+ `${chalk.hex(WHITE)(pad('Activity', 18))}` +
92
+ `${chalk.hex(WHITE)(pad('Tokens', 10))}` +
93
+ `${chalk.hex(WHITE)(pad('Cost', 11))}` +
94
+ `${chalk.hex(WHITE)('Requests')}`;
95
+
96
+ const lines = [
97
+ TOP,
98
+ row(title()),
99
+ row(subtitle()),
100
+ DIV,
101
+ row(''),
102
+ sectionHeader('◷', 'Token Usage'),
103
+ row(''),
104
+ row(colHeader),
105
+ tokenRow(chalk.hex('#c084fc').bold(pad('Monthly', 8)), monthly, maxTokens),
106
+ tokenRow(chalk.hex('#60a5fa').bold(pad('Weekly ', 8)), weekly, maxTokens),
107
+ tokenRow(chalk.hex('#34d399').bold(pad('Today ', 8)), daily, maxTokens),
108
+ row(''),
109
+ DIV,
110
+ row(''),
111
+ sectionHeader('⬡', 'Context Window (current session)'),
112
+ row(''),
113
+ contextRow(session),
114
+ row(''),
115
+ DIV,
116
+ row(''),
117
+ breakdown(monthly),
118
+ row(''),
119
+ BOT,
120
+ ];
121
+
122
+ return lines.join('\n');
123
+ }
124
+
125
+ function breakdown(monthly) {
126
+ const items = [
127
+ { label: 'Input', val: formatTokens(monthly.inputTokens), color: '#7dd3fc' },
128
+ { label: 'Output', val: formatTokens(monthly.outputTokens), color: '#86efac' },
129
+ { label: 'Cache read', val: formatTokens(monthly.cacheReadTokens), color: '#fde68a' },
130
+ { label: 'Cache write', val: formatTokens(monthly.cacheWriteTokens), color: '#f9a8d4' },
131
+ ];
132
+
133
+ const cols = items.map(i =>
134
+ `${chalk.hex(WHITE)(i.label + ':')} ${chalk.hex(i.color).bold(i.val)}`
135
+ );
136
+
137
+ return [
138
+ row(` ${chalk.bold.hex(WHITE)('Monthly breakdown')}`),
139
+ row(` ${cols[0]} ${cols[1]}`),
140
+ row(` ${cols[2]} ${cols[3]}`),
141
+ ].join('\n');
142
+ }
143
+
144
+ export function renderError(msg) {
145
+ console.error(chalk.red(`\n Error: ${msg}\n`));
146
+ }
147
+
148
+ export function renderLoading() {
149
+ process.stdout.write(chalk.hex(DIM)(' Loading usage data...'));
150
+ }
package/src/index.js ADDED
@@ -0,0 +1,26 @@
1
+ import { readAllUsage, getCurrentSessionFile, readCurrentSessionUsage } from './reader.js';
2
+ import { aggregateStats, aggregateSession } from './calculator.js';
3
+ import { render, renderError, renderLoading } from './display.js';
4
+
5
+ export async function run() {
6
+ renderLoading();
7
+
8
+ const allEntries = readAllUsage();
9
+
10
+ if (!allEntries.length) {
11
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
12
+ renderError('No Claude Code usage data found. Have you used Claude Code yet?');
13
+ process.exit(1);
14
+ }
15
+
16
+ const stats = aggregateStats(allEntries);
17
+
18
+ // current session
19
+ const sessionMeta = getCurrentSessionFile();
20
+ const sessionId = sessionMeta?.sessionId;
21
+ const sessionEntries = sessionId ? readCurrentSessionUsage(sessionId) : [];
22
+ const session = aggregateSession(sessionEntries);
23
+
24
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
25
+ console.log('\n' + render(stats, session) + '\n');
26
+ }
@@ -0,0 +1,374 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from '@modelcontextprotocol/sdk/types.js';
7
+ import { runQuery, runWriteQuery, closeDriver } from './neo4j-client.js';
8
+
9
+ // ── Tool definitions ──────────────────────────────────────────────────────────
10
+
11
+ const TOOLS = [
12
+ {
13
+ name: 'list-projects',
14
+ description: 'Lista todos os projetos e branches indexados no grafo de memória.',
15
+ inputSchema: { type: 'object', properties: {}, required: [] },
16
+ },
17
+ {
18
+ name: 'query-project',
19
+ description: 'Retorna resumo completo de um projeto: módulos, padrões, conceitos e dependências.',
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ name: { type: 'string', description: 'Nome do projeto' },
24
+ branch: { type: 'string', description: 'Branch (main ou qa)', enum: ['main', 'qa'] },
25
+ },
26
+ required: ['name', 'branch'],
27
+ },
28
+ },
29
+ {
30
+ name: 'search-concept',
31
+ description: 'Busca módulos e arquivos relacionados a um conceito de negócio.',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ concept: { type: 'string', description: 'Conceito a buscar (ex: "pedido", "autenticação")' },
36
+ projectName: { type: 'string', description: 'Nome do projeto (opcional)' },
37
+ },
38
+ required: ['concept'],
39
+ },
40
+ },
41
+ {
42
+ name: 'get-module-detail',
43
+ description: 'Retorna detalhes completos de um módulo: arquivos, padrões, conceitos, dependências.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {
47
+ moduleName: { type: 'string', description: 'Nome do módulo' },
48
+ projectName: { type: 'string', description: 'Nome do projeto' },
49
+ branch: { type: 'string', description: 'Branch (main ou qa)', enum: ['main', 'qa'] },
50
+ },
51
+ required: ['moduleName', 'projectName', 'branch'],
52
+ },
53
+ },
54
+ {
55
+ name: 'save-project',
56
+ description: 'Salva o entendimento completo de um projeto no grafo. Use após aprovação do usuário no /memory-index.',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: {
60
+ project: {
61
+ type: 'object',
62
+ properties: {
63
+ name: { type: 'string' },
64
+ path: { type: 'string' },
65
+ description: { type: 'string' },
66
+ language: { type: 'string' },
67
+ branch: { type: 'string' },
68
+ },
69
+ required: ['name', 'path', 'language', 'branch'],
70
+ },
71
+ modules: {
72
+ type: 'array',
73
+ items: {
74
+ type: 'object',
75
+ properties: {
76
+ name: { type: 'string' },
77
+ path: { type: 'string' },
78
+ domain: { type: 'string' },
79
+ files: { type: 'array', items: { type: 'object' } },
80
+ patterns: { type: 'array', items: { type: 'string' } },
81
+ concepts: { type: 'array', items: { type: 'string' } },
82
+ dependsOn: { type: 'array', items: { type: 'string' } },
83
+ },
84
+ required: ['name', 'path', 'domain'],
85
+ },
86
+ },
87
+ dependencies: {
88
+ type: 'array',
89
+ items: {
90
+ type: 'object',
91
+ properties: {
92
+ name: { type: 'string' },
93
+ version: { type: 'string' },
94
+ type: { type: 'string', enum: ['external', 'internal'] },
95
+ },
96
+ },
97
+ },
98
+ patterns: {
99
+ type: 'array',
100
+ items: {
101
+ type: 'object',
102
+ properties: {
103
+ name: { type: 'string' },
104
+ description: { type: 'string' },
105
+ },
106
+ },
107
+ },
108
+ },
109
+ required: ['project'],
110
+ },
111
+ },
112
+ ];
113
+
114
+ // ── Tool handlers ─────────────────────────────────────────────────────────────
115
+
116
+ async function handleListProjects() {
117
+ const records = await runQuery(`
118
+ MATCH (p:Project)
119
+ RETURN p.name AS name, p.branch AS branch, p.description AS description, p.language AS language
120
+ ORDER BY p.name, p.branch
121
+ `);
122
+ const projects = records.map(r => ({
123
+ name: r.get('name'),
124
+ branch: r.get('branch'),
125
+ description: r.get('description'),
126
+ language: r.get('language'),
127
+ }));
128
+ if (!projects.length) {
129
+ return 'Nenhum projeto indexado ainda. Use /memory-index para indexar um repositório.';
130
+ }
131
+ return `Projetos indexados:\n${projects.map(p =>
132
+ `• ${p.name} [${p.branch}] — ${p.language}${p.description ? ': ' + p.description : ''}`
133
+ ).join('\n')}`;
134
+ }
135
+
136
+ async function handleQueryProject({ name, branch }) {
137
+ const records = await runQuery(`
138
+ MATCH (p:Project {name: $name, branch: $branch})
139
+ OPTIONAL MATCH (p)-[:HAS_MODULE]->(m:Module)
140
+ OPTIONAL MATCH (m)-[:HANDLES]->(c:Concept)
141
+ OPTIONAL MATCH (m)-[:IMPLEMENTS]->(pat:Pattern)
142
+ RETURN p, collect(DISTINCT m) AS modules,
143
+ collect(DISTINCT c.name) AS concepts,
144
+ collect(DISTINCT pat.name) AS patterns
145
+ `, { name, branch });
146
+
147
+ if (!records.length) {
148
+ return `Projeto "${name}" [${branch}] não encontrado. Use list-projects para ver os disponíveis.`;
149
+ }
150
+
151
+ const r = records[0];
152
+ const p = r.get('p').properties;
153
+ const modules = r.get('modules').map(m => m.properties);
154
+ const concepts = [...new Set(r.get('concepts'))].filter(Boolean);
155
+ const patterns = [...new Set(r.get('patterns'))].filter(Boolean);
156
+
157
+ return [
158
+ `# ${p.name} [${p.branch}]`,
159
+ p.description ? `**Descrição:** ${p.description}` : '',
160
+ `**Linguagem:** ${p.language}`,
161
+ `**Path:** ${p.path}`,
162
+ '',
163
+ `## Módulos (${modules.length})`,
164
+ modules.map(m => `• **${m.name}** — ${m.domain} (${m.path})`).join('\n') || 'Nenhum',
165
+ '',
166
+ `## Conceitos de Negócio`,
167
+ concepts.length ? concepts.map(c => `• ${c}`).join('\n') : 'Nenhum',
168
+ '',
169
+ `## Padrões`,
170
+ patterns.length ? patterns.map(p => `• ${p}`).join('\n') : 'Nenhum',
171
+ ].filter(l => l !== undefined).join('\n');
172
+ }
173
+
174
+ async function handleSearchConcept({ concept, projectName }) {
175
+ let cypher, params;
176
+
177
+ if (projectName) {
178
+ cypher = `
179
+ MATCH (p:Project {name: $projectName})-[:HAS_MODULE]->(m:Module)-[:HANDLES]->(c:Concept)
180
+ WHERE toLower(c.name) CONTAINS toLower($concept)
181
+ OPTIONAL MATCH (m)-[:CONTAINS]->(f:File)
182
+ RETURN p.name AS project, p.branch AS branch,
183
+ m.name AS module, m.domain AS domain, m.path AS modulePath,
184
+ collect(f.path) AS files
185
+ `;
186
+ params = { concept, projectName };
187
+ } else {
188
+ cypher = `
189
+ MATCH (m:Module)-[:HANDLES]->(c:Concept)
190
+ WHERE toLower(c.name) CONTAINS toLower($concept)
191
+ MATCH (p:Project)-[:HAS_MODULE]->(m)
192
+ OPTIONAL MATCH (m)-[:CONTAINS]->(f:File)
193
+ RETURN p.name AS project, p.branch AS branch,
194
+ m.name AS module, m.domain AS domain, m.path AS modulePath,
195
+ collect(f.path) AS files
196
+ `;
197
+ params = { concept };
198
+ }
199
+
200
+ const records = await runQuery(cypher, params);
201
+
202
+ if (!records.length) {
203
+ return `Nenhum módulo encontrado para o conceito "${concept}".`;
204
+ }
205
+
206
+ return records.map(r =>
207
+ `[${r.get('project')} / ${r.get('branch')}] **${r.get('module')}** (${r.get('domain')}) — ${r.get('modulePath')}`
208
+ ).join('\n');
209
+ }
210
+
211
+ async function handleGetModuleDetail({ moduleName, projectName, branch }) {
212
+ const records = await runQuery(`
213
+ MATCH (p:Project {name: $projectName, branch: $branch})-[:HAS_MODULE]->(m:Module {name: $moduleName})
214
+ OPTIONAL MATCH (m)-[:CONTAINS]->(f:File)
215
+ OPTIONAL MATCH (m)-[:HANDLES]->(c:Concept)
216
+ OPTIONAL MATCH (m)-[:IMPLEMENTS]->(pat:Pattern)
217
+ OPTIONAL MATCH (m)-[:DEPENDS_ON]->(dep:Module)
218
+ RETURN m,
219
+ collect(DISTINCT f) AS files,
220
+ collect(DISTINCT c.name) AS concepts,
221
+ collect(DISTINCT pat.name) AS patterns,
222
+ collect(DISTINCT dep.name) AS deps
223
+ `, { moduleName, projectName, branch });
224
+
225
+ if (!records.length) {
226
+ return `Módulo "${moduleName}" não encontrado em ${projectName} [${branch}].`;
227
+ }
228
+
229
+ const r = records[0];
230
+ const m = r.get('m').properties;
231
+ const files = r.get('files').map(f => f.properties);
232
+ const concepts = r.get('concepts').filter(Boolean);
233
+ const patterns = r.get('patterns').filter(Boolean);
234
+ const deps = r.get('deps').filter(Boolean);
235
+
236
+ return [
237
+ `# Módulo: ${m.name}`,
238
+ `**Domínio:** ${m.domain}`,
239
+ `**Path:** ${m.path}`,
240
+ '',
241
+ `## Arquivos (${files.length})`,
242
+ files.map(f => `• ${f.path}${f.purpose ? ' — ' + f.purpose : ''}`).join('\n') || 'Nenhum',
243
+ '',
244
+ `## Conceitos`,
245
+ concepts.length ? concepts.map(c => `• ${c}`).join('\n') : 'Nenhum',
246
+ '',
247
+ `## Padrões`,
248
+ patterns.length ? patterns.map(p => `• ${p}`).join('\n') : 'Nenhum',
249
+ '',
250
+ `## Depende de`,
251
+ deps.length ? deps.map(d => `• ${d}`).join('\n') : 'Nenhum',
252
+ ].join('\n');
253
+ }
254
+
255
+ async function handleSaveProject({ project, modules = [], dependencies = [], patterns = [] }) {
256
+ const createdAt = new Date().toISOString();
257
+
258
+ // Upsert Project
259
+ await runWriteQuery(`
260
+ MERGE (p:Project {name: $name, branch: $branch})
261
+ SET p.path = $path, p.description = $description,
262
+ p.language = $language, p.createdAt = $createdAt
263
+ `, { ...project, description: project.description || '', createdAt });
264
+
265
+ // Upsert global Patterns e relação com Project
266
+ for (const pat of patterns) {
267
+ await runWriteQuery(`
268
+ MERGE (pat:Pattern {name: $name})
269
+ SET pat.description = $description
270
+ WITH pat
271
+ MATCH (p:Project {name: $projectName, branch: $branch})
272
+ MERGE (p)-[:USES_PATTERN]->(pat)
273
+ `, { name: pat.name, description: pat.description || '', projectName: project.name, branch: project.branch });
274
+ }
275
+
276
+ // Upsert Dependencies
277
+ for (const dep of dependencies) {
278
+ await runWriteQuery(`
279
+ MERGE (d:Dependency {name: $name, projectName: $projectName, branch: $branch})
280
+ SET d.version = $version, d.type = $type
281
+ `, { name: dep.name, version: dep.version || '', type: dep.type || 'external', projectName: project.name, branch: project.branch });
282
+ }
283
+
284
+ // Upsert Modules, Files, Concepts, Patterns
285
+ for (const mod of modules) {
286
+ await runWriteQuery(`
287
+ MATCH (p:Project {name: $projectName, branch: $branch})
288
+ MERGE (m:Module {path: $path, projectName: $projectName, branch: $branch})
289
+ SET m.name = $name, m.domain = $domain
290
+ MERGE (p)-[:HAS_MODULE]->(m)
291
+ `, { projectName: project.name, branch: project.branch, name: mod.name, path: mod.path, domain: mod.domain });
292
+
293
+ for (const file of (mod.files || [])) {
294
+ await runWriteQuery(`
295
+ MATCH (m:Module {path: $modulePath, projectName: $projectName, branch: $branch})
296
+ MERGE (f:File {path: $filePath, projectName: $projectName, branch: $branch})
297
+ SET f.name = $fileName, f.extension = $ext, f.purpose = $purpose
298
+ MERGE (m)-[:CONTAINS]->(f)
299
+ `, {
300
+ modulePath: mod.path,
301
+ projectName: project.name,
302
+ branch: project.branch,
303
+ filePath: file.path,
304
+ fileName: file.name || file.path.split('/').pop(),
305
+ ext: file.extension || file.path.split('.').pop(),
306
+ purpose: file.purpose || '',
307
+ });
308
+ }
309
+
310
+ for (const conceptName of (mod.concepts || [])) {
311
+ await runWriteQuery(`
312
+ MATCH (m:Module {path: $modulePath, projectName: $projectName, branch: $branch})
313
+ MERGE (c:Concept {name: $name, projectName: $projectName})
314
+ MERGE (m)-[:HANDLES]->(c)
315
+ `, { modulePath: mod.path, projectName: project.name, branch: project.branch, name: conceptName });
316
+ }
317
+
318
+ for (const patternName of (mod.patterns || [])) {
319
+ await runWriteQuery(`
320
+ MATCH (m:Module {path: $modulePath, projectName: $projectName, branch: $branch})
321
+ MERGE (pat:Pattern {name: $name})
322
+ MERGE (m)-[:IMPLEMENTS]->(pat)
323
+ `, { modulePath: mod.path, projectName: project.name, branch: project.branch, name: patternName });
324
+ }
325
+ }
326
+
327
+ // Relações dependsOn (após todos os módulos existirem no grafo)
328
+ for (const mod of modules) {
329
+ for (const depName of (mod.dependsOn || [])) {
330
+ await runWriteQuery(`
331
+ MATCH (a:Module {name: $fromName, projectName: $projectName, branch: $branch})
332
+ MATCH (b:Module {name: $toName, projectName: $projectName, branch: $branch})
333
+ MERGE (a)-[:DEPENDS_ON]->(b)
334
+ `, { fromName: mod.name, toName: depName, projectName: project.name, branch: project.branch });
335
+ }
336
+ }
337
+
338
+ return `Projeto "${project.name}" [${project.branch}] indexado com sucesso. ${modules.length} módulos gravados.`;
339
+ }
340
+
341
+ // ── Server bootstrap ──────────────────────────────────────────────────────────
342
+
343
+ export async function startServer() {
344
+ const server = new Server(
345
+ { name: 'claude-memory', version: '1.0.0' },
346
+ { capabilities: { tools: {} } }
347
+ );
348
+
349
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
350
+
351
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
352
+ const { name, arguments: args } = request.params;
353
+ try {
354
+ let text;
355
+ switch (name) {
356
+ case 'list-projects': text = await handleListProjects(); break;
357
+ case 'query-project': text = await handleQueryProject(args); break;
358
+ case 'search-concept': text = await handleSearchConcept(args); break;
359
+ case 'get-module-detail': text = await handleGetModuleDetail(args); break;
360
+ case 'save-project': text = await handleSaveProject(args); break;
361
+ default: throw new Error(`Tool desconhecida: ${name}`);
362
+ }
363
+ return { content: [{ type: 'text', text }] };
364
+ } catch (err) {
365
+ return { content: [{ type: 'text', text: `Erro: ${err.message}` }], isError: true };
366
+ }
367
+ });
368
+
369
+ const transport = new StdioServerTransport();
370
+ await server.connect(transport);
371
+
372
+ process.on('SIGINT', async () => { await closeDriver(); process.exit(0); });
373
+ process.on('SIGTERM', async () => { await closeDriver(); process.exit(0); });
374
+ }
@@ -0,0 +1,71 @@
1
+ import neo4j from 'neo4j-driver';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ const CONFIG_PATH = join(homedir(), '.claude-memory.json');
7
+
8
+ const DEFAULT_CONFIG = {
9
+ neo4j: {
10
+ uri: 'bolt://localhost:7687',
11
+ user: 'neo4j',
12
+ password: 'claudememory',
13
+ },
14
+ };
15
+
16
+ function loadConfig() {
17
+ if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
18
+ try {
19
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
20
+ } catch {
21
+ return DEFAULT_CONFIG;
22
+ }
23
+ }
24
+
25
+ let _driver = null;
26
+
27
+ export function getDriver() {
28
+ if (_driver) return _driver;
29
+ const config = loadConfig();
30
+ const { uri, user, password } = config.neo4j;
31
+ _driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
32
+ return _driver;
33
+ }
34
+
35
+ export async function closeDriver() {
36
+ if (_driver) {
37
+ await _driver.close();
38
+ _driver = null;
39
+ }
40
+ }
41
+
42
+ export async function runQuery(cypher, params = {}) {
43
+ const driver = getDriver();
44
+ const session = driver.session();
45
+ try {
46
+ const result = await session.run(cypher, params);
47
+ return result.records;
48
+ } finally {
49
+ await session.close();
50
+ }
51
+ }
52
+
53
+ export async function runWriteQuery(cypher, params = {}) {
54
+ const driver = getDriver();
55
+ const session = driver.session({ defaultAccessMode: neo4j.session.WRITE });
56
+ try {
57
+ const result = await session.run(cypher, params);
58
+ return result.records;
59
+ } finally {
60
+ await session.close();
61
+ }
62
+ }
63
+
64
+ export async function testConnection() {
65
+ try {
66
+ await runQuery('RETURN 1 AS ok');
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
@@ -0,0 +1,26 @@
1
+ import { runWriteQuery } from './neo4j-client.js';
2
+
3
+ const CONSTRAINTS = [
4
+ 'CREATE CONSTRAINT project_unique IF NOT EXISTS FOR (p:Project) REQUIRE (p.name, p.branch) IS UNIQUE',
5
+ 'CREATE CONSTRAINT module_unique IF NOT EXISTS FOR (m:Module) REQUIRE (m.path, m.projectName, m.branch) IS UNIQUE',
6
+ 'CREATE CONSTRAINT file_unique IF NOT EXISTS FOR (f:File) REQUIRE (f.path, f.projectName, f.branch) IS UNIQUE',
7
+ 'CREATE CONSTRAINT pattern_unique IF NOT EXISTS FOR (pat:Pattern) REQUIRE pat.name IS UNIQUE',
8
+ 'CREATE CONSTRAINT concept_unique IF NOT EXISTS FOR (c:Concept) REQUIRE (c.name, c.projectName) IS UNIQUE',
9
+ ];
10
+
11
+ export async function applySchema() {
12
+ for (const cypher of CONSTRAINTS) {
13
+ try {
14
+ await runWriteQuery(cypher);
15
+ } catch (err) {
16
+ if (!err.message?.includes('already exists')) throw err;
17
+ }
18
+ }
19
+ }
20
+
21
+ // Estrutura canônica esperada pelo save-project
22
+ // project: { name, path, description, language, branch, createdAt }
23
+ // modules: [{ name, path, domain, files: [{ path, purpose }], patterns: [string], concepts: [string], dependsOn: [string] }]
24
+ // dependencies: [{ name, version, type }]
25
+ // patterns: [{ name, description }]
26
+ export const PROJECT_SHAPE = {};
package/src/reader.js ADDED
@@ -0,0 +1,163 @@
1
+ import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { execSync } from 'child_process';
5
+
6
+ const CLAUDE_DIR = join(homedir(), '.claude');
7
+ const PROJECTS_DIR = join(CLAUDE_DIR, 'projects');
8
+
9
+ function parseJsonlFile(filePath) {
10
+ try {
11
+ const content = readFileSync(filePath, 'utf-8');
12
+ return content
13
+ .split('\n')
14
+ .filter(line => line.trim())
15
+ .map(line => {
16
+ try { return JSON.parse(line); }
17
+ catch { return null; }
18
+ })
19
+ .filter(Boolean);
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ function getAllSessionFiles() {
26
+ if (!existsSync(PROJECTS_DIR)) return [];
27
+
28
+ const files = [];
29
+
30
+ try {
31
+ const projects = readdirSync(PROJECTS_DIR);
32
+ for (const project of projects) {
33
+ const projectPath = join(PROJECTS_DIR, project);
34
+ if (!statSync(projectPath).isDirectory()) continue;
35
+
36
+ const entries = readdirSync(projectPath, { withFileTypes: true });
37
+ for (const entry of entries) {
38
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) {
39
+ files.push(join(projectPath, entry.name));
40
+ } else if (entry.isDirectory()) {
41
+ // subagent dirs
42
+ try {
43
+ const subEntries = readdirSync(join(projectPath, entry.name), { withFileTypes: true });
44
+ for (const sub of subEntries) {
45
+ if (sub.isFile() && sub.name.endsWith('.jsonl')) {
46
+ files.push(join(projectPath, entry.name, sub.name));
47
+ }
48
+ }
49
+ } catch { /* skip */ }
50
+ }
51
+ }
52
+ }
53
+ } catch { /* skip */ }
54
+
55
+ return files;
56
+ }
57
+
58
+ function extractUsageFromEntry(entry) {
59
+ if (!entry || entry.type !== 'assistant') return null;
60
+
61
+ const usage = entry.message?.usage;
62
+ if (!usage) return null;
63
+
64
+ return {
65
+ timestamp: entry.timestamp ? new Date(entry.timestamp) : null,
66
+ model: entry.message?.model || 'unknown',
67
+ inputTokens: usage.input_tokens || 0,
68
+ outputTokens: usage.output_tokens || 0,
69
+ cacheReadTokens: usage.cache_read_input_tokens || 0,
70
+ cacheWriteTokens: usage.cache_creation_input_tokens || 0,
71
+ sessionId: entry.sessionId || null,
72
+ cwd: entry.cwd || null,
73
+ };
74
+ }
75
+
76
+ export function readAllUsage() {
77
+ const files = getAllSessionFiles();
78
+ const entries = [];
79
+
80
+ for (const file of files) {
81
+ const lines = parseJsonlFile(file);
82
+ for (const line of lines) {
83
+ const usage = extractUsageFromEntry(line);
84
+ if (usage && usage.timestamp) {
85
+ entries.push(usage);
86
+ }
87
+ }
88
+ }
89
+
90
+ return entries.sort((a, b) => b.timestamp - a.timestamp);
91
+ }
92
+
93
+ export function getCurrentSessionFile() {
94
+ const sessionsDir = join(CLAUDE_DIR, 'sessions');
95
+ if (!existsSync(sessionsDir)) return null;
96
+
97
+ try {
98
+ // Try to match by parent PID chain: Claude spawns the status line command,
99
+ // so process.ppid (or its parent) should match a session file named <pid>.json
100
+ const pidsToCheck = new Set([process.ppid]);
101
+
102
+ // also check grandparent in case the command runs through a shell
103
+ try {
104
+ const grandparent = parseInt(
105
+ execSync(`ps -o ppid= -p ${process.ppid}`, { stdio: ['pipe','pipe','pipe'] }).toString().trim()
106
+ );
107
+ if (grandparent) pidsToCheck.add(grandparent);
108
+ } catch { /* skip */ }
109
+
110
+ for (const pid of pidsToCheck) {
111
+ const candidate = join(sessionsDir, `${pid}.json`);
112
+ if (existsSync(candidate)) {
113
+ try { return JSON.parse(readFileSync(candidate, 'utf-8')); }
114
+ catch { /* skip */ }
115
+ }
116
+ }
117
+
118
+ // Fallback: find a session whose sessionId appears in JSONL files
119
+ const sessionFiles = readdirSync(sessionsDir)
120
+ .map(f => {
121
+ try { return JSON.parse(readFileSync(join(sessionsDir, f), 'utf-8')); }
122
+ catch { return null; }
123
+ })
124
+ .filter(Boolean);
125
+
126
+ if (!sessionFiles.length) return null;
127
+
128
+ const allFiles = getAllSessionFiles();
129
+ const sessionIdSet = new Set();
130
+ for (const file of allFiles) {
131
+ try {
132
+ const lines = readFileSync(file, 'utf-8').split('\n').filter(l => l.trim());
133
+ for (const line of lines.slice(-5)) {
134
+ try { const obj = JSON.parse(line); if (obj.sessionId) sessionIdSet.add(obj.sessionId); }
135
+ catch { /* skip */ }
136
+ }
137
+ } catch { /* skip */ }
138
+ }
139
+
140
+ return sessionFiles.find(s => sessionIdSet.has(s.sessionId)) || sessionFiles[0];
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ export function readCurrentSessionUsage(sessionId) {
147
+ if (!sessionId) return [];
148
+
149
+ const files = getAllSessionFiles();
150
+ const entries = [];
151
+
152
+ for (const file of files) {
153
+ const lines = parseJsonlFile(file);
154
+ for (const line of lines) {
155
+ if (line.sessionId === sessionId) {
156
+ const usage = extractUsageFromEntry(line);
157
+ if (usage) entries.push(usage);
158
+ }
159
+ }
160
+ }
161
+
162
+ return entries;
163
+ }
@@ -0,0 +1,57 @@
1
+ import { Chalk } from 'chalk';
2
+ import { readAllUsage, getCurrentSessionFile, readCurrentSessionUsage } from './reader.js';
3
+ import { aggregateStats, aggregateSession, formatCost } from './calculator.js';
4
+
5
+ const chalk = new Chalk({ level: 3 });
6
+
7
+ const PINK = '#f472b6';
8
+ const BLUE = '#60a5fa';
9
+ const CYAN = '#22d3ee';
10
+ const DIM = '#444444';
11
+
12
+ function bar(percent, width = 8) {
13
+ const filled = Math.round((percent / 100) * width);
14
+ const empty = width - filled;
15
+ return '█'.repeat(filled) + '░'.repeat(empty);
16
+ }
17
+
18
+ export function renderLine() {
19
+ const allEntries = readAllUsage();
20
+ if (!allEntries.length) {
21
+ process.stdout.write('claude: no data');
22
+ return;
23
+ }
24
+
25
+ const stats = aggregateStats(allEntries);
26
+ const sessionMeta = getCurrentSessionFile();
27
+ const sessionId = sessionMeta?.sessionId;
28
+ const sessionEntries = sessionId ? readCurrentSessionUsage(sessionId) : [];
29
+ const session = aggregateSession(sessionEntries);
30
+
31
+ const { monthly, weekly, daily } = stats;
32
+
33
+ const weeklyPct = monthly.total > 0 ? Math.round((weekly.total / monthly.total) * 100) : 0;
34
+ const todayPct = monthly.total > 0 ? Math.round((daily.total / monthly.total) * 100) : 0;
35
+
36
+ if (!session) {
37
+ const inner = ` CONTEXT ${'░'.repeat(8)} 0% `;
38
+ const top = `╭${'─'.repeat(inner.length)}╮`;
39
+ const bottom = `╰${'─'.repeat(inner.length)}╯`;
40
+ process.stdout.write(
41
+ chalk.hex(CYAN).bold(top) + '\n' +
42
+ chalk.hex(CYAN).bold(`│${inner}│`) + '\n' +
43
+ chalk.hex(CYAN).bold(bottom)
44
+ );
45
+ return;
46
+ }
47
+
48
+ const inner = ` CONTEXT ${bar(session.percent)} ${session.percent}% `;
49
+ const top = `╭${'─'.repeat(inner.length)}╮`;
50
+ const bottom = `╰${'─'.repeat(inner.length)}╯`;
51
+
52
+ process.stdout.write(
53
+ chalk.hex(CYAN).bold(top) + '\n' +
54
+ chalk.hex(CYAN).bold(`│${inner}│`) + '\n' +
55
+ chalk.hex(CYAN).bold(bottom)
56
+ );
57
+ }