viberag 0.7.0 → 0.8.0

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.
@@ -0,0 +1,172 @@
1
+ # Memory Diagnostics Scripts
2
+
3
+ This folder contains repeatable memory diagnostics for daemon + MCP search/indexing workloads.
4
+
5
+ Reference ADR: `adr/018-adr-memory-lifecycle-and-regression-guardrails.md`
6
+
7
+ ## Scripts
8
+
9
+ ### `profile.mjs`
10
+
11
+ Purpose:
12
+
13
+ - Repeated in-process memory profiling for chunking and indexing loops.
14
+ - Useful for post-GC drift checks while iterating on lifecycle fixes.
15
+
16
+ Run:
17
+
18
+ ```bash
19
+ npm run memory:profile
20
+ ```
21
+
22
+ Optional env:
23
+
24
+ - `VIBERAG_MEM_RUNS` (default `3`)
25
+ - `VIBERAG_MEM_ITERATIONS` (default `200`)
26
+
27
+ Notes:
28
+
29
+ - Uses `--expose-gc` via npm script.
30
+ - Prints JSON samples (`rssMB`, `heapUsedMB`, `externalMB`, `arrayBuffersMB`).
31
+
32
+ ### `isolate-intent-growth.mjs`
33
+
34
+ Purpose:
35
+
36
+ - Reproduces memory behavior per search intent on a fresh daemon process.
37
+ - Kills daemon between scenarios to avoid cross-run contamination.
38
+ - Captures before/after RSS from both `ps` and daemon status.
39
+
40
+ Run:
41
+
42
+ ```bash
43
+ npm run memory:isolate
44
+ ```
45
+
46
+ Useful env:
47
+
48
+ - `VIBERAG_ISOLATE_QUERY`
49
+ - `VIBERAG_ISOLATE_INTENTS` (comma-separated, e.g. `exact_text,concept`)
50
+ - `VIBERAG_ISOLATE_K` (clamped to `1..100`)
51
+ - `VIBERAG_ISOLATE_EXPLAIN` (`1` or `0`)
52
+ - `VIBERAG_ISOLATE_REQUEST_TIMEOUT_MS`
53
+ - `VIBERAG_ISOLATE_POST_SAMPLE_DELAY_MS`
54
+ - `VIBERAG_ISOLATE_VMMAP_TRIGGER_MB`
55
+
56
+ Example:
57
+
58
+ ```bash
59
+ VIBERAG_ISOLATE_QUERY='indexing memory lifecycle ownership contract cleanup' \
60
+ VIBERAG_ISOLATE_INTENTS='exact_text' \
61
+ VIBERAG_ISOLATE_K=100 \
62
+ npm run memory:isolate
63
+ ```
64
+
65
+ ### `stress-search-growth.mjs`
66
+
67
+ Purpose:
68
+
69
+ - High-concurrency search stress test with periodic memory sampling.
70
+ - Supports optional indexing churn and safety guards.
71
+
72
+ Run:
73
+
74
+ ```bash
75
+ npm run memory:stress
76
+ ```
77
+
78
+ Safety defaults:
79
+
80
+ - Hard guard: `VIBERAG_STRESS_MAX_RSS_MB=10240` (10 GB)
81
+ - Soft guard: `VIBERAG_STRESS_SOFT_RSS_MB=8192` (80% of hard cap by default)
82
+ - On guard trigger, script cancels work and requests daemon shutdown.
83
+
84
+ Key env:
85
+
86
+ - `VIBERAG_STRESS_SECONDS`
87
+ - `VIBERAG_STRESS_PARALLELISM`
88
+ - `VIBERAG_STRESS_MAX_IN_FLIGHT`
89
+ - `VIBERAG_STRESS_K` (clamped to `1..100`)
90
+ - `VIBERAG_STRESS_MAX_RSS_MB`
91
+ - `VIBERAG_STRESS_SOFT_RSS_MB`
92
+ - `VIBERAG_STRESS_GUARD_KILL` (`1` default, `0` disables SIGTERM fallback)
93
+ - `VIBERAG_STRESS_INDEX_CHURN_SECONDS`
94
+ - `VIBERAG_STRESS_INDEX_FORCE`
95
+
96
+ ### `watch-reindex-stress.mjs`
97
+
98
+ Purpose:
99
+
100
+ - Sustained watcher-driven reindex stress via parallel continuous file edits.
101
+ - Simulates real file-change churn with high write rates over many probe files.
102
+ - Tracks daemon RSS/external memory, watcher pending queue pressure, and index update cadence.
103
+ - Includes hard/soft RSS guards to avoid host OOM during investigation.
104
+
105
+ Run:
106
+
107
+ ```bash
108
+ npm run memory:watch-stress
109
+ ```
110
+
111
+ Key env:
112
+
113
+ - `VIBERAG_WATCH_STRESS_SECONDS` (default `60`)
114
+ - `VIBERAG_WATCH_STRESS_WRITERS` (default `48`)
115
+ - `VIBERAG_WATCH_STRESS_FILES` (default `96`)
116
+ - `VIBERAG_WATCH_STRESS_WRITE_INTERVAL_MS` (default `0`)
117
+ - `VIBERAG_WATCH_STRESS_CONTENT_KB` (default `2`)
118
+ - `VIBERAG_WATCH_STRESS_SAMPLE_MS` (default `500`)
119
+ - `VIBERAG_WATCH_STRESS_MAX_RSS_MB` (default `10240`)
120
+ - `VIBERAG_WATCH_STRESS_SOFT_RSS_MB` (default `80%` of max)
121
+ - `VIBERAG_WATCH_STRESS_CLEAN_START` (`1` by default, kills existing daemon first)
122
+ - `VIBERAG_WATCH_STRESS_TARGET_DIR` (default `source/daemon/__watch_stress_probe`)
123
+
124
+ Example:
125
+
126
+ ```bash
127
+ VIBERAG_WATCH_STRESS_SECONDS=60 \
128
+ VIBERAG_WATCH_STRESS_WRITERS=64 \
129
+ VIBERAG_WATCH_STRESS_FILES=128 \
130
+ VIBERAG_WATCH_STRESS_MAX_RSS_MB=10240 \
131
+ npm run memory:watch-stress
132
+ ```
133
+
134
+ ## Testing Daemon Memory Alerts
135
+
136
+ You can force local alerting by lowering daemon monitor thresholds via env vars.
137
+
138
+ Supported daemon monitor env vars:
139
+
140
+ - `VIBERAG_MEMORY_MONITOR_POLL_INTERVAL_MS` (default `3000`)
141
+ - `VIBERAG_MEMORY_MONITOR_THRESHOLD_MB` (default `4096`)
142
+ - `VIBERAG_MEMORY_MONITOR_RECOVERY_MB` (default `3584`)
143
+ - `VIBERAG_MEMORY_MONITOR_GROWTH_THRESHOLD_MB` (default `1024`)
144
+ - `VIBERAG_MEMORY_MONITOR_GROWTH_WINDOW_MS` (default `10000`)
145
+ - `VIBERAG_MEMORY_MONITOR_MIN_REPORT_INTERVAL_MS` (default `10000`)
146
+ - `VIBERAG_MEMORY_MONITOR_MAX_REPORTS_PER_DAY` (default `20`)
147
+
148
+ Example (force threshold alerts at normal RSS):
149
+
150
+ ```bash
151
+ VIBERAG_MEMORY_MONITOR_THRESHOLD_MB=200 \
152
+ VIBERAG_MEMORY_MONITOR_RECOVERY_MB=150 \
153
+ VIBERAG_STRESS_SECONDS=10 \
154
+ VIBERAG_STRESS_PARALLELISM=1 \
155
+ VIBERAG_STRESS_MAX_IN_FLIGHT=1 \
156
+ npm run memory:stress
157
+ ```
158
+
159
+ Expected signal:
160
+
161
+ - Daemon log line like:
162
+ `Memory monitor triggered Sentry report (threshold) at rss=...MB`
163
+
164
+ ## Output Files
165
+
166
+ Generated run logs are written to `scripts/memory/out/` as JSONL.
167
+
168
+ - `isolate-intent-growth-*.jsonl`
169
+ - `stress-search-growth-*.jsonl`
170
+ - `watch-reindex-stress-*.jsonl`
171
+
172
+ These generated files are intentionally gitignored. Keep `scripts/memory/out/.gitkeep` tracked so the folder exists by default.
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {execFile as execFileCb} from 'node:child_process';
6
+ import {promisify} from 'node:util';
7
+ import {fileURLToPath} from 'node:url';
8
+
9
+ const execFile = promisify(execFileCb);
10
+
11
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
12
+ const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..');
13
+ const DAEMON_ENTRY = path.join(REPO_ROOT, 'dist/daemon/index.js');
14
+ const OUTPUT_DIR = path.join(REPO_ROOT, 'scripts/memory/out');
15
+
16
+ const QUERY =
17
+ process.env['VIBERAG_ISOLATE_QUERY'] ??
18
+ 'indexing memory lifecycle ownership contract cleanup';
19
+ const K = Math.max(
20
+ 1,
21
+ Math.min(100, Number(process.env['VIBERAG_ISOLATE_K'] ?? '100')),
22
+ );
23
+ const EXPLAIN = process.env['VIBERAG_ISOLATE_EXPLAIN'] !== '0';
24
+ const REQUEST_TIMEOUT_MS = Number(
25
+ process.env['VIBERAG_ISOLATE_REQUEST_TIMEOUT_MS'] ?? '30000',
26
+ );
27
+ const POST_SAMPLE_DELAY_MS = Number(
28
+ process.env['VIBERAG_ISOLATE_POST_SAMPLE_DELAY_MS'] ?? '1500',
29
+ );
30
+ const VMAP_TRIGGER_MB = Number(
31
+ process.env['VIBERAG_ISOLATE_VMMAP_TRIGGER_MB'] ?? '1500',
32
+ );
33
+ const INTENTS = (
34
+ process.env['VIBERAG_ISOLATE_INTENTS'] ??
35
+ 'definition,concept,usage,exact_text,similar_code,auto'
36
+ )
37
+ .split(',')
38
+ .map(intent => intent.trim())
39
+ .filter(Boolean);
40
+
41
+ if (INTENTS.length === 0) {
42
+ throw new Error('VIBERAG_ISOLATE_INTENTS produced an empty intent list.');
43
+ }
44
+
45
+ function sleep(ms) {
46
+ return new Promise(resolve => setTimeout(resolve, ms));
47
+ }
48
+
49
+ function nowIso() {
50
+ return new Date().toISOString();
51
+ }
52
+
53
+ function compactError(error) {
54
+ if (error instanceof Error) return `${error.name}: ${error.message}`;
55
+ return String(error);
56
+ }
57
+
58
+ function mbFromKb(kb) {
59
+ return Number((kb / 1024).toFixed(1));
60
+ }
61
+
62
+ async function withTimeout(promise, timeoutMs, label) {
63
+ const timeout = new Promise((_, reject) => {
64
+ setTimeout(() => {
65
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
66
+ }, timeoutMs);
67
+ });
68
+ return Promise.race([promise, timeout]);
69
+ }
70
+
71
+ async function listDaemonRows() {
72
+ const {stdout} = await execFile('ps', [
73
+ '-axo',
74
+ 'pid=,rss=,vsz=,%cpu=,%mem=,state=,etime=,command=',
75
+ ]);
76
+ const rows = [];
77
+ for (const line of stdout.split('\n')) {
78
+ if (!line.includes(DAEMON_ENTRY)) continue;
79
+ const match = line.match(
80
+ /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([0-9.]+)\s+([0-9.]+)\s+(\S+)\s+(\S+)\s+(.+)$/,
81
+ );
82
+ if (!match) continue;
83
+ rows.push({
84
+ pid: Number(match[1]),
85
+ rssMB: mbFromKb(Number(match[2])),
86
+ vszMB: mbFromKb(Number(match[3])),
87
+ cpuPercent: Number(match[4]),
88
+ memPercent: Number(match[5]),
89
+ state: match[6],
90
+ etime: match[7],
91
+ command: match[8],
92
+ });
93
+ }
94
+ return rows;
95
+ }
96
+
97
+ async function killDaemonProcesses() {
98
+ const initial = await listDaemonRows();
99
+ if (initial.length === 0) {
100
+ await cleanupStaleRunArtifacts();
101
+ return {killed: [], zombieLike: []};
102
+ }
103
+
104
+ const killed = [];
105
+ for (const row of initial) {
106
+ try {
107
+ process.kill(row.pid, 'SIGTERM');
108
+ killed.push({pid: row.pid, signal: 'SIGTERM'});
109
+ } catch {}
110
+ }
111
+ await sleep(500);
112
+
113
+ const afterTerm = await listDaemonRows();
114
+ for (const row of afterTerm) {
115
+ try {
116
+ process.kill(row.pid, 'SIGKILL');
117
+ killed.push({pid: row.pid, signal: 'SIGKILL'});
118
+ } catch {}
119
+ }
120
+ await sleep(500);
121
+
122
+ const stillThere = await listDaemonRows();
123
+ if (stillThere.length === 0) {
124
+ await cleanupStaleRunArtifacts();
125
+ }
126
+ return {killed, zombieLike: stillThere.map(row => row.pid)};
127
+ }
128
+
129
+ async function cleanupStaleRunArtifacts() {
130
+ const {getDaemonLockPath, getDaemonPidPath, getDaemonSocketPath} =
131
+ await import(path.join(REPO_ROOT, 'dist/daemon/lib/constants.js'));
132
+ const stalePaths = [
133
+ getDaemonLockPath(REPO_ROOT),
134
+ getDaemonPidPath(REPO_ROOT),
135
+ getDaemonSocketPath(REPO_ROOT),
136
+ ];
137
+ for (const stalePath of stalePaths) {
138
+ try {
139
+ await fs.rm(stalePath, {force: true, recursive: true});
140
+ } catch {}
141
+ }
142
+ }
143
+
144
+ function summarizeResult(result) {
145
+ if (!result || typeof result !== 'object') {
146
+ return {bytes: 0, groups: null};
147
+ }
148
+ let bytes = 0;
149
+ try {
150
+ bytes = JSON.stringify(result).length;
151
+ } catch {}
152
+
153
+ const groups = result.groups ?? {};
154
+ return {
155
+ bytes,
156
+ groups: {
157
+ definitions: Array.isArray(groups.definitions)
158
+ ? groups.definitions.length
159
+ : 0,
160
+ files: Array.isArray(groups.files) ? groups.files.length : 0,
161
+ blocks: Array.isArray(groups.blocks) ? groups.blocks.length : 0,
162
+ usages: Array.isArray(groups.usages) ? groups.usages.length : 0,
163
+ },
164
+ };
165
+ }
166
+
167
+ async function captureVmmapSummary(pid) {
168
+ try {
169
+ const {stdout} = await withTimeout(
170
+ execFile('vmmap', ['-summary', String(pid)]),
171
+ 5000,
172
+ 'vmmap',
173
+ );
174
+ const lines = stdout.split('\n');
175
+ const picked = lines.filter(
176
+ line =>
177
+ line.includes('Physical footprint') ||
178
+ line.includes('MALLOC') ||
179
+ line.includes('VM_ALLOCATE') ||
180
+ line.includes('MALLOC_NANO') ||
181
+ line.includes('TOTAL'),
182
+ );
183
+ return picked.slice(0, 120);
184
+ } catch (error) {
185
+ return [`vmmap_error: ${compactError(error)}`];
186
+ }
187
+ }
188
+
189
+ const {DaemonClient} = await import(
190
+ path.join(REPO_ROOT, 'dist/client/index.js')
191
+ );
192
+
193
+ await fs.mkdir(OUTPUT_DIR, {recursive: true});
194
+ const runId = nowIso().replace(/[:.]/g, '-');
195
+ const outputFile = path.join(
196
+ OUTPUT_DIR,
197
+ `isolate-intent-growth-${runId}.jsonl`,
198
+ );
199
+
200
+ async function writeRow(row) {
201
+ await fs.appendFile(outputFile, `${JSON.stringify(row)}\n`, 'utf8');
202
+ }
203
+
204
+ const header = {
205
+ ts: nowIso(),
206
+ label: 'run.start',
207
+ query: QUERY,
208
+ intents: INTENTS,
209
+ k: K,
210
+ explain: EXPLAIN,
211
+ requestTimeoutMs: REQUEST_TIMEOUT_MS,
212
+ postSampleDelayMs: POST_SAMPLE_DELAY_MS,
213
+ vmmapTriggerMB: VMAP_TRIGGER_MB,
214
+ outputFile,
215
+ };
216
+ console.log(JSON.stringify(header));
217
+ await writeRow(header);
218
+
219
+ const summary = [];
220
+
221
+ for (const intent of INTENTS) {
222
+ const scenarioStart = Date.now();
223
+ const cleanupBefore = await killDaemonProcesses();
224
+ const client = new DaemonClient({
225
+ projectRoot: REPO_ROOT,
226
+ autoStart: true,
227
+ connectTimeout: 30_000,
228
+ clientSource: 'mcp',
229
+ });
230
+
231
+ let baselineStatus = null;
232
+ let baselinePs = null;
233
+ let searchOutcome = null;
234
+ let postStatus = null;
235
+ let postPs = null;
236
+ let postDelayStatus = null;
237
+ let vmmapSummary = null;
238
+ let error = null;
239
+
240
+ try {
241
+ await withTimeout(client.connect(), 30_000, 'connect');
242
+ baselineStatus = await client.status();
243
+ baselinePs = (await listDaemonRows())[0] ?? null;
244
+
245
+ const searchStarted = Date.now();
246
+ try {
247
+ const result = await withTimeout(
248
+ client.search(QUERY, {intent, k: K, explain: EXPLAIN}),
249
+ REQUEST_TIMEOUT_MS,
250
+ `search:${intent}`,
251
+ );
252
+ searchOutcome = {
253
+ ok: true,
254
+ durationMs: Date.now() - searchStarted,
255
+ ...summarizeResult(result),
256
+ };
257
+ } catch (searchError) {
258
+ searchOutcome = {
259
+ ok: false,
260
+ durationMs: Date.now() - searchStarted,
261
+ error: compactError(searchError),
262
+ };
263
+ }
264
+
265
+ try {
266
+ postStatus = await client.status();
267
+ } catch (statusError) {
268
+ error = `post_status: ${compactError(statusError)}`;
269
+ }
270
+ postPs = (await listDaemonRows())[0] ?? null;
271
+
272
+ await sleep(POST_SAMPLE_DELAY_MS);
273
+ try {
274
+ postDelayStatus = await client.status();
275
+ } catch {}
276
+
277
+ const observedPostRss = Math.max(
278
+ postStatus?.memory?.rssMB ?? 0,
279
+ postPs?.rssMB ?? 0,
280
+ );
281
+ if (observedPostRss >= VMAP_TRIGGER_MB && postPs?.pid) {
282
+ vmmapSummary = await captureVmmapSummary(postPs.pid);
283
+ }
284
+ } catch (scenarioError) {
285
+ error = compactError(scenarioError);
286
+ } finally {
287
+ await client.disconnect().catch(() => {});
288
+ }
289
+
290
+ const cleanupAfter = await killDaemonProcesses();
291
+ const row = {
292
+ ts: nowIso(),
293
+ label: 'scenario.result',
294
+ intent,
295
+ durationMs: Date.now() - scenarioStart,
296
+ baseline: {
297
+ pid: baselinePs?.pid ?? null,
298
+ rssMBPs: baselinePs?.rssMB ?? null,
299
+ rssMBStatus: baselineStatus?.memory?.rssMB ?? null,
300
+ heapUsedMBStatus: baselineStatus?.memory?.heapUsedMB ?? null,
301
+ externalMBStatus: baselineStatus?.memory?.externalMB ?? null,
302
+ },
303
+ search: searchOutcome,
304
+ post: {
305
+ pid: postPs?.pid ?? null,
306
+ rssMBPs: postPs?.rssMB ?? null,
307
+ rssMBStatus: postStatus?.memory?.rssMB ?? null,
308
+ heapUsedMBStatus: postStatus?.memory?.heapUsedMB ?? null,
309
+ externalMBStatus: postStatus?.memory?.externalMB ?? null,
310
+ indexingStatus: postStatus?.indexing?.status ?? null,
311
+ },
312
+ postDelayed: {
313
+ rssMBStatus: postDelayStatus?.memory?.rssMB ?? null,
314
+ heapUsedMBStatus: postDelayStatus?.memory?.heapUsedMB ?? null,
315
+ externalMBStatus: postDelayStatus?.memory?.externalMB ?? null,
316
+ },
317
+ cleanupBefore,
318
+ cleanupAfter,
319
+ vmmapSummary,
320
+ error,
321
+ };
322
+
323
+ summary.push(row);
324
+ console.log(JSON.stringify(row));
325
+ await writeRow(row);
326
+ }
327
+
328
+ const final = {
329
+ ts: nowIso(),
330
+ label: 'run.summary',
331
+ outputFile,
332
+ results: summary.map(row => {
333
+ const start =
334
+ Math.max(row.baseline.rssMBPs ?? 0, row.baseline.rssMBStatus ?? 0) || 0;
335
+ const end = Math.max(row.post.rssMBPs ?? 0, row.post.rssMBStatus ?? 0) || 0;
336
+ return {
337
+ intent: row.intent,
338
+ ok: row.search?.ok ?? false,
339
+ searchDurationMs: row.search?.durationMs ?? null,
340
+ searchError: row.search?.error ?? null,
341
+ startMB: start,
342
+ endMB: end,
343
+ growthMB: Number((end - start).toFixed(1)),
344
+ };
345
+ }),
346
+ };
347
+ console.log(JSON.stringify(final));
348
+ await writeRow(final);
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {fileURLToPath} from 'node:url';
7
+
8
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
9
+ const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..');
10
+ const RUNS = Number(process.env['VIBERAG_MEM_RUNS'] ?? '3');
11
+ const ITERATIONS = Number(process.env['VIBERAG_MEM_ITERATIONS'] ?? '200');
12
+
13
+ const {Chunker} = await import(
14
+ path.join(REPO_ROOT, 'dist/daemon/lib/chunker/index.js')
15
+ );
16
+ const {IndexingServiceV2} = await import(
17
+ path.join(REPO_ROOT, 'dist/daemon/services/v2/indexing.js')
18
+ );
19
+ const {createConfigForProvider, saveConfig} = await import(
20
+ path.join(REPO_ROOT, 'dist/daemon/lib/config.js')
21
+ );
22
+
23
+ class DummyEmbeddingProvider {
24
+ constructor(dimensions) {
25
+ this.dimensions = dimensions;
26
+ this.initialized = false;
27
+ }
28
+
29
+ async initialize() {
30
+ this.initialized = true;
31
+ }
32
+
33
+ async embed(texts) {
34
+ if (!this.initialized) {
35
+ await this.initialize();
36
+ }
37
+ return texts.map((text, index) => {
38
+ const seed = ((text.length + index * 7) % 97) / 100;
39
+ const out = new Array(this.dimensions);
40
+ for (let i = 0; i < out.length; i += 1) out[i] = seed;
41
+ return out;
42
+ });
43
+ }
44
+
45
+ async embedSingle(text) {
46
+ const [vector] = await this.embed([text]);
47
+ return vector;
48
+ }
49
+
50
+ close() {
51
+ this.initialized = false;
52
+ }
53
+ }
54
+
55
+ function mb(value) {
56
+ return Number((value / (1024 * 1024)).toFixed(1));
57
+ }
58
+
59
+ function memory(label) {
60
+ const usage = process.memoryUsage();
61
+ const sample = {
62
+ label,
63
+ time: new Date().toISOString(),
64
+ rssMB: mb(usage.rss),
65
+ heapUsedMB: mb(usage.heapUsed),
66
+ externalMB: mb(usage.external),
67
+ arrayBuffersMB: mb(usage.arrayBuffers),
68
+ };
69
+ console.log(JSON.stringify(sample));
70
+ return sample;
71
+ }
72
+
73
+ function forceGc() {
74
+ if (typeof global.gc !== 'function') {
75
+ return;
76
+ }
77
+ global.gc();
78
+ global.gc();
79
+ }
80
+
81
+ async function createSyntheticProject() {
82
+ const root = await fs.mkdtemp(
83
+ path.join(os.tmpdir(), 'viberag-memory-profile-'),
84
+ );
85
+ const projectRoot = path.join(root, 'project');
86
+ const homeRoot = path.join(root, 'home');
87
+ process.env['VIBERAG_HOME'] = homeRoot;
88
+
89
+ await fs.mkdir(projectRoot, {recursive: true});
90
+ await fs.mkdir(homeRoot, {recursive: true});
91
+ await fs.cp(
92
+ path.join(REPO_ROOT, 'source'),
93
+ path.join(projectRoot, 'source'),
94
+ {
95
+ recursive: true,
96
+ },
97
+ );
98
+
99
+ const config = createConfigForProvider('gemini');
100
+ config.watch = {...config.watch, enabled: false};
101
+ config.extensions = ['.ts', '.tsx', '.js', '.mjs', '.cjs'];
102
+ await saveConfig(projectRoot, config);
103
+
104
+ return {projectRoot, homeRoot, dimensions: config.embeddingDimensions};
105
+ }
106
+
107
+ async function runChunkerScenario(projectRoot) {
108
+ console.log(JSON.stringify({section: 'chunker'}));
109
+ const filePath = path.join(projectRoot, 'source/daemon/lib/chunker/index.ts');
110
+ const source = await fs.readFile(filePath, 'utf8');
111
+
112
+ const chunker = new Chunker();
113
+ await chunker.initialize();
114
+
115
+ forceGc();
116
+ memory('chunker.before');
117
+
118
+ for (let i = 0; i < RUNS; i += 1) {
119
+ for (let j = 0; j < ITERATIONS; j += 1) {
120
+ chunker.analyzeFile(filePath, source, {
121
+ chunkMaxSize: 2000,
122
+ definitionMaxChunkSize: Number.MAX_SAFE_INTEGER,
123
+ refs: {
124
+ identifier_mode: 'symbolish',
125
+ max_occurrences_per_token: 0,
126
+ include_string_literals: false,
127
+ },
128
+ });
129
+ }
130
+ forceGc();
131
+ memory(`chunker.iter${i}`);
132
+ }
133
+
134
+ chunker.close();
135
+ forceGc();
136
+ memory('chunker.afterClose');
137
+ }
138
+
139
+ async function runIndexScenario(projectRoot, dimensions) {
140
+ console.log(JSON.stringify({section: 'indexing'}));
141
+ for (let i = 0; i < RUNS; i += 1) {
142
+ forceGc();
143
+ memory(`index.before.${i}`);
144
+
145
+ const indexer = new IndexingServiceV2(projectRoot, {
146
+ embeddings: new DummyEmbeddingProvider(dimensions),
147
+ });
148
+ const started = Date.now();
149
+ const stats = await indexer.index({force: true});
150
+ memory(`index.after.${i}`);
151
+ indexer.close();
152
+
153
+ forceGc();
154
+ memory(`index.afterClose.${i}`);
155
+ console.log(
156
+ JSON.stringify({
157
+ label: `index.stats.${i}`,
158
+ durationMs: Date.now() - started,
159
+ filesIndexed: stats.filesIndexed,
160
+ symbolRowsUpserted: stats.symbolRowsUpserted,
161
+ chunkRowsUpserted: stats.chunkRowsUpserted,
162
+ embeddingsComputed: stats.embeddingsComputed,
163
+ }),
164
+ );
165
+ }
166
+ }
167
+
168
+ if (typeof global.gc !== 'function') {
169
+ console.error(
170
+ '[memory-profile] Run with --expose-gc for reliable post-GC comparisons.',
171
+ );
172
+ }
173
+
174
+ const setup = await createSyntheticProject();
175
+ console.log(
176
+ JSON.stringify({
177
+ label: 'setup',
178
+ ...setup,
179
+ runs: RUNS,
180
+ iterations: ITERATIONS,
181
+ }),
182
+ );
183
+
184
+ await runChunkerScenario(setup.projectRoot);
185
+ await runIndexScenario(setup.projectRoot, setup.dimensions);
186
+
187
+ forceGc();
188
+ memory('done');