ccsniff 1.1.16 → 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.
- package/package.json +1 -1
- package/src/cli.js +28 -3
- package/src/store.js +46 -7
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -383,6 +383,23 @@ if (opts['search-discipline']) {
|
|
|
383
383
|
// A search-tool token inside a quoted string (echo/printf/node -e payloads) is text, not a shell
|
|
384
384
|
// invocation; blank quoted bodies before matching, like git-discipline strips commit-message bodies.
|
|
385
385
|
const stripQuoted = (s) => s.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/'(?:\\.|[^'\\])*'/g, "''");
|
|
386
|
+
// codesearch indexes ONLY the conversation's own cwd (the gm repo). A search whose target is a
|
|
387
|
+
// sibling repo outside cwd has NO codesearch index to route through, so the agent is forced to
|
|
388
|
+
// native search and flagging it is a false positive. Exempt a line that targets an absolute path
|
|
389
|
+
// or cd's into a directory that is not under the conversation cwd.
|
|
390
|
+
const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
391
|
+
const targetsOutsideCwd = (line, cwd) => {
|
|
392
|
+
const cwdN = normPath(cwd);
|
|
393
|
+
if (!cwdN) return false;
|
|
394
|
+
const stripped = stripQuoted(line);
|
|
395
|
+
// explicit `cd <dir>` to a path outside cwd
|
|
396
|
+
const cdM = stripped.match(/(?:^|[|&;]\s*)cd\s+([^\s|&;]+)/i);
|
|
397
|
+
if (cdM) { const d = normPath(cdM[1]); if (d.startsWith('/') || /^[a-z]:/.test(d)) { if (!d.startsWith(cwdN)) return true; } }
|
|
398
|
+
// absolute path argument to the search tool that is outside cwd
|
|
399
|
+
const absArgs = stripped.match(/(?:^|\s)((?:[a-z]:)?\/[^\s|&;"']+)/gi) || [];
|
|
400
|
+
for (const a of absArgs) { const d = normPath(a.trim()); if ((d.startsWith('/') || /^[a-z]:/.test(d)) && !d.startsWith(cwdN)) return true; }
|
|
401
|
+
return false;
|
|
402
|
+
};
|
|
386
403
|
const violations = [];
|
|
387
404
|
for (const ev of all) {
|
|
388
405
|
if (!filter(ev)) continue;
|
|
@@ -393,8 +410,14 @@ if (opts['search-discipline']) {
|
|
|
393
410
|
const ts = ev.timestamp, sid = ev.conversation?.id || '';
|
|
394
411
|
let kind = null, detail = '';
|
|
395
412
|
if (name === 'Grep' || name === 'Glob') {
|
|
396
|
-
|
|
397
|
-
|
|
413
|
+
// A Grep/Glob whose path points outside the cwd targets a sibling repo with no codesearch
|
|
414
|
+
// index — exempt it, same as a cross-repo bash search.
|
|
415
|
+
const gp = ev.block?.input?.path;
|
|
416
|
+
if (gp && targetsOutsideCwd(gp, ev.conversation?.cwd)) { /* cross-repo, exempt */ }
|
|
417
|
+
else {
|
|
418
|
+
kind = `native-search-${name.toLowerCase()}`;
|
|
419
|
+
detail = (ev.block?.input?.pattern || ev.block?.input?.query || '').slice(0, 120);
|
|
420
|
+
}
|
|
398
421
|
} else if (name === 'Task' || name === 'Agent') {
|
|
399
422
|
const sub = (ev.block?.input?.subagent_type || ev.block?.input?.description || '').toLowerCase();
|
|
400
423
|
if (/explore|search|general-purpose/.test(sub)) {
|
|
@@ -409,7 +432,9 @@ if (opts['search-discipline']) {
|
|
|
409
432
|
// tree directly), never one immediately downstream of a pipe.
|
|
410
433
|
const isTreeSearchLine = (line) => BASH_SEARCH.test(stripQuoted(line).split('|')[0]);
|
|
411
434
|
const hitLine = cmd.split('\n').find(isTreeSearchLine);
|
|
412
|
-
|
|
435
|
+
// Exempt a tree-search line that targets a sibling repo outside cwd (no codesearch index exists
|
|
436
|
+
// for it). Each command may `cd` first, so evaluate the cd context on the same line.
|
|
437
|
+
if (hitLine && !targetsOutsideCwd(hitLine, ev.conversation?.cwd)) {
|
|
413
438
|
kind = 'native-search-bash';
|
|
414
439
|
detail = (hitLine.split('|')[0]).trim().slice(0, 120);
|
|
415
440
|
}
|
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,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
|
-
|
|
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 };
|