ccsniff 1.0.32 → 1.1.1

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 CHANGED
@@ -33,6 +33,20 @@ CommonJS:
33
33
  const { watch, JsonlWatcher } = require('ccsniff');
34
34
  ```
35
35
 
36
+ ## Mountable Express router (`/v1/history/*`)
37
+
38
+ ccsniff ships a mountable router that hosts all the read-only history endpoints used by AgentGUI's live client (`/v1/history/snapshot`, `/sessions`, `/sessions/:sid/events`, `/search`, `/stream` SSE, and `POST /reindex`). It reads `~/.claude/projects` by default; override with the `CLAUDE_PROJECTS_DIR` env var. `express` is an optional peer dependency — install it in the host app.
39
+
40
+ ```js
41
+ import express from 'express';
42
+ import { createHistoryRouter } from 'ccsniff';
43
+
44
+ const app = express();
45
+ app.use(await createHistoryRouter({ projectsDir: process.env.CLAUDE_PROJECTS_DIR }));
46
+ app.listen(3000);
47
+ ```
48
+
49
+
36
50
  ## CLI
37
51
 
38
52
  ```bash
package/gui/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mount, components as C, h } from 'anentrypoint-design';
2
2
 
3
- const TABS = ['overview', 'sessions', 'projects', 'tools', 'timeline', 'errors', 'subagents', 'live', 'search'];
3
+ const TABS = ['overview', 'sessions', 'projects', 'tools', 'timeline', 'errors', 'subagents', 'events', 'live', 'search'];
4
4
  const state = {
5
5
  tab: 'overview',
6
6
  data: { snapshot: null, sessions: [], projects: [], tools: [], timeline: [], stats: null, errors: [], subagents: [] },
@@ -8,6 +8,9 @@ const state = {
8
8
  searchResults: [],
9
9
  searching: false,
10
10
  liveLog: [],
11
+ defaults: { active: 'recent', presets: [] },
12
+ activePreset: 'recent',
13
+ events: { total: 0, rows: [], loading: false },
11
14
  };
12
15
 
13
16
  const api = (p) => fetch(p).then(r => r.json());
@@ -144,16 +147,81 @@ function SubagentsView() {
144
147
  ) });
145
148
  }
146
149
 
147
- function LiveView() {
148
- return C.Panel({ head: `live · ${state.liveLog.length} events (last 200)`, children: h('div', { class: 'live' },
149
- ...state.liveLog.slice().reverse().map((e, i) => h('div', { key: i, class: 'e' },
150
- h('span', { class: 'ts' }, ts(e.ts || Date.now()).slice(11)),
151
- h('span', { class: 'k' }, e._kind || 'event'),
152
- h('span', {}, fmtLive(e)),
150
+ function PresetChips() {
151
+ const presets = state.defaults.presets || [];
152
+ return h('div', { style: 'display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px' },
153
+ ...presets.map(p => h('span', {
154
+ class: 'pill' + (p.id === state.activePreset ? ' accent' : ''),
155
+ style: 'cursor:pointer;user-select:none;' + (p.id === state.activePreset ? 'outline:1px solid currentColor' : ''),
156
+ onclick: () => { state.activePreset = p.id; loadEvents(); },
157
+ }, p.label)),
158
+ );
159
+ }
160
+
161
+ function currentPresetQuery() {
162
+ const p = (state.defaults.presets || []).find(x => x.id === state.activePreset);
163
+ return p?.query || {};
164
+ }
165
+
166
+ async function loadEvents() {
167
+ state.events.loading = true; render();
168
+ const q = currentPresetQuery();
169
+ const params = new URLSearchParams();
170
+ for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') params.set(k, String(v));
171
+ params.set('limit', '200');
172
+ const r = await api('/api/events?' + params.toString());
173
+ state.events = { total: r.total || 0, rows: r.rows || [], loading: false, error: r.error || null };
174
+ render();
175
+ }
176
+
177
+ function EventsView() {
178
+ const rows = state.events.rows;
179
+ return C.Panel({ head: `events · ${state.events.total} match · showing ${rows.length}`, children: h('div', {},
180
+ PresetChips(),
181
+ state.events.error ? h('div', { class: 'err', style: 'padding:8px 12px' }, state.events.error) : null,
182
+ h('div', { class: 'row-grid', style: 'grid-template-columns:140px 80px 110px 100px 1fr;opacity:.55' },
183
+ h('span', {}, 'when'), h('span', {}, 'role'), h('span', {}, 'type'), h('span', {}, 'tool'), h('span', {}, 'text')),
184
+ ...rows.map(e => h('div', { class: 'row-grid', style: 'grid-template-columns:140px 80px 110px 100px 1fr' },
185
+ h('span', {}, ts(e.ts).slice(5)),
186
+ h('span', {}, e.role || '—'),
187
+ h('span', {}, e.type || '—'),
188
+ h('span', { class: 'accent' }, e.tool || '—'),
189
+ h('span', { class: 'truncate' + (e.isError ? ' err' : '') },
190
+ h('span', { class: 'pill' }, e.project || '—'),
191
+ ' ',
192
+ (e.text || '').slice(0, 400)),
153
193
  )),
154
194
  ) });
155
195
  }
156
196
 
197
+ function liveMatches(e) {
198
+ const q = currentPresetQuery();
199
+ if (q.role && e.role !== q.role) return false;
200
+ if (q.type && e.type !== q.type) return false;
201
+ if (q.tool && e.tool !== q.tool) return false;
202
+ if (q.project && e.project !== q.project) return false;
203
+ if (q.isMeta === true && !e.isMeta) return false;
204
+ if (q.isMeta === false && e.isMeta) return false;
205
+ if (q.isSubagent === true && !e.isSubagent) return false;
206
+ if (q.isSubagent === false && e.isSubagent) return false;
207
+ if (q.isError === true && !e.isError) return false;
208
+ return true;
209
+ }
210
+
211
+ function LiveView() {
212
+ const filtered = state.liveLog.filter(e => e._kind !== 'event' || liveMatches(e));
213
+ return C.Panel({ head: `live · ${filtered.length}/${state.liveLog.length} events (preset: ${state.activePreset})`, children: h('div', {},
214
+ PresetChips(),
215
+ h('div', { class: 'live' },
216
+ ...filtered.slice().reverse().map((e, i) => h('div', { key: i, class: 'e' },
217
+ h('span', { class: 'ts' }, ts(e.ts || Date.now()).slice(11)),
218
+ h('span', { class: 'k' }, e._kind || 'event'),
219
+ h('span', {}, fmtLive(e)),
220
+ )),
221
+ ),
222
+ ) });
223
+ }
224
+
157
225
  function fmtLive(e) {
158
226
  if (e._kind === 'conversation') return 'new ' + (e.conv?.title || e.conv?.id?.slice(0, 8));
159
227
  if (e._kind === 'start' || e._kind === 'complete') return (e.sid || '').slice(0, 8);
@@ -192,7 +260,7 @@ async function doSearch() {
192
260
  state.searching = false; render();
193
261
  }
194
262
 
195
- const VIEWS = { overview: Overview, sessions: SessionsView, projects: ProjectsView, tools: ToolsView, timeline: TimelineView, errors: ErrorsView, subagents: SubagentsView, live: LiveView, search: SearchView };
263
+ const VIEWS = { overview: Overview, sessions: SessionsView, projects: ProjectsView, tools: ToolsView, timeline: TimelineView, errors: ErrorsView, subagents: SubagentsView, events: EventsView, live: LiveView, search: SearchView };
196
264
 
197
265
  function App() {
198
266
  const s = state.data.snapshot || {};
@@ -201,7 +269,7 @@ function App() {
201
269
  brand: '247420', leaf: 'ccsniff',
202
270
  items: TABS.map(t => [t, '#/' + t]),
203
271
  active: state.tab,
204
- onNav: (t) => { state.tab = t; location.hash = '#/' + t; render(); },
272
+ onNav: (t) => { state.tab = t; location.hash = '#/' + t; render(); if (t === 'events') loadEvents(); },
205
273
  }),
206
274
  main: h('div', { style: 'padding:16px;display:flex;flex-direction:column;gap:16px' },
207
275
  C.Crumb({ trail: ['247420', 'ccsniff'], leaf: state.tab, right: h('span', { class: 'pill' }, n(s.events) + ' events') }),
@@ -224,7 +292,13 @@ render();
224
292
  })();
225
293
 
226
294
  (async function init() {
295
+ try {
296
+ const d = await api('/api/defaults');
297
+ state.defaults = d || state.defaults;
298
+ state.activePreset = d?.active || state.activePreset;
299
+ } catch {}
227
300
  await loadAll();
301
+ if (state.tab === 'events') loadEvents();
228
302
  setInterval(loadAll, 15_000);
229
303
  const sse = new EventSource('/api/stream');
230
304
  const push = (kind, data) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccsniff",
3
- "version": "1.0.32",
3
+ "version": "1.1.1",
4
4
  "description": "Watch Claude Code JSONL output files and emit structured events as a Node.js EventEmitter",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -39,5 +39,13 @@
39
39
  "homepage": "https://anentrypoint.github.io/ccsniff",
40
40
  "engines": {
41
41
  "node": ">=18"
42
+ },
43
+ "peerDependencies": {
44
+ "express": "^4 || ^5"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "express": {
48
+ "optional": true
49
+ }
42
50
  }
43
51
  }
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { JsonlReplayer, rollup, vault } from './index.js';
3
3
  import { toUnslothMessages, toShareGPT } from './unsloth.js';
4
+ import { parseTime, compileRegexes, buildFilter } from './filters.js';
4
5
  import fs from 'fs';
5
6
  import path from 'path';
6
7
 
@@ -69,7 +70,7 @@ TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
69
70
 
70
71
  FILTERS (repeatable flags combine as OR within a flag, AND across flags)
71
72
  --grep <re> text regex (case-insensitive); repeat = AND
72
- --igrep <re> inverted text regex; repeat = AND (none must match)
73
+ --igrep <re> exclude if regex matches text; repeat = exclude if ANY matches
73
74
  --invert invert the entire filter result
74
75
  --cwd <re> working-dir regex
75
76
  --project <name> basename(cwd) exact match; repeat = OR
@@ -114,21 +115,6 @@ EXAMPLES
114
115
  `);
115
116
  }
116
117
 
117
- function parseTime(s) {
118
- if (!s) return 0;
119
- if (/^\d{10,}$/.test(s)) return parseInt(s, 10);
120
- const m = /^(\d+)([smhdw])$/.exec(s);
121
- if (m) {
122
- const n = parseInt(m[1], 10);
123
- const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[m[2]];
124
- return Date.now() - n * mult;
125
- }
126
- const t = Date.parse(s);
127
- return Number.isFinite(t) ? t : 0;
128
- }
129
-
130
- function compileRegexes(arr) { return arr.map(s => new RegExp(s, 'i')); }
131
-
132
118
  function blockText(b) {
133
119
  if (!b) return '';
134
120
  if (typeof b.text === 'string') return b.text;
@@ -138,46 +124,6 @@ function blockText(b) {
138
124
  return '';
139
125
  }
140
126
 
141
- function buildFilter(opts) {
142
- const since = parseTime(opts.since || opts.after);
143
- const until = parseTime(opts.until || opts.before);
144
- const greps = compileRegexes(opts._multi.grep);
145
- const igreps = compileRegexes(opts._multi.igrep);
146
- const cwdRes = compileRegexes(opts._multi.cwd);
147
- const projects = new Set(opts._multi.project);
148
- const roles = new Set(opts._multi.role);
149
- const types = new Set(opts._multi.type);
150
- const tools = new Set(opts._multi.tool);
151
- const sids = opts._multi.session.concat(opts._multi.sid || []);
152
- const parent = opts.parent || null;
153
-
154
- return ev => {
155
- const conv = ev.conversation || {};
156
- const block = ev.block || {};
157
- const ts = ev.timestamp || 0;
158
- let pass = true;
159
- if (since && ts < since) pass = false;
160
- else if (until && ts > until) pass = false;
161
- else if (cwdRes.length && !cwdRes.every(r => r.test(conv.cwd || ''))) pass = false;
162
- else if (projects.size && !projects.has(path.basename(conv.cwd || ''))) pass = false;
163
- else if (roles.size && !roles.has(ev.role)) pass = false;
164
- else if (types.size && !types.has(block.type)) pass = false;
165
- else if (tools.size && !tools.has(block.name)) pass = false;
166
- else if (sids.length && !sids.some(s => conv.id?.startsWith(s))) pass = false;
167
- else if (parent && conv.parentSid !== parent) pass = false;
168
- else if (opts['no-subagents'] && conv.isSubagent) pass = false;
169
- else if (opts['only-subagents'] && !conv.isSubagent) pass = false;
170
- else if (opts['no-meta'] && block.isMeta) pass = false;
171
- else if (opts['only-meta'] && !block.isMeta) pass = false;
172
- else {
173
- const text = blockText(block);
174
- if (greps.length && !greps.every(r => r.test(text))) pass = false;
175
- else if (igreps.length && igreps.some(r => r.test(text))) pass = false;
176
- }
177
- return opts.invert ? !pass : pass;
178
- };
179
- }
180
-
181
127
  function formatRow(ev, opts) {
182
128
  const conv = ev.conversation || {};
183
129
  const block = ev.block || {};
@@ -242,8 +188,14 @@ if (opts.help || process.argv.length <= 2) { printHelp(); process.exit(0); }
242
188
 
243
189
  { const r = vault(); if (r.copied > 0) process.stderr.write(`# vault: ${r.copied} copied → ~/.claude/history-backup\n`); }
244
190
 
245
- const since = parseTime(opts.since || opts.after);
246
- const filter = buildFilter(opts);
191
+ let since, filter;
192
+ try {
193
+ since = parseTime(opts.since || opts.after);
194
+ filter = buildFilter(opts);
195
+ } catch (e) {
196
+ process.stderr.write(`ccsniff: ${e.message}\n`);
197
+ process.exit(2);
198
+ }
247
199
 
248
200
  // ---------- rollup (filtered)
249
201
  if (opts.rollup) {
package/src/filters.js ADDED
@@ -0,0 +1,89 @@
1
+ // Filter primitives shared by cli.js, gui-server.js, and tests.
2
+ import path from 'path';
3
+
4
+ export function parseTime(s) {
5
+ if (s === undefined || s === null || s === '') return 0;
6
+ if (typeof s === 'number') return Number.isFinite(s) ? s : 0;
7
+ s = String(s).trim();
8
+ if (!s) return 0;
9
+ if (/^\d{10,}$/.test(s)) return parseInt(s, 10);
10
+ const m = /^(\d+)\s*([smhdw])$/i.exec(s);
11
+ if (m) {
12
+ const n = parseInt(m[1], 10);
13
+ const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[m[2].toLowerCase()];
14
+ return Date.now() - n * mult;
15
+ }
16
+ const t = Date.parse(s);
17
+ if (Number.isFinite(t)) return t;
18
+ throw new Error(`invalid time: ${s} (use ISO date, epoch ms, or Ns/Nm/Nh/Nd/Nw)`);
19
+ }
20
+
21
+ export function compileRegexes(arr) {
22
+ return (arr || []).map(s => {
23
+ try { return new RegExp(s, 'i'); }
24
+ catch (e) { throw new Error(`invalid regex /${s}/: ${e.message}`); }
25
+ });
26
+ }
27
+
28
+ function blockText(b) {
29
+ if (!b) return '';
30
+ if (typeof b.text === 'string') return b.text;
31
+ if (typeof b.content === 'string') return b.content;
32
+ if (Array.isArray(b.content)) return b.content.map(c => c?.text || '').join('');
33
+ if (b.input) { try { return JSON.stringify(b.input); } catch { return ''; } }
34
+ return '';
35
+ }
36
+
37
+ export function buildFilter(opts) {
38
+ const m = opts._multi || {};
39
+ const since = parseTime(opts.since || opts.after);
40
+ const until = parseTime(opts.until || opts.before);
41
+ const greps = compileRegexes(m.grep);
42
+ const igreps = compileRegexes(m.igrep);
43
+ const cwdRes = compileRegexes(m.cwd);
44
+ const projects = new Set(m.project || []);
45
+ const roles = new Set(m.role || []);
46
+ const types = new Set(m.type || []);
47
+ const tools = new Set(m.tool || []);
48
+ const sids = (m.session || []).concat(m.sid || []);
49
+ const parent = opts.parent || null;
50
+
51
+ return ev => {
52
+ const conv = ev.conversation || {};
53
+ const block = ev.block || {};
54
+ const ts = ev.timestamp || 0;
55
+ let pass = true;
56
+ if (since && ts < since) pass = false;
57
+ else if (until && ts > until) pass = false;
58
+ else if (cwdRes.length && !cwdRes.every(r => r.test(conv.cwd || ''))) pass = false;
59
+ else if (projects.size && !projects.has(path.basename(conv.cwd || ''))) pass = false;
60
+ else if (roles.size && !roles.has(ev.role)) pass = false;
61
+ else if (types.size && !types.has(block.type)) pass = false;
62
+ else if (tools.size && !tools.has(block.name)) pass = false;
63
+ else if (sids.length && !sids.some(s => conv.id?.startsWith(s))) pass = false;
64
+ else if (parent && conv.parentSid !== parent) pass = false;
65
+ else if (opts['no-subagents'] && conv.isSubagent) pass = false;
66
+ else if (opts['only-subagents'] && !conv.isSubagent) pass = false;
67
+ else if (opts['no-meta'] && block.isMeta) pass = false;
68
+ else if (opts['only-meta'] && !block.isMeta) pass = false;
69
+ else {
70
+ const text = blockText(block);
71
+ if (greps.length && !greps.every(r => r.test(text))) pass = false;
72
+ else if (igreps.length && igreps.some(r => r.test(text))) pass = false;
73
+ }
74
+ return opts.invert ? !pass : pass;
75
+ };
76
+ }
77
+
78
+ export const DEFAULT_PRESETS = [
79
+ { id: 'recent', label: 'Last 24h', query: { since: '24h', isMeta: false } },
80
+ { id: 'week', label: 'Last 7d', query: { since: '7d', isMeta: false } },
81
+ { id: 'errors', label: 'Errors', query: { since: '7d', isError: true } },
82
+ { id: 'tools', label: 'Tool calls', query: { since: '24h', type: 'tool_use' } },
83
+ { id: 'user-turns', label: 'User turns', query: { since: '7d', role: 'user', type: 'text', isMeta: false } },
84
+ { id: 'assistant', label: 'Assistant text', query: { since: '24h', role: 'assistant', type: 'text' } },
85
+ { id: 'subagents', label: 'Subagents', query: { since: '7d', isSubagent: true } },
86
+ { id: 'all', label: 'All time', query: {} },
87
+ ];
88
+
89
+ export const DEFAULT_ACTIVE = 'recent';
package/src/gui-server.js CHANGED
@@ -4,9 +4,12 @@ import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { JsonlReplayer, JsonlWatcher } from './index.js';
6
6
  import { buildIndex, search, snippet, tokenize } from './bm25.js';
7
+ import { DEFAULT_PRESETS, DEFAULT_ACTIVE } from './filters.js';
7
8
 
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  const GUI_DIR = path.join(__dirname, '..', 'gui');
11
+
12
+ export const DEFAULT_FILTERS = { active: DEFAULT_ACTIVE, presets: DEFAULT_PRESETS };
10
13
  const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml' };
11
14
 
12
15
  function blockText(b) {
@@ -34,6 +37,7 @@ function flattenEvent(ev, idx) {
34
37
  tool: b.name || null,
35
38
  text: blockText(b),
36
39
  isError: !!b.is_error || ev.role === 'streaming_error',
40
+ isMeta: !!b.isMeta,
37
41
  cost: b.total_cost_usd || null,
38
42
  duration: b.duration_ms || null,
39
43
  subtype: b.subtype || null,
@@ -223,20 +227,42 @@ class Store {
223
227
  return out;
224
228
  }
225
229
 
226
- events_filtered({ role, type, project, sid, tool, since, until, limit = 200, offset = 0, q } = {}) {
230
+ events_filtered({ role, type, project, sid, tool, since, until, limit = 200, offset = 0, q, grep, igrep, isMeta, isSubagent, isError, parent } = {}) {
227
231
  let arr = this.events;
232
+ let greRe = null, igreRe = null;
233
+ try {
234
+ if (grep) greRe = new RegExp(grep, 'i');
235
+ if (igrep) igreRe = new RegExp(igrep, 'i');
236
+ } catch (e) {
237
+ return { total: 0, rows: [], error: `invalid regex: ${e.message}` };
238
+ }
228
239
  if (q) {
229
- const tokens = new Set(tokenize(q));
230
- arr = arr.filter(e => { const t = tokenize(e.text); return [...tokens].every(x => t.includes(x)); });
240
+ const tokens = [...new Set(tokenize(q))];
241
+ if (tokens.length) {
242
+ arr = arr.filter(e => { const t = tokenize(e.text); return tokens.every(x => t.includes(x)); });
243
+ } else {
244
+ // q tokenized to nothing (too short / stopwords) → substring fallback
245
+ const needle = String(q).toLowerCase();
246
+ arr = arr.filter(e => (e.text || '').toLowerCase().includes(needle));
247
+ }
231
248
  }
232
249
  arr = arr.filter(e => {
233
250
  if (role && e.role !== role) return false;
234
251
  if (type && e.type !== type) return false;
235
252
  if (project && e.project !== project) return false;
236
253
  if (sid && !e.sid.startsWith(sid)) return false;
254
+ if (parent && e.parent !== parent) return false;
237
255
  if (tool && e.tool !== tool) return false;
238
256
  if (since && e.ts < since) return false;
239
257
  if (until && e.ts > until) return false;
258
+ if (isMeta === true && !e.isMeta) return false;
259
+ if (isMeta === false && e.isMeta) return false;
260
+ if (isSubagent === true && !e.isSubagent) return false;
261
+ if (isSubagent === false && e.isSubagent) return false;
262
+ if (isError === true && !e.isError) return false;
263
+ if (isError === false && e.isError) return false;
264
+ if (greRe && !greRe.test(e.text || '')) return false;
265
+ if (igreRe && igreRe.test(e.text || '')) return false;
240
266
  return true;
241
267
  });
242
268
  return { total: arr.length, rows: arr.slice(offset, offset + limit) };
@@ -260,14 +286,36 @@ function serveStatic(req, res) {
260
286
  });
261
287
  }
262
288
 
289
+ function parseRelTime(s) {
290
+ if (s === null || s === undefined || s === '') return 0;
291
+ const str = String(s).trim();
292
+ if (/^\d{10,}$/.test(str)) return parseInt(str, 10);
293
+ const m = /^(\d+)\s*([smhdw])$/i.exec(str);
294
+ if (m) {
295
+ const n = parseInt(m[1], 10);
296
+ const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[m[2].toLowerCase()];
297
+ return Date.now() - n * mult;
298
+ }
299
+ const t = Date.parse(str);
300
+ return Number.isFinite(t) ? t : 0;
301
+ }
302
+ function parseBool(v) {
303
+ if (v === undefined) return undefined;
304
+ if (v === 'true' || v === '1') return true;
305
+ if (v === 'false' || v === '0') return false;
306
+ return undefined;
307
+ }
263
308
  function parseQuery(u) {
264
309
  const q = {};
265
310
  for (const [k, v] of u.searchParams) q[k] = v;
266
- if (q.limit) q.limit = parseInt(q.limit, 10);
267
- if (q.offset) q.offset = parseInt(q.offset, 10);
268
- if (q.since) q.since = parseInt(q.since, 10);
269
- if (q.until) q.until = parseInt(q.until, 10);
270
- if (q.bucket) q.bucket = parseInt(q.bucket, 10);
311
+ if (q.limit !== undefined) q.limit = parseInt(q.limit, 10) || 200;
312
+ if (q.offset !== undefined) q.offset = parseInt(q.offset, 10) || 0;
313
+ if (q.since !== undefined) q.since = parseRelTime(q.since);
314
+ if (q.until !== undefined) q.until = parseRelTime(q.until);
315
+ if (q.bucket !== undefined) q.bucket = parseInt(q.bucket, 10) || 0;
316
+ for (const k of ['isMeta', 'isSubagent', 'isError']) {
317
+ if (q[k] !== undefined) q[k] = parseBool(q[k]);
318
+ }
271
319
  return q;
272
320
  }
273
321
 
@@ -291,6 +339,7 @@ export function createServer({ projectsDir, port = 0, host = '127.0.0.1' } = {})
291
339
  if (path === '/api/errors') return send(res, 200, store.errorsList());
292
340
  if (path === '/api/subagents') return send(res, 200, store.subagents());
293
341
  if (path === '/api/events') return send(res, 200, store.events_filtered(q));
342
+ if (path === '/api/defaults') return send(res, 200, DEFAULT_FILTERS);
294
343
  if (path === '/api/search') return send(res, 200, { query: q.q || '', results: q.q ? store.search(q.q, q) : [] });
295
344
  if (path === '/api/reindex') { store.rebuildIndex(); return send(res, 200, { ok: true, at: store.lastBuilt }); }
296
345
  if (path === '/api/stream') {
package/src/index.js CHANGED
@@ -3,7 +3,8 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { EventEmitter } from 'events';
5
5
 
6
- const DEFAULT_DIR = path.join(os.homedir(), '.claude', 'projects');
6
+ const DEFAULT_DIR = process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects');
7
+ export { DEFAULT_DIR };
7
8
  const DEBOUNCE_MS = 16;
8
9
 
9
10
  export class JsonlWatcher extends EventEmitter {
@@ -265,6 +266,9 @@ export function vault({ projectsDir = DEFAULT_DIR, destDir = path.join(os.homedi
265
266
  return { copied, skipped };
266
267
  }
267
268
 
269
+ export { Store, getStore, flattenEvent, blockText, DEFAULT_PROJECTS_DIR } from './store.js';
270
+ export { createHistoryRouter } from './router.js';
271
+
268
272
  export async function rollup({ projectsDir, since = 0, out, format = 'ndjson' } = {}) {
269
273
  if (!out) throw new Error('rollup: out path required');
270
274
  const r = new JsonlReplayer(projectsDir);
package/src/router.js ADDED
@@ -0,0 +1,86 @@
1
+ // Mountable Express router exposing /v1/history/* — mount at app.use('/', createHistoryRouter()).
2
+ // Express is a peer dependency. Caller must `npm i express`.
3
+ import { Store, getStore, DEFAULT_PROJECTS_DIR } from './store.js';
4
+
5
+ function loadExpress() {
6
+ // Lazy import so ccsniff doesn't hard-require express for its other exports.
7
+ // Caller (e.g. agentgui) already has express in its deps.
8
+ // eslint-disable-next-line no-undef
9
+ return import('express').then(m => m.default || m);
10
+ }
11
+
12
+ export async function createHistoryRouter({ projectsDir, store: providedStore } = {}) {
13
+ const express = await loadExpress();
14
+ const router = express.Router();
15
+ const store = providedStore || getStore(projectsDir || DEFAULT_PROJECTS_DIR);
16
+
17
+ const corsHeaders = (res) => {
18
+ res.setHeader('Access-Control-Allow-Origin', '*');
19
+ res.setHeader('Cache-Control', 'no-cache');
20
+ };
21
+
22
+ router.use((req, res, next) => { corsHeaders(res); next(); });
23
+
24
+ router.get('/v1/history/snapshot', (req, res) => {
25
+ try { res.json(store.snapshot()); }
26
+ catch (e) { res.status(500).json({ error: { message: e.message } }); }
27
+ });
28
+
29
+ router.get('/v1/history/sessions', (req, res) => {
30
+ try { res.json({ sessions: store.sessions() }); }
31
+ catch (e) { res.status(500).json({ error: { message: e.message } }); }
32
+ });
33
+
34
+ router.get('/v1/history/sessions/:sid/events', (req, res) => {
35
+ try { res.json({ sid: req.params.sid, events: store.sessionEvents(req.params.sid) }); }
36
+ catch (e) { res.status(500).json({ error: { message: e.message } }); }
37
+ });
38
+
39
+ router.get('/v1/history/search', (req, res) => {
40
+ try {
41
+ const q = req.query.q || '';
42
+ const limit = parseInt(req.query.limit, 10) || 50;
43
+ const opts = { limit };
44
+ for (const k of ['role', 'type', 'project', 'sid']) {
45
+ if (req.query[k]) opts[k] = req.query[k];
46
+ }
47
+ const results = q ? store.search(q, opts) : [];
48
+ const hits = results.map(r => ({ sid: r.sid, snippet: r.snippet, score: r.score, ts: r.ts, role: r.role, type: r.type, project: r.project, text: r.text }));
49
+ res.json({ query: q, hits, results });
50
+ } catch (e) {
51
+ res.status(500).json({ error: { message: e.message } });
52
+ }
53
+ });
54
+
55
+ router.post('/v1/history/reindex', (req, res) => {
56
+ try { store.rebuildIndex(); res.json({ ok: true, at: store.lastBuilt }); }
57
+ catch (e) { res.status(500).json({ error: { message: e.message } }); }
58
+ });
59
+
60
+ router.get('/v1/history/stream', (req, res) => {
61
+ res.writeHead(200, {
62
+ 'Content-Type': 'text/event-stream',
63
+ 'Cache-Control': 'no-cache',
64
+ 'Connection': 'keep-alive',
65
+ 'Access-Control-Allow-Origin': '*',
66
+ });
67
+ res.write('event: hello\ndata: {}\n\n');
68
+ store.sseClients.add(res);
69
+ req.on('close', () => store.sseClients.delete(res));
70
+ });
71
+
72
+ // Compatibility GET reindex (some clients may call GET).
73
+ router.get('/v1/history/reindex', (req, res) => {
74
+ try { store.rebuildIndex(); res.json({ ok: true, at: store.lastBuilt }); }
75
+ catch (e) { res.status(500).json({ error: { message: e.message } }); }
76
+ });
77
+
78
+ router.get('/v1/history', (req, res) => {
79
+ try { res.json(store.snapshot()); }
80
+ catch (e) { res.status(500).json({ error: { message: e.message } }); }
81
+ });
82
+
83
+ return router;
84
+ }
85
+
86
+ export { Store, getStore, DEFAULT_PROJECTS_DIR };
package/src/store.js ADDED
@@ -0,0 +1,294 @@
1
+ // Shared store for both legacy /api/* (gui-server.js) and new /v1/history/* (router.js).
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { JsonlReplayer, JsonlWatcher } from './index.js';
5
+ import { buildIndex, search, snippet, tokenize } from './bm25.js';
6
+
7
+ export const DEFAULT_PROJECTS_DIR =
8
+ process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects');
9
+
10
+ export function blockText(b) {
11
+ if (!b) return '';
12
+ if (typeof b.text === 'string') return b.text;
13
+ if (typeof b.content === 'string') return b.content;
14
+ if (Array.isArray(b.content)) return b.content.map(c => c?.text || '').join('');
15
+ if (b.input) { try { return JSON.stringify(b.input); } catch { return ''; } }
16
+ return '';
17
+ }
18
+
19
+ export function flattenEvent(ev, idx) {
20
+ const c = ev.conversation || {};
21
+ const b = ev.block || {};
22
+ return {
23
+ i: idx,
24
+ ts: ev.timestamp || 0,
25
+ sid: c.id || '',
26
+ parent: c.parentSid || null,
27
+ cwd: c.cwd || '',
28
+ project: path.basename(c.cwd || ''),
29
+ isSubagent: !!c.isSubagent,
30
+ role: ev.role,
31
+ type: b.type || null,
32
+ tool: b.name || null,
33
+ text: blockText(b),
34
+ isError: !!b.is_error || ev.role === 'streaming_error',
35
+ isMeta: !!b.isMeta,
36
+ cost: b.total_cost_usd || null,
37
+ duration: b.duration_ms || null,
38
+ subtype: b.subtype || null,
39
+ model: b.model || null,
40
+ };
41
+ }
42
+
43
+ export class Store {
44
+ constructor(projectsDir) {
45
+ this.projectsDir = projectsDir || DEFAULT_PROJECTS_DIR;
46
+ this.events = [];
47
+ this.errors = [];
48
+ this.fileBytes = 0;
49
+ this.fileCount = 0;
50
+ this.index = null;
51
+ this.lastBuilt = 0;
52
+ this.watcher = null;
53
+ this.sseClients = new Set();
54
+ this.convs = new Map();
55
+ }
56
+
57
+ loadOnce() {
58
+ const r = new JsonlReplayer(this.projectsDir);
59
+ let i = 0;
60
+ r.on('conversation_created', ev => this.convs.set(ev.conversation.id, ev.conversation));
61
+ r.on('streaming_progress', ev => { this.events.push(flattenEvent(ev, i++)); });
62
+ r.on('streaming_error', ev => { this.errors.push({ ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable }); });
63
+ const stats = r.replay({});
64
+ this.fileCount = stats.files;
65
+ this.rebuildIndex();
66
+ return stats;
67
+ }
68
+
69
+ rebuildIndex() {
70
+ this.index = buildIndex(this.events, e => e.text);
71
+ this.lastBuilt = Date.now();
72
+ }
73
+
74
+ startLive() {
75
+ if (this.watcher) return;
76
+ this.watcher = new JsonlWatcher(this.projectsDir);
77
+ this.watcher.on('conversation_created', ev => {
78
+ this.convs.set(ev.conversation.id, ev.conversation);
79
+ this.broadcast('conversation', { conv: ev.conversation, ts: ev.timestamp });
80
+ });
81
+ this.watcher.on('streaming_progress', ev => {
82
+ const fl = flattenEvent(ev, this.events.length);
83
+ this.events.push(fl);
84
+ this.broadcast('event', { sid: fl.sid, payload: fl });
85
+ });
86
+ this.watcher.on('streaming_error', ev => {
87
+ const e = { ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable };
88
+ this.errors.push(e);
89
+ this.broadcast('error', e);
90
+ });
91
+ this.watcher.on('streaming_start', ev => this.broadcast('start', { sid: ev.conversationId, ts: ev.timestamp }));
92
+ this.watcher.on('streaming_complete', ev => this.broadcast('complete', { sid: ev.conversationId, ts: ev.timestamp }));
93
+ this.watcher.start();
94
+ }
95
+
96
+ stop() {
97
+ if (this.watcher) this.watcher.stop();
98
+ for (const r of this.sseClients) try { r.end(); } catch {}
99
+ this.sseClients.clear();
100
+ }
101
+
102
+ broadcast(kind, data) {
103
+ const payload = `event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`;
104
+ for (const res of this.sseClients) { try { res.write(payload); } catch {} }
105
+ }
106
+
107
+ snapshot() {
108
+ const sids = new Set(), projects = new Set(), tools = new Map();
109
+ let earliest = Infinity, latest = 0, bytes = 0;
110
+ for (const e of this.events) {
111
+ sids.add(e.sid); if (e.project) projects.add(e.project);
112
+ if (e.tool) tools.set(e.tool, (tools.get(e.tool) || 0) + 1);
113
+ if (e.ts < earliest) earliest = e.ts;
114
+ if (e.ts > latest) latest = e.ts;
115
+ bytes += (e.text || '').length;
116
+ }
117
+ return {
118
+ events: this.events.length,
119
+ sessions: sids.size,
120
+ projects: projects.size,
121
+ tools: tools.size,
122
+ errors: this.errors.length,
123
+ files: this.fileCount,
124
+ bytes,
125
+ earliest: earliest === Infinity ? 0 : earliest,
126
+ latest,
127
+ dateRange: { earliest: earliest === Infinity ? 0 : earliest, latest },
128
+ indexedAt: this.lastBuilt,
129
+ };
130
+ }
131
+
132
+ sessions() {
133
+ const map = new Map();
134
+ for (const e of this.events) {
135
+ let s = map.get(e.sid);
136
+ if (!s) {
137
+ const conv = this.convs.get(e.sid) || {};
138
+ s = { sid: e.sid, title: conv.title || '', project: e.project, cwd: e.cwd, parent: e.parent, isSubagent: e.isSubagent, first: e.ts, last: e.ts, events: 0, tools: 0, userTurns: 0, cost: 0, errors: 0 };
139
+ map.set(e.sid, s);
140
+ }
141
+ s.events++;
142
+ if (e.ts < s.first) s.first = e.ts;
143
+ if (e.ts > s.last) s.last = e.ts;
144
+ if (e.type === 'tool_use') s.tools++;
145
+ if (e.role === 'user' && e.type === 'text') s.userTurns++;
146
+ if (e.cost) s.cost += e.cost;
147
+ if (e.isError) s.errors++;
148
+ }
149
+ return [...map.values()].sort((a, b) => b.last - a.last);
150
+ }
151
+
152
+ sessionEvents(sid) {
153
+ return this.events.filter(e => e.sid === sid);
154
+ }
155
+
156
+ projects() {
157
+ const map = new Map();
158
+ for (const e of this.events) {
159
+ if (!e.project) continue;
160
+ let p = map.get(e.project);
161
+ if (!p) { p = { project: e.project, sessions: new Set(), events: 0, tools: 0, last: 0, errors: 0, cost: 0 }; map.set(e.project, p); }
162
+ p.events++;
163
+ p.sessions.add(e.sid);
164
+ if (e.type === 'tool_use') p.tools++;
165
+ if (e.ts > p.last) p.last = e.ts;
166
+ if (e.cost) p.cost += e.cost;
167
+ if (e.isError) p.errors++;
168
+ }
169
+ return [...map.values()].map(p => ({ ...p, sessions: p.sessions.size })).sort((a, b) => b.last - a.last);
170
+ }
171
+
172
+ tools() {
173
+ const map = new Map();
174
+ for (const e of this.events) {
175
+ if (!e.tool) continue;
176
+ let t = map.get(e.tool);
177
+ if (!t) { t = { tool: e.tool, count: 0, sessions: new Set(), errors: 0, last: 0 }; map.set(e.tool, t); }
178
+ t.count++;
179
+ t.sessions.add(e.sid);
180
+ if (e.isError) t.errors++;
181
+ if (e.ts > t.last) t.last = e.ts;
182
+ }
183
+ return [...map.values()].map(t => ({ ...t, sessions: t.sessions.size })).sort((a, b) => b.count - a.count);
184
+ }
185
+
186
+ timeline(bucketMs = 3600_000) {
187
+ const buckets = new Map();
188
+ for (const e of this.events) {
189
+ const k = Math.floor(e.ts / bucketMs) * bucketMs;
190
+ let b = buckets.get(k);
191
+ if (!b) { b = { t: k, events: 0, tools: 0, errors: 0, sessions: new Set() }; buckets.set(k, b); }
192
+ b.events++;
193
+ if (e.type === 'tool_use') b.tools++;
194
+ if (e.isError) b.errors++;
195
+ b.sessions.add(e.sid);
196
+ }
197
+ return [...buckets.values()].map(b => ({ ...b, sessions: b.sessions.size })).sort((a, b) => a.t - b.t);
198
+ }
199
+
200
+ stats() {
201
+ const role = {}, type = {}, model = {};
202
+ let cost = 0, dur = 0, results = 0;
203
+ for (const e of this.events) {
204
+ role[e.role || '?'] = (role[e.role || '?'] || 0) + 1;
205
+ type[e.type || '?'] = (type[e.type || '?'] || 0) + 1;
206
+ if (e.model) model[e.model] = (model[e.model] || 0) + 1;
207
+ if (e.cost) { cost += e.cost; results++; }
208
+ if (e.duration) dur += e.duration;
209
+ }
210
+ return { role, type, model, totalCostUsd: cost, totalDurationMs: dur, results };
211
+ }
212
+
213
+ errorsList() { return this.errors.slice(-200).reverse(); }
214
+
215
+ subagents() {
216
+ const tree = new Map();
217
+ for (const e of this.events) {
218
+ if (!e.isSubagent) continue;
219
+ const parent = e.parent || 'orphan';
220
+ let p = tree.get(parent);
221
+ if (!p) { p = { parent, children: new Map() }; tree.set(parent, p); }
222
+ let c = p.children.get(e.sid);
223
+ if (!c) { c = { sid: e.sid, project: e.project, events: 0, last: 0 }; p.children.set(e.sid, c); }
224
+ c.events++;
225
+ if (e.ts > c.last) c.last = e.ts;
226
+ }
227
+ return [...tree.values()].map(p => ({ parent: p.parent, children: [...p.children.values()] }));
228
+ }
229
+
230
+ search(q, { limit = 50, role, type, project, sid } = {}) {
231
+ if (!this.index) this.rebuildIndex();
232
+ const hits = search(this.index, q, { limit: limit * 4 });
233
+ const out = [];
234
+ for (const h of hits) {
235
+ const e = this.events[h.i];
236
+ if (role && e.role !== role) continue;
237
+ if (type && e.type !== type) continue;
238
+ if (project && e.project !== project) continue;
239
+ if (sid && !e.sid.startsWith(sid)) continue;
240
+ out.push({ ...e, score: h.score, terms: h.terms, snippet: snippet(e.text, h.terms) });
241
+ if (out.length >= limit) break;
242
+ }
243
+ return out;
244
+ }
245
+
246
+ events_filtered({ role, type, project, sid, tool, since, until, limit = 200, offset = 0, q, grep, igrep, isMeta, isSubagent, isError, parent } = {}) {
247
+ let arr = this.events;
248
+ let greRe = null, igreRe = null;
249
+ try {
250
+ if (grep) greRe = new RegExp(grep, 'i');
251
+ if (igrep) igreRe = new RegExp(igrep, 'i');
252
+ } catch (e) {
253
+ return { total: 0, rows: [], error: `invalid regex: ${e.message}` };
254
+ }
255
+ if (q) {
256
+ const tokens = [...new Set(tokenize(q))];
257
+ if (tokens.length) {
258
+ arr = arr.filter(e => { const t = tokenize(e.text); return tokens.every(x => t.includes(x)); });
259
+ } else {
260
+ const needle = String(q).toLowerCase();
261
+ arr = arr.filter(e => (e.text || '').toLowerCase().includes(needle));
262
+ }
263
+ }
264
+ arr = arr.filter(e => {
265
+ if (role && e.role !== role) return false;
266
+ if (type && e.type !== type) return false;
267
+ if (project && e.project !== project) return false;
268
+ if (sid && !e.sid.startsWith(sid)) return false;
269
+ if (parent && e.parent !== parent) return false;
270
+ if (tool && e.tool !== tool) return false;
271
+ if (since && e.ts < since) return false;
272
+ if (until && e.ts > until) return false;
273
+ if (isMeta === true && !e.isMeta) return false;
274
+ if (isMeta === false && e.isMeta) return false;
275
+ if (isSubagent === true && !e.isSubagent) return false;
276
+ if (isSubagent === false && e.isSubagent) return false;
277
+ if (isError === true && !e.isError) return false;
278
+ if (isError === false && e.isError) return false;
279
+ if (greRe && !greRe.test(e.text || '')) return false;
280
+ if (igreRe && igreRe.test(e.text || '')) return false;
281
+ return true;
282
+ });
283
+ return { total: arr.length, rows: arr.slice(offset, offset + limit) };
284
+ }
285
+ }
286
+
287
+ let _shared = null;
288
+ export function getStore(projectsDir) {
289
+ if (_shared) return _shared;
290
+ _shared = new Store(projectsDir);
291
+ _shared.loadOnce();
292
+ _shared.startLive();
293
+ return _shared;
294
+ }