composto-ai 0.3.0 → 0.4.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.
@@ -0,0 +1,986 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/memory/db.ts
4
+ import Database from "better-sqlite3";
5
+ import { mkdirSync } from "fs";
6
+ import { dirname } from "path";
7
+ function openDatabase(path) {
8
+ mkdirSync(dirname(path), { recursive: true });
9
+ const db = new Database(path);
10
+ db.pragma("journal_mode = WAL");
11
+ db.pragma("synchronous = NORMAL");
12
+ db.pragma("foreign_keys = ON");
13
+ return db;
14
+ }
15
+
16
+ // src/memory/schema.ts
17
+ var CURRENT_VERSION = 1;
18
+ var V1_SQL = `
19
+ CREATE TABLE IF NOT EXISTS index_state (
20
+ key TEXT PRIMARY KEY,
21
+ value TEXT NOT NULL
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS commits (
25
+ sha TEXT PRIMARY KEY,
26
+ parent_sha TEXT,
27
+ author TEXT NOT NULL,
28
+ timestamp INTEGER NOT NULL,
29
+ subject TEXT NOT NULL,
30
+ is_fix INTEGER NOT NULL,
31
+ is_revert INTEGER NOT NULL,
32
+ reverts_sha TEXT,
33
+ FOREIGN KEY (reverts_sha) REFERENCES commits(sha)
34
+ );
35
+ CREATE INDEX IF NOT EXISTS idx_commits_timestamp ON commits(timestamp);
36
+ CREATE INDEX IF NOT EXISTS idx_commits_is_fix ON commits(is_fix) WHERE is_fix = 1;
37
+
38
+ CREATE TABLE IF NOT EXISTS file_touches (
39
+ commit_sha TEXT NOT NULL,
40
+ file_path TEXT NOT NULL,
41
+ adds INTEGER NOT NULL,
42
+ dels INTEGER NOT NULL,
43
+ change_type TEXT NOT NULL,
44
+ renamed_from TEXT,
45
+ PRIMARY KEY (commit_sha, file_path),
46
+ FOREIGN KEY (commit_sha) REFERENCES commits(sha)
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_ft_file ON file_touches(file_path);
49
+
50
+ CREATE TABLE IF NOT EXISTS symbols (
51
+ id INTEGER PRIMARY KEY,
52
+ file_path TEXT NOT NULL,
53
+ kind TEXT NOT NULL,
54
+ qualified_name TEXT NOT NULL,
55
+ first_seen_sha TEXT NOT NULL,
56
+ last_seen_sha TEXT,
57
+ UNIQUE (file_path, kind, qualified_name)
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path);
60
+
61
+ CREATE TABLE IF NOT EXISTS symbol_touches (
62
+ commit_sha TEXT NOT NULL,
63
+ symbol_id INTEGER NOT NULL,
64
+ change_type TEXT NOT NULL,
65
+ PRIMARY KEY (commit_sha, symbol_id),
66
+ FOREIGN KEY (commit_sha) REFERENCES commits(sha),
67
+ FOREIGN KEY (symbol_id) REFERENCES symbols(id)
68
+ );
69
+ CREATE INDEX IF NOT EXISTS idx_st_symbol ON symbol_touches(symbol_id);
70
+
71
+ CREATE TABLE IF NOT EXISTS fix_links (
72
+ fix_commit_sha TEXT NOT NULL,
73
+ suspected_break_sha TEXT NOT NULL,
74
+ evidence_type TEXT NOT NULL,
75
+ confidence REAL NOT NULL,
76
+ window_hours INTEGER,
77
+ PRIMARY KEY (fix_commit_sha, suspected_break_sha, evidence_type),
78
+ FOREIGN KEY (fix_commit_sha) REFERENCES commits(sha),
79
+ FOREIGN KEY (suspected_break_sha) REFERENCES commits(sha)
80
+ );
81
+ CREATE INDEX IF NOT EXISTS idx_fl_break ON fix_links(suspected_break_sha);
82
+
83
+ CREATE TABLE IF NOT EXISTS signal_calibration (
84
+ signal_type TEXT PRIMARY KEY,
85
+ precision REAL NOT NULL,
86
+ sample_size INTEGER NOT NULL,
87
+ last_computed_sha TEXT NOT NULL,
88
+ computed_at INTEGER NOT NULL
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS file_index_state (
92
+ file_path TEXT PRIMARY KEY,
93
+ last_commit_indexed TEXT NOT NULL,
94
+ last_blob_indexed TEXT,
95
+ indexed_at INTEGER NOT NULL,
96
+ parse_failed INTEGER NOT NULL DEFAULT 0,
97
+ FOREIGN KEY (last_commit_indexed) REFERENCES commits(sha)
98
+ );
99
+ `;
100
+ function runMigrations(db) {
101
+ const current = db.pragma("user_version", { simple: true });
102
+ if (current >= CURRENT_VERSION) return;
103
+ db.exec("BEGIN");
104
+ try {
105
+ db.exec(V1_SQL);
106
+ db.pragma(`user_version = ${CURRENT_VERSION}`);
107
+ db.exec("COMMIT");
108
+ } catch (err) {
109
+ db.exec("ROLLBACK");
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ // src/memory/git.ts
115
+ import { execSync } from "child_process";
116
+ function run(cwd, cmd, timeoutMs = 1e4) {
117
+ return execSync(cmd, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
118
+ }
119
+ function revParseHead(cwd) {
120
+ return run(cwd, "git rev-parse HEAD");
121
+ }
122
+ function isShallowRepo(cwd) {
123
+ return run(cwd, "git rev-parse --is-shallow-repository") === "true";
124
+ }
125
+ function revListCount(cwd, from, to) {
126
+ if (from === to) return 0;
127
+ const out = run(cwd, `git rev-list --count ${from}..${to}`);
128
+ return parseInt(out, 10);
129
+ }
130
+ function isAncestor(cwd, ancestor, descendant) {
131
+ try {
132
+ execSync(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
133
+ cwd,
134
+ stdio: "ignore"
135
+ });
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ function countCommits(cwd) {
142
+ const out = run(cwd, "git rev-list --count HEAD");
143
+ return parseInt(out, 10);
144
+ }
145
+
146
+ // src/memory/freshness.ts
147
+ function ensureFresh(db, repoPath) {
148
+ const head = revParseHead(repoPath);
149
+ const row = db.prepare("SELECT value FROM index_state WHERE key = 'last_indexed_sha'").get();
150
+ if (!row) {
151
+ return {
152
+ tazelik: "bootstrapping",
153
+ head,
154
+ delta: { from: null, to: head },
155
+ behind_by: 0,
156
+ rewritten: false
157
+ };
158
+ }
159
+ const last = row.value;
160
+ if (last === head) {
161
+ return { tazelik: "fresh", head, delta: null, behind_by: 0, rewritten: false };
162
+ }
163
+ const reachable = isAncestor(repoPath, last, head);
164
+ if (!reachable) {
165
+ return {
166
+ tazelik: "bootstrapping",
167
+ head,
168
+ delta: { from: null, to: head },
169
+ behind_by: 0,
170
+ rewritten: true
171
+ };
172
+ }
173
+ const behind_by = revListCount(repoPath, last, head);
174
+ return {
175
+ tazelik: "catching_up",
176
+ head,
177
+ delta: { from: last, to: head },
178
+ behind_by,
179
+ rewritten: false
180
+ };
181
+ }
182
+
183
+ // src/memory/signals/calibration-lookup.ts
184
+ function getCalibration(db, type, fallbackPrecision) {
185
+ const row = db.prepare("SELECT precision, sample_size FROM signal_calibration WHERE signal_type = ?").get(type);
186
+ if (!row) {
187
+ return { precision: fallbackPrecision, sampleSize: 0, source: "heuristic" };
188
+ }
189
+ return {
190
+ precision: row.precision,
191
+ sampleSize: row.sample_size,
192
+ source: "repo-calibrated"
193
+ };
194
+ }
195
+
196
+ // src/memory/signals/revert-match.ts
197
+ var STRENGTH_BY_EVIDENCE = {
198
+ revert_marker: 1,
199
+ short_followup_fix: 0.7,
200
+ same_region_fix_chain: 0.4
201
+ };
202
+ var FALLBACK_PRECISION = 0.5;
203
+ var MAX_EVIDENCE = 5;
204
+ function computeRevertMatch(db, filePath) {
205
+ const rows = db.prepare(`
206
+ SELECT fl.evidence_type, fl.confidence, fl.suspected_break_sha,
207
+ c.subject, c.timestamp
208
+ FROM fix_links fl
209
+ JOIN file_touches ft ON ft.commit_sha = fl.suspected_break_sha
210
+ JOIN commits c ON c.sha = fl.suspected_break_sha
211
+ WHERE ft.file_path = ?
212
+ ORDER BY c.timestamp DESC
213
+ LIMIT ?
214
+ `).all(filePath, MAX_EVIDENCE);
215
+ const cal = getCalibration(db, "revert_match", FALLBACK_PRECISION);
216
+ if (rows.length === 0) {
217
+ return {
218
+ type: "revert_match",
219
+ strength: 0,
220
+ precision: cal.precision,
221
+ sample_size: cal.sampleSize,
222
+ evidence: []
223
+ };
224
+ }
225
+ let strength = 0;
226
+ const evidence = [];
227
+ const now = Math.floor(Date.now() / 1e3);
228
+ for (const r of rows) {
229
+ const s = STRENGTH_BY_EVIDENCE[r.evidence_type] ?? 0;
230
+ if (s > strength) strength = s;
231
+ evidence.push({
232
+ commit_sha: r.suspected_break_sha,
233
+ subject: r.subject,
234
+ days_ago: Math.floor((now - r.timestamp) / 86400),
235
+ evidence_type: r.evidence_type
236
+ });
237
+ }
238
+ return {
239
+ type: "revert_match",
240
+ strength,
241
+ precision: cal.precision,
242
+ sample_size: cal.sampleSize,
243
+ evidence
244
+ };
245
+ }
246
+
247
+ // src/memory/signals/hotspot.ts
248
+ var WINDOW_SECONDS = 90 * 86400;
249
+ var SATURATION_TOUCHES = 30;
250
+ var FALLBACK_PRECISION2 = 0.3;
251
+ function computeHotspot(db, filePath) {
252
+ const now = Math.floor(Date.now() / 1e3);
253
+ const lowerBound = now - WINDOW_SECONDS;
254
+ const row = db.prepare(`
255
+ SELECT COUNT(*) AS n
256
+ FROM file_touches ft
257
+ JOIN commits c ON c.sha = ft.commit_sha
258
+ WHERE ft.file_path = ? AND c.timestamp >= ?
259
+ `).get(filePath, lowerBound);
260
+ const touches = row.n;
261
+ const strength = Math.min(1, touches / SATURATION_TOUCHES);
262
+ const cal = getCalibration(db, "hotspot", FALLBACK_PRECISION2);
263
+ return {
264
+ type: "hotspot",
265
+ strength,
266
+ precision: cal.precision,
267
+ sample_size: cal.sampleSize,
268
+ evidence: [],
269
+ touches_90d: touches
270
+ };
271
+ }
272
+
273
+ // src/memory/signals/fix-ratio.ts
274
+ var WINDOW_COMMITS = 30;
275
+ var DEAD_ZONE = 0.3;
276
+ var SATURATION_OVER_DEAD_ZONE = 0.5;
277
+ var FALLBACK_PRECISION3 = 0.3;
278
+ function computeFixRatio(db, filePath) {
279
+ const rows = db.prepare(`
280
+ SELECT c.is_fix
281
+ FROM file_touches ft
282
+ JOIN commits c ON c.sha = ft.commit_sha
283
+ WHERE ft.file_path = ?
284
+ ORDER BY c.timestamp DESC
285
+ LIMIT ?
286
+ `).all(filePath, WINDOW_COMMITS);
287
+ const cal = getCalibration(db, "fix_ratio", FALLBACK_PRECISION3);
288
+ if (rows.length === 0) {
289
+ return {
290
+ type: "fix_ratio",
291
+ strength: 0,
292
+ precision: cal.precision,
293
+ sample_size: cal.sampleSize,
294
+ evidence: [],
295
+ ratio: 0
296
+ };
297
+ }
298
+ const fixes = rows.filter((r) => r.is_fix === 1).length;
299
+ const ratio = fixes / rows.length;
300
+ const strength = Math.max(0, Math.min(1, (ratio - DEAD_ZONE) / SATURATION_OVER_DEAD_ZONE));
301
+ return {
302
+ type: "fix_ratio",
303
+ strength,
304
+ precision: cal.precision,
305
+ sample_size: cal.sampleSize,
306
+ evidence: [],
307
+ ratio
308
+ };
309
+ }
310
+
311
+ // src/trends/git-log-parser.ts
312
+ import { execSync as execSync2 } from "child_process";
313
+ var BUG_FIX_PATTERNS = [
314
+ /\bfix\b/i,
315
+ /\bbugfix\b/i,
316
+ /\bhotfix\b/i,
317
+ /\bpatch\b/i,
318
+ /\bresolve\b/i,
319
+ /\bbug\b/i
320
+ ];
321
+ function isBugFixCommit(message) {
322
+ return BUG_FIX_PATTERNS.some((p) => p.test(message));
323
+ }
324
+ function parseGitLogOutput(output) {
325
+ const entries = [];
326
+ const lines = output.split("\n");
327
+ let i = 0;
328
+ while (i < lines.length) {
329
+ const line = lines[i].trim();
330
+ if (!line || !line.includes("|")) {
331
+ i++;
332
+ continue;
333
+ }
334
+ const [hash, author, date, ...messageParts] = line.split("|");
335
+ const message = messageParts.join("|");
336
+ const files = [];
337
+ i++;
338
+ while (i < lines.length && lines[i].trim() !== "" && !lines[i].includes("|")) {
339
+ const fileLine = lines[i].trim();
340
+ if (fileLine) files.push(fileLine);
341
+ i++;
342
+ }
343
+ entries.push({ hash, author, date, message, files });
344
+ }
345
+ return entries;
346
+ }
347
+ function getGitLog(repoPath, count = 100) {
348
+ try {
349
+ const output = execSync2(
350
+ `git log --format="%h|%an|%as|%s" --name-only -n ${count}`,
351
+ { cwd: repoPath, encoding: "utf-8", timeout: 1e4 }
352
+ );
353
+ return parseGitLogOutput(output);
354
+ } catch {
355
+ return [];
356
+ }
357
+ }
358
+
359
+ // src/trends/hotspot.ts
360
+ function detectHotspots(entries, options) {
361
+ const fileStats = /* @__PURE__ */ new Map();
362
+ for (const entry of entries) {
363
+ const isFix = isBugFixCommit(entry.message);
364
+ for (const file of entry.files) {
365
+ const stats = fileStats.get(file) ?? { changes: 0, fixes: 0, authors: /* @__PURE__ */ new Set() };
366
+ stats.changes++;
367
+ if (isFix) stats.fixes++;
368
+ stats.authors.add(entry.author);
369
+ fileStats.set(file, stats);
370
+ }
371
+ }
372
+ const hotspots = [];
373
+ for (const [file, stats] of fileStats) {
374
+ const fixRatio = stats.changes > 0 ? stats.fixes / stats.changes : 0;
375
+ if (stats.changes >= options.threshold && fixRatio >= options.fixRatioThreshold) {
376
+ hotspots.push({
377
+ file,
378
+ changesInLast30Commits: stats.changes,
379
+ bugFixRatio: fixRatio,
380
+ authorCount: stats.authors.size
381
+ });
382
+ }
383
+ }
384
+ return hotspots.sort((a, b) => b.changesInLast30Commits - a.changesInLast30Commits);
385
+ }
386
+
387
+ // src/trends/decay.ts
388
+ function detectDecay(entries) {
389
+ const fileChanges = /* @__PURE__ */ new Map();
390
+ for (const entry of entries) {
391
+ for (const file of entry.files) {
392
+ const changes = fileChanges.get(file) ?? [];
393
+ changes.push({ date: entry.date });
394
+ fileChanges.set(file, changes);
395
+ }
396
+ }
397
+ const signals = [];
398
+ for (const [file, changes] of fileChanges) {
399
+ if (changes.length < 4) continue;
400
+ const sorted = [...changes].sort((a, b) => a.date.localeCompare(b.date));
401
+ const firstDate = new Date(sorted[0].date).getTime();
402
+ const lastDate = new Date(sorted[sorted.length - 1].date).getTime();
403
+ const midDate = firstDate + (lastDate - firstDate) / 2;
404
+ const firstHalfCount = sorted.filter((c) => new Date(c.date).getTime() <= midDate).length;
405
+ const secondHalfCount = sorted.length - firstHalfCount;
406
+ if (secondHalfCount > firstHalfCount) {
407
+ signals.push({
408
+ file,
409
+ metric: "churn",
410
+ trend: "declining",
411
+ dataPoints: sorted.map((c, i) => ({ date: c.date, value: i + 1 }))
412
+ });
413
+ }
414
+ }
415
+ return signals;
416
+ }
417
+
418
+ // src/trends/inconsistency.ts
419
+ function detectInconsistencies(entries, minAuthors = 3) {
420
+ const fileAuthors = /* @__PURE__ */ new Map();
421
+ for (const entry of entries) {
422
+ for (const file of entry.files) {
423
+ const authors = fileAuthors.get(file) ?? /* @__PURE__ */ new Map();
424
+ const commits = authors.get(entry.author) ?? [];
425
+ commits.push(entry.message);
426
+ authors.set(entry.author, commits);
427
+ fileAuthors.set(file, authors);
428
+ }
429
+ }
430
+ const inconsistencies = [];
431
+ for (const [file, authors] of fileAuthors) {
432
+ if (authors.size >= minAuthors) {
433
+ const patterns = Array.from(authors.entries()).map(([author, commits]) => ({
434
+ author,
435
+ style: categorizeStyle(commits)
436
+ }));
437
+ inconsistencies.push({ file, patterns });
438
+ }
439
+ }
440
+ return inconsistencies;
441
+ }
442
+ function categorizeStyle(commits) {
443
+ const types = commits.map((m) => {
444
+ if (m.match(/^fix/i)) return "fix";
445
+ if (m.match(/^feat/i)) return "feature";
446
+ if (m.match(/^refactor/i)) return "refactor";
447
+ return "other";
448
+ });
449
+ const primary = mode(types);
450
+ return `primarily ${primary} (${commits.length} commits)`;
451
+ }
452
+ function mode(arr) {
453
+ const counts = /* @__PURE__ */ new Map();
454
+ for (const item of arr) {
455
+ counts.set(item, (counts.get(item) ?? 0) + 1);
456
+ }
457
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
458
+ }
459
+
460
+ // src/ir/health.ts
461
+ function computeHealthFromTrends(file, trends) {
462
+ const hotspot = trends.hotspots.find((h) => h.file === file);
463
+ const decay = trends.decaySignals.find((d) => d.file === file);
464
+ const inconsistency = trends.inconsistencies.find((i) => i.file === file);
465
+ return {
466
+ churn: hotspot?.changesInLast30Commits ?? 0,
467
+ fixRatio: hotspot?.bugFixRatio ?? 0,
468
+ coverageTrend: decay?.trend === "declining" ? "down" : decay?.trend === "improving" ? "up" : "stable",
469
+ staleness: "",
470
+ authorCount: hotspot?.authorCount ?? 0,
471
+ consistency: inconsistency ? "low" : "high"
472
+ };
473
+ }
474
+
475
+ // src/memory/signals/coverage-decline.ts
476
+ var FALLBACK_PRECISION4 = 0.3;
477
+ function computeCoverageDecline(db, repoPath, filePath) {
478
+ const cal = getCalibration(db, "coverage_decline", FALLBACK_PRECISION4);
479
+ let strength = 0;
480
+ try {
481
+ const entries = getGitLog(repoPath, 200);
482
+ const trends = {
483
+ hotspots: detectHotspots(entries, { threshold: 10, fixRatioThreshold: 0.5 }),
484
+ decaySignals: detectDecay(entries),
485
+ inconsistencies: detectInconsistencies(entries)
486
+ };
487
+ const health = computeHealthFromTrends(filePath, trends);
488
+ if (health.coverageTrend === "down") strength = 1;
489
+ } catch {
490
+ strength = 0;
491
+ }
492
+ return {
493
+ type: "coverage_decline",
494
+ strength,
495
+ precision: cal.precision,
496
+ sample_size: cal.sampleSize,
497
+ evidence: []
498
+ };
499
+ }
500
+
501
+ // src/memory/signals/author-churn.ts
502
+ var WINDOW_SECONDS2 = 90 * 86400;
503
+ var INACTIVE_THRESHOLD = 5;
504
+ var FALLBACK_PRECISION5 = 0.3;
505
+ function computeAuthorChurn(db, filePath) {
506
+ const cal = getCalibration(db, "author_churn", FALLBACK_PRECISION5);
507
+ const base = {
508
+ type: "author_churn",
509
+ precision: cal.precision,
510
+ sample_size: cal.sampleSize,
511
+ evidence: []
512
+ };
513
+ const lastTouch = db.prepare(`
514
+ SELECT c.author, c.timestamp
515
+ FROM file_touches ft
516
+ JOIN commits c ON c.sha = ft.commit_sha
517
+ WHERE ft.file_path = ?
518
+ ORDER BY c.timestamp DESC
519
+ LIMIT 1
520
+ `).get(filePath);
521
+ if (!lastTouch) return { ...base, strength: 0 };
522
+ const now = Math.floor(Date.now() / 1e3);
523
+ const lowerBound = now - WINDOW_SECONDS2;
524
+ const activity = db.prepare(`SELECT COUNT(*) AS n FROM commits WHERE author = ? AND timestamp >= ?`).get(lastTouch.author, lowerBound);
525
+ let strength = 0;
526
+ if (activity.n === 0) strength = 1;
527
+ else if (activity.n < INACTIVE_THRESHOLD) strength = 0.5;
528
+ return { ...base, strength };
529
+ }
530
+
531
+ // src/memory/signals/index.ts
532
+ function collectSignals(db, repoPath, filePath) {
533
+ return [
534
+ computeRevertMatch(db, filePath),
535
+ computeHotspot(db, filePath),
536
+ computeFixRatio(db, filePath),
537
+ computeCoverageDecline(db, repoPath, filePath),
538
+ computeAuthorChurn(db, filePath)
539
+ ];
540
+ }
541
+
542
+ // src/memory/confidence.ts
543
+ var USABLE_SAMPLE_THRESHOLD = 20;
544
+ function coverageFactor(signals) {
545
+ const usable = signals.filter(
546
+ (s) => s.strength > 0 && s.sample_size >= USABLE_SAMPLE_THRESHOLD
547
+ ).length;
548
+ return Math.min(1, usable / 3);
549
+ }
550
+ function calibrationFactor(signals) {
551
+ const firing = signals.filter((s) => s.strength > 0);
552
+ if (firing.length === 0) return 1;
553
+ const avg = firing.reduce((acc, s) => acc + s.sample_size, 0) / firing.length;
554
+ if (avg < 20) return 0.3;
555
+ if (avg < 100) return 0.6;
556
+ return 1;
557
+ }
558
+ function freshnessFactor(ctx) {
559
+ if (ctx.partial) return 0.4;
560
+ switch (ctx.tazelik) {
561
+ case "fresh":
562
+ return 1;
563
+ case "catching_up":
564
+ return 0.8;
565
+ case "partial":
566
+ return 0.4;
567
+ case "bootstrapping":
568
+ return 0.2;
569
+ }
570
+ }
571
+ function historyFactor(totalCommits) {
572
+ if (totalCommits < 50) return 0.2;
573
+ if (totalCommits < 200) return 0.5;
574
+ if (totalCommits < 1e3) return 0.8;
575
+ return 1;
576
+ }
577
+ function computeScoreAndConfidence(signals, ctx) {
578
+ let num = 0;
579
+ let den = 0;
580
+ for (const s of signals) {
581
+ if (s.strength <= 0 || s.precision <= 0) continue;
582
+ num += s.strength * s.precision;
583
+ den += s.precision;
584
+ }
585
+ const score = den === 0 ? 0 : num / den;
586
+ const confidence = Math.min(
587
+ coverageFactor(signals),
588
+ calibrationFactor(signals),
589
+ freshnessFactor(ctx),
590
+ historyFactor(ctx.totalCommits)
591
+ );
592
+ return { score, confidence };
593
+ }
594
+
595
+ // src/memory/verdict.ts
596
+ function mapVerdict(score, confidence) {
597
+ if (confidence < 0.3) return "unknown";
598
+ if (score < 0.3) return "low";
599
+ if (score < 0.6) return "medium";
600
+ return "high";
601
+ }
602
+
603
+ // src/memory/envelope.ts
604
+ var CONFIDENCE_CAP = {
605
+ ok: 1,
606
+ empty_repo: 0,
607
+ insufficient_history: 0.3,
608
+ shallow_clone: 0,
609
+ indexing: 0.4,
610
+ squashed_history: 0.5,
611
+ reindexing: 0,
612
+ internal_error: 0,
613
+ disabled: 0
614
+ };
615
+ var USABLE_SAMPLE_THRESHOLD2 = 20;
616
+ function inferCalibrationSource(signals) {
617
+ return signals.some((s) => s.sample_size > 0) ? "repo-calibrated" : "heuristic";
618
+ }
619
+ function buildEnvelope(args) {
620
+ const cap = CONFIDENCE_CAP[args.status];
621
+ const cappedConfidence = Math.min(args.confidence, cap);
622
+ const verdict = mapVerdict(args.score, cappedConfidence);
623
+ const usable = args.signals.filter(
624
+ (s) => s.strength > 0 && s.sample_size >= USABLE_SAMPLE_THRESHOLD2
625
+ ).length;
626
+ return {
627
+ status: args.status,
628
+ reason: args.reason,
629
+ verdict,
630
+ score: args.score,
631
+ confidence: cappedConfidence,
632
+ signals: args.signals,
633
+ calibration: inferCalibrationSource(args.signals),
634
+ retry_hint_ms: args.retry_hint_ms,
635
+ confidence_cap: args.status === "ok" ? void 0 : cap,
636
+ metadata: {
637
+ tazelik: args.tazelik,
638
+ index_version: 1,
639
+ indexed_commits_through: args.indexedThrough,
640
+ indexed_commits_total: args.indexedTotal,
641
+ query_ms: args.queryMs,
642
+ signal_coverage: `${usable}/${args.signals.length}`
643
+ }
644
+ };
645
+ }
646
+
647
+ // src/memory/pool.ts
648
+ import { Worker } from "worker_threads";
649
+ import { fileURLToPath } from "url";
650
+ import { dirname as dirname2, join } from "path";
651
+ function resolveWorkerPath() {
652
+ const here = dirname2(fileURLToPath(import.meta.url));
653
+ if (here.endsWith("/memory") || here.endsWith("\\memory")) {
654
+ return join(here, "worker.js");
655
+ }
656
+ return join(here, "memory", "worker.js");
657
+ }
658
+ var WorkerPool = class {
659
+ workers = [];
660
+ nextJobId = 1;
661
+ pending = /* @__PURE__ */ new Map();
662
+ constructor(opts = {}) {
663
+ const size = Math.max(1, opts.size ?? 1);
664
+ for (let i = 0; i < size; i++) this.spawn();
665
+ }
666
+ spawn() {
667
+ const worker = new Worker(resolveWorkerPath());
668
+ worker.on("message", (msg) => {
669
+ const job = this.pending.get(msg.jobId);
670
+ if (!job) return;
671
+ this.pending.delete(msg.jobId);
672
+ if (msg.type === "ingest_done") {
673
+ job.resolve({ status: "done", commits: msg.commits });
674
+ } else if (msg.type === "ingest_error") {
675
+ job.reject(new Error(msg.message));
676
+ }
677
+ });
678
+ worker.on("error", (err) => {
679
+ const error = err instanceof Error ? err : new Error(String(err));
680
+ for (const job of this.pending.values()) job.reject(error);
681
+ this.pending.clear();
682
+ });
683
+ this.workers.push(worker);
684
+ }
685
+ runIngest(args) {
686
+ const jobId = this.nextJobId++;
687
+ const worker = this.workers[jobId % this.workers.length];
688
+ return new Promise((resolve, reject) => {
689
+ this.pending.set(jobId, { resolve, reject });
690
+ worker.postMessage({ type: "ingest", jobId, ...args });
691
+ });
692
+ }
693
+ async close() {
694
+ await Promise.all(this.workers.map((w) => w.terminate()));
695
+ this.workers = [];
696
+ this.pending.clear();
697
+ }
698
+ };
699
+
700
+ // src/memory/detectors.ts
701
+ function detectSquashed(db) {
702
+ const row = db.prepare(`
703
+ SELECT author, COUNT(*) AS n, MIN(timestamp) AS t0, MAX(timestamp) AS t1
704
+ FROM commits
705
+ GROUP BY author
706
+ ORDER BY n DESC
707
+ LIMIT 1
708
+ `).get();
709
+ if (!row || row.n < 50) return false;
710
+ const span = row.t1 - row.t0;
711
+ return span < 86400;
712
+ }
713
+
714
+ // src/memory/failure-tracker.ts
715
+ import { readFileSync, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
716
+ import { join as join2 } from "path";
717
+ var STRIKE_THRESHOLD = 3;
718
+ var WINDOW_SECONDS3 = 300;
719
+ function createFailureTracker(composto_dir) {
720
+ const path = join2(composto_dir, "failures.json");
721
+ try {
722
+ mkdirSync2(composto_dir, { recursive: true });
723
+ } catch {
724
+ }
725
+ function load() {
726
+ try {
727
+ const raw = readFileSync(path, "utf-8");
728
+ return JSON.parse(raw);
729
+ } catch {
730
+ return { failures: [], disabled: false };
731
+ }
732
+ }
733
+ function save(s) {
734
+ try {
735
+ writeFileSync(path, JSON.stringify(s), "utf-8");
736
+ } catch {
737
+ }
738
+ }
739
+ function now() {
740
+ return Math.floor(Date.now() / 1e3);
741
+ }
742
+ return {
743
+ recordFailure: (failureClass) => {
744
+ const s = load();
745
+ s.failures.push({ class: failureClass, t: now() });
746
+ s.failures = s.failures.filter((f) => now() - f.t <= WINDOW_SECONDS3);
747
+ const sameClass = s.failures.filter((f) => f.class === failureClass);
748
+ if (sameClass.length >= STRIKE_THRESHOLD) s.disabled = true;
749
+ save(s);
750
+ },
751
+ recordSuccess: () => {
752
+ save({ failures: [], disabled: false });
753
+ },
754
+ isDisabled: () => {
755
+ return load().disabled;
756
+ }
757
+ };
758
+ }
759
+
760
+ // src/memory/log.ts
761
+ import { appendFileSync, mkdirSync as mkdirSync3, readdirSync, renameSync, statSync, unlinkSync } from "fs";
762
+ import { join as join3 } from "path";
763
+ var LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
764
+ var RETENTION_DAYS = 7;
765
+ function currentThreshold() {
766
+ const raw = (process.env.COMPOSTO_LOG ?? "info").toLowerCase();
767
+ if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") return raw;
768
+ return "info";
769
+ }
770
+ function rotateIfNeeded(dir) {
771
+ const logPath = join3(dir, "index.log");
772
+ try {
773
+ const s = statSync(logPath);
774
+ const age = (Date.now() - s.mtimeMs) / 864e5;
775
+ if (age < 1) return;
776
+ const files = readdirSync(dir).filter((f) => /^index\.log(\.\d+)?$/.test(f));
777
+ const numbered = files.map((f) => {
778
+ const m = f.match(/^index\.log\.(\d+)$/);
779
+ return { name: f, n: m ? parseInt(m[1], 10) : 0 };
780
+ }).sort((a, b) => b.n - a.n);
781
+ for (const f of numbered) {
782
+ if (f.n >= RETENTION_DAYS) {
783
+ unlinkSync(join3(dir, f.name));
784
+ continue;
785
+ }
786
+ if (f.n === 0) {
787
+ renameSync(join3(dir, f.name), join3(dir, "index.log.1"));
788
+ } else {
789
+ renameSync(join3(dir, f.name), join3(dir, `index.log.${f.n + 1}`));
790
+ }
791
+ }
792
+ } catch {
793
+ }
794
+ }
795
+ function createLogger(composto_dir) {
796
+ let disabled = false;
797
+ try {
798
+ mkdirSync3(composto_dir, { recursive: true });
799
+ rotateIfNeeded(composto_dir);
800
+ } catch {
801
+ disabled = true;
802
+ }
803
+ const path = join3(composto_dir, "index.log");
804
+ const threshold = currentThreshold();
805
+ function write(level, evt, extras) {
806
+ if (disabled) return;
807
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[threshold]) return;
808
+ const line = JSON.stringify({
809
+ t: Math.floor(Date.now() / 1e3),
810
+ lvl: level,
811
+ evt,
812
+ ...extras ?? {}
813
+ });
814
+ try {
815
+ appendFileSync(path, line + "\n", "utf-8");
816
+ } catch {
817
+ disabled = true;
818
+ }
819
+ }
820
+ return {
821
+ debug: (evt, extras) => write("debug", evt, extras),
822
+ info: (evt, extras) => write("info", evt, extras),
823
+ warn: (evt, extras) => write("warn", evt, extras),
824
+ error: (evt, extras) => write("error", evt, extras),
825
+ close: () => {
826
+ }
827
+ };
828
+ }
829
+
830
+ // src/memory/api.ts
831
+ import { dirname as dirname3 } from "path";
832
+ var EMPTY_REPO_THRESHOLD = 10;
833
+ var MemoryAPI = class {
834
+ db;
835
+ pool;
836
+ dbPath;
837
+ repoPath;
838
+ compostoDir;
839
+ log;
840
+ failures;
841
+ bootstrapPromise = null;
842
+ constructor(opts) {
843
+ this.dbPath = opts.dbPath;
844
+ this.repoPath = opts.repoPath;
845
+ this.compostoDir = dirname3(opts.dbPath);
846
+ this.log = createLogger(this.compostoDir);
847
+ this.failures = createFailureTracker(this.compostoDir);
848
+ this.db = openDatabase(opts.dbPath);
849
+ runMigrations(this.db);
850
+ this.pool = new WorkerPool({ size: opts.workerPoolSize ?? 1 });
851
+ this.log.info("api_open", { dbPath: opts.dbPath });
852
+ }
853
+ async bootstrapIfNeeded() {
854
+ if (this.bootstrapPromise) return this.bootstrapPromise;
855
+ const fresh = ensureFresh(this.db, this.repoPath);
856
+ if (fresh.tazelik === "fresh" || !fresh.delta) return;
857
+ this.bootstrapPromise = this.pool.runIngest({ dbPath: this.dbPath, repoPath: this.repoPath, range: fresh.delta }).then(() => {
858
+ this.log.info("bootstrap_done", { through: fresh.delta?.to });
859
+ }).catch((err) => {
860
+ this.log.error("bootstrap_failed", { message: err.message });
861
+ this.failures.recordFailure("ingest_failure");
862
+ throw err;
863
+ }).finally(() => {
864
+ this.bootstrapPromise = null;
865
+ });
866
+ return this.bootstrapPromise;
867
+ }
868
+ async blastradius(input) {
869
+ const start = Date.now();
870
+ if (this.failures.isDisabled()) {
871
+ this.log.warn("call_on_disabled", { file: input.file });
872
+ return buildEnvelope({
873
+ status: "disabled",
874
+ signals: [],
875
+ score: 0,
876
+ confidence: 0,
877
+ tazelik: "fresh",
878
+ indexedThrough: "",
879
+ indexedTotal: 0,
880
+ queryMs: Date.now() - start,
881
+ reason: "tool disabled after repeated failures; clear .composto/failures.json to re-enable"
882
+ });
883
+ }
884
+ try {
885
+ return await this.runQuery(input, start);
886
+ } catch (err) {
887
+ const message = err instanceof Error ? err.message : String(err);
888
+ this.log.error("internal_error", { file: input.file, message });
889
+ this.failures.recordFailure("internal_error");
890
+ return buildEnvelope({
891
+ status: "internal_error",
892
+ signals: [],
893
+ score: 0,
894
+ confidence: 0,
895
+ tazelik: "fresh",
896
+ indexedThrough: "",
897
+ indexedTotal: 0,
898
+ queryMs: Date.now() - start,
899
+ reason: `internal error: ${message}; see .composto/index.log`
900
+ });
901
+ }
902
+ }
903
+ async runQuery(input, start) {
904
+ if (isShallowRepo(this.repoPath)) {
905
+ return buildEnvelope({
906
+ status: "shallow_clone",
907
+ signals: [],
908
+ score: 0,
909
+ confidence: 0,
910
+ tazelik: "fresh",
911
+ indexedThrough: "",
912
+ indexedTotal: 0,
913
+ queryMs: Date.now() - start,
914
+ reason: "shallow clone detected; run `git fetch --unshallow` or `composto index --deepen`"
915
+ });
916
+ }
917
+ const totalCommits = countCommits(this.repoPath);
918
+ if (totalCommits < EMPTY_REPO_THRESHOLD) {
919
+ return buildEnvelope({
920
+ status: "empty_repo",
921
+ signals: [],
922
+ score: 0,
923
+ confidence: 0,
924
+ tazelik: "fresh",
925
+ indexedThrough: "",
926
+ indexedTotal: totalCommits,
927
+ queryMs: Date.now() - start,
928
+ reason: `repo has ${totalCommits} commits; blastradius requires >= ${EMPTY_REPO_THRESHOLD}`
929
+ });
930
+ }
931
+ const fresh = ensureFresh(this.db, this.repoPath);
932
+ let status = "ok";
933
+ if (fresh.rewritten) {
934
+ status = "reindexing";
935
+ this.log.warn("history_rewritten", { last_indexed: fresh.head });
936
+ }
937
+ if (fresh.tazelik === "bootstrapping") {
938
+ await this.bootstrapIfNeeded();
939
+ } else if (fresh.tazelik === "catching_up" && fresh.delta) {
940
+ this.pool.runIngest({ dbPath: this.dbPath, repoPath: this.repoPath, range: fresh.delta }).catch((err) => {
941
+ this.log.error("delta_ingest_failed", { message: err.message });
942
+ });
943
+ }
944
+ if (status === "ok" && detectSquashed(this.db)) {
945
+ status = "squashed_history";
946
+ }
947
+ const indexedTotalRow = this.db.prepare("SELECT value FROM index_state WHERE key='indexed_commits_total'").get();
948
+ const indexedTotal = indexedTotalRow ? parseInt(indexedTotalRow.value, 10) : 0;
949
+ const indexedThrough = this.db.prepare("SELECT value FROM index_state WHERE key='last_indexed_sha'").get()?.value ?? "";
950
+ const signals = collectSignals(this.db, this.repoPath, input.file);
951
+ const tazelik = fresh.tazelik === "bootstrapping" ? "fresh" : fresh.tazelik;
952
+ const { score, confidence } = computeScoreAndConfidence(signals, {
953
+ tazelik,
954
+ partial: false,
955
+ totalCommits: indexedTotal
956
+ });
957
+ const response = buildEnvelope({
958
+ status,
959
+ signals,
960
+ score,
961
+ confidence,
962
+ tazelik,
963
+ indexedThrough,
964
+ indexedTotal,
965
+ queryMs: Date.now() - start
966
+ });
967
+ this.log.info("query", {
968
+ file: input.file,
969
+ status: response.status,
970
+ verdict: response.verdict,
971
+ confidence: response.confidence,
972
+ query_ms: response.metadata.query_ms
973
+ });
974
+ this.failures.recordSuccess();
975
+ return response;
976
+ }
977
+ async close() {
978
+ this.log.info("api_close", {});
979
+ this.log.close();
980
+ this.db.close();
981
+ await this.pool.close();
982
+ }
983
+ };
984
+ export {
985
+ MemoryAPI
986
+ };