ccsniff 1.1.17 → 1.1.18

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/store.js +46 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccsniff",
3
- "version": "1.1.17",
3
+ "version": "1.1.18",
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",
package/src/store.js CHANGED
@@ -7,13 +7,20 @@ import { buildIndex, search, snippet, tokenize } from './bm25.js';
7
7
  export const DEFAULT_PROJECTS_DIR =
8
8
  process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects');
9
9
 
10
+ // Per-event retained-text ceiling. Tool outputs (file reads, command stdout) can
11
+ // be many KB each; held in full across tens of thousands of events they dominate
12
+ // the in-memory footprint. A generous cap preserves search tokens and snippet
13
+ // windows while bounding the heap. Override via CCSNIFF_MAX_TEXT.
14
+ const MAX_TEXT = parseInt(process.env.CCSNIFF_MAX_TEXT || '', 10) || 8192;
15
+
10
16
  export function blockText(b) {
11
17
  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 '';
18
+ let t = '';
19
+ if (typeof b.text === 'string') t = b.text;
20
+ else if (typeof b.content === 'string') t = b.content;
21
+ else if (Array.isArray(b.content)) t = b.content.map(c => c?.text || '').join('');
22
+ else if (b.input) { try { t = JSON.stringify(b.input); } catch { t = ''; } }
23
+ return t.length > MAX_TEXT ? t.slice(0, MAX_TEXT) : t;
17
24
  }
18
25
 
19
26
  export function flattenEvent(ev, idx) {
@@ -40,8 +47,16 @@ export function flattenEvent(ev, idx) {
40
47
  };
41
48
  }
42
49
 
50
+ // Cap retained in-memory events so a long-lived server watching an active
51
+ // ~/.claude/projects tree does not grow without bound. The live watcher appends
52
+ // every new event for the life of the process; without a ceiling the heap climbs
53
+ // until OOM. Oldest events are evicted in batches (front-splice is O(n), so we
54
+ // drop a slab at once and rebuild the search index lazily on next search).
55
+ const DEFAULT_MAX_EVENTS = parseInt(process.env.CCSNIFF_MAX_EVENTS || '', 10) || 15000;
56
+ const EVICT_BATCH_FRACTION = 0.1;
57
+
43
58
  export class Store {
44
- constructor(projectsDir) {
59
+ constructor(projectsDir, { maxEvents } = {}) {
45
60
  this.projectsDir = projectsDir || DEFAULT_PROJECTS_DIR;
46
61
  this.events = [];
47
62
  this.errors = [];
@@ -52,16 +67,39 @@ export class Store {
52
67
  this.watcher = null;
53
68
  this.sseClients = new Set();
54
69
  this.convs = new Map();
70
+ this.maxEvents = maxEvents || DEFAULT_MAX_EVENTS;
71
+ }
72
+
73
+ // Drop oldest events once the cap is exceeded. Evicting shifts array indices,
74
+ // so the BM25 index (which maps by position) is invalidated and rebuilt lazily.
75
+ trimEvents() {
76
+ if (this.events.length > this.maxEvents) {
77
+ const drop = Math.max(this.events.length - this.maxEvents, Math.floor(this.maxEvents * EVICT_BATCH_FRACTION));
78
+ this.events.splice(0, drop);
79
+ this.index = null;
80
+ }
81
+ // errors are capped independently of events (a burst of errors must not be
82
+ // gated behind the events ceiling).
83
+ if (this.errors.length > this.maxEvents) this.errors.splice(0, this.errors.length - this.maxEvents);
55
84
  }
56
85
 
57
86
  loadOnce() {
58
87
  const r = new JsonlReplayer(this.projectsDir);
59
88
  let i = 0;
60
89
  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++)); });
90
+ // Trim during replay (not only after) so the full backlog never materializes
91
+ // at once — that one-shot peak, not steady-state, is what spikes RSS on a
92
+ // large ~/.claude/projects tree. A soft headroom above maxEvents keeps the
93
+ // trim from running on every push.
94
+ const softCap = Math.floor(this.maxEvents * 1.25);
95
+ r.on('streaming_progress', ev => {
96
+ this.events.push(flattenEvent(ev, i++));
97
+ if (this.events.length > softCap) this.events.splice(0, this.events.length - this.maxEvents);
98
+ });
62
99
  r.on('streaming_error', ev => { this.errors.push({ ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable }); });
63
100
  const stats = r.replay({});
64
101
  this.fileCount = stats.files;
102
+ this.trimEvents();
65
103
  this.rebuildIndex();
66
104
  return stats;
67
105
  }
@@ -82,6 +120,7 @@ export class Store {
82
120
  const fl = flattenEvent(ev, this.events.length);
83
121
  this.events.push(fl);
84
122
  this.broadcast('event', { sid: fl.sid, payload: fl });
123
+ this.trimEvents();
85
124
  });
86
125
  this.watcher.on('streaming_error', ev => {
87
126
  const e = { ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable };