@timmeck/brain-core 2.15.0 → 2.17.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.
Files changed (62) hide show
  1. package/dist/api/server.js +10 -0
  2. package/dist/api/server.js.map +1 -1
  3. package/dist/codegen/code-generator.d.ts +42 -0
  4. package/dist/codegen/code-generator.js +225 -0
  5. package/dist/codegen/code-generator.js.map +1 -0
  6. package/dist/codegen/code-miner.d.ts +45 -0
  7. package/dist/codegen/code-miner.js +243 -0
  8. package/dist/codegen/code-miner.js.map +1 -0
  9. package/dist/codegen/context-builder.d.ts +15 -0
  10. package/dist/codegen/context-builder.js +137 -0
  11. package/dist/codegen/context-builder.js.map +1 -0
  12. package/dist/codegen/index.d.ts +5 -0
  13. package/dist/codegen/index.js +5 -0
  14. package/dist/codegen/index.js.map +1 -0
  15. package/dist/codegen/pattern-extractor.d.ts +25 -0
  16. package/dist/codegen/pattern-extractor.js +222 -0
  17. package/dist/codegen/pattern-extractor.js.map +1 -0
  18. package/dist/codegen/types.d.ts +127 -0
  19. package/dist/codegen/types.js +3 -0
  20. package/dist/codegen/types.js.map +1 -0
  21. package/dist/consciousness/consciousness-server.js +10 -0
  22. package/dist/consciousness/consciousness-server.js.map +1 -1
  23. package/dist/index.d.ts +12 -1
  24. package/dist/index.js +12 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/mcp/http-server.js +10 -0
  27. package/dist/mcp/http-server.js.map +1 -1
  28. package/dist/prediction/types.d.ts +1 -1
  29. package/dist/research/adapters/index.d.ts +1 -0
  30. package/dist/research/adapters/index.js +1 -0
  31. package/dist/research/adapters/index.js.map +1 -1
  32. package/dist/research/adapters/scanner-adapter.d.ts +14 -0
  33. package/dist/research/adapters/scanner-adapter.js +209 -0
  34. package/dist/research/adapters/scanner-adapter.js.map +1 -0
  35. package/dist/research/data-miner.d.ts +3 -1
  36. package/dist/research/data-miner.js +79 -69
  37. package/dist/research/data-miner.js.map +1 -1
  38. package/dist/research/research-orchestrator.d.ts +13 -1
  39. package/dist/research/research-orchestrator.js +78 -2
  40. package/dist/research/research-orchestrator.js.map +1 -1
  41. package/dist/scanner/crypto-collector.d.ts +17 -0
  42. package/dist/scanner/crypto-collector.js +89 -0
  43. package/dist/scanner/crypto-collector.js.map +1 -0
  44. package/dist/scanner/github-collector.d.ts +22 -0
  45. package/dist/scanner/github-collector.js +104 -0
  46. package/dist/scanner/github-collector.js.map +1 -0
  47. package/dist/scanner/hn-collector.d.ts +17 -0
  48. package/dist/scanner/hn-collector.js +58 -0
  49. package/dist/scanner/hn-collector.js.map +1 -0
  50. package/dist/scanner/index.d.ts +6 -0
  51. package/dist/scanner/index.js +6 -0
  52. package/dist/scanner/index.js.map +1 -0
  53. package/dist/scanner/signal-scanner.d.ts +83 -0
  54. package/dist/scanner/signal-scanner.js +859 -0
  55. package/dist/scanner/signal-scanner.js.map +1 -0
  56. package/dist/scanner/signal-scorer.d.ts +31 -0
  57. package/dist/scanner/signal-scorer.js +205 -0
  58. package/dist/scanner/signal-scorer.js.map +1 -0
  59. package/dist/scanner/types.d.ts +170 -0
  60. package/dist/scanner/types.js +3 -0
  61. package/dist/scanner/types.js.map +1 -0
  62. package/package.json +1 -1
@@ -0,0 +1,859 @@
1
+ import Database from 'better-sqlite3';
2
+ import { getLogger } from '../utils/logger.js';
3
+ import { GitHubCollector } from './github-collector.js';
4
+ import { HnCollector } from './hn-collector.js';
5
+ import { CryptoCollector } from './crypto-collector.js';
6
+ import { scoreRepo, classifyWithHysteresis, scoreCrypto } from './signal-scorer.js';
7
+ const log = getLogger();
8
+ // ── Migration ───────────────────────────────────────────────
9
+ export function runScannerMigration(db) {
10
+ db.exec(`
11
+ CREATE TABLE IF NOT EXISTS scanned_repos (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ github_id INTEGER UNIQUE NOT NULL,
14
+ full_name TEXT NOT NULL,
15
+ name TEXT NOT NULL,
16
+ owner TEXT NOT NULL,
17
+ url TEXT NOT NULL,
18
+ description TEXT,
19
+ language TEXT,
20
+ topics TEXT DEFAULT '[]',
21
+ created_at TEXT,
22
+ first_seen_at TEXT DEFAULT (datetime('now')),
23
+ current_stars INTEGER DEFAULT 0,
24
+ current_forks INTEGER DEFAULT 0,
25
+ current_watchers INTEGER DEFAULT 0,
26
+ current_issues INTEGER DEFAULT 0,
27
+ signal_score REAL DEFAULT 0,
28
+ signal_level TEXT DEFAULT 'noise',
29
+ phase TEXT DEFAULT 'discovery',
30
+ peak_signal_level TEXT,
31
+ peak_level_since TEXT,
32
+ star_velocity_24h INTEGER DEFAULT 0,
33
+ star_velocity_7d INTEGER DEFAULT 0,
34
+ star_acceleration REAL DEFAULT 0,
35
+ last_scanned_at TEXT,
36
+ is_active INTEGER DEFAULT 1
37
+ );
38
+
39
+ CREATE INDEX IF NOT EXISTS idx_scanned_repos_level ON scanned_repos(signal_level);
40
+ CREATE INDEX IF NOT EXISTS idx_scanned_repos_score ON scanned_repos(signal_score DESC);
41
+ CREATE INDEX IF NOT EXISTS idx_scanned_repos_language ON scanned_repos(language);
42
+ CREATE INDEX IF NOT EXISTS idx_scanned_repos_stars ON scanned_repos(current_stars DESC);
43
+
44
+ CREATE TABLE IF NOT EXISTS repo_daily_stats (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ repo_id INTEGER NOT NULL,
47
+ date TEXT NOT NULL,
48
+ stars INTEGER DEFAULT 0,
49
+ forks INTEGER DEFAULT 0,
50
+ watchers INTEGER DEFAULT 0,
51
+ issues INTEGER DEFAULT 0,
52
+ star_velocity_24h INTEGER DEFAULT 0,
53
+ star_velocity_7d INTEGER DEFAULT 0,
54
+ star_acceleration REAL DEFAULT 0,
55
+ fork_velocity_24h INTEGER DEFAULT 0,
56
+ UNIQUE(repo_id, date)
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS hn_mentions (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ hn_id INTEGER UNIQUE,
62
+ title TEXT NOT NULL,
63
+ url TEXT,
64
+ score INTEGER DEFAULT 0,
65
+ comment_count INTEGER DEFAULT 0,
66
+ author TEXT,
67
+ posted_at TEXT,
68
+ detected_at TEXT DEFAULT (datetime('now')),
69
+ repo_id INTEGER
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS crypto_tokens (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ coingecko_id TEXT UNIQUE NOT NULL,
75
+ symbol TEXT NOT NULL,
76
+ name TEXT NOT NULL,
77
+ category TEXT,
78
+ current_price REAL,
79
+ market_cap REAL,
80
+ market_cap_rank INTEGER,
81
+ price_change_24h REAL,
82
+ price_change_7d REAL,
83
+ total_volume REAL,
84
+ signal_score REAL DEFAULT 0,
85
+ signal_level TEXT DEFAULT 'noise',
86
+ last_scanned_at TEXT,
87
+ is_active INTEGER DEFAULT 1
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS scanner_state (
91
+ key TEXT PRIMARY KEY,
92
+ value TEXT NOT NULL,
93
+ updated_at TEXT DEFAULT (datetime('now'))
94
+ );
95
+ `);
96
+ }
97
+ // ── Scanner ─────────────────────────────────────────────────
98
+ export class SignalScanner {
99
+ db;
100
+ config;
101
+ github;
102
+ hn;
103
+ crypto;
104
+ timer = null;
105
+ scanning = false;
106
+ lastResult = null;
107
+ constructor(db, config) {
108
+ this.db = db;
109
+ this.config = {
110
+ enabled: config.enabled ?? true,
111
+ githubToken: config.githubToken ?? process.env['GITHUB_TOKEN'] ?? '',
112
+ scanIntervalMs: config.scanIntervalMs ?? 21_600_000, // 6h
113
+ minStarsEmerging: config.minStarsEmerging ?? 15,
114
+ minStarsTrending: config.minStarsTrending ?? 200,
115
+ maxReposPerScan: config.maxReposPerScan ?? 5000,
116
+ cryptoEnabled: config.cryptoEnabled ?? true,
117
+ hnEnabled: config.hnEnabled ?? true,
118
+ };
119
+ this.github = new GitHubCollector(this.config);
120
+ this.hn = new HnCollector();
121
+ this.crypto = new CryptoCollector();
122
+ runScannerMigration(db);
123
+ // Load last result from state
124
+ this.loadLastResult();
125
+ }
126
+ /** Start periodic scanning. */
127
+ start() {
128
+ if (!this.config.enabled || this.timer)
129
+ return;
130
+ if (!this.config.githubToken) {
131
+ log.warn('[scanner] No GITHUB_TOKEN — scanner disabled');
132
+ return;
133
+ }
134
+ log.info(`[scanner] Starting (interval: ${this.config.scanIntervalMs}ms)`);
135
+ // First scan after 30 seconds (let other engines initialize)
136
+ setTimeout(() => {
137
+ this.scan().catch(err => log.error(`[scanner] Initial scan error: ${err.message}`));
138
+ }, 30_000);
139
+ this.timer = setInterval(() => {
140
+ this.scan().catch(err => log.error(`[scanner] Periodic scan error: ${err.message}`));
141
+ }, this.config.scanIntervalMs);
142
+ }
143
+ /** Stop periodic scanning. */
144
+ stop() {
145
+ if (this.timer) {
146
+ clearInterval(this.timer);
147
+ this.timer = null;
148
+ }
149
+ this.abortScan();
150
+ }
151
+ /** Abort currently running scan. */
152
+ abortScan() {
153
+ this.github.abort();
154
+ this.hn.abort();
155
+ this.crypto.abort();
156
+ this.scanning = false;
157
+ }
158
+ /** Run a full scan pipeline. */
159
+ async scan() {
160
+ if (this.scanning) {
161
+ log.warn('[scanner] Scan already in progress');
162
+ return this.lastResult ?? createEmptyResult();
163
+ }
164
+ this.scanning = true;
165
+ this.github.reset();
166
+ this.hn.reset();
167
+ this.crypto.reset();
168
+ const started = new Date().toISOString();
169
+ const start = Date.now();
170
+ const errors = [];
171
+ let reposDiscovered = 0;
172
+ let reposUpdated = 0;
173
+ let newBreakouts = 0;
174
+ let newSignals = 0;
175
+ let hnMentionsFound = 0;
176
+ let cryptoTokensScanned = 0;
177
+ try {
178
+ // Step 1: GitHub Emerging Repos
179
+ log.info('[scanner] Step 1/8: Collecting emerging repos...');
180
+ let emerging = [];
181
+ try {
182
+ emerging = await this.github.collectEmerging();
183
+ log.info(`[scanner] Found ${emerging.length} emerging repos`);
184
+ }
185
+ catch (err) {
186
+ errors.push(`emerging: ${err.message}`);
187
+ }
188
+ // Step 2: GitHub Trending Repos
189
+ log.info('[scanner] Step 2/8: Collecting trending repos...');
190
+ let trending = [];
191
+ try {
192
+ trending = await this.github.collectTrending();
193
+ log.info(`[scanner] Found ${trending.length} trending repos`);
194
+ }
195
+ catch (err) {
196
+ errors.push(`trending: ${err.message}`);
197
+ }
198
+ // Step 3: Upsert repos + calculate velocity
199
+ log.info('[scanner] Step 3/8: Upserting repos and calculating velocity...');
200
+ const allRepos = dedupeRepos([...emerging, ...trending]);
201
+ for (const ghRepo of allRepos) {
202
+ const isNew = this.upsertRepo(ghRepo);
203
+ if (isNew)
204
+ reposDiscovered++;
205
+ else
206
+ reposUpdated++;
207
+ }
208
+ this.calculateVelocities();
209
+ log.info(`[scanner] Upserted: ${reposDiscovered} new, ${reposUpdated} updated`);
210
+ // Step 4: HN Mentions
211
+ if (this.config.hnEnabled) {
212
+ log.info('[scanner] Step 4/8: Scanning HackerNews...');
213
+ try {
214
+ const hnHits = await this.hn.collectFrontpage();
215
+ hnMentionsFound = this.processHnMentions(hnHits);
216
+ log.info(`[scanner] HN: ${hnMentionsFound} mentions processed`);
217
+ }
218
+ catch (err) {
219
+ errors.push(`hn: ${err.message}`);
220
+ }
221
+ }
222
+ // Step 5: Score all active repos
223
+ log.info('[scanner] Step 5/8: Scoring repos...');
224
+ this.scoreAllRepos();
225
+ // Step 6: Classify with hysteresis
226
+ log.info('[scanner] Step 6/8: Classifying signal levels...');
227
+ const classResult = this.classifyAll();
228
+ newBreakouts = classResult.newBreakouts;
229
+ newSignals = classResult.newSignals;
230
+ // Step 7: Crypto scan
231
+ if (this.config.cryptoEnabled) {
232
+ log.info('[scanner] Step 7/8: Scanning crypto...');
233
+ try {
234
+ cryptoTokensScanned = await this.scanCrypto();
235
+ log.info(`[scanner] Crypto: ${cryptoTokensScanned} tokens scanned`);
236
+ }
237
+ catch (err) {
238
+ errors.push(`crypto: ${err.message}`);
239
+ }
240
+ }
241
+ // Step 8: Update state
242
+ log.info('[scanner] Step 8/8: Updating state...');
243
+ }
244
+ catch (err) {
245
+ errors.push(`fatal: ${err.message}`);
246
+ log.error(`[scanner] Fatal error: ${err.message}`);
247
+ }
248
+ finally {
249
+ this.scanning = false;
250
+ }
251
+ const result = {
252
+ started_at: started,
253
+ finished_at: new Date().toISOString(),
254
+ duration_ms: Date.now() - start,
255
+ repos_discovered: reposDiscovered,
256
+ repos_updated: reposUpdated,
257
+ new_breakouts: newBreakouts,
258
+ new_signals: newSignals,
259
+ hn_mentions_found: hnMentionsFound,
260
+ crypto_tokens_scanned: cryptoTokensScanned,
261
+ errors,
262
+ };
263
+ this.lastResult = result;
264
+ this.saveState('last_scan', JSON.stringify(result));
265
+ this.saveState('last_scan_at', result.finished_at);
266
+ log.info(`[scanner] Scan complete in ${result.duration_ms}ms — ${reposDiscovered} new, ${newBreakouts} breakouts, ${newSignals} signals`);
267
+ return result;
268
+ }
269
+ // ── Read Methods ─────────────────────────────────────────
270
+ getStatus() {
271
+ const counts = this.db.prepare(`
272
+ SELECT signal_level, COUNT(*) as count FROM scanned_repos
273
+ WHERE is_active = 1 GROUP BY signal_level
274
+ `).all();
275
+ const byLevel = { breakout: 0, signal: 0, watch: 0, noise: 0 };
276
+ let totalActive = 0;
277
+ for (const r of counts) {
278
+ byLevel[r.signal_level] = r.count;
279
+ totalActive += r.count;
280
+ }
281
+ const totalRepos = this.db.prepare('SELECT COUNT(*) as c FROM scanned_repos').get().c;
282
+ const nextScan = this.timer ? new Date(Date.now() + this.config.scanIntervalMs).toISOString() : null;
283
+ return {
284
+ running: this.scanning,
285
+ enabled: this.config.enabled && !!this.config.githubToken,
286
+ last_scan: this.lastResult,
287
+ total_repos: totalRepos,
288
+ total_active: totalActive,
289
+ by_level: byLevel,
290
+ next_scan_at: nextScan,
291
+ };
292
+ }
293
+ getSignals(level, limit = 50) {
294
+ const rows = this.db.prepare(`
295
+ SELECT * FROM scanned_repos
296
+ WHERE signal_level = ? AND is_active = 1
297
+ ORDER BY signal_score DESC
298
+ LIMIT ?
299
+ `).all(level, limit);
300
+ return rows.map(deserializeRepo);
301
+ }
302
+ getTrending(limit = 30) {
303
+ const rows = this.db.prepare(`
304
+ SELECT * FROM scanned_repos
305
+ WHERE is_active = 1
306
+ ORDER BY star_velocity_24h DESC
307
+ LIMIT ?
308
+ `).all(limit);
309
+ return rows.map(deserializeRepo);
310
+ }
311
+ searchRepos(query, language, limit = 50) {
312
+ let sql = 'SELECT * FROM scanned_repos WHERE is_active = 1';
313
+ const params = [];
314
+ if (query) {
315
+ sql += ' AND (full_name LIKE ? OR description LIKE ? OR topics LIKE ?)';
316
+ const like = `%${query}%`;
317
+ params.push(like, like, like);
318
+ }
319
+ if (language) {
320
+ sql += ' AND language = ?';
321
+ params.push(language);
322
+ }
323
+ sql += ' ORDER BY signal_score DESC LIMIT ?';
324
+ params.push(limit);
325
+ const rows = this.db.prepare(sql).all(...params);
326
+ return rows.map(deserializeRepo);
327
+ }
328
+ getRepo(githubId) {
329
+ const row = this.db.prepare('SELECT * FROM scanned_repos WHERE github_id = ?').get(githubId);
330
+ if (!row)
331
+ return null;
332
+ const repo = deserializeRepo(row);
333
+ const daily = this.db.prepare(`
334
+ SELECT * FROM repo_daily_stats WHERE repo_id = ?
335
+ ORDER BY date DESC LIMIT 30
336
+ `).all(repo.id);
337
+ return { ...repo, daily_stats: daily };
338
+ }
339
+ getHnMentions(limit = 50) {
340
+ return this.db.prepare(`
341
+ SELECT * FROM hn_mentions ORDER BY score DESC LIMIT ?
342
+ `).all(limit);
343
+ }
344
+ getCryptoTokens(limit = 50) {
345
+ return this.db.prepare(`
346
+ SELECT * FROM crypto_tokens WHERE is_active = 1
347
+ ORDER BY signal_score DESC LIMIT ?
348
+ `).all(limit);
349
+ }
350
+ getCryptoTrending() {
351
+ return this.db.prepare(`
352
+ SELECT * FROM crypto_tokens WHERE is_active = 1
353
+ ORDER BY ABS(COALESCE(price_change_24h, 0)) DESC LIMIT 20
354
+ `).all();
355
+ }
356
+ getStats() {
357
+ const totalRepos = this.db.prepare('SELECT COUNT(*) as c FROM scanned_repos').get().c;
358
+ const activeRepos = this.db.prepare('SELECT COUNT(*) as c FROM scanned_repos WHERE is_active = 1').get().c;
359
+ const byLanguage = this.db.prepare(`
360
+ SELECT language, COUNT(*) as count FROM scanned_repos
361
+ WHERE is_active = 1 AND language IS NOT NULL
362
+ GROUP BY language ORDER BY count DESC LIMIT 20
363
+ `).all();
364
+ const byLevel = this.db.prepare(`
365
+ SELECT signal_level, COUNT(*) as count FROM scanned_repos
366
+ WHERE is_active = 1 GROUP BY signal_level
367
+ `).all();
368
+ const hnTotal = this.db.prepare('SELECT COUNT(*) as c FROM hn_mentions').get().c;
369
+ const cryptoTotal = this.db.prepare('SELECT COUNT(*) as c FROM crypto_tokens WHERE is_active = 1').get().c;
370
+ const avgScore = this.db.prepare('SELECT AVG(signal_score) as avg FROM scanned_repos WHERE is_active = 1').get().avg ?? 0;
371
+ return {
372
+ total_repos: totalRepos,
373
+ active_repos: activeRepos,
374
+ by_language: byLanguage,
375
+ by_level: byLevel,
376
+ hn_mentions: hnTotal,
377
+ crypto_tokens: cryptoTotal,
378
+ avg_score: Math.round(avgScore * 100) / 100,
379
+ last_scan: this.lastResult,
380
+ };
381
+ }
382
+ getConfig() {
383
+ return { ...this.config };
384
+ }
385
+ updateConfig(updates) {
386
+ Object.assign(this.config, updates);
387
+ return { ...this.config };
388
+ }
389
+ // ── Internal Write Methods ───────────────────────────────
390
+ /** Upsert a GitHub repo. Returns true if new. */
391
+ upsertRepo(gh) {
392
+ const existing = this.db.prepare('SELECT id FROM scanned_repos WHERE github_id = ?').get(gh.id);
393
+ const now = new Date().toISOString();
394
+ if (!existing) {
395
+ this.db.prepare(`
396
+ INSERT INTO scanned_repos (github_id, full_name, name, owner, url, description, language, topics, created_at, current_stars, current_forks, current_watchers, current_issues, last_scanned_at)
397
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
398
+ `).run(gh.id, gh.full_name, gh.name, gh.owner.login, gh.html_url, gh.description, gh.language, JSON.stringify(gh.topics ?? []), gh.created_at, gh.stargazers_count, gh.forks_count, gh.watchers_count, gh.open_issues_count, now);
399
+ // Record first daily stats
400
+ const repoId = this.db.prepare('SELECT id FROM scanned_repos WHERE github_id = ?').get(gh.id).id;
401
+ this.recordDailyStats(repoId, gh.stargazers_count, gh.forks_count, gh.watchers_count, gh.open_issues_count);
402
+ return true;
403
+ }
404
+ // Update existing
405
+ this.db.prepare(`
406
+ UPDATE scanned_repos SET
407
+ current_stars = ?, current_forks = ?, current_watchers = ?,
408
+ current_issues = ?, description = ?, language = ?,
409
+ topics = ?, last_scanned_at = ?
410
+ WHERE github_id = ?
411
+ `).run(gh.stargazers_count, gh.forks_count, gh.watchers_count, gh.open_issues_count, gh.description, gh.language, JSON.stringify(gh.topics ?? []), now, gh.id);
412
+ // Record daily stats
413
+ const row = this.db.prepare('SELECT id FROM scanned_repos WHERE github_id = ?').get(gh.id);
414
+ this.recordDailyStats(row.id, gh.stargazers_count, gh.forks_count, gh.watchers_count, gh.open_issues_count);
415
+ return false;
416
+ }
417
+ /** Record daily stats (UPSERT). */
418
+ recordDailyStats(repoId, stars, forks, watchers, issues) {
419
+ const today = new Date().toISOString().split('T')[0];
420
+ this.db.prepare(`
421
+ INSERT INTO repo_daily_stats (repo_id, date, stars, forks, watchers, issues)
422
+ VALUES (?, ?, ?, ?, ?, ?)
423
+ ON CONFLICT(repo_id, date) DO UPDATE SET
424
+ stars = excluded.stars, forks = excluded.forks,
425
+ watchers = excluded.watchers, issues = excluded.issues
426
+ `).run(repoId, today, stars, forks, watchers, issues);
427
+ }
428
+ /** Calculate velocity for all active repos from daily_stats. */
429
+ calculateVelocities() {
430
+ const repos = this.db.prepare('SELECT id FROM scanned_repos WHERE is_active = 1').all();
431
+ const today = new Date().toISOString().split('T')[0];
432
+ for (const { id } of repos) {
433
+ const stats = this.db.prepare(`
434
+ SELECT date, stars FROM repo_daily_stats
435
+ WHERE repo_id = ? ORDER BY date DESC LIMIT 8
436
+ `).all(id);
437
+ if (stats.length < 2)
438
+ continue;
439
+ const todayStars = stats[0].stars;
440
+ const yesterday = stats.find(s => s.date !== today);
441
+ const weekAgo = stats[stats.length - 1];
442
+ const vel24h = yesterday ? Math.max(0, todayStars - yesterday.stars) : 0;
443
+ const vel7d = weekAgo ? Math.max(0, todayStars - weekAgo.stars) : 0;
444
+ // Acceleration: change in velocity
445
+ let accel = 0;
446
+ if (stats.length >= 3) {
447
+ const prevVel = stats[1].stars - stats[2].stars;
448
+ const curVel = stats[0].stars - stats[1].stars;
449
+ accel = curVel - prevVel;
450
+ }
451
+ this.db.prepare(`
452
+ UPDATE scanned_repos SET star_velocity_24h = ?, star_velocity_7d = ?, star_acceleration = ?
453
+ WHERE id = ?
454
+ `).run(vel24h, vel7d, accel, id);
455
+ // Also update today's daily_stats
456
+ this.db.prepare(`
457
+ UPDATE repo_daily_stats SET star_velocity_24h = ?, star_velocity_7d = ?, star_acceleration = ?
458
+ WHERE repo_id = ? AND date = ?
459
+ `).run(vel24h, vel7d, accel, id, today);
460
+ }
461
+ }
462
+ /** Process HN mentions and link to repos. */
463
+ processHnMentions(hits) {
464
+ let count = 0;
465
+ for (const hit of hits) {
466
+ const hnId = parseInt(hit.objectID, 10);
467
+ const existing = this.db.prepare('SELECT id FROM hn_mentions WHERE hn_id = ?').get(hnId);
468
+ if (existing)
469
+ continue;
470
+ // Try to match URL to a repo
471
+ let repoId = null;
472
+ if (hit.url && hit.url.includes('github.com')) {
473
+ const match = hit.url.match(/github\.com\/([^/]+\/[^/]+)/);
474
+ if (match) {
475
+ const repo = this.db.prepare('SELECT id FROM scanned_repos WHERE full_name = ?').get(match[1]);
476
+ repoId = repo?.id ?? null;
477
+ }
478
+ }
479
+ this.db.prepare(`
480
+ INSERT INTO hn_mentions (hn_id, title, url, score, comment_count, author, posted_at, repo_id)
481
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
482
+ `).run(hnId, hit.title, hit.url, hit.points, hit.num_comments, hit.author, hit.created_at, repoId);
483
+ count++;
484
+ }
485
+ return count;
486
+ }
487
+ /** Score all active repos. */
488
+ scoreAllRepos() {
489
+ const repos = this.db.prepare(`
490
+ SELECT * FROM scanned_repos WHERE is_active = 1
491
+ `).all();
492
+ for (const repo of repos) {
493
+ // Get HN mentions for this repo
494
+ const mentions = repo.id
495
+ ? this.db.prepare('SELECT score, comment_count FROM hn_mentions WHERE repo_id = ?').all(repo.id)
496
+ : [];
497
+ const deserialized = deserializeRepo(repo);
498
+ const breakdown = scoreRepo(deserialized, mentions);
499
+ this.db.prepare(`
500
+ UPDATE scanned_repos SET signal_score = ? WHERE id = ?
501
+ `).run(breakdown.total, repo.id);
502
+ }
503
+ }
504
+ /** Classify all repos with hysteresis. Returns new breakout/signal counts. */
505
+ classifyAll() {
506
+ const repos = this.db.prepare(`
507
+ SELECT id, signal_score, signal_level, peak_signal_level, peak_level_since
508
+ FROM scanned_repos WHERE is_active = 1
509
+ `).all();
510
+ let newBreakouts = 0;
511
+ let newSignals = 0;
512
+ for (const repo of repos) {
513
+ const { level, peak, peakSince } = classifyWithHysteresis(repo.signal_score, repo.signal_level, repo.peak_signal_level, repo.peak_level_since);
514
+ if (level !== repo.signal_level) {
515
+ if (level === 'breakout' && repo.signal_level !== 'breakout')
516
+ newBreakouts++;
517
+ if (level === 'signal' && repo.signal_level !== 'signal' && repo.signal_level !== 'breakout')
518
+ newSignals++;
519
+ }
520
+ this.db.prepare(`
521
+ UPDATE scanned_repos SET signal_level = ?, phase = ?, peak_signal_level = ?, peak_level_since = ?
522
+ WHERE id = ?
523
+ `).run(level, this.getPhase(repo.signal_score), peak, peakSince, repo.id);
524
+ }
525
+ return { newBreakouts, newSignals };
526
+ }
527
+ getPhase(score) {
528
+ // Phase is based on stars, but we need the stars from DB
529
+ // This is a simplified version; real phase is set during scoring
530
+ return 'discovery';
531
+ }
532
+ /** Scan crypto tokens. */
533
+ async scanCrypto() {
534
+ let count = 0;
535
+ const now = new Date().toISOString();
536
+ // Watchlist
537
+ const watchlist = await this.crypto.collectWatchlist();
538
+ for (const coin of watchlist) {
539
+ this.upsertCrypto(coin, 'watchlist', now);
540
+ count++;
541
+ }
542
+ // Trending
543
+ const trending = await this.crypto.collectTrending();
544
+ if (trending) {
545
+ for (const item of trending.coins) {
546
+ // Trending API returns minimal data; mark as trending
547
+ this.db.prepare(`
548
+ INSERT INTO crypto_tokens (coingecko_id, symbol, name, category, market_cap_rank, last_scanned_at)
549
+ VALUES (?, ?, ?, 'trending', ?, ?)
550
+ ON CONFLICT(coingecko_id) DO UPDATE SET category = 'trending', last_scanned_at = ?
551
+ `).run(item.item.id, item.item.symbol, item.item.name, item.item.market_cap_rank, now, now);
552
+ count++;
553
+ }
554
+ }
555
+ return count;
556
+ }
557
+ upsertCrypto(coin, category, now) {
558
+ const { score, level } = scoreCrypto(coin.price_change_percentage_24h, coin.price_change_percentage_7d_in_currency ?? null, coin.total_volume, coin.market_cap);
559
+ this.db.prepare(`
560
+ INSERT INTO crypto_tokens (coingecko_id, symbol, name, category, current_price, market_cap, market_cap_rank, price_change_24h, price_change_7d, total_volume, signal_score, signal_level, last_scanned_at)
561
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
562
+ ON CONFLICT(coingecko_id) DO UPDATE SET
563
+ current_price = excluded.current_price, market_cap = excluded.market_cap,
564
+ market_cap_rank = excluded.market_cap_rank, price_change_24h = excluded.price_change_24h,
565
+ price_change_7d = excluded.price_change_7d, total_volume = excluded.total_volume,
566
+ signal_score = excluded.signal_score, signal_level = excluded.signal_level,
567
+ last_scanned_at = excluded.last_scanned_at
568
+ `).run(coin.id, coin.symbol, coin.name, category, coin.current_price, coin.market_cap, coin.market_cap_rank, coin.price_change_percentage_24h, coin.price_change_percentage_7d_in_currency ?? null, coin.total_volume, score, level, now);
569
+ }
570
+ // ── Bulk Import from Reposignal API ─────────────────────────
571
+ /**
572
+ * Import repos from the reposignal.dev API into scanned_repos.
573
+ * Fetches all signals from the live API and upserts them.
574
+ */
575
+ async importFromApi(apiUrl = 'https://www.reposignal.dev/api/signals', options = {}) {
576
+ const start = Date.now();
577
+ const limit = options.limit ?? 50000;
578
+ let url = `${apiUrl}?limit=${limit}`;
579
+ if (options.level)
580
+ url += `&level=${options.level}`;
581
+ if (options.adminKey)
582
+ url += `&key=${options.adminKey}`;
583
+ log.info(`[scanner] Fetching repos from ${apiUrl} (limit=${limit})...`);
584
+ const res = await fetch(url);
585
+ if (!res.ok) {
586
+ throw new Error(`API request failed: ${res.status} ${res.statusText}`);
587
+ }
588
+ const data = await res.json();
589
+ const signals = data.signals ?? [];
590
+ log.info(`[scanner] Received ${signals.length} repos from API`);
591
+ let repos = 0, skipped = 0;
592
+ const insertRepo = this.db.prepare(`
593
+ INSERT INTO scanned_repos (
594
+ github_id, full_name, name, owner, url, description, language, topics,
595
+ created_at, first_seen_at, current_stars, current_forks, current_watchers, current_issues,
596
+ signal_score, signal_level, phase, peak_signal_level, peak_level_since,
597
+ star_velocity_24h, star_velocity_7d, star_acceleration, last_scanned_at, is_active
598
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
599
+ ON CONFLICT(github_id) DO UPDATE SET
600
+ current_stars = excluded.current_stars, current_forks = excluded.current_forks,
601
+ current_watchers = excluded.current_watchers, current_issues = excluded.current_issues,
602
+ signal_score = excluded.signal_score, signal_level = excluded.signal_level,
603
+ phase = excluded.phase, star_velocity_24h = excluded.star_velocity_24h,
604
+ star_velocity_7d = excluded.star_velocity_7d, star_acceleration = excluded.star_acceleration,
605
+ last_scanned_at = excluded.last_scanned_at, description = excluded.description,
606
+ language = excluded.language, topics = excluded.topics
607
+ `);
608
+ const importBatch = this.db.transaction(() => {
609
+ for (const r of signals) {
610
+ const ghId = r.github_id;
611
+ if (!ghId) {
612
+ skipped++;
613
+ continue;
614
+ }
615
+ try {
616
+ const topics = Array.isArray(r.topics) ? JSON.stringify(r.topics) : (r.topics ?? '[]');
617
+ insertRepo.run(ghId, r.full_name, r.name ?? r.full_name.split('/')[1], r.owner ?? r.full_name.split('/')[0], r.url ?? `https://github.com/${r.full_name}`, r.description, r.language, topics, r.created_at, r.first_seen_at ?? new Date().toISOString(), r.current_stars ?? 0, r.current_forks ?? 0, r.current_watchers ?? 0, r.current_issues ?? 0, r.signal_score ?? 0, r.signal_level ?? 'noise', r.phase ?? 'discovery', r.peak_signal_level ?? r.signal_level, r.peak_level_since, r.star_velocity_24h ?? 0, r.star_velocity_7d ?? 0, r.star_acceleration ?? 0, r.last_scanned_at, r.is_active ?? 1);
618
+ repos++;
619
+ }
620
+ catch {
621
+ skipped++;
622
+ }
623
+ }
624
+ });
625
+ importBatch();
626
+ const duration_ms = Date.now() - start;
627
+ log.info(`[scanner] API import complete: ${repos} repos imported, ${skipped} skipped in ${duration_ms}ms`);
628
+ this.saveState('api_import', JSON.stringify({ repos, skipped, duration_ms, source: apiUrl, importedAt: new Date().toISOString() }));
629
+ return { repos, skipped, duration_ms };
630
+ }
631
+ // ── Bulk Import from Reposignal DB ────────────────────────
632
+ /**
633
+ * Import repos from a reposignal/aisurvival SQLite database directly into scanned_repos.
634
+ * Copies: repositories → scanned_repos, repo_daily_stats → repo_daily_stats,
635
+ * hn_mentions → hn_mentions, crypto_tokens → crypto_tokens.
636
+ */
637
+ importFromReposignal(dbPath, options = {}) {
638
+ const start = Date.now();
639
+ const minLevel = options.minLevel ?? 'noise'; // Import everything by default
640
+ const extDb = new Database(dbPath, { readonly: true });
641
+ let repos = 0, dailyStats = 0, hnMentions = 0, crypto = 0, skipped = 0;
642
+ try {
643
+ // 1. Import repositories → scanned_repos
644
+ const levelOrder = ['noise', 'watch', 'signal', 'breakout'];
645
+ const minIdx = levelOrder.indexOf(minLevel);
646
+ const allowed = levelOrder.filter((_, i) => i >= minIdx);
647
+ const placeholders = allowed.map(() => '?').join(',');
648
+ const extRepos = extDb.prepare(`
649
+ SELECT github_id, full_name, name, owner, url, description, language, topics,
650
+ created_at, first_seen_at, current_stars, current_forks, current_watchers, current_issues,
651
+ signal_score, signal_level, phase, star_velocity_24h, star_velocity_7d, star_acceleration,
652
+ last_scanned_at, is_active
653
+ FROM repositories
654
+ WHERE signal_level IN (${placeholders})
655
+ ORDER BY signal_score DESC
656
+ `).all(...allowed);
657
+ log.info(`[scanner] Importing ${extRepos.length} repos from reposignal DB...`);
658
+ const insertRepo = this.db.prepare(`
659
+ INSERT INTO scanned_repos (
660
+ github_id, full_name, name, owner, url, description, language, topics,
661
+ created_at, first_seen_at, current_stars, current_forks, current_watchers, current_issues,
662
+ signal_score, signal_level, phase, peak_signal_level, peak_level_since,
663
+ star_velocity_24h, star_velocity_7d, star_acceleration, last_scanned_at, is_active
664
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
665
+ ON CONFLICT(github_id) DO UPDATE SET
666
+ current_stars = excluded.current_stars, current_forks = excluded.current_forks,
667
+ current_watchers = excluded.current_watchers, current_issues = excluded.current_issues,
668
+ signal_score = excluded.signal_score, signal_level = excluded.signal_level,
669
+ phase = excluded.phase, star_velocity_24h = excluded.star_velocity_24h,
670
+ star_velocity_7d = excluded.star_velocity_7d, star_acceleration = excluded.star_acceleration,
671
+ last_scanned_at = excluded.last_scanned_at, description = excluded.description,
672
+ language = excluded.language, topics = excluded.topics
673
+ `);
674
+ const importRepos = this.db.transaction(() => {
675
+ for (const r of extRepos) {
676
+ try {
677
+ insertRepo.run(r.github_id, r.full_name, r.name, r.owner, r.url ?? `https://github.com/${r.full_name}`, r.description, r.language, r.topics ?? '[]', r.created_at, r.first_seen_at ?? new Date().toISOString(), r.current_stars ?? 0, r.current_forks ?? 0, r.current_watchers ?? 0, r.current_issues ?? 0, r.signal_score ?? 0, r.signal_level ?? 'noise', r.phase ?? 'discovery', r.signal_level, new Date().toISOString(), r.star_velocity_24h ?? 0, r.star_velocity_7d ?? 0, r.star_acceleration ?? 0, r.last_scanned_at, r.is_active ?? 1);
678
+ repos++;
679
+ }
680
+ catch {
681
+ skipped++;
682
+ }
683
+ }
684
+ });
685
+ importRepos();
686
+ log.info(`[scanner] Imported ${repos} repos (${skipped} skipped)`);
687
+ // 2. Import repo_daily_stats (need to map repo_id from github_id)
688
+ try {
689
+ const idMap = new Map();
690
+ const mappings = this.db.prepare('SELECT id, github_id FROM scanned_repos').all();
691
+ for (const m of mappings)
692
+ idMap.set(m.github_id, m.id);
693
+ // Get external repo id → github_id mapping
694
+ const extIdMap = new Map();
695
+ const extMappings = extDb.prepare('SELECT id, github_id FROM repositories').all();
696
+ for (const m of extMappings)
697
+ extIdMap.set(m.id, m.github_id);
698
+ const extStats = extDb.prepare(`
699
+ SELECT repo_id, date, stars, forks, watchers, issues,
700
+ star_velocity_24h, star_velocity_7d, star_acceleration, fork_velocity_24h
701
+ FROM repo_daily_stats ORDER BY date DESC
702
+ `).all();
703
+ const insertStats = this.db.prepare(`
704
+ INSERT INTO repo_daily_stats (repo_id, date, stars, forks, watchers, issues,
705
+ star_velocity_24h, star_velocity_7d, star_acceleration, fork_velocity_24h)
706
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
707
+ ON CONFLICT(repo_id, date) DO NOTHING
708
+ `);
709
+ const importStats = this.db.transaction(() => {
710
+ for (const s of extStats) {
711
+ const ghId = extIdMap.get(s.repo_id);
712
+ if (!ghId)
713
+ continue;
714
+ const localId = idMap.get(ghId);
715
+ if (!localId)
716
+ continue;
717
+ try {
718
+ insertStats.run(localId, s.date, s.stars ?? 0, s.forks ?? 0, s.watchers ?? 0, s.issues ?? 0, s.star_velocity_24h ?? 0, s.star_velocity_7d ?? 0, s.star_acceleration ?? 0, s.fork_velocity_24h ?? 0);
719
+ dailyStats++;
720
+ }
721
+ catch { /* skip duplicates */ }
722
+ }
723
+ });
724
+ importStats();
725
+ log.info(`[scanner] Imported ${dailyStats} daily stats`);
726
+ }
727
+ catch (err) {
728
+ log.warn(`[scanner] daily_stats import skipped: ${err.message}`);
729
+ }
730
+ // 3. Import hn_mentions
731
+ try {
732
+ const extHn = extDb.prepare(`
733
+ SELECT hn_id, title, url, score, comment_count, author, posted_at, repo_id
734
+ FROM hn_mentions ORDER BY score DESC
735
+ `).all();
736
+ const insertHn = this.db.prepare(`
737
+ INSERT INTO hn_mentions (hn_id, title, url, score, comment_count, author, posted_at, repo_id)
738
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
739
+ ON CONFLICT(hn_id) DO NOTHING
740
+ `);
741
+ // Map ext repo_id to local repo_id via github_id
742
+ const idMap = new Map();
743
+ const mappings = this.db.prepare('SELECT id, github_id FROM scanned_repos').all();
744
+ for (const m of mappings)
745
+ idMap.set(m.github_id, m.id);
746
+ const extIdMap = new Map();
747
+ try {
748
+ const extMappings = extDb.prepare('SELECT id, github_id FROM repositories').all();
749
+ for (const m of extMappings)
750
+ extIdMap.set(m.id, m.github_id);
751
+ }
752
+ catch { /* no repositories table */ }
753
+ const importHn = this.db.transaction(() => {
754
+ for (const h of extHn) {
755
+ let localRepoId = null;
756
+ if (h.repo_id) {
757
+ const ghId = extIdMap.get(h.repo_id);
758
+ if (ghId)
759
+ localRepoId = idMap.get(ghId) ?? null;
760
+ }
761
+ try {
762
+ insertHn.run(h.hn_id, h.title, h.url, h.score ?? 0, h.comment_count ?? 0, h.author, h.posted_at, localRepoId);
763
+ hnMentions++;
764
+ }
765
+ catch { /* skip dupes */ }
766
+ }
767
+ });
768
+ importHn();
769
+ log.info(`[scanner] Imported ${hnMentions} HN mentions`);
770
+ }
771
+ catch (err) {
772
+ log.warn(`[scanner] hn_mentions import skipped: ${err.message}`);
773
+ }
774
+ // 4. Import crypto_tokens
775
+ try {
776
+ const extCrypto = extDb.prepare(`
777
+ SELECT coingecko_id, symbol, name, category, current_price, market_cap, market_cap_rank,
778
+ price_change_24h, price_change_7d, total_volume, signal_score, signal_level,
779
+ last_scanned_at, is_active
780
+ FROM crypto_tokens
781
+ `).all();
782
+ const insertCrypto = this.db.prepare(`
783
+ INSERT INTO crypto_tokens (coingecko_id, symbol, name, category, current_price, market_cap, market_cap_rank,
784
+ price_change_24h, price_change_7d, total_volume, signal_score, signal_level, last_scanned_at, is_active)
785
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
786
+ ON CONFLICT(coingecko_id) DO UPDATE SET
787
+ current_price = excluded.current_price, market_cap = excluded.market_cap,
788
+ signal_score = excluded.signal_score, signal_level = excluded.signal_level
789
+ `);
790
+ const importCrypto = this.db.transaction(() => {
791
+ for (const c of extCrypto) {
792
+ try {
793
+ insertCrypto.run(c.coingecko_id, c.symbol, c.name, c.category, c.current_price, c.market_cap, c.market_cap_rank, c.price_change_24h, c.price_change_7d, c.total_volume, c.signal_score ?? 0, c.signal_level ?? 'noise', c.last_scanned_at, c.is_active ?? 1);
794
+ crypto++;
795
+ }
796
+ catch { /* skip */ }
797
+ }
798
+ });
799
+ importCrypto();
800
+ log.info(`[scanner] Imported ${crypto} crypto tokens`);
801
+ }
802
+ catch (err) {
803
+ log.warn(`[scanner] crypto_tokens import skipped: ${err.message}`);
804
+ }
805
+ }
806
+ finally {
807
+ extDb.close();
808
+ }
809
+ const duration_ms = Date.now() - start;
810
+ log.info(`[scanner] Reposignal import complete: ${repos} repos, ${dailyStats} stats, ${hnMentions} HN, ${crypto} crypto in ${duration_ms}ms`);
811
+ // Save import state
812
+ this.saveState('reposignal_import', JSON.stringify({ repos, dailyStats, hnMentions, crypto, skipped, duration_ms, importedAt: new Date().toISOString() }));
813
+ return { repos, dailyStats, hnMentions, crypto, skipped, duration_ms };
814
+ }
815
+ // ── State Persistence ────────────────────────────────────
816
+ saveState(key, value) {
817
+ this.db.prepare(`
818
+ INSERT INTO scanner_state (key, value, updated_at)
819
+ VALUES (?, ?, datetime('now'))
820
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
821
+ `).run(key, value);
822
+ }
823
+ loadLastResult() {
824
+ const row = this.db.prepare('SELECT value FROM scanner_state WHERE key = ?').get('last_scan');
825
+ if (row) {
826
+ try {
827
+ this.lastResult = JSON.parse(row.value);
828
+ }
829
+ catch { /* ignore */ }
830
+ }
831
+ }
832
+ }
833
+ // ── Helpers ─────────────────────────────────────────────────
834
+ function dedupeRepos(repos) {
835
+ const seen = new Set();
836
+ return repos.filter(r => {
837
+ if (seen.has(r.id))
838
+ return false;
839
+ seen.add(r.id);
840
+ return true;
841
+ });
842
+ }
843
+ function deserializeRepo(row) {
844
+ return {
845
+ ...row,
846
+ topics: typeof row.topics === 'string' ? JSON.parse(row.topics) : (row.topics ?? []),
847
+ is_active: Boolean(row.is_active),
848
+ };
849
+ }
850
+ function createEmptyResult() {
851
+ const now = new Date().toISOString();
852
+ return {
853
+ started_at: now, finished_at: now, duration_ms: 0,
854
+ repos_discovered: 0, repos_updated: 0, new_breakouts: 0,
855
+ new_signals: 0, hn_mentions_found: 0, crypto_tokens_scanned: 0,
856
+ errors: ['Scan already in progress'],
857
+ };
858
+ }
859
+ //# sourceMappingURL=signal-scanner.js.map