ccsniff 1.1.17 → 1.1.19
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/package.json +1 -1
- package/src/index.js +12 -2
- package/src/store.js +55 -8
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -195,7 +195,7 @@ export function watch(projectsDir) {
|
|
|
195
195
|
export class JsonlReplayer extends JsonlWatcher {
|
|
196
196
|
constructor(projectsDir = DEFAULT_DIR) { super(projectsDir); }
|
|
197
197
|
|
|
198
|
-
replay({ since = 0, files: fileFilter = null } = {}) {
|
|
198
|
+
replay({ since = 0, files: fileFilter = null, maxEvents = 0 } = {}) {
|
|
199
199
|
const all = [];
|
|
200
200
|
const collect = (dir, depth) => {
|
|
201
201
|
if (depth > 5) return;
|
|
@@ -208,9 +208,19 @@ export class JsonlReplayer extends JsonlWatcher {
|
|
|
208
208
|
} catch {}
|
|
209
209
|
};
|
|
210
210
|
if (fs.existsSync(this._dir)) collect(this._dir, 0);
|
|
211
|
-
|
|
211
|
+
let chosen = fileFilter ? all.filter(fileFilter) : all;
|
|
212
|
+
// When a maxEvents budget is set, read newest files first and stop once the
|
|
213
|
+
// budget is met — so a huge projects tree never has its full backlog parsed
|
|
214
|
+
// into the heap at once (the load-time memory peak this guards against).
|
|
215
|
+
if (maxEvents > 0) {
|
|
216
|
+
chosen = chosen
|
|
217
|
+
.map(fp => { try { return { fp, m: fs.statSync(fp).mtimeMs }; } catch { return { fp, m: 0 }; } })
|
|
218
|
+
.sort((a, b) => b.m - a.m)
|
|
219
|
+
.map(x => x.fp);
|
|
220
|
+
}
|
|
212
221
|
let emitted = 0;
|
|
213
222
|
for (const fp of chosen) {
|
|
223
|
+
if (maxEvents > 0 && emitted >= maxEvents) break;
|
|
214
224
|
const fallbackSid = path.basename(fp, '.jsonl');
|
|
215
225
|
let data;
|
|
216
226
|
try { data = fs.readFileSync(fp, 'utf8'); } catch { continue; }
|
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
|
-
|
|
13
|
-
if (typeof b.
|
|
14
|
-
if (
|
|
15
|
-
if (b.
|
|
16
|
-
|
|
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,47 @@ 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
|
-
|
|
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
|
+
// Bound the replay to a little above the retention cap and read newest-first,
|
|
101
|
+
// so a large projects tree never parses its entire backlog into the heap at
|
|
102
|
+
// once. The post-replay trim then lands exactly on maxEvents.
|
|
103
|
+
const stats = r.replay({ maxEvents: Math.floor(this.maxEvents * 1.2) });
|
|
64
104
|
this.fileCount = stats.files;
|
|
105
|
+
// Newest-first file read can leave events out of chronological order; restore
|
|
106
|
+
// it (downstream sessions/search/snippet logic assumes ascending ts) and
|
|
107
|
+
// renumber the positional index the BM25 index keys on.
|
|
108
|
+
this.events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
109
|
+
this.events.forEach((e, k) => { e.i = k; });
|
|
110
|
+
this.trimEvents();
|
|
65
111
|
this.rebuildIndex();
|
|
66
112
|
return stats;
|
|
67
113
|
}
|
|
@@ -82,6 +128,7 @@ export class Store {
|
|
|
82
128
|
const fl = flattenEvent(ev, this.events.length);
|
|
83
129
|
this.events.push(fl);
|
|
84
130
|
this.broadcast('event', { sid: fl.sid, payload: fl });
|
|
131
|
+
this.trimEvents();
|
|
85
132
|
});
|
|
86
133
|
this.watcher.on('streaming_error', ev => {
|
|
87
134
|
const e = { ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable };
|