agentacta 2026.3.6 → 2026.3.12-r2
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 +79 -3
- package/config.js +20 -2
- package/index.js +235 -12
- package/indexer.js +62 -25
- package/package.json +1 -1
- package/public/app.js +124 -19
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ AgentActa gives you one place to inspect the full trail.
|
|
|
32
32
|
|
|
33
33
|
- 🔍 Full-text search across messages, tool calls, and results
|
|
34
34
|
- 📋 Session browser with summaries, token breakdowns, and model info
|
|
35
|
+
- 🧭 Project-scoped session filtering with per-event attribution
|
|
36
|
+
- 🤖 Clear Codex run visibility for direct and Symphony-origin sessions
|
|
35
37
|
- 📅 Timeline view with live updates for today
|
|
36
38
|
- 📁 File activity across all indexed sessions
|
|
37
39
|
- 🌗 Light and dark themes
|
|
@@ -80,7 +82,9 @@ Suggestions come from your own dataset: top tools, common topics, frequently tou
|
|
|
80
82
|
|
|
81
83
|
Browse indexed sessions with auto-generated summaries, token splits (input/output), and model details. Click into any session to see the full event history.
|
|
82
84
|
|
|
83
|
-
Session
|
|
85
|
+
Session detail view supports project-scoped filtering, so mixed-project sessions can be narrowed down without losing the full underlying transcript. The Initial Prompt jump still resolves from full session context even when a project filter is active.
|
|
86
|
+
|
|
87
|
+
Session types get tagged so noisy categories are easier to spot (cron, sub-agent, heartbeat). Codex-backed work is also distinguished more clearly, including direct Codex runs and Symphony-origin Codex sessions.
|
|
84
88
|
|
|
85
89
|
### Timeline
|
|
86
90
|
|
|
@@ -118,18 +122,20 @@ On first run, AgentActa creates:
|
|
|
118
122
|
- `~/.config/agentacta/config.json`
|
|
119
123
|
- or `agentacta.config.json` in current directory (if present)
|
|
120
124
|
|
|
121
|
-
Default config:
|
|
125
|
+
Default config (auto-generated on first run — session directories are detected automatically):
|
|
122
126
|
|
|
123
127
|
```json
|
|
124
128
|
{
|
|
125
129
|
"port": 4003,
|
|
126
130
|
"storage": "reference",
|
|
127
|
-
"sessionsPath":
|
|
131
|
+
"sessionsPath": ["~/.claude/projects", "~/.openclaw/sessions"],
|
|
128
132
|
"dbPath": "./agentacta.db",
|
|
129
133
|
"projectAliases": {}
|
|
130
134
|
}
|
|
131
135
|
```
|
|
132
136
|
|
|
137
|
+
`sessionsPath` accepts a string, a colon-delimited string, or a JSON array.
|
|
138
|
+
|
|
133
139
|
### Storage modes
|
|
134
140
|
|
|
135
141
|
- `reference` (default): index parsed events in SQLite, keep source JSONL on disk. Lightweight.
|
|
@@ -163,6 +169,76 @@ Default config:
|
|
|
163
169
|
| `GET /api/health` | Server status, version, uptime, session count |
|
|
164
170
|
| `GET /api/export/search?q=<query>&format=md` | Export search results |
|
|
165
171
|
|
|
172
|
+
### Context API
|
|
173
|
+
|
|
174
|
+
The Context API gives agents historical context before they start working. Instead of exploring a codebase from scratch, an agent can query what's happened before.
|
|
175
|
+
|
|
176
|
+
| Endpoint | Description |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `GET /api/context/file?path=<filepath>` | History for a specific file |
|
|
179
|
+
| `GET /api/context/repo?path=<repo-path>` | Aggregates for a repo/project |
|
|
180
|
+
| `GET /api/context/agent?name=<agent-name>` | Stats for a specific agent |
|
|
181
|
+
|
|
182
|
+
**File context** — how many sessions touched this file, when it was last modified, recent change summaries, operation breakdown (reads vs edits), related files, and recent errors:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
curl http://localhost:4003/api/context/file?path=/home/user/project/server.js
|
|
186
|
+
```
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"file": "/home/user/project/server.js",
|
|
190
|
+
"sessionCount": 34,
|
|
191
|
+
"lastModified": "3h ago",
|
|
192
|
+
"recentChanges": ["Added OAuth state validation", "Fixed password masking"],
|
|
193
|
+
"operations": { "edit": 105, "read": 56 },
|
|
194
|
+
"relatedFiles": [{ "path": "public/app.js", "count": 28 }],
|
|
195
|
+
"recentErrors": []
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Agent context** — total sessions, cost, average duration, most-used tools, recent work:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
curl http://localhost:4003/api/context/agent?name=claude-code
|
|
203
|
+
```
|
|
204
|
+
```json
|
|
205
|
+
{
|
|
206
|
+
"agent": "claude-code",
|
|
207
|
+
"sessionCount": 60,
|
|
208
|
+
"totalCost": 18.83,
|
|
209
|
+
"avgDuration": 288,
|
|
210
|
+
"topTools": [{ "tool": "edit", "count": 190 }, { "tool": "exec", "count": 560 }],
|
|
211
|
+
"recentSessions": [{ "id": "...", "summary": "Added context API...", "timestamp": "..." }],
|
|
212
|
+
"successRate": 100
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Repo context** — aggregate cost, tokens, distinct agents, most-touched files, common tools:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
curl http://localhost:4003/api/context/repo?path=agentacta
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### Using the Context API with agents
|
|
223
|
+
|
|
224
|
+
Inject context into agent prompts so new sessions start informed:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
# Fetch context before starting Claude Code
|
|
228
|
+
CONTEXT=$(curl -s http://localhost:4003/api/context/file?path=$(pwd)/server.js)
|
|
229
|
+
claude --print "Context from previous sessions: $CONTEXT
|
|
230
|
+
|
|
231
|
+
Your task: refactor the auth module"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Or add it to a CLAUDE.md / AGENTS.md:
|
|
235
|
+
|
|
236
|
+
```markdown
|
|
237
|
+
## Project Context API
|
|
238
|
+
Before modifying key files, query AgentActa for history:
|
|
239
|
+
curl http://localhost:4003/api/context/file?path={filepath}
|
|
240
|
+
```
|
|
241
|
+
|
|
166
242
|
Agent integration example:
|
|
167
243
|
|
|
168
244
|
```javascript
|
package/config.js
CHANGED
|
@@ -14,6 +14,12 @@ function resolveConfigFile() {
|
|
|
14
14
|
|
|
15
15
|
const CONFIG_FILE = resolveConfigFile();
|
|
16
16
|
|
|
17
|
+
const KNOWN_SESSION_DIRS = [
|
|
18
|
+
path.join(os.homedir(), '.claude', 'projects'), // Claude Code
|
|
19
|
+
path.join(os.homedir(), '.codex', 'sessions'), // Codex CLI
|
|
20
|
+
path.join(os.homedir(), '.openclaw', 'sessions'), // OpenClaw
|
|
21
|
+
];
|
|
22
|
+
|
|
17
23
|
const DEFAULTS = {
|
|
18
24
|
port: 4003,
|
|
19
25
|
storage: 'reference',
|
|
@@ -22,6 +28,11 @@ const DEFAULTS = {
|
|
|
22
28
|
projectAliases: {}
|
|
23
29
|
};
|
|
24
30
|
|
|
31
|
+
function detectSessionDirs() {
|
|
32
|
+
const found = KNOWN_SESSION_DIRS.filter(d => fs.existsSync(d));
|
|
33
|
+
return found.length > 0 ? found : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
function loadConfig() {
|
|
26
37
|
let fileConfig = {};
|
|
27
38
|
|
|
@@ -32,11 +43,18 @@ function loadConfig() {
|
|
|
32
43
|
console.error(`Warning: Could not parse ${CONFIG_FILE}:`, err.message);
|
|
33
44
|
}
|
|
34
45
|
} else {
|
|
35
|
-
// First-run: create default config
|
|
46
|
+
// First-run: create default config with auto-detected session dirs
|
|
47
|
+
const detected = detectSessionDirs();
|
|
48
|
+
const firstRunDefaults = { ...DEFAULTS, sessionsPath: detected };
|
|
36
49
|
const dir = path.dirname(CONFIG_FILE);
|
|
37
50
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
38
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(
|
|
51
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(firstRunDefaults, null, 2) + '\n');
|
|
52
|
+
// Apply to in-memory config so this run also benefits
|
|
53
|
+
fileConfig = firstRunDefaults;
|
|
39
54
|
console.log(`Created default config: ${CONFIG_FILE}`);
|
|
55
|
+
if (detected) {
|
|
56
|
+
console.log(`Auto-detected session directories:\n${detected.map(d => ` - ${d}`).join('\n')}`);
|
|
57
|
+
}
|
|
40
58
|
}
|
|
41
59
|
|
|
42
60
|
const config = { ...DEFAULTS, ...fileConfig };
|
package/index.js
CHANGED
|
@@ -25,7 +25,9 @@ if (process.argv.includes('--demo')) {
|
|
|
25
25
|
|
|
26
26
|
const { loadConfig } = require('./config');
|
|
27
27
|
const { open, init, createStmts } = require('./db');
|
|
28
|
-
const { discoverSessionDirs, indexFile } = require('./indexer');
|
|
28
|
+
const { discoverSessionDirs, listJsonlFiles, indexFile } = require('./indexer');
|
|
29
|
+
const { attributeSessionEvents, attributeEventDelta } = require('./project-attribution');
|
|
30
|
+
const { loadDeltaAttributionContext } = require('./delta-attribution-context');
|
|
29
31
|
|
|
30
32
|
const config = loadConfig();
|
|
31
33
|
const PORT = config.port;
|
|
@@ -105,6 +107,15 @@ function getDbSize() {
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
function relativeTime(ts) {
|
|
111
|
+
if (!ts) return null;
|
|
112
|
+
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
|
113
|
+
if (diff < 60) return 'just now';
|
|
114
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
115
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
116
|
+
return `${Math.floor(diff / 86400)}d ago`;
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
function normalizeAgentLabel(agent) {
|
|
109
120
|
if (!agent) return agent;
|
|
110
121
|
if (agent === 'main') return 'openclaw-main';
|
|
@@ -145,13 +156,13 @@ const sessionDirs = discoverSessionDirs(config);
|
|
|
145
156
|
|
|
146
157
|
// Initial indexing pass
|
|
147
158
|
for (const dir of sessionDirs) {
|
|
148
|
-
const files =
|
|
149
|
-
for (const
|
|
159
|
+
const files = listJsonlFiles(dir.path, !!dir.recursive);
|
|
160
|
+
for (const filePath of files) {
|
|
150
161
|
try {
|
|
151
|
-
const result = indexFile(db,
|
|
152
|
-
if (!result.skipped) console.log(`Indexed: ${
|
|
162
|
+
const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
|
|
163
|
+
if (!result.skipped) console.log(`Indexed: ${path.basename(filePath)} (${dir.agent})`);
|
|
153
164
|
} catch (err) {
|
|
154
|
-
console.error(`Error indexing ${
|
|
165
|
+
console.error(`Error indexing ${path.basename(filePath)}:`, err.message);
|
|
155
166
|
}
|
|
156
167
|
}
|
|
157
168
|
}
|
|
@@ -161,10 +172,37 @@ console.log(`Watching ${sessionDirs.length} session directories`);
|
|
|
161
172
|
// Debounce map: filePath -> timeout handle
|
|
162
173
|
const _reindexTimers = new Map();
|
|
163
174
|
const REINDEX_DEBOUNCE_MS = 2000;
|
|
175
|
+
const RECURSIVE_RESCAN_MS = 15000;
|
|
176
|
+
|
|
177
|
+
function reindexRecursiveDir(dir) {
|
|
178
|
+
try {
|
|
179
|
+
const files = listJsonlFiles(dir.path, true);
|
|
180
|
+
let changed = 0;
|
|
181
|
+
for (const filePath of files) {
|
|
182
|
+
const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
|
|
183
|
+
if (!result.skipped) {
|
|
184
|
+
changed++;
|
|
185
|
+
if (result.sessionId) sseEmitter.emit('session-update', result.sessionId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (changed > 0) console.log(`Live re-indexed ${changed} files (${dir.agent})`);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(`Error rescanning ${dir.path}:`, err.message);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
164
193
|
|
|
165
194
|
for (const dir of sessionDirs) {
|
|
166
195
|
try {
|
|
167
196
|
fs.watch(dir.path, { persistent: false }, (eventType, filename) => {
|
|
197
|
+
if (dir.recursive) {
|
|
198
|
+
if (_reindexTimers.has(dir.path)) clearTimeout(_reindexTimers.get(dir.path));
|
|
199
|
+
_reindexTimers.set(dir.path, setTimeout(() => {
|
|
200
|
+
_reindexTimers.delete(dir.path);
|
|
201
|
+
reindexRecursiveDir(dir);
|
|
202
|
+
}, REINDEX_DEBOUNCE_MS));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
168
206
|
if (!filename || !filename.endsWith('.jsonl')) return;
|
|
169
207
|
const filePath = path.join(dir.path, filename);
|
|
170
208
|
if (!fs.existsSync(filePath)) return;
|
|
@@ -174,7 +212,7 @@ for (const dir of sessionDirs) {
|
|
|
174
212
|
_reindexTimers.set(filePath, setTimeout(() => {
|
|
175
213
|
_reindexTimers.delete(filePath);
|
|
176
214
|
try {
|
|
177
|
-
const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE);
|
|
215
|
+
const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
|
|
178
216
|
if (!result.skipped) {
|
|
179
217
|
console.log(`Live re-indexed: ${filename} (${dir.agent})`);
|
|
180
218
|
if (result.sessionId) sseEmitter.emit('session-update', result.sessionId);
|
|
@@ -185,6 +223,10 @@ for (const dir of sessionDirs) {
|
|
|
185
223
|
}, REINDEX_DEBOUNCE_MS));
|
|
186
224
|
});
|
|
187
225
|
console.log(` Watching: ${dir.path}`);
|
|
226
|
+
if (dir.recursive) {
|
|
227
|
+
const timer = setInterval(() => reindexRecursiveDir(dir), RECURSIVE_RESCAN_MS);
|
|
228
|
+
timer.unref?.();
|
|
229
|
+
}
|
|
188
230
|
} catch (err) {
|
|
189
231
|
console.error(` Failed to watch ${dir.path}:`, err.message);
|
|
190
232
|
}
|
|
@@ -284,7 +326,7 @@ const server = http.createServer((req, res) => {
|
|
|
284
326
|
|
|
285
327
|
else if (pathname.match(/^\/api\/sessions\/[^/]+\/events$/)) {
|
|
286
328
|
const id = pathname.split('/')[3];
|
|
287
|
-
const session = db.prepare('SELECT
|
|
329
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
|
|
288
330
|
if (!session) return json(res, { error: 'Not found' }, 404);
|
|
289
331
|
|
|
290
332
|
const after = query.after || '1970-01-01T00:00:00.000Z';
|
|
@@ -297,12 +339,14 @@ const server = http.createServer((req, res) => {
|
|
|
297
339
|
ORDER BY timestamp ASC, id ASC
|
|
298
340
|
LIMIT ?`
|
|
299
341
|
).all(id, after, after, afterId, limit);
|
|
300
|
-
|
|
342
|
+
const contextRows = loadDeltaAttributionContext(db, id, rows);
|
|
343
|
+
const events = attributeEventDelta(session, rows, contextRows);
|
|
344
|
+
json(res, { events, after, afterId, count: events.length });
|
|
301
345
|
}
|
|
302
346
|
|
|
303
347
|
else if (pathname.match(/^\/api\/sessions\/[^/]+\/stream$/)) {
|
|
304
348
|
const id = pathname.split('/')[3];
|
|
305
|
-
const session = db.prepare('SELECT
|
|
349
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
|
|
306
350
|
if (!session) return json(res, { error: 'Not found' }, 404);
|
|
307
351
|
|
|
308
352
|
res.writeHead(200, {
|
|
@@ -322,8 +366,10 @@ const server = http.createServer((req, res) => {
|
|
|
322
366
|
'SELECT * FROM events WHERE session_id = ? AND timestamp > ? ORDER BY timestamp ASC'
|
|
323
367
|
).all(id, lastTs);
|
|
324
368
|
if (rows.length) {
|
|
369
|
+
const contextRows = loadDeltaAttributionContext(db, id, rows);
|
|
370
|
+
const attributedRows = attributeEventDelta(session, rows, contextRows);
|
|
325
371
|
lastTs = rows[rows.length - 1].timestamp;
|
|
326
|
-
res.write(`id: ${lastTs}\ndata: ${JSON.stringify(
|
|
372
|
+
res.write(`id: ${lastTs}\ndata: ${JSON.stringify(attributedRows)}\n\n`);
|
|
327
373
|
}
|
|
328
374
|
} catch (err) {
|
|
329
375
|
console.error('SSE query error:', err.message);
|
|
@@ -347,8 +393,9 @@ const server = http.createServer((req, res) => {
|
|
|
347
393
|
if (!session) { json(res, { error: 'Not found' }, 404); }
|
|
348
394
|
else {
|
|
349
395
|
const events = db.prepare('SELECT * FROM events WHERE session_id = ? ORDER BY timestamp DESC').all(id);
|
|
396
|
+
const attributed = attributeSessionEvents(session, events);
|
|
350
397
|
const hasArchive = ARCHIVE_MODE && db.prepare('SELECT COUNT(*) as c FROM archive WHERE session_id = ?').get(id).c > 0;
|
|
351
|
-
json(res, { session, events, hasArchive });
|
|
398
|
+
json(res, { session, events: attributed.events, projectFilters: attributed.projectFilters, hasArchive });
|
|
352
399
|
}
|
|
353
400
|
}
|
|
354
401
|
else if (pathname.match(/^\/api\/archive\/session\/[^/]+$/)) {
|
|
@@ -514,6 +561,182 @@ const server = http.createServer((req, res) => {
|
|
|
514
561
|
const sizeAfter = getDbSize();
|
|
515
562
|
json(res, { ok: true, sizeBefore, sizeAfter });
|
|
516
563
|
}
|
|
564
|
+
// --- Context API ---
|
|
565
|
+
else if (pathname === '/api/context/file') {
|
|
566
|
+
const fp = query.path || '';
|
|
567
|
+
if (!fp) return json(res, { error: 'path parameter is required' }, 400);
|
|
568
|
+
|
|
569
|
+
const sessionCount = db.prepare(
|
|
570
|
+
'SELECT COUNT(DISTINCT session_id) as c FROM file_activity WHERE file_path = ?'
|
|
571
|
+
).get(fp).c;
|
|
572
|
+
|
|
573
|
+
if (sessionCount === 0) {
|
|
574
|
+
return json(res, { file: fp, sessionCount: 0, lastModified: null, recentChanges: [], operations: {}, relatedFiles: [], recentErrors: [] });
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const lastTouched = db.prepare(
|
|
578
|
+
'SELECT MAX(timestamp) as t FROM file_activity WHERE file_path = ?'
|
|
579
|
+
).get(fp).t;
|
|
580
|
+
|
|
581
|
+
const recentChanges = db.prepare(
|
|
582
|
+
`SELECT DISTINCT s.summary FROM file_activity fa
|
|
583
|
+
JOIN sessions s ON s.id = fa.session_id
|
|
584
|
+
WHERE fa.file_path = ? AND s.summary IS NOT NULL
|
|
585
|
+
ORDER BY s.start_time DESC LIMIT 5`
|
|
586
|
+
).all(fp).map(r => r.summary);
|
|
587
|
+
|
|
588
|
+
const opsRows = db.prepare(
|
|
589
|
+
'SELECT operation, COUNT(*) as c FROM file_activity WHERE file_path = ? GROUP BY operation'
|
|
590
|
+
).all(fp);
|
|
591
|
+
const operations = {};
|
|
592
|
+
for (const r of opsRows) operations[r.operation] = r.c;
|
|
593
|
+
|
|
594
|
+
const relatedFiles = db.prepare(
|
|
595
|
+
`SELECT fa2.file_path, COUNT(DISTINCT fa1.session_id) as c
|
|
596
|
+
FROM file_activity fa1
|
|
597
|
+
JOIN file_activity fa2 ON fa1.session_id = fa2.session_id
|
|
598
|
+
WHERE fa1.file_path = ? AND fa2.file_path != ?
|
|
599
|
+
GROUP BY fa2.file_path
|
|
600
|
+
ORDER BY c DESC LIMIT 5`
|
|
601
|
+
).all(fp, fp).map(r => ({ path: r.file_path, count: r.c }));
|
|
602
|
+
|
|
603
|
+
const sessionIds = db.prepare(
|
|
604
|
+
'SELECT DISTINCT session_id FROM file_activity WHERE file_path = ?'
|
|
605
|
+
).all(fp).map(r => r.session_id);
|
|
606
|
+
|
|
607
|
+
let recentErrors = [];
|
|
608
|
+
if (sessionIds.length) {
|
|
609
|
+
const placeholders = sessionIds.map(() => '?').join(',');
|
|
610
|
+
recentErrors = db.prepare(
|
|
611
|
+
`SELECT tool_result FROM events
|
|
612
|
+
WHERE session_id IN (${placeholders})
|
|
613
|
+
AND tool_result IS NOT NULL
|
|
614
|
+
AND (tool_result LIKE '%error%' OR tool_result LIKE '%Error%' OR tool_result LIKE '%ERROR%')
|
|
615
|
+
ORDER BY timestamp DESC LIMIT 3`
|
|
616
|
+
).all(...sessionIds).map(r => r.tool_result.slice(0, 200));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return json(res, {
|
|
620
|
+
file: fp, sessionCount,
|
|
621
|
+
lastModified: relativeTime(lastTouched),
|
|
622
|
+
recentChanges, operations, relatedFiles, recentErrors
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
else if (pathname === '/api/context/repo') {
|
|
626
|
+
const repoPath = query.path || '';
|
|
627
|
+
if (!repoPath) return json(res, { error: 'path parameter is required' }, 400);
|
|
628
|
+
|
|
629
|
+
// Find sessions matching the repo path via file_activity or initial_prompt
|
|
630
|
+
const sessionIds = db.prepare(
|
|
631
|
+
`SELECT DISTINCT session_id FROM file_activity WHERE file_path = ? OR file_path LIKE ?`
|
|
632
|
+
).all(repoPath, repoPath + '/%').map(r => r.session_id);
|
|
633
|
+
|
|
634
|
+
const promptSessions = db.prepare(
|
|
635
|
+
`SELECT id FROM sessions WHERE initial_prompt LIKE ?`
|
|
636
|
+
).all('%' + repoPath + '%').map(r => r.id);
|
|
637
|
+
|
|
638
|
+
const allIds = [...new Set([...sessionIds, ...promptSessions])];
|
|
639
|
+
|
|
640
|
+
if (allIds.length === 0) {
|
|
641
|
+
return json(res, { repo: repoPath, sessionCount: 0, totalCost: 0, totalTokens: 0, agents: [], topFiles: [], recentSessions: [], commonTools: [], commonErrors: [] });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const ph = allIds.map(() => '?').join(',');
|
|
645
|
+
|
|
646
|
+
const agg = db.prepare(
|
|
647
|
+
`SELECT COUNT(*) as c, SUM(total_cost) as cost, SUM(total_tokens) as tokens
|
|
648
|
+
FROM sessions WHERE id IN (${ph})`
|
|
649
|
+
).get(...allIds);
|
|
650
|
+
|
|
651
|
+
const agents = [...new Set(
|
|
652
|
+
db.prepare(`SELECT DISTINCT agent FROM sessions WHERE id IN (${ph}) AND agent IS NOT NULL`).all(...allIds)
|
|
653
|
+
.map(r => normalizeAgentLabel(r.agent)).filter(Boolean)
|
|
654
|
+
)];
|
|
655
|
+
|
|
656
|
+
const topFiles = db.prepare(
|
|
657
|
+
`SELECT file_path, COUNT(*) as c FROM file_activity
|
|
658
|
+
WHERE session_id IN (${ph})
|
|
659
|
+
GROUP BY file_path ORDER BY c DESC LIMIT 10`
|
|
660
|
+
).all(...allIds).map(r => ({ path: r.file_path, count: r.c }));
|
|
661
|
+
|
|
662
|
+
const recentSessions = db.prepare(
|
|
663
|
+
`SELECT id, summary, agent, start_time, end_time FROM sessions
|
|
664
|
+
WHERE id IN (${ph})
|
|
665
|
+
ORDER BY start_time DESC LIMIT 5`
|
|
666
|
+
).all(...allIds).map(r => ({
|
|
667
|
+
id: r.id, summary: r.summary, agent: normalizeAgentLabel(r.agent),
|
|
668
|
+
timestamp: r.start_time, status: r.end_time ? 'completed' : 'in-progress'
|
|
669
|
+
}));
|
|
670
|
+
|
|
671
|
+
const commonTools = db.prepare(
|
|
672
|
+
`SELECT tool_name, COUNT(*) as c FROM events
|
|
673
|
+
WHERE session_id IN (${ph}) AND tool_name IS NOT NULL
|
|
674
|
+
GROUP BY tool_name ORDER BY c DESC LIMIT 10`
|
|
675
|
+
).all(...allIds).map(r => ({ tool: r.tool_name, count: r.c }));
|
|
676
|
+
|
|
677
|
+
const commonErrors = db.prepare(
|
|
678
|
+
`SELECT DISTINCT SUBSTR(tool_result, 1, 200) as err FROM events
|
|
679
|
+
WHERE session_id IN (${ph})
|
|
680
|
+
AND tool_result IS NOT NULL
|
|
681
|
+
AND (tool_result LIKE '%error%' OR tool_result LIKE '%Error%' OR tool_result LIKE '%ERROR%')
|
|
682
|
+
ORDER BY timestamp DESC LIMIT 5`
|
|
683
|
+
).all(...allIds).map(r => r.err);
|
|
684
|
+
|
|
685
|
+
return json(res, {
|
|
686
|
+
repo: repoPath, sessionCount: allIds.length,
|
|
687
|
+
totalCost: agg.cost || 0, totalTokens: agg.tokens || 0,
|
|
688
|
+
agents, topFiles, recentSessions, commonTools, commonErrors
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
else if (pathname === '/api/context/agent') {
|
|
692
|
+
const name = query.name || '';
|
|
693
|
+
if (!name) return json(res, { error: 'name parameter is required' }, 400);
|
|
694
|
+
|
|
695
|
+
// Try exact match first, then check all sessions with normalized label match
|
|
696
|
+
let sessions = db.prepare(
|
|
697
|
+
'SELECT * FROM sessions WHERE agent = ?'
|
|
698
|
+
).all(name);
|
|
699
|
+
if (sessions.length === 0) {
|
|
700
|
+
sessions = db.prepare('SELECT * FROM sessions WHERE agent IS NOT NULL').all()
|
|
701
|
+
.filter(s => normalizeAgentLabel(s.agent) === name);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (sessions.length === 0) {
|
|
705
|
+
return json(res, { agent: name, sessionCount: 0, totalCost: 0, avgDuration: 0, topTools: [], recentSessions: [], successRate: 0 });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const totalCost = sessions.reduce((s, r) => s + (r.total_cost || 0), 0);
|
|
709
|
+
let totalDuration = 0;
|
|
710
|
+
let durationCount = 0;
|
|
711
|
+
for (const s of sessions) {
|
|
712
|
+
if (s.start_time && s.end_time) {
|
|
713
|
+
totalDuration += (new Date(s.end_time) - new Date(s.start_time)) / 1000;
|
|
714
|
+
durationCount++;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const avgDuration = durationCount > 0 ? Math.round(totalDuration / durationCount) : 0;
|
|
718
|
+
|
|
719
|
+
const withSummary = sessions.filter(s => s.summary).length;
|
|
720
|
+
const successRate = Math.round((withSummary / sessions.length) * 100);
|
|
721
|
+
|
|
722
|
+
const ids = sessions.map(s => s.id);
|
|
723
|
+
const ph = ids.map(() => '?').join(',');
|
|
724
|
+
const topTools = db.prepare(
|
|
725
|
+
`SELECT tool_name, COUNT(*) as c FROM events
|
|
726
|
+
WHERE session_id IN (${ph}) AND tool_name IS NOT NULL
|
|
727
|
+
GROUP BY tool_name ORDER BY c DESC LIMIT 10`
|
|
728
|
+
).all(...ids).map(r => ({ tool: r.tool_name, count: r.c }));
|
|
729
|
+
|
|
730
|
+
const recentSessions = sessions
|
|
731
|
+
.sort((a, b) => (b.start_time || '').localeCompare(a.start_time || ''))
|
|
732
|
+
.slice(0, 5)
|
|
733
|
+
.map(s => ({ id: s.id, summary: s.summary, timestamp: s.start_time }));
|
|
734
|
+
|
|
735
|
+
return json(res, {
|
|
736
|
+
agent: name, sessionCount: sessions.length,
|
|
737
|
+
totalCost, avgDuration, topTools, recentSessions, successRate
|
|
738
|
+
});
|
|
739
|
+
}
|
|
517
740
|
else if (pathname === '/api/files') {
|
|
518
741
|
const limit = parseInt(query.limit) || 100;
|
|
519
742
|
const offset = parseInt(query.offset) || 0;
|
package/indexer.js
CHANGED
|
@@ -28,17 +28,56 @@ function listJsonlFiles(baseDir, recursive = false) {
|
|
|
28
28
|
function discoverSessionDirs(config) {
|
|
29
29
|
const dirs = [];
|
|
30
30
|
const home = process.env.HOME;
|
|
31
|
+
const codexSessionsPath = path.join(home, '.codex/sessions');
|
|
32
|
+
|
|
33
|
+
function normalizedPath(p) {
|
|
34
|
+
return path.resolve(p).replace(/[\\\/]+$/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasDir(targetPath) {
|
|
38
|
+
const wanted = normalizedPath(targetPath);
|
|
39
|
+
return dirs.some(d => normalizedPath(d.path) === wanted);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Expand a single path into session dirs, handling Claude Code's per-project structure
|
|
43
|
+
function expandPath(p) {
|
|
44
|
+
if (!fs.existsSync(p)) return;
|
|
45
|
+
const stat = fs.statSync(p);
|
|
46
|
+
if (!stat.isDirectory()) return;
|
|
47
|
+
const normalized = normalizedPath(p);
|
|
48
|
+
const normalizedCodex = normalizedPath(codexSessionsPath);
|
|
49
|
+
// Claude Code: ~/.claude/projects contains per-project subdirs with JSONL files
|
|
50
|
+
if (normalized.endsWith('/.claude/projects')) {
|
|
51
|
+
for (const proj of fs.readdirSync(p)) {
|
|
52
|
+
const projDir = path.join(p, proj);
|
|
53
|
+
if (fs.statSync(projDir).isDirectory()) {
|
|
54
|
+
const hasJsonl = fs.readdirSync(projDir).some(f => f.endsWith('.jsonl'));
|
|
55
|
+
if (hasJsonl) dirs.push({ path: projDir, agent: 'claude-code' });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} else if (normalized === normalizedCodex) {
|
|
59
|
+
// Codex CLI stores nested YYYY/MM/DD directories and must be recursive.
|
|
60
|
+
dirs.push({ path: p, agent: 'codex-cli', recursive: true });
|
|
61
|
+
} else {
|
|
62
|
+
dirs.push({ path: p, agent: path.basename(path.dirname(p)) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
31
65
|
|
|
32
66
|
// Config sessionsPath or env var override
|
|
33
67
|
const sessionsOverride = process.env.AGENTACTA_SESSIONS_PATH || (config && config.sessionsPath);
|
|
34
68
|
if (sessionsOverride) {
|
|
35
|
-
|
|
36
|
-
|
|
69
|
+
const overridePaths = Array.isArray(sessionsOverride)
|
|
70
|
+
? sessionsOverride
|
|
71
|
+
: sessionsOverride.split(':');
|
|
72
|
+
overridePaths.forEach(expandPath);
|
|
73
|
+
// Keep direct Codex visibility even when custom overrides omit it.
|
|
74
|
+
if (fs.existsSync(codexSessionsPath) && fs.statSync(codexSessionsPath).isDirectory() && !hasDir(codexSessionsPath)) {
|
|
75
|
+
dirs.push({ path: codexSessionsPath, agent: 'codex-cli', recursive: true });
|
|
37
76
|
}
|
|
38
77
|
if (dirs.length) return dirs;
|
|
39
78
|
}
|
|
40
79
|
|
|
41
|
-
//
|
|
80
|
+
// Auto-discover: ~/.openclaw/agents/*/sessions/
|
|
42
81
|
const oclawAgents = path.join(home, '.openclaw/agents');
|
|
43
82
|
if (fs.existsSync(oclawAgents)) {
|
|
44
83
|
for (const agent of fs.readdirSync(oclawAgents)) {
|
|
@@ -49,26 +88,11 @@ function discoverSessionDirs(config) {
|
|
|
49
88
|
}
|
|
50
89
|
}
|
|
51
90
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
if (fs.existsSync(claudeProjects)) {
|
|
55
|
-
for (const proj of fs.readdirSync(claudeProjects)) {
|
|
56
|
-
const projDir = path.join(claudeProjects, proj);
|
|
57
|
-
// Claude Code: JSONL files directly in project dir
|
|
58
|
-
if (fs.existsSync(projDir) && fs.statSync(projDir).isDirectory()) {
|
|
59
|
-
const hasJsonl = fs.readdirSync(projDir).some(f => f.endsWith('.jsonl'));
|
|
60
|
-
if (hasJsonl) dirs.push({ path: projDir, agent: 'claude-code' });
|
|
61
|
-
}
|
|
62
|
-
// Also check sessions/ subdirectory (future-proofing)
|
|
63
|
-
const sp = path.join(projDir, 'sessions');
|
|
64
|
-
if (fs.existsSync(sp) && fs.statSync(sp).isDirectory()) {
|
|
65
|
-
dirs.push({ path: sp, agent: 'claude-code' });
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
91
|
+
// Auto-discover: ~/.claude/projects/
|
|
92
|
+
expandPath(path.join(home, '.claude/projects'));
|
|
69
93
|
|
|
70
94
|
// Scan ~/.codex/sessions recursively (Codex CLI stores nested YYYY/MM/DD/*.jsonl)
|
|
71
|
-
const codexSessions =
|
|
95
|
+
const codexSessions = codexSessionsPath;
|
|
72
96
|
if (fs.existsSync(codexSessions) && fs.statSync(codexSessions).isDirectory()) {
|
|
73
97
|
dirs.push({ path: codexSessions, agent: 'codex-cli', recursive: true });
|
|
74
98
|
}
|
|
@@ -221,6 +245,8 @@ function extractProjectFromPath(filePath, config) {
|
|
|
221
245
|
|
|
222
246
|
// Common repo location: ~/Developer/<repo>/...
|
|
223
247
|
if (parts[0] === 'Developer' && parts[1]) return aliasProject(parts[1], config);
|
|
248
|
+
// Symphony worktrees: ~/symphony-workspaces/<issue>/...
|
|
249
|
+
if (parts[0] === 'symphony-workspaces' && parts[1]) return aliasProject(parts[1], config);
|
|
224
250
|
|
|
225
251
|
// OpenClaw workspace and agent stores
|
|
226
252
|
if (parts[0] === '.openclaw' && parts[1] === 'workspace') return aliasProject('workspace', config);
|
|
@@ -269,6 +295,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
|
|
|
269
295
|
let firstMessageTimestamp = null;
|
|
270
296
|
let codexProvider = null;
|
|
271
297
|
let codexSource = null;
|
|
298
|
+
let codexOriginator = null;
|
|
272
299
|
let sawSnapshotRecord = false;
|
|
273
300
|
let sawNonSnapshotRecord = false;
|
|
274
301
|
|
|
@@ -289,11 +316,16 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
|
|
|
289
316
|
if (firstLine.agent) agent = firstLine.agent;
|
|
290
317
|
if (firstLine.sessionType) sessionType = firstLine.sessionType;
|
|
291
318
|
if (sessionId.includes('subagent')) sessionType = 'subagent';
|
|
292
|
-
} else if (firstLine.type === 'user' || firstLine.type === 'assistant' || firstLine.type === 'file-history-snapshot') {
|
|
293
|
-
// Claude Code format — no session header, extract from first message line
|
|
319
|
+
} else if (firstLine.type === 'user' || firstLine.type === 'assistant' || firstLine.type === 'file-history-snapshot' || firstLine.type === 'queue-operation') {
|
|
320
|
+
// Claude Code format — no session header, extract from first message or queue-operation line
|
|
294
321
|
isClaudeCode = true;
|
|
295
322
|
for (const line of lines) {
|
|
296
323
|
let obj; try { obj = JSON.parse(line); } catch { continue; }
|
|
324
|
+
if (obj.sessionId && obj.timestamp) {
|
|
325
|
+
sessionId = obj.sessionId;
|
|
326
|
+
sessionStart = obj.timestamp;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
297
329
|
if ((obj.type === 'user' || obj.type === 'assistant') && obj.sessionId) {
|
|
298
330
|
sessionId = obj.sessionId;
|
|
299
331
|
sessionStart = obj.timestamp;
|
|
@@ -311,7 +343,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
|
|
|
311
343
|
const meta = firstLine.payload || {};
|
|
312
344
|
sessionId = meta.id || path.basename(filePath, '.jsonl');
|
|
313
345
|
sessionStart = meta.timestamp || firstLine.timestamp || new Date().toISOString();
|
|
314
|
-
sessionType = 'codex-
|
|
346
|
+
sessionType = 'codex-direct';
|
|
315
347
|
agent = 'codex-cli';
|
|
316
348
|
if (meta.model) {
|
|
317
349
|
model = meta.model;
|
|
@@ -319,6 +351,8 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
|
|
|
319
351
|
}
|
|
320
352
|
codexProvider = meta.model_provider || null;
|
|
321
353
|
codexSource = meta.source || null;
|
|
354
|
+
codexOriginator = meta.originator || null;
|
|
355
|
+
if (codexOriginator && codexOriginator.includes('symphony')) sessionType = 'codex-symphony';
|
|
322
356
|
} else {
|
|
323
357
|
return { skipped: true };
|
|
324
358
|
}
|
|
@@ -353,6 +387,8 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
|
|
|
353
387
|
}
|
|
354
388
|
if (meta.model_provider) codexProvider = meta.model_provider;
|
|
355
389
|
if (meta.source) codexSource = meta.source;
|
|
390
|
+
if (meta.originator) codexOriginator = meta.originator;
|
|
391
|
+
if (codexOriginator && codexOriginator.includes('symphony')) sessionType = 'codex-symphony';
|
|
356
392
|
if (meta.model_provider && !model) model = meta.model_provider;
|
|
357
393
|
continue;
|
|
358
394
|
}
|
|
@@ -557,6 +593,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
|
|
|
557
593
|
const parts = ['Codex CLI session'];
|
|
558
594
|
if (codexProvider) parts.push(`provider=${codexProvider}`);
|
|
559
595
|
if (codexSource) parts.push(`source=${codexSource}`);
|
|
596
|
+
if (codexOriginator) parts.push(`originator=${codexOriginator}`);
|
|
560
597
|
summary = parts.join(' · ');
|
|
561
598
|
} else {
|
|
562
599
|
summary = 'Heartbeat session';
|
|
@@ -720,6 +757,6 @@ function indexAll(db, config) {
|
|
|
720
757
|
return { sessions: stats.sessions, events: evStats.events, newSessions: totalSessions };
|
|
721
758
|
}
|
|
722
759
|
|
|
723
|
-
module.exports = { discoverSessionDirs, indexFile, indexAll };
|
|
760
|
+
module.exports = { discoverSessionDirs, listJsonlFiles, indexFile, indexAll };
|
|
724
761
|
|
|
725
762
|
if (require.main === module) run();
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -647,7 +647,9 @@ async function viewSession(id) {
|
|
|
647
647
|
if (data._error || data.error) { content.innerHTML = `<div class="empty"><h2>${escHtml(data.error || 'Unable to load')}</h2></div>`; return; }
|
|
648
648
|
|
|
649
649
|
const s = data.session;
|
|
650
|
-
const
|
|
650
|
+
const projectFilters = Array.isArray(data.projectFilters)
|
|
651
|
+
? data.projectFilters.filter(p => p && p.project && Number.isFinite(Number(p.eventCount)))
|
|
652
|
+
: [];
|
|
651
653
|
let html = `
|
|
652
654
|
<div class="back-btn" id="backBtn">\u2190 Back</div>
|
|
653
655
|
<div class="page-title">Session</div>
|
|
@@ -667,7 +669,6 @@ async function viewSession(id) {
|
|
|
667
669
|
<div class="session-header" style="margin-bottom:12px">
|
|
668
670
|
<span class="session-time">${fmtDate(s.start_time)} \u00b7 ${fmtTimeShort(s.start_time)} \u2013 ${fmtTimeShort(s.end_time)}</span>
|
|
669
671
|
<span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
|
670
|
-
${renderProjectTags(s)}
|
|
671
672
|
${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
|
|
672
673
|
${s.session_type && s.session_type !== normalizeAgentLabel(s.agent || '') ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
|
|
673
674
|
${renderModelTags(s)}
|
|
@@ -678,16 +679,62 @@ async function viewSession(id) {
|
|
|
678
679
|
<span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span> ${s.tool_count} tools</span>
|
|
679
680
|
${s.output_tokens ? `<span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/></svg></span> ${fmtTokens(s.output_tokens)} output</span><span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></span> ${fmtTokens(s.input_tokens + s.cache_read_tokens)} input</span>` : s.total_tokens ? `<span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg></span> ${fmtTokens(s.total_tokens)} tokens</span><span></span>` : '<span></span><span></span>'}
|
|
680
681
|
</div>
|
|
682
|
+
${projectFilters.length ? `
|
|
683
|
+
<div class="section-label" style="margin-top:14px">Project Filter</div>
|
|
684
|
+
<div class="filters" id="sessionProjectFilters" style="margin-bottom:4px">
|
|
685
|
+
<span class="filter-chip active" data-project-filter="all">All</span>
|
|
686
|
+
${projectFilters.map(p => `<span class="filter-chip" data-project-filter="${escHtml(p.project)}">${escHtml(p.project)} · ${p.eventCount}</span>`).join('')}
|
|
687
|
+
</div>
|
|
688
|
+
` : ''}
|
|
681
689
|
</div>
|
|
682
|
-
<div class="section-label">Events</div>
|
|
690
|
+
<div class="section-label" id="sessionEventsLabel">Events</div>
|
|
691
|
+
<div id="eventsContainer"></div>
|
|
692
|
+
<div class="empty" id="sessionEventsEmpty" style="display:none"><h2>No events</h2><p>This session has no events to display.</p></div>
|
|
683
693
|
`;
|
|
684
694
|
|
|
685
695
|
const PAGE_SIZE = 50;
|
|
686
|
-
const allEvents = data.events;
|
|
696
|
+
const allEvents = Array.isArray(data.events) ? [...data.events] : [];
|
|
697
|
+
let activeProjectFilter = 'all';
|
|
687
698
|
let rendered = 0;
|
|
699
|
+
let onScroll = null;
|
|
700
|
+
let pendingNewCount = 0;
|
|
701
|
+
|
|
702
|
+
const getFilteredEvents = () => {
|
|
703
|
+
if (activeProjectFilter === 'all') return allEvents;
|
|
704
|
+
return allEvents.filter(ev => ev.project === activeProjectFilter);
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const updateEventsLabel = () => {
|
|
708
|
+
const el = $('#sessionEventsLabel');
|
|
709
|
+
if (!el) return;
|
|
710
|
+
const count = getFilteredEvents().length;
|
|
711
|
+
if (activeProjectFilter === 'all') {
|
|
712
|
+
el.textContent = `Events (${count})`;
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
el.textContent = `Events (${count}) · ${activeProjectFilter}`;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const updateEventsEmptyState = () => {
|
|
719
|
+
const empty = $('#sessionEventsEmpty');
|
|
720
|
+
if (!empty) return;
|
|
721
|
+
empty.style.display = getFilteredEvents().length ? 'none' : 'block';
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const setProjectFilter = (nextFilter) => {
|
|
725
|
+
if (nextFilter === activeProjectFilter) return;
|
|
726
|
+
activeProjectFilter = nextFilter;
|
|
727
|
+
const chips = $$('#sessionProjectFilters .filter-chip');
|
|
728
|
+
chips.forEach(node => node.classList.toggle('active', node.dataset.projectFilter === activeProjectFilter));
|
|
729
|
+
pendingNewCount = 0;
|
|
730
|
+
const indicator = document.getElementById('newEventsIndicator');
|
|
731
|
+
if (indicator) indicator.remove();
|
|
732
|
+
resetRenderedEvents();
|
|
733
|
+
};
|
|
688
734
|
|
|
689
735
|
function renderBatch() {
|
|
690
|
-
const
|
|
736
|
+
const filtered = getFilteredEvents();
|
|
737
|
+
const batch = filtered.slice(rendered, rendered + PAGE_SIZE);
|
|
691
738
|
if (!batch.length) return;
|
|
692
739
|
const frag = document.createElement('div');
|
|
693
740
|
frag.innerHTML = batch.map(renderEvent).join('');
|
|
@@ -696,33 +743,61 @@ async function viewSession(id) {
|
|
|
696
743
|
while (frag.firstChild) container.appendChild(frag.firstChild);
|
|
697
744
|
}
|
|
698
745
|
rendered += batch.length;
|
|
699
|
-
|
|
700
746
|
}
|
|
701
747
|
|
|
702
|
-
html += '<div id="eventsContainer">' + allEvents.slice(0, PAGE_SIZE).map(renderEvent).join('') + '</div>';
|
|
703
|
-
rendered = Math.min(PAGE_SIZE, allEvents.length);
|
|
704
748
|
content.innerHTML = html;
|
|
705
749
|
transitionView();
|
|
706
750
|
|
|
707
|
-
|
|
708
|
-
|
|
751
|
+
const syncScrollHandler = () => {
|
|
752
|
+
const total = getFilteredEvents().length;
|
|
753
|
+
if (total <= rendered) {
|
|
754
|
+
if (onScroll) {
|
|
755
|
+
window.removeEventListener('scroll', onScroll);
|
|
756
|
+
onScroll = null;
|
|
757
|
+
}
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (onScroll) return;
|
|
762
|
+
|
|
709
763
|
let loading = false;
|
|
710
764
|
onScroll = () => {
|
|
711
|
-
if (loading || rendered >=
|
|
765
|
+
if (loading || rendered >= getFilteredEvents().length) return;
|
|
712
766
|
const scrollBottom = window.innerHeight + window.scrollY;
|
|
713
767
|
const threshold = document.body.offsetHeight - 300;
|
|
714
768
|
if (scrollBottom >= threshold) {
|
|
715
769
|
loading = true;
|
|
716
770
|
renderBatch();
|
|
717
771
|
loading = false;
|
|
718
|
-
if (rendered >=
|
|
772
|
+
if (rendered >= getFilteredEvents().length) {
|
|
719
773
|
window.removeEventListener('scroll', onScroll);
|
|
720
774
|
onScroll = null;
|
|
721
775
|
}
|
|
722
776
|
}
|
|
723
777
|
};
|
|
724
778
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
725
|
-
}
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const resetRenderedEvents = () => {
|
|
782
|
+
const container = document.getElementById('eventsContainer');
|
|
783
|
+
if (!container) return;
|
|
784
|
+
container.innerHTML = '';
|
|
785
|
+
rendered = 0;
|
|
786
|
+
renderBatch();
|
|
787
|
+
syncScrollHandler();
|
|
788
|
+
updateEventsLabel();
|
|
789
|
+
updateEventsEmptyState();
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
resetRenderedEvents();
|
|
793
|
+
|
|
794
|
+
const filterChips = $$('#sessionProjectFilters .filter-chip');
|
|
795
|
+
filterChips.forEach(chip => {
|
|
796
|
+
chip.addEventListener('click', () => {
|
|
797
|
+
const nextFilter = chip.dataset.projectFilter || 'all';
|
|
798
|
+
setProjectFilter(nextFilter);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
726
801
|
|
|
727
802
|
$('#backBtn').addEventListener('click', () => {
|
|
728
803
|
clearJumpUi();
|
|
@@ -778,22 +853,35 @@ async function viewSession(id) {
|
|
|
778
853
|
if (jumpBtn) {
|
|
779
854
|
jumpBtn.addEventListener('click', async () => {
|
|
780
855
|
const fromY = window.scrollY || window.pageYOffset || 0;
|
|
856
|
+
const firstMessageId = s.first_message_id;
|
|
781
857
|
|
|
782
858
|
jumpBtn.classList.add('jumping');
|
|
783
859
|
jumpBtn.disabled = true;
|
|
784
860
|
|
|
861
|
+
if (!firstMessageId) {
|
|
862
|
+
jumpBtn.classList.remove('jumping');
|
|
863
|
+
jumpBtn.disabled = false;
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Resolve from full session context, not just project-filtered events.
|
|
868
|
+
const inCurrentFilter = getFilteredEvents().some(ev => ev.id === firstMessageId);
|
|
869
|
+
if (!inCurrentFilter && activeProjectFilter !== 'all') {
|
|
870
|
+
setProjectFilter('all');
|
|
871
|
+
}
|
|
872
|
+
|
|
785
873
|
// Let button state paint before heavy DOM work.
|
|
786
874
|
await new Promise(requestAnimationFrame);
|
|
787
875
|
|
|
788
876
|
// Load remaining events in chunks so UI stays responsive.
|
|
789
877
|
let loops = 0;
|
|
790
|
-
while (rendered <
|
|
878
|
+
while (rendered < getFilteredEvents().length) {
|
|
791
879
|
renderBatch();
|
|
792
880
|
loops += 1;
|
|
793
881
|
if (loops % 2 === 0) await new Promise(requestAnimationFrame);
|
|
794
882
|
}
|
|
795
883
|
|
|
796
|
-
const firstMessage = document.querySelector(`[data-event-id="${
|
|
884
|
+
const firstMessage = document.querySelector(`[data-event-id="${firstMessageId}"]`);
|
|
797
885
|
if (!firstMessage) {
|
|
798
886
|
jumpBtn.classList.remove('jumping');
|
|
799
887
|
jumpBtn.disabled = false;
|
|
@@ -838,9 +926,8 @@ async function viewSession(id) {
|
|
|
838
926
|
});
|
|
839
927
|
}
|
|
840
928
|
|
|
841
|
-
|
|
929
|
+
// --- Lightweight realtime updates (polling fallback first) ---
|
|
842
930
|
const knownIds = new Set(allEvents.map(e => e.id));
|
|
843
|
-
let pendingNewCount = 0;
|
|
844
931
|
|
|
845
932
|
const applyIncomingEvents = (incoming) => {
|
|
846
933
|
const container = document.getElementById('eventsContainer');
|
|
@@ -850,8 +937,22 @@ async function viewSession(id) {
|
|
|
850
937
|
if (!fresh.length) return;
|
|
851
938
|
fresh.forEach(e => knownIds.add(e.id));
|
|
852
939
|
|
|
940
|
+
// Delta endpoint returns oldest -> newest, and view is newest-first.
|
|
941
|
+
for (const ev of fresh) allEvents.unshift(ev);
|
|
942
|
+
|
|
943
|
+
const visibleFresh = activeProjectFilter === 'all'
|
|
944
|
+
? fresh
|
|
945
|
+
: fresh.filter(ev => ev.project === activeProjectFilter);
|
|
946
|
+
|
|
947
|
+
if (!visibleFresh.length) {
|
|
948
|
+
updateEventsLabel();
|
|
949
|
+
updateEventsEmptyState();
|
|
950
|
+
syncScrollHandler();
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
853
954
|
const isAtTop = window.scrollY < 100;
|
|
854
|
-
for (const ev of
|
|
955
|
+
for (const ev of visibleFresh) {
|
|
855
956
|
const div = document.createElement('div');
|
|
856
957
|
div.innerHTML = renderEvent(ev);
|
|
857
958
|
const el = div.firstElementChild;
|
|
@@ -859,9 +960,13 @@ async function viewSession(id) {
|
|
|
859
960
|
container.insertBefore(el, container.firstChild);
|
|
860
961
|
setTimeout(() => el.classList.remove('event-highlight'), 2000);
|
|
861
962
|
}
|
|
963
|
+
rendered += visibleFresh.length;
|
|
964
|
+
updateEventsLabel();
|
|
965
|
+
updateEventsEmptyState();
|
|
966
|
+
syncScrollHandler();
|
|
862
967
|
|
|
863
968
|
if (!isAtTop) {
|
|
864
|
-
pendingNewCount +=
|
|
969
|
+
pendingNewCount += visibleFresh.length;
|
|
865
970
|
let indicator = document.getElementById('newEventsIndicator');
|
|
866
971
|
if (!indicator) {
|
|
867
972
|
indicator = document.createElement('div');
|