lean-claudient-daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { Router } from 'express';
2
+ import { MetricsStore } from './metricsStore.js';
3
+
4
+ export function createApiRouter(store: MetricsStore): Router {
5
+ const router = Router();
6
+
7
+ router.get('/status', (req, res) => {
8
+ const uptime = process.uptime();
9
+ const memory = process.memoryUsage().heapUsed / 1024 / 1024;
10
+ res.json({
11
+ running: true,
12
+ uptime: Math.floor(uptime),
13
+ memory: parseFloat(memory.toFixed(2)),
14
+ pid: process.pid,
15
+ });
16
+ });
17
+
18
+ router.get('/metrics', async (req, res) => {
19
+ try {
20
+ const metrics = await store.getAggregateMetrics();
21
+ res.json(metrics);
22
+ } catch (error) {
23
+ res.status(500).json({ error: 'Failed to retrieve metrics' });
24
+ }
25
+ });
26
+
27
+ router.get('/budget/:sessionId', async (req, res) => {
28
+ try {
29
+ const budget = await store.getBudgetStatus(req.params.sessionId);
30
+ if (!budget) {
31
+ return res.status(404).json({ error: 'Session not found' });
32
+ }
33
+ res.json(budget);
34
+ } catch (error) {
35
+ res.status(500).json({ error: 'Failed to retrieve budget' });
36
+ }
37
+ });
38
+
39
+ router.get('/sessions', async (req, res) => {
40
+ try {
41
+ const sessions = await store.getAllSessions();
42
+ res.json(sessions);
43
+ } catch (error) {
44
+ res.status(500).json({ error: 'Failed to retrieve sessions' });
45
+ }
46
+ });
47
+
48
+ router.post('/sessions/:id/checkpoint', async (req, res) => {
49
+ try {
50
+ await store.saveCheckpoint(req.params.id, req.body.checkpoint);
51
+ res.json({ saved: true });
52
+ } catch (error) {
53
+ res.status(500).json({ error: 'Failed to save checkpoint' });
54
+ }
55
+ });
56
+
57
+ return router;
58
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { LeanClaudientDaemon } from '../service.js';
5
+
6
+ const program = new Command();
7
+
8
+ program
9
+ .name('lean-claudient-daemon')
10
+ .version('0.1.0')
11
+ .option('-p, --port <port>', 'Port to listen on', '9831')
12
+ .option('-l, --log <path>', 'Log file path')
13
+ .action(async (options) => {
14
+ const daemon = new LeanClaudientDaemon(parseInt(options.port), options.log);
15
+ await daemon.start();
16
+ });
17
+
18
+ program.parse();
package/src/metrics.ts ADDED
@@ -0,0 +1,45 @@
1
+ export interface MetricsRecord {
2
+ sessionId: string;
3
+ timestamp: number;
4
+ inputTokens: number;
5
+ outputTokens: number;
6
+ totalTokens: number;
7
+ cost: number;
8
+ savedTokens: number;
9
+ compressionRatio: number;
10
+ subagentCount: number;
11
+ }
12
+
13
+ export function aggregateMetrics(records: MetricsRecord[]): any {
14
+ if (records.length === 0) {
15
+ return {
16
+ totalInputTokens: 0,
17
+ totalOutputTokens: 0,
18
+ totalTokens: 0,
19
+ totalCost: '$0.00',
20
+ totalSavedTokens: 0,
21
+ averageCompressionRatio: 0,
22
+ sessionCount: 0,
23
+ averageSubagentCount: 0,
24
+ };
25
+ }
26
+
27
+ const totalInputTokens = records.reduce((sum, r) => sum + r.inputTokens, 0);
28
+ const totalOutputTokens = records.reduce((sum, r) => sum + r.outputTokens, 0);
29
+ const totalTokens = totalInputTokens + totalOutputTokens;
30
+ const totalCost = totalInputTokens * 0.000003 + totalOutputTokens * 0.000015;
31
+ const totalSavedTokens = records.reduce((sum, r) => sum + r.savedTokens, 0);
32
+ const avgCompressionRatio = records.reduce((sum, r) => sum + r.compressionRatio, 0) / records.length;
33
+ const avgSubagentCount = records.reduce((sum, r) => sum + r.subagentCount, 0) / records.length;
34
+
35
+ return {
36
+ totalInputTokens,
37
+ totalOutputTokens,
38
+ totalTokens,
39
+ totalCost: `$${totalCost.toFixed(2)}`,
40
+ totalSavedTokens,
41
+ averageCompressionRatio: parseFloat(avgCompressionRatio.toFixed(2)),
42
+ sessionCount: records.length,
43
+ averageSubagentCount: Math.round(avgSubagentCount),
44
+ };
45
+ }
@@ -0,0 +1,114 @@
1
+ import sqlite3 from 'sqlite3';
2
+ import { promisify } from 'util';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { MetricsRecord, aggregateMetrics } from './metrics.js';
6
+
7
+ export class MetricsStore {
8
+ private db?: sqlite3.Database;
9
+ private dbPath: string;
10
+
11
+ constructor(dbPath?: string) {
12
+ this.dbPath = dbPath || path.join(process.env.HOME!, '.lean-claudient', 'daemon.db');
13
+ }
14
+
15
+ async initialize() {
16
+ const dir = path.dirname(this.dbPath);
17
+ if (!fs.existsSync(dir)) {
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ }
20
+
21
+ this.db = new sqlite3.Database(this.dbPath);
22
+
23
+ const run = promisify(this.db.run.bind(this.db));
24
+ await run(`
25
+ CREATE TABLE IF NOT EXISTS metrics (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ sessionId TEXT NOT NULL,
28
+ timestamp INTEGER NOT NULL,
29
+ inputTokens INTEGER,
30
+ outputTokens INTEGER,
31
+ cost REAL,
32
+ savedTokens INTEGER,
33
+ compressionRatio REAL,
34
+ subagentCount INTEGER,
35
+ UNIQUE(sessionId, timestamp)
36
+ )
37
+ `);
38
+
39
+ await run(`
40
+ CREATE TABLE IF NOT EXISTS checkpoints (
41
+ sessionId TEXT PRIMARY KEY,
42
+ checkpoint TEXT NOT NULL,
43
+ timestamp INTEGER NOT NULL
44
+ )
45
+ `);
46
+ }
47
+
48
+ async recordMetrics(metrics: MetricsRecord) {
49
+ const run = promisify(this.db!.run.bind(this.db!));
50
+ await run(
51
+ `INSERT OR REPLACE INTO metrics (sessionId, timestamp, inputTokens, outputTokens, cost, savedTokens, compressionRatio, subagentCount)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
53
+ [
54
+ metrics.sessionId,
55
+ metrics.timestamp,
56
+ metrics.inputTokens,
57
+ metrics.outputTokens,
58
+ metrics.cost,
59
+ metrics.savedTokens,
60
+ metrics.compressionRatio,
61
+ metrics.subagentCount,
62
+ ]
63
+ );
64
+ }
65
+
66
+ async getAggregateMetrics() {
67
+ const all = promisify(this.db!.all.bind(this.db!));
68
+ const records = (await all(
69
+ 'SELECT * FROM metrics WHERE timestamp > ? ORDER BY timestamp DESC LIMIT 1000',
70
+ [Date.now() - 7 * 24 * 60 * 60 * 1000]
71
+ )) as MetricsRecord[];
72
+
73
+ return aggregateMetrics(records);
74
+ }
75
+
76
+ async getBudgetStatus(sessionId: string) {
77
+ const get = promisify(this.db!.get.bind(this.db!));
78
+ const record = (await get(
79
+ 'SELECT * FROM metrics WHERE sessionId = ? ORDER BY timestamp DESC LIMIT 1',
80
+ [sessionId]
81
+ )) as MetricsRecord | undefined;
82
+
83
+ if (!record) return null;
84
+
85
+ return {
86
+ sessionId,
87
+ spent: record.cost,
88
+ limit: 500,
89
+ remaining: 500 - record.cost,
90
+ alert: record.cost > 375 ? 'warning' : 'ok',
91
+ };
92
+ }
93
+
94
+ async getAllSessions() {
95
+ const all = promisify(this.db!.all.bind(this.db!));
96
+ return (await all(
97
+ 'SELECT DISTINCT sessionId, MIN(timestamp) as startTime, COUNT(*) as recordCount FROM metrics GROUP BY sessionId'
98
+ )) as any[];
99
+ }
100
+
101
+ async saveCheckpoint(sessionId: string, checkpoint: string) {
102
+ const run = promisify(this.db!.run.bind(this.db!));
103
+ await run(
104
+ 'INSERT OR REPLACE INTO checkpoints (sessionId, checkpoint, timestamp) VALUES (?, ?, ?)',
105
+ [sessionId, checkpoint, Date.now()]
106
+ );
107
+ }
108
+
109
+ async close() {
110
+ return new Promise<void>((resolve, reject) => {
111
+ this.db!.close((err) => (err ? reject(err) : resolve()));
112
+ });
113
+ }
114
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Rate Limiting Middleware
3
+ * Prevents DOS attacks by limiting requests per IP and session
4
+ */
5
+
6
+ import { Request, Response, NextFunction } from 'express';
7
+
8
+ /**
9
+ * Rate limit store - stores request counts by key
10
+ * Key format: "ip:address" or "session:sessionId"
11
+ */
12
+ class RateLimitStore {
13
+ private store: Map<string, { count: number; resetTime: number }> = new Map();
14
+ private cleanupInterval: NodeJS.Timeout;
15
+
16
+ constructor() {
17
+ // Clean up expired entries every minute
18
+ this.cleanupInterval = setInterval(() => {
19
+ const now = Date.now();
20
+ for (const [key, value] of this.store.entries()) {
21
+ if (value.resetTime < now) {
22
+ this.store.delete(key);
23
+ }
24
+ }
25
+ }, 60000);
26
+ }
27
+
28
+ /**
29
+ * Record a request and return remaining quota
30
+ */
31
+ recordRequest(key: string, windowMs: number, limit: number): number {
32
+ const now = Date.now();
33
+ const entry = this.store.get(key);
34
+
35
+ if (!entry || entry.resetTime < now) {
36
+ this.store.set(key, { count: 1, resetTime: now + windowMs });
37
+ return limit - 1;
38
+ }
39
+
40
+ entry.count++;
41
+ return Math.max(0, limit - entry.count);
42
+ }
43
+
44
+ /**
45
+ * Check if limit is exceeded
46
+ */
47
+ isLimited(key: string, windowMs: number, limit: number): boolean {
48
+ const now = Date.now();
49
+ const entry = this.store.get(key);
50
+
51
+ if (!entry || entry.resetTime < now) {
52
+ return false;
53
+ }
54
+
55
+ return entry.count >= limit;
56
+ }
57
+
58
+ /**
59
+ * Get remaining time until reset
60
+ */
61
+ getResetTime(key: string): number {
62
+ const entry = this.store.get(key);
63
+ if (!entry) return 0;
64
+ return Math.max(0, entry.resetTime - Date.now());
65
+ }
66
+
67
+ /**
68
+ * Cleanup
69
+ */
70
+ destroy(): void {
71
+ clearInterval(this.cleanupInterval);
72
+ this.store.clear();
73
+ }
74
+ }
75
+
76
+ const globalStore = new RateLimitStore();
77
+
78
+ /**
79
+ * Rate limiting configuration
80
+ */
81
+ interface RateLimitConfig {
82
+ windowMs: number; // Time window in milliseconds
83
+ max: number; // Max requests per window
84
+ keyGenerator?: (req: Request) => string;
85
+ handler?: (req: Request, res: Response) => void;
86
+ skip?: (req: Request) => boolean;
87
+ onLimitReached?: (req: Request, key: string) => void;
88
+ }
89
+
90
+ /**
91
+ * Create rate limit middleware
92
+ */
93
+ export function rateLimit(config: RateLimitConfig) {
94
+ const {
95
+ windowMs = 15 * 60 * 1000, // 15 minutes default
96
+ max = 100,
97
+ keyGenerator = (req) => `ip:${req.ip}`,
98
+ skip,
99
+ onLimitReached,
100
+ } = config;
101
+
102
+ return (req: Request, res: Response, next: NextFunction) => {
103
+ if (skip && skip(req)) {
104
+ return next();
105
+ }
106
+
107
+ const key = keyGenerator(req);
108
+ const remaining = globalStore.recordRequest(key, windowMs, max);
109
+ const isLimited = globalStore.isLimited(key, windowMs, max);
110
+ const resetTime = globalStore.getResetTime(key);
111
+
112
+ // Set rate limit headers
113
+ res.setHeader('X-RateLimit-Limit', max);
114
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, remaining));
115
+ res.setHeader('X-RateLimit-Reset', Math.ceil((Date.now() + resetTime) / 1000));
116
+
117
+ if (isLimited) {
118
+ res.setHeader('Retry-After', Math.ceil(resetTime / 1000));
119
+
120
+ if (onLimitReached) {
121
+ onLimitReached(req, key);
122
+ }
123
+
124
+ return res.status(429).json({
125
+ error: 'Too Many Requests',
126
+ code: 'RATE_LIMIT_EXCEEDED',
127
+ details: {
128
+ retryAfter: Math.ceil(resetTime / 1000),
129
+ limit: max,
130
+ windowMs,
131
+ },
132
+ });
133
+ }
134
+
135
+ next();
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Generic rate limiter by key
141
+ */
142
+ export function createRateLimiter(config: RateLimitConfig) {
143
+ return rateLimit(config);
144
+ }
145
+
146
+ /**
147
+ * Rate limiter by IP address
148
+ */
149
+ export function createIPRateLimiter(maxRequests: number, windowMinutes: number = 15) {
150
+ return rateLimit({
151
+ windowMs: windowMinutes * 60 * 1000,
152
+ max: maxRequests,
153
+ keyGenerator: (req) => `ip:${req.ip || 'unknown'}`,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Rate limiter by session ID
159
+ */
160
+ export function createSessionRateLimiter(maxRequests: number, windowMinutes: number = 15) {
161
+ return rateLimit({
162
+ windowMs: windowMinutes * 60 * 1000,
163
+ max: maxRequests,
164
+ keyGenerator: (req) => {
165
+ const sessionId = req.params.sessionId || req.query.sessionId;
166
+ return `session:${sessionId || 'unknown'}`;
167
+ },
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Rate limiter by user ID (from query or body)
173
+ */
174
+ export function createUserRateLimiter(maxRequests: number, windowMinutes: number = 15) {
175
+ return rateLimit({
176
+ windowMs: windowMinutes * 60 * 1000,
177
+ max: maxRequests,
178
+ keyGenerator: (req) => {
179
+ const userId = (req.query.userId as string) || (req.body as any)?.userId;
180
+ return `user:${userId || 'anonymous'}`;
181
+ },
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Tiered rate limiter configuration
187
+ * Different limits for different endpoints
188
+ */
189
+ export const RATE_LIMITS = {
190
+ // High frequency - status checks
191
+ STATUS: { max: 100, windowMinutes: 1 },
192
+
193
+ // Medium frequency - metrics reads
194
+ METRICS: { max: 60, windowMinutes: 1 },
195
+
196
+ // Medium frequency - budget checks
197
+ BUDGET: { max: 60, windowMinutes: 1 },
198
+
199
+ // Low frequency - session creation
200
+ SESSIONS: { max: 30, windowMinutes: 1 },
201
+
202
+ // Very low frequency - checkpoints
203
+ CHECKPOINT: { max: 10, windowMinutes: 1 },
204
+
205
+ // Low frequency - admin operations
206
+ ADMIN: { max: 20, windowMinutes: 1 },
207
+ };
208
+
209
+ /**
210
+ * Create logging function for rate limit violations
211
+ */
212
+ export function createRateLimitLogger(logFile?: string) {
213
+ return (req: Request, key: string) => {
214
+ const logEntry = {
215
+ timestamp: new Date().toISOString(),
216
+ key,
217
+ method: req.method,
218
+ path: req.path,
219
+ ip: req.ip,
220
+ userAgent: req.get('User-Agent'),
221
+ };
222
+
223
+ if (logFile) {
224
+ // Would log to file in production
225
+ console.warn('[RATE_LIMIT]', JSON.stringify(logEntry));
226
+ } else {
227
+ console.warn('[RATE_LIMIT]', logEntry);
228
+ }
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Cleanup rate limiter
234
+ */
235
+ export function destroyRateLimiter(): void {
236
+ globalStore.destroy();
237
+ }
238
+
239
+ /**
240
+ * Reset rate limiter for testing
241
+ */
242
+ export function resetRateLimiter(): void {
243
+ destroyRateLimiter();
244
+ }