agentacta 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/LICENSE +21 -0
- package/README.md +216 -0
- package/config.js +55 -0
- package/db.js +137 -0
- package/index.js +376 -0
- package/indexer.js +330 -0
- package/package.json +51 -0
- package/public/app.js +658 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +72 -0
- package/public/index.html +50 -0
- package/public/manifest.json +26 -0
- package/public/style.css +562 -0
- package/public/sw.js +42 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
const $ = (s, p = document) => p.querySelector(s);
|
|
2
|
+
const $$ = (s, p = document) => [...p.querySelectorAll(s)];
|
|
3
|
+
const content = $('#content');
|
|
4
|
+
const API = '/api';
|
|
5
|
+
|
|
6
|
+
async function api(path) {
|
|
7
|
+
const res = await fetch(API + path);
|
|
8
|
+
return res.json();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fmtTime(ts) {
|
|
12
|
+
if (!ts) return '';
|
|
13
|
+
const d = new Date(ts);
|
|
14
|
+
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fmtTimeShort(ts) {
|
|
18
|
+
if (!ts) return '';
|
|
19
|
+
return new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function dlExport(url, filename) {
|
|
23
|
+
fetch(url).then(r => r.blob()).then(blob => {
|
|
24
|
+
const a = document.createElement('a');
|
|
25
|
+
a.href = URL.createObjectURL(blob);
|
|
26
|
+
a.download = filename;
|
|
27
|
+
a.click();
|
|
28
|
+
URL.revokeObjectURL(a.href);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fmtTokens(n) {
|
|
33
|
+
if (!n) return '0';
|
|
34
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
35
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
36
|
+
return n.toLocaleString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fmtDate(ts) {
|
|
40
|
+
if (!ts) return '';
|
|
41
|
+
return new Date(ts).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fmtCost(c) {
|
|
45
|
+
if (!c || c === 0) return '';
|
|
46
|
+
if (c < 0.01) return `$${c.toFixed(4)}`;
|
|
47
|
+
return `$${c.toFixed(2)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escHtml(s) {
|
|
51
|
+
if (!s) return '';
|
|
52
|
+
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function truncate(s, n = 200) {
|
|
56
|
+
if (!s) return '';
|
|
57
|
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Removed jumpToInitialPrompt - now handled within session view
|
|
61
|
+
|
|
62
|
+
function badgeClass(type, role) {
|
|
63
|
+
if (type === 'tool_call') return 'badge-tool_call';
|
|
64
|
+
if (type === 'tool_result') return 'badge-tool_result';
|
|
65
|
+
if (role === 'user') return 'badge-user';
|
|
66
|
+
if (role === 'assistant') return 'badge-assistant';
|
|
67
|
+
return 'badge-message';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderEvent(ev) {
|
|
71
|
+
const badge = `<span class="event-badge ${badgeClass(ev.type, ev.role)}">${ev.type === 'tool_call' ? 'tool' : ev.role || ev.type}</span>`;
|
|
72
|
+
let body = '';
|
|
73
|
+
|
|
74
|
+
if (ev.type === 'tool_call') {
|
|
75
|
+
body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
|
|
76
|
+
if (ev.tool_args) {
|
|
77
|
+
try {
|
|
78
|
+
const args = JSON.parse(ev.tool_args);
|
|
79
|
+
body += `<div class="tool-args">${escHtml(JSON.stringify(args, null, 2))}</div>`;
|
|
80
|
+
} catch {
|
|
81
|
+
body += `<div class="tool-args">${escHtml(ev.tool_args)}</div>`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} else if (ev.type === 'tool_result') {
|
|
85
|
+
body = `<span class="tool-name">→ ${escHtml(ev.tool_name)}</span>`;
|
|
86
|
+
if (ev.content) {
|
|
87
|
+
body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
body = `<div class="event-content">${escHtml(ev.content || '')}</div>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return `<div class="event-item" data-event-id="${ev.id}">
|
|
94
|
+
<div class="event-time">${fmtTimeShort(ev.timestamp)}</div>
|
|
95
|
+
${badge}
|
|
96
|
+
<div class="event-body">${body}</div>
|
|
97
|
+
</div>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fmtDuration(start, end) {
|
|
101
|
+
if (!start) return '';
|
|
102
|
+
const ms = (end ? new Date(end) : new Date()) - new Date(start);
|
|
103
|
+
const mins = Math.floor(ms / 60000);
|
|
104
|
+
if (mins < 60) return `${mins}m`;
|
|
105
|
+
const hrs = Math.floor(mins / 60);
|
|
106
|
+
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
|
|
107
|
+
const days = Math.floor(hrs / 24);
|
|
108
|
+
return `${days}d ${hrs % 24}h`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fmtTimeOnly(ts) {
|
|
112
|
+
if (!ts) return '';
|
|
113
|
+
return new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderSessionItem(s) {
|
|
117
|
+
const duration = fmtDuration(s.start_time, s.end_time);
|
|
118
|
+
const timeRange = `${fmtTime(s.start_time)} → ${s.end_time ? fmtTimeOnly(s.end_time) : 'now'}`;
|
|
119
|
+
|
|
120
|
+
return `
|
|
121
|
+
<div class="session-item" data-id="${s.id}">
|
|
122
|
+
<div class="session-header">
|
|
123
|
+
<span class="session-time">${timeRange} · ${duration}</span>
|
|
124
|
+
<span style="display:flex;gap:6px;align-items:center">
|
|
125
|
+
${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(s.agent)}</span>` : ''}
|
|
126
|
+
${s.session_type ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
|
|
127
|
+
${s.model ? `<span class="session-model">${escHtml(s.model)}</span>` : ''}
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="session-summary">${escHtml(truncate(s.summary || 'No summary', 120))}</div>
|
|
131
|
+
<div class="session-meta">
|
|
132
|
+
<span>💬 ${s.message_count}</span>
|
|
133
|
+
<span>🔧 ${s.tool_count}</span>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Views ---
|
|
140
|
+
|
|
141
|
+
async function viewSearch(query = '') {
|
|
142
|
+
const typeFilter = window._searchType || '';
|
|
143
|
+
const roleFilter = window._searchRole || '';
|
|
144
|
+
|
|
145
|
+
let html = `<div class="page-title">Search</div>
|
|
146
|
+
<div class="search-bar">
|
|
147
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
148
|
+
<input type="text" id="searchInput" placeholder="Search messages, tool calls, files…" value="${escHtml(query)}">
|
|
149
|
+
</div>
|
|
150
|
+
<div class="filters">
|
|
151
|
+
<span class="filter-chip ${typeFilter===''?'active':''}" data-filter="type" data-val="">All</span>
|
|
152
|
+
<span class="filter-chip ${typeFilter==='message'?'active':''}" data-filter="type" data-val="message">Messages</span>
|
|
153
|
+
<span class="filter-chip ${typeFilter==='tool_call'?'active':''}" data-filter="type" data-val="tool_call">Tool Calls</span>
|
|
154
|
+
<span class="filter-chip ${typeFilter==='tool_result'?'active':''}" data-filter="type" data-val="tool_result">Results</span>
|
|
155
|
+
<span class="filter-chip ${roleFilter==='user'?'active':''}" data-filter="role" data-val="user">User</span>
|
|
156
|
+
<span class="filter-chip ${roleFilter==='assistant'?'active':''}" data-filter="role" data-val="assistant">Assistant</span>
|
|
157
|
+
</div>
|
|
158
|
+
<div id="results"></div>`;
|
|
159
|
+
|
|
160
|
+
content.innerHTML = html;
|
|
161
|
+
|
|
162
|
+
const input = $('#searchInput');
|
|
163
|
+
input.focus();
|
|
164
|
+
let debounce;
|
|
165
|
+
input.addEventListener('input', () => {
|
|
166
|
+
clearTimeout(debounce);
|
|
167
|
+
debounce = setTimeout(() => doSearch(input.value), 250);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
$$('.filter-chip').forEach(chip => {
|
|
171
|
+
chip.addEventListener('click', () => {
|
|
172
|
+
const f = chip.dataset.filter;
|
|
173
|
+
const v = chip.dataset.val;
|
|
174
|
+
if (f === 'type') window._searchType = v === window._searchType ? '' : v;
|
|
175
|
+
if (f === 'role') window._searchRole = v === window._searchRole ? '' : v;
|
|
176
|
+
viewSearch(input.value);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (query) doSearch(query);
|
|
181
|
+
else showSearchHome();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function showSearchHome() {
|
|
185
|
+
const el = $('#results');
|
|
186
|
+
el.innerHTML = '<div class="loading">Loading…</div>';
|
|
187
|
+
|
|
188
|
+
const stats = await api('/stats');
|
|
189
|
+
const sessions = await api('/sessions?limit=5');
|
|
190
|
+
|
|
191
|
+
let suggestions = [];
|
|
192
|
+
try { const r = await fetch('/api/suggestions'); const d = await r.json(); suggestions = d.suggestions || []; } catch(e) { suggestions = []; }
|
|
193
|
+
|
|
194
|
+
let html = `
|
|
195
|
+
<div class="stat-grid" style="margin-top:8px">
|
|
196
|
+
<div class="stat-card"><div class="label">Sessions</div><div class="value">${stats.sessions}</div></div>
|
|
197
|
+
<div class="stat-card"><div class="label">Messages</div><div class="value">${stats.messages.toLocaleString()}</div></div>
|
|
198
|
+
<div class="stat-card"><div class="label">Tool Calls</div><div class="value">${stats.toolCalls.toLocaleString()}</div></div>
|
|
199
|
+
<div class="stat-card"><div class="label">Tokens</div><div class="value">${(stats.totalTokens || 0).toLocaleString()}</div></div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div class="section-label">Quick Search</div>
|
|
203
|
+
<div class="filters" id="suggestions">
|
|
204
|
+
${suggestions.map(s => `<span class="filter-chip suggestion" data-q="${s}">${s}</span>`).join('')}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div class="section-label">Recent Sessions</div>
|
|
208
|
+
${sessions.sessions.map(renderSessionItem).join('')}
|
|
209
|
+
`;
|
|
210
|
+
|
|
211
|
+
el.innerHTML = html;
|
|
212
|
+
|
|
213
|
+
$$('.suggestion', el).forEach(chip => {
|
|
214
|
+
chip.addEventListener('click', () => {
|
|
215
|
+
$('#searchInput').value = chip.dataset.q;
|
|
216
|
+
doSearch(chip.dataset.q);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
$$('.session-item', el).forEach(item => {
|
|
221
|
+
item.addEventListener('click', () => viewSession(item.dataset.id));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function doSearch(q) {
|
|
226
|
+
const el = $('#results');
|
|
227
|
+
if (!q.trim()) { el.innerHTML = '<div class="empty"><h2>Type to search</h2><p>Search across all sessions, messages, and tool calls</p></div>'; return; }
|
|
228
|
+
|
|
229
|
+
el.innerHTML = '<div class="loading">Searching…</div>';
|
|
230
|
+
|
|
231
|
+
const type = window._searchType || '';
|
|
232
|
+
const role = window._searchRole || '';
|
|
233
|
+
let url = `/search?q=${encodeURIComponent(q)}&limit=100`;
|
|
234
|
+
if (type) url += `&type=${type}`;
|
|
235
|
+
if (role) url += `&role=${role}`;
|
|
236
|
+
|
|
237
|
+
const data = await api(url);
|
|
238
|
+
|
|
239
|
+
if (data.error) { el.innerHTML = `<div class="empty"><p>${escHtml(data.error)}</p></div>`; return; }
|
|
240
|
+
if (!data.results.length) { el.innerHTML = '<div class="empty"><h2>No results</h2></div>'; return; }
|
|
241
|
+
|
|
242
|
+
let header = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
|
243
|
+
<span class="section-label" style="margin:0">${data.results.length} results</span>
|
|
244
|
+
<div style="display:flex;gap:8px">
|
|
245
|
+
<a class="export-btn" href="#" onclick="dlExport('/api/export/search?q=${encodeURIComponent(q)}&format=md','search.md');return false">📄 MD</a>
|
|
246
|
+
<a class="export-btn" href="#" onclick="dlExport('/api/export/search?q=${encodeURIComponent(q)}&format=json','search.json');return false">📋 JSON</a>
|
|
247
|
+
</div>
|
|
248
|
+
</div>`;
|
|
249
|
+
|
|
250
|
+
el.innerHTML = header + data.results.map(r => `
|
|
251
|
+
<div class="result-item">
|
|
252
|
+
<div class="result-meta">
|
|
253
|
+
<span class="event-badge ${badgeClass(r.type, r.role)}">${r.type === 'tool_call' ? 'tool' : r.role || r.type}</span>
|
|
254
|
+
<span class="session-time">${fmtTime(r.timestamp)}</span>
|
|
255
|
+
${r.tool_name ? `<span class="tool-name">${escHtml(r.tool_name)}</span>` : ''}
|
|
256
|
+
<span class="session-link" data-session="${r.session_id}">view session →</span>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="result-content">${escHtml(truncate(r.content || r.tool_args || r.tool_result || '', 400))}</div>
|
|
259
|
+
</div>
|
|
260
|
+
`).join('');
|
|
261
|
+
|
|
262
|
+
$$('.session-link', el).forEach(link => {
|
|
263
|
+
link.addEventListener('click', () => viewSession(link.dataset.session));
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function viewSessions() {
|
|
268
|
+
content.innerHTML = '<div class="loading">Loading…</div>';
|
|
269
|
+
const data = await api('/sessions?limit=200');
|
|
270
|
+
|
|
271
|
+
let html = `<div class="page-title">Sessions</div>`;
|
|
272
|
+
html += data.sessions.map(renderSessionItem).join('');
|
|
273
|
+
content.innerHTML = html;
|
|
274
|
+
|
|
275
|
+
$$('.session-item').forEach(item => {
|
|
276
|
+
item.addEventListener('click', () => viewSession(item.dataset.id));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function viewSession(id) {
|
|
281
|
+
content.innerHTML = '<div class="loading">Loading…</div>';
|
|
282
|
+
const data = await api(`/sessions/${id}`);
|
|
283
|
+
|
|
284
|
+
if (data.error) { content.innerHTML = `<div class="empty"><h2>${data.error}</h2></div>`; return; }
|
|
285
|
+
|
|
286
|
+
const s = data.session;
|
|
287
|
+
const cost = fmtCost(s.total_cost);
|
|
288
|
+
let html = `
|
|
289
|
+
<div class="back-btn" id="backBtn">← Back</div>
|
|
290
|
+
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
291
|
+
<div class="page-title">Session</div>
|
|
292
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
293
|
+
${s.first_message_id ? `<button class="jump-to-start-btn" id="jumpToStartBtn" title="Jump to initial prompt">↗️ Initial Prompt</button>` : ''}
|
|
294
|
+
${data.hasArchive ? `<a class="export-btn" href="#" onclick="dlExport('/api/archive/export/${id}','session.jsonl');return false">📦 JSONL</a>` : ''}
|
|
295
|
+
<a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=md','session.md');return false">📄 MD</a>
|
|
296
|
+
<a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=json','session.json');return false">📋 JSON</a>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
<div class="session-item" style="cursor:default">
|
|
300
|
+
<div class="session-header">
|
|
301
|
+
<span class="session-time">${fmtDate(s.start_time)} · ${fmtTimeShort(s.start_time)} – ${fmtTimeShort(s.end_time)}</span>
|
|
302
|
+
<span style="display:flex;gap:6px;align-items:center">
|
|
303
|
+
${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(s.agent)}</span>` : ''}
|
|
304
|
+
${s.session_type ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
|
|
305
|
+
${s.model ? `<span class="session-model">${escHtml(s.model)}</span>` : ''}
|
|
306
|
+
</span>
|
|
307
|
+
</div>
|
|
308
|
+
<div class="session-meta" style="display:grid;grid-template-columns:repeat(2,1fr);gap:6px 16px">
|
|
309
|
+
<span>💬 ${s.message_count} messages</span>
|
|
310
|
+
<span>🔧 ${s.tool_count} tools</span>
|
|
311
|
+
${s.output_tokens ? `<span>📤 ${fmtTokens(s.output_tokens)} output</span><span>📥 ${fmtTokens(s.input_tokens + s.cache_read_tokens)} input</span>` : s.total_tokens ? `<span>🔤 ${fmtTokens(s.total_tokens)} tokens</span><span></span>` : '<span></span><span></span>'}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
<div class="section-label">Events</div>
|
|
315
|
+
`;
|
|
316
|
+
|
|
317
|
+
html += data.events.map(renderEvent).join('');
|
|
318
|
+
content.innerHTML = html;
|
|
319
|
+
|
|
320
|
+
$('#backBtn').addEventListener('click', () => {
|
|
321
|
+
if (window._lastView === 'timeline') viewTimeline();
|
|
322
|
+
else if (window._lastView === 'files') viewFiles();
|
|
323
|
+
else viewSessions();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const jumpBtn = $('#jumpToStartBtn');
|
|
327
|
+
if (jumpBtn) {
|
|
328
|
+
jumpBtn.addEventListener('click', () => {
|
|
329
|
+
const firstMessage = document.querySelector(`[data-event-id="${s.first_message_id}"]`);
|
|
330
|
+
if (firstMessage) {
|
|
331
|
+
firstMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
332
|
+
firstMessage.style.background = 'var(--accent-bg)';
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
firstMessage.style.background = '';
|
|
335
|
+
}, 2000);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function viewTimeline(date) {
|
|
342
|
+
if (!date) date = new Date().toISOString().slice(0, 10);
|
|
343
|
+
window._lastView = 'timeline';
|
|
344
|
+
|
|
345
|
+
let html = `<div class="page-title">Timeline</div>
|
|
346
|
+
<input type="date" class="date-input" id="dateInput" value="${date}">
|
|
347
|
+
<div id="timelineContent"><div class="loading">Loading…</div></div>`;
|
|
348
|
+
content.innerHTML = html;
|
|
349
|
+
|
|
350
|
+
const data = await api(`/timeline?date=${date}`);
|
|
351
|
+
const el = $('#timelineContent');
|
|
352
|
+
|
|
353
|
+
if (!data.events.length) {
|
|
354
|
+
el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
|
|
355
|
+
} else {
|
|
356
|
+
el.innerHTML = data.events.map(renderEvent).join('');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
$('#dateInput').addEventListener('change', e => viewTimeline(e.target.value));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function viewStats() {
|
|
363
|
+
content.innerHTML = '<div class="loading">Loading…</div>';
|
|
364
|
+
const data = await api('/stats');
|
|
365
|
+
|
|
366
|
+
let html = `<div class="page-title">Stats</div>
|
|
367
|
+
<div class="stat-grid">
|
|
368
|
+
<div class="stat-card"><div class="label">Sessions</div><div class="value">${data.sessions}</div></div>
|
|
369
|
+
<div class="stat-card"><div class="label">Messages</div><div class="value">${data.messages.toLocaleString()}</div></div>
|
|
370
|
+
<div class="stat-card"><div class="label">Tool Calls</div><div class="value">${data.toolCalls.toLocaleString()}</div></div>
|
|
371
|
+
<div class="stat-card"><div class="label">Unique Tools</div><div class="value">${data.uniqueTools}</div></div>
|
|
372
|
+
<div class="stat-card"><div class="label">Total Tokens</div><div class="value">${(data.totalTokens || 0).toLocaleString()}</div></div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div class="section-label">Configuration</div>
|
|
376
|
+
<div class="stat-grid">
|
|
377
|
+
<div class="stat-card"><div class="label">Storage Mode</div><div class="value" style="font-size:18px">${escHtml(data.storageMode || 'reference')}</div></div>
|
|
378
|
+
<div class="stat-card"><div class="label">DB Size</div><div class="value" style="font-size:18px">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
${data.sessionDirs && data.sessionDirs.length ? `<div class="section-label">Sessions Paths</div>
|
|
382
|
+
<div style="font-size:13px;color:var(--text2);font-family:var(--mono)">
|
|
383
|
+
${data.sessionDirs.map(d => {
|
|
384
|
+
const display = d.path.replace(/^\/home\/[^/]+/, '~').replace(/^\/Users\/[^/]+/, '~');
|
|
385
|
+
return `<div style="margin-bottom:4px">📂 ${escHtml(display)} <span style="color:var(--accent)">(${escHtml(d.agent)})</span></div>`;
|
|
386
|
+
}).join('')}
|
|
387
|
+
</div>` : ''}
|
|
388
|
+
|
|
389
|
+
${data.agents && data.agents.length > 1 ? `<div class="section-label">Agents</div><div class="filters">${data.agents.map(a => `<span class="filter-chip">${escHtml(a)}</span>`).join('')}</div>` : ''}
|
|
390
|
+
<div class="section-label">Date Range</div>
|
|
391
|
+
<p style="color:var(--text2);font-size:14px">${fmtDate(data.dateRange?.earliest)} — ${fmtDate(data.dateRange?.latest)}</p>
|
|
392
|
+
<div class="section-label">Tools Used</div>
|
|
393
|
+
<div class="filters">${(data.tools||[]).filter(t => t).sort().map(t => `<span class="filter-chip">${escHtml(t)}</span>`).join('')}</div>
|
|
394
|
+
`;
|
|
395
|
+
|
|
396
|
+
content.innerHTML = html;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function viewFiles() {
|
|
400
|
+
window._lastView = 'files';
|
|
401
|
+
content.innerHTML = '<div class="loading">Loading…</div>';
|
|
402
|
+
const data = await api('/files?limit=500');
|
|
403
|
+
window._allFiles = data.files || [];
|
|
404
|
+
window._fileSort = window._fileSort || 'touches';
|
|
405
|
+
window._fileFilter = window._fileFilter || '';
|
|
406
|
+
window._fileSearch = window._fileSearch || '';
|
|
407
|
+
window._fileGrouped = window._fileGrouped !== false;
|
|
408
|
+
renderFiles();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getFileExt(p) {
|
|
412
|
+
const m = p.match(/\.([a-zA-Z0-9]+)$/);
|
|
413
|
+
return m ? '.' + m[1] : 'other';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function getFileDir(p) {
|
|
417
|
+
// Group by project-level directory
|
|
418
|
+
// Strip common home dir prefixes
|
|
419
|
+
let rel = p.replace(/^\/home\/[^/]+\//, '~/').replace(/^\/Users\/[^/]+\//, '~/');
|
|
420
|
+
if (rel.startsWith('~/')) rel = rel.slice(2);
|
|
421
|
+
const parts = rel.split('/');
|
|
422
|
+
if (parts.length <= 2) return parts[0] || '/';
|
|
423
|
+
return parts.slice(0, 2).join('/');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function renderFiles() {
|
|
427
|
+
let files = [...window._allFiles];
|
|
428
|
+
|
|
429
|
+
// Search filter
|
|
430
|
+
const q = window._fileSearch.toLowerCase();
|
|
431
|
+
if (q) files = files.filter(f => f.file_path.toLowerCase().includes(q));
|
|
432
|
+
|
|
433
|
+
// Extension filter
|
|
434
|
+
if (window._fileFilter) {
|
|
435
|
+
files = files.filter(f => getFileExt(f.file_path) === window._fileFilter);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Sort
|
|
439
|
+
const sort = window._fileSort;
|
|
440
|
+
if (sort === 'touches') files.sort((a, b) => b.touch_count - a.touch_count);
|
|
441
|
+
else if (sort === 'recent') files.sort((a, b) => new Date(b.last_touched) - new Date(a.last_touched));
|
|
442
|
+
else if (sort === 'name') files.sort((a, b) => a.file_path.localeCompare(b.file_path));
|
|
443
|
+
else if (sort === 'sessions') files.sort((a, b) => b.session_count - a.session_count);
|
|
444
|
+
|
|
445
|
+
// Get unique extensions for filter chips
|
|
446
|
+
const exts = [...new Set(window._allFiles.map(f => getFileExt(f.file_path)))].sort();
|
|
447
|
+
|
|
448
|
+
let html = `<div class="page-title">Files</div>
|
|
449
|
+
<div class="search-bar" style="margin-bottom:12px">
|
|
450
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
451
|
+
<input type="text" id="fileSearchInput" placeholder="Filter by filename or path…" value="${escHtml(window._fileSearch)}">
|
|
452
|
+
</div>
|
|
453
|
+
<div class="filters" style="margin-bottom:8px">
|
|
454
|
+
<span class="filter-chip ${sort==='touches'?'active':''}" data-sort="touches">Most touched</span>
|
|
455
|
+
<span class="filter-chip ${sort==='recent'?'active':''}" data-sort="recent">Recent</span>
|
|
456
|
+
<span class="filter-chip ${sort==='sessions'?'active':''}" data-sort="sessions">Most sessions</span>
|
|
457
|
+
<span class="filter-chip ${sort==='name'?'active':''}" data-sort="name">A-Z</span>
|
|
458
|
+
</div>
|
|
459
|
+
<div class="filters" style="margin-bottom:12px">
|
|
460
|
+
<span class="filter-chip ext-chip ${!window._fileFilter?'active':''}" data-ext="">All</span>
|
|
461
|
+
${exts.map(e => `<span class="filter-chip ext-chip ${window._fileFilter===e?'active':''}" data-ext="${e}">${e}</span>`).join('')}
|
|
462
|
+
</div>
|
|
463
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
|
464
|
+
<span style="color:var(--text2);font-size:13px">${files.length} files</span>
|
|
465
|
+
<span class="filter-chip ${window._fileGrouped?'active':''}" id="groupToggle" style="cursor:pointer">📂 Group by directory</span>
|
|
466
|
+
</div>
|
|
467
|
+
<div id="filesList"></div>`;
|
|
468
|
+
|
|
469
|
+
content.innerHTML = html;
|
|
470
|
+
|
|
471
|
+
// Render file list
|
|
472
|
+
const listEl = $('#filesList');
|
|
473
|
+
|
|
474
|
+
if (window._fileGrouped && !q) {
|
|
475
|
+
// Group by directory
|
|
476
|
+
const groups = {};
|
|
477
|
+
files.forEach(f => {
|
|
478
|
+
const dir = getFileDir(f.file_path);
|
|
479
|
+
if (!groups[dir]) groups[dir] = [];
|
|
480
|
+
groups[dir].push(f);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Sort groups by total touches
|
|
484
|
+
const sortedGroups = Object.entries(groups).sort((a, b) => {
|
|
485
|
+
const aTotal = a[1].reduce((s, f) => s + f.touch_count, 0);
|
|
486
|
+
const bTotal = b[1].reduce((s, f) => s + f.touch_count, 0);
|
|
487
|
+
return bTotal - aTotal;
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
listEl.innerHTML = sortedGroups.map(([dir, dirFiles]) => {
|
|
491
|
+
const totalTouches = dirFiles.reduce((s, f) => s + f.touch_count, 0);
|
|
492
|
+
return `
|
|
493
|
+
<div class="file-group">
|
|
494
|
+
<div class="file-group-header" data-dir="${escHtml(dir)}">
|
|
495
|
+
<span class="file-group-arrow">▶</span>
|
|
496
|
+
<span class="file-group-name">~/${escHtml(dir)}</span>
|
|
497
|
+
<span style="color:var(--text2);font-size:12px;margin-left:auto">${dirFiles.length} files · ${totalTouches} touches</span>
|
|
498
|
+
</div>
|
|
499
|
+
<div class="file-group-items" style="display:none">
|
|
500
|
+
${dirFiles.map(f => renderFileItem(f)).join('')}
|
|
501
|
+
</div>
|
|
502
|
+
</div>`;
|
|
503
|
+
}).join('');
|
|
504
|
+
|
|
505
|
+
$$('.file-group-header').forEach(h => {
|
|
506
|
+
h.addEventListener('click', () => {
|
|
507
|
+
const items = h.nextElementSibling;
|
|
508
|
+
const arrow = h.querySelector('.file-group-arrow');
|
|
509
|
+
if (items.style.display === 'none') {
|
|
510
|
+
items.style.display = 'block';
|
|
511
|
+
arrow.textContent = '▼';
|
|
512
|
+
} else {
|
|
513
|
+
items.style.display = 'none';
|
|
514
|
+
arrow.textContent = '▶';
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
} else {
|
|
519
|
+
listEl.innerHTML = files.map(f => renderFileItem(f)).join('');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Event listeners — must re-attach every render since innerHTML replaces DOM
|
|
523
|
+
let debounce;
|
|
524
|
+
const searchInput = $('#fileSearchInput');
|
|
525
|
+
searchInput.addEventListener('input', e => {
|
|
526
|
+
clearTimeout(debounce);
|
|
527
|
+
debounce = setTimeout(() => { window._fileSearch = e.target.value; renderFiles(); }, 200);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Preserve cursor position after re-render
|
|
531
|
+
const cursorPos = window._fileCursorPos || 0;
|
|
532
|
+
searchInput.setSelectionRange(cursorPos, cursorPos);
|
|
533
|
+
if (window._fileSearch) searchInput.focus();
|
|
534
|
+
|
|
535
|
+
$$('[data-sort]').forEach(chip => {
|
|
536
|
+
chip.onclick = () => { window._fileSort = chip.dataset.sort; renderFiles(); };
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
$$('.ext-chip').forEach(chip => {
|
|
540
|
+
chip.onclick = () => { window._fileFilter = chip.dataset.ext; renderFiles(); };
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
$('#groupToggle').addEventListener('click', () => { window._fileGrouped = !window._fileGrouped; renderFiles(); });
|
|
544
|
+
|
|
545
|
+
$$('.file-item').forEach(item => {
|
|
546
|
+
item.addEventListener('click', () => viewFileDetail(item.dataset.path));
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Track cursor for re-renders
|
|
550
|
+
searchInput.addEventListener('keyup', () => { window._fileCursorPos = searchInput.selectionStart; });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function renderFileItem(f) {
|
|
554
|
+
const fname = f.file_path.split('/').pop();
|
|
555
|
+
const dir = f.file_path.split('/').slice(0, -1).join('/');
|
|
556
|
+
return `
|
|
557
|
+
<div class="file-item" data-path="${escHtml(f.file_path)}">
|
|
558
|
+
<div class="file-path"><span style="color:var(--text)">${escHtml(fname)}</span> <span style="color:var(--text2);font-size:12px">${escHtml(dir)}/</span></div>
|
|
559
|
+
<div class="file-meta">
|
|
560
|
+
<span>${f.touch_count} touches</span>
|
|
561
|
+
<span>${f.session_count} sessions</span>
|
|
562
|
+
<span style="color:var(--orange)">${escHtml(f.operations)}</span>
|
|
563
|
+
<span class="session-time">${fmtTime(f.last_touched)}</span>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function viewFileDetail(filePath) {
|
|
570
|
+
content.innerHTML = '<div class="loading">Loading…</div>';
|
|
571
|
+
const data = await api(`/files/sessions?path=${encodeURIComponent(filePath)}`);
|
|
572
|
+
|
|
573
|
+
let html = `
|
|
574
|
+
<div class="back-btn" id="backBtn">← Back</div>
|
|
575
|
+
<div class="page-title" style="word-break:break-all;font-size:16px">${escHtml(filePath)}</div>
|
|
576
|
+
<div class="section-label">${data.sessions.length} sessions touched this file</div>
|
|
577
|
+
`;
|
|
578
|
+
|
|
579
|
+
html += data.sessions.map(s => renderSessionItem(s)).join('');
|
|
580
|
+
content.innerHTML = html;
|
|
581
|
+
|
|
582
|
+
$('#backBtn').addEventListener('click', () => viewFiles());
|
|
583
|
+
$$('.session-item').forEach(item => {
|
|
584
|
+
item.addEventListener('click', () => viewSession(item.dataset.id));
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- Navigation ---
|
|
589
|
+
window._searchType = '';
|
|
590
|
+
window._searchRole = '';
|
|
591
|
+
window._lastView = 'sessions';
|
|
592
|
+
|
|
593
|
+
$$('.nav-item').forEach(item => {
|
|
594
|
+
item.addEventListener('click', () => {
|
|
595
|
+
$$('.nav-item').forEach(i => i.classList.remove('active'));
|
|
596
|
+
item.classList.add('active');
|
|
597
|
+
const view = item.dataset.view;
|
|
598
|
+
window._lastView = view;
|
|
599
|
+
if (view === 'search') viewSearch();
|
|
600
|
+
else if (view === 'sessions') viewSessions();
|
|
601
|
+
else if (view === 'files') viewFiles();
|
|
602
|
+
else if (view === 'timeline') viewTimeline();
|
|
603
|
+
else if (view === 'stats') viewStats();
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
viewSearch();
|
|
608
|
+
|
|
609
|
+
// Pull to refresh
|
|
610
|
+
(function initPTR() {
|
|
611
|
+
let startY = 0;
|
|
612
|
+
let pulling = false;
|
|
613
|
+
const threshold = 80;
|
|
614
|
+
|
|
615
|
+
const indicator = document.createElement('div');
|
|
616
|
+
indicator.className = 'ptr-indicator';
|
|
617
|
+
indicator.id = 'ptr';
|
|
618
|
+
indicator.textContent = '↓ Pull to refresh';
|
|
619
|
+
document.body.appendChild(indicator);
|
|
620
|
+
|
|
621
|
+
document.addEventListener('touchstart', e => {
|
|
622
|
+
if (window.scrollY <= 0) {
|
|
623
|
+
startY = e.touches[0].clientY;
|
|
624
|
+
pulling = true;
|
|
625
|
+
}
|
|
626
|
+
}, { passive: true });
|
|
627
|
+
|
|
628
|
+
document.addEventListener('touchmove', e => {
|
|
629
|
+
if (!pulling) return;
|
|
630
|
+
const diff = e.touches[0].clientY - startY;
|
|
631
|
+
if (diff > 20 && window.scrollY <= 0) {
|
|
632
|
+
indicator.classList.add('visible');
|
|
633
|
+
indicator.textContent = diff > threshold ? '↑ Release to refresh' : '↓ Pull to refresh';
|
|
634
|
+
} else {
|
|
635
|
+
indicator.classList.remove('visible');
|
|
636
|
+
}
|
|
637
|
+
}, { passive: true });
|
|
638
|
+
|
|
639
|
+
document.addEventListener('touchend', async e => {
|
|
640
|
+
if (!pulling) return;
|
|
641
|
+
pulling = false;
|
|
642
|
+
const diff = e.changedTouches[0].clientY - startY;
|
|
643
|
+
if (diff > threshold && indicator.classList.contains('visible')) {
|
|
644
|
+
indicator.textContent = 'Refreshing…';
|
|
645
|
+
indicator.classList.add('refreshing');
|
|
646
|
+
try {
|
|
647
|
+
await api('/reindex');
|
|
648
|
+
const active = $('.nav-item.active');
|
|
649
|
+
if (active) active.click();
|
|
650
|
+
} catch(err) {}
|
|
651
|
+
setTimeout(() => {
|
|
652
|
+
indicator.classList.remove('visible', 'refreshing');
|
|
653
|
+
}, 500);
|
|
654
|
+
} else {
|
|
655
|
+
indicator.classList.remove('visible');
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
})();
|
|
Binary file
|
|
Binary file
|