ccsniff 1.0.11
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 +82 -0
- package/package.json +38 -0
- package/src/cli.js +92 -0
- package/src/index.cjs +302 -0
- package/src/index.js +299 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 AnEntrypoint
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ccsniff
|
|
2
|
+
|
|
3
|
+
Watch Claude Code JSONL output files and emit structured events as a Node.js EventEmitter.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install ccsniff
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { watch } from 'ccsniff';
|
|
15
|
+
|
|
16
|
+
const watcher = watch()
|
|
17
|
+
.on('conversation_created', ({ conversation }) => {
|
|
18
|
+
console.log('New session:', conversation.title);
|
|
19
|
+
})
|
|
20
|
+
.on('streaming_progress', ({ block, role }) => {
|
|
21
|
+
if (block.type === 'text') process.stdout.write(block.text);
|
|
22
|
+
})
|
|
23
|
+
.on('streaming_complete', ({ conversationId }) => {
|
|
24
|
+
console.log('Done:', conversationId);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
process.on('SIGINT', () => watcher.stop());
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
CommonJS:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const { watch, JsonlWatcher } = require('ccsniff');
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## CLI
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx ccsniff --since 24h --grep "rs-exec" --limit 50
|
|
40
|
+
npx ccsniff --since 7d --role user --json
|
|
41
|
+
npx ccsniff -f # tail new events live
|
|
42
|
+
npx ccsniff --rollup out.ndjson --since 7d
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
### `watch(projectsDir?)` → `JsonlWatcher`
|
|
48
|
+
|
|
49
|
+
Creates and starts a watcher. `projectsDir` defaults to `~/.claude/projects`.
|
|
50
|
+
|
|
51
|
+
### `new JsonlWatcher(projectsDir?)`
|
|
52
|
+
|
|
53
|
+
Class constructor. Call `.start()` manually after attaching listeners.
|
|
54
|
+
|
|
55
|
+
### `watcher.start()` → `this`
|
|
56
|
+
|
|
57
|
+
Scans for existing `.jsonl` files and begins watching. Chainable.
|
|
58
|
+
|
|
59
|
+
### `watcher.stop()`
|
|
60
|
+
|
|
61
|
+
Closes file descriptors and directory watcher.
|
|
62
|
+
|
|
63
|
+
## Events
|
|
64
|
+
|
|
65
|
+
| Event | Payload |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `conversation_created` | `{ conversation: { id, title, cwd }, timestamp }` |
|
|
68
|
+
| `streaming_start` | `{ conversationId, conversation, timestamp }` |
|
|
69
|
+
| `streaming_progress` | `{ conversationId, conversation, block, role, seq, timestamp }` |
|
|
70
|
+
| `streaming_complete` | `{ conversationId, conversation, seq, timestamp }` |
|
|
71
|
+
| `streaming_error` | `{ conversationId, error, recoverable, timestamp }` |
|
|
72
|
+
| `error` | `Error` |
|
|
73
|
+
|
|
74
|
+
`block.type` values: `text`, `tool_use`, `tool_result`, `system`, `result`, etc.
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
Node >= 18. Zero external dependencies.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ccsniff",
|
|
3
|
+
"version": "1.0.11",
|
|
4
|
+
"description": "Watch Claude Code JSONL output files and emit structured events as a Node.js EventEmitter",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.js",
|
|
10
|
+
"require": "./src/index.cjs"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"ccsniff": "./src/cli.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"claude",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"ai",
|
|
23
|
+
"agent",
|
|
24
|
+
"watch",
|
|
25
|
+
"streaming",
|
|
26
|
+
"jsonl"
|
|
27
|
+
],
|
|
28
|
+
"author": "lanmower",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/AnEntrypoint/ccsniff.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://anentrypoint.github.io/ccsniff",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { JsonlReplayer, rollup } from './index.js';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const opts = { since: null, grep: null, cwd: null, role: null, type: null, limit: 0, json: false, tail: false, rollup: null, format: 'ndjson' };
|
|
7
|
+
const rest = [];
|
|
8
|
+
for (let i = 0; i < argv.length; i++) {
|
|
9
|
+
const a = argv[i];
|
|
10
|
+
const next = () => argv[++i];
|
|
11
|
+
if (a === '--since') opts.since = next();
|
|
12
|
+
else if (a === '--grep') opts.grep = next();
|
|
13
|
+
else if (a === '--cwd') opts.cwd = next();
|
|
14
|
+
else if (a === '--role') opts.role = next();
|
|
15
|
+
else if (a === '--type') opts.type = next();
|
|
16
|
+
else if (a === '--limit') opts.limit = parseInt(next(), 10) || 0;
|
|
17
|
+
else if (a === '--json') opts.json = true;
|
|
18
|
+
else if (a === '--tail' || a === '-f') opts.tail = true;
|
|
19
|
+
else if (a === '--rollup') opts.rollup = next();
|
|
20
|
+
else if (a === '--format') opts.format = next();
|
|
21
|
+
else if (a === '-h' || a === '--help') { printHelp(); process.exit(0); }
|
|
22
|
+
else rest.push(a);
|
|
23
|
+
}
|
|
24
|
+
return { opts, rest };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printHelp() {
|
|
28
|
+
process.stdout.write(`ccsniff — query and tail Claude Code session history
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
ccsniff [--since 12d] [--grep pattern] [--cwd path] [--role user|assistant|tool_result]
|
|
32
|
+
[--type text|tool_use|tool_result] [--limit N] [--json] [-f]
|
|
33
|
+
ccsniff --rollup out.ndjson [--since 7d]
|
|
34
|
+
ccsniff --rollup out.sqlite --format sqlite [--since 7d] # requires better-sqlite3
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
ccsniff --since 24h --grep "rs-exec" --limit 50
|
|
38
|
+
ccsniff --since 7d --role user --json
|
|
39
|
+
ccsniff -f # tail new events live
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseSince(s) {
|
|
44
|
+
if (!s) return 0;
|
|
45
|
+
const m = /^(\d+)([smhd])$/.exec(s);
|
|
46
|
+
if (!m) return Date.parse(s) || 0;
|
|
47
|
+
const n = parseInt(m[1], 10);
|
|
48
|
+
const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[m[2]];
|
|
49
|
+
return Date.now() - n * mult;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
53
|
+
const since = parseSince(opts.since);
|
|
54
|
+
const grepRe = opts.grep ? new RegExp(opts.grep, 'i') : null;
|
|
55
|
+
const cwdRe = opts.cwd ? new RegExp(opts.cwd, 'i') : null;
|
|
56
|
+
|
|
57
|
+
let count = 0;
|
|
58
|
+
function out(ev) {
|
|
59
|
+
const conv = ev.conversation;
|
|
60
|
+
if (cwdRe && !cwdRe.test(conv.cwd || '')) return;
|
|
61
|
+
if (opts.role && ev.role !== opts.role) return;
|
|
62
|
+
if (opts.type && ev.block?.type !== opts.type) return;
|
|
63
|
+
const text = ev.block?.text || (ev.block?.content ? (typeof ev.block.content === 'string' ? ev.block.content : JSON.stringify(ev.block.content).slice(0, 400)) : '');
|
|
64
|
+
if (grepRe && !grepRe.test(text)) return;
|
|
65
|
+
count++;
|
|
66
|
+
if (opts.limit && count > opts.limit) return;
|
|
67
|
+
if (opts.json) {
|
|
68
|
+
process.stdout.write(JSON.stringify({ ts: ev.timestamp, sid: conv.id, cwd: conv.cwd, role: ev.role, type: ev.block?.type, text: text.slice(0, 2000) }) + '\n');
|
|
69
|
+
} else {
|
|
70
|
+
const t = new Date(ev.timestamp).toISOString().slice(0, 19).replace('T', ' ');
|
|
71
|
+
const repo = path.basename(conv.cwd || '');
|
|
72
|
+
process.stdout.write(`[${t}] [${repo}] ${ev.role}/${ev.block?.type || '?'}: ${text.replace(/\s+/g, ' ').slice(0, 200)}\n`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (opts.rollup) {
|
|
77
|
+
const stats = await rollup({ since, out: opts.rollup, format: opts.format });
|
|
78
|
+
process.stderr.write(`# rolled up ${stats.rows} events from ${stats.events} routed (${stats.files} files) → ${stats.format}: ${stats.out}\n`);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const r = new JsonlReplayer();
|
|
83
|
+
r.on('streaming_progress', out);
|
|
84
|
+
r.on('error', e => process.stderr.write(`error: ${e?.message || e}\n`));
|
|
85
|
+
|
|
86
|
+
if (opts.tail) {
|
|
87
|
+
r.start();
|
|
88
|
+
process.stdout.write('tailing... (Ctrl-C to exit)\n');
|
|
89
|
+
} else {
|
|
90
|
+
const stats = r.replay({ since });
|
|
91
|
+
process.stderr.write(`# ${stats.events} events across ${stats.files} files (matched: ${count})\n`);
|
|
92
|
+
}
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { EventEmitter } = require('events');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
8
|
+
const DEBOUNCE_MS = 16;
|
|
9
|
+
|
|
10
|
+
class JsonlWatcher extends EventEmitter {
|
|
11
|
+
constructor(projectsDir = DEFAULT_DIR) {
|
|
12
|
+
super();
|
|
13
|
+
this._dir = projectsDir;
|
|
14
|
+
this._tails = new Map();
|
|
15
|
+
this._convs = new Map();
|
|
16
|
+
this._emitted = new Map();
|
|
17
|
+
this._timers = new Map();
|
|
18
|
+
this._seqs = new Map();
|
|
19
|
+
this._streaming = new Set();
|
|
20
|
+
this._watcher = null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
start() {
|
|
24
|
+
if (!fs.existsSync(this._dir)) return this;
|
|
25
|
+
this._scan(this._dir, 0);
|
|
26
|
+
try {
|
|
27
|
+
this._watcher = fs.watch(this._dir, { recursive: true }, (_, f) => {
|
|
28
|
+
if (f && f.endsWith('.jsonl')) this._debounce(path.join(this._dir, f));
|
|
29
|
+
});
|
|
30
|
+
this._watcher.on('error', e => this.emit('error', e));
|
|
31
|
+
} catch (e) { this.emit('error', e); }
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stop() {
|
|
36
|
+
if (this._watcher) try { this._watcher.close(); } catch (_) {}
|
|
37
|
+
for (const s of this._tails.values()) if (s.fd !== null) try { fs.closeSync(s.fd); } catch (_) {}
|
|
38
|
+
for (const t of this._timers.values()) clearTimeout(t);
|
|
39
|
+
this._tails.clear(); this._convs.clear(); this._emitted.clear();
|
|
40
|
+
this._timers.clear(); this._seqs.clear(); this._streaming.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_scan(dir, depth) {
|
|
44
|
+
if (depth > 4) return;
|
|
45
|
+
try {
|
|
46
|
+
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
47
|
+
const fp = path.join(dir, d.name);
|
|
48
|
+
if (d.isFile() && d.name.endsWith('.jsonl')) this._debounce(fp);
|
|
49
|
+
else if (d.isDirectory()) this._scan(fp, depth + 1);
|
|
50
|
+
}
|
|
51
|
+
} catch (_) {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_debounce(fp) {
|
|
55
|
+
const t = this._timers.get(fp);
|
|
56
|
+
if (t) clearTimeout(t);
|
|
57
|
+
this._timers.set(fp, setTimeout(() => { this._timers.delete(fp); this._read(fp); }, DEBOUNCE_MS));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_read(fp) {
|
|
61
|
+
let s = this._tails.get(fp);
|
|
62
|
+
if (!s) { s = { fd: null, offset: 0, partial: '' }; this._tails.set(fp, s); }
|
|
63
|
+
try {
|
|
64
|
+
if (s.fd === null) s.fd = fs.openSync(fp, 'r');
|
|
65
|
+
const stat = fs.fstatSync(s.fd);
|
|
66
|
+
if (stat.size <= s.offset) return;
|
|
67
|
+
const buf = Buffer.allocUnsafe(stat.size - s.offset);
|
|
68
|
+
const n = fs.readSync(s.fd, buf, 0, buf.length, s.offset);
|
|
69
|
+
s.offset += n;
|
|
70
|
+
const text = s.partial + buf.toString('utf8', 0, n);
|
|
71
|
+
const lines = []; let start = 0, idx;
|
|
72
|
+
while ((idx = text.indexOf('\n', start)) !== -1) { lines.push(text.slice(start, idx)); start = idx + 1; }
|
|
73
|
+
s.partial = text.slice(start);
|
|
74
|
+
const fallbackSid = path.basename(fp, '.jsonl');
|
|
75
|
+
for (const l of lines) this._line(l, fallbackSid, fp);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (e.code !== 'ENOENT') this.emit('error', e);
|
|
78
|
+
if (s.fd !== null) { try { fs.closeSync(s.fd); } catch (_) {} s.fd = null; }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_line(line, fallbackSid, fp) {
|
|
83
|
+
line = line.trim();
|
|
84
|
+
if (!line) return;
|
|
85
|
+
let e;
|
|
86
|
+
try { e = JSON.parse(line); } catch (_) { return; }
|
|
87
|
+
if (!e) return;
|
|
88
|
+
const sid = e.sessionId || fallbackSid;
|
|
89
|
+
if (!sid) return;
|
|
90
|
+
const conv = this._conv(sid, e, fp);
|
|
91
|
+
if (!conv) return;
|
|
92
|
+
this._route(conv, sid, e);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_conv(sid, e, fp) {
|
|
96
|
+
if (this._convs.has(sid)) return this._convs.get(sid);
|
|
97
|
+
if (e.type === 'queue-operation' || e.type === 'last-prompt') return null;
|
|
98
|
+
if (e.type === 'user' && e.isMeta) return null;
|
|
99
|
+
const dir = fp ? path.dirname(fp) : '';
|
|
100
|
+
const isSubagent = /[\\/]subagents$/.test(dir);
|
|
101
|
+
const projectDir = fp ? path.basename(isSubagent ? path.dirname(path.dirname(dir)) : path.dirname(fp)) : '';
|
|
102
|
+
const parentSid = isSubagent ? path.basename(path.dirname(dir)) : null;
|
|
103
|
+
const cwd = e.cwd || projectDir || '';
|
|
104
|
+
const branch = e.gitBranch || '';
|
|
105
|
+
const base = path.basename(cwd);
|
|
106
|
+
const title = isSubagent ? `[agent] ${base}` : (branch ? `${branch} @ ${base}` : base);
|
|
107
|
+
const conv = { id: sid, title, cwd, file: fp || null, parentSid, isSubagent };
|
|
108
|
+
this._convs.set(sid, conv);
|
|
109
|
+
this.emit('conversation_created', { conversation: conv, timestamp: Date.now() });
|
|
110
|
+
return conv;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_seq(sid) { const n = (this._seqs.get(sid) || 0) + 1; this._seqs.set(sid, n); return n; }
|
|
114
|
+
|
|
115
|
+
_startStreaming(conv, sid) {
|
|
116
|
+
if (this._streaming.has(sid)) return;
|
|
117
|
+
this._streaming.add(sid);
|
|
118
|
+
this.emit('streaming_start', { conversationId: conv.id, conversation: conv, timestamp: Date.now() });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_endStreaming(conv, sid) {
|
|
122
|
+
if (!this._streaming.has(sid)) return;
|
|
123
|
+
this._streaming.delete(sid);
|
|
124
|
+
this.emit('streaming_complete', { conversationId: conv.id, conversation: conv, seq: this._seq(sid), timestamp: Date.now() });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_push(conv, sid, block, role) {
|
|
128
|
+
this.emit('streaming_progress', { conversationId: conv.id, conversation: conv, block, role, seq: this._seq(sid), timestamp: Date.now() });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_route(conv, sid, e) {
|
|
132
|
+
if (e.type === 'queue-operation' || e.type === 'last-prompt' || (e.type === 'user' && e.isMeta)) return;
|
|
133
|
+
|
|
134
|
+
if (e.isApiErrorMessage && e.error === 'rate_limit') {
|
|
135
|
+
this.emit('streaming_error', { conversationId: conv.id, error: 'Rate limit hit', recoverable: true, timestamp: Date.now() });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (e.type === 'system') {
|
|
140
|
+
if (e.subtype === 'init') { this._startStreaming(conv, sid); return; }
|
|
141
|
+
if (e.subtype === 'turn_duration' || e.subtype === 'stop_hook_summary') { this._endStreaming(conv, sid); return; }
|
|
142
|
+
this._startStreaming(conv, sid);
|
|
143
|
+
this._push(conv, sid, { type: 'system', subtype: e.subtype, model: e.model, cwd: e.cwd, tools: e.tools }, 'system');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (e.type === 'assistant' && e.message?.content) {
|
|
148
|
+
this._startStreaming(conv, sid);
|
|
149
|
+
const key = `${sid}:${e.message.id}`;
|
|
150
|
+
const prev = this._emitted.get(key) || 0;
|
|
151
|
+
const newBlocks = e.message.content.slice(prev);
|
|
152
|
+
if (newBlocks.length > 0) {
|
|
153
|
+
this._emitted.set(key, e.message.content.length);
|
|
154
|
+
for (const b of newBlocks) if (b?.type) this._push(conv, sid, b, 'assistant');
|
|
155
|
+
}
|
|
156
|
+
if (e.message.stop_reason) this._emitted.delete(key);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (e.type === 'user' && e.message?.content) {
|
|
161
|
+
this._startStreaming(conv, sid);
|
|
162
|
+
const content = e.message.content;
|
|
163
|
+
if (typeof content === 'string') {
|
|
164
|
+
if (content.trim()) this._push(conv, sid, { type: 'text', text: content }, 'user');
|
|
165
|
+
} else if (Array.isArray(content)) {
|
|
166
|
+
for (const b of content) {
|
|
167
|
+
if (!b || !b.type) continue;
|
|
168
|
+
if (b.type === 'tool_result') this._push(conv, sid, b, 'tool_result');
|
|
169
|
+
else this._push(conv, sid, b, 'user');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (e.type === 'result') {
|
|
176
|
+
this._push(conv, sid, { type: 'result', result: e.result, subtype: e.subtype, duration_ms: e.duration_ms, total_cost_usd: e.total_cost_usd, is_error: e.is_error || false }, 'result');
|
|
177
|
+
this._endStreaming(conv, sid);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function watch(projectsDir) {
|
|
183
|
+
return new JsonlWatcher(projectsDir).start();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
class JsonlReplayer extends JsonlWatcher {
|
|
187
|
+
constructor(projectsDir = DEFAULT_DIR) { super(projectsDir); }
|
|
188
|
+
|
|
189
|
+
replay({ since = 0, files: fileFilter = null } = {}) {
|
|
190
|
+
const all = [];
|
|
191
|
+
const collect = (dir, depth) => {
|
|
192
|
+
if (depth > 5) return;
|
|
193
|
+
try {
|
|
194
|
+
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
195
|
+
const fp = path.join(dir, d.name);
|
|
196
|
+
if (d.isFile() && d.name.endsWith('.jsonl')) all.push(fp);
|
|
197
|
+
else if (d.isDirectory()) collect(fp, depth + 1);
|
|
198
|
+
}
|
|
199
|
+
} catch {}
|
|
200
|
+
};
|
|
201
|
+
if (fs.existsSync(this._dir)) collect(this._dir, 0);
|
|
202
|
+
const chosen = fileFilter ? all.filter(fileFilter) : all;
|
|
203
|
+
let emitted = 0;
|
|
204
|
+
for (const fp of chosen) {
|
|
205
|
+
const fallbackSid = path.basename(fp, '.jsonl');
|
|
206
|
+
let data;
|
|
207
|
+
try { data = fs.readFileSync(fp, 'utf8'); } catch { continue; }
|
|
208
|
+
for (const line of data.split('\n')) {
|
|
209
|
+
if (!line.trim()) continue;
|
|
210
|
+
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
211
|
+
if (!e) continue;
|
|
212
|
+
const t = e.timestamp ? Date.parse(e.timestamp) : 0;
|
|
213
|
+
if (since && t && t < since) continue;
|
|
214
|
+
const sid = e.sessionId || fallbackSid;
|
|
215
|
+
if (!sid) continue;
|
|
216
|
+
const conv = this._conv(sid, e, fp);
|
|
217
|
+
if (!conv) continue;
|
|
218
|
+
this._route(conv, sid, e);
|
|
219
|
+
emitted++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.emit('replay_complete', { files: chosen.length, events: emitted, timestamp: Date.now() });
|
|
223
|
+
return { files: chosen.length, events: emitted };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function replay(projectsDir, opts) {
|
|
228
|
+
return new JsonlReplayer(projectsDir);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function rollup({ projectsDir, since = 0, out, format = 'ndjson' } = {}) {
|
|
232
|
+
if (!out) throw new Error('rollup: out path required');
|
|
233
|
+
const r = new JsonlReplayer(projectsDir);
|
|
234
|
+
if (format === 'sqlite') return rollupSqlite(r, { since, out });
|
|
235
|
+
return rollupNdjson(r, { since, out });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function rollupNdjson(r, { since, out }) {
|
|
239
|
+
const stream = fs.createWriteStream(out);
|
|
240
|
+
let rows = 0;
|
|
241
|
+
r.on('streaming_progress', ev => {
|
|
242
|
+
const b = ev.block || {};
|
|
243
|
+
const text = b.text || (typeof b.content === 'string' ? b.content : '');
|
|
244
|
+
stream.write(JSON.stringify({
|
|
245
|
+
ts: ev.timestamp,
|
|
246
|
+
sid: ev.conversationId,
|
|
247
|
+
parent: ev.conversation?.parentSid || null,
|
|
248
|
+
cwd: ev.conversation?.cwd || '',
|
|
249
|
+
role: ev.role,
|
|
250
|
+
type: b.type || null,
|
|
251
|
+
text: text.slice(0, 4000),
|
|
252
|
+
tool: b.name || null,
|
|
253
|
+
}) + '\n');
|
|
254
|
+
rows++;
|
|
255
|
+
});
|
|
256
|
+
const stats = r.replay({ since });
|
|
257
|
+
stream.end();
|
|
258
|
+
return { ...stats, rows, format: 'ndjson', out };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function rollupSqlite(r, { since, out }) {
|
|
262
|
+
let Database;
|
|
263
|
+
try { Database = require('better-sqlite3'); } catch {
|
|
264
|
+
throw new Error('rollup: format=sqlite requires better-sqlite3 (npm i better-sqlite3)');
|
|
265
|
+
}
|
|
266
|
+
const db = new Database(out);
|
|
267
|
+
db.exec(`
|
|
268
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
269
|
+
ts INTEGER, sid TEXT, parent TEXT, cwd TEXT,
|
|
270
|
+
role TEXT, type TEXT, tool TEXT, text TEXT
|
|
271
|
+
);
|
|
272
|
+
CREATE INDEX IF NOT EXISTS events_sid ON events(sid);
|
|
273
|
+
CREATE INDEX IF NOT EXISTS events_ts ON events(ts);
|
|
274
|
+
CREATE INDEX IF NOT EXISTS events_parent ON events(parent);
|
|
275
|
+
`);
|
|
276
|
+
const insert = db.prepare('INSERT INTO events (ts, sid, parent, cwd, role, type, tool, text) VALUES (?,?,?,?,?,?,?,?)');
|
|
277
|
+
let rows = 0;
|
|
278
|
+
const tx = db.transaction(events => { for (const e of events) insert.run(e.ts, e.sid, e.parent, e.cwd, e.role, e.type, e.tool, e.text); });
|
|
279
|
+
let batch = [];
|
|
280
|
+
r.on('streaming_progress', ev => {
|
|
281
|
+
const b = ev.block || {};
|
|
282
|
+
const text = b.text || (typeof b.content === 'string' ? b.content : '');
|
|
283
|
+
batch.push({
|
|
284
|
+
ts: ev.timestamp || 0,
|
|
285
|
+
sid: ev.conversationId || '',
|
|
286
|
+
parent: ev.conversation?.parentSid || null,
|
|
287
|
+
cwd: ev.conversation?.cwd || '',
|
|
288
|
+
role: ev.role || '',
|
|
289
|
+
type: b.type || null,
|
|
290
|
+
tool: b.name || null,
|
|
291
|
+
text: (text || '').slice(0, 4000),
|
|
292
|
+
});
|
|
293
|
+
rows++;
|
|
294
|
+
if (batch.length >= 500) { tx(batch); batch = []; }
|
|
295
|
+
});
|
|
296
|
+
const stats = r.replay({ since });
|
|
297
|
+
if (batch.length) tx(batch);
|
|
298
|
+
db.close();
|
|
299
|
+
return { ...stats, rows, format: 'sqlite', out };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = { JsonlWatcher, JsonlReplayer, watch, replay, rollup };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DIR = path.join(os.homedir(), '.claude', 'projects');
|
|
7
|
+
const DEBOUNCE_MS = 16;
|
|
8
|
+
|
|
9
|
+
export class JsonlWatcher extends EventEmitter {
|
|
10
|
+
constructor(projectsDir = DEFAULT_DIR) {
|
|
11
|
+
super();
|
|
12
|
+
this._dir = projectsDir;
|
|
13
|
+
this._tails = new Map();
|
|
14
|
+
this._convs = new Map();
|
|
15
|
+
this._emitted = new Map();
|
|
16
|
+
this._timers = new Map();
|
|
17
|
+
this._seqs = new Map();
|
|
18
|
+
this._streaming = new Set();
|
|
19
|
+
this._watcher = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
start() {
|
|
23
|
+
if (!fs.existsSync(this._dir)) return this;
|
|
24
|
+
this._scan(this._dir, 0);
|
|
25
|
+
try {
|
|
26
|
+
this._watcher = fs.watch(this._dir, { recursive: true }, (_, f) => {
|
|
27
|
+
if (f && f.endsWith('.jsonl')) this._debounce(path.join(this._dir, f));
|
|
28
|
+
});
|
|
29
|
+
this._watcher.on('error', e => this.emit('error', e));
|
|
30
|
+
} catch (e) { this.emit('error', e); }
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
stop() {
|
|
35
|
+
if (this._watcher) try { this._watcher.close(); } catch (_) {}
|
|
36
|
+
for (const s of this._tails.values()) if (s.fd !== null) try { fs.closeSync(s.fd); } catch (_) {}
|
|
37
|
+
for (const t of this._timers.values()) clearTimeout(t);
|
|
38
|
+
this._tails.clear(); this._convs.clear(); this._emitted.clear();
|
|
39
|
+
this._timers.clear(); this._seqs.clear(); this._streaming.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_scan(dir, depth) {
|
|
43
|
+
if (depth > 4) return;
|
|
44
|
+
try {
|
|
45
|
+
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
46
|
+
const fp = path.join(dir, d.name);
|
|
47
|
+
if (d.isFile() && d.name.endsWith('.jsonl')) this._debounce(fp);
|
|
48
|
+
else if (d.isDirectory()) this._scan(fp, depth + 1);
|
|
49
|
+
}
|
|
50
|
+
} catch (_) {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_debounce(fp) {
|
|
54
|
+
const t = this._timers.get(fp);
|
|
55
|
+
if (t) clearTimeout(t);
|
|
56
|
+
this._timers.set(fp, setTimeout(() => { this._timers.delete(fp); this._read(fp); }, DEBOUNCE_MS));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_read(fp) {
|
|
60
|
+
let s = this._tails.get(fp);
|
|
61
|
+
if (!s) { s = { fd: null, offset: 0, partial: '' }; this._tails.set(fp, s); }
|
|
62
|
+
try {
|
|
63
|
+
if (s.fd === null) s.fd = fs.openSync(fp, 'r');
|
|
64
|
+
const stat = fs.fstatSync(s.fd);
|
|
65
|
+
if (stat.size <= s.offset) return;
|
|
66
|
+
const buf = Buffer.allocUnsafe(stat.size - s.offset);
|
|
67
|
+
const n = fs.readSync(s.fd, buf, 0, buf.length, s.offset);
|
|
68
|
+
s.offset += n;
|
|
69
|
+
const text = s.partial + buf.toString('utf8', 0, n);
|
|
70
|
+
const lines = []; let start = 0, idx;
|
|
71
|
+
while ((idx = text.indexOf('\n', start)) !== -1) { lines.push(text.slice(start, idx)); start = idx + 1; }
|
|
72
|
+
s.partial = text.slice(start);
|
|
73
|
+
const fallbackSid = path.basename(fp, '.jsonl');
|
|
74
|
+
for (const l of lines) this._line(l, fallbackSid, fp);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e.code !== 'ENOENT') this.emit('error', e);
|
|
77
|
+
if (s.fd !== null) { try { fs.closeSync(s.fd); } catch (_) {} s.fd = null; }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_line(line, fallbackSid, fp) {
|
|
82
|
+
line = line.trim();
|
|
83
|
+
if (!line) return;
|
|
84
|
+
let e;
|
|
85
|
+
try { e = JSON.parse(line); } catch (_) { return; }
|
|
86
|
+
if (!e) return;
|
|
87
|
+
const sid = e.sessionId || fallbackSid;
|
|
88
|
+
if (!sid) return;
|
|
89
|
+
const conv = this._conv(sid, e, fp);
|
|
90
|
+
if (!conv) return;
|
|
91
|
+
this._route(conv, sid, e);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_conv(sid, e, fp) {
|
|
95
|
+
if (this._convs.has(sid)) return this._convs.get(sid);
|
|
96
|
+
if (e.type === 'queue-operation' || e.type === 'last-prompt') return null;
|
|
97
|
+
if (e.type === 'user' && e.isMeta) return null;
|
|
98
|
+
const dir = fp ? path.dirname(fp) : '';
|
|
99
|
+
const isSubagent = /[\\/]subagents$/.test(dir);
|
|
100
|
+
const projectDir = fp ? path.basename(isSubagent ? path.dirname(path.dirname(dir)) : path.dirname(fp)) : '';
|
|
101
|
+
const parentSid = isSubagent ? path.basename(path.dirname(dir)) : null;
|
|
102
|
+
const cwd = e.cwd || projectDir || '';
|
|
103
|
+
const branch = e.gitBranch || '';
|
|
104
|
+
const base = path.basename(cwd);
|
|
105
|
+
const title = isSubagent ? `[agent] ${base}` : (branch ? `${branch} @ ${base}` : base);
|
|
106
|
+
const conv = { id: sid, title, cwd, file: fp || null, parentSid, isSubagent };
|
|
107
|
+
this._convs.set(sid, conv);
|
|
108
|
+
this.emit('conversation_created', { conversation: conv, timestamp: Date.now() });
|
|
109
|
+
return conv;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_seq(sid) { const n = (this._seqs.get(sid) || 0) + 1; this._seqs.set(sid, n); return n; }
|
|
113
|
+
|
|
114
|
+
_startStreaming(conv, sid) {
|
|
115
|
+
if (this._streaming.has(sid)) return;
|
|
116
|
+
this._streaming.add(sid);
|
|
117
|
+
this.emit('streaming_start', { conversationId: conv.id, conversation: conv, timestamp: Date.now() });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_endStreaming(conv, sid) {
|
|
121
|
+
if (!this._streaming.has(sid)) return;
|
|
122
|
+
this._streaming.delete(sid);
|
|
123
|
+
this.emit('streaming_complete', { conversationId: conv.id, conversation: conv, seq: this._seq(sid), timestamp: Date.now() });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_push(conv, sid, block, role) {
|
|
127
|
+
this.emit('streaming_progress', { conversationId: conv.id, conversation: conv, block, role, seq: this._seq(sid), timestamp: Date.now() });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
_route(conv, sid, e) {
|
|
131
|
+
if (e.type === 'queue-operation' || e.type === 'last-prompt' || (e.type === 'user' && e.isMeta)) return;
|
|
132
|
+
|
|
133
|
+
if (e.isApiErrorMessage && e.error === 'rate_limit') {
|
|
134
|
+
this.emit('streaming_error', { conversationId: conv.id, error: 'Rate limit hit', recoverable: true, timestamp: Date.now() });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (e.type === 'system') {
|
|
139
|
+
if (e.subtype === 'init') { this._startStreaming(conv, sid); return; }
|
|
140
|
+
if (e.subtype === 'turn_duration' || e.subtype === 'stop_hook_summary') { this._endStreaming(conv, sid); return; }
|
|
141
|
+
this._startStreaming(conv, sid);
|
|
142
|
+
this._push(conv, sid, { type: 'system', subtype: e.subtype, model: e.model, cwd: e.cwd, tools: e.tools }, 'system');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (e.type === 'assistant' && e.message?.content) {
|
|
147
|
+
this._startStreaming(conv, sid);
|
|
148
|
+
const key = `${sid}:${e.message.id}`;
|
|
149
|
+
const prev = this._emitted.get(key) || 0;
|
|
150
|
+
const newBlocks = e.message.content.slice(prev);
|
|
151
|
+
if (newBlocks.length > 0) {
|
|
152
|
+
this._emitted.set(key, e.message.content.length);
|
|
153
|
+
for (const b of newBlocks) if (b?.type) this._push(conv, sid, b, 'assistant');
|
|
154
|
+
}
|
|
155
|
+
if (e.message.stop_reason) this._emitted.delete(key);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (e.type === 'user' && e.message?.content) {
|
|
160
|
+
this._startStreaming(conv, sid);
|
|
161
|
+
const content = e.message.content;
|
|
162
|
+
if (typeof content === 'string') {
|
|
163
|
+
if (content.trim()) this._push(conv, sid, { type: 'text', text: content }, 'user');
|
|
164
|
+
} else if (Array.isArray(content)) {
|
|
165
|
+
for (const b of content) {
|
|
166
|
+
if (!b || !b.type) continue;
|
|
167
|
+
if (b.type === 'tool_result') this._push(conv, sid, b, 'tool_result');
|
|
168
|
+
else this._push(conv, sid, b, 'user');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (e.type === 'result') {
|
|
175
|
+
this._push(conv, sid, { type: 'result', result: e.result, subtype: e.subtype, duration_ms: e.duration_ms, total_cost_usd: e.total_cost_usd, is_error: e.is_error || false }, 'result');
|
|
176
|
+
this._endStreaming(conv, sid);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function watch(projectsDir) {
|
|
182
|
+
return new JsonlWatcher(projectsDir).start();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export class JsonlReplayer extends JsonlWatcher {
|
|
186
|
+
constructor(projectsDir = DEFAULT_DIR) { super(projectsDir); }
|
|
187
|
+
|
|
188
|
+
replay({ since = 0, files: fileFilter = null } = {}) {
|
|
189
|
+
const all = [];
|
|
190
|
+
const collect = (dir, depth) => {
|
|
191
|
+
if (depth > 5) return;
|
|
192
|
+
try {
|
|
193
|
+
for (const d of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
194
|
+
const fp = path.join(dir, d.name);
|
|
195
|
+
if (d.isFile() && d.name.endsWith('.jsonl')) all.push(fp);
|
|
196
|
+
else if (d.isDirectory()) collect(fp, depth + 1);
|
|
197
|
+
}
|
|
198
|
+
} catch {}
|
|
199
|
+
};
|
|
200
|
+
if (fs.existsSync(this._dir)) collect(this._dir, 0);
|
|
201
|
+
const chosen = fileFilter ? all.filter(fileFilter) : all;
|
|
202
|
+
let emitted = 0;
|
|
203
|
+
for (const fp of chosen) {
|
|
204
|
+
const fallbackSid = path.basename(fp, '.jsonl');
|
|
205
|
+
let data;
|
|
206
|
+
try { data = fs.readFileSync(fp, 'utf8'); } catch { continue; }
|
|
207
|
+
for (const line of data.split('\n')) {
|
|
208
|
+
if (!line.trim()) continue;
|
|
209
|
+
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
210
|
+
if (!e) continue;
|
|
211
|
+
const t = e.timestamp ? Date.parse(e.timestamp) : 0;
|
|
212
|
+
if (since && t && t < since) continue;
|
|
213
|
+
const sid = e.sessionId || fallbackSid;
|
|
214
|
+
if (!sid) continue;
|
|
215
|
+
const conv = this._conv(sid, e, fp);
|
|
216
|
+
if (!conv) continue;
|
|
217
|
+
this._route(conv, sid, e);
|
|
218
|
+
emitted++;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
this.emit('replay_complete', { files: chosen.length, events: emitted, timestamp: Date.now() });
|
|
222
|
+
return { files: chosen.length, events: emitted };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function replay(projectsDir, opts) {
|
|
227
|
+
return new JsonlReplayer(projectsDir);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function rollup({ projectsDir, since = 0, out, format = 'ndjson' } = {}) {
|
|
231
|
+
if (!out) throw new Error('rollup: out path required');
|
|
232
|
+
const r = new JsonlReplayer(projectsDir);
|
|
233
|
+
if (format === 'sqlite') return rollupSqlite(r, { since, out });
|
|
234
|
+
return rollupNdjson(r, { since, out });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function rollupNdjson(r, { since, out }) {
|
|
238
|
+
const stream = fs.createWriteStream(out);
|
|
239
|
+
let rows = 0;
|
|
240
|
+
r.on('streaming_progress', ev => {
|
|
241
|
+
const b = ev.block || {};
|
|
242
|
+
const text = b.text || (typeof b.content === 'string' ? b.content : '');
|
|
243
|
+
stream.write(JSON.stringify({
|
|
244
|
+
ts: ev.timestamp,
|
|
245
|
+
sid: ev.conversationId,
|
|
246
|
+
parent: ev.conversation?.parentSid || null,
|
|
247
|
+
cwd: ev.conversation?.cwd || '',
|
|
248
|
+
role: ev.role,
|
|
249
|
+
type: b.type || null,
|
|
250
|
+
text: text.slice(0, 4000),
|
|
251
|
+
tool: b.name || null,
|
|
252
|
+
}) + '\n');
|
|
253
|
+
rows++;
|
|
254
|
+
});
|
|
255
|
+
const stats = r.replay({ since });
|
|
256
|
+
stream.end();
|
|
257
|
+
return { ...stats, rows, format: 'ndjson', out };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function rollupSqlite(r, { since, out }) {
|
|
261
|
+
let Database;
|
|
262
|
+
try { Database = (await import('better-sqlite3')).default; } catch {
|
|
263
|
+
throw new Error('rollup: format=sqlite requires better-sqlite3 (npm i better-sqlite3)');
|
|
264
|
+
}
|
|
265
|
+
const db = new Database(out);
|
|
266
|
+
db.exec(`
|
|
267
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
268
|
+
ts INTEGER, sid TEXT, parent TEXT, cwd TEXT,
|
|
269
|
+
role TEXT, type TEXT, tool TEXT, text TEXT
|
|
270
|
+
);
|
|
271
|
+
CREATE INDEX IF NOT EXISTS events_sid ON events(sid);
|
|
272
|
+
CREATE INDEX IF NOT EXISTS events_ts ON events(ts);
|
|
273
|
+
CREATE INDEX IF NOT EXISTS events_parent ON events(parent);
|
|
274
|
+
`);
|
|
275
|
+
const insert = db.prepare('INSERT INTO events (ts, sid, parent, cwd, role, type, tool, text) VALUES (?,?,?,?,?,?,?,?)');
|
|
276
|
+
let rows = 0;
|
|
277
|
+
const tx = db.transaction(events => { for (const e of events) insert.run(e.ts, e.sid, e.parent, e.cwd, e.role, e.type, e.tool, e.text); });
|
|
278
|
+
let batch = [];
|
|
279
|
+
r.on('streaming_progress', ev => {
|
|
280
|
+
const b = ev.block || {};
|
|
281
|
+
const text = b.text || (typeof b.content === 'string' ? b.content : '');
|
|
282
|
+
batch.push({
|
|
283
|
+
ts: ev.timestamp || 0,
|
|
284
|
+
sid: ev.conversationId || '',
|
|
285
|
+
parent: ev.conversation?.parentSid || null,
|
|
286
|
+
cwd: ev.conversation?.cwd || '',
|
|
287
|
+
role: ev.role || '',
|
|
288
|
+
type: b.type || null,
|
|
289
|
+
tool: b.name || null,
|
|
290
|
+
text: (text || '').slice(0, 4000),
|
|
291
|
+
});
|
|
292
|
+
rows++;
|
|
293
|
+
if (batch.length >= 500) { tx(batch); batch = []; }
|
|
294
|
+
});
|
|
295
|
+
const stats = r.replay({ since });
|
|
296
|
+
if (batch.length) tx(batch);
|
|
297
|
+
db.close();
|
|
298
|
+
return { ...stats, rows, format: 'sqlite', out };
|
|
299
|
+
}
|