ai-agent-session-center 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. package/server/wsManager.js +83 -0
@@ -0,0 +1,806 @@
1
+ // browserDb.js — IndexedDB wrapper for client-side persistence
2
+ // Replaces server-side SQLite. All session data, notes, settings, profiles stored here.
3
+
4
+ const DB_NAME = 'claude-dashboard';
5
+ const DB_VERSION = 1;
6
+
7
+ let dbInstance = null;
8
+
9
+ const STORES = {
10
+ sessions: { keyPath: 'id' },
11
+ prompts: { keyPath: 'id', autoIncrement: true },
12
+ responses: { keyPath: 'id', autoIncrement: true },
13
+ toolCalls: { keyPath: 'id', autoIncrement: true },
14
+ events: { keyPath: 'id', autoIncrement: true },
15
+ notes: { keyPath: 'id', autoIncrement: true },
16
+ promptQueue: { keyPath: 'id', autoIncrement: true },
17
+ alerts: { keyPath: 'id', autoIncrement: true },
18
+ sshProfiles: { keyPath: 'id', autoIncrement: true },
19
+ settings: { keyPath: 'key' },
20
+ summaryPrompts: { keyPath: 'id', autoIncrement: true },
21
+ teams: { keyPath: 'id' },
22
+ };
23
+
24
+ const INDEXES = {
25
+ sessions: [['status', 'status'], ['projectPath', 'projectPath'], ['startedAt', 'startedAt'], ['lastActivityAt', 'lastActivityAt'], ['archived', 'archived']],
26
+ prompts: [['sessionId', 'sessionId'], ['timestamp', 'timestamp'], ['sessionId_timestamp', ['sessionId', 'timestamp']]],
27
+ responses: [['sessionId', 'sessionId'], ['timestamp', 'timestamp'], ['sessionId_timestamp', ['sessionId', 'timestamp']]],
28
+ toolCalls: [['sessionId', 'sessionId'], ['timestamp', 'timestamp'], ['toolName', 'toolName'], ['sessionId_timestamp', ['sessionId', 'timestamp']]],
29
+ events: [['sessionId', 'sessionId'], ['timestamp', 'timestamp'], ['sessionId_timestamp', ['sessionId', 'timestamp']]],
30
+ notes: [['sessionId', 'sessionId']],
31
+ promptQueue: [['sessionId', 'sessionId'], ['sessionId_position', ['sessionId', 'position']]],
32
+ alerts: [['sessionId', 'sessionId']],
33
+ sshProfiles: [['name', 'name']],
34
+ summaryPrompts: [['isDefault', 'isDefault']],
35
+ };
36
+
37
+ // ---- Open / Initialize ----
38
+
39
+ export async function openDB() {
40
+ if (dbInstance) return dbInstance;
41
+
42
+ return new Promise((resolve, reject) => {
43
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
44
+
45
+ request.onupgradeneeded = (event) => {
46
+ const db = event.target.result;
47
+ for (const [name, opts] of Object.entries(STORES)) {
48
+ if (!db.objectStoreNames.contains(name)) {
49
+ const store = db.createObjectStore(name, opts);
50
+ const indexes = INDEXES[name] || [];
51
+ for (const [indexName, keyPath] of indexes) {
52
+ store.createIndex(indexName, keyPath, { unique: false });
53
+ }
54
+ }
55
+ }
56
+ };
57
+
58
+ request.onsuccess = async (event) => {
59
+ dbInstance = event.target.result;
60
+ await seedDefaults();
61
+ resolve(dbInstance);
62
+ };
63
+
64
+ request.onerror = (event) => {
65
+ console.error('[browserDb] Failed to open IndexedDB:', event.target.error);
66
+ reject(event.target.error);
67
+ };
68
+ });
69
+ }
70
+
71
+ function getDB() {
72
+ if (!dbInstance) throw new Error('IndexedDB not initialized. Call openDB() first.');
73
+ return dbInstance;
74
+ }
75
+
76
+ // ---- Generic CRUD ----
77
+
78
+ export function put(storeName, data) {
79
+ return new Promise((resolve, reject) => {
80
+ const tx = getDB().transaction(storeName, 'readwrite');
81
+ const store = tx.objectStore(storeName);
82
+ const req = store.put(data);
83
+ req.onsuccess = () => resolve(req.result);
84
+ req.onerror = () => reject(req.error);
85
+ });
86
+ }
87
+
88
+ export function get(storeName, key) {
89
+ return new Promise((resolve, reject) => {
90
+ const tx = getDB().transaction(storeName, 'readonly');
91
+ const store = tx.objectStore(storeName);
92
+ const req = store.get(key);
93
+ req.onsuccess = () => resolve(req.result || null);
94
+ req.onerror = () => reject(req.error);
95
+ });
96
+ }
97
+
98
+ export function getAll(storeName) {
99
+ return new Promise((resolve, reject) => {
100
+ const tx = getDB().transaction(storeName, 'readonly');
101
+ const store = tx.objectStore(storeName);
102
+ const req = store.getAll();
103
+ req.onsuccess = () => resolve(req.result);
104
+ req.onerror = () => reject(req.error);
105
+ });
106
+ }
107
+
108
+ export function del(storeName, key) {
109
+ return new Promise((resolve, reject) => {
110
+ const tx = getDB().transaction(storeName, 'readwrite');
111
+ const store = tx.objectStore(storeName);
112
+ const req = store.delete(key);
113
+ req.onsuccess = () => resolve();
114
+ req.onerror = () => reject(req.error);
115
+ });
116
+ }
117
+
118
+ export function getByIndex(storeName, indexName, value) {
119
+ return new Promise((resolve, reject) => {
120
+ const tx = getDB().transaction(storeName, 'readonly');
121
+ const store = tx.objectStore(storeName);
122
+ const index = store.index(indexName);
123
+ const req = index.getAll(value);
124
+ req.onsuccess = () => resolve(req.result);
125
+ req.onerror = () => reject(req.error);
126
+ });
127
+ }
128
+
129
+ export function clear(storeName) {
130
+ return new Promise((resolve, reject) => {
131
+ const tx = getDB().transaction(storeName, 'readwrite');
132
+ const store = tx.objectStore(storeName);
133
+ const req = store.clear();
134
+ req.onsuccess = () => resolve();
135
+ req.onerror = () => reject(req.error);
136
+ });
137
+ }
138
+
139
+ export function count(storeName) {
140
+ return new Promise((resolve, reject) => {
141
+ const tx = getDB().transaction(storeName, 'readonly');
142
+ const store = tx.objectStore(storeName);
143
+ const req = store.count();
144
+ req.onsuccess = () => resolve(req.result);
145
+ req.onerror = () => reject(req.error);
146
+ });
147
+ }
148
+
149
+ // ---- Batch operations ----
150
+
151
+ export function putMany(storeName, items) {
152
+ return new Promise((resolve, reject) => {
153
+ const tx = getDB().transaction(storeName, 'readwrite');
154
+ const store = tx.objectStore(storeName);
155
+ for (const item of items) {
156
+ store.put(item);
157
+ }
158
+ tx.oncomplete = () => resolve();
159
+ tx.onerror = () => reject(tx.error);
160
+ });
161
+ }
162
+
163
+ // ---- Session-specific helpers ----
164
+
165
+ // Persist a session snapshot from WebSocket (upsert into sessions store + child records)
166
+ export async function persistSessionUpdate(session) {
167
+ if (!session || !session.sessionId) return;
168
+
169
+ // Upsert session record
170
+ const record = {
171
+ id: session.sessionId,
172
+ projectPath: session.projectPath || '',
173
+ projectName: session.projectName || 'Unknown',
174
+ title: session.title || '',
175
+ status: session.status || 'idle',
176
+ model: session.model || '',
177
+ source: session.source || 'hook',
178
+ startedAt: session.startedAt || Date.now(),
179
+ lastActivityAt: session.lastActivityAt || Date.now(),
180
+ endedAt: session.endedAt || null,
181
+ totalToolCalls: session.totalToolCalls || 0,
182
+ totalPrompts: session.totalPrompts || 0,
183
+ archived: session.archived || 0,
184
+ summary: session.summary || null,
185
+ characterModel: session.characterModel || null,
186
+ accentColor: session.accentColor || null,
187
+ teamId: session.teamId || null,
188
+ teamRole: session.teamRole || null,
189
+ terminalId: session.terminalId || null,
190
+ queueCount: session.queueCount || 0,
191
+ label: session.label || null,
192
+ };
193
+ await put('sessions', record);
194
+
195
+ // Persist prompt history entries (deduplicate by timestamp)
196
+ if (session.promptHistory?.length) {
197
+ const existing = await getByIndex('prompts', 'sessionId', session.sessionId);
198
+ const existingTs = new Set(existing.map(e => e.timestamp));
199
+ const newPrompts = session.promptHistory.filter(p => !existingTs.has(p.timestamp));
200
+ if (newPrompts.length > 0) {
201
+ await putMany('prompts', newPrompts.map(p => ({
202
+ sessionId: session.sessionId,
203
+ text: p.text,
204
+ timestamp: p.timestamp,
205
+ })));
206
+ }
207
+ }
208
+
209
+ // Persist tool log entries
210
+ if (session.toolLog?.length) {
211
+ const existing = await getByIndex('toolCalls', 'sessionId', session.sessionId);
212
+ const existingTs = new Set(existing.map(e => e.timestamp));
213
+ const newTools = session.toolLog.filter(t => !existingTs.has(t.timestamp));
214
+ if (newTools.length > 0) {
215
+ await putMany('toolCalls', newTools.map(t => ({
216
+ sessionId: session.sessionId,
217
+ toolName: t.tool,
218
+ toolInputSummary: t.input,
219
+ timestamp: t.timestamp,
220
+ })));
221
+ }
222
+ }
223
+
224
+ // Persist response log entries
225
+ if (session.responseLog?.length) {
226
+ const existing = await getByIndex('responses', 'sessionId', session.sessionId);
227
+ const existingTs = new Set(existing.map(e => e.timestamp));
228
+ const newResponses = session.responseLog.filter(r => !existingTs.has(r.timestamp));
229
+ if (newResponses.length > 0) {
230
+ await putMany('responses', newResponses.map(r => ({
231
+ sessionId: session.sessionId,
232
+ textExcerpt: r.text,
233
+ timestamp: r.timestamp,
234
+ })));
235
+ }
236
+ }
237
+
238
+ // Persist events
239
+ if (session.events?.length) {
240
+ const existing = await getByIndex('events', 'sessionId', session.sessionId);
241
+ const existingTs = new Set(existing.map(e => e.timestamp));
242
+ const newEvents = session.events.filter(e => !existingTs.has(e.timestamp));
243
+ if (newEvents.length > 0) {
244
+ await putMany('events', newEvents.map(e => ({
245
+ sessionId: session.sessionId,
246
+ eventType: e.type,
247
+ detail: e.detail || '',
248
+ timestamp: e.timestamp,
249
+ })));
250
+ }
251
+ }
252
+ }
253
+
254
+ // ---- History / Query ----
255
+
256
+ export async function searchSessions({ query, project, status, dateFrom, dateTo, archived, sortBy = 'startedAt', sortDir = 'desc', page = 1, pageSize = 50 } = {}) {
257
+ let sessions = await getAll('sessions');
258
+
259
+ // Filter
260
+ if (project) sessions = sessions.filter(s => s.projectPath === project);
261
+ if (status) sessions = sessions.filter(s => s.status === status);
262
+ if (dateFrom) sessions = sessions.filter(s => s.startedAt >= dateFrom);
263
+ if (dateTo) sessions = sessions.filter(s => s.startedAt <= dateTo);
264
+ if (archived === true || archived === 'true') {
265
+ sessions = sessions.filter(s => s.archived === 1);
266
+ } else if (archived !== 'all') {
267
+ sessions = sessions.filter(s => !s.archived || s.archived === 0);
268
+ }
269
+
270
+ // Text search (simple substring match on prompts)
271
+ if (query) {
272
+ const allPrompts = await getAll('prompts');
273
+ const matchingSessionIds = new Set();
274
+ const lowerQuery = query.toLowerCase();
275
+ for (const p of allPrompts) {
276
+ if (p.text && p.text.toLowerCase().includes(lowerQuery)) {
277
+ matchingSessionIds.add(p.sessionId);
278
+ }
279
+ }
280
+ sessions = sessions.filter(s => matchingSessionIds.has(s.id));
281
+ }
282
+
283
+ // Sort
284
+ const dir = sortDir === 'asc' ? 1 : -1;
285
+ sessions.sort((a, b) => ((a[sortBy] || 0) - (b[sortBy] || 0)) * dir);
286
+
287
+ const total = sessions.length;
288
+ const offset = (page - 1) * pageSize;
289
+ const paged = sessions.slice(offset, offset + pageSize);
290
+
291
+ return { sessions: paged, total, page, pageSize };
292
+ }
293
+
294
+ export async function getSessionDetail(sessionId) {
295
+ const session = await get('sessions', sessionId);
296
+ if (!session) return null;
297
+
298
+ const prompts = (await getByIndex('prompts', 'sessionId', sessionId)).sort((a, b) => a.timestamp - b.timestamp);
299
+ const responses = (await getByIndex('responses', 'sessionId', sessionId)).sort((a, b) => a.timestamp - b.timestamp);
300
+ const toolCalls = (await getByIndex('toolCalls', 'sessionId', sessionId)).sort((a, b) => a.timestamp - b.timestamp);
301
+ const events = (await getByIndex('events', 'sessionId', sessionId)).sort((a, b) => a.timestamp - b.timestamp);
302
+ const notes = (await getByIndex('notes', 'sessionId', sessionId)).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
303
+
304
+ return { session, prompts, responses, tool_calls: toolCalls, events, notes };
305
+ }
306
+
307
+ export async function deleteSession(sessionId) {
308
+ // Remove session record
309
+ await del('sessions', sessionId);
310
+ // Remove all related records from child stores
311
+ const childStores = ['prompts', 'responses', 'toolCalls', 'events', 'notes', 'promptQueue', 'alerts'];
312
+ for (const storeName of childStores) {
313
+ const records = await getByIndex(storeName, 'sessionId', sessionId);
314
+ for (const r of records) {
315
+ await del(storeName, r.id);
316
+ }
317
+ }
318
+ }
319
+
320
+ export async function getDistinctProjects() {
321
+ const sessions = await getAll('sessions');
322
+ const seen = new Map();
323
+ for (const s of sessions) {
324
+ if (s.projectPath && !seen.has(s.projectPath)) {
325
+ seen.set(s.projectPath, s.projectName || s.projectPath);
326
+ }
327
+ }
328
+ return [...seen.entries()].map(([path, name]) => ({ project_path: path, project_name: name })).sort((a, b) => a.project_name.localeCompare(b.project_name));
329
+ }
330
+
331
+ // ---- Full-text search ----
332
+
333
+ export async function fullTextSearch({ query, type = 'all', page = 1, pageSize = 50 } = {}) {
334
+ if (!query) return { results: [], total: 0, page, pageSize };
335
+
336
+ const lowerQuery = query.toLowerCase();
337
+ const results = [];
338
+ const sessions = await getAll('sessions');
339
+ const sessionMap = new Map(sessions.map(s => [s.id, s]));
340
+
341
+ if (type === 'all' || type === 'prompts') {
342
+ const prompts = await getAll('prompts');
343
+ for (const p of prompts) {
344
+ if (p.text && p.text.toLowerCase().includes(lowerQuery)) {
345
+ const s = sessionMap.get(p.sessionId);
346
+ results.push({
347
+ session_id: p.sessionId,
348
+ project_name: s?.projectName || 'Unknown',
349
+ type: 'prompt',
350
+ text_snippet: highlightMatch(p.text, query),
351
+ timestamp: p.timestamp,
352
+ });
353
+ }
354
+ }
355
+ }
356
+
357
+ if (type === 'all' || type === 'responses') {
358
+ const responses = await getAll('responses');
359
+ for (const r of responses) {
360
+ const text = r.textExcerpt || r.fullText || '';
361
+ if (text.toLowerCase().includes(lowerQuery)) {
362
+ const s = sessionMap.get(r.sessionId);
363
+ results.push({
364
+ session_id: r.sessionId,
365
+ project_name: s?.projectName || 'Unknown',
366
+ type: 'response',
367
+ text_snippet: highlightMatch(text, query),
368
+ timestamp: r.timestamp,
369
+ });
370
+ }
371
+ }
372
+ }
373
+
374
+ results.sort((a, b) => b.timestamp - a.timestamp);
375
+ const total = results.length;
376
+ const offset = (page - 1) * pageSize;
377
+ return { results: results.slice(offset, offset + pageSize), total, page, pageSize };
378
+ }
379
+
380
+ function highlightMatch(text, query) {
381
+ if (!text || !query) return text;
382
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
383
+ if (idx === -1) return text.substring(0, 200);
384
+ const start = Math.max(0, idx - 60);
385
+ const end = Math.min(text.length, idx + query.length + 60);
386
+ let snippet = text.substring(start, end);
387
+ // Wrap match in <mark> tags
388
+ const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
389
+ snippet = snippet.replace(re, '<mark>$1</mark>');
390
+ return (start > 0 ? '...' : '') + snippet + (end < text.length ? '...' : '');
391
+ }
392
+
393
+ // ---- Analytics ----
394
+
395
+ export async function getSummaryStats() {
396
+ const sessions = await getAll('sessions');
397
+ const toolCalls = await getAll('toolCalls');
398
+ const prompts = await getAll('prompts');
399
+
400
+ const totalSessions = sessions.length;
401
+ const totalPrompts = prompts.length;
402
+ const totalToolCalls = toolCalls.length;
403
+ const activeSessions = sessions.filter(s => s.status !== 'ended').length;
404
+
405
+ // Average duration (only for sessions with endedAt)
406
+ const withDuration = sessions.filter(s => s.endedAt && s.startedAt);
407
+ const avgDuration = withDuration.length > 0
408
+ ? Math.round(withDuration.reduce((sum, s) => sum + (s.endedAt - s.startedAt), 0) / withDuration.length)
409
+ : 0;
410
+
411
+ // Most used tool
412
+ const toolCounts = {};
413
+ for (const t of toolCalls) {
414
+ toolCounts[t.toolName] = (toolCounts[t.toolName] || 0) + 1;
415
+ }
416
+ const mostUsedTool = Object.entries(toolCounts).sort((a, b) => b[1] - a[1])[0];
417
+
418
+ // Busiest project (track name alongside path)
419
+ const projectCounts = {};
420
+ const projectNames = {};
421
+ for (const s of sessions) {
422
+ if (s.projectPath) {
423
+ projectCounts[s.projectPath] = (projectCounts[s.projectPath] || 0) + 1;
424
+ if (s.projectName) projectNames[s.projectPath] = s.projectName;
425
+ }
426
+ }
427
+ const busiestProject = Object.entries(projectCounts).sort((a, b) => b[1] - a[1])[0];
428
+
429
+ return {
430
+ total_sessions: totalSessions,
431
+ total_prompts: totalPrompts,
432
+ total_tool_calls: totalToolCalls,
433
+ active_sessions: activeSessions,
434
+ avg_duration: avgDuration,
435
+ most_used_tool: mostUsedTool ? { tool_name: mostUsedTool[0], count: mostUsedTool[1] } : null,
436
+ busiest_project: busiestProject
437
+ ? { project_path: busiestProject[0], name: projectNames[busiestProject[0]] || busiestProject[0], count: busiestProject[1] }
438
+ : null,
439
+ };
440
+ }
441
+
442
+ export async function getToolBreakdown() {
443
+ const toolCalls = await getAll('toolCalls');
444
+ const counts = {};
445
+ for (const t of toolCalls) {
446
+ counts[t.toolName] = (counts[t.toolName] || 0) + 1;
447
+ }
448
+ const total = toolCalls.length;
449
+ return Object.entries(counts)
450
+ .map(([tool_name, count]) => ({ tool_name, count, percentage: total > 0 ? Math.round(count / total * 1000) / 10 : 0 }))
451
+ .sort((a, b) => b.count - a.count);
452
+ }
453
+
454
+ export async function getDurationTrends({ period = 'day' } = {}) {
455
+ const sessions = await getAll('sessions');
456
+ const withDuration = sessions.filter(s => s.endedAt && s.startedAt);
457
+
458
+ const buckets = {};
459
+ for (const s of withDuration) {
460
+ const key = formatPeriod(s.startedAt, period);
461
+ if (!buckets[key]) buckets[key] = { durations: [], count: 0 };
462
+ buckets[key].durations.push(s.endedAt - s.startedAt);
463
+ buckets[key].count++;
464
+ }
465
+
466
+ return Object.entries(buckets)
467
+ .map(([period_label, data]) => ({
468
+ period: period_label,
469
+ avg_duration: Math.round(data.durations.reduce((a, b) => a + b, 0) / data.durations.length),
470
+ session_count: data.count,
471
+ }))
472
+ .sort((a, b) => a.period.localeCompare(b.period));
473
+ }
474
+
475
+ export async function getActiveProjects() {
476
+ const sessions = await getAll('sessions');
477
+ const prompts = await getAll('prompts');
478
+ const toolCalls = await getAll('toolCalls');
479
+
480
+ // Count prompts/tools per session
481
+ const sessionPromptCounts = {};
482
+ for (const p of prompts) sessionPromptCounts[p.sessionId] = (sessionPromptCounts[p.sessionId] || 0) + 1;
483
+ const sessionToolCounts = {};
484
+ for (const t of toolCalls) sessionToolCounts[t.sessionId] = (sessionToolCounts[t.sessionId] || 0) + 1;
485
+
486
+ const projects = {};
487
+ for (const s of sessions) {
488
+ const key = s.projectPath || 'Unknown';
489
+ if (!projects[key]) {
490
+ projects[key] = { project_path: key, project_name: s.projectName || key, session_count: 0, total_prompts: 0, total_tools: 0, last_activity: 0 };
491
+ }
492
+ projects[key].session_count++;
493
+ projects[key].total_prompts += sessionPromptCounts[s.id] || 0;
494
+ projects[key].total_tools += sessionToolCounts[s.id] || 0;
495
+ projects[key].last_activity = Math.max(projects[key].last_activity, s.lastActivityAt || 0);
496
+ }
497
+
498
+ return Object.values(projects).sort((a, b) => b.last_activity - a.last_activity);
499
+ }
500
+
501
+ export async function getHeatmap() {
502
+ const events = await getAll('events');
503
+ const grid = {}; // "day-hour" -> count (day: 0=Mon, 6=Sun)
504
+ for (const e of events) {
505
+ const d = new Date(e.timestamp);
506
+ // Convert JS getDay (0=Sun) to Mon-first (0=Mon, 6=Sun)
507
+ const jsDay = d.getDay();
508
+ const day = jsDay === 0 ? 6 : jsDay - 1;
509
+ const hour = d.getHours();
510
+ const key = `${day}-${hour}`;
511
+ grid[key] = (grid[key] || 0) + 1;
512
+ }
513
+
514
+ const result = [];
515
+ for (let day = 0; day < 7; day++) {
516
+ for (let hour = 0; hour < 24; hour++) {
517
+ const key = `${day}-${hour}`;
518
+ if (grid[key]) {
519
+ result.push({ day_of_week: day, hour, count: grid[key] });
520
+ }
521
+ }
522
+ }
523
+ return result;
524
+ }
525
+
526
+ export async function getTimeline({ dateFrom, dateTo, granularity = 'day', project } = {}) {
527
+ let sessions = await getAll('sessions');
528
+
529
+ if (project) sessions = sessions.filter(s => s.projectPath === project);
530
+ if (dateFrom) sessions = sessions.filter(s => s.startedAt >= dateFrom);
531
+ if (dateTo) sessions = sessions.filter(s => s.startedAt <= dateTo);
532
+
533
+ // Build a set of matching session IDs for filtering prompts/tools
534
+ const sessionIds = new Set(sessions.map(s => s.id));
535
+
536
+ // Count actual prompts and tool calls by their own timestamps for accurate per-period data
537
+ const [allPrompts, allToolCalls] = await Promise.all([
538
+ getAll('prompts'),
539
+ getAll('toolCalls'),
540
+ ]);
541
+
542
+ const buckets = {};
543
+
544
+ // Count sessions by startedAt
545
+ for (const s of sessions) {
546
+ const key = formatPeriod(s.startedAt, granularity);
547
+ if (!buckets[key]) buckets[key] = { session_count: 0, prompt_count: 0, tool_call_count: 0 };
548
+ buckets[key].session_count++;
549
+ }
550
+
551
+ // Count prompts by their own timestamp
552
+ for (const p of allPrompts) {
553
+ if (!sessionIds.has(p.sessionId)) continue;
554
+ if (dateFrom && p.timestamp < dateFrom) continue;
555
+ if (dateTo && p.timestamp > dateTo) continue;
556
+ const key = formatPeriod(p.timestamp, granularity);
557
+ if (!buckets[key]) buckets[key] = { session_count: 0, prompt_count: 0, tool_call_count: 0 };
558
+ buckets[key].prompt_count++;
559
+ }
560
+
561
+ // Count tool calls by their own timestamp
562
+ for (const t of allToolCalls) {
563
+ if (!sessionIds.has(t.sessionId)) continue;
564
+ if (dateFrom && t.timestamp < dateFrom) continue;
565
+ if (dateTo && t.timestamp > dateTo) continue;
566
+ const key = formatPeriod(t.timestamp, granularity);
567
+ if (!buckets[key]) buckets[key] = { session_count: 0, prompt_count: 0, tool_call_count: 0 };
568
+ buckets[key].tool_call_count++;
569
+ }
570
+
571
+ return {
572
+ buckets: Object.entries(buckets)
573
+ .map(([period, data]) => ({ period, ...data }))
574
+ .sort((a, b) => a.period.localeCompare(b.period)),
575
+ };
576
+ }
577
+
578
+ function formatPeriod(ts, granularity) {
579
+ const d = new Date(ts);
580
+ const yyyy = d.getFullYear();
581
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
582
+ const dd = String(d.getDate()).padStart(2, '0');
583
+ const hh = String(d.getHours()).padStart(2, '0');
584
+
585
+ switch (granularity) {
586
+ case 'hour': return `${yyyy}-${mm}-${dd} ${hh}:00`;
587
+ case 'week': {
588
+ // ISO week
589
+ const jan1 = new Date(yyyy, 0, 1);
590
+ const week = Math.ceil(((d - jan1) / 86400000 + jan1.getDay() + 1) / 7);
591
+ return `${yyyy}-W${String(week).padStart(2, '0')}`;
592
+ }
593
+ case 'month': return `${yyyy}-${mm}`;
594
+ default: return `${yyyy}-${mm}-${dd}`;
595
+ }
596
+ }
597
+
598
+ // ---- Prompt Queue helpers ----
599
+
600
+ export async function getQueue(sessionId) {
601
+ const items = await getByIndex('promptQueue', 'sessionId', sessionId);
602
+ return items.sort((a, b) => a.position - b.position);
603
+ }
604
+
605
+ export async function addToQueue(sessionId, text) {
606
+ const items = await getQueue(sessionId);
607
+ const maxPos = items.length > 0 ? Math.max(...items.map(i => i.position)) : -1;
608
+ const now = Date.now();
609
+ const id = await put('promptQueue', { sessionId, text: text.trim(), position: maxPos + 1, createdAt: now });
610
+ return { id, sessionId, text: text.trim(), position: maxPos + 1, createdAt: now };
611
+ }
612
+
613
+ export async function popQueue(sessionId) {
614
+ const items = await getQueue(sessionId);
615
+ if (items.length === 0) return null;
616
+ const top = items[0];
617
+ await del('promptQueue', top.id);
618
+ return top;
619
+ }
620
+
621
+ export async function reorderQueue(sessionId, orderedIds) {
622
+ const tx = getDB().transaction('promptQueue', 'readwrite');
623
+ const store = tx.objectStore('promptQueue');
624
+ for (let i = 0; i < orderedIds.length; i++) {
625
+ const req = store.get(orderedIds[i]);
626
+ req.onsuccess = () => {
627
+ const item = req.result;
628
+ if (item && item.sessionId === sessionId) {
629
+ item.position = i;
630
+ store.put(item);
631
+ }
632
+ };
633
+ }
634
+ return new Promise((resolve, reject) => {
635
+ tx.oncomplete = () => resolve();
636
+ tx.onerror = () => reject(tx.error);
637
+ });
638
+ }
639
+
640
+ // ---- Notes helpers ----
641
+
642
+ export async function getNotes(sessionId) {
643
+ const notes = await getByIndex('notes', 'sessionId', sessionId);
644
+ return notes.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
645
+ }
646
+
647
+ export async function addNote(sessionId, text) {
648
+ const now = Date.now();
649
+ const id = await put('notes', { sessionId, text, createdAt: now, updatedAt: now });
650
+ return { id, sessionId, text, createdAt: now, updatedAt: now };
651
+ }
652
+
653
+ // ---- Settings helpers ----
654
+
655
+ export async function getSetting(key) {
656
+ const row = await get('settings', key);
657
+ return row ? row.value : null;
658
+ }
659
+
660
+ export async function setSetting(key, value) {
661
+ await put('settings', { key, value, updatedAt: Date.now() });
662
+ }
663
+
664
+ export async function getAllSettings() {
665
+ const all = await getAll('settings');
666
+ const result = {};
667
+ for (const row of all) result[row.key] = row.value;
668
+ return result;
669
+ }
670
+
671
+ export async function setManySettings(obj) {
672
+ const now = Date.now();
673
+ await putMany('settings', Object.entries(obj).map(([key, value]) => ({ key, value, updatedAt: now })));
674
+ }
675
+
676
+ // ---- Seed defaults (first run only) ----
677
+
678
+ async function seedDefaults() {
679
+ // Seed default settings
680
+ const settingsCount = await count('settings');
681
+ if (settingsCount === 0) {
682
+ const now = Date.now();
683
+ const defaults = {
684
+ theme: 'command-center',
685
+ fontSize: '13',
686
+ modelUrl: 'https://threejs.org/examples/models/gltf/Xbot.glb',
687
+ modelName: 'Xbot',
688
+ soundEnabled: 'true',
689
+ soundVolume: '0.5',
690
+ soundPack: 'default',
691
+ };
692
+ await putMany('settings', Object.entries(defaults).map(([key, value]) => ({ key, value, updatedAt: now })));
693
+ console.log('[browserDb] Seeded default settings');
694
+ }
695
+
696
+ // Seed default summary prompts
697
+ const promptCount = await count('summaryPrompts');
698
+ if (promptCount === 0) {
699
+ const now = Date.now();
700
+ const templates = [
701
+ {
702
+ name: 'Detailed Technical Summary',
703
+ prompt: `You are summarizing a Claude Code coding session. Produce a detailed summary with these sections:
704
+
705
+ ## Overview
706
+ One paragraph describing the overall goal and outcome of the session.
707
+
708
+ ## What Was Accomplished
709
+ - List every concrete change, feature, or fix completed (be specific — mention file names, function names, components)
710
+ - Group related changes together
711
+
712
+ ## Key Decisions & Approach
713
+ - Architectural choices made (e.g. data structures, algorithms, patterns chosen)
714
+ - Trade-offs considered
715
+ - Why certain approaches were chosen over alternatives
716
+
717
+ ## Files Modified
718
+ List each file touched and a brief note on what changed in it.
719
+
720
+ ## Issues & Blockers
721
+ - Any errors encountered and how they were resolved
722
+ - Workarounds applied
723
+ - Things left unfinished or requiring follow-up
724
+
725
+ ## Technical Details
726
+ - Notable implementation details worth remembering
727
+ - Dependencies added or updated
728
+ - Configuration changes
729
+
730
+ Be thorough and specific. Include file paths, function names, and concrete details. This summary should allow someone to fully understand what happened in this session without reading the transcript.`,
731
+ isDefault: 1,
732
+ createdAt: now,
733
+ updatedAt: now,
734
+ },
735
+ {
736
+ name: 'Quick Bullet Points',
737
+ prompt: 'Summarize this Claude Code session in 5-8 bullet points. Focus on what was accomplished, key files changed, and any issues encountered.',
738
+ isDefault: 0,
739
+ createdAt: now,
740
+ updatedAt: now,
741
+ },
742
+ {
743
+ name: 'Changelog Entry',
744
+ prompt: `Generate a changelog entry for this Claude Code session. Format it as:
745
+
746
+ ### [Feature/Fix/Refactor]: <title>
747
+
748
+ **Changes:**
749
+ - List each change with the affected file path
750
+ - Be specific about what was added, modified, or removed
751
+
752
+ **Breaking Changes:** (if any)
753
+ **Migration Notes:** (if any)`,
754
+ isDefault: 0,
755
+ createdAt: now,
756
+ updatedAt: now,
757
+ },
758
+ {
759
+ name: 'Handoff Notes',
760
+ prompt: `Write detailed handoff notes for another developer picking up this work. Include:
761
+
762
+ ## Context
763
+ What was the developer trying to accomplish? What's the current state of things?
764
+
765
+ ## What's Done
766
+ List completed changes with file paths and implementation details.
767
+
768
+ ## What's Left / Next Steps
769
+ Any unfinished work, TODOs, or follow-up tasks.
770
+
771
+ ## Gotchas & Important Notes
772
+ Anything the next developer needs to be aware of — edge cases, workarounds, architectural decisions that might not be obvious.
773
+
774
+ ## How to Test
775
+ Steps to verify the changes work correctly.`,
776
+ isDefault: 0,
777
+ createdAt: now,
778
+ updatedAt: now,
779
+ },
780
+ {
781
+ name: 'PR Description',
782
+ prompt: `Generate a pull request description for the changes made in this session. Format:
783
+
784
+ ## Summary
785
+ 1-3 sentences describing what this PR does.
786
+
787
+ ## Changes
788
+ - Bullet list of every change, organized by file or feature area
789
+ - Include file paths
790
+
791
+ ## Testing
792
+ - How to test these changes
793
+ - Any edge cases to watch for
794
+
795
+ ## Screenshots / Notes
796
+ Any additional context for reviewers.`,
797
+ isDefault: 0,
798
+ createdAt: now,
799
+ updatedAt: now,
800
+ },
801
+ ];
802
+ await putMany('summaryPrompts', templates);
803
+ console.log('[browserDb] Seeded 5 default summary prompt templates');
804
+ }
805
+ }
806
+