claude-cup 0.2.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/src/eco.js ADDED
@@ -0,0 +1,151 @@
1
+ // Real eco mode: makes the SAME model spend fewer tokens by toggling
2
+ // Claude Code's own documented levers:
3
+ // - settings.json env:
4
+ // MAX_THINKING_TOKENS=4096 caps extended-thinking spend
5
+ // CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192 caps runaway long outputs
6
+ // DISABLE_NON_ESSENTIAL_MODEL_CALLS=1 skips flavor/non-essential calls
7
+ // - ~/.claude/CLAUDE.md: a clearly-marked concise-mode instruction block
8
+ //
9
+ // Everything is reversible: previous values are backed up on enable and
10
+ // restored exactly on disable. If the user hand-edits a value while eco is
11
+ // on, disable leaves their value alone (never clobbers user intent).
12
+ // Writes are atomic (tmp file + rename). A corrupt settings.json aborts
13
+ // the toggle instead of overwriting it.
14
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+
17
+ export const ECO_ENV = {
18
+ MAX_THINKING_TOKENS: '4096',
19
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: '8192',
20
+ DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1',
21
+ };
22
+
23
+ const BLOCK_START = '<!-- claude-jar:eco:start -->';
24
+ const BLOCK_END = '<!-- claude-jar:eco:end -->';
25
+ const ECO_BLOCK = `${BLOCK_START}
26
+ # Eco mode (managed by claude-jar - toggling eco off removes this block)
27
+ Be maximally token-efficient: answer concisely with no preamble, recap, or
28
+ celebratory summary. Read only files you truly need and never re-read
29
+ unchanged files. Batch related edits. Prefer targeted diffs over full file
30
+ dumps. Skip optional confirmations and pleasantries.
31
+ ${BLOCK_END}`;
32
+
33
+ export const ECO_SUMMARY = 'thinking 4k \u00b7 output 8k \u00b7 trims chatter';
34
+
35
+ function atomicWrite(path, content) {
36
+ const tmp = path + '.claude-jar-tmp';
37
+ writeFileSync(tmp, content);
38
+ renameSync(tmp, path);
39
+ }
40
+
41
+ export class EcoMode {
42
+ constructor({ configDir, jarDir }) {
43
+ this.settingsPath = join(configDir, 'settings.json');
44
+ this.claudeMdPath = join(configDir, 'CLAUDE.md');
45
+ this.statePath = join(jarDir, 'eco.json');
46
+ }
47
+
48
+ _readState() {
49
+ try {
50
+ return JSON.parse(readFileSync(this.statePath, 'utf8'));
51
+ } catch {
52
+ return { on: false };
53
+ }
54
+ }
55
+
56
+ _writeState(state) {
57
+ mkdirSync(dirname(this.statePath), { recursive: true });
58
+ atomicWrite(this.statePath, JSON.stringify(state, null, 2));
59
+ }
60
+
61
+ _readSettings() {
62
+ if (!existsSync(this.settingsPath)) return {};
63
+ const raw = readFileSync(this.settingsPath, 'utf8');
64
+ if (!raw.trim()) return {};
65
+ return JSON.parse(raw); // throws on corrupt JSON -> toggle aborts safely
66
+ }
67
+
68
+ /** Effective status: state flag, self-healed against manual removal. */
69
+ status() {
70
+ const state = this._readState();
71
+ let applied = false;
72
+ try {
73
+ const env = this._readSettings().env || {};
74
+ const hasEnv = Object.entries(ECO_ENV).every(([k, v]) => env[k] === v);
75
+ const hasBlock = existsSync(this.claudeMdPath) && readFileSync(this.claudeMdPath, 'utf8').includes(BLOCK_START);
76
+ applied = hasEnv || hasBlock;
77
+ } catch {
78
+ applied = false;
79
+ }
80
+ const on = Boolean(state.on && applied);
81
+ return { on, summary: ECO_SUMMARY, env: ECO_ENV, appliedAt: state.appliedAt || null };
82
+ }
83
+
84
+ enable() {
85
+ try {
86
+ const settings = this._readSettings();
87
+ const prevEnv = settings.env || {};
88
+ const backup = { env: {}, claudeMd: {} };
89
+ for (const key of Object.keys(ECO_ENV)) {
90
+ backup.env[key] = Object.prototype.hasOwnProperty.call(prevEnv, key) ? prevEnv[key] : null;
91
+ }
92
+ settings.env = { ...prevEnv, ...ECO_ENV };
93
+ atomicWrite(this.settingsPath, JSON.stringify(settings, null, 2) + '\n');
94
+
95
+ const mdExisted = existsSync(this.claudeMdPath);
96
+ backup.claudeMd.existed = mdExisted;
97
+ const md = mdExisted ? readFileSync(this.claudeMdPath, 'utf8') : '';
98
+ if (!md.includes(BLOCK_START)) {
99
+ const next = md.trim() ? `${md.replace(/\s+$/, '')}\n\n${ECO_BLOCK}\n` : `${ECO_BLOCK}\n`;
100
+ atomicWrite(this.claudeMdPath, next);
101
+ }
102
+
103
+ this._writeState({ on: true, appliedAt: Date.now(), backup });
104
+ return { ok: true, status: this.status() };
105
+ } catch (err) {
106
+ return { ok: false, error: `could not enable eco mode: ${err.message}`, status: this.status() };
107
+ }
108
+ }
109
+
110
+ disable() {
111
+ try {
112
+ const state = this._readState();
113
+ const backup = state.backup || { env: {}, claudeMd: {} };
114
+
115
+ const settings = this._readSettings();
116
+ if (settings.env) {
117
+ for (const [key, ecoValue] of Object.entries(ECO_ENV)) {
118
+ // only undo values we set; leave the user's manual edits alone
119
+ if (settings.env[key] === ecoValue) {
120
+ const prev = backup.env?.[key];
121
+ if (prev === null || prev === undefined) delete settings.env[key];
122
+ else settings.env[key] = prev;
123
+ }
124
+ }
125
+ if (Object.keys(settings.env).length === 0) delete settings.env;
126
+ atomicWrite(this.settingsPath, JSON.stringify(settings, null, 2) + '\n');
127
+ }
128
+
129
+ if (existsSync(this.claudeMdPath)) {
130
+ const md = readFileSync(this.claudeMdPath, 'utf8');
131
+ const startIdx = md.indexOf(BLOCK_START);
132
+ const endIdx = md.indexOf(BLOCK_END);
133
+ if (startIdx !== -1 && endIdx !== -1) {
134
+ const before = md.slice(0, startIdx).replace(/\n+$/, '\n');
135
+ const after = md.slice(endIdx + BLOCK_END.length).replace(/^\n+/, '');
136
+ const next = (before + after).replace(/^\n+/, '').replace(/\s+$/, '');
137
+ if (!next && backup.claudeMd?.existed === false) {
138
+ unlinkSync(this.claudeMdPath); // we created the file; remove it again
139
+ } else {
140
+ atomicWrite(this.claudeMdPath, next ? next + '\n' : '');
141
+ }
142
+ }
143
+ }
144
+
145
+ this._writeState({ on: false });
146
+ return { ok: true, status: this.status() };
147
+ } catch (err) {
148
+ return { ok: false, error: `could not disable eco mode: ${err.message}`, status: this.status() };
149
+ }
150
+ }
151
+ }
package/src/parse.js ADDED
@@ -0,0 +1,86 @@
1
+ // Parses one line of a Claude Code JSONL transcript into a compact event, or null.
2
+
3
+ const CATEGORY_BY_TOOL = {
4
+ read: ['Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'ListMcpResourcesTool', 'ReadMcpResourceTool'],
5
+ edit: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'StrReplace', 'Delete'],
6
+ terminal: ['Bash', 'BashOutput', 'KillShell', 'Shell', 'AwaitShell'],
7
+ web: ['WebSearch', 'WebFetch'],
8
+ agent: ['Task', 'Agent'],
9
+ };
10
+
11
+ const toolToCategory = new Map();
12
+ for (const [cat, names] of Object.entries(CATEGORY_BY_TOOL)) {
13
+ for (const n of names) toolToCategory.set(n.toLowerCase(), cat);
14
+ }
15
+
16
+ export function categorizeTool(name) {
17
+ if (!name) return 'other';
18
+ const found = toolToCategory.get(String(name).toLowerCase());
19
+ if (found) return found;
20
+ if (/^mcp__/i.test(name)) return 'web';
21
+ return 'other';
22
+ }
23
+
24
+ function num(v) {
25
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
26
+ }
27
+
28
+ /**
29
+ * @param {string} line raw JSONL line
30
+ * @returns {object|null} event:
31
+ * { kind:'assistant', uuid, sessionId, ts, model, usage:{in,out,cacheRead,cacheW5m,cacheW1h}, tools:[{name,category}] }
32
+ * { kind:'prompt', uuid, sessionId, ts }
33
+ */
34
+ export function parseLine(line) {
35
+ if (!line) return null;
36
+ // strip BOM and CR
37
+ let s = line.replace(/^\uFEFF/, '').trim();
38
+ if (!s) return null;
39
+ let obj;
40
+ try {
41
+ obj = JSON.parse(s);
42
+ } catch {
43
+ return null;
44
+ }
45
+ if (!obj || typeof obj !== 'object') return null;
46
+
47
+ const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN;
48
+ if (!Number.isFinite(ts)) return null;
49
+ const base = { uuid: obj.uuid || null, sessionId: obj.sessionId || null, ts };
50
+
51
+ if (obj.type === 'assistant' && obj.message) {
52
+ const m = obj.message;
53
+ const u = m.usage || {};
54
+ const cc = u.cache_creation || {};
55
+ const tools = [];
56
+ if (Array.isArray(m.content)) {
57
+ for (const block of m.content) {
58
+ if (block && block.type === 'tool_use') {
59
+ tools.push({ name: block.name || 'unknown', category: categorizeTool(block.name) });
60
+ }
61
+ }
62
+ }
63
+ return {
64
+ kind: 'assistant',
65
+ ...base,
66
+ model: m.model || 'unknown',
67
+ usage: {
68
+ in: num(u.input_tokens),
69
+ out: num(u.output_tokens),
70
+ cacheRead: num(u.cache_read_input_tokens),
71
+ cacheW5m: num(cc.ephemeral_5m_input_tokens) || (cc.ephemeral_1h_input_tokens == null ? num(u.cache_creation_input_tokens) : 0),
72
+ cacheW1h: num(cc.ephemeral_1h_input_tokens),
73
+ },
74
+ tools,
75
+ };
76
+ }
77
+
78
+ if (obj.type === 'user' && obj.message && !obj.isMeta) {
79
+ // Skip tool_result carriers; count only real human prompts.
80
+ const c = obj.message.content;
81
+ const isToolResult = Array.isArray(c) && c.some((b) => b && b.type === 'tool_result');
82
+ if (!isToolResult) return { kind: 'prompt', ...base };
83
+ }
84
+
85
+ return null;
86
+ }
package/src/server.js ADDED
@@ -0,0 +1,162 @@
1
+ // Localhost-only HTTP server: static UI + SSE event stream + JSON state.
2
+ import { createServer } from 'node:http';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join, extname, normalize } from 'node:path';
5
+
6
+ const MIME = {
7
+ '.html': 'text/html; charset=utf-8',
8
+ '.js': 'text/javascript; charset=utf-8',
9
+ '.css': 'text/css; charset=utf-8',
10
+ '.svg': 'image/svg+xml',
11
+ '.png': 'image/png',
12
+ '.json': 'application/json',
13
+ };
14
+
15
+ export function createJarServer({ distDir, aggregator, poller, watcher, eco, dbh }) {
16
+ const clients = new Set();
17
+ let statsDirty = false;
18
+
19
+ function broadcast(event, data) {
20
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
21
+ for (const res of clients) res.write(msg);
22
+ }
23
+
24
+ function fullState() {
25
+ return {
26
+ stats: aggregator.snapshot(),
27
+ usage: poller.state,
28
+ history: aggregator.historyDays(7),
29
+ eco: eco ? eco.status() : null,
30
+ serverTime: Date.now(),
31
+ };
32
+ }
33
+
34
+ // Wiring: transcripts -> aggregator -> (live) activity events to browser
35
+ watcher.on('event', (evt, { live }) => {
36
+ const counted = aggregator.addEvent(evt);
37
+ if (!counted || !live) return;
38
+ statsDirty = true;
39
+ if (evt.kind === 'assistant') {
40
+ const freshTokens = evt.usage.in + evt.usage.out;
41
+ broadcast('activity', {
42
+ kind: 'message',
43
+ category: 'ai',
44
+ model: evt.model,
45
+ tokens: freshTokens,
46
+ ts: evt.ts,
47
+ });
48
+ for (const t of evt.tools) {
49
+ broadcast('activity', { kind: 'tool', category: t.category, name: t.name, ts: evt.ts });
50
+ }
51
+ }
52
+ });
53
+
54
+ poller.on('usage', (usage) => {
55
+ if (usage.status === 'ok' && usage.fiveHour) aggregator.noteFill(usage.fiveHour.pct);
56
+ broadcast('usage', usage);
57
+ });
58
+
59
+ const statsTimer = setInterval(() => {
60
+ if (statsDirty && clients.size) {
61
+ statsDirty = false;
62
+ broadcast('stats', aggregator.snapshot());
63
+ }
64
+ }, 2000);
65
+ statsTimer.unref?.();
66
+
67
+ const heartbeat = setInterval(() => {
68
+ for (const res of clients) res.write(': ping\n\n');
69
+ }, 25_000);
70
+ heartbeat.unref?.();
71
+
72
+ const server = createServer(async (req, res) => {
73
+ const url = new URL(req.url, 'http://localhost');
74
+ const path = url.pathname;
75
+
76
+ if (path === '/events') {
77
+ res.writeHead(200, {
78
+ 'Content-Type': 'text/event-stream',
79
+ 'Cache-Control': 'no-cache',
80
+ Connection: 'keep-alive',
81
+ });
82
+ res.write(`event: snapshot\ndata: ${JSON.stringify(fullState())}\n\n`);
83
+ clients.add(res);
84
+ req.on('close', () => clients.delete(res));
85
+ return;
86
+ }
87
+
88
+ if (path === '/api/state') {
89
+ res.writeHead(200, { 'Content-Type': 'application/json' });
90
+ res.end(JSON.stringify(fullState()));
91
+ return;
92
+ }
93
+
94
+ if (path === '/api/mcp-state' && dbh) {
95
+ try {
96
+ const { getCurrentSession, getRecentActivity, getValidatedTokenSummary } = await import('../mcp-server/src/db.js');
97
+ const sess = getCurrentSession(dbh) || {};
98
+ const recent = getRecentActivity(dbh, 20);
99
+ const tokenSummary = getValidatedTokenSummary(dbh);
100
+ const mcpState = {
101
+ session_id: sess.session_id || null,
102
+ total_intensity: sess.total_intensity || 0,
103
+ environment_richness_score: sess.environment_richness_score || 0,
104
+ power_level: sess.power_level || 'standard',
105
+ recent_event_count: recent.length,
106
+ token_summary: tokenSummary,
107
+ last_update_ts: sess.last_update_ts || null,
108
+ };
109
+ res.writeHead(200, { 'Content-Type': 'application/json' });
110
+ res.end(JSON.stringify(mcpState));
111
+ } catch (e) {
112
+ res.writeHead(500, { 'Content-Type': 'application/json' });
113
+ res.end(JSON.stringify({ error: e.message }));
114
+ }
115
+ return;
116
+ }
117
+
118
+ if (path === '/api/eco' && eco) {
119
+ if (req.method === 'POST') {
120
+ let body = '';
121
+ req.on('data', (d) => (body += d));
122
+ req.on('end', () => {
123
+ let result;
124
+ try {
125
+ const want = Boolean(JSON.parse(body || '{}').on);
126
+ result = want ? eco.enable() : eco.disable();
127
+ } catch (err) {
128
+ result = { ok: false, error: err.message, status: eco.status() };
129
+ }
130
+ broadcast('eco', eco.status());
131
+ res.writeHead(result.ok ? 200 : 500, { 'Content-Type': 'application/json' });
132
+ res.end(JSON.stringify(result));
133
+ });
134
+ } else {
135
+ res.writeHead(200, { 'Content-Type': 'application/json' });
136
+ res.end(JSON.stringify(eco.status()));
137
+ }
138
+ return;
139
+ }
140
+
141
+ // static files
142
+ const rel = path === '/' ? 'index.html' : path.slice(1);
143
+ const file = normalize(join(distDir, rel));
144
+ if (!file.startsWith(normalize(distDir))) {
145
+ res.writeHead(403).end();
146
+ return;
147
+ }
148
+ try {
149
+ const body = await readFile(file);
150
+ res.writeHead(200, {
151
+ 'Content-Type': MIME[extname(file)] || 'application/octet-stream',
152
+ 'Cache-Control': 'no-cache',
153
+ });
154
+ res.end(body);
155
+ } catch {
156
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
157
+ res.end('not found');
158
+ }
159
+ });
160
+
161
+ return { server, broadcast, clientCount: () => clients.size };
162
+ }
@@ -0,0 +1,71 @@
1
+ // `claude-jar statusline`: one-line jar meter for Claude Code's statusline.
2
+ // Claude Code (>= 2.1.80) pipes session JSON to the statusline command on
3
+ // stdin, including rate_limits for Pro/Max accounts. We parse it and print
4
+ // a single line. No network calls - this runs on every statusline refresh.
5
+ //
6
+ // Glyph language matches Claude Code itself: the ✻ spark, █░ meter,
7
+ // dim · separators. No emoji (renders everywhere, including conhost).
8
+
9
+ const CLAY = '\x1b[38;2;217;119;87m';
10
+ const EMBER = '\x1b[38;2;198;97;63m';
11
+ const BOLD = '\x1b[1m';
12
+ const DIM = '\x1b[2m';
13
+ const RESET = '\x1b[0m';
14
+ const SPARK = '\u273b'; // ✻
15
+
16
+ // All known fields are percentages (used_percentage 42 = 42%, utilization 1 = 1%).
17
+ function pctOf(bucket) {
18
+ if (!bucket || typeof bucket !== 'object') return null;
19
+ const v = bucket.used_percentage ?? bucket.utilization ?? bucket.percent;
20
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
21
+ return Math.max(0, Math.min(100, v));
22
+ }
23
+
24
+ function countdown(resetsAt) {
25
+ if (!resetsAt) return null;
26
+ const ms = Date.parse(resetsAt) - Date.now();
27
+ if (!Number.isFinite(ms) || ms <= 0) return null;
28
+ const h = Math.floor(ms / 3600000);
29
+ const m = Math.floor((ms % 3600000) / 60000);
30
+ return h > 0 ? `${h}h${m}m` : `${m}m`;
31
+ }
32
+
33
+ /** Pure formatter (exported for tests). Returns the statusline string. */
34
+ export function formatStatusline(input) {
35
+ const rl = input?.rate_limits || {};
36
+ const five = pctOf(rl.five_hour);
37
+ const seven = pctOf(rl.seven_day);
38
+
39
+ if (five === null && seven === null) {
40
+ return `${CLAY}${SPARK}${RESET} ${DIM}claude-jar${RESET}`;
41
+ }
42
+
43
+ const parts = [];
44
+ if (five !== null) {
45
+ const slots = 8;
46
+ const filled = Math.round((five / 100) * slots);
47
+ const color = five > 85 ? EMBER : CLAY;
48
+ const bar =
49
+ `${color}${'\u2588'.repeat(filled)}${RESET}` + `${DIM}${'\u2591'.repeat(slots - filled)}${RESET}`;
50
+ parts.push(`${color}${SPARK}${RESET} ${bar} ${BOLD}${Math.round(five)}%${RESET}`);
51
+ const reset = countdown(rl.five_hour?.resets_at);
52
+ if (reset) parts.push(`${DIM}\u21bb ${reset}${RESET}`);
53
+ }
54
+ if (seven !== null) {
55
+ const sevenStr = seven > 85 ? `${EMBER}${Math.round(seven)}%${RESET}` : `${Math.round(seven)}%`;
56
+ parts.push(`${DIM}7d${RESET} ${sevenStr}`);
57
+ }
58
+ return parts.join(` ${DIM}\u00b7${RESET} `);
59
+ }
60
+
61
+ export async function runStatusline() {
62
+ let raw = '';
63
+ for await (const chunk of process.stdin) raw += chunk;
64
+ let input = null;
65
+ try {
66
+ input = JSON.parse(raw);
67
+ } catch {
68
+ /* no/bad input: print the idle form */
69
+ }
70
+ process.stdout.write(formatStatusline(input) + '\n');
71
+ }