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.
- package/README.md +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- 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
|
+
|