@runtimescope/collector 0.6.2 → 0.7.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.
@@ -0,0 +1,4616 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/ring-buffer.ts
9
+ var RingBuffer = class {
10
+ constructor(capacity) {
11
+ this.capacity = capacity;
12
+ this.buffer = new Array(capacity);
13
+ }
14
+ buffer;
15
+ head = 0;
16
+ _count = 0;
17
+ get count() {
18
+ return this._count;
19
+ }
20
+ push(item) {
21
+ this.buffer[this.head] = item;
22
+ this.head = (this.head + 1) % this.capacity;
23
+ if (this._count < this.capacity) this._count++;
24
+ }
25
+ /** Returns all items from oldest to newest. */
26
+ toArray() {
27
+ if (this._count === 0) return [];
28
+ const result = [];
29
+ const start = this._count < this.capacity ? 0 : this.head;
30
+ for (let i = 0; i < this._count; i++) {
31
+ const idx = (start + i) % this.capacity;
32
+ result.push(this.buffer[idx]);
33
+ }
34
+ return result;
35
+ }
36
+ /** Returns matching items from newest to oldest (most recent first). */
37
+ query(predicate) {
38
+ if (this._count === 0) return [];
39
+ const result = [];
40
+ const start = this._count < this.capacity ? 0 : this.head;
41
+ for (let i = this._count - 1; i >= 0; i--) {
42
+ const idx = (start + i) % this.capacity;
43
+ const item = this.buffer[idx];
44
+ if (predicate(item)) result.push(item);
45
+ }
46
+ return result;
47
+ }
48
+ clear() {
49
+ this.buffer = new Array(this.capacity);
50
+ this.head = 0;
51
+ this._count = 0;
52
+ }
53
+ };
54
+
55
+ // src/store.ts
56
+ var EventStore = class {
57
+ buffer;
58
+ sessions = /* @__PURE__ */ new Map();
59
+ sqliteStore = null;
60
+ currentProject = null;
61
+ onEventCallbacks = [];
62
+ redactor = null;
63
+ constructor(capacity = 1e4) {
64
+ this.buffer = new RingBuffer(capacity);
65
+ }
66
+ setRedactor(redactor) {
67
+ this.redactor = redactor;
68
+ }
69
+ get eventCount() {
70
+ return this.buffer.count;
71
+ }
72
+ setSqliteStore(store, project) {
73
+ this.sqliteStore = store;
74
+ this.currentProject = project;
75
+ }
76
+ onEvent(callback) {
77
+ this.onEventCallbacks.push(callback);
78
+ }
79
+ removeEventListener(callback) {
80
+ const idx = this.onEventCallbacks.indexOf(callback);
81
+ if (idx !== -1) this.onEventCallbacks.splice(idx, 1);
82
+ }
83
+ addEvent(event) {
84
+ if (this.redactor?.isEnabled()) {
85
+ event = this.redactor.redactEvent(event);
86
+ }
87
+ this.buffer.push(event);
88
+ if (event.eventType === "session") {
89
+ const se = event;
90
+ this.sessions.set(se.sessionId, {
91
+ sessionId: se.sessionId,
92
+ appName: se.appName,
93
+ connectedAt: se.connectedAt,
94
+ sdkVersion: se.sdkVersion,
95
+ eventCount: 0,
96
+ isConnected: true
97
+ });
98
+ }
99
+ const session = this.sessions.get(event.sessionId);
100
+ if (session) session.eventCount++;
101
+ if (this.sqliteStore && this.currentProject) {
102
+ this.sqliteStore.addEvent(event, this.currentProject);
103
+ }
104
+ for (const cb of this.onEventCallbacks) {
105
+ try {
106
+ cb(event);
107
+ } catch {
108
+ }
109
+ }
110
+ }
111
+ getNetworkRequests(filter = {}) {
112
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
113
+ return this.buffer.query((e) => {
114
+ if (e.eventType !== "network") return false;
115
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
116
+ const ne = e;
117
+ if (ne.timestamp < since) return false;
118
+ if (filter.urlPattern && !ne.url.includes(filter.urlPattern)) return false;
119
+ if (filter.status !== void 0 && ne.status !== filter.status) return false;
120
+ if (filter.method && ne.method.toUpperCase() !== filter.method.toUpperCase())
121
+ return false;
122
+ return true;
123
+ });
124
+ }
125
+ getConsoleMessages(filter = {}) {
126
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
127
+ return this.buffer.query((e) => {
128
+ if (e.eventType !== "console") return false;
129
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
130
+ const ce = e;
131
+ if (ce.timestamp < since) return false;
132
+ if (filter.level && ce.level !== filter.level) return false;
133
+ if (filter.search && !ce.message.toLowerCase().includes(filter.search.toLowerCase()))
134
+ return false;
135
+ return true;
136
+ });
137
+ }
138
+ getSessionInfo() {
139
+ return Array.from(this.sessions.values());
140
+ }
141
+ markDisconnected(sessionId) {
142
+ const s = this.sessions.get(sessionId);
143
+ if (s) s.isConnected = false;
144
+ }
145
+ getEventTimeline(filter = {}) {
146
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
147
+ const typeSet = filter.eventTypes ? new Set(filter.eventTypes) : null;
148
+ return this.buffer.toArray().filter((e) => {
149
+ if (e.timestamp < since) return false;
150
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
151
+ if (typeSet && !typeSet.has(e.eventType)) return false;
152
+ return true;
153
+ });
154
+ }
155
+ getAllEvents(sinceSeconds, sessionId) {
156
+ const since = sinceSeconds ? Date.now() - sinceSeconds * 1e3 : 0;
157
+ return this.buffer.toArray().filter((e) => {
158
+ if (e.timestamp < since) return false;
159
+ if (sessionId && e.sessionId !== sessionId) return false;
160
+ return true;
161
+ });
162
+ }
163
+ getSessionIdsForProject(appName) {
164
+ return Array.from(this.sessions.values()).filter((s) => s.appName === appName).map((s) => s.sessionId);
165
+ }
166
+ getStateEvents(filter = {}) {
167
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
168
+ return this.buffer.query((e) => {
169
+ if (e.eventType !== "state") return false;
170
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
171
+ const se = e;
172
+ if (se.timestamp < since) return false;
173
+ if (filter.storeId && se.storeId !== filter.storeId) return false;
174
+ return true;
175
+ });
176
+ }
177
+ getRenderEvents(filter = {}) {
178
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
179
+ return this.buffer.query((e) => {
180
+ if (e.eventType !== "render") return false;
181
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
182
+ const re = e;
183
+ if (re.timestamp < since) return false;
184
+ if (filter.componentName) {
185
+ const hasMatch = re.profiles.some(
186
+ (p) => p.componentName.toLowerCase().includes(filter.componentName.toLowerCase())
187
+ );
188
+ if (!hasMatch) return false;
189
+ }
190
+ return true;
191
+ });
192
+ }
193
+ getPerformanceMetrics(filter = {}) {
194
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
195
+ return this.buffer.query((e) => {
196
+ if (e.eventType !== "performance") return false;
197
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
198
+ const pe = e;
199
+ if (pe.timestamp < since) return false;
200
+ if (filter.metricName && pe.metricName !== filter.metricName) return false;
201
+ return true;
202
+ });
203
+ }
204
+ getDatabaseEvents(filter = {}) {
205
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
206
+ return this.buffer.query((e) => {
207
+ if (e.eventType !== "database") return false;
208
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
209
+ const de = e;
210
+ if (de.timestamp < since) return false;
211
+ if (filter.table) {
212
+ const hasTable = de.tablesAccessed.some(
213
+ (t) => t.toLowerCase() === filter.table.toLowerCase()
214
+ );
215
+ if (!hasTable) return false;
216
+ }
217
+ if (filter.minDurationMs !== void 0 && de.duration < filter.minDurationMs) return false;
218
+ if (filter.search && !de.query.toLowerCase().includes(filter.search.toLowerCase()))
219
+ return false;
220
+ if (filter.operation && de.operation !== filter.operation) return false;
221
+ if (filter.source && de.source !== filter.source) return false;
222
+ return true;
223
+ });
224
+ }
225
+ getCustomEvents(filter = {}) {
226
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
227
+ return this.buffer.query((e) => {
228
+ if (e.eventType !== "custom") return false;
229
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
230
+ const ce = e;
231
+ if (ce.timestamp < since) return false;
232
+ if (filter.name && ce.name !== filter.name) return false;
233
+ return true;
234
+ });
235
+ }
236
+ // ============================================================
237
+ // Recon event queries — returns the most recent event of each type
238
+ // ============================================================
239
+ getLatestReconEvent(eventType, filter = {}) {
240
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
241
+ const results = this.buffer.query((e) => {
242
+ if (e.eventType !== eventType) return false;
243
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
244
+ if (e.timestamp < since) return false;
245
+ if (filter.url) {
246
+ const re = e;
247
+ if (re.url && !re.url.includes(filter.url)) return false;
248
+ }
249
+ return true;
250
+ });
251
+ return results[0] ?? null;
252
+ }
253
+ getReconEvents(eventType, filter = {}) {
254
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
255
+ return this.buffer.query((e) => {
256
+ if (e.eventType !== eventType) return false;
257
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
258
+ if (e.timestamp < since) return false;
259
+ if (filter.url) {
260
+ const re = e;
261
+ if (re.url && !re.url.includes(filter.url)) return false;
262
+ }
263
+ return true;
264
+ });
265
+ }
266
+ getReconMetadata(filter = {}) {
267
+ return this.getLatestReconEvent("recon_metadata", filter);
268
+ }
269
+ getReconDesignTokens(filter = {}) {
270
+ return this.getLatestReconEvent("recon_design_tokens", filter);
271
+ }
272
+ getReconFonts(filter = {}) {
273
+ return this.getLatestReconEvent("recon_fonts", filter);
274
+ }
275
+ getReconLayoutTree(filter = {}) {
276
+ return this.getLatestReconEvent("recon_layout_tree", filter);
277
+ }
278
+ getReconAccessibility(filter = {}) {
279
+ return this.getLatestReconEvent("recon_accessibility", filter);
280
+ }
281
+ getReconComputedStyles(filter = {}) {
282
+ return this.getReconEvents("recon_computed_styles", filter);
283
+ }
284
+ getReconElementSnapshots(filter = {}) {
285
+ return this.getReconEvents("recon_element_snapshot", filter);
286
+ }
287
+ getReconAssetInventory(filter = {}) {
288
+ return this.getLatestReconEvent("recon_asset_inventory", filter);
289
+ }
290
+ clear() {
291
+ const count = this.buffer.count;
292
+ this.buffer.clear();
293
+ this.sessions.clear();
294
+ return { clearedCount: count };
295
+ }
296
+ };
297
+
298
+ // src/sqlite-store.ts
299
+ import Database from "better-sqlite3";
300
+ var SqliteStore = class {
301
+ db;
302
+ writeBuffer = [];
303
+ flushTimer = null;
304
+ batchSize;
305
+ insertEventStmt;
306
+ insertSessionStmt;
307
+ updateSessionDisconnectedStmt;
308
+ constructor(options) {
309
+ this.db = new Database(options.dbPath);
310
+ this.batchSize = options.batchSize ?? 50;
311
+ if (options.walMode !== false) {
312
+ this.db.pragma("journal_mode = WAL");
313
+ }
314
+ this.db.pragma("synchronous = NORMAL");
315
+ this.createSchema();
316
+ this.insertEventStmt = this.db.prepare(`
317
+ INSERT INTO events (event_id, session_id, project, event_type, timestamp, data)
318
+ VALUES (?, ?, ?, ?, ?, ?)
319
+ `);
320
+ this.insertSessionStmt = this.db.prepare(`
321
+ INSERT OR REPLACE INTO sessions (
322
+ session_id, project, app_name, connected_at, sdk_version,
323
+ event_count, is_connected, build_meta
324
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
325
+ `);
326
+ this.updateSessionDisconnectedStmt = this.db.prepare(`
327
+ UPDATE sessions SET is_connected = 0, disconnected_at = ? WHERE session_id = ?
328
+ `);
329
+ const flushInterval = options.flushIntervalMs ?? 100;
330
+ this.flushTimer = setInterval(() => this.flush(), flushInterval);
331
+ }
332
+ createSchema() {
333
+ this.db.exec(`
334
+ CREATE TABLE IF NOT EXISTS events (
335
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
336
+ event_id TEXT NOT NULL UNIQUE,
337
+ session_id TEXT NOT NULL,
338
+ project TEXT NOT NULL,
339
+ event_type TEXT NOT NULL,
340
+ timestamp INTEGER NOT NULL,
341
+ data TEXT NOT NULL
342
+ );
343
+
344
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
345
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
346
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
347
+ CREATE INDEX IF NOT EXISTS idx_events_type_timestamp ON events(event_type, timestamp);
348
+ CREATE INDEX IF NOT EXISTS idx_events_project ON events(project);
349
+
350
+ CREATE TABLE IF NOT EXISTS sessions (
351
+ session_id TEXT PRIMARY KEY,
352
+ project TEXT NOT NULL,
353
+ app_name TEXT NOT NULL,
354
+ connected_at INTEGER NOT NULL,
355
+ disconnected_at INTEGER,
356
+ sdk_version TEXT NOT NULL,
357
+ event_count INTEGER DEFAULT 0,
358
+ is_connected INTEGER DEFAULT 1,
359
+ build_meta TEXT
360
+ );
361
+
362
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
363
+
364
+ CREATE TABLE IF NOT EXISTS session_snapshots (
365
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
366
+ session_id TEXT NOT NULL,
367
+ project TEXT NOT NULL,
368
+ label TEXT,
369
+ metrics TEXT NOT NULL,
370
+ created_at INTEGER NOT NULL,
371
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id)
372
+ );
373
+
374
+ CREATE INDEX IF NOT EXISTS idx_snapshots_session ON session_snapshots(session_id);
375
+ CREATE INDEX IF NOT EXISTS idx_snapshots_project ON session_snapshots(project, created_at);
376
+ `);
377
+ this.migrateSessionMetrics();
378
+ }
379
+ // --- Write Operations ---
380
+ addEvent(event, project) {
381
+ this.writeBuffer.push({ event, project });
382
+ if (this.writeBuffer.length >= this.batchSize) {
383
+ this.flush();
384
+ }
385
+ }
386
+ flush() {
387
+ if (this.writeBuffer.length === 0) return;
388
+ const batch = this.writeBuffer.splice(0);
389
+ const insertMany = this.db.transaction(() => {
390
+ for (const { event, project } of batch) {
391
+ try {
392
+ this.insertEventStmt.run(
393
+ event.eventId,
394
+ event.sessionId,
395
+ project,
396
+ event.eventType,
397
+ event.timestamp,
398
+ JSON.stringify(event)
399
+ );
400
+ } catch {
401
+ }
402
+ }
403
+ });
404
+ try {
405
+ insertMany();
406
+ } catch (err) {
407
+ console.error("[RuntimeScope] SQLite flush error:", err.message);
408
+ }
409
+ }
410
+ saveSession(info) {
411
+ this.insertSessionStmt.run(
412
+ info.sessionId,
413
+ info.project,
414
+ info.appName,
415
+ info.connectedAt,
416
+ info.sdkVersion,
417
+ info.eventCount,
418
+ info.isConnected ? 1 : 0,
419
+ info.buildMeta ? JSON.stringify(info.buildMeta) : null
420
+ );
421
+ }
422
+ updateSessionDisconnected(sessionId, disconnectedAt) {
423
+ this.updateSessionDisconnectedStmt.run(disconnectedAt, sessionId);
424
+ }
425
+ saveSessionMetrics(sessionId, project, metrics, label) {
426
+ this.db.prepare(`
427
+ INSERT INTO session_snapshots (session_id, project, label, metrics, created_at)
428
+ VALUES (?, ?, ?, ?, ?)
429
+ `).run(sessionId, project, label ?? null, JSON.stringify(metrics), Date.now());
430
+ }
431
+ // --- Read Operations ---
432
+ getEvents(filter) {
433
+ const conditions = [];
434
+ const params = [];
435
+ if (filter.project) {
436
+ conditions.push("project = ?");
437
+ params.push(filter.project);
438
+ }
439
+ if (filter.sessionId) {
440
+ conditions.push("session_id = ?");
441
+ params.push(filter.sessionId);
442
+ }
443
+ if (filter.eventTypes && filter.eventTypes.length > 0) {
444
+ const placeholders = filter.eventTypes.map(() => "?").join(", ");
445
+ conditions.push(`event_type IN (${placeholders})`);
446
+ params.push(...filter.eventTypes);
447
+ }
448
+ if (filter.since) {
449
+ conditions.push("timestamp >= ?");
450
+ params.push(filter.since);
451
+ }
452
+ if (filter.until) {
453
+ conditions.push("timestamp <= ?");
454
+ params.push(filter.until);
455
+ }
456
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
457
+ params.push(filter.limit ?? 1e3);
458
+ params.push(filter.offset ?? 0);
459
+ const rows = this.db.prepare(`SELECT data FROM events ${where} ORDER BY timestamp ASC LIMIT ? OFFSET ?`).all(...params);
460
+ return rows.map((row) => JSON.parse(row.data));
461
+ }
462
+ getEventCount(filter) {
463
+ const conditions = [];
464
+ const params = [];
465
+ if (filter.project) {
466
+ conditions.push("project = ?");
467
+ params.push(filter.project);
468
+ }
469
+ if (filter.sessionId) {
470
+ conditions.push("session_id = ?");
471
+ params.push(filter.sessionId);
472
+ }
473
+ if (filter.eventTypes && filter.eventTypes.length > 0) {
474
+ const placeholders = filter.eventTypes.map(() => "?").join(", ");
475
+ conditions.push(`event_type IN (${placeholders})`);
476
+ params.push(...filter.eventTypes);
477
+ }
478
+ if (filter.since) {
479
+ conditions.push("timestamp >= ?");
480
+ params.push(filter.since);
481
+ }
482
+ if (filter.until) {
483
+ conditions.push("timestamp <= ?");
484
+ params.push(filter.until);
485
+ }
486
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
487
+ const row = this.db.prepare(`SELECT COUNT(*) as count FROM events ${where}`).get(...params);
488
+ return row.count;
489
+ }
490
+ getSessions(project, limit = 50) {
491
+ const rows = this.db.prepare(`
492
+ SELECT session_id, project, app_name, connected_at, disconnected_at,
493
+ sdk_version, event_count, is_connected, build_meta
494
+ FROM sessions
495
+ WHERE project = ?
496
+ ORDER BY connected_at DESC
497
+ LIMIT ?
498
+ `).all(project, limit);
499
+ return rows.map((row) => ({
500
+ sessionId: row.session_id,
501
+ project: row.project,
502
+ appName: row.app_name,
503
+ connectedAt: row.connected_at,
504
+ disconnectedAt: row.disconnected_at ?? void 0,
505
+ sdkVersion: row.sdk_version,
506
+ eventCount: row.event_count,
507
+ isConnected: row.is_connected === 1,
508
+ buildMeta: row.build_meta ? JSON.parse(row.build_meta) : void 0
509
+ }));
510
+ }
511
+ getSessionMetrics(sessionId) {
512
+ const row = this.db.prepare("SELECT metrics FROM session_snapshots WHERE session_id = ? ORDER BY created_at DESC LIMIT 1").get(sessionId);
513
+ return row ? JSON.parse(row.metrics) : null;
514
+ }
515
+ getSessionSnapshots(sessionId) {
516
+ const rows = this.db.prepare(`
517
+ SELECT id, session_id, project, label, metrics, created_at
518
+ FROM session_snapshots
519
+ WHERE session_id = ?
520
+ ORDER BY created_at ASC
521
+ `).all(sessionId);
522
+ return rows.map((row) => ({
523
+ id: row.id,
524
+ sessionId: row.session_id,
525
+ project: row.project,
526
+ label: row.label ?? void 0,
527
+ metrics: JSON.parse(row.metrics),
528
+ createdAt: row.created_at
529
+ }));
530
+ }
531
+ getSnapshotById(snapshotId) {
532
+ const row = this.db.prepare("SELECT id, session_id, project, label, metrics, created_at FROM session_snapshots WHERE id = ?").get(snapshotId);
533
+ if (!row) return null;
534
+ return {
535
+ id: row.id,
536
+ sessionId: row.session_id,
537
+ project: row.project,
538
+ label: row.label ?? void 0,
539
+ metrics: JSON.parse(row.metrics),
540
+ createdAt: row.created_at
541
+ };
542
+ }
543
+ getEventsByType(project, eventType, sinceMs) {
544
+ const conditions = ["project = ?", "event_type = ?"];
545
+ const params = [project, eventType];
546
+ if (sinceMs) {
547
+ conditions.push("timestamp >= ?");
548
+ params.push(sinceMs);
549
+ }
550
+ const where = conditions.join(" AND ");
551
+ const rows = this.db.prepare(`SELECT data FROM events WHERE ${where} ORDER BY timestamp ASC LIMIT 1000`).all(...params);
552
+ return rows.map((row) => JSON.parse(row.data));
553
+ }
554
+ // --- Migration ---
555
+ migrateSessionMetrics() {
556
+ const hasOldTable = this.db.prepare(
557
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='session_metrics'"
558
+ ).get();
559
+ if (hasOldTable) {
560
+ this.db.exec(`
561
+ INSERT OR IGNORE INTO session_snapshots (session_id, project, label, metrics, created_at)
562
+ SELECT session_id, project, 'auto-disconnect', metrics, created_at
563
+ FROM session_metrics
564
+ `);
565
+ this.db.exec("DROP TABLE session_metrics");
566
+ }
567
+ }
568
+ // --- Maintenance ---
569
+ deleteOldEvents(beforeTimestamp) {
570
+ const result = this.db.prepare("DELETE FROM events WHERE timestamp < ?").run(beforeTimestamp);
571
+ return result.changes;
572
+ }
573
+ vacuum() {
574
+ this.db.exec("VACUUM");
575
+ }
576
+ close() {
577
+ if (this.flushTimer) {
578
+ clearInterval(this.flushTimer);
579
+ this.flushTimer = null;
580
+ }
581
+ this.flush();
582
+ this.db.close();
583
+ }
584
+ };
585
+
586
+ // src/rate-limiter.ts
587
+ var SessionRateLimiter = class {
588
+ windows = /* @__PURE__ */ new Map();
589
+ maxPerSecond;
590
+ maxPerMinute;
591
+ _droppedTotal = 0;
592
+ constructor(config = {}) {
593
+ this.maxPerSecond = config.maxEventsPerSecond ?? Infinity;
594
+ this.maxPerMinute = config.maxEventsPerMinute ?? Infinity;
595
+ }
596
+ get droppedTotal() {
597
+ return this._droppedTotal;
598
+ }
599
+ isEnabled() {
600
+ return this.maxPerSecond !== Infinity || this.maxPerMinute !== Infinity;
601
+ }
602
+ /** Returns true if the event should be accepted, false if rate-limited. */
603
+ allow(sessionId) {
604
+ if (!this.isEnabled()) return true;
605
+ if (this.windows.size > 1e4 && !this.windows.has(sessionId)) {
606
+ this.prune(6e4);
607
+ if (this.windows.size > 1e4) {
608
+ this._droppedTotal++;
609
+ return false;
610
+ }
611
+ }
612
+ const now = Date.now();
613
+ let w = this.windows.get(sessionId);
614
+ if (!w) {
615
+ w = {
616
+ secondCount: 0,
617
+ secondStart: now,
618
+ minuteCount: 0,
619
+ minuteStart: now,
620
+ lastWarning: 0
621
+ };
622
+ this.windows.set(sessionId, w);
623
+ }
624
+ if (now - w.secondStart >= 1e3) {
625
+ w.secondCount = 0;
626
+ w.secondStart = now;
627
+ }
628
+ if (now - w.minuteStart >= 6e4) {
629
+ w.minuteCount = 0;
630
+ w.minuteStart = now;
631
+ }
632
+ if (w.secondCount >= this.maxPerSecond) {
633
+ this._droppedTotal++;
634
+ this.maybeWarn(sessionId, w, now);
635
+ return false;
636
+ }
637
+ if (w.minuteCount >= this.maxPerMinute) {
638
+ this._droppedTotal++;
639
+ this.maybeWarn(sessionId, w, now);
640
+ return false;
641
+ }
642
+ w.secondCount++;
643
+ w.minuteCount++;
644
+ return true;
645
+ }
646
+ /** Allow a batch of N events. Returns the number accepted. */
647
+ allowBatch(sessionId, count) {
648
+ let accepted = 0;
649
+ for (let i = 0; i < count; i++) {
650
+ if (this.allow(sessionId)) accepted++;
651
+ else break;
652
+ }
653
+ return accepted;
654
+ }
655
+ /** Remove tracking for sessions that haven't been seen in maxAgeMs. */
656
+ prune(maxAgeMs = 3e5) {
657
+ const cutoff = Date.now() - maxAgeMs;
658
+ for (const [id, w] of this.windows) {
659
+ if (w.minuteStart < cutoff) {
660
+ this.windows.delete(id);
661
+ }
662
+ }
663
+ }
664
+ maybeWarn(sessionId, w, now) {
665
+ if (now - w.lastWarning >= 6e4) {
666
+ w.lastWarning = now;
667
+ console.error(
668
+ `[RuntimeScope] Rate limiting session ${sessionId.slice(0, 8)}... (dropped ${this._droppedTotal} total)`
669
+ );
670
+ }
671
+ }
672
+ };
673
+
674
+ // src/tls.ts
675
+ import { readFileSync } from "fs";
676
+ function loadTlsOptions(config) {
677
+ return {
678
+ cert: readFileSync(config.certPath, "utf-8"),
679
+ key: readFileSync(config.keyPath, "utf-8"),
680
+ ...config.caPath ? { ca: readFileSync(config.caPath, "utf-8") } : {}
681
+ };
682
+ }
683
+ function resolveTlsConfig() {
684
+ const certPath = process.env.RUNTIMESCOPE_TLS_CERT;
685
+ const keyPath = process.env.RUNTIMESCOPE_TLS_KEY;
686
+ if (!certPath || !keyPath) return null;
687
+ return {
688
+ certPath,
689
+ keyPath,
690
+ caPath: process.env.RUNTIMESCOPE_TLS_CA
691
+ };
692
+ }
693
+
694
+ // src/server.ts
695
+ import { createServer as createHttpsServer } from "https";
696
+ import { WebSocketServer } from "ws";
697
+ var CollectorServer = class {
698
+ wss = null;
699
+ store;
700
+ projectManager;
701
+ authManager = null;
702
+ rateLimiter;
703
+ clients = /* @__PURE__ */ new Map();
704
+ pendingHandshakes = /* @__PURE__ */ new Set();
705
+ pendingCommands = /* @__PURE__ */ new Map();
706
+ sqliteStores = /* @__PURE__ */ new Map();
707
+ connectCallbacks = [];
708
+ disconnectCallbacks = [];
709
+ pruneTimer = null;
710
+ tlsConfig = null;
711
+ constructor(options = {}) {
712
+ this.store = new EventStore(options.bufferSize ?? 1e4);
713
+ this.projectManager = options.projectManager ?? null;
714
+ this.authManager = options.authManager ?? null;
715
+ this.rateLimiter = new SessionRateLimiter(options.rateLimits ?? {});
716
+ this.tlsConfig = options.tls ?? null;
717
+ if (this.projectManager) {
718
+ this.projectManager.ensureGlobalDir();
719
+ }
720
+ if (this.rateLimiter.isEnabled()) {
721
+ this.pruneTimer = setInterval(() => this.rateLimiter.prune(), 6e4);
722
+ }
723
+ }
724
+ getStore() {
725
+ return this.store;
726
+ }
727
+ getPort() {
728
+ const addr = this.wss?.address();
729
+ return addr && typeof addr === "object" ? addr.port : null;
730
+ }
731
+ getClientCount() {
732
+ return this.clients.size;
733
+ }
734
+ getProjectManager() {
735
+ return this.projectManager;
736
+ }
737
+ getSqliteStore(projectName) {
738
+ return this.sqliteStores.get(projectName);
739
+ }
740
+ getSqliteStores() {
741
+ return this.sqliteStores;
742
+ }
743
+ getRateLimiter() {
744
+ return this.rateLimiter;
745
+ }
746
+ onConnect(cb) {
747
+ this.connectCallbacks.push(cb);
748
+ }
749
+ onDisconnect(cb) {
750
+ this.disconnectCallbacks.push(cb);
751
+ }
752
+ start(options = {}) {
753
+ const port = options.port ?? 9090;
754
+ const host = options.host ?? "127.0.0.1";
755
+ const maxRetries = options.maxRetries ?? 5;
756
+ const retryDelayMs = options.retryDelayMs ?? 1e3;
757
+ const tls = options.tls ?? this.tlsConfig;
758
+ return this.tryStart(port, host, maxRetries, retryDelayMs, tls);
759
+ }
760
+ tryStart(port, host, retriesLeft, retryDelayMs, tls) {
761
+ return new Promise((resolve2, reject) => {
762
+ let wss;
763
+ if (tls) {
764
+ const httpsServer = createHttpsServer(loadTlsOptions(tls));
765
+ wss = new WebSocketServer({ server: httpsServer });
766
+ httpsServer.on("listening", () => {
767
+ this.wss = wss;
768
+ this.setupConnectionHandler(wss);
769
+ console.error(`[RuntimeScope] Collector listening on wss://${host}:${port}`);
770
+ resolve2();
771
+ });
772
+ httpsServer.on("error", (err) => {
773
+ httpsServer.close();
774
+ this.handleStartError(err, port, host, retriesLeft, retryDelayMs, tls, resolve2, reject);
775
+ });
776
+ httpsServer.listen(port, host);
777
+ } else {
778
+ wss = new WebSocketServer({ port, host });
779
+ wss.on("listening", () => {
780
+ this.wss = wss;
781
+ this.setupConnectionHandler(wss);
782
+ console.error(`[RuntimeScope] Collector listening on ws://${host}:${port}`);
783
+ resolve2();
784
+ });
785
+ wss.on("error", (err) => {
786
+ wss.close();
787
+ this.handleStartError(err, port, host, retriesLeft, retryDelayMs, tls, resolve2, reject);
788
+ });
789
+ }
790
+ });
791
+ }
792
+ handleStartError(err, port, host, retriesLeft, retryDelayMs, tls, resolve2, reject) {
793
+ if (err.code === "EADDRINUSE" && retriesLeft > 0) {
794
+ console.error(
795
+ `[RuntimeScope] Port ${port} in use, retrying in ${retryDelayMs}ms (${retriesLeft} attempts left)...`
796
+ );
797
+ setTimeout(() => {
798
+ this.tryStart(port, host, retriesLeft - 1, retryDelayMs, tls).then(resolve2).catch(reject);
799
+ }, retryDelayMs);
800
+ } else {
801
+ console.error("[RuntimeScope] WebSocket server error:", err.message);
802
+ reject(err);
803
+ }
804
+ }
805
+ ensureSqliteStore(projectName) {
806
+ if (!this.projectManager) return null;
807
+ let sqliteStore = this.sqliteStores.get(projectName);
808
+ if (!sqliteStore) {
809
+ try {
810
+ this.projectManager.ensureProjectDir(projectName);
811
+ const dbPath = this.projectManager.getProjectDbPath(projectName);
812
+ sqliteStore = new SqliteStore({ dbPath });
813
+ this.sqliteStores.set(projectName, sqliteStore);
814
+ this.store.setSqliteStore(sqliteStore, projectName);
815
+ console.error(`[RuntimeScope] SQLite store opened for project "${projectName}"`);
816
+ } catch (err) {
817
+ console.error(
818
+ `[RuntimeScope] Failed to open SQLite for "${projectName}":`,
819
+ err.message
820
+ );
821
+ return null;
822
+ }
823
+ }
824
+ return sqliteStore;
825
+ }
826
+ setupConnectionHandler(wss) {
827
+ wss.on("connection", (ws) => {
828
+ if (this.authManager?.isEnabled()) {
829
+ this.pendingHandshakes.add(ws);
830
+ const authTimeout = setTimeout(() => {
831
+ if (this.pendingHandshakes.has(ws)) {
832
+ this.pendingHandshakes.delete(ws);
833
+ try {
834
+ ws.send(JSON.stringify({
835
+ type: "error",
836
+ payload: { code: "AUTH_TIMEOUT", message: "Handshake timeout" },
837
+ timestamp: Date.now()
838
+ }));
839
+ } catch {
840
+ }
841
+ ws.close(4001, "Authentication timeout");
842
+ }
843
+ }, 5e3);
844
+ ws.on("close", () => {
845
+ clearTimeout(authTimeout);
846
+ this.pendingHandshakes.delete(ws);
847
+ });
848
+ }
849
+ ws.on("message", (data) => {
850
+ try {
851
+ const msg = JSON.parse(data.toString());
852
+ this.handleMessage(ws, msg);
853
+ } catch {
854
+ console.error("[RuntimeScope] Malformed WebSocket message, ignoring");
855
+ }
856
+ });
857
+ ws.on("close", () => {
858
+ const clientInfo = this.clients.get(ws);
859
+ if (clientInfo) {
860
+ this.store.markDisconnected(clientInfo.sessionId);
861
+ const sqliteStore = this.sqliteStores.get(clientInfo.projectName);
862
+ if (sqliteStore) {
863
+ sqliteStore.updateSessionDisconnected(clientInfo.sessionId, Date.now());
864
+ }
865
+ console.error(`[RuntimeScope] Session ${clientInfo.sessionId} disconnected`);
866
+ for (const cb of this.disconnectCallbacks) {
867
+ try {
868
+ cb(clientInfo.sessionId, clientInfo.projectName);
869
+ } catch {
870
+ }
871
+ }
872
+ }
873
+ this.clients.delete(ws);
874
+ });
875
+ ws.on("error", (err) => {
876
+ console.error("[RuntimeScope] WebSocket client error:", err.message);
877
+ });
878
+ });
879
+ }
880
+ handleMessage(ws, msg) {
881
+ switch (msg.type) {
882
+ case "handshake": {
883
+ const payload = msg.payload;
884
+ if (this.authManager?.isEnabled()) {
885
+ if (!this.authManager.isAuthorized(payload.authToken)) {
886
+ try {
887
+ ws.send(JSON.stringify({
888
+ type: "error",
889
+ payload: { code: "AUTH_FAILED", message: "Invalid or missing API key" },
890
+ timestamp: Date.now()
891
+ }));
892
+ } catch {
893
+ }
894
+ ws.close(4001, "Authentication failed");
895
+ return;
896
+ }
897
+ this.pendingHandshakes.delete(ws);
898
+ }
899
+ const projectName = payload.appName;
900
+ this.clients.set(ws, {
901
+ sessionId: payload.sessionId,
902
+ projectName
903
+ });
904
+ const sqliteStore = this.ensureSqliteStore(projectName);
905
+ if (sqliteStore) {
906
+ const sessionInfo = {
907
+ sessionId: payload.sessionId,
908
+ project: projectName,
909
+ appName: payload.appName,
910
+ connectedAt: msg.timestamp,
911
+ sdkVersion: payload.sdkVersion,
912
+ eventCount: 0,
913
+ isConnected: true
914
+ };
915
+ sqliteStore.saveSession(sessionInfo);
916
+ }
917
+ console.error(
918
+ `[RuntimeScope] Session ${payload.sessionId} connected (${payload.appName} v${payload.sdkVersion})`
919
+ );
920
+ for (const cb of this.connectCallbacks) {
921
+ try {
922
+ cb(payload.sessionId, projectName);
923
+ } catch {
924
+ }
925
+ }
926
+ break;
927
+ }
928
+ case "event": {
929
+ if (this.pendingHandshakes.has(ws)) return;
930
+ const clientInfo = this.clients.get(ws);
931
+ const payload = msg.payload;
932
+ if (Array.isArray(payload.events)) {
933
+ for (const event of payload.events) {
934
+ if (clientInfo && !this.rateLimiter.allow(clientInfo.sessionId)) {
935
+ break;
936
+ }
937
+ this.store.addEvent(event);
938
+ }
939
+ }
940
+ break;
941
+ }
942
+ case "command_response": {
943
+ const resp = msg;
944
+ const pending = this.pendingCommands.get(resp.requestId);
945
+ if (pending) {
946
+ clearTimeout(pending.timer);
947
+ this.pendingCommands.delete(resp.requestId);
948
+ pending.resolve(resp.payload);
949
+ }
950
+ break;
951
+ }
952
+ case "heartbeat":
953
+ break;
954
+ }
955
+ }
956
+ /** Find the WebSocket for a given sessionId */
957
+ findWsBySessionId(sessionId) {
958
+ for (const [ws, info] of this.clients) {
959
+ if (info.sessionId === sessionId) return ws;
960
+ }
961
+ return void 0;
962
+ }
963
+ /** Get the first connected session ID (for single-app use) */
964
+ getFirstSessionId() {
965
+ for (const [, info] of this.clients) {
966
+ return info.sessionId;
967
+ }
968
+ return void 0;
969
+ }
970
+ /** Get the project name for a session */
971
+ getProjectForSession(sessionId) {
972
+ for (const [, info] of this.clients) {
973
+ if (info.sessionId === sessionId) return info.projectName;
974
+ }
975
+ return void 0;
976
+ }
977
+ /** Get all connected session IDs with their project names */
978
+ getConnectedSessions() {
979
+ const sessions = [];
980
+ for (const [, info] of this.clients) {
981
+ sessions.push({ sessionId: info.sessionId, projectName: info.projectName });
982
+ }
983
+ return sessions;
984
+ }
985
+ /** Send a command to the SDK and await the response */
986
+ sendCommand(sessionId, command, timeoutMs = 1e4) {
987
+ return new Promise((resolve2, reject) => {
988
+ const ws = this.findWsBySessionId(sessionId);
989
+ if (!ws || ws.readyState !== 1) {
990
+ reject(new Error(`No active WebSocket for session ${sessionId}`));
991
+ return;
992
+ }
993
+ const timer = setTimeout(() => {
994
+ this.pendingCommands.delete(command.requestId);
995
+ reject(new Error(`Command ${command.command} timed out after ${timeoutMs}ms`));
996
+ }, timeoutMs);
997
+ this.pendingCommands.set(command.requestId, { resolve: resolve2, reject, timer });
998
+ try {
999
+ ws.send(JSON.stringify({
1000
+ type: "command",
1001
+ payload: command,
1002
+ timestamp: Date.now(),
1003
+ sessionId
1004
+ }));
1005
+ } catch (err) {
1006
+ clearTimeout(timer);
1007
+ this.pendingCommands.delete(command.requestId);
1008
+ reject(err);
1009
+ }
1010
+ });
1011
+ }
1012
+ stop() {
1013
+ if (this.pruneTimer) {
1014
+ clearInterval(this.pruneTimer);
1015
+ this.pruneTimer = null;
1016
+ }
1017
+ for (const [name, sqliteStore] of this.sqliteStores) {
1018
+ try {
1019
+ sqliteStore.close();
1020
+ console.error(`[RuntimeScope] SQLite store closed for "${name}"`);
1021
+ } catch {
1022
+ }
1023
+ }
1024
+ this.sqliteStores.clear();
1025
+ if (this.wss) {
1026
+ this.wss.close();
1027
+ this.wss = null;
1028
+ console.error("[RuntimeScope] Collector stopped");
1029
+ }
1030
+ }
1031
+ };
1032
+
1033
+ // src/project-manager.ts
1034
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync, existsSync, readdirSync } from "fs";
1035
+ import { join } from "path";
1036
+ import { homedir } from "os";
1037
+ var DEFAULT_GLOBAL_CONFIG = {
1038
+ defaultPort: 9090,
1039
+ bufferSize: 1e4,
1040
+ httpPort: 9091
1041
+ };
1042
+ var ProjectManager = class {
1043
+ baseDir;
1044
+ constructor(baseDir) {
1045
+ this.baseDir = baseDir ?? join(homedir(), ".runtimescope");
1046
+ }
1047
+ get rootDir() {
1048
+ return this.baseDir;
1049
+ }
1050
+ // --- Directory helpers ---
1051
+ getProjectDir(projectName) {
1052
+ const safe = projectName.replace(/[^a-zA-Z0-9_.-]/g, "_");
1053
+ if (!safe || safe === "." || safe === "..") {
1054
+ return join(this.baseDir, "projects", "_invalid");
1055
+ }
1056
+ return join(this.baseDir, "projects", safe);
1057
+ }
1058
+ getProjectDbPath(projectName) {
1059
+ return join(this.getProjectDir(projectName), "events.db");
1060
+ }
1061
+ // --- Lifecycle (idempotent) ---
1062
+ ensureGlobalDir() {
1063
+ this.mkdirp(this.baseDir);
1064
+ this.mkdirp(join(this.baseDir, "projects"));
1065
+ const configPath = join(this.baseDir, "config.json");
1066
+ if (!existsSync(configPath)) {
1067
+ this.writeJson(configPath, DEFAULT_GLOBAL_CONFIG);
1068
+ }
1069
+ }
1070
+ ensureProjectDir(projectName) {
1071
+ const projectDir = this.getProjectDir(projectName);
1072
+ this.mkdirp(projectDir);
1073
+ const configPath = join(projectDir, "config.json");
1074
+ if (!existsSync(configPath)) {
1075
+ const config = {
1076
+ name: projectName,
1077
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1078
+ settings: {
1079
+ retentionDays: 30
1080
+ }
1081
+ };
1082
+ this.writeJson(configPath, config);
1083
+ }
1084
+ }
1085
+ // --- Config ---
1086
+ getGlobalConfig() {
1087
+ const configPath = join(this.baseDir, "config.json");
1088
+ if (!existsSync(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
1089
+ return { ...DEFAULT_GLOBAL_CONFIG, ...this.readJson(configPath) };
1090
+ }
1091
+ saveGlobalConfig(config) {
1092
+ this.writeJson(join(this.baseDir, "config.json"), config);
1093
+ }
1094
+ getProjectConfig(projectName) {
1095
+ const configPath = join(this.getProjectDir(projectName), "config.json");
1096
+ if (!existsSync(configPath)) return null;
1097
+ return this.readJson(configPath);
1098
+ }
1099
+ saveProjectConfig(projectName, config) {
1100
+ this.writeJson(join(this.getProjectDir(projectName), "config.json"), config);
1101
+ }
1102
+ getInfrastructureConfig(projectName) {
1103
+ const jsonPath = join(this.getProjectDir(projectName), "infrastructure.json");
1104
+ if (existsSync(jsonPath)) {
1105
+ const config = this.readJson(jsonPath);
1106
+ return this.resolveConfigEnvVars(config);
1107
+ }
1108
+ const yamlPath = join(this.getProjectDir(projectName), "infrastructure.yaml");
1109
+ if (existsSync(yamlPath)) {
1110
+ try {
1111
+ const content = readFileSync2(yamlPath, "utf-8");
1112
+ return this.resolveConfigEnvVars(this.parseSimpleYaml(content));
1113
+ } catch {
1114
+ return null;
1115
+ }
1116
+ }
1117
+ return null;
1118
+ }
1119
+ getClaudeInstructions(projectName) {
1120
+ const filePath = join(this.getProjectDir(projectName), "claude-instructions.md");
1121
+ if (!existsSync(filePath)) return null;
1122
+ return readFileSync2(filePath, "utf-8");
1123
+ }
1124
+ // --- Discovery ---
1125
+ listProjects() {
1126
+ const projectsDir = join(this.baseDir, "projects");
1127
+ if (!existsSync(projectsDir)) return [];
1128
+ return readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1129
+ }
1130
+ projectExists(projectName) {
1131
+ return existsSync(this.getProjectDir(projectName));
1132
+ }
1133
+ // --- Environment variable resolution ---
1134
+ resolveEnvVars(value) {
1135
+ return value.replace(/\$\{([^}]+)\}/g, (_match, varName) => {
1136
+ return process.env[varName] ?? "";
1137
+ });
1138
+ }
1139
+ // --- Private helpers ---
1140
+ mkdirp(dir) {
1141
+ if (!existsSync(dir)) {
1142
+ mkdirSync(dir, { recursive: true });
1143
+ }
1144
+ }
1145
+ readJson(path) {
1146
+ const content = readFileSync2(path, "utf-8");
1147
+ return JSON.parse(content);
1148
+ }
1149
+ writeJson(path, data) {
1150
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
1151
+ }
1152
+ resolveConfigEnvVars(config) {
1153
+ const resolve2 = (obj) => {
1154
+ if (typeof obj === "string") return this.resolveEnvVars(obj);
1155
+ if (Array.isArray(obj)) return obj.map(resolve2);
1156
+ if (obj && typeof obj === "object") {
1157
+ const result = {};
1158
+ for (const [key, value] of Object.entries(obj)) {
1159
+ result[key] = resolve2(value);
1160
+ }
1161
+ return result;
1162
+ }
1163
+ return obj;
1164
+ };
1165
+ return resolve2(config);
1166
+ }
1167
+ /**
1168
+ * Minimal YAML parser for simple infrastructure config files.
1169
+ * Handles flat key-value pairs and one level of nesting.
1170
+ * For full YAML support, install js-yaml.
1171
+ */
1172
+ parseSimpleYaml(content) {
1173
+ try {
1174
+ const yaml = __require("js-yaml");
1175
+ return yaml.load(content);
1176
+ } catch {
1177
+ try {
1178
+ return JSON.parse(content);
1179
+ } catch {
1180
+ return {};
1181
+ }
1182
+ }
1183
+ }
1184
+ };
1185
+
1186
+ // src/auth.ts
1187
+ import { randomBytes, timingSafeEqual } from "crypto";
1188
+ var AuthManager = class {
1189
+ keys = /* @__PURE__ */ new Map();
1190
+ enabled;
1191
+ constructor(config = {}) {
1192
+ this.enabled = config.enabled ?? false;
1193
+ for (const entry of config.apiKeys ?? []) {
1194
+ this.keys.set(entry.key, entry);
1195
+ }
1196
+ }
1197
+ isEnabled() {
1198
+ return this.enabled;
1199
+ }
1200
+ /** Validate an API key. Returns the entry if valid, null if invalid. */
1201
+ validate(key) {
1202
+ if (!this.enabled) return null;
1203
+ if (!key) return null;
1204
+ for (const [storedKey, entry] of this.keys) {
1205
+ if (this.safeCompare(key, storedKey)) {
1206
+ return entry;
1207
+ }
1208
+ }
1209
+ return null;
1210
+ }
1211
+ /** Check if request is authorized. Returns true if auth is disabled or key is valid. */
1212
+ isAuthorized(key) {
1213
+ if (!this.enabled) return true;
1214
+ return this.validate(key) !== null;
1215
+ }
1216
+ /** Extract bearer token from Authorization header value. */
1217
+ static extractBearer(header) {
1218
+ if (!header) return void 0;
1219
+ const match = header.match(/^Bearer\s+(\S+)$/i);
1220
+ return match?.[1];
1221
+ }
1222
+ /** Constant-time string comparison to prevent timing attacks. */
1223
+ safeCompare(a, b) {
1224
+ if (a.length !== b.length) return false;
1225
+ try {
1226
+ return timingSafeEqual(Buffer.from(a, "utf-8"), Buffer.from(b, "utf-8"));
1227
+ } catch {
1228
+ return false;
1229
+ }
1230
+ }
1231
+ };
1232
+ function generateApiKey(label, project) {
1233
+ return {
1234
+ key: randomBytes(32).toString("hex"),
1235
+ label,
1236
+ project,
1237
+ createdAt: Date.now()
1238
+ };
1239
+ }
1240
+
1241
+ // src/redactor.ts
1242
+ var BUILT_IN_RULES = [
1243
+ {
1244
+ name: "jwt",
1245
+ pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g,
1246
+ replacement: "[REDACTED:jwt]"
1247
+ },
1248
+ {
1249
+ name: "credit_card",
1250
+ pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g,
1251
+ replacement: "[REDACTED:cc]"
1252
+ },
1253
+ {
1254
+ name: "ssn",
1255
+ pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
1256
+ replacement: "[REDACTED:ssn]"
1257
+ },
1258
+ {
1259
+ name: "email",
1260
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi,
1261
+ replacement: "[REDACTED:email]"
1262
+ },
1263
+ {
1264
+ name: "bearer_token",
1265
+ pattern: /Bearer\s+[A-Za-z0-9._~+/=-]+/gi,
1266
+ replacement: "Bearer [REDACTED]"
1267
+ },
1268
+ {
1269
+ name: "api_key_param",
1270
+ pattern: /(?:api[_-]?key|apikey|secret|token|password|passwd|authorization)=[^&\s"']+/gi,
1271
+ replacement: "[REDACTED:param]"
1272
+ }
1273
+ ];
1274
+ var DEFAULT_SENSITIVE_KEYS = [
1275
+ "password",
1276
+ "passwd",
1277
+ "secret",
1278
+ "token",
1279
+ "accessToken",
1280
+ "refreshToken",
1281
+ "apiKey",
1282
+ "api_key",
1283
+ "authorization",
1284
+ "credit_card",
1285
+ "creditCard",
1286
+ "ssn",
1287
+ "socialSecurity"
1288
+ ];
1289
+ var Redactor = class {
1290
+ rules;
1291
+ sensitiveKeyPattern;
1292
+ enabled;
1293
+ constructor(config = {}) {
1294
+ this.enabled = config.enabled ?? true;
1295
+ this.rules = [];
1296
+ if (config.useBuiltIn !== false) {
1297
+ this.rules.push(...BUILT_IN_RULES);
1298
+ }
1299
+ if (config.rules) {
1300
+ this.rules.push(...config.rules);
1301
+ }
1302
+ const keys = config.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS;
1303
+ this.sensitiveKeyPattern = keys.length > 0 ? new RegExp(`^(${keys.join("|")})$`, "i") : null;
1304
+ }
1305
+ isEnabled() {
1306
+ return this.enabled;
1307
+ }
1308
+ /** Apply all redaction rules to a string value. */
1309
+ redactString(value) {
1310
+ let result = value;
1311
+ for (const rule of this.rules) {
1312
+ rule.pattern.lastIndex = 0;
1313
+ result = result.replace(rule.pattern, rule.replacement);
1314
+ }
1315
+ return result;
1316
+ }
1317
+ /**
1318
+ * Deep-walk an event and redact all string fields.
1319
+ * Returns a new event object (does not mutate the original).
1320
+ */
1321
+ redactEvent(event) {
1322
+ if (!this.enabled) return event;
1323
+ return this.deepRedact(event);
1324
+ }
1325
+ deepRedact(value, key) {
1326
+ if (value === null || value === void 0) return value;
1327
+ if (key && this.sensitiveKeyPattern?.test(key)) {
1328
+ return "[REDACTED]";
1329
+ }
1330
+ if (typeof value === "string") {
1331
+ return this.redactString(value);
1332
+ }
1333
+ if (Array.isArray(value)) {
1334
+ return value.map((item) => this.deepRedact(item));
1335
+ }
1336
+ if (typeof value === "object") {
1337
+ const result = {};
1338
+ for (const [k, v] of Object.entries(value)) {
1339
+ result[k] = this.deepRedact(v, k);
1340
+ }
1341
+ return result;
1342
+ }
1343
+ return value;
1344
+ }
1345
+ };
1346
+
1347
+ // src/session-manager.ts
1348
+ var SessionManager = class {
1349
+ projectManager;
1350
+ sqliteStores;
1351
+ store;
1352
+ constructor(projectManager, sqliteStores, store) {
1353
+ this.projectManager = projectManager;
1354
+ this.sqliteStores = sqliteStores;
1355
+ this.store = store;
1356
+ }
1357
+ computeMetrics(sessionId, project, events) {
1358
+ const sessionEvents = events.filter((e) => e.sessionId === sessionId);
1359
+ const networkEvents = sessionEvents.filter((e) => e.eventType === "network");
1360
+ const renderEvents = sessionEvents.filter((e) => e.eventType === "render");
1361
+ const stateEvents = sessionEvents.filter((e) => e.eventType === "state");
1362
+ const performanceEvents = sessionEvents.filter((e) => e.eventType === "performance");
1363
+ const databaseEvents = sessionEvents.filter((e) => e.eventType === "database");
1364
+ const consoleEvents = sessionEvents.filter((e) => e.eventType === "console");
1365
+ const endpoints = {};
1366
+ const endpointGroups = /* @__PURE__ */ new Map();
1367
+ for (const e of networkEvents) {
1368
+ const key = `${e.method} ${e.url}`;
1369
+ const group = endpointGroups.get(key) ?? [];
1370
+ group.push(e);
1371
+ endpointGroups.set(key, group);
1372
+ }
1373
+ for (const [key, group] of endpointGroups) {
1374
+ const avgLatency = group.reduce((s, e) => s + e.duration, 0) / group.length;
1375
+ const errorCount2 = group.filter((e) => e.status >= 400).length;
1376
+ endpoints[key] = { avgLatency, errorRate: errorCount2 / group.length, callCount: group.length };
1377
+ }
1378
+ const components = {};
1379
+ for (const re of renderEvents) {
1380
+ for (const p of re.profiles) {
1381
+ const existing = components[p.componentName];
1382
+ if (existing) {
1383
+ existing.renderCount += p.renderCount;
1384
+ existing.avgDuration = (existing.avgDuration + p.avgDuration) / 2;
1385
+ } else {
1386
+ components[p.componentName] = { renderCount: p.renderCount, avgDuration: p.avgDuration };
1387
+ }
1388
+ }
1389
+ }
1390
+ const stores = {};
1391
+ for (const se of stateEvents) {
1392
+ const existing = stores[se.storeId];
1393
+ if (existing) {
1394
+ existing.updateCount++;
1395
+ } else {
1396
+ stores[se.storeId] = { updateCount: 1 };
1397
+ }
1398
+ }
1399
+ const webVitals = {};
1400
+ for (const pe of performanceEvents) {
1401
+ if (pe.rating) {
1402
+ webVitals[pe.metricName] = { value: pe.value, rating: pe.rating };
1403
+ }
1404
+ }
1405
+ const queries = {};
1406
+ const queryGroups = /* @__PURE__ */ new Map();
1407
+ for (const de of databaseEvents) {
1408
+ const group = queryGroups.get(de.normalizedQuery) ?? [];
1409
+ group.push(de);
1410
+ queryGroups.set(de.normalizedQuery, group);
1411
+ }
1412
+ for (const [key, group] of queryGroups) {
1413
+ const avgDuration = group.reduce((s, e) => s + e.duration, 0) / group.length;
1414
+ queries[key] = { avgDuration, callCount: group.length };
1415
+ }
1416
+ const timestamps = sessionEvents.map((e) => e.timestamp);
1417
+ const errorCount = consoleEvents.filter((e) => e.level === "error").length + networkEvents.filter((e) => e.status >= 400).length;
1418
+ return {
1419
+ sessionId,
1420
+ project,
1421
+ connectedAt: timestamps.length > 0 ? Math.min(...timestamps) : Date.now(),
1422
+ disconnectedAt: timestamps.length > 0 ? Math.max(...timestamps) : Date.now(),
1423
+ totalEvents: sessionEvents.length,
1424
+ errorCount,
1425
+ endpoints,
1426
+ components,
1427
+ stores,
1428
+ webVitals,
1429
+ queries
1430
+ };
1431
+ }
1432
+ createSnapshot(sessionId, project, label) {
1433
+ const events = this.store.getAllEvents();
1434
+ const metrics = this.computeMetrics(sessionId, project, events);
1435
+ const sqliteStore = this.sqliteStores.get(project);
1436
+ if (sqliteStore) {
1437
+ sqliteStore.saveSessionMetrics(sessionId, project, metrics, label);
1438
+ }
1439
+ return {
1440
+ sessionId,
1441
+ project,
1442
+ label,
1443
+ metrics,
1444
+ createdAt: Date.now()
1445
+ };
1446
+ }
1447
+ getSessionSnapshots(project, sessionId) {
1448
+ const sqliteStore = this.sqliteStores.get(project);
1449
+ if (!sqliteStore) return [];
1450
+ return sqliteStore.getSessionSnapshots(sessionId);
1451
+ }
1452
+ getSnapshotById(project, snapshotId) {
1453
+ const sqliteStore = this.sqliteStores.get(project);
1454
+ if (!sqliteStore) return null;
1455
+ return sqliteStore.getSnapshotById(snapshotId);
1456
+ }
1457
+ getSessionHistory(project, limit = 20) {
1458
+ const sqliteStore = this.sqliteStores.get(project);
1459
+ if (!sqliteStore) return [];
1460
+ const sessions = sqliteStore.getSessions(project, limit);
1461
+ const snapshots = [];
1462
+ for (const session of sessions) {
1463
+ const metricsData = sqliteStore.getSessionMetrics(session.sessionId);
1464
+ if (metricsData) {
1465
+ snapshots.push({
1466
+ sessionId: session.sessionId,
1467
+ project,
1468
+ metrics: metricsData,
1469
+ buildMeta: session.buildMeta,
1470
+ createdAt: session.disconnectedAt ?? session.connectedAt
1471
+ });
1472
+ }
1473
+ }
1474
+ return snapshots;
1475
+ }
1476
+ };
1477
+
1478
+ // src/http-server.ts
1479
+ import { createServer } from "http";
1480
+ import { createServer as createHttpsServer2 } from "https";
1481
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
1482
+ import { resolve, dirname } from "path";
1483
+ import { fileURLToPath } from "url";
1484
+ import { WebSocketServer as WebSocketServer2 } from "ws";
1485
+
1486
+ // src/pm/pm-routes.ts
1487
+ import { readdir, readFile, writeFile, unlink, mkdir } from "fs/promises";
1488
+ import { existsSync as existsSync2 } from "fs";
1489
+ import { join as join2 } from "path";
1490
+ import { homedir as homedir2 } from "os";
1491
+ import { spawn, execSync, execFileSync } from "child_process";
1492
+ var LOG_RING_SIZE = 500;
1493
+ var managedProcesses = /* @__PURE__ */ new Map();
1494
+ function pushLog(mp, stream, line) {
1495
+ const entry = `[${stream}] ${line}`;
1496
+ if (mp.logs.length >= LOG_RING_SIZE) mp.logs.shift();
1497
+ mp.logs.push(entry);
1498
+ }
1499
+ function createPmRouter(pmStore, discovery, helpers, broadcastDevServer) {
1500
+ const routes = [];
1501
+ function route(method, pattern, handler) {
1502
+ routes.push({ method, pattern, segments: pattern.split("/"), handler });
1503
+ }
1504
+ route("POST", "/api/pm/discover", async (_req, res) => {
1505
+ try {
1506
+ const result = await discovery.discoverAll();
1507
+ helpers.json(res, result);
1508
+ } catch (err) {
1509
+ helpers.json(res, { error: err.message }, 500);
1510
+ }
1511
+ });
1512
+ route("GET", "/api/pm/categories", (_req, res) => {
1513
+ const categories = pmStore.listCategories();
1514
+ helpers.json(res, { data: categories });
1515
+ });
1516
+ route("GET", "/api/pm/projects", (_req, res, params) => {
1517
+ const id = params.get("id");
1518
+ if (id) {
1519
+ const project = pmStore.getProject(id);
1520
+ if (!project) {
1521
+ helpers.json(res, { error: "Project not found" }, 404);
1522
+ return;
1523
+ }
1524
+ const stats = pmStore.getSessionStats(id);
1525
+ helpers.json(res, { ...project, stats });
1526
+ return;
1527
+ }
1528
+ const projects = pmStore.listProjects();
1529
+ helpers.json(res, { data: projects, count: projects.length });
1530
+ });
1531
+ route("GET", "/api/pm/projects/export-csv", (_req, res, params) => {
1532
+ const startDate = params.get("start_date") ?? void 0;
1533
+ const endDate = params.get("end_date") ?? void 0;
1534
+ const hideEmpty = params.get("hide_empty") === "1" || params.get("hide_empty") === "true";
1535
+ const projectIdsRaw = params.get("project_ids") ?? "";
1536
+ const projectIds = projectIdsRaw ? projectIdsRaw.split(",").filter(Boolean) : void 0;
1537
+ const allSummaries = pmStore.getProjectSummaries({ startDate, endDate, hideEmpty });
1538
+ const summaries = projectIds ? allSummaries.filter((s) => projectIds.includes(s.id)) : allSummaries;
1539
+ const projectIdSet = new Set(summaries.map((s) => s.id));
1540
+ const allSessions = [];
1541
+ for (const pid of projectIdSet) {
1542
+ const sessions = pmStore.listSessions(pid, { limit: 1e4, offset: 0, startDate, endDate, hideEmpty });
1543
+ allSessions.push(...sessions);
1544
+ }
1545
+ allSessions.sort((a, b) => b.startedAt - a.startedAt);
1546
+ const csvEscape = (val) => {
1547
+ if (val === null || val === void 0) return "";
1548
+ const s = String(val);
1549
+ if (s.includes(",") || s.includes('"') || s.includes("\n")) {
1550
+ return `"${s.replace(/"/g, '""')}"`;
1551
+ }
1552
+ return s;
1553
+ };
1554
+ const lines = [];
1555
+ lines.push("=== PROJECTS ===");
1556
+ lines.push("Project,Category,Sessions,Messages,Cost ($),Active Time (min),Last Session");
1557
+ for (const p of summaries) {
1558
+ lines.push([
1559
+ csvEscape(p.name),
1560
+ csvEscape(p.category),
1561
+ p.session_count,
1562
+ p.total_messages,
1563
+ (p.total_cost / 1e6).toFixed(2),
1564
+ Math.round(p.total_active_minutes),
1565
+ p.last_session_at ? new Date(p.last_session_at).toISOString().split("T")[0] : ""
1566
+ ].join(","));
1567
+ }
1568
+ lines.push("");
1569
+ lines.push("=== SESSIONS ===");
1570
+ lines.push("Project,Session ID,Slug,Model,Date,Messages,Tokens In,Tokens Out,Cost ($),Active Time (min),Branch");
1571
+ for (const s of allSessions) {
1572
+ const proj = summaries.find((p) => p.id === s.projectId);
1573
+ lines.push([
1574
+ csvEscape(proj?.name ?? s.projectId),
1575
+ csvEscape(s.id),
1576
+ csvEscape(s.slug),
1577
+ csvEscape(s.model),
1578
+ new Date(s.startedAt).toISOString().split("T")[0],
1579
+ s.messageCount,
1580
+ s.totalInputTokens,
1581
+ s.totalOutputTokens,
1582
+ (s.costMicrodollars / 1e6).toFixed(2),
1583
+ Math.round(s.activeMinutes),
1584
+ csvEscape(s.gitBranch)
1585
+ ].join(","));
1586
+ }
1587
+ const csv = lines.join("\n");
1588
+ res.writeHead(200, {
1589
+ "Content-Type": "text/csv",
1590
+ "Content-Disposition": `attachment; filename="runtimescope-export-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.csv"`
1591
+ });
1592
+ res.end(csv);
1593
+ });
1594
+ route("GET", "/api/pm/projects/summaries", (_req, res, params) => {
1595
+ const startDate = params.get("start_date") ?? void 0;
1596
+ const endDate = params.get("end_date") ?? void 0;
1597
+ const hideEmpty = params.get("hide_empty") === "1" || params.get("hide_empty") === "true";
1598
+ const summaries = pmStore.getProjectSummaries({ startDate, endDate, hideEmpty });
1599
+ helpers.json(res, { data: summaries, count: summaries.length });
1600
+ });
1601
+ route("GET", "/api/pm/projects/:id", (_req, res, params) => {
1602
+ const id = params.get("id");
1603
+ const project = pmStore.getProject(id);
1604
+ if (!project) {
1605
+ helpers.json(res, { error: "Project not found" }, 404);
1606
+ return;
1607
+ }
1608
+ const stats = pmStore.getSessionStats(id);
1609
+ helpers.json(res, { ...project, stats });
1610
+ });
1611
+ route("PUT", "/api/pm/projects/:id", async (req, res, params) => {
1612
+ const id = params.get("id");
1613
+ const body = await helpers.readBody(req, 65536);
1614
+ if (!body) {
1615
+ helpers.json(res, { error: "Body required" }, 400);
1616
+ return;
1617
+ }
1618
+ try {
1619
+ const updates = JSON.parse(body);
1620
+ pmStore.updateProject(id, updates);
1621
+ helpers.json(res, { ok: true });
1622
+ } catch (err) {
1623
+ helpers.json(res, { error: err.message }, 400);
1624
+ }
1625
+ });
1626
+ route("GET", "/api/pm/tasks", (_req, res, params) => {
1627
+ const projectId = params.get("project_id") ?? void 0;
1628
+ const status = params.get("status") ?? void 0;
1629
+ const tasks = pmStore.listTasks(projectId, status);
1630
+ helpers.json(res, { data: tasks, count: tasks.length });
1631
+ });
1632
+ route("POST", "/api/pm/tasks", async (req, res) => {
1633
+ const body = await helpers.readBody(req, 65536);
1634
+ if (!body) {
1635
+ helpers.json(res, { error: "Body required" }, 400);
1636
+ return;
1637
+ }
1638
+ try {
1639
+ const data = JSON.parse(body);
1640
+ const now = Date.now();
1641
+ const task = {
1642
+ id: crypto.randomUUID(),
1643
+ projectId: data.projectId ?? void 0,
1644
+ title: data.title,
1645
+ description: data.description ?? void 0,
1646
+ status: data.status ?? "todo",
1647
+ priority: data.priority ?? "medium",
1648
+ labels: data.labels ?? [],
1649
+ source: data.source ?? "manual",
1650
+ sourceRef: data.sourceRef ?? void 0,
1651
+ sortOrder: data.sortOrder ?? now,
1652
+ assignedTo: data.assignedTo ?? void 0,
1653
+ dueDate: data.dueDate ?? void 0,
1654
+ createdAt: now,
1655
+ updatedAt: now,
1656
+ completedAt: void 0
1657
+ };
1658
+ pmStore.createTask(task);
1659
+ helpers.json(res, task, 201);
1660
+ } catch (err) {
1661
+ helpers.json(res, { error: err.message }, 400);
1662
+ }
1663
+ });
1664
+ route("PUT", "/api/pm/tasks/:id", async (req, res, params) => {
1665
+ const id = params.get("id");
1666
+ const body = await helpers.readBody(req, 65536);
1667
+ if (!body) {
1668
+ helpers.json(res, { error: "Body required" }, 400);
1669
+ return;
1670
+ }
1671
+ try {
1672
+ const updates = JSON.parse(body);
1673
+ pmStore.updateTask(id, updates);
1674
+ helpers.json(res, { ok: true });
1675
+ } catch (err) {
1676
+ helpers.json(res, { error: err.message }, 400);
1677
+ }
1678
+ });
1679
+ route("DELETE", "/api/pm/tasks/:id", (_req, res, params) => {
1680
+ const id = params.get("id");
1681
+ pmStore.deleteTask(id);
1682
+ helpers.json(res, { ok: true });
1683
+ });
1684
+ route("PUT", "/api/pm/tasks/:id/reorder", async (req, res, params) => {
1685
+ const id = params.get("id");
1686
+ const body = await helpers.readBody(req, 4096);
1687
+ if (!body) {
1688
+ helpers.json(res, { error: "Body required" }, 400);
1689
+ return;
1690
+ }
1691
+ try {
1692
+ const { status, sortOrder } = JSON.parse(body);
1693
+ pmStore.reorderTask(id, status, sortOrder);
1694
+ helpers.json(res, { ok: true });
1695
+ } catch (err) {
1696
+ helpers.json(res, { error: err.message }, 400);
1697
+ }
1698
+ });
1699
+ route("GET", "/api/pm/sessions", (_req, res, params) => {
1700
+ const projectId = params.get("project_id") ?? void 0;
1701
+ const limit = parseInt(params.get("limit") ?? "100", 10);
1702
+ const offset = parseInt(params.get("offset") ?? "0", 10);
1703
+ const startDate = params.get("start_date") ?? void 0;
1704
+ const endDate = params.get("end_date") ?? void 0;
1705
+ const hideEmpty = params.get("hide_empty") === "1" || params.get("hide_empty") === "true";
1706
+ const sessions = pmStore.listSessions(projectId, { limit, offset, startDate, endDate, hideEmpty });
1707
+ const stats = pmStore.getSessionStats(projectId, { startDate, endDate, hideEmpty });
1708
+ helpers.json(res, { data: sessions, count: sessions.length, total: stats.totalSessions });
1709
+ });
1710
+ route("GET", "/api/pm/sessions/stats", (_req, res, params) => {
1711
+ const projectId = params.get("project_id") || void 0;
1712
+ const startDate = params.get("start_date") ?? void 0;
1713
+ const endDate = params.get("end_date") ?? void 0;
1714
+ const hideEmpty = params.get("hide_empty") === "1" || params.get("hide_empty") === "true";
1715
+ const stats = pmStore.getSessionStats(projectId, { startDate, endDate, hideEmpty });
1716
+ helpers.json(res, stats);
1717
+ });
1718
+ route("GET", "/api/pm/sessions/:id", (_req, res, params) => {
1719
+ const id = params.get("id");
1720
+ const session = pmStore.getSession(id);
1721
+ if (!session) {
1722
+ helpers.json(res, { error: "Session not found" }, 404);
1723
+ return;
1724
+ }
1725
+ helpers.json(res, session);
1726
+ });
1727
+ route("POST", "/api/pm/sessions/:id/refresh", async (_req, res, params) => {
1728
+ const id = params.get("id");
1729
+ const session = pmStore.getSession(id);
1730
+ if (!session) {
1731
+ helpers.json(res, { error: "Session not found" }, 404);
1732
+ return;
1733
+ }
1734
+ try {
1735
+ await discovery.indexSession(id, session.projectId, session.jsonlPath);
1736
+ const updated = pmStore.getSession(id);
1737
+ helpers.json(res, updated);
1738
+ } catch (err) {
1739
+ helpers.json(res, { error: err.message }, 500);
1740
+ }
1741
+ });
1742
+ route("GET", "/api/pm/notes", (_req, res, params) => {
1743
+ const projectId = params.get("project_id") ?? void 0;
1744
+ const pinned = params.get("pinned") === "1" ? true : void 0;
1745
+ const notes = pmStore.listNotes({ projectId, pinned });
1746
+ helpers.json(res, { data: notes, count: notes.length });
1747
+ });
1748
+ route("POST", "/api/pm/notes", async (req, res) => {
1749
+ const body = await helpers.readBody(req, 1048576);
1750
+ if (!body) {
1751
+ helpers.json(res, { error: "Body required" }, 400);
1752
+ return;
1753
+ }
1754
+ try {
1755
+ const data = JSON.parse(body);
1756
+ const now = Date.now();
1757
+ const note = {
1758
+ id: crypto.randomUUID(),
1759
+ projectId: data.projectId ?? void 0,
1760
+ sessionId: data.sessionId ?? void 0,
1761
+ title: data.title ?? "Untitled",
1762
+ content: data.content ?? "",
1763
+ pinned: data.pinned ?? false,
1764
+ tags: data.tags ?? [],
1765
+ createdAt: now,
1766
+ updatedAt: now
1767
+ };
1768
+ pmStore.createNote(note);
1769
+ helpers.json(res, note, 201);
1770
+ } catch (err) {
1771
+ helpers.json(res, { error: err.message }, 400);
1772
+ }
1773
+ });
1774
+ route("PUT", "/api/pm/notes/:id", async (req, res, params) => {
1775
+ const id = params.get("id");
1776
+ const body = await helpers.readBody(req, 1048576);
1777
+ if (!body) {
1778
+ helpers.json(res, { error: "Body required" }, 400);
1779
+ return;
1780
+ }
1781
+ try {
1782
+ const updates = JSON.parse(body);
1783
+ pmStore.updateNote(id, updates);
1784
+ helpers.json(res, { ok: true });
1785
+ } catch (err) {
1786
+ helpers.json(res, { error: err.message }, 400);
1787
+ }
1788
+ });
1789
+ route("DELETE", "/api/pm/notes/:id", (_req, res, params) => {
1790
+ const id = params.get("id");
1791
+ pmStore.deleteNote(id);
1792
+ helpers.json(res, { ok: true });
1793
+ });
1794
+ route("GET", "/api/pm/memory/:projectId", async (_req, res, params) => {
1795
+ const projectId = params.get("projectId");
1796
+ const project = pmStore.getProject(projectId);
1797
+ if (!project?.claudeProjectKey) {
1798
+ helpers.json(res, { data: [], count: 0 });
1799
+ return;
1800
+ }
1801
+ const memoryDir = join2(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
1802
+ try {
1803
+ const files = await readdir(memoryDir);
1804
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
1805
+ const result = await Promise.all(
1806
+ mdFiles.map(async (filename) => {
1807
+ const content = await readFile(join2(memoryDir, filename), "utf-8");
1808
+ return { filename, content, sizeBytes: Buffer.byteLength(content) };
1809
+ })
1810
+ );
1811
+ helpers.json(res, { data: result, count: result.length });
1812
+ } catch {
1813
+ helpers.json(res, { data: [], count: 0 });
1814
+ }
1815
+ });
1816
+ route("GET", "/api/pm/memory/:projectId/:filename", async (_req, res, params) => {
1817
+ const projectId = params.get("projectId");
1818
+ const filename = sanitizeFilename(params.get("filename"));
1819
+ const project = pmStore.getProject(projectId);
1820
+ if (!project?.claudeProjectKey) {
1821
+ helpers.json(res, { error: "Project not found" }, 404);
1822
+ return;
1823
+ }
1824
+ const filePath = join2(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
1825
+ try {
1826
+ const content = await readFile(filePath, "utf-8");
1827
+ helpers.json(res, { filename, content, sizeBytes: Buffer.byteLength(content) });
1828
+ } catch {
1829
+ helpers.json(res, { error: "File not found" }, 404);
1830
+ }
1831
+ });
1832
+ route("PUT", "/api/pm/memory/:projectId/:filename", async (req, res, params) => {
1833
+ const projectId = params.get("projectId");
1834
+ const filename = sanitizeFilename(params.get("filename"));
1835
+ const project = pmStore.getProject(projectId);
1836
+ if (!project?.claudeProjectKey) {
1837
+ helpers.json(res, { error: "Project not found" }, 404);
1838
+ return;
1839
+ }
1840
+ const body = await helpers.readBody(req, 1048576);
1841
+ if (!body) {
1842
+ helpers.json(res, { error: "Body required" }, 400);
1843
+ return;
1844
+ }
1845
+ try {
1846
+ const { content } = JSON.parse(body);
1847
+ const memoryDir = join2(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory");
1848
+ await mkdir(memoryDir, { recursive: true });
1849
+ await writeFile(join2(memoryDir, filename), content, "utf-8");
1850
+ helpers.json(res, { ok: true });
1851
+ } catch (err) {
1852
+ helpers.json(res, { error: err.message }, 500);
1853
+ }
1854
+ });
1855
+ route("DELETE", "/api/pm/memory/:projectId/:filename", async (_req, res, params) => {
1856
+ const projectId = params.get("projectId");
1857
+ const filename = sanitizeFilename(params.get("filename"));
1858
+ const project = pmStore.getProject(projectId);
1859
+ if (!project?.claudeProjectKey) {
1860
+ helpers.json(res, { error: "Project not found" }, 404);
1861
+ return;
1862
+ }
1863
+ const filePath = join2(homedir2(), ".claude", "projects", project.claudeProjectKey, "memory", filename);
1864
+ try {
1865
+ await unlink(filePath);
1866
+ helpers.json(res, { ok: true });
1867
+ } catch {
1868
+ helpers.json(res, { error: "File not found" }, 404);
1869
+ }
1870
+ });
1871
+ route("GET", "/api/pm/rules/:projectId", async (_req, res, params) => {
1872
+ const projectId = params.get("projectId");
1873
+ const project = pmStore.getProject(projectId);
1874
+ if (!project) {
1875
+ helpers.json(res, { error: "Project not found" }, 404);
1876
+ return;
1877
+ }
1878
+ const paths = getRulesPaths(project.claudeProjectKey, project.path);
1879
+ const result = {
1880
+ global: await readRuleFile(paths.global),
1881
+ project: await readRuleFile(paths.project),
1882
+ local: await readRuleFile(paths.local)
1883
+ };
1884
+ helpers.json(res, result);
1885
+ });
1886
+ route("GET", "/api/pm/rules/:projectId/:scope", async (_req, res, params) => {
1887
+ const projectId = params.get("projectId");
1888
+ const scope = params.get("scope");
1889
+ if (!["global", "project", "local"].includes(scope)) {
1890
+ helpers.json(res, { error: "Invalid scope. Must be: global, project, or local" }, 400);
1891
+ return;
1892
+ }
1893
+ const project = pmStore.getProject(projectId);
1894
+ if (!project) {
1895
+ helpers.json(res, { error: "Project not found" }, 404);
1896
+ return;
1897
+ }
1898
+ const paths = getRulesPaths(project.claudeProjectKey, project.path);
1899
+ const filePath = paths[scope];
1900
+ helpers.json(res, await readRuleFile(filePath));
1901
+ });
1902
+ route("PUT", "/api/pm/rules/:projectId/:scope", async (req, res, params) => {
1903
+ const projectId = params.get("projectId");
1904
+ const scope = params.get("scope");
1905
+ if (!["global", "project", "local"].includes(scope)) {
1906
+ helpers.json(res, { error: "Invalid scope" }, 400);
1907
+ return;
1908
+ }
1909
+ const project = pmStore.getProject(projectId);
1910
+ if (!project) {
1911
+ helpers.json(res, { error: "Project not found" }, 404);
1912
+ return;
1913
+ }
1914
+ const body = await helpers.readBody(req, 1048576);
1915
+ if (!body) {
1916
+ helpers.json(res, { error: "Body required" }, 400);
1917
+ return;
1918
+ }
1919
+ try {
1920
+ const { content } = JSON.parse(body);
1921
+ const paths = getRulesPaths(project.claudeProjectKey, project.path);
1922
+ const filePath = paths[scope];
1923
+ const dir = join2(filePath, "..");
1924
+ await mkdir(dir, { recursive: true });
1925
+ await writeFile(filePath, content, "utf-8");
1926
+ helpers.json(res, { ok: true });
1927
+ } catch (err) {
1928
+ helpers.json(res, { error: err.message }, 500);
1929
+ }
1930
+ });
1931
+ route("GET", "/api/pm/projects/:id/scripts", async (_req, res, params) => {
1932
+ const id = params.get("id");
1933
+ const project = pmStore.getProject(id);
1934
+ if (!project) {
1935
+ helpers.json(res, { error: "Project not found" }, 404);
1936
+ return;
1937
+ }
1938
+ if (!project.path) {
1939
+ helpers.json(res, { data: { scripts: {}, recommended: null } });
1940
+ return;
1941
+ }
1942
+ try {
1943
+ const pkgPath = join2(project.path, "package.json");
1944
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
1945
+ const scripts = pkg.scripts ?? {};
1946
+ const recommended = ["dev", "start", "serve"].find((s) => s in scripts) ?? null;
1947
+ helpers.json(res, { data: { scripts, recommended } });
1948
+ } catch {
1949
+ helpers.json(res, { data: { scripts: {}, recommended: null } });
1950
+ }
1951
+ });
1952
+ route("GET", "/api/pm/projects/:id/dev-server", (_req, res, params) => {
1953
+ const id = params.get("id");
1954
+ const mp = managedProcesses.get(id);
1955
+ if (!mp) {
1956
+ helpers.json(res, { data: { status: "stopped" } });
1957
+ return;
1958
+ }
1959
+ try {
1960
+ process.kill(mp.pid, 0);
1961
+ } catch {
1962
+ managedProcesses.delete(id);
1963
+ helpers.json(res, { data: { status: "stopped" } });
1964
+ return;
1965
+ }
1966
+ helpers.json(res, {
1967
+ data: {
1968
+ status: mp.status,
1969
+ pid: mp.pid,
1970
+ command: mp.command,
1971
+ startedAt: mp.startedAt,
1972
+ exitCode: mp.exitCode,
1973
+ logs: mp.logs.slice(-100)
1974
+ }
1975
+ });
1976
+ });
1977
+ route("POST", "/api/pm/projects/:id/dev-server", async (req, res, params) => {
1978
+ const id = params.get("id");
1979
+ const project = pmStore.getProject(id);
1980
+ if (!project) {
1981
+ helpers.json(res, { error: "Project not found" }, 404);
1982
+ return;
1983
+ }
1984
+ if (!project.path) {
1985
+ helpers.json(res, { error: "Project has no filesystem path" }, 400);
1986
+ return;
1987
+ }
1988
+ const existing = managedProcesses.get(id);
1989
+ if (existing) {
1990
+ try {
1991
+ process.kill(existing.pid, 0);
1992
+ helpers.json(res, { error: "Dev server already running", data: { pid: existing.pid, status: existing.status } }, 409);
1993
+ return;
1994
+ } catch {
1995
+ managedProcesses.delete(id);
1996
+ }
1997
+ }
1998
+ const body = await helpers.readBody(req, 4096);
1999
+ let script;
2000
+ let command;
2001
+ if (body) {
2002
+ try {
2003
+ const data = JSON.parse(body);
2004
+ script = data.script;
2005
+ command = data.command;
2006
+ } catch {
2007
+ }
2008
+ }
2009
+ const finalCommand = command ?? (script ? `npm run ${script}` : "npm run dev");
2010
+ const broadcast = broadcastDevServer ?? (() => {
2011
+ });
2012
+ try {
2013
+ const child = spawn(finalCommand, {
2014
+ cwd: project.path,
2015
+ shell: true,
2016
+ stdio: ["ignore", "pipe", "pipe"]
2017
+ });
2018
+ const pid = child.pid;
2019
+ if (!pid) {
2020
+ helpers.json(res, { error: "Failed to spawn process" }, 500);
2021
+ return;
2022
+ }
2023
+ const managed = {
2024
+ pid,
2025
+ command: finalCommand,
2026
+ projectId: id,
2027
+ startedAt: Date.now(),
2028
+ status: "starting",
2029
+ child,
2030
+ logs: [],
2031
+ exitCode: null
2032
+ };
2033
+ managedProcesses.set(id, managed);
2034
+ broadcast({ type: "dev_server_status", projectId: id, status: "starting", pid });
2035
+ let detectedPort = null;
2036
+ const flipTimer = setTimeout(() => {
2037
+ if (managed.status === "starting") {
2038
+ managed.status = "running";
2039
+ broadcast({ type: "dev_server_status", projectId: id, status: "running", pid, port: detectedPort });
2040
+ }
2041
+ }, 500);
2042
+ const PORT_RE = /(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d{4,5})/;
2043
+ for (const [stream, src] of [["stdout", child.stdout], ["stderr", child.stderr]]) {
2044
+ let buf = "";
2045
+ src.on("data", (chunk) => {
2046
+ if (managed.status === "starting") {
2047
+ managed.status = "running";
2048
+ clearTimeout(flipTimer);
2049
+ broadcast({ type: "dev_server_status", projectId: id, status: "running", pid, port: detectedPort });
2050
+ }
2051
+ buf += chunk.toString("utf-8");
2052
+ const lines = buf.split("\n");
2053
+ buf = lines.pop() ?? "";
2054
+ for (const line of lines) {
2055
+ if (!line) continue;
2056
+ if (!detectedPort) {
2057
+ const portMatch = line.match(PORT_RE);
2058
+ if (portMatch) {
2059
+ detectedPort = parseInt(portMatch[1], 10);
2060
+ broadcast({ type: "dev_server_status", projectId: id, status: "running", pid, port: detectedPort });
2061
+ }
2062
+ }
2063
+ pushLog(managed, stream, line);
2064
+ broadcast({ type: "dev_server_log", projectId: id, stream, line, ts: Date.now() });
2065
+ }
2066
+ });
2067
+ }
2068
+ child.on("exit", (code) => {
2069
+ clearTimeout(flipTimer);
2070
+ managed.status = code === 0 ? "stopped" : "crashed";
2071
+ managed.exitCode = code;
2072
+ broadcast({ type: "dev_server_status", projectId: id, status: managed.status, pid, exitCode: code });
2073
+ setTimeout(() => managedProcesses.delete(id), 5e3);
2074
+ });
2075
+ child.on("error", (err) => {
2076
+ clearTimeout(flipTimer);
2077
+ managed.status = "crashed";
2078
+ pushLog(managed, "stderr", `[error] ${err.message}`);
2079
+ broadcast({ type: "dev_server_status", projectId: id, status: "crashed", pid, error: err.message });
2080
+ setTimeout(() => managedProcesses.delete(id), 5e3);
2081
+ });
2082
+ helpers.json(res, { data: { pid, command: finalCommand, cwd: project.path, status: "starting" } });
2083
+ } catch (err) {
2084
+ helpers.json(res, { error: err.message }, 500);
2085
+ }
2086
+ });
2087
+ route("DELETE", "/api/pm/projects/:id/dev-server", async (req, res, params) => {
2088
+ const id = params.get("id");
2089
+ const project = pmStore.getProject(id);
2090
+ if (!project) {
2091
+ helpers.json(res, { error: "Project not found" }, 404);
2092
+ return;
2093
+ }
2094
+ let signal = "SIGTERM";
2095
+ const body = await helpers.readBody(req, 1024);
2096
+ if (body) {
2097
+ try {
2098
+ const data = JSON.parse(body);
2099
+ if (data.signal === "SIGKILL") signal = "SIGKILL";
2100
+ } catch {
2101
+ }
2102
+ }
2103
+ let pid = null;
2104
+ const managed = managedProcesses.get(id);
2105
+ if (managed) {
2106
+ pid = managed.pid;
2107
+ try {
2108
+ managed.child.kill(signal);
2109
+ } catch {
2110
+ }
2111
+ managedProcesses.delete(id);
2112
+ } else if (project.path) {
2113
+ try {
2114
+ const output = execSync(
2115
+ `lsof -t +D "${project.path}" 2>/dev/null | head -5`,
2116
+ { encoding: "utf-8", timeout: 5e3 }
2117
+ ).trim();
2118
+ const pids = output.split("\n").filter(Boolean).map(Number).filter((n) => n > 1 && n !== process.pid);
2119
+ if (pids.length > 0) pid = pids[0];
2120
+ } catch {
2121
+ }
2122
+ }
2123
+ if (!pid) {
2124
+ helpers.json(res, { error: "No running dev server found for this project" }, 404);
2125
+ return;
2126
+ }
2127
+ try {
2128
+ process.kill(pid, signal);
2129
+ managedProcesses.delete(id);
2130
+ helpers.json(res, { data: { killed: true, pid, signal } });
2131
+ } catch (err) {
2132
+ const code = err.code;
2133
+ managedProcesses.delete(id);
2134
+ if (code === "ESRCH") {
2135
+ helpers.json(res, { data: { killed: true, pid, signal, note: "Process already exited" } });
2136
+ } else {
2137
+ helpers.json(res, { error: `Failed to kill PID ${pid}: ${err.message}` }, 500);
2138
+ }
2139
+ }
2140
+ });
2141
+ route("GET", "/api/pm/projects/:id/git/status", (_req, res, params) => {
2142
+ const id = params.get("id");
2143
+ const project = pmStore.getProject(id);
2144
+ if (!project) {
2145
+ helpers.json(res, { error: "Project not found" }, 404);
2146
+ return;
2147
+ }
2148
+ if (!project.path) {
2149
+ helpers.json(res, { data: { isGitRepo: false, branch: "", staged: [], unstaged: [], untracked: [] } });
2150
+ return;
2151
+ }
2152
+ if (!isGitRepo(project.path)) {
2153
+ helpers.json(res, { data: { isGitRepo: false, branch: "", staged: [], unstaged: [], untracked: [] } });
2154
+ return;
2155
+ }
2156
+ try {
2157
+ const branch = execGit(["rev-parse", "--abbrev-ref", "HEAD"], project.path).trim();
2158
+ const porcelain = execGit(["status", "--porcelain"], project.path);
2159
+ const { staged, unstaged, untracked } = parseGitStatus(porcelain);
2160
+ helpers.json(res, { data: { isGitRepo: true, branch, staged, unstaged, untracked } });
2161
+ } catch (err) {
2162
+ helpers.json(res, { error: err.message }, 500);
2163
+ }
2164
+ });
2165
+ route("GET", "/api/pm/projects/:id/git/log", (_req, res, params) => {
2166
+ const id = params.get("id");
2167
+ const project = pmStore.getProject(id);
2168
+ if (!project) {
2169
+ helpers.json(res, { error: "Project not found" }, 404);
2170
+ return;
2171
+ }
2172
+ if (!project.path || !isGitRepo(project.path)) {
2173
+ helpers.json(res, { data: [] });
2174
+ return;
2175
+ }
2176
+ try {
2177
+ const raw = execGit(["log", "-30", "--format=%H%x00%h%x00%B%x00%an%x00%cr%x00%D%x01"], project.path);
2178
+ const commits = raw.trim().split("").filter(Boolean).map((entry) => {
2179
+ const [hash, shortHash, message, author, relativeDate, refs] = entry.trim().split("\0");
2180
+ const fullMsg = (message ?? "").trim();
2181
+ const subject = fullMsg.split("\n")[0];
2182
+ return { hash, shortHash, subject, message: fullMsg, author, relativeDate, refs: refs?.trim() || "" };
2183
+ });
2184
+ helpers.json(res, { data: commits });
2185
+ } catch (err) {
2186
+ helpers.json(res, { error: err.message }, 500);
2187
+ }
2188
+ });
2189
+ route("POST", "/api/pm/projects/:id/git/stage", async (req, res, params) => {
2190
+ const id = params.get("id");
2191
+ const project = pmStore.getProject(id);
2192
+ if (!project) {
2193
+ helpers.json(res, { error: "Project not found" }, 404);
2194
+ return;
2195
+ }
2196
+ if (!project.path || !isGitRepo(project.path)) {
2197
+ helpers.json(res, { error: "Not a git repo" }, 400);
2198
+ return;
2199
+ }
2200
+ const body = await helpers.readBody(req, 65536);
2201
+ let files;
2202
+ if (body) {
2203
+ try {
2204
+ files = JSON.parse(body).files;
2205
+ } catch {
2206
+ }
2207
+ }
2208
+ try {
2209
+ if (files && files.length > 0) {
2210
+ execGit(["add", "--", ...files], project.path);
2211
+ } else {
2212
+ execGit(["add", "-A"], project.path);
2213
+ }
2214
+ helpers.json(res, { ok: true });
2215
+ } catch (err) {
2216
+ helpers.json(res, { error: err.message }, 500);
2217
+ }
2218
+ });
2219
+ route("POST", "/api/pm/projects/:id/git/unstage", async (req, res, params) => {
2220
+ const id = params.get("id");
2221
+ const project = pmStore.getProject(id);
2222
+ if (!project) {
2223
+ helpers.json(res, { error: "Project not found" }, 404);
2224
+ return;
2225
+ }
2226
+ if (!project.path || !isGitRepo(project.path)) {
2227
+ helpers.json(res, { error: "Not a git repo" }, 400);
2228
+ return;
2229
+ }
2230
+ const body = await helpers.readBody(req, 65536);
2231
+ let files;
2232
+ if (body) {
2233
+ try {
2234
+ files = JSON.parse(body).files;
2235
+ } catch {
2236
+ }
2237
+ }
2238
+ try {
2239
+ if (files && files.length > 0) {
2240
+ execGit(["restore", "--staged", "--", ...files], project.path);
2241
+ } else {
2242
+ execGit(["reset", "HEAD"], project.path);
2243
+ }
2244
+ helpers.json(res, { ok: true });
2245
+ } catch (err) {
2246
+ helpers.json(res, { error: err.message }, 500);
2247
+ }
2248
+ });
2249
+ route("POST", "/api/pm/projects/:id/git/commit", async (req, res, params) => {
2250
+ const id = params.get("id");
2251
+ const project = pmStore.getProject(id);
2252
+ if (!project) {
2253
+ helpers.json(res, { error: "Project not found" }, 404);
2254
+ return;
2255
+ }
2256
+ if (!project.path || !isGitRepo(project.path)) {
2257
+ helpers.json(res, { error: "Not a git repo" }, 400);
2258
+ return;
2259
+ }
2260
+ const body = await helpers.readBody(req, 65536);
2261
+ if (!body) {
2262
+ helpers.json(res, { error: "Body required" }, 400);
2263
+ return;
2264
+ }
2265
+ try {
2266
+ const { message } = JSON.parse(body);
2267
+ if (!message || !message.trim()) {
2268
+ helpers.json(res, { error: "Commit message required" }, 400);
2269
+ return;
2270
+ }
2271
+ const output = execGit(["commit", "-m", message], project.path);
2272
+ const hashMatch = output.match(/\[[\w/.-]+ ([a-f0-9]+)\]/);
2273
+ helpers.json(res, { ok: true, hash: hashMatch?.[1] ?? "" });
2274
+ } catch (err) {
2275
+ helpers.json(res, { error: err.message }, 500);
2276
+ }
2277
+ });
2278
+ route("GET", "/api/pm/projects/:id/git/diff", (_req, res, params) => {
2279
+ const id = params.get("id");
2280
+ const project = pmStore.getProject(id);
2281
+ if (!project) {
2282
+ helpers.json(res, { error: "Project not found" }, 404);
2283
+ return;
2284
+ }
2285
+ if (!project.path || !isGitRepo(project.path)) {
2286
+ helpers.json(res, { data: { diff: "" } });
2287
+ return;
2288
+ }
2289
+ const staged = params.get("staged") === "1" || params.get("staged") === "true";
2290
+ const file = params.get("file") ?? void 0;
2291
+ try {
2292
+ const args = ["diff"];
2293
+ if (staged) args.push("--staged");
2294
+ if (file) args.push("--", file);
2295
+ const diff = execGit(args, project.path);
2296
+ helpers.json(res, { data: { diff } });
2297
+ } catch (err) {
2298
+ helpers.json(res, { error: err.message }, 500);
2299
+ }
2300
+ });
2301
+ route("GET", "/api/pm/capex/:projectId", (_req, res, params) => {
2302
+ const projectId = params.get("projectId");
2303
+ const month = params.get("month") ?? void 0;
2304
+ const confirmed = params.get("confirmed") === "1" ? true : params.get("confirmed") === "0" ? false : void 0;
2305
+ const entries = pmStore.listCapexEntries(projectId, { month, confirmed });
2306
+ helpers.json(res, { data: entries, count: entries.length });
2307
+ });
2308
+ route("GET", "/api/pm/capex/:projectId/summary", (_req, res, params) => {
2309
+ const projectId = params.get("projectId");
2310
+ const startDate = params.get("start_date") ?? void 0;
2311
+ const endDate = params.get("end_date") ?? void 0;
2312
+ const summary = pmStore.getCapexSummary(projectId, { startDate, endDate });
2313
+ helpers.json(res, summary);
2314
+ });
2315
+ route("PUT", "/api/pm/capex/:projectId/:entryId", async (req, res, params) => {
2316
+ const entryId = params.get("entryId");
2317
+ const body = await helpers.readBody(req, 65536);
2318
+ if (!body) {
2319
+ helpers.json(res, { error: "Body required" }, 400);
2320
+ return;
2321
+ }
2322
+ try {
2323
+ const updates = JSON.parse(body);
2324
+ pmStore.updateCapexEntry(entryId, updates);
2325
+ helpers.json(res, { ok: true });
2326
+ } catch (err) {
2327
+ helpers.json(res, { error: err.message }, 400);
2328
+ }
2329
+ });
2330
+ route("POST", "/api/pm/capex/:projectId/:entryId/confirm", (_req, res, params) => {
2331
+ const entryId = params.get("entryId");
2332
+ pmStore.confirmCapexEntry(entryId);
2333
+ helpers.json(res, { ok: true });
2334
+ });
2335
+ route("GET", "/api/pm/capex/:projectId/export", (_req, res, params) => {
2336
+ const projectId = params.get("projectId");
2337
+ const startDate = params.get("start_date") ?? void 0;
2338
+ const endDate = params.get("end_date") ?? void 0;
2339
+ const csv = pmStore.exportCapexCsv(projectId, { startDate, endDate });
2340
+ res.writeHead(200, {
2341
+ "Content-Type": "text/csv",
2342
+ "Content-Disposition": `attachment; filename="capex-${projectId}.csv"`
2343
+ });
2344
+ res.end(csv);
2345
+ });
2346
+ return {
2347
+ match(method, pathname) {
2348
+ const pathSegments = pathname.split("/");
2349
+ for (const r of routes) {
2350
+ if (r.method !== method) continue;
2351
+ if (r.segments.length !== pathSegments.length) continue;
2352
+ const pathParams = {};
2353
+ let matched = true;
2354
+ for (let i = 0; i < r.segments.length; i++) {
2355
+ if (r.segments[i].startsWith(":")) {
2356
+ pathParams[r.segments[i].slice(1)] = decodeURIComponent(pathSegments[i]);
2357
+ } else if (r.segments[i] !== pathSegments[i]) {
2358
+ matched = false;
2359
+ break;
2360
+ }
2361
+ }
2362
+ if (matched) {
2363
+ return { handler: r.handler, pathParams };
2364
+ }
2365
+ }
2366
+ return null;
2367
+ }
2368
+ };
2369
+ }
2370
+ function sanitizeFilename(name) {
2371
+ return name.replace(/[/\\]/g, "").replace(/\.\./g, "");
2372
+ }
2373
+ function getRulesPaths(claudeProjectKey, projectPath) {
2374
+ const home = homedir2();
2375
+ return {
2376
+ global: join2(home, ".claude", "CLAUDE.md"),
2377
+ project: claudeProjectKey ? join2(home, ".claude", "projects", claudeProjectKey, "CLAUDE.md") : join2(projectPath ?? "", ".claude", "CLAUDE.md"),
2378
+ local: projectPath ? join2(projectPath, "CLAUDE.md") : join2(home, "CLAUDE.md")
2379
+ };
2380
+ }
2381
+ function execGit(args, cwd) {
2382
+ return execFileSync("git", args, { cwd, encoding: "utf-8", timeout: 1e4, maxBuffer: 10 * 1024 * 1024 });
2383
+ }
2384
+ function isGitRepo(path) {
2385
+ try {
2386
+ execFileSync("git", ["rev-parse", "--git-dir"], { cwd: path, encoding: "utf-8", timeout: 3e3 });
2387
+ return true;
2388
+ } catch {
2389
+ return false;
2390
+ }
2391
+ }
2392
+ function parseGitStatus(porcelain) {
2393
+ const staged = [];
2394
+ const unstaged = [];
2395
+ const untracked = [];
2396
+ for (const line of porcelain.split("\n")) {
2397
+ if (!line || line.length < 4) continue;
2398
+ const x = line[0];
2399
+ const y = line[1];
2400
+ const filepath = line.slice(3);
2401
+ let path = filepath;
2402
+ let oldPath;
2403
+ if (filepath.includes(" -> ")) {
2404
+ const parts = filepath.split(" -> ");
2405
+ oldPath = parts[0];
2406
+ path = parts[1];
2407
+ }
2408
+ if (x === "?" && y === "?") {
2409
+ untracked.push({ path, status: "?" });
2410
+ continue;
2411
+ }
2412
+ if (x !== " " && x !== "?") {
2413
+ staged.push({ path, status: x, oldPath });
2414
+ }
2415
+ if (y !== " " && y !== "?") {
2416
+ unstaged.push({ path, status: y });
2417
+ }
2418
+ }
2419
+ return { staged, unstaged, untracked };
2420
+ }
2421
+ async function readRuleFile(filePath) {
2422
+ try {
2423
+ if (existsSync2(filePath)) {
2424
+ const content = await readFile(filePath, "utf-8");
2425
+ return { path: filePath, content, exists: true };
2426
+ }
2427
+ } catch {
2428
+ }
2429
+ return { path: filePath, content: "", exists: false };
2430
+ }
2431
+
2432
+ // src/http-server.ts
2433
+ var HttpServer = class {
2434
+ server = null;
2435
+ wss = null;
2436
+ store;
2437
+ processMonitor;
2438
+ authManager;
2439
+ allowedOrigins;
2440
+ rateLimiter;
2441
+ dashboardClients = /* @__PURE__ */ new Set();
2442
+ eventListener = null;
2443
+ routes = /* @__PURE__ */ new Map();
2444
+ pmRouter = null;
2445
+ sdkBundlePath = null;
2446
+ activePort = 9091;
2447
+ startedAt = Date.now();
2448
+ constructor(store, processMonitor, options) {
2449
+ this.store = store;
2450
+ this.processMonitor = processMonitor ?? null;
2451
+ this.authManager = options?.authManager ?? null;
2452
+ this.allowedOrigins = options?.allowedOrigins ?? null;
2453
+ this.rateLimiter = options?.rateLimiter ?? null;
2454
+ this.registerRoutes();
2455
+ if (options?.pmStore && options?.discovery) {
2456
+ this.pmRouter = createPmRouter(options.pmStore, options.discovery, {
2457
+ json: (res, data, status) => this.json(res, data, status),
2458
+ readBody: (req, maxBytes) => this.readBody(req, maxBytes)
2459
+ }, (msg) => this.broadcastDevServer(msg));
2460
+ }
2461
+ }
2462
+ registerRoutes() {
2463
+ this.routes.set("GET /api/health", (_req, res) => {
2464
+ this.json(res, {
2465
+ status: "ok",
2466
+ timestamp: Date.now(),
2467
+ uptime: Math.floor((Date.now() - this.startedAt) / 1e3),
2468
+ sessions: this.store.getSessionInfo().filter((s) => s.isConnected).length,
2469
+ authEnabled: this.authManager?.isEnabled() ?? false
2470
+ });
2471
+ });
2472
+ this.routes.set("GET /api/sessions", (_req, res) => {
2473
+ const sessions = this.store.getSessionInfo();
2474
+ this.json(res, { data: sessions, count: sessions.length });
2475
+ });
2476
+ this.routes.set("GET /api/projects", (_req, res) => {
2477
+ const sessions = this.store.getSessionInfo();
2478
+ const projectMap = /* @__PURE__ */ new Map();
2479
+ for (const s of sessions) {
2480
+ const existing = projectMap.get(s.appName);
2481
+ if (existing) {
2482
+ existing.sessions.push(s.sessionId);
2483
+ existing.eventCount += s.eventCount;
2484
+ if (s.isConnected) existing.isConnected = true;
2485
+ } else {
2486
+ projectMap.set(s.appName, {
2487
+ appName: s.appName,
2488
+ sessions: [s.sessionId],
2489
+ isConnected: s.isConnected,
2490
+ eventCount: s.eventCount
2491
+ });
2492
+ }
2493
+ }
2494
+ const projects = Array.from(projectMap.values());
2495
+ this.json(res, { data: projects, count: projects.length });
2496
+ });
2497
+ this.routes.set("GET /api/processes", (_req, res, params) => {
2498
+ if (!this.processMonitor) {
2499
+ this.json(res, { data: [], count: 0 });
2500
+ return;
2501
+ }
2502
+ const type = params.get("type") ?? void 0;
2503
+ const project = params.get("project") ?? void 0;
2504
+ const processes = this.processMonitor.getProcesses({ type, project });
2505
+ this.json(res, { data: processes, count: processes.length });
2506
+ });
2507
+ this.routes.set("DELETE /api/processes", async (req, res, params) => {
2508
+ if (!this.processMonitor) {
2509
+ this.json(res, { error: "Process monitor not available" }, 500);
2510
+ return;
2511
+ }
2512
+ const pid = numParam(params, "pid");
2513
+ if (!pid) {
2514
+ const body = await this.readBody(req, 1024);
2515
+ const parsed = body ? JSON.parse(body) : {};
2516
+ if (!parsed.pid) {
2517
+ this.json(res, { error: "pid is required" }, 400);
2518
+ return;
2519
+ }
2520
+ const result2 = this.processMonitor.killProcess(parsed.pid, parsed.signal ?? "SIGTERM");
2521
+ this.json(res, { data: result2 });
2522
+ return;
2523
+ }
2524
+ const signal = params.get("signal") ?? "SIGTERM";
2525
+ const result = this.processMonitor.killProcess(pid, signal);
2526
+ this.json(res, { data: result });
2527
+ });
2528
+ this.routes.set("GET /api/ports", (_req, res, params) => {
2529
+ if (!this.processMonitor) {
2530
+ this.json(res, { data: [], count: 0 });
2531
+ return;
2532
+ }
2533
+ const port = numParam(params, "port");
2534
+ const ports = this.processMonitor.getPortUsage(port);
2535
+ this.json(res, { data: ports, count: ports.length });
2536
+ });
2537
+ this.routes.set("GET /api/events/network", (_req, res, params) => {
2538
+ const events = this.store.getNetworkRequests({
2539
+ sinceSeconds: numParam(params, "since_seconds"),
2540
+ urlPattern: params.get("url_pattern") ?? void 0,
2541
+ method: params.get("method") ?? void 0,
2542
+ sessionId: params.get("session_id") ?? void 0
2543
+ });
2544
+ this.json(res, { data: events, count: events.length });
2545
+ });
2546
+ this.routes.set("GET /api/events/console", (_req, res, params) => {
2547
+ const events = this.store.getConsoleMessages({
2548
+ sinceSeconds: numParam(params, "since_seconds"),
2549
+ level: params.get("level") ?? void 0,
2550
+ search: params.get("search") ?? void 0,
2551
+ sessionId: params.get("session_id") ?? void 0
2552
+ });
2553
+ this.json(res, { data: events, count: events.length });
2554
+ });
2555
+ this.routes.set("GET /api/events/state", (_req, res, params) => {
2556
+ const events = this.store.getStateEvents({
2557
+ sinceSeconds: numParam(params, "since_seconds"),
2558
+ storeId: params.get("store_id") ?? void 0,
2559
+ sessionId: params.get("session_id") ?? void 0
2560
+ });
2561
+ this.json(res, { data: events, count: events.length });
2562
+ });
2563
+ this.routes.set("GET /api/events/renders", (_req, res, params) => {
2564
+ const events = this.store.getRenderEvents({
2565
+ sinceSeconds: numParam(params, "since_seconds"),
2566
+ componentName: params.get("component") ?? void 0,
2567
+ sessionId: params.get("session_id") ?? void 0
2568
+ });
2569
+ this.json(res, { data: events, count: events.length });
2570
+ });
2571
+ this.routes.set("GET /api/events/performance", (_req, res, params) => {
2572
+ const events = this.store.getPerformanceMetrics({
2573
+ sinceSeconds: numParam(params, "since_seconds"),
2574
+ metricName: params.get("metric") ?? void 0,
2575
+ sessionId: params.get("session_id") ?? void 0
2576
+ });
2577
+ this.json(res, { data: events, count: events.length });
2578
+ });
2579
+ this.routes.set("GET /api/events/database", (_req, res, params) => {
2580
+ const events = this.store.getDatabaseEvents({
2581
+ sinceSeconds: numParam(params, "since_seconds"),
2582
+ table: params.get("table") ?? void 0,
2583
+ minDurationMs: numParam(params, "min_duration_ms"),
2584
+ search: params.get("search") ?? void 0,
2585
+ sessionId: params.get("session_id") ?? void 0
2586
+ });
2587
+ this.json(res, { data: events, count: events.length });
2588
+ });
2589
+ this.routes.set("GET /api/events/timeline", (_req, res, params) => {
2590
+ const eventTypes = params.get("event_types")?.split(",") ?? void 0;
2591
+ const events = this.store.getEventTimeline({
2592
+ sinceSeconds: numParam(params, "since_seconds"),
2593
+ eventTypes,
2594
+ sessionId: params.get("session_id") ?? void 0
2595
+ });
2596
+ this.json(res, { data: events, count: events.length });
2597
+ });
2598
+ this.routes.set("GET /api/events/custom", (_req, res, params) => {
2599
+ const events = this.store.getCustomEvents({
2600
+ name: params.get("name") ?? void 0,
2601
+ sinceSeconds: numParam(params, "since_seconds"),
2602
+ sessionId: params.get("session_id") ?? void 0
2603
+ });
2604
+ this.json(res, { data: events, count: events.length });
2605
+ });
2606
+ this.routes.set("DELETE /api/events", (_req, res) => {
2607
+ const result = this.store.clear();
2608
+ this.json(res, result);
2609
+ });
2610
+ this.routes.set("POST /api/events", async (req, res) => {
2611
+ const body = await this.readBody(req, 1048576);
2612
+ if (!body) {
2613
+ this.json(res, { error: "Request body required", code: "EMPTY_BODY" }, 400);
2614
+ return;
2615
+ }
2616
+ let parsed;
2617
+ try {
2618
+ parsed = JSON.parse(body);
2619
+ } catch {
2620
+ this.json(res, { error: "Invalid JSON", code: "PARSE_ERROR" }, 400);
2621
+ return;
2622
+ }
2623
+ const payload = parsed;
2624
+ if (!payload.sessionId || !Array.isArray(payload.events) || payload.events.length === 0) {
2625
+ this.json(res, {
2626
+ error: "Required: sessionId (string), events (non-empty array)",
2627
+ code: "INVALID_PAYLOAD"
2628
+ }, 400);
2629
+ return;
2630
+ }
2631
+ const sessions = this.store.getSessionInfo();
2632
+ const knownSession = sessions.find((s) => s.sessionId === payload.sessionId);
2633
+ if (!knownSession && payload.appName) {
2634
+ this.store.addEvent({
2635
+ eventId: `session-${payload.sessionId}`,
2636
+ sessionId: payload.sessionId,
2637
+ timestamp: Date.now(),
2638
+ eventType: "session",
2639
+ appName: payload.appName,
2640
+ connectedAt: Date.now(),
2641
+ sdkVersion: payload.sdkVersion ?? "http"
2642
+ });
2643
+ }
2644
+ const VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
2645
+ "network",
2646
+ "console",
2647
+ "session",
2648
+ "state",
2649
+ "render",
2650
+ "dom_snapshot",
2651
+ "performance",
2652
+ "database",
2653
+ "recon_metadata",
2654
+ "recon_design_tokens",
2655
+ "recon_fonts",
2656
+ "recon_layout_tree",
2657
+ "recon_accessibility",
2658
+ "recon_computed_styles",
2659
+ "recon_element_snapshot",
2660
+ "recon_asset_inventory"
2661
+ ]);
2662
+ let accepted = 0;
2663
+ let dropped = 0;
2664
+ let rejected = 0;
2665
+ for (const raw of payload.events) {
2666
+ const event = raw;
2667
+ if (!event.eventType || !VALID_EVENT_TYPES.has(event.eventType)) {
2668
+ rejected++;
2669
+ continue;
2670
+ }
2671
+ if (!event.eventId) event.eventId = `http-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2672
+ if (!event.sessionId) event.sessionId = payload.sessionId;
2673
+ if (!event.timestamp) event.timestamp = Date.now();
2674
+ if (this.rateLimiter && !this.rateLimiter.allow(payload.sessionId)) {
2675
+ dropped++;
2676
+ continue;
2677
+ }
2678
+ this.store.addEvent(event);
2679
+ accepted++;
2680
+ }
2681
+ this.json(res, { accepted, dropped, rejected, sessionId: payload.sessionId }, accepted > 0 ? 200 : 429);
2682
+ });
2683
+ }
2684
+ /**
2685
+ * Resolve the SDK IIFE bundle path.
2686
+ * Tries multiple locations for monorepo and installed-package scenarios.
2687
+ */
2688
+ resolveSdkPath() {
2689
+ if (this.sdkBundlePath) return this.sdkBundlePath;
2690
+ const __dir = dirname(fileURLToPath(import.meta.url));
2691
+ const candidates = [
2692
+ resolve(__dir, "../../sdk/dist/index.global.js"),
2693
+ // monorepo: packages/collector/dist -> packages/sdk/dist
2694
+ resolve(__dir, "../../../node_modules/@runtimescope/sdk/dist/index.global.js")
2695
+ // npm installed
2696
+ ];
2697
+ for (const p of candidates) {
2698
+ if (existsSync3(p)) {
2699
+ this.sdkBundlePath = p;
2700
+ return p;
2701
+ }
2702
+ }
2703
+ return null;
2704
+ }
2705
+ async start(options = {}) {
2706
+ const basePort = options.port ?? parseInt(process.env.RUNTIMESCOPE_HTTP_PORT ?? "9091", 10);
2707
+ const host = options.host ?? "127.0.0.1";
2708
+ const tls = options.tls;
2709
+ const maxRetries = 5;
2710
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2711
+ const port = basePort + attempt;
2712
+ try {
2713
+ await this.tryStart(port, host, tls);
2714
+ return;
2715
+ } catch (err) {
2716
+ const isAddrInUse = err.code === "EADDRINUSE";
2717
+ if (isAddrInUse && attempt < maxRetries) {
2718
+ console.error(`[RuntimeScope] HTTP port ${port} in use, trying ${port + 1}...`);
2719
+ continue;
2720
+ }
2721
+ throw err;
2722
+ }
2723
+ }
2724
+ }
2725
+ tryStart(port, host, tls) {
2726
+ return new Promise((resolve2, reject) => {
2727
+ const handler = (req, res) => this.handleRequest(req, res);
2728
+ const server = tls ? createHttpsServer2(loadTlsOptions(tls), handler) : createServer(handler);
2729
+ this.wss = new WebSocketServer2({ server, path: "/api/ws/events" });
2730
+ this.wss.on("connection", (ws, req) => {
2731
+ if (this.authManager?.isEnabled()) {
2732
+ const wsUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
2733
+ const token = wsUrl.searchParams.get("token") ?? AuthManager.extractBearer(req.headers.authorization);
2734
+ if (!this.authManager.isAuthorized(token)) {
2735
+ ws.close(4001, "Authentication required");
2736
+ return;
2737
+ }
2738
+ }
2739
+ this.dashboardClients.add(ws);
2740
+ ws.on("close", () => this.dashboardClients.delete(ws));
2741
+ ws.on("error", () => this.dashboardClients.delete(ws));
2742
+ });
2743
+ this.eventListener = (event) => this.broadcastEvent(event);
2744
+ this.store.onEvent(this.eventListener);
2745
+ server.on("listening", () => {
2746
+ this.server = server;
2747
+ this.activePort = port;
2748
+ this.startedAt = Date.now();
2749
+ const proto = tls ? "https" : "http";
2750
+ console.error(`[RuntimeScope] HTTP API listening on ${proto}://${host}:${port}`);
2751
+ resolve2();
2752
+ });
2753
+ server.on("error", (err) => {
2754
+ this.wss?.close();
2755
+ this.wss = null;
2756
+ if (this.eventListener) {
2757
+ this.store.removeEventListener(this.eventListener);
2758
+ this.eventListener = null;
2759
+ }
2760
+ reject(err);
2761
+ });
2762
+ server.listen(port, host);
2763
+ });
2764
+ }
2765
+ async stop() {
2766
+ if (this.eventListener) {
2767
+ this.store.removeEventListener(this.eventListener);
2768
+ this.eventListener = null;
2769
+ }
2770
+ for (const ws of this.dashboardClients) {
2771
+ ws.close();
2772
+ }
2773
+ this.dashboardClients.clear();
2774
+ if (this.wss) {
2775
+ this.wss.close();
2776
+ this.wss = null;
2777
+ }
2778
+ if (this.server) {
2779
+ return new Promise((resolve2) => {
2780
+ this.server.close(() => {
2781
+ this.server = null;
2782
+ console.error("[RuntimeScope] HTTP API stopped");
2783
+ resolve2();
2784
+ });
2785
+ });
2786
+ }
2787
+ }
2788
+ broadcastEvent(event) {
2789
+ if (this.dashboardClients.size === 0) return;
2790
+ const message = JSON.stringify({ type: "event", data: event });
2791
+ for (const ws of this.dashboardClients) {
2792
+ if (ws.readyState === 1) {
2793
+ try {
2794
+ ws.send(message);
2795
+ } catch {
2796
+ this.dashboardClients.delete(ws);
2797
+ }
2798
+ }
2799
+ }
2800
+ }
2801
+ broadcastSessionChange(type, sessionId, appName) {
2802
+ if (this.dashboardClients.size === 0) return;
2803
+ const message = JSON.stringify({ type, sessionId, appName });
2804
+ for (const ws of this.dashboardClients) {
2805
+ if (ws.readyState === 1) {
2806
+ try {
2807
+ ws.send(message);
2808
+ } catch {
2809
+ this.dashboardClients.delete(ws);
2810
+ }
2811
+ }
2812
+ }
2813
+ }
2814
+ broadcastDevServer(msg) {
2815
+ if (this.dashboardClients.size === 0) return;
2816
+ const message = JSON.stringify(msg);
2817
+ for (const ws of this.dashboardClients) {
2818
+ if (ws.readyState === 1) {
2819
+ try {
2820
+ ws.send(message);
2821
+ } catch {
2822
+ this.dashboardClients.delete(ws);
2823
+ }
2824
+ }
2825
+ }
2826
+ }
2827
+ handleRequest(req, res) {
2828
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
2829
+ this.setCorsHeaders(req, res);
2830
+ if (req.method === "OPTIONS") {
2831
+ res.writeHead(204);
2832
+ res.end();
2833
+ return;
2834
+ }
2835
+ const isPublic = url.pathname === "/api/health" || url.pathname === "/runtimescope.js" || url.pathname === "/snippet";
2836
+ if (!isPublic && this.authManager?.isEnabled()) {
2837
+ const token = AuthManager.extractBearer(req.headers.authorization);
2838
+ if (!this.authManager.isAuthorized(token)) {
2839
+ this.json(res, { error: "Unauthorized", code: "AUTH_FAILED" }, 401);
2840
+ return;
2841
+ }
2842
+ }
2843
+ if (req.method === "GET" && url.pathname === "/runtimescope.js") {
2844
+ const sdkPath = this.resolveSdkPath();
2845
+ if (sdkPath) {
2846
+ const bundle = readFileSync3(sdkPath, "utf-8");
2847
+ res.writeHead(200, {
2848
+ "Content-Type": "application/javascript",
2849
+ "Cache-Control": "no-cache"
2850
+ });
2851
+ res.end(bundle);
2852
+ } else {
2853
+ res.writeHead(404, { "Content-Type": "text/plain" });
2854
+ res.end("SDK bundle not found. Run: npm run build -w packages/sdk");
2855
+ }
2856
+ return;
2857
+ }
2858
+ if (req.method === "GET" && url.pathname === "/snippet") {
2859
+ const appName = (url.searchParams.get("app") || "my-app").replace(/[^a-zA-Z0-9_-]/g, "");
2860
+ const wsPort = process.env.RUNTIMESCOPE_PORT ?? "9090";
2861
+ const snippet = `<!-- RuntimeScope SDK \u2014 paste before </body> -->
2862
+ <script src="http://localhost:${this.activePort}/runtimescope.js"></script>
2863
+ <script>
2864
+ RuntimeScope.init({
2865
+ appName: '${appName}',
2866
+ endpoint: 'ws://localhost:${wsPort}',
2867
+ });
2868
+ </script>`;
2869
+ res.writeHead(200, {
2870
+ "Content-Type": "text/plain"
2871
+ });
2872
+ res.end(snippet);
2873
+ return;
2874
+ }
2875
+ const routeKey = `${req.method} ${url.pathname}`;
2876
+ const handler = this.routes.get(routeKey);
2877
+ if (handler) {
2878
+ try {
2879
+ const result = handler(req, res, url.searchParams);
2880
+ if (result instanceof Promise) {
2881
+ result.catch((err) => {
2882
+ this.json(res, { error: err.message }, 500);
2883
+ });
2884
+ }
2885
+ } catch (err) {
2886
+ this.json(res, { error: err.message }, 500);
2887
+ }
2888
+ return;
2889
+ }
2890
+ if (this.pmRouter && url.pathname.startsWith("/api/pm/")) {
2891
+ const match = this.pmRouter.match(req.method, url.pathname);
2892
+ if (match) {
2893
+ for (const [k, v] of Object.entries(match.pathParams)) {
2894
+ url.searchParams.set(k, v);
2895
+ }
2896
+ try {
2897
+ const result = match.handler(req, res, url.searchParams);
2898
+ if (result instanceof Promise) {
2899
+ result.catch((err) => {
2900
+ this.json(res, { error: err.message }, 500);
2901
+ });
2902
+ }
2903
+ } catch (err) {
2904
+ this.json(res, { error: err.message }, 500);
2905
+ }
2906
+ return;
2907
+ }
2908
+ }
2909
+ this.json(res, { error: "Not found", path: url.pathname }, 404);
2910
+ }
2911
+ setCorsHeaders(req, res) {
2912
+ const origin = req.headers.origin;
2913
+ if (this.allowedOrigins && this.allowedOrigins.length > 0) {
2914
+ if (origin && this.allowedOrigins.includes(origin)) {
2915
+ res.setHeader("Access-Control-Allow-Origin", origin);
2916
+ res.setHeader("Vary", "Origin");
2917
+ }
2918
+ } else {
2919
+ res.setHeader("Access-Control-Allow-Origin", "*");
2920
+ }
2921
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
2922
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
2923
+ }
2924
+ readBody(req, maxBytes) {
2925
+ return new Promise((resolve2) => {
2926
+ const chunks = [];
2927
+ let size = 0;
2928
+ let resolved = false;
2929
+ const done = (result) => {
2930
+ if (resolved) return;
2931
+ resolved = true;
2932
+ clearTimeout(timer);
2933
+ resolve2(result);
2934
+ };
2935
+ const timer = setTimeout(() => {
2936
+ req.destroy();
2937
+ done(null);
2938
+ }, 3e4);
2939
+ req.on("data", (chunk) => {
2940
+ size += chunk.length;
2941
+ if (size > maxBytes) {
2942
+ req.destroy();
2943
+ done(null);
2944
+ return;
2945
+ }
2946
+ chunks.push(chunk);
2947
+ });
2948
+ req.on("end", () => {
2949
+ if (size === 0) {
2950
+ done(null);
2951
+ return;
2952
+ }
2953
+ done(Buffer.concat(chunks).toString("utf-8"));
2954
+ });
2955
+ req.on("error", () => done(null));
2956
+ });
2957
+ }
2958
+ json(res, data, status = 200) {
2959
+ res.writeHead(status, { "Content-Type": "application/json" });
2960
+ res.end(JSON.stringify(data));
2961
+ }
2962
+ };
2963
+ function numParam(params, key) {
2964
+ const val = params.get(key);
2965
+ if (!val) return void 0;
2966
+ const num = parseInt(val, 10);
2967
+ return isNaN(num) ? void 0 : num;
2968
+ }
2969
+
2970
+ // src/pm/pm-store.ts
2971
+ import Database2 from "better-sqlite3";
2972
+ var PmStore = class {
2973
+ db;
2974
+ constructor(options) {
2975
+ this.db = new Database2(options.dbPath);
2976
+ if (options.walMode !== false) {
2977
+ this.db.pragma("journal_mode = WAL");
2978
+ }
2979
+ this.db.pragma("synchronous = NORMAL");
2980
+ this.createSchema();
2981
+ this.runMigrations();
2982
+ }
2983
+ createSchema() {
2984
+ this.db.exec(`
2985
+ CREATE TABLE IF NOT EXISTS pm_projects (
2986
+ id TEXT PRIMARY KEY,
2987
+ name TEXT NOT NULL,
2988
+ path TEXT,
2989
+ claude_project_key TEXT,
2990
+ runtimescope_project TEXT,
2991
+ phase TEXT NOT NULL DEFAULT 'preliminary',
2992
+ management_authorized INTEGER NOT NULL DEFAULT 0,
2993
+ probable_to_complete INTEGER NOT NULL DEFAULT 0,
2994
+ project_status TEXT NOT NULL DEFAULT 'active',
2995
+ created_at INTEGER NOT NULL,
2996
+ updated_at INTEGER NOT NULL,
2997
+ metadata TEXT
2998
+ );
2999
+
3000
+ CREATE TABLE IF NOT EXISTS pm_tasks (
3001
+ id TEXT PRIMARY KEY,
3002
+ project_id TEXT,
3003
+ title TEXT NOT NULL,
3004
+ description TEXT,
3005
+ status TEXT NOT NULL DEFAULT 'todo',
3006
+ priority TEXT NOT NULL DEFAULT 'medium',
3007
+ labels TEXT,
3008
+ source TEXT DEFAULT 'manual',
3009
+ source_ref TEXT,
3010
+ sort_order REAL NOT NULL DEFAULT 0,
3011
+ assigned_to TEXT,
3012
+ due_date TEXT,
3013
+ created_at INTEGER NOT NULL,
3014
+ updated_at INTEGER NOT NULL,
3015
+ completed_at INTEGER,
3016
+ FOREIGN KEY (project_id) REFERENCES pm_projects(id)
3017
+ );
3018
+
3019
+ CREATE INDEX IF NOT EXISTS idx_pm_tasks_project ON pm_tasks(project_id);
3020
+ CREATE INDEX IF NOT EXISTS idx_pm_tasks_status ON pm_tasks(status);
3021
+ CREATE INDEX IF NOT EXISTS idx_pm_tasks_sort ON pm_tasks(status, sort_order);
3022
+
3023
+ CREATE TABLE IF NOT EXISTS pm_sessions (
3024
+ id TEXT PRIMARY KEY,
3025
+ project_id TEXT NOT NULL,
3026
+ jsonl_path TEXT NOT NULL,
3027
+ jsonl_size INTEGER,
3028
+ first_prompt TEXT,
3029
+ summary TEXT,
3030
+ slug TEXT,
3031
+ model TEXT,
3032
+ version TEXT,
3033
+ git_branch TEXT,
3034
+ message_count INTEGER NOT NULL DEFAULT 0,
3035
+ user_message_count INTEGER NOT NULL DEFAULT 0,
3036
+ assistant_message_count INTEGER NOT NULL DEFAULT 0,
3037
+ total_input_tokens INTEGER NOT NULL DEFAULT 0,
3038
+ total_output_tokens INTEGER NOT NULL DEFAULT 0,
3039
+ total_cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
3040
+ total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3041
+ cost_microdollars INTEGER NOT NULL DEFAULT 0,
3042
+ started_at INTEGER NOT NULL,
3043
+ ended_at INTEGER,
3044
+ active_minutes REAL NOT NULL DEFAULT 0,
3045
+ compaction_count INTEGER NOT NULL DEFAULT 0,
3046
+ pre_compaction_tokens INTEGER,
3047
+ permission_mode TEXT,
3048
+ created_at INTEGER NOT NULL,
3049
+ updated_at INTEGER NOT NULL,
3050
+ FOREIGN KEY (project_id) REFERENCES pm_projects(id)
3051
+ );
3052
+
3053
+ CREATE INDEX IF NOT EXISTS idx_pm_sessions_project ON pm_sessions(project_id);
3054
+ CREATE INDEX IF NOT EXISTS idx_pm_sessions_started ON pm_sessions(started_at DESC);
3055
+
3056
+ CREATE TABLE IF NOT EXISTS pm_notes (
3057
+ id TEXT PRIMARY KEY,
3058
+ project_id TEXT,
3059
+ session_id TEXT,
3060
+ title TEXT NOT NULL,
3061
+ content TEXT NOT NULL DEFAULT '',
3062
+ pinned INTEGER NOT NULL DEFAULT 0,
3063
+ tags TEXT,
3064
+ created_at INTEGER NOT NULL,
3065
+ updated_at INTEGER NOT NULL,
3066
+ FOREIGN KEY (project_id) REFERENCES pm_projects(id),
3067
+ FOREIGN KEY (session_id) REFERENCES pm_sessions(id)
3068
+ );
3069
+
3070
+ CREATE INDEX IF NOT EXISTS idx_pm_notes_project ON pm_notes(project_id);
3071
+ CREATE INDEX IF NOT EXISTS idx_pm_notes_pinned ON pm_notes(pinned DESC, updated_at DESC);
3072
+
3073
+ CREATE TABLE IF NOT EXISTS pm_capex_entries (
3074
+ id TEXT PRIMARY KEY,
3075
+ project_id TEXT NOT NULL,
3076
+ session_id TEXT NOT NULL,
3077
+ classification TEXT NOT NULL DEFAULT 'expensed',
3078
+ work_type TEXT,
3079
+ active_minutes REAL NOT NULL DEFAULT 0,
3080
+ cost_microdollars INTEGER NOT NULL DEFAULT 0,
3081
+ adjustment_factor REAL NOT NULL DEFAULT 1.0,
3082
+ adjusted_cost_microdollars INTEGER NOT NULL DEFAULT 0,
3083
+ confirmed INTEGER NOT NULL DEFAULT 0,
3084
+ confirmed_at INTEGER,
3085
+ confirmed_by TEXT,
3086
+ notes TEXT,
3087
+ period TEXT NOT NULL,
3088
+ created_at INTEGER NOT NULL,
3089
+ updated_at INTEGER NOT NULL,
3090
+ FOREIGN KEY (project_id) REFERENCES pm_projects(id),
3091
+ FOREIGN KEY (session_id) REFERENCES pm_sessions(id)
3092
+ );
3093
+
3094
+ CREATE INDEX IF NOT EXISTS idx_pm_capex_project ON pm_capex_entries(project_id);
3095
+ CREATE INDEX IF NOT EXISTS idx_pm_capex_period ON pm_capex_entries(period);
3096
+ CREATE INDEX IF NOT EXISTS idx_pm_capex_confirmed ON pm_capex_entries(confirmed);
3097
+ `);
3098
+ }
3099
+ runMigrations() {
3100
+ try {
3101
+ this.db.exec("ALTER TABLE pm_projects ADD COLUMN category TEXT DEFAULT NULL");
3102
+ } catch {
3103
+ }
3104
+ try {
3105
+ this.db.exec("ALTER TABLE pm_projects ADD COLUMN sdk_installed INTEGER DEFAULT 0");
3106
+ } catch {
3107
+ }
3108
+ }
3109
+ // ============================================================
3110
+ // Projects
3111
+ // ============================================================
3112
+ upsertProject(project) {
3113
+ this.db.prepare(`
3114
+ INSERT INTO pm_projects (id, name, path, claude_project_key, runtimescope_project,
3115
+ phase, management_authorized, probable_to_complete, project_status,
3116
+ category, sdk_installed,
3117
+ created_at, updated_at, metadata)
3118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3119
+ ON CONFLICT(id) DO UPDATE SET
3120
+ name = excluded.name,
3121
+ path = COALESCE(excluded.path, pm_projects.path),
3122
+ claude_project_key = COALESCE(excluded.claude_project_key, pm_projects.claude_project_key),
3123
+ runtimescope_project = COALESCE(excluded.runtimescope_project, pm_projects.runtimescope_project),
3124
+ sdk_installed = CASE WHEN excluded.sdk_installed = 1 THEN 1 ELSE pm_projects.sdk_installed END,
3125
+ updated_at = excluded.updated_at,
3126
+ metadata = COALESCE(excluded.metadata, pm_projects.metadata)
3127
+ `).run(
3128
+ project.id,
3129
+ project.name,
3130
+ project.path ?? null,
3131
+ project.claudeProjectKey ?? null,
3132
+ project.runtimescopeProject ?? null,
3133
+ project.phase,
3134
+ project.managementAuthorized ? 1 : 0,
3135
+ project.probableToComplete ? 1 : 0,
3136
+ project.projectStatus,
3137
+ project.category ?? null,
3138
+ project.sdkInstalled ? 1 : 0,
3139
+ project.createdAt,
3140
+ project.updatedAt,
3141
+ project.metadata ? JSON.stringify(project.metadata) : null
3142
+ );
3143
+ }
3144
+ getProject(id) {
3145
+ const row = this.db.prepare("SELECT * FROM pm_projects WHERE id = ?").get(id);
3146
+ return row ? this.mapProjectRow(row) : null;
3147
+ }
3148
+ listProjects() {
3149
+ const rows = this.db.prepare("SELECT * FROM pm_projects ORDER BY name ASC").all();
3150
+ return rows.map((r) => this.mapProjectRow(r));
3151
+ }
3152
+ updateProject(id, updates) {
3153
+ const sets = [];
3154
+ const params = [];
3155
+ if (updates.name !== void 0) {
3156
+ sets.push("name = ?");
3157
+ params.push(updates.name);
3158
+ }
3159
+ if (updates.phase !== void 0) {
3160
+ sets.push("phase = ?");
3161
+ params.push(updates.phase);
3162
+ }
3163
+ if (updates.managementAuthorized !== void 0) {
3164
+ sets.push("management_authorized = ?");
3165
+ params.push(updates.managementAuthorized ? 1 : 0);
3166
+ }
3167
+ if (updates.probableToComplete !== void 0) {
3168
+ sets.push("probable_to_complete = ?");
3169
+ params.push(updates.probableToComplete ? 1 : 0);
3170
+ }
3171
+ if (updates.projectStatus !== void 0) {
3172
+ sets.push("project_status = ?");
3173
+ params.push(updates.projectStatus);
3174
+ }
3175
+ if (updates.category !== void 0) {
3176
+ sets.push("category = ?");
3177
+ params.push(updates.category);
3178
+ }
3179
+ if (updates.sdkInstalled !== void 0) {
3180
+ sets.push("sdk_installed = ?");
3181
+ params.push(updates.sdkInstalled ? 1 : 0);
3182
+ }
3183
+ if (updates.metadata !== void 0) {
3184
+ sets.push("metadata = ?");
3185
+ params.push(JSON.stringify(updates.metadata));
3186
+ }
3187
+ if (sets.length === 0) return;
3188
+ sets.push("updated_at = ?");
3189
+ params.push(Date.now());
3190
+ params.push(id);
3191
+ this.db.prepare(`UPDATE pm_projects SET ${sets.join(", ")} WHERE id = ?`).run(...params);
3192
+ }
3193
+ listCategories() {
3194
+ const rows = this.db.prepare("SELECT DISTINCT category FROM pm_projects WHERE category IS NOT NULL ORDER BY category ASC").all();
3195
+ return rows.map((r) => r.category);
3196
+ }
3197
+ mapProjectRow(row) {
3198
+ return {
3199
+ id: row.id,
3200
+ name: row.name,
3201
+ path: row.path ?? void 0,
3202
+ claudeProjectKey: row.claude_project_key ?? void 0,
3203
+ runtimescopeProject: row.runtimescope_project ?? void 0,
3204
+ phase: row.phase,
3205
+ managementAuthorized: row.management_authorized === 1,
3206
+ probableToComplete: row.probable_to_complete === 1,
3207
+ projectStatus: row.project_status,
3208
+ category: row.category ?? void 0,
3209
+ sdkInstalled: row.sdk_installed === 1,
3210
+ createdAt: row.created_at,
3211
+ updatedAt: row.updated_at,
3212
+ metadata: row.metadata ? JSON.parse(row.metadata) : void 0
3213
+ };
3214
+ }
3215
+ // ============================================================
3216
+ // Tasks
3217
+ // ============================================================
3218
+ createTask(task) {
3219
+ this.db.prepare(`
3220
+ INSERT INTO pm_tasks (id, project_id, title, description, status, priority,
3221
+ labels, source, source_ref, sort_order, assigned_to, due_date,
3222
+ created_at, updated_at, completed_at)
3223
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3224
+ `).run(
3225
+ task.id,
3226
+ task.projectId ?? null,
3227
+ task.title,
3228
+ task.description ?? null,
3229
+ task.status,
3230
+ task.priority,
3231
+ JSON.stringify(task.labels),
3232
+ task.source,
3233
+ task.sourceRef ?? null,
3234
+ task.sortOrder,
3235
+ task.assignedTo ?? null,
3236
+ task.dueDate ?? null,
3237
+ task.createdAt,
3238
+ task.updatedAt,
3239
+ task.completedAt ?? null
3240
+ );
3241
+ return task;
3242
+ }
3243
+ updateTask(id, updates) {
3244
+ const sets = [];
3245
+ const params = [];
3246
+ if (updates.title !== void 0) {
3247
+ sets.push("title = ?");
3248
+ params.push(updates.title);
3249
+ }
3250
+ if (updates.description !== void 0) {
3251
+ sets.push("description = ?");
3252
+ params.push(updates.description);
3253
+ }
3254
+ if (updates.status !== void 0) {
3255
+ sets.push("status = ?");
3256
+ params.push(updates.status);
3257
+ }
3258
+ if (updates.priority !== void 0) {
3259
+ sets.push("priority = ?");
3260
+ params.push(updates.priority);
3261
+ }
3262
+ if (updates.labels !== void 0) {
3263
+ sets.push("labels = ?");
3264
+ params.push(JSON.stringify(updates.labels));
3265
+ }
3266
+ if (updates.sortOrder !== void 0) {
3267
+ sets.push("sort_order = ?");
3268
+ params.push(updates.sortOrder);
3269
+ }
3270
+ if (updates.assignedTo !== void 0) {
3271
+ sets.push("assigned_to = ?");
3272
+ params.push(updates.assignedTo);
3273
+ }
3274
+ if (updates.dueDate !== void 0) {
3275
+ sets.push("due_date = ?");
3276
+ params.push(updates.dueDate);
3277
+ }
3278
+ if (updates.completedAt !== void 0) {
3279
+ sets.push("completed_at = ?");
3280
+ params.push(updates.completedAt);
3281
+ }
3282
+ if (sets.length === 0) return;
3283
+ sets.push("updated_at = ?");
3284
+ params.push(Date.now());
3285
+ params.push(id);
3286
+ this.db.prepare(`UPDATE pm_tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params);
3287
+ }
3288
+ deleteTask(id) {
3289
+ this.db.prepare("DELETE FROM pm_tasks WHERE id = ?").run(id);
3290
+ }
3291
+ listTasks(projectId, status) {
3292
+ const conditions = [];
3293
+ const params = [];
3294
+ if (projectId) {
3295
+ conditions.push("project_id = ?");
3296
+ params.push(projectId);
3297
+ }
3298
+ if (status) {
3299
+ conditions.push("status = ?");
3300
+ params.push(status);
3301
+ }
3302
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3303
+ const rows = this.db.prepare(`SELECT * FROM pm_tasks ${where} ORDER BY sort_order ASC`).all(...params);
3304
+ return rows.map((r) => this.mapTaskRow(r));
3305
+ }
3306
+ reorderTask(id, status, sortOrder) {
3307
+ const now = Date.now();
3308
+ const completedAt = status === "done" ? now : null;
3309
+ this.db.prepare(`
3310
+ UPDATE pm_tasks SET status = ?, sort_order = ?, updated_at = ?, completed_at = COALESCE(?, completed_at)
3311
+ WHERE id = ?
3312
+ `).run(status, sortOrder, now, completedAt, id);
3313
+ }
3314
+ mapTaskRow(row) {
3315
+ return {
3316
+ id: row.id,
3317
+ projectId: row.project_id ?? void 0,
3318
+ title: row.title,
3319
+ description: row.description ?? void 0,
3320
+ status: row.status,
3321
+ priority: row.priority,
3322
+ labels: row.labels ? JSON.parse(row.labels) : [],
3323
+ source: row.source ?? "manual",
3324
+ sourceRef: row.source_ref ?? void 0,
3325
+ sortOrder: row.sort_order,
3326
+ assignedTo: row.assigned_to ?? void 0,
3327
+ dueDate: row.due_date ?? void 0,
3328
+ createdAt: row.created_at,
3329
+ updatedAt: row.updated_at,
3330
+ completedAt: row.completed_at ?? void 0
3331
+ };
3332
+ }
3333
+ // ============================================================
3334
+ // Sessions
3335
+ // ============================================================
3336
+ upsertSession(session) {
3337
+ this.db.prepare(`
3338
+ INSERT INTO pm_sessions (id, project_id, jsonl_path, jsonl_size, first_prompt,
3339
+ summary, slug, model, version, git_branch,
3340
+ message_count, user_message_count, assistant_message_count,
3341
+ total_input_tokens, total_output_tokens,
3342
+ total_cache_creation_tokens, total_cache_read_tokens,
3343
+ cost_microdollars, started_at, ended_at, active_minutes,
3344
+ compaction_count, pre_compaction_tokens, permission_mode,
3345
+ created_at, updated_at)
3346
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3347
+ ON CONFLICT(id) DO UPDATE SET
3348
+ jsonl_size = excluded.jsonl_size,
3349
+ first_prompt = COALESCE(excluded.first_prompt, pm_sessions.first_prompt),
3350
+ summary = COALESCE(excluded.summary, pm_sessions.summary),
3351
+ slug = COALESCE(excluded.slug, pm_sessions.slug),
3352
+ model = COALESCE(excluded.model, pm_sessions.model),
3353
+ version = COALESCE(excluded.version, pm_sessions.version),
3354
+ git_branch = COALESCE(excluded.git_branch, pm_sessions.git_branch),
3355
+ message_count = excluded.message_count,
3356
+ user_message_count = excluded.user_message_count,
3357
+ assistant_message_count = excluded.assistant_message_count,
3358
+ total_input_tokens = excluded.total_input_tokens,
3359
+ total_output_tokens = excluded.total_output_tokens,
3360
+ total_cache_creation_tokens = excluded.total_cache_creation_tokens,
3361
+ total_cache_read_tokens = excluded.total_cache_read_tokens,
3362
+ cost_microdollars = excluded.cost_microdollars,
3363
+ ended_at = excluded.ended_at,
3364
+ active_minutes = excluded.active_minutes,
3365
+ compaction_count = excluded.compaction_count,
3366
+ pre_compaction_tokens = excluded.pre_compaction_tokens,
3367
+ permission_mode = excluded.permission_mode,
3368
+ updated_at = excluded.updated_at
3369
+ `).run(
3370
+ session.id,
3371
+ session.projectId,
3372
+ session.jsonlPath,
3373
+ session.jsonlSize ?? null,
3374
+ session.firstPrompt ?? null,
3375
+ session.summary ?? null,
3376
+ session.slug ?? null,
3377
+ session.model ?? null,
3378
+ session.version ?? null,
3379
+ session.gitBranch ?? null,
3380
+ session.messageCount,
3381
+ session.userMessageCount,
3382
+ session.assistantMessageCount,
3383
+ session.totalInputTokens,
3384
+ session.totalOutputTokens,
3385
+ session.totalCacheCreationTokens,
3386
+ session.totalCacheReadTokens,
3387
+ session.costMicrodollars,
3388
+ session.startedAt,
3389
+ session.endedAt ?? null,
3390
+ session.activeMinutes,
3391
+ session.compactionCount,
3392
+ session.preCompactionTokens ?? null,
3393
+ session.permissionMode ?? null,
3394
+ session.createdAt,
3395
+ session.updatedAt
3396
+ );
3397
+ }
3398
+ getSession(id) {
3399
+ const row = this.db.prepare("SELECT * FROM pm_sessions WHERE id = ?").get(id);
3400
+ return row ? this.mapSessionRow(row) : null;
3401
+ }
3402
+ listSessions(projectId, opts) {
3403
+ const limit = opts?.limit ?? 100;
3404
+ const offset = opts?.offset ?? 0;
3405
+ const conditions = [];
3406
+ const params = [];
3407
+ if (projectId) {
3408
+ conditions.push("project_id = ?");
3409
+ params.push(projectId);
3410
+ }
3411
+ if (opts?.startDate) {
3412
+ conditions.push("started_at >= ?");
3413
+ params.push(new Date(opts.startDate).getTime());
3414
+ }
3415
+ if (opts?.endDate) {
3416
+ conditions.push("started_at <= ?");
3417
+ params.push((/* @__PURE__ */ new Date(opts.endDate + "T23:59:59.999Z")).getTime());
3418
+ }
3419
+ if (opts?.hideEmpty) {
3420
+ conditions.push("(message_count > 0 OR total_input_tokens > 0 OR total_output_tokens > 0 OR cost_microdollars > 0 OR active_minutes > 0)");
3421
+ }
3422
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3423
+ const rows = this.db.prepare(`SELECT * FROM pm_sessions ${where} ORDER BY started_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
3424
+ return rows.map((r) => this.mapSessionRow(r));
3425
+ }
3426
+ getSessionStats(projectId, opts) {
3427
+ const conditions = [];
3428
+ const params = [];
3429
+ if (projectId) {
3430
+ conditions.push("project_id = ?");
3431
+ params.push(projectId);
3432
+ }
3433
+ if (opts?.startDate) {
3434
+ conditions.push("started_at >= ?");
3435
+ params.push(new Date(opts.startDate).getTime());
3436
+ }
3437
+ if (opts?.endDate) {
3438
+ conditions.push("started_at <= ?");
3439
+ params.push((/* @__PURE__ */ new Date(opts.endDate + "T23:59:59.999Z")).getTime());
3440
+ }
3441
+ if (opts?.hideEmpty) {
3442
+ conditions.push("(message_count > 0 OR total_input_tokens > 0 OR total_output_tokens > 0 OR cost_microdollars > 0 OR active_minutes > 0)");
3443
+ }
3444
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3445
+ const row = this.db.prepare(`
3446
+ SELECT
3447
+ COUNT(*) as total_sessions,
3448
+ COALESCE(SUM(active_minutes), 0) as total_active_minutes,
3449
+ COALESCE(SUM(cost_microdollars), 0) as total_cost,
3450
+ COALESCE(SUM(total_input_tokens), 0) as total_input,
3451
+ COALESCE(SUM(total_output_tokens), 0) as total_output,
3452
+ COALESCE(AVG(active_minutes), 0) as avg_minutes
3453
+ FROM pm_sessions ${whereClause}
3454
+ `).get(...params);
3455
+ const modelConditions = [...conditions];
3456
+ if (!modelConditions.some((c) => c.includes("model"))) {
3457
+ modelConditions.push("model IS NOT NULL");
3458
+ }
3459
+ const modelWhere = `WHERE ${modelConditions.join(" AND ")}`;
3460
+ const modelRows = this.db.prepare(`
3461
+ SELECT model, COUNT(*) as sessions, SUM(cost_microdollars) as cost
3462
+ FROM pm_sessions
3463
+ ${modelWhere}
3464
+ GROUP BY model
3465
+ ORDER BY cost DESC
3466
+ `).all(...params);
3467
+ return {
3468
+ totalSessions: row.total_sessions,
3469
+ totalActiveMinutes: row.total_active_minutes,
3470
+ totalCostMicrodollars: row.total_cost,
3471
+ totalInputTokens: row.total_input,
3472
+ totalOutputTokens: row.total_output,
3473
+ avgSessionMinutes: row.avg_minutes,
3474
+ modelBreakdown: modelRows
3475
+ };
3476
+ }
3477
+ getProjectSummaries(opts) {
3478
+ const conditions = [];
3479
+ const params = [];
3480
+ if (opts?.startDate) {
3481
+ conditions.push("s.started_at >= ?");
3482
+ params.push(new Date(opts.startDate).getTime());
3483
+ }
3484
+ if (opts?.endDate) {
3485
+ conditions.push("s.started_at <= ?");
3486
+ params.push((/* @__PURE__ */ new Date(opts.endDate + "T23:59:59.999Z")).getTime());
3487
+ }
3488
+ if (opts?.hideEmpty) {
3489
+ conditions.push("(s.message_count > 0 OR s.total_input_tokens > 0 OR s.total_output_tokens > 0 OR s.cost_microdollars > 0 OR s.active_minutes > 0)");
3490
+ }
3491
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3492
+ const rows = this.db.prepare(`
3493
+ SELECT
3494
+ p.id,
3495
+ p.name,
3496
+ p.path,
3497
+ p.category,
3498
+ p.sdk_installed,
3499
+ p.runtimescope_project,
3500
+ COUNT(s.id) as session_count,
3501
+ COALESCE(SUM(s.cost_microdollars), 0) as total_cost,
3502
+ COALESCE(SUM(s.active_minutes), 0) as total_active_minutes,
3503
+ MAX(s.started_at) as last_session_at,
3504
+ COALESCE(SUM(s.message_count), 0) as total_messages
3505
+ FROM pm_projects p
3506
+ LEFT JOIN pm_sessions s ON s.project_id = p.id ${where ? "AND " + conditions.join(" AND ") : ""}
3507
+ GROUP BY p.id
3508
+ ORDER BY last_session_at DESC NULLS LAST
3509
+ `).all(...params);
3510
+ return rows;
3511
+ }
3512
+ mapSessionRow(row) {
3513
+ return {
3514
+ id: row.id,
3515
+ projectId: row.project_id,
3516
+ jsonlPath: row.jsonl_path,
3517
+ jsonlSize: row.jsonl_size ?? void 0,
3518
+ firstPrompt: row.first_prompt ?? void 0,
3519
+ summary: row.summary ?? void 0,
3520
+ slug: row.slug ?? void 0,
3521
+ model: row.model ?? void 0,
3522
+ version: row.version ?? void 0,
3523
+ gitBranch: row.git_branch ?? void 0,
3524
+ messageCount: row.message_count,
3525
+ userMessageCount: row.user_message_count,
3526
+ assistantMessageCount: row.assistant_message_count,
3527
+ totalInputTokens: row.total_input_tokens,
3528
+ totalOutputTokens: row.total_output_tokens,
3529
+ totalCacheCreationTokens: row.total_cache_creation_tokens,
3530
+ totalCacheReadTokens: row.total_cache_read_tokens,
3531
+ costMicrodollars: row.cost_microdollars,
3532
+ startedAt: row.started_at,
3533
+ endedAt: row.ended_at ?? void 0,
3534
+ activeMinutes: row.active_minutes,
3535
+ compactionCount: row.compaction_count,
3536
+ preCompactionTokens: row.pre_compaction_tokens ?? void 0,
3537
+ permissionMode: row.permission_mode ?? void 0,
3538
+ createdAt: row.created_at,
3539
+ updatedAt: row.updated_at
3540
+ };
3541
+ }
3542
+ // ============================================================
3543
+ // Notes
3544
+ // ============================================================
3545
+ createNote(note) {
3546
+ this.db.prepare(`
3547
+ INSERT INTO pm_notes (id, project_id, session_id, title, content, pinned, tags, created_at, updated_at)
3548
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3549
+ `).run(
3550
+ note.id,
3551
+ note.projectId ?? null,
3552
+ note.sessionId ?? null,
3553
+ note.title,
3554
+ note.content,
3555
+ note.pinned ? 1 : 0,
3556
+ JSON.stringify(note.tags),
3557
+ note.createdAt,
3558
+ note.updatedAt
3559
+ );
3560
+ return note;
3561
+ }
3562
+ updateNote(id, updates) {
3563
+ const sets = [];
3564
+ const params = [];
3565
+ if (updates.title !== void 0) {
3566
+ sets.push("title = ?");
3567
+ params.push(updates.title);
3568
+ }
3569
+ if (updates.content !== void 0) {
3570
+ sets.push("content = ?");
3571
+ params.push(updates.content);
3572
+ }
3573
+ if (updates.pinned !== void 0) {
3574
+ sets.push("pinned = ?");
3575
+ params.push(updates.pinned ? 1 : 0);
3576
+ }
3577
+ if (updates.tags !== void 0) {
3578
+ sets.push("tags = ?");
3579
+ params.push(JSON.stringify(updates.tags));
3580
+ }
3581
+ if (sets.length === 0) return;
3582
+ sets.push("updated_at = ?");
3583
+ params.push(Date.now());
3584
+ params.push(id);
3585
+ this.db.prepare(`UPDATE pm_notes SET ${sets.join(", ")} WHERE id = ?`).run(...params);
3586
+ }
3587
+ deleteNote(id) {
3588
+ this.db.prepare("DELETE FROM pm_notes WHERE id = ?").run(id);
3589
+ }
3590
+ listNotes(opts) {
3591
+ const conditions = [];
3592
+ const params = [];
3593
+ if (opts?.projectId) {
3594
+ conditions.push("project_id = ?");
3595
+ params.push(opts.projectId);
3596
+ }
3597
+ if (opts?.pinned !== void 0) {
3598
+ conditions.push("pinned = ?");
3599
+ params.push(opts.pinned ? 1 : 0);
3600
+ }
3601
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3602
+ const rows = this.db.prepare(`SELECT * FROM pm_notes ${where} ORDER BY pinned DESC, updated_at DESC`).all(...params);
3603
+ return rows.map((r) => this.mapNoteRow(r));
3604
+ }
3605
+ mapNoteRow(row) {
3606
+ return {
3607
+ id: row.id,
3608
+ projectId: row.project_id ?? void 0,
3609
+ sessionId: row.session_id ?? void 0,
3610
+ title: row.title,
3611
+ content: row.content,
3612
+ pinned: row.pinned === 1,
3613
+ tags: row.tags ? JSON.parse(row.tags) : [],
3614
+ createdAt: row.created_at,
3615
+ updatedAt: row.updated_at
3616
+ };
3617
+ }
3618
+ // ============================================================
3619
+ // CapEx
3620
+ // ============================================================
3621
+ upsertCapexEntry(entry) {
3622
+ this.db.prepare(`
3623
+ INSERT INTO pm_capex_entries (id, project_id, session_id, classification, work_type,
3624
+ active_minutes, cost_microdollars, adjustment_factor, adjusted_cost_microdollars,
3625
+ confirmed, confirmed_at, confirmed_by, notes, period, created_at, updated_at)
3626
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3627
+ ON CONFLICT(id) DO UPDATE SET
3628
+ classification = excluded.classification,
3629
+ work_type = excluded.work_type,
3630
+ active_minutes = excluded.active_minutes,
3631
+ cost_microdollars = excluded.cost_microdollars,
3632
+ adjustment_factor = excluded.adjustment_factor,
3633
+ adjusted_cost_microdollars = excluded.adjusted_cost_microdollars,
3634
+ confirmed = excluded.confirmed,
3635
+ confirmed_at = excluded.confirmed_at,
3636
+ confirmed_by = excluded.confirmed_by,
3637
+ notes = excluded.notes,
3638
+ updated_at = excluded.updated_at
3639
+ `).run(
3640
+ entry.id,
3641
+ entry.projectId,
3642
+ entry.sessionId,
3643
+ entry.classification,
3644
+ entry.workType ?? null,
3645
+ entry.activeMinutes,
3646
+ entry.costMicrodollars,
3647
+ entry.adjustmentFactor,
3648
+ entry.adjustedCostMicrodollars,
3649
+ entry.confirmed ? 1 : 0,
3650
+ entry.confirmedAt ?? null,
3651
+ entry.confirmedBy ?? null,
3652
+ entry.notes ?? null,
3653
+ entry.period,
3654
+ entry.createdAt,
3655
+ entry.updatedAt
3656
+ );
3657
+ }
3658
+ listCapexEntries(projectId, opts) {
3659
+ const conditions = ["project_id = ?"];
3660
+ const params = [projectId];
3661
+ if (opts?.month) {
3662
+ conditions.push("period = ?");
3663
+ params.push(opts.month);
3664
+ }
3665
+ if (opts?.confirmed !== void 0) {
3666
+ conditions.push("confirmed = ?");
3667
+ params.push(opts.confirmed ? 1 : 0);
3668
+ }
3669
+ const where = conditions.join(" AND ");
3670
+ const rows = this.db.prepare(`SELECT * FROM pm_capex_entries WHERE ${where} ORDER BY period DESC, created_at DESC`).all(...params);
3671
+ return rows.map((r) => this.mapCapexRow(r));
3672
+ }
3673
+ updateCapexEntry(id, updates) {
3674
+ const sets = [];
3675
+ const params = [];
3676
+ if (updates.classification !== void 0) {
3677
+ sets.push("classification = ?");
3678
+ params.push(updates.classification);
3679
+ }
3680
+ if (updates.workType !== void 0) {
3681
+ sets.push("work_type = ?");
3682
+ params.push(updates.workType);
3683
+ }
3684
+ if (updates.adjustmentFactor !== void 0) {
3685
+ sets.push("adjustment_factor = ?");
3686
+ params.push(updates.adjustmentFactor);
3687
+ if (updates.costMicrodollars !== void 0) {
3688
+ sets.push("adjusted_cost_microdollars = ?");
3689
+ params.push(Math.round(updates.costMicrodollars * updates.adjustmentFactor));
3690
+ }
3691
+ }
3692
+ if (updates.notes !== void 0) {
3693
+ sets.push("notes = ?");
3694
+ params.push(updates.notes);
3695
+ }
3696
+ if (sets.length === 0) return;
3697
+ sets.push("updated_at = ?");
3698
+ params.push(Date.now());
3699
+ params.push(id);
3700
+ this.db.prepare(`UPDATE pm_capex_entries SET ${sets.join(", ")} WHERE id = ?`).run(...params);
3701
+ }
3702
+ confirmCapexEntry(id, confirmedBy) {
3703
+ const now = Date.now();
3704
+ this.db.prepare(`
3705
+ UPDATE pm_capex_entries SET confirmed = 1, confirmed_at = ?, confirmed_by = ?, updated_at = ?
3706
+ WHERE id = ?
3707
+ `).run(now, confirmedBy ?? null, now, id);
3708
+ }
3709
+ getCapexSummary(projectId, opts) {
3710
+ const conditions = ["project_id = ?"];
3711
+ const params = [projectId];
3712
+ if (opts?.startDate) {
3713
+ conditions.push("period >= ?");
3714
+ params.push(opts.startDate);
3715
+ }
3716
+ if (opts?.endDate) {
3717
+ conditions.push("period <= ?");
3718
+ params.push(opts.endDate);
3719
+ }
3720
+ const where = conditions.join(" AND ");
3721
+ const totals = this.db.prepare(`
3722
+ SELECT
3723
+ COUNT(*) as total_sessions,
3724
+ COALESCE(SUM(active_minutes), 0) as total_active_minutes,
3725
+ COALESCE(SUM(adjusted_cost_microdollars), 0) as total_cost,
3726
+ COALESCE(SUM(CASE WHEN classification = 'capitalizable' THEN adjusted_cost_microdollars ELSE 0 END), 0) as cap_cost,
3727
+ COALESCE(SUM(CASE WHEN classification = 'expensed' THEN adjusted_cost_microdollars ELSE 0 END), 0) as exp_cost,
3728
+ COALESCE(SUM(CASE WHEN confirmed = 1 THEN 1 ELSE 0 END), 0) as confirmed_count,
3729
+ COALESCE(SUM(CASE WHEN confirmed = 0 THEN 1 ELSE 0 END), 0) as unconfirmed_count
3730
+ FROM pm_capex_entries WHERE ${where}
3731
+ `).get(...params);
3732
+ const monthlyRows = this.db.prepare(`
3733
+ SELECT
3734
+ period,
3735
+ SUM(CASE WHEN classification = 'capitalizable' THEN adjusted_cost_microdollars ELSE 0 END) as capitalizable,
3736
+ SUM(CASE WHEN classification = 'expensed' THEN adjusted_cost_microdollars ELSE 0 END) as expensed,
3737
+ SUM(active_minutes) as activeMinutes
3738
+ FROM pm_capex_entries
3739
+ WHERE ${where}
3740
+ GROUP BY period
3741
+ ORDER BY period ASC
3742
+ `).all(...params);
3743
+ return {
3744
+ projectId,
3745
+ period: opts?.startDate || opts?.endDate ? { start: opts.startDate ?? "", end: opts.endDate ?? "" } : void 0,
3746
+ totalSessions: totals.total_sessions,
3747
+ totalActiveMinutes: totals.total_active_minutes,
3748
+ totalCostMicrodollars: totals.total_cost,
3749
+ capitalizableCostMicrodollars: totals.cap_cost,
3750
+ expensedCostMicrodollars: totals.exp_cost,
3751
+ confirmedCount: totals.confirmed_count,
3752
+ unconfirmedCount: totals.unconfirmed_count,
3753
+ byMonth: monthlyRows
3754
+ };
3755
+ }
3756
+ exportCapexCsv(projectId, opts) {
3757
+ const entries = this.listCapexEntries(projectId, { month: opts?.startDate });
3758
+ const sessions = /* @__PURE__ */ new Map();
3759
+ for (const entry of entries) {
3760
+ if (!sessions.has(entry.sessionId)) {
3761
+ const s = this.getSession(entry.sessionId);
3762
+ if (s) sessions.set(entry.sessionId, s);
3763
+ }
3764
+ }
3765
+ const headers = [
3766
+ "Period",
3767
+ "Session ID",
3768
+ "Session Slug",
3769
+ "Date",
3770
+ "Model",
3771
+ "Active Minutes",
3772
+ "Active Hours",
3773
+ "Cost (USD)",
3774
+ "Classification",
3775
+ "Work Type",
3776
+ "Adjustment Factor",
3777
+ "Adjusted Cost (USD)",
3778
+ "Confirmed",
3779
+ "Confirmed By",
3780
+ "Notes"
3781
+ ];
3782
+ const rows = entries.map((e) => {
3783
+ const s = sessions.get(e.sessionId);
3784
+ const date = s?.startedAt ? new Date(s.startedAt).toISOString().split("T")[0] : "";
3785
+ return [
3786
+ e.period,
3787
+ e.sessionId,
3788
+ s?.slug ?? "",
3789
+ date,
3790
+ s?.model ?? "",
3791
+ e.activeMinutes.toFixed(2),
3792
+ (e.activeMinutes / 60).toFixed(2),
3793
+ (e.costMicrodollars / 1e6).toFixed(4),
3794
+ e.classification,
3795
+ e.workType ?? "",
3796
+ e.adjustmentFactor.toFixed(2),
3797
+ (e.adjustedCostMicrodollars / 1e6).toFixed(4),
3798
+ e.confirmed ? "Yes" : "No",
3799
+ e.confirmedBy ?? "",
3800
+ (e.notes ?? "").replace(/"/g, '""')
3801
+ ].map((v) => `"${v}"`).join(",");
3802
+ });
3803
+ return [headers.join(","), ...rows].join("\n");
3804
+ }
3805
+ mapCapexRow(row) {
3806
+ return {
3807
+ id: row.id,
3808
+ projectId: row.project_id,
3809
+ sessionId: row.session_id,
3810
+ classification: row.classification,
3811
+ workType: row.work_type ?? void 0,
3812
+ activeMinutes: row.active_minutes,
3813
+ costMicrodollars: row.cost_microdollars,
3814
+ adjustmentFactor: row.adjustment_factor,
3815
+ adjustedCostMicrodollars: row.adjusted_cost_microdollars,
3816
+ confirmed: row.confirmed === 1,
3817
+ confirmedAt: row.confirmed_at ?? void 0,
3818
+ confirmedBy: row.confirmed_by ?? void 0,
3819
+ notes: row.notes ?? void 0,
3820
+ period: row.period,
3821
+ createdAt: row.created_at,
3822
+ updatedAt: row.updated_at
3823
+ };
3824
+ }
3825
+ // ============================================================
3826
+ // Cleanup
3827
+ // ============================================================
3828
+ close() {
3829
+ this.db.close();
3830
+ }
3831
+ };
3832
+
3833
+ // src/pm/session-parser.ts
3834
+ import { createReadStream } from "fs";
3835
+ import { createInterface } from "readline";
3836
+ var MODEL_PRICING = {
3837
+ "claude-opus-4-6": { input: 15e6, output: 75e6, cacheWrite: 1875e4, cacheRead: 15e5 },
3838
+ "claude-sonnet-4-6": { input: 3e6, output: 15e6, cacheWrite: 375e4, cacheRead: 3e5 },
3839
+ "claude-sonnet-4-5": { input: 3e6, output: 15e6, cacheWrite: 375e4, cacheRead: 3e5 },
3840
+ "claude-haiku-4-5": { input: 8e5, output: 4e6, cacheWrite: 1e6, cacheRead: 8e4 },
3841
+ "claude-haiku-3-5": { input: 8e5, output: 4e6, cacheWrite: 1e6, cacheRead: 8e4 }
3842
+ };
3843
+ function calculateActiveMinutes(timestamps, idleThresholdMs = 9e5) {
3844
+ if (timestamps.length < 2) return 0;
3845
+ const sorted = [...timestamps].sort((a, b) => a - b);
3846
+ let activeMs = 0;
3847
+ for (let i = 1; i < sorted.length; i++) {
3848
+ const gap = sorted[i] - sorted[i - 1];
3849
+ if (gap < idleThresholdMs) {
3850
+ activeMs += gap;
3851
+ }
3852
+ }
3853
+ return activeMs / 6e4;
3854
+ }
3855
+ function lookupPricing(model) {
3856
+ if (MODEL_PRICING[model]) return MODEL_PRICING[model];
3857
+ const stripped = model.replace(/-\d{8}$/, "");
3858
+ if (MODEL_PRICING[stripped]) return MODEL_PRICING[stripped];
3859
+ const keys = Object.keys(MODEL_PRICING);
3860
+ let bestMatch;
3861
+ let bestLen = 0;
3862
+ for (const key of keys) {
3863
+ if (stripped.startsWith(key) && key.length > bestLen) {
3864
+ bestMatch = key;
3865
+ bestLen = key.length;
3866
+ }
3867
+ }
3868
+ if (bestMatch) return MODEL_PRICING[bestMatch];
3869
+ for (const key of keys) {
3870
+ if (key.startsWith(stripped) && key.length > bestLen) {
3871
+ bestMatch = key;
3872
+ bestLen = key.length;
3873
+ }
3874
+ }
3875
+ if (bestMatch) return MODEL_PRICING[bestMatch];
3876
+ return void 0;
3877
+ }
3878
+ function calculateCostMicrodollars(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
3879
+ const pricing = lookupPricing(model);
3880
+ if (!pricing) return 0;
3881
+ return Math.round(
3882
+ (inputTokens * pricing.input + outputTokens * pricing.output + cacheCreationTokens * pricing.cacheWrite + cacheReadTokens * pricing.cacheRead) / 1e6
3883
+ );
3884
+ }
3885
+ function parseTimestamp(value) {
3886
+ if (typeof value === "number") return value;
3887
+ if (typeof value === "string") {
3888
+ const ms = Date.parse(value);
3889
+ return Number.isNaN(ms) ? 0 : ms;
3890
+ }
3891
+ return 0;
3892
+ }
3893
+ function extractTextContent(content) {
3894
+ if (typeof content === "string") return content;
3895
+ if (Array.isArray(content)) {
3896
+ for (const block of content) {
3897
+ if (typeof block === "object" && block !== null && block.type === "text") {
3898
+ const text = block.text;
3899
+ if (typeof text === "string") return text;
3900
+ }
3901
+ }
3902
+ }
3903
+ return "";
3904
+ }
3905
+ async function parseSessionJsonl(jsonlPath, sessionId, projectId) {
3906
+ const session = {
3907
+ id: sessionId,
3908
+ projectId,
3909
+ jsonlPath,
3910
+ messageCount: 0,
3911
+ userMessageCount: 0,
3912
+ assistantMessageCount: 0,
3913
+ totalInputTokens: 0,
3914
+ totalOutputTokens: 0,
3915
+ totalCacheCreationTokens: 0,
3916
+ totalCacheReadTokens: 0,
3917
+ costMicrodollars: 0,
3918
+ activeMinutes: 0,
3919
+ compactionCount: 0
3920
+ };
3921
+ const messageTimestamps = [];
3922
+ let firstHumanSeen = false;
3923
+ let earliestTs = Infinity;
3924
+ let latestTs = 0;
3925
+ const rl = createInterface({
3926
+ input: createReadStream(jsonlPath, { encoding: "utf-8" }),
3927
+ crlfDelay: Infinity
3928
+ });
3929
+ for await (const line of rl) {
3930
+ if (!line.trim()) continue;
3931
+ let obj;
3932
+ try {
3933
+ obj = JSON.parse(line);
3934
+ } catch {
3935
+ continue;
3936
+ }
3937
+ if (typeof obj !== "object" || obj === null) continue;
3938
+ const type = obj.type;
3939
+ const ts = parseTimestamp(obj.timestamp);
3940
+ if (ts > 0) {
3941
+ messageTimestamps.push(ts);
3942
+ if (ts < earliestTs) earliestTs = ts;
3943
+ if (ts > latestTs) latestTs = ts;
3944
+ }
3945
+ if (!session.version && typeof obj.version === "string") {
3946
+ session.version = obj.version;
3947
+ }
3948
+ if (!session.slug && typeof obj.slug === "string") {
3949
+ session.slug = obj.slug;
3950
+ }
3951
+ if (!session.gitBranch && typeof obj.gitBranch === "string") {
3952
+ session.gitBranch = obj.gitBranch;
3953
+ }
3954
+ if (!session.permissionMode && typeof obj.permissionMode === "string") {
3955
+ session.permissionMode = obj.permissionMode;
3956
+ }
3957
+ if (type === "user") {
3958
+ session.messageCount = (session.messageCount ?? 0) + 1;
3959
+ if (!obj.toolUseResult) {
3960
+ session.userMessageCount = (session.userMessageCount ?? 0) + 1;
3961
+ if (!firstHumanSeen) {
3962
+ firstHumanSeen = true;
3963
+ const msg = obj.message;
3964
+ if (msg) {
3965
+ const text = extractTextContent(msg.content);
3966
+ if (text) {
3967
+ session.firstPrompt = text.slice(0, 500);
3968
+ }
3969
+ }
3970
+ }
3971
+ }
3972
+ } else if (type === "assistant") {
3973
+ session.messageCount = (session.messageCount ?? 0) + 1;
3974
+ session.assistantMessageCount = (session.assistantMessageCount ?? 0) + 1;
3975
+ const msg = obj.message;
3976
+ const model = msg?.model ?? obj.model;
3977
+ if (model && typeof model === "string") {
3978
+ session.model = model;
3979
+ }
3980
+ const usage = msg?.usage ?? obj.usage;
3981
+ if (usage && typeof usage === "object") {
3982
+ const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
3983
+ const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
3984
+ const cacheCreation = typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : 0;
3985
+ const cacheRead = typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : 0;
3986
+ session.totalInputTokens = (session.totalInputTokens ?? 0) + inputTokens;
3987
+ session.totalOutputTokens = (session.totalOutputTokens ?? 0) + outputTokens;
3988
+ session.totalCacheCreationTokens = (session.totalCacheCreationTokens ?? 0) + cacheCreation;
3989
+ session.totalCacheReadTokens = (session.totalCacheReadTokens ?? 0) + cacheRead;
3990
+ if (model) {
3991
+ session.costMicrodollars = (session.costMicrodollars ?? 0) + calculateCostMicrodollars(model, inputTokens, outputTokens, cacheCreation, cacheRead);
3992
+ }
3993
+ }
3994
+ } else if (type === "summary") {
3995
+ session.compactionCount = (session.compactionCount ?? 0) + 1;
3996
+ if (!session.summary && typeof obj.summary === "string") {
3997
+ session.summary = obj.summary;
3998
+ }
3999
+ } else if (type === "system") {
4000
+ session.messageCount = (session.messageCount ?? 0) + 1;
4001
+ const subtype = obj.subtype;
4002
+ if (subtype === "compact_boundary") {
4003
+ session.compactionCount = (session.compactionCount ?? 0) + 1;
4004
+ const meta = obj.compactMetadata;
4005
+ if (meta) {
4006
+ const preTokens = typeof meta.preTokens === "string" ? parseInt(meta.preTokens, 10) : typeof meta.preTokens === "number" ? meta.preTokens : void 0;
4007
+ if (preTokens && !Number.isNaN(preTokens)) {
4008
+ session.preCompactionTokens = preTokens;
4009
+ }
4010
+ }
4011
+ } else {
4012
+ const content = typeof obj.content === "string" ? obj.content : "";
4013
+ if (content.toLowerCase().includes("compact")) {
4014
+ session.compactionCount = (session.compactionCount ?? 0) + 1;
4015
+ }
4016
+ }
4017
+ }
4018
+ const directCost = obj.costUSD ?? obj.cost_usd;
4019
+ if (typeof directCost === "number") {
4020
+ session.costMicrodollars = (session.costMicrodollars ?? 0) + Math.round(directCost * 1e6);
4021
+ }
4022
+ }
4023
+ if (earliestTs < Infinity) {
4024
+ session.startedAt = earliestTs;
4025
+ }
4026
+ if (latestTs > 0) {
4027
+ session.endedAt = latestTs;
4028
+ }
4029
+ session.activeMinutes = calculateActiveMinutes(messageTimestamps);
4030
+ const now = Date.now();
4031
+ session.createdAt = session.startedAt ?? now;
4032
+ session.updatedAt = now;
4033
+ return { session, messageTimestamps };
4034
+ }
4035
+
4036
+ // src/pm/project-discovery.ts
4037
+ import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "fs/promises";
4038
+ import { join as join3, basename as basename2 } from "path";
4039
+ import { existsSync as existsSync4 } from "fs";
4040
+ import { homedir as homedir3 } from "os";
4041
+ var LOG_PREFIX = "[RuntimeScope PM]";
4042
+ async function detectSdkInstalled(projectPath) {
4043
+ try {
4044
+ const pkgPath = join3(projectPath, "package.json");
4045
+ const pkg = JSON.parse(await readFile2(pkgPath, "utf-8"));
4046
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
4047
+ if ("@runtimescope/sdk" in allDeps || "@runtimescope/server-sdk" in allDeps) {
4048
+ return true;
4049
+ }
4050
+ if (pkg.workspaces) {
4051
+ const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? [];
4052
+ for (const ws of workspaces) {
4053
+ const wsBase = ws.replace(/\/?\*$/, "");
4054
+ const wsDir = join3(projectPath, wsBase);
4055
+ try {
4056
+ const entries = await readdir2(wsDir, { withFileTypes: true });
4057
+ for (const entry of entries) {
4058
+ if (!entry.isDirectory()) continue;
4059
+ try {
4060
+ const wsPkg = JSON.parse(await readFile2(join3(wsDir, entry.name, "package.json"), "utf-8"));
4061
+ const wsDeps = { ...wsPkg.dependencies, ...wsPkg.devDependencies };
4062
+ if ("@runtimescope/sdk" in wsDeps || "@runtimescope/server-sdk" in wsDeps) {
4063
+ return true;
4064
+ }
4065
+ } catch {
4066
+ }
4067
+ }
4068
+ } catch {
4069
+ }
4070
+ }
4071
+ }
4072
+ } catch {
4073
+ }
4074
+ try {
4075
+ await stat2(join3(projectPath, "node_modules", "@runtimescope"));
4076
+ return true;
4077
+ } catch {
4078
+ return false;
4079
+ }
4080
+ }
4081
+ function emptyResult() {
4082
+ return {
4083
+ projectsDiscovered: 0,
4084
+ projectsUpdated: 0,
4085
+ sessionsDiscovered: 0,
4086
+ sessionsUpdated: 0,
4087
+ errors: []
4088
+ };
4089
+ }
4090
+ function mergeResults(a, b) {
4091
+ return {
4092
+ projectsDiscovered: (a.projectsDiscovered ?? 0) + (b.projectsDiscovered ?? 0),
4093
+ projectsUpdated: (a.projectsUpdated ?? 0) + (b.projectsUpdated ?? 0),
4094
+ sessionsDiscovered: (a.sessionsDiscovered ?? 0) + (b.sessionsDiscovered ?? 0),
4095
+ sessionsUpdated: (a.sessionsUpdated ?? 0) + (b.sessionsUpdated ?? 0),
4096
+ errors: [...a.errors ?? [], ...b.errors ?? []]
4097
+ };
4098
+ }
4099
+ function slugifyPath(fsPath) {
4100
+ const parts = fsPath.replace(/\/+$/, "").split("/").filter(Boolean);
4101
+ const segments = parts.length >= 2 ? parts.slice(-2) : parts;
4102
+ return segments.join("--").toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/-{3,}/g, "--").replace(/^-|-$/g, "");
4103
+ }
4104
+ function decodeClaudeKey(key) {
4105
+ const naive = "/" + key.slice(1).replace(/-/g, "/");
4106
+ if (existsSync4(naive)) return naive;
4107
+ const parts = key.slice(1).split("-");
4108
+ return resolvePathSegments(parts);
4109
+ }
4110
+ function resolvePathSegments(parts) {
4111
+ if (parts.length === 0) return null;
4112
+ function tryResolve(prefix, remaining) {
4113
+ if (remaining.length === 0) {
4114
+ return existsSync4(prefix) ? prefix : null;
4115
+ }
4116
+ for (let count = remaining.length; count >= 1; count--) {
4117
+ const segment = remaining.slice(0, count).join("-");
4118
+ const candidate = join3(prefix, segment);
4119
+ if (count === remaining.length) {
4120
+ if (existsSync4(candidate)) return candidate;
4121
+ } else {
4122
+ try {
4123
+ if (existsSync4(candidate)) {
4124
+ const result = tryResolve(candidate, remaining.slice(count));
4125
+ if (result) return result;
4126
+ }
4127
+ } catch {
4128
+ }
4129
+ }
4130
+ }
4131
+ return null;
4132
+ }
4133
+ return tryResolve("/", parts);
4134
+ }
4135
+ function toPeriod(timestampMs) {
4136
+ const d = new Date(timestampMs);
4137
+ const year = d.getFullYear();
4138
+ const month = String(d.getMonth() + 1).padStart(2, "0");
4139
+ return `${year}-${month}`;
4140
+ }
4141
+ var ProjectDiscovery = class {
4142
+ constructor(pmStore, projectManager, claudeBaseDir) {
4143
+ this.pmStore = pmStore;
4144
+ this.projectManager = projectManager;
4145
+ this.claudeBaseDir = claudeBaseDir ?? join3(homedir3(), ".claude");
4146
+ }
4147
+ claudeBaseDir;
4148
+ /**
4149
+ * Run full discovery: Claude Code projects + RuntimeScope projects.
4150
+ * Never throws — all errors are captured in the result.
4151
+ */
4152
+ async discoverAll() {
4153
+ const result = emptyResult();
4154
+ try {
4155
+ const [claudeResult, runtimeResult] = await Promise.all([
4156
+ this.discoverClaudeProjects(),
4157
+ this.discoverRuntimeScopeProjects()
4158
+ ]);
4159
+ return mergeResults(claudeResult, runtimeResult);
4160
+ } catch (err) {
4161
+ const msg = err instanceof Error ? err.message : String(err);
4162
+ console.error(`${LOG_PREFIX} Fatal discovery error: ${msg}`);
4163
+ result.errors.push(`Fatal discovery error: ${msg}`);
4164
+ return result;
4165
+ }
4166
+ }
4167
+ /**
4168
+ * Discover Claude Code projects from ~/.claude/projects/.
4169
+ */
4170
+ async discoverClaudeProjects() {
4171
+ const result = {
4172
+ projectsDiscovered: 0,
4173
+ projectsUpdated: 0,
4174
+ sessionsDiscovered: 0,
4175
+ sessionsUpdated: 0,
4176
+ errors: []
4177
+ };
4178
+ const projectsDir = join3(this.claudeBaseDir, "projects");
4179
+ try {
4180
+ await stat2(projectsDir);
4181
+ } catch {
4182
+ return result;
4183
+ }
4184
+ let entries;
4185
+ try {
4186
+ const dirEntries = await readdir2(projectsDir, { withFileTypes: true });
4187
+ entries = dirEntries.filter((d) => d.isDirectory()).map((d) => d.name);
4188
+ } catch (err) {
4189
+ const msg = err instanceof Error ? err.message : String(err);
4190
+ console.error(`${LOG_PREFIX} Failed to read Claude projects dir: ${msg}`);
4191
+ result.errors.push(`Failed to read Claude projects dir: ${msg}`);
4192
+ return result;
4193
+ }
4194
+ for (const key of entries) {
4195
+ try {
4196
+ await this.processClaudeProject(key, result);
4197
+ } catch (err) {
4198
+ const msg = err instanceof Error ? err.message : String(err);
4199
+ console.error(`${LOG_PREFIX} Error processing Claude project ${key}: ${msg}`);
4200
+ result.errors.push(`Claude project ${key}: ${msg}`);
4201
+ }
4202
+ }
4203
+ return result;
4204
+ }
4205
+ /**
4206
+ * Discover RuntimeScope projects from ~/.runtimescope/projects/.
4207
+ */
4208
+ async discoverRuntimeScopeProjects() {
4209
+ const result = {
4210
+ projectsDiscovered: 0,
4211
+ projectsUpdated: 0,
4212
+ sessionsDiscovered: 0,
4213
+ sessionsUpdated: 0,
4214
+ errors: []
4215
+ };
4216
+ try {
4217
+ const runtimeProjects = this.projectManager.listProjects();
4218
+ for (const projectName of runtimeProjects) {
4219
+ try {
4220
+ const projectDir = this.projectManager.getProjectDir(projectName);
4221
+ const id = projectName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
4222
+ const existingProjects = await this.pmStore.listProjects();
4223
+ const nameLower = projectName.toLowerCase();
4224
+ const existing = existingProjects.find(
4225
+ (p) => p.id === id || p.runtimescopeProject === projectName || p.name.toLowerCase() === nameLower
4226
+ );
4227
+ const now = Date.now();
4228
+ const sourcePath = existing?.path ?? projectDir;
4229
+ const sdkInstalled = await detectSdkInstalled(sourcePath);
4230
+ if (existing) {
4231
+ const updated = {
4232
+ ...existing,
4233
+ runtimescopeProject: projectName,
4234
+ sdkInstalled: sdkInstalled || existing.sdkInstalled,
4235
+ updatedAt: now
4236
+ };
4237
+ await this.pmStore.upsertProject(updated);
4238
+ result.projectsUpdated = (result.projectsUpdated ?? 0) + 1;
4239
+ } else {
4240
+ const fsPath = projectDir;
4241
+ const project = {
4242
+ id,
4243
+ name: projectName,
4244
+ path: fsPath,
4245
+ runtimescopeProject: projectName,
4246
+ phase: "application_development",
4247
+ managementAuthorized: false,
4248
+ probableToComplete: true,
4249
+ projectStatus: "active",
4250
+ sdkInstalled,
4251
+ createdAt: now,
4252
+ updatedAt: now
4253
+ };
4254
+ await this.pmStore.upsertProject(project);
4255
+ result.projectsDiscovered = (result.projectsDiscovered ?? 0) + 1;
4256
+ }
4257
+ } catch (err) {
4258
+ const msg = err instanceof Error ? err.message : String(err);
4259
+ console.error(`${LOG_PREFIX} Error processing RuntimeScope project ${projectName}: ${msg}`);
4260
+ result.errors.push(`RuntimeScope project ${projectName}: ${msg}`);
4261
+ }
4262
+ }
4263
+ } catch (err) {
4264
+ const msg = err instanceof Error ? err.message : String(err);
4265
+ console.error(`${LOG_PREFIX} Failed to list RuntimeScope projects: ${msg}`);
4266
+ result.errors.push(`Failed to list RuntimeScope projects: ${msg}`);
4267
+ }
4268
+ return result;
4269
+ }
4270
+ /**
4271
+ * Index all sessions for a given project.
4272
+ * Returns the number of sessions indexed (new or updated).
4273
+ */
4274
+ async indexProjectSessions(projectId) {
4275
+ const existingProjects = await this.pmStore.listProjects();
4276
+ const project = existingProjects.find((p) => p.id === projectId);
4277
+ if (!project) {
4278
+ console.error(`${LOG_PREFIX} Project not found: ${projectId}`);
4279
+ return 0;
4280
+ }
4281
+ if (!project.claudeProjectKey) {
4282
+ return 0;
4283
+ }
4284
+ const projectDir = join3(this.claudeBaseDir, "projects", project.claudeProjectKey);
4285
+ let sessionsIndexed = 0;
4286
+ try {
4287
+ const indexPath = join3(projectDir, "sessions-index.json");
4288
+ let indexEntries = null;
4289
+ try {
4290
+ const indexContent = await readFile2(indexPath, "utf-8");
4291
+ const index = JSON.parse(indexContent);
4292
+ indexEntries = index.entries ?? [];
4293
+ } catch {
4294
+ }
4295
+ const dirEntries = await readdir2(projectDir, { withFileTypes: true });
4296
+ const jsonlFiles = dirEntries.filter((d) => d.isFile() && d.name.endsWith(".jsonl")).map((d) => d.name);
4297
+ for (const jsonlFile of jsonlFiles) {
4298
+ try {
4299
+ const sessionId = jsonlFile.replace(".jsonl", "");
4300
+ const jsonlPath = join3(projectDir, jsonlFile);
4301
+ const fileStat = await stat2(jsonlPath);
4302
+ const fileSize = fileStat.size;
4303
+ const existingSession = await this.pmStore.getSession(sessionId);
4304
+ if (existingSession && existingSession.jsonlSize === fileSize) {
4305
+ continue;
4306
+ }
4307
+ const indexEntry = indexEntries?.find((e) => e.sessionId === sessionId);
4308
+ let session;
4309
+ if (indexEntry) {
4310
+ session = this.buildSessionFromIndex(indexEntry, projectId, jsonlPath, fileSize);
4311
+ } else {
4312
+ session = await this.buildSessionFromJsonl(sessionId, projectId, jsonlPath, fileSize);
4313
+ }
4314
+ await this.pmStore.upsertSession(session);
4315
+ await this.upsertCapexStub(session);
4316
+ sessionsIndexed++;
4317
+ } catch (err) {
4318
+ const msg = err instanceof Error ? err.message : String(err);
4319
+ console.error(`${LOG_PREFIX} Error indexing session ${jsonlFile}: ${msg}`);
4320
+ }
4321
+ }
4322
+ } catch (err) {
4323
+ const msg = err instanceof Error ? err.message : String(err);
4324
+ console.error(`${LOG_PREFIX} Error indexing sessions for project ${projectId}: ${msg}`);
4325
+ }
4326
+ return sessionsIndexed;
4327
+ }
4328
+ // ---------------------------------------------------------------
4329
+ // Private helpers
4330
+ // ---------------------------------------------------------------
4331
+ /**
4332
+ * Process a single Claude project directory key.
4333
+ */
4334
+ async processClaudeProject(key, result) {
4335
+ const projectDir = join3(this.claudeBaseDir, "projects", key);
4336
+ let fsPath = decodeClaudeKey(key);
4337
+ if (!fsPath) {
4338
+ fsPath = await this.resolvePathFromIndex(projectDir);
4339
+ }
4340
+ const id = fsPath ? slugifyPath(fsPath) : slugifyPath(key);
4341
+ const name = fsPath ? basename2(fsPath) : key;
4342
+ const now = Date.now();
4343
+ const existingProjects = await this.pmStore.listProjects();
4344
+ const nameLower = name.toLowerCase();
4345
+ const existing = existingProjects.find(
4346
+ (p) => p.id === id || p.claudeProjectKey === key || p.name.toLowerCase() === nameLower
4347
+ );
4348
+ const resolvedPath = fsPath ?? existing?.path;
4349
+ const sdkInstalled = resolvedPath ? await detectSdkInstalled(resolvedPath) : false;
4350
+ if (existing) {
4351
+ const updated = {
4352
+ ...existing,
4353
+ claudeProjectKey: key,
4354
+ path: fsPath ?? existing.path,
4355
+ sdkInstalled: sdkInstalled || existing.sdkInstalled,
4356
+ updatedAt: now
4357
+ };
4358
+ await this.pmStore.upsertProject(updated);
4359
+ result.projectsUpdated = (result.projectsUpdated ?? 0) + 1;
4360
+ } else {
4361
+ const project = {
4362
+ id,
4363
+ name,
4364
+ path: fsPath ?? void 0,
4365
+ claudeProjectKey: key,
4366
+ phase: "application_development",
4367
+ managementAuthorized: false,
4368
+ probableToComplete: true,
4369
+ projectStatus: "active",
4370
+ sdkInstalled,
4371
+ createdAt: now,
4372
+ updatedAt: now
4373
+ };
4374
+ await this.pmStore.upsertProject(project);
4375
+ result.projectsDiscovered = (result.projectsDiscovered ?? 0) + 1;
4376
+ }
4377
+ const actualId = existing ? existing.id : id;
4378
+ const sessionsIndexed = await this.indexSessionsForClaudeProject(actualId, key);
4379
+ result.sessionsDiscovered = (result.sessionsDiscovered ?? 0) + sessionsIndexed.discovered;
4380
+ result.sessionsUpdated = (result.sessionsUpdated ?? 0) + sessionsIndexed.updated;
4381
+ }
4382
+ /**
4383
+ * Try to resolve the filesystem path from the sessions-index.json projectPath field.
4384
+ */
4385
+ async resolvePathFromIndex(projectDir) {
4386
+ try {
4387
+ const indexPath = join3(projectDir, "sessions-index.json");
4388
+ const content = await readFile2(indexPath, "utf-8");
4389
+ const index = JSON.parse(content);
4390
+ const entry = index.entries?.find((e) => e.projectPath);
4391
+ return entry?.projectPath ?? null;
4392
+ } catch {
4393
+ return null;
4394
+ }
4395
+ }
4396
+ /**
4397
+ * Index sessions for a Claude project directly (used during discovery).
4398
+ * Returns counts of discovered and updated sessions.
4399
+ */
4400
+ async indexSessionsForClaudeProject(projectId, claudeKey) {
4401
+ const counts = { discovered: 0, updated: 0 };
4402
+ const projectDir = join3(this.claudeBaseDir, "projects", claudeKey);
4403
+ try {
4404
+ let indexEntries = null;
4405
+ try {
4406
+ const indexPath = join3(projectDir, "sessions-index.json");
4407
+ const indexContent = await readFile2(indexPath, "utf-8");
4408
+ const index = JSON.parse(indexContent);
4409
+ indexEntries = index.entries ?? [];
4410
+ } catch {
4411
+ }
4412
+ const dirEntries = await readdir2(projectDir, { withFileTypes: true });
4413
+ const jsonlFiles = dirEntries.filter((d) => d.isFile() && d.name.endsWith(".jsonl")).map((d) => d.name);
4414
+ for (const jsonlFile of jsonlFiles) {
4415
+ try {
4416
+ const sessionId = jsonlFile.replace(".jsonl", "");
4417
+ const jsonlPath = join3(projectDir, jsonlFile);
4418
+ const fileStat = await stat2(jsonlPath);
4419
+ const fileSize = fileStat.size;
4420
+ const existingSession = await this.pmStore.getSession(sessionId);
4421
+ if (existingSession && existingSession.jsonlSize === fileSize) {
4422
+ continue;
4423
+ }
4424
+ const indexEntry = indexEntries?.find((e) => e.sessionId === sessionId);
4425
+ let session;
4426
+ if (indexEntry) {
4427
+ session = this.buildSessionFromIndex(indexEntry, projectId, jsonlPath, fileSize);
4428
+ } else {
4429
+ session = await this.buildSessionFromJsonl(sessionId, projectId, jsonlPath, fileSize);
4430
+ }
4431
+ await this.pmStore.upsertSession(session);
4432
+ await this.upsertCapexStub(session);
4433
+ if (existingSession) {
4434
+ counts.updated++;
4435
+ } else {
4436
+ counts.discovered++;
4437
+ }
4438
+ } catch (err) {
4439
+ const msg = err instanceof Error ? err.message : String(err);
4440
+ console.error(`${LOG_PREFIX} Error indexing session ${jsonlFile} in ${claudeKey}: ${msg}`);
4441
+ }
4442
+ }
4443
+ } catch (err) {
4444
+ const msg = err instanceof Error ? err.message : String(err);
4445
+ console.error(`${LOG_PREFIX} Error scanning sessions for ${claudeKey}: ${msg}`);
4446
+ }
4447
+ return counts;
4448
+ }
4449
+ /**
4450
+ * Build a PmSession from the fast sessions-index.json entry.
4451
+ * Token counts and cost are zeroed since the index doesn't contain them;
4452
+ * they will be populated on a subsequent full parse if needed.
4453
+ */
4454
+ buildSessionFromIndex(entry, projectId, jsonlPath, jsonlSize) {
4455
+ const now = Date.now();
4456
+ const startedAt = new Date(entry.created).getTime();
4457
+ const endedAt = entry.modified ? new Date(entry.modified).getTime() : void 0;
4458
+ const activeMinutes = endedAt ? Math.max(1, Math.round((endedAt - startedAt) / 6e4)) : 0;
4459
+ return {
4460
+ id: entry.sessionId,
4461
+ projectId,
4462
+ jsonlPath,
4463
+ jsonlSize,
4464
+ firstPrompt: entry.firstPrompt ?? void 0,
4465
+ summary: entry.summary ?? void 0,
4466
+ slug: void 0,
4467
+ model: void 0,
4468
+ version: void 0,
4469
+ gitBranch: entry.gitBranch ?? void 0,
4470
+ messageCount: entry.messageCount ?? 0,
4471
+ userMessageCount: 0,
4472
+ assistantMessageCount: 0,
4473
+ totalInputTokens: 0,
4474
+ totalOutputTokens: 0,
4475
+ totalCacheCreationTokens: 0,
4476
+ totalCacheReadTokens: 0,
4477
+ costMicrodollars: 0,
4478
+ startedAt,
4479
+ endedAt,
4480
+ activeMinutes,
4481
+ compactionCount: 0,
4482
+ preCompactionTokens: void 0,
4483
+ permissionMode: void 0,
4484
+ createdAt: now,
4485
+ updatedAt: now
4486
+ };
4487
+ }
4488
+ /**
4489
+ * Build a PmSession by fully parsing a .jsonl file.
4490
+ */
4491
+ async buildSessionFromJsonl(sessionId, projectId, jsonlPath, jsonlSize) {
4492
+ const now = Date.now();
4493
+ let fileMtime = now;
4494
+ try {
4495
+ const fstat = await stat2(jsonlPath);
4496
+ fileMtime = fstat.mtimeMs;
4497
+ } catch {
4498
+ }
4499
+ try {
4500
+ const { session: parsed, messageTimestamps } = await parseSessionJsonl(jsonlPath, sessionId, projectId);
4501
+ const activeMinutes = calculateActiveMinutes(messageTimestamps);
4502
+ const costMicrodollars = parsed.costMicrodollars ?? calculateCostMicrodollars(
4503
+ parsed.model ?? "",
4504
+ parsed.totalInputTokens ?? 0,
4505
+ parsed.totalOutputTokens ?? 0,
4506
+ parsed.totalCacheCreationTokens ?? 0,
4507
+ parsed.totalCacheReadTokens ?? 0
4508
+ );
4509
+ return {
4510
+ id: sessionId,
4511
+ projectId,
4512
+ jsonlPath,
4513
+ jsonlSize,
4514
+ firstPrompt: parsed.firstPrompt ?? void 0,
4515
+ summary: parsed.summary ?? void 0,
4516
+ slug: parsed.slug ?? void 0,
4517
+ model: parsed.model ?? void 0,
4518
+ version: parsed.version ?? void 0,
4519
+ gitBranch: parsed.gitBranch ?? void 0,
4520
+ messageCount: parsed.messageCount ?? 0,
4521
+ userMessageCount: parsed.userMessageCount ?? 0,
4522
+ assistantMessageCount: parsed.assistantMessageCount ?? 0,
4523
+ totalInputTokens: parsed.totalInputTokens ?? 0,
4524
+ totalOutputTokens: parsed.totalOutputTokens ?? 0,
4525
+ totalCacheCreationTokens: parsed.totalCacheCreationTokens ?? 0,
4526
+ totalCacheReadTokens: parsed.totalCacheReadTokens ?? 0,
4527
+ costMicrodollars,
4528
+ startedAt: parsed.startedAt ?? fileMtime,
4529
+ endedAt: parsed.endedAt ?? void 0,
4530
+ activeMinutes,
4531
+ compactionCount: parsed.compactionCount ?? 0,
4532
+ preCompactionTokens: parsed.preCompactionTokens ?? void 0,
4533
+ permissionMode: parsed.permissionMode ?? void 0,
4534
+ createdAt: now,
4535
+ updatedAt: now
4536
+ };
4537
+ } catch (err) {
4538
+ const msg = err instanceof Error ? err.message : String(err);
4539
+ console.error(`${LOG_PREFIX} Failed to parse session ${sessionId}: ${msg}`);
4540
+ return {
4541
+ id: sessionId,
4542
+ projectId,
4543
+ jsonlPath,
4544
+ jsonlSize,
4545
+ messageCount: 0,
4546
+ userMessageCount: 0,
4547
+ assistantMessageCount: 0,
4548
+ totalInputTokens: 0,
4549
+ totalOutputTokens: 0,
4550
+ totalCacheCreationTokens: 0,
4551
+ totalCacheReadTokens: 0,
4552
+ costMicrodollars: 0,
4553
+ startedAt: fileMtime,
4554
+ activeMinutes: 0,
4555
+ compactionCount: 0,
4556
+ createdAt: now,
4557
+ updatedAt: now
4558
+ };
4559
+ }
4560
+ }
4561
+ /**
4562
+ * Create or update a CapEx entry stub for a session.
4563
+ * Defaults to 'expensed' classification, unconfirmed.
4564
+ */
4565
+ async upsertCapexStub(session) {
4566
+ try {
4567
+ const now = Date.now();
4568
+ const entry = {
4569
+ id: `capex-${session.id}`,
4570
+ projectId: session.projectId,
4571
+ sessionId: session.id,
4572
+ classification: "expensed",
4573
+ workType: void 0,
4574
+ activeMinutes: session.activeMinutes,
4575
+ costMicrodollars: session.costMicrodollars,
4576
+ adjustmentFactor: 1,
4577
+ adjustedCostMicrodollars: session.costMicrodollars,
4578
+ confirmed: false,
4579
+ confirmedAt: void 0,
4580
+ confirmedBy: void 0,
4581
+ notes: void 0,
4582
+ period: toPeriod(session.startedAt),
4583
+ createdAt: now,
4584
+ updatedAt: now
4585
+ };
4586
+ await this.pmStore.upsertCapexEntry(entry);
4587
+ } catch (err) {
4588
+ const msg = err instanceof Error ? err.message : String(err);
4589
+ console.error(`${LOG_PREFIX} Failed to create CapEx stub for session ${session.id}: ${msg}`);
4590
+ }
4591
+ }
4592
+ };
4593
+
4594
+ export {
4595
+ __require,
4596
+ RingBuffer,
4597
+ EventStore,
4598
+ SqliteStore,
4599
+ SessionRateLimiter,
4600
+ loadTlsOptions,
4601
+ resolveTlsConfig,
4602
+ CollectorServer,
4603
+ ProjectManager,
4604
+ AuthManager,
4605
+ generateApiKey,
4606
+ BUILT_IN_RULES,
4607
+ Redactor,
4608
+ SessionManager,
4609
+ HttpServer,
4610
+ PmStore,
4611
+ calculateActiveMinutes,
4612
+ calculateCostMicrodollars,
4613
+ parseSessionJsonl,
4614
+ ProjectDiscovery
4615
+ };
4616
+ //# sourceMappingURL=chunk-6JZXAFPC.js.map