@simonfestl/husky-cli 1.38.4 → 1.38.6

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,774 @@
1
+ import { Command } from "commander";
2
+ import { apiRequest } from "../lib/api-client.js";
3
+ import { isRedditConfigured, RedditClient } from "../lib/biz/reddit.js";
4
+ import { isYouTubeConfigured, YouTubeMonitorClient } from "../lib/biz/youtube-monitor.js";
5
+ import { isXConfigured, XClient } from "../lib/biz/x-client.js";
6
+ import { GoogleGenerativeAI } from "@google/generative-ai";
7
+ import { getConfig } from "./config.js";
8
+ import { ApiBrain } from "../lib/biz/api-brain.js";
9
+ import chalk from "chalk";
10
+ // ============================================================================
11
+ // Constants
12
+ // ============================================================================
13
+ /** Timeout for all API and fetch calls (ms) */
14
+ const API_TIMEOUT_MS = 15_000;
15
+ /** Maximum Reddit posts to fetch per subreddit */
16
+ const REDDIT_POSTS_PER_SUBREDDIT = 50;
17
+ /** Maximum YouTube videos to fetch per channel */
18
+ const YOUTUBE_VIDEOS_PER_CHANNEL = 10;
19
+ /** Maximum X posts to fetch per account */
20
+ const X_POSTS_PER_ACCOUNT = 10;
21
+ /** Default max age for YouTube videos (hours) */
22
+ const DEFAULT_YOUTUBE_MAX_AGE_HOURS = 48;
23
+ /** Maximum Reddit posts to include in summary prompt */
24
+ const SUMMARY_TOP_REDDIT_COUNT = 10;
25
+ /** Maximum YouTube videos to include in summary prompt */
26
+ const SUMMARY_TOP_YOUTUBE_COUNT = 5;
27
+ /** Maximum X posts to include in summary prompt */
28
+ const SUMMARY_TOP_X_COUNT = 5;
29
+ /** Maximum posts to display in markdown results */
30
+ const MARKDOWN_DISPLAY_LIMIT = 10;
31
+ /** Relevance score multiplier per matched keyword */
32
+ const RELEVANCE_SCORE_PER_KEYWORD = 20;
33
+ /** Maximum relevance score */
34
+ const MAX_RELEVANCE_SCORE = 100;
35
+ /** Maximum X post content length in the summary prompt */
36
+ const X_POST_CONTENT_PREVIEW_LENGTH = 200;
37
+ /** Maximum summary text length for notifications */
38
+ const NOTIFICATION_SUMMARY_MAX_LENGTH = 500;
39
+ /** Default job list limit */
40
+ const DEFAULT_JOB_LIST_LIMIT = "10";
41
+ /** Valid output formats for results command */
42
+ const VALID_RESULT_FORMATS = ["json", "markdown", "summary"];
43
+ /** Valid job statuses */
44
+ const VALID_JOB_STATUSES = ["pending", "running", "completed", "failed"];
45
+ // ============================================================================
46
+ // Helpers
47
+ // ============================================================================
48
+ /** Create an AbortSignal with the standard API timeout */
49
+ function apiTimeoutSignal() {
50
+ return AbortSignal.timeout(API_TIMEOUT_MS);
51
+ }
52
+ /**
53
+ * Sanitize user-generated content before interpolating into LLM prompts.
54
+ * Strips characters that could be used for prompt injection while keeping
55
+ * the text readable.
56
+ */
57
+ function sanitizeForPrompt(text) {
58
+ return text
59
+ // Remove control characters
60
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
61
+ // Collapse sequences of backticks (prevents markdown code-fence injection)
62
+ .replace(/`{3,}/g, "``")
63
+ // Remove system/role instruction patterns
64
+ .replace(/\b(system|assistant|user)\s*:/gi, "")
65
+ // Limit length to prevent flooding
66
+ .slice(0, 1000)
67
+ .trim();
68
+ }
69
+ /**
70
+ * Robustly extract a JSON object from an LLM response.
71
+ * Handles markdown code fences and extra text around the JSON.
72
+ */
73
+ function extractJsonFromLlmResponse(text) {
74
+ // Try markdown code fence first: ```json ... ``` or ``` ... ```
75
+ const fencedMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
76
+ if (fencedMatch) {
77
+ try {
78
+ return JSON.parse(fencedMatch[1]);
79
+ }
80
+ catch {
81
+ // Fall through to next strategy
82
+ }
83
+ }
84
+ // Try to find the outermost balanced braces
85
+ const firstBrace = text.indexOf("{");
86
+ const lastBrace = text.lastIndexOf("}");
87
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
88
+ try {
89
+ return JSON.parse(text.slice(firstBrace, lastBrace + 1));
90
+ }
91
+ catch {
92
+ // Fall through
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ /** Format an error for logging, extracting the message if it's an Error instance */
98
+ function formatError(err) {
99
+ return err instanceof Error ? err.message : String(err);
100
+ }
101
+ /**
102
+ * Validate a string is a positive integer. Returns the parsed number or null.
103
+ */
104
+ function parsePositiveInt(value) {
105
+ const num = Number(value);
106
+ if (!Number.isInteger(num) || num <= 0)
107
+ return null;
108
+ return num;
109
+ }
110
+ // ============================================================================
111
+ // Command Definition
112
+ // ============================================================================
113
+ export const researchCommand = new Command("research")
114
+ .description("AI News Research - Monitor Reddit, YouTube, and X for AI-related content");
115
+ // Config subcommand
116
+ researchCommand
117
+ .command("config")
118
+ .description("View or manage research configuration")
119
+ .option("--json", "Output as JSON")
120
+ .action(async (options) => {
121
+ try {
122
+ const config = await apiRequest("/api/research/config", {
123
+ signal: apiTimeoutSignal(),
124
+ });
125
+ if (options.json) {
126
+ console.log(JSON.stringify(config, null, 2));
127
+ return;
128
+ }
129
+ console.log("🔍 Research Configuration\n");
130
+ console.log(`Version: ${config.version}`);
131
+ console.log(`Schedule: ${config.schedule.enabled ? '✅' : '❌'} ${config.schedule.cronExpression} (${config.schedule.timezone})`);
132
+ console.log(`Min Score: ${config.minScoreThreshold}`);
133
+ console.log("\n📊 Subreddits:");
134
+ config.subreddits.forEach((sub) => {
135
+ console.log(` ${sub.enabled ? '✅' : '❌'} r/${sub.name} (min: ${sub.minScore})`);
136
+ });
137
+ console.log("\n📺 YouTube Channels:");
138
+ config.youtubeChannels.forEach((ch) => {
139
+ console.log(` ${ch.enabled ? '✅' : '❌'} ${ch.channelName}`);
140
+ });
141
+ console.log("\n🔔 Notifications:");
142
+ config.notificationChannels.forEach((nc) => {
143
+ console.log(` ${nc.enabled ? '✅' : '❌'} ${nc.type}`);
144
+ });
145
+ }
146
+ catch (error) {
147
+ console.error("Failed to get config:", formatError(error));
148
+ process.exit(1);
149
+ }
150
+ });
151
+ // Run subcommand - Trigger research manually
152
+ researchCommand
153
+ .command("run")
154
+ .description("Trigger a research job manually")
155
+ .option("--job <id>", "Job ID (for resuming existing job)")
156
+ .option("--json", "Output as JSON")
157
+ .action(async (options) => {
158
+ try {
159
+ if (options.job) {
160
+ // Resume existing job
161
+ console.log(`Resuming research job: ${options.job}`);
162
+ await runResearchJob(options.job, options.json);
163
+ }
164
+ else {
165
+ // Trigger new job
166
+ if (!options.json)
167
+ console.log("Triggering new research job...");
168
+ const result = await apiRequest("/api/research/trigger", {
169
+ method: "POST",
170
+ body: {},
171
+ signal: apiTimeoutSignal(),
172
+ });
173
+ if (options.json) {
174
+ console.log(JSON.stringify(result, null, 2));
175
+ }
176
+ else {
177
+ console.log(`✅ Job created: ${result.jobId}`);
178
+ console.log(`Message: ${result.message}`);
179
+ console.log("\nThe supervisor will be notified and start the research shortly.");
180
+ }
181
+ }
182
+ }
183
+ catch (error) {
184
+ console.error("Failed to run research:", formatError(error));
185
+ process.exit(1);
186
+ }
187
+ });
188
+ // Jobs subcommand - List jobs
189
+ researchCommand
190
+ .command("jobs")
191
+ .description("List research jobs")
192
+ .option("--status <status>", "Filter by status (pending|running|completed|failed)")
193
+ .option("--limit <n>", "Limit results", DEFAULT_JOB_LIST_LIMIT)
194
+ .option("--json", "Output as JSON")
195
+ .action(async (options) => {
196
+ try {
197
+ if (options.status && !VALID_JOB_STATUSES.includes(options.status)) {
198
+ console.error(`Invalid status: ${options.status}. Must be one of: ${VALID_JOB_STATUSES.join(", ")}`);
199
+ process.exit(1);
200
+ }
201
+ if (options.limit) {
202
+ const parsed = parsePositiveInt(options.limit);
203
+ if (parsed === null) {
204
+ console.error(`Invalid --limit: "${options.limit}". Must be a positive integer.`);
205
+ process.exit(1);
206
+ }
207
+ }
208
+ const params = new URLSearchParams();
209
+ if (options.status)
210
+ params.append("status", options.status);
211
+ if (options.limit)
212
+ params.append("limit", options.limit);
213
+ const data = await apiRequest(`/api/research/jobs?${params.toString()}`, {
214
+ signal: apiTimeoutSignal(),
215
+ });
216
+ if (options.json) {
217
+ console.log(JSON.stringify(data, null, 2));
218
+ return;
219
+ }
220
+ console.log("🔍 Research Jobs\n");
221
+ data.jobs.forEach((job) => {
222
+ const statusEmoji = {
223
+ pending: "⏳",
224
+ running: "🔄",
225
+ completed: "✅",
226
+ failed: "❌"
227
+ }[job.status] || "❓";
228
+ console.log(`${statusEmoji} ${job.id}`);
229
+ console.log(` Status: ${job.status}`);
230
+ console.log(` Triggered: ${new Date(job.triggeredAt).toLocaleString()}`);
231
+ console.log(` Posts: ${job.totalPostsFound} found, ${job.totalPostsFiltered} filtered`);
232
+ console.log();
233
+ });
234
+ }
235
+ catch (error) {
236
+ console.error("Failed to list jobs:", formatError(error));
237
+ process.exit(1);
238
+ }
239
+ });
240
+ // Results subcommand - Get job results
241
+ researchCommand
242
+ .command("results <job-id>")
243
+ .description("Get research job results")
244
+ .option("--format <format>", "Output format (json|markdown|summary)", "summary")
245
+ .option("--json", "Output as JSON (shorthand for --format json)")
246
+ .action(async (jobId, options) => {
247
+ try {
248
+ const format = options.json ? "json" : validateResultFormat(options.format);
249
+ const [job, summary] = await Promise.all([
250
+ apiRequest(`/api/research/jobs/${jobId}`, {
251
+ signal: apiTimeoutSignal(),
252
+ }),
253
+ apiRequest(`/api/research/jobs/${jobId}/summary`, {
254
+ signal: apiTimeoutSignal(),
255
+ }).catch((err) => {
256
+ if (process.env.DEBUG) {
257
+ console.error(`[debug] Failed to fetch summary for job ${jobId}: ${formatError(err)}`);
258
+ }
259
+ return null;
260
+ })
261
+ ]);
262
+ if (format === "json") {
263
+ console.log(JSON.stringify({ job, summary }, null, 2));
264
+ return;
265
+ }
266
+ console.log(`🔍 Research Results: ${jobId}\n`);
267
+ console.log(`Status: ${job.status}`);
268
+ console.log(`Triggered: ${new Date(job.triggeredAt).toLocaleString()}`);
269
+ if (job.status === "running") {
270
+ console.log("\n⚠️ Job is still running. Check back later.");
271
+ return;
272
+ }
273
+ if (job.status === "failed") {
274
+ console.log(`\n❌ Job failed: ${job.error || "Unknown error"}`);
275
+ return;
276
+ }
277
+ console.log(`\n📊 Summary:`);
278
+ console.log(` Total Posts: ${job.totalPostsFound}`);
279
+ console.log(` Filtered (AI-related): ${job.totalPostsFiltered}`);
280
+ if (summary) {
281
+ console.log(`\n📝 AI-Generated Summary:`);
282
+ console.log(summary.fullSummary);
283
+ if (summary.keyInsights?.length > 0) {
284
+ console.log(`\n💡 Key Insights:`);
285
+ summary.keyInsights.forEach((insight, i) => {
286
+ console.log(` ${i + 1}. ${insight}`);
287
+ });
288
+ }
289
+ if (summary.storedInKB) {
290
+ console.log(`\n✅ Stored in Knowledge Base: ${summary.kbEntryId}`);
291
+ }
292
+ }
293
+ if (format === "markdown") {
294
+ // Fetch detailed results
295
+ const [reddit, youtube] = await Promise.all([
296
+ apiRequest(`/api/research/jobs/${jobId}/reddit-posts`, {
297
+ signal: apiTimeoutSignal(),
298
+ }).catch((err) => {
299
+ if (process.env.DEBUG) {
300
+ console.error(`[debug] Failed to fetch Reddit posts for job ${jobId}: ${formatError(err)}`);
301
+ }
302
+ return { posts: [] };
303
+ }),
304
+ apiRequest(`/api/research/jobs/${jobId}/youtube-videos`, {
305
+ signal: apiTimeoutSignal(),
306
+ }).catch((err) => {
307
+ if (process.env.DEBUG) {
308
+ console.error(`[debug] Failed to fetch YouTube videos for job ${jobId}: ${formatError(err)}`);
309
+ }
310
+ return { videos: [] };
311
+ })
312
+ ]);
313
+ if (reddit.posts?.length > 0) {
314
+ console.log(`\n## Reddit Posts (${reddit.posts.length})\n`);
315
+ reddit.posts.slice(0, MARKDOWN_DISPLAY_LIMIT).forEach((post) => {
316
+ console.log(`### ${post.title}`);
317
+ console.log(`- **Subreddit:** r/${post.subreddit}`);
318
+ console.log(`- **Score:** ${post.score} | **Relevance:** ${post.relevanceScore || 'N/A'}/10`);
319
+ console.log(`- **URL:** ${post.url}\n`);
320
+ });
321
+ }
322
+ if (youtube.videos?.length > 0) {
323
+ console.log(`\n## YouTube Videos (${youtube.videos.length})\n`);
324
+ youtube.videos.slice(0, MARKDOWN_DISPLAY_LIMIT).forEach((video) => {
325
+ console.log(`### ${video.title}`);
326
+ console.log(`- **Channel:** ${video.channelName}`);
327
+ console.log(`- **Published:** ${new Date(video.publishedAt).toLocaleDateString()}`);
328
+ console.log(`- **Relevance:** ${video.relevanceScore || 'N/A'}/10`);
329
+ console.log(`- **URL:** ${video.url}\n`);
330
+ });
331
+ }
332
+ }
333
+ }
334
+ catch (error) {
335
+ console.error("Failed to get results:", formatError(error));
336
+ process.exit(1);
337
+ }
338
+ });
339
+ // Status subcommand - Check API configuration
340
+ researchCommand
341
+ .command("status")
342
+ .description("Check research API configuration status")
343
+ .option("--json", "Output as JSON")
344
+ .action(async (options) => {
345
+ const redditConfigured = isRedditConfigured();
346
+ const youtubeConfigured = isYouTubeConfigured();
347
+ const xConfigured = isXConfigured();
348
+ if (options.json) {
349
+ console.log(JSON.stringify({
350
+ reddit: { configured: redditConfigured },
351
+ youtube: { configured: youtubeConfigured },
352
+ x: { configured: xConfigured }
353
+ }, null, 2));
354
+ return;
355
+ }
356
+ console.log("🔍 Research API Status\n");
357
+ console.log("Reddit API:");
358
+ if (redditConfigured) {
359
+ console.log(" ✅ Configured");
360
+ console.log(" ✅ Configuration valid");
361
+ }
362
+ else {
363
+ console.log(" ❌ Not configured");
364
+ console.log(" Use: husky config set reddit-client-id <id>");
365
+ console.log(" Use: husky config set reddit-client-secret <secret>");
366
+ }
367
+ console.log("\nYouTube API:");
368
+ if (youtubeConfigured) {
369
+ console.log(" ✅ Configured");
370
+ console.log(" ✅ Configuration valid");
371
+ }
372
+ else {
373
+ console.log(" ❌ Not configured");
374
+ console.log(" Use: husky config set youtube-api-key <key>");
375
+ }
376
+ console.log("\nX/Twitter API:");
377
+ if (xConfigured) {
378
+ console.log(" ✅ Configured");
379
+ console.log(" ✅ Configuration valid");
380
+ }
381
+ else {
382
+ console.log(" ❌ Not configured");
383
+ console.log(" Use: husky config set x-bearer-token <token>");
384
+ }
385
+ });
386
+ // ============================================================================
387
+ // Validation Helpers
388
+ // ============================================================================
389
+ function validateResultFormat(format) {
390
+ const f = format || "summary";
391
+ if (!VALID_RESULT_FORMATS.includes(f)) {
392
+ console.error(`Invalid --format: "${f}". Must be one of: ${VALID_RESULT_FORMATS.join(", ")}`);
393
+ process.exit(1);
394
+ }
395
+ return f;
396
+ }
397
+ // ============================================================================
398
+ // Core Research Job Runner
399
+ // ============================================================================
400
+ async function runResearchJob(jobId, jsonOutput) {
401
+ const log = (msg) => { if (!jsonOutput)
402
+ console.log(msg); };
403
+ const logSuccess = (msg) => { if (!jsonOutput)
404
+ console.log(chalk.green(msg)); };
405
+ const logError = (msg) => { if (!jsonOutput)
406
+ console.error(chalk.red(msg)); };
407
+ const logDebug = (msg) => { if (process.env.DEBUG)
408
+ console.error(`[debug] ${msg}`); };
409
+ try {
410
+ // 1. Fetch job details
411
+ log(`📡 Fetching job details for ${jobId}...`);
412
+ const job = await apiRequest(`/api/research/jobs/${jobId}`, {
413
+ signal: apiTimeoutSignal(),
414
+ });
415
+ const config = job.configSnapshot;
416
+ // 2. Race condition protection: only transition from "pending" to "running"
417
+ if (job.status !== "pending") {
418
+ logError(`⚠️ Job ${jobId} is in "${job.status}" status, expected "pending". Aborting to avoid race condition.`);
419
+ if (jsonOutput) {
420
+ console.log(JSON.stringify({ success: false, error: `Job status is "${job.status}", expected "pending"` }));
421
+ }
422
+ process.exit(1);
423
+ }
424
+ await apiRequest(`/api/research/jobs/${jobId}`, {
425
+ method: "PATCH",
426
+ body: { status: "running", startedAt: new Date().toISOString() },
427
+ signal: apiTimeoutSignal(),
428
+ });
429
+ const redditPosts = [];
430
+ const youtubeVideos = [];
431
+ const xPosts = [];
432
+ let totalFound = 0;
433
+ // 3. Execute Reddit Research
434
+ if (isRedditConfigured() && config.subreddits.some(s => s.enabled)) {
435
+ log("🤖 Starting Reddit research...");
436
+ const reddit = RedditClient.fromConfig();
437
+ for (const sub of config.subreddits.filter(s => s.enabled)) {
438
+ log(` - Searching r/${sub.name}...`);
439
+ try {
440
+ const { posts } = await reddit.getSubredditPosts(sub.name, { sort: 'hot', limit: REDDIT_POSTS_PER_SUBREDDIT });
441
+ totalFound += posts.length;
442
+ for (const post of posts) {
443
+ const postDate = new Date(post.createdUtc * 1000);
444
+ const ageHours = (Date.now() - postDate.getTime()) / (1000 * 60 * 60);
445
+ if (ageHours > sub.maxAgeHours)
446
+ continue;
447
+ const relevance = calculateRelevance(post.title + " " + post.selftext, config.aiKeywords);
448
+ if (relevance.isAiRelated && post.score >= sub.minScore) {
449
+ redditPosts.push(mapRedditPostToApi(post, jobId, relevance));
450
+ }
451
+ }
452
+ }
453
+ catch (err) {
454
+ logError(` Error searching r/${sub.name}: ${formatError(err)}`);
455
+ logDebug(`Reddit r/${sub.name} error stack: ${err instanceof Error ? err.stack : ""}`);
456
+ }
457
+ }
458
+ }
459
+ // 4. Execute YouTube Research
460
+ if (isYouTubeConfigured() && config.youtubeChannels.some(c => c.enabled)) {
461
+ log("📺 Starting YouTube research...");
462
+ const yt = YouTubeMonitorClient.fromConfig();
463
+ for (const channel of config.youtubeChannels.filter(c => c.enabled)) {
464
+ log(` - Searching channel ${channel.channelName}...`);
465
+ try {
466
+ // Resolve channel ID if empty
467
+ let channelId = channel.channelId;
468
+ if (!channelId) {
469
+ const resolved = await yt.getChannelByHandle(channel.channelName);
470
+ if (resolved)
471
+ channelId = resolved.id;
472
+ }
473
+ if (channelId) {
474
+ const minDate = new Date();
475
+ minDate.setHours(minDate.getHours() - (channel.maxAgeHours || DEFAULT_YOUTUBE_MAX_AGE_HOURS));
476
+ const videos = await yt.getLatestVideos(channelId, { maxResults: YOUTUBE_VIDEOS_PER_CHANNEL, publishedAfter: minDate });
477
+ totalFound += videos.length;
478
+ for (const video of videos) {
479
+ const relevance = calculateRelevance(video.title + " " + video.description, config.aiKeywords);
480
+ if (relevance.isAiRelated) {
481
+ youtubeVideos.push(mapYouTubeVideoToApi(video, jobId, relevance));
482
+ }
483
+ }
484
+ }
485
+ }
486
+ catch (err) {
487
+ logError(` Error searching channel ${channel.channelName}: ${formatError(err)}`);
488
+ logDebug(`YouTube ${channel.channelName} error stack: ${err instanceof Error ? err.stack : ""}`);
489
+ }
490
+ }
491
+ }
492
+ // 5. Execute X (Twitter) Research
493
+ if (isXConfigured() && config.xAccounts.some(a => a.enabled)) {
494
+ log("🐦 Starting X research...");
495
+ const x = XClient.fromConfig();
496
+ if (x) {
497
+ for (const account of config.xAccounts.filter(a => a.enabled)) {
498
+ log(` - Searching @${account.username}...`);
499
+ try {
500
+ const tweets = await x.getUserTweets(account.username, X_POSTS_PER_ACCOUNT);
501
+ totalFound += tweets.length;
502
+ for (const tweet of tweets) {
503
+ const postDate = new Date(tweet.createdAt);
504
+ const ageHours = (Date.now() - postDate.getTime()) / (1000 * 60 * 60);
505
+ if (ageHours > account.maxAgeHours)
506
+ continue;
507
+ const relevance = calculateRelevance(tweet.text, config.aiKeywords.concat(account.searchKeywords));
508
+ if (relevance.isAiRelated) {
509
+ xPosts.push(mapXPostToApi(tweet, jobId, relevance));
510
+ }
511
+ }
512
+ }
513
+ catch (err) {
514
+ logError(` Error searching @${account.username}: ${formatError(err)}`);
515
+ logDebug(`X @${account.username} error stack: ${err instanceof Error ? err.stack : ""}`);
516
+ }
517
+ }
518
+ }
519
+ }
520
+ // 6. Save results back to API
521
+ log(`💾 Saving ${redditPosts.length} Reddit posts, ${youtubeVideos.length} YouTube videos, and ${xPosts.length} X posts...`);
522
+ if (redditPosts.length > 0) {
523
+ await apiRequest(`/api/research/jobs/${jobId}/reddit-posts`, {
524
+ method: "POST",
525
+ body: redditPosts,
526
+ signal: apiTimeoutSignal(),
527
+ });
528
+ }
529
+ if (youtubeVideos.length > 0) {
530
+ await apiRequest(`/api/research/jobs/${jobId}/youtube-videos`, {
531
+ method: "POST",
532
+ body: youtubeVideos,
533
+ signal: apiTimeoutSignal(),
534
+ });
535
+ }
536
+ if (xPosts.length > 0) {
537
+ await apiRequest(`/api/research/jobs/${jobId}/x-posts`, {
538
+ method: "POST",
539
+ body: xPosts,
540
+ signal: apiTimeoutSignal(),
541
+ });
542
+ }
543
+ // 7. Generate Summary
544
+ log("🧠 Generating AI summary...");
545
+ const summary = await generateSummary(redditPosts, youtubeVideos, config.aiKeywords, xPosts);
546
+ let kbEntryId;
547
+ if (summary) {
548
+ // 7.1 Save summary to KB
549
+ try {
550
+ log("💾 Storing summary in Knowledge Base...");
551
+ const brain = new ApiBrain({ agentId: "researcher", agentType: "supervisor" });
552
+ kbEntryId = await brain.remember(`AI Research Summary (${new Date().toLocaleDateString()}):\n\n${summary.fullSummary}\n\nKey Insights:\n${summary.keyInsights.map(i => "- " + i).join("\n")}`, ["research", "ai-news", jobId]);
553
+ }
554
+ catch (err) {
555
+ logError(` Failed to store in KB: ${formatError(err)}`);
556
+ logDebug(`KB store error stack: ${err instanceof Error ? err.stack : ""}`);
557
+ }
558
+ // 7.2 Save summary to API
559
+ await apiRequest(`/api/research/jobs/${jobId}/summary`, {
560
+ method: "POST",
561
+ body: {
562
+ ...summary,
563
+ jobId,
564
+ date: new Date().toISOString(),
565
+ totalPosts: redditPosts.length + youtubeVideos.length + xPosts.length,
566
+ redditCount: redditPosts.length,
567
+ youtubeCount: youtubeVideos.length,
568
+ xCount: xPosts.length,
569
+ storedInKB: !!kbEntryId,
570
+ kbEntryId,
571
+ },
572
+ signal: apiTimeoutSignal(),
573
+ });
574
+ // 7.3 Send Notification
575
+ const gchatEnabled = config.notificationChannels.find(c => c.type === "google_chat" && c.enabled);
576
+ if (gchatEnabled) {
577
+ log("🔔 Sending Google Chat notification...");
578
+ try {
579
+ const truncatedSummary = summary.fullSummary.length > NOTIFICATION_SUMMARY_MAX_LENGTH
580
+ ? summary.fullSummary.slice(0, NOTIFICATION_SUMMARY_MAX_LENGTH) + "..."
581
+ : summary.fullSummary;
582
+ const notificationText = `🔍 *AI News Research Complete* (${new Date().toLocaleDateString()})\n\n` +
583
+ `*Job ID:* \`${jobId}\`\n` +
584
+ `*Stats:* ${redditPosts.length} Reddit, ${youtubeVideos.length} YouTube, ${xPosts.length} X\n\n` +
585
+ `*Key Insights:*\n${summary.keyInsights.map(i => "• " + i).join("\n")}\n\n` +
586
+ `*Summary:* ${truncatedSummary}\n\n` +
587
+ `View details: \`husky research results ${jobId}\``;
588
+ await apiRequest("/api/google-chat/send", {
589
+ method: "POST",
590
+ body: { text: notificationText, threadName: `research-${jobId}` },
591
+ signal: apiTimeoutSignal(),
592
+ });
593
+ }
594
+ catch (err) {
595
+ logError(` Failed to send notification: ${formatError(err)}`);
596
+ logDebug(`Notification error stack: ${err instanceof Error ? err.stack : ""}`);
597
+ }
598
+ }
599
+ }
600
+ // 8. Mark job as completed
601
+ await apiRequest(`/api/research/jobs/${jobId}`, {
602
+ method: "PATCH",
603
+ body: {
604
+ status: "completed",
605
+ completedAt: new Date().toISOString(),
606
+ totalPostsFound: totalFound,
607
+ totalPostsFiltered: redditPosts.length + youtubeVideos.length + xPosts.length
608
+ },
609
+ signal: apiTimeoutSignal(),
610
+ });
611
+ logSuccess("✨ Research job completed successfully!");
612
+ }
613
+ catch (error) {
614
+ logError(`❌ Research job failed: ${formatError(error)}`);
615
+ await apiRequest(`/api/research/jobs/${jobId}`, {
616
+ method: "PATCH",
617
+ body: { status: "failed", error: error instanceof Error ? error.message : String(error) },
618
+ signal: apiTimeoutSignal(),
619
+ }).catch((patchErr) => {
620
+ logDebug(`Failed to mark job as failed: ${formatError(patchErr)}`);
621
+ });
622
+ if (jsonOutput) {
623
+ console.log(JSON.stringify({ success: false, error: String(error) }));
624
+ }
625
+ process.exit(1);
626
+ }
627
+ }
628
+ // ============================================================================
629
+ // Helper Functions
630
+ // ============================================================================
631
+ function calculateRelevance(text, keywords) {
632
+ const lowercaseText = text.toLowerCase();
633
+ const matchedKeywords = keywords.filter(kw => {
634
+ // Use word boundary regex so "AI" doesn't match "email", "said", etc.
635
+ const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
636
+ const pattern = new RegExp(`\\b${escaped}\\b`, "i");
637
+ return pattern.test(lowercaseText);
638
+ });
639
+ const isAiRelated = matchedKeywords.length > 0;
640
+ const relevanceScore = Math.min(matchedKeywords.length * RELEVANCE_SCORE_PER_KEYWORD, MAX_RELEVANCE_SCORE);
641
+ const relevanceReason = isAiRelated ? `Matches keywords: ${matchedKeywords.join(", ")}` : "No AI keywords found";
642
+ return {
643
+ isAiRelated,
644
+ relevanceScore,
645
+ relevanceReason,
646
+ tags: matchedKeywords
647
+ };
648
+ }
649
+ function mapRedditPostToApi(post, jobId, relevance) {
650
+ return {
651
+ ...relevance,
652
+ jobId,
653
+ fetchedAt: new Date().toISOString(),
654
+ redditPostId: post.id,
655
+ subreddit: post.subreddit,
656
+ title: post.title,
657
+ content: post.selftext,
658
+ url: post.url,
659
+ discussionUrl: `https://reddit.com${post.permalink}`,
660
+ author: post.author,
661
+ score: post.score,
662
+ upvoteRatio: post.upvoteRatio,
663
+ commentCount: post.numComments,
664
+ createdAt: new Date(post.createdUtc * 1000).toISOString(),
665
+ };
666
+ }
667
+ function mapYouTubeVideoToApi(video, jobId, relevance) {
668
+ return {
669
+ ...relevance,
670
+ jobId,
671
+ fetchedAt: new Date().toISOString(),
672
+ videoId: video.id,
673
+ channelId: video.channelId,
674
+ channelName: video.channelTitle,
675
+ title: video.title,
676
+ description: video.description,
677
+ url: `https://www.youtube.com/watch?v=${video.id}`,
678
+ publishedAt: new Date(video.publishedAt).toISOString(),
679
+ };
680
+ }
681
+ function mapXPostToApi(tweet, jobId, relevance) {
682
+ return {
683
+ ...relevance,
684
+ jobId,
685
+ fetchedAt: new Date().toISOString(),
686
+ postId: tweet.id,
687
+ username: tweet.username,
688
+ content: tweet.text,
689
+ url: `https://twitter.com/${tweet.username}/status/${tweet.id}`,
690
+ postedAt: new Date(tweet.createdAt).toISOString(),
691
+ likes: tweet.publicMetrics.likeCount,
692
+ retweets: tweet.publicMetrics.retweetCount,
693
+ };
694
+ }
695
+ async function generateSummary(redditPosts, youtubeVideos, keywords, xPosts = []) {
696
+ try {
697
+ const cliConfig = getConfig();
698
+ const env = process.env.HUSKY_ENV || 'PROD';
699
+ const apiKey = process.env[`${env}_GEMINI_API_KEY`] || process.env.GEMINI_API_KEY || cliConfig.geminiApiKey;
700
+ if (!apiKey) {
701
+ console.warn(" ⚠️ Gemini API Key not configured. Skipping AI summary.");
702
+ const totalPosts = redditPosts.length + youtubeVideos.length + xPosts.length;
703
+ return {
704
+ fullSummary: "AI summary skipped due to missing API key.",
705
+ keyInsights: [`Collected ${totalPosts} posts.`],
706
+ topPosts: {
707
+ reddit: [...redditPosts].sort((a, b) => b.score - a.score)[0],
708
+ youtube: [...youtubeVideos].sort((a, b) => b.relevanceScore - a.relevanceScore)[0],
709
+ x: [...xPosts].sort((a, b) => b.likes - a.likes)[0],
710
+ }
711
+ };
712
+ }
713
+ const genAI = new GoogleGenerativeAI(apiKey);
714
+ const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
715
+ const topReddit = [...redditPosts].sort((a, b) => b.score - a.score).slice(0, SUMMARY_TOP_REDDIT_COUNT);
716
+ const topYouTube = [...youtubeVideos].sort((a, b) => b.relevanceScore - a.relevanceScore).slice(0, SUMMARY_TOP_YOUTUBE_COUNT);
717
+ const topX = [...xPosts].sort((a, b) => b.likes - a.likes).slice(0, SUMMARY_TOP_X_COUNT);
718
+ // Sanitize all user content before interpolating into the prompt
719
+ const redditLines = topReddit
720
+ .map(p => `- [r/${sanitizeForPrompt(p.subreddit)}] ${sanitizeForPrompt(p.title)} (${p.discussionUrl})`)
721
+ .join("\n");
722
+ const youtubLines = topYouTube
723
+ .map(v => `- [${sanitizeForPrompt(v.channelName)}] ${sanitizeForPrompt(v.title)} (${v.url})`)
724
+ .join("\n");
725
+ const xLines = topX
726
+ .map(x => `- [@${sanitizeForPrompt(x.username)}] ${sanitizeForPrompt(x.content).slice(0, X_POST_CONTENT_PREVIEW_LENGTH)} (${x.url})`)
727
+ .join("\n");
728
+ const prompt = `You are an AI News Researcher. Summarize the following AI-related posts and videos collected today.
729
+ Keywords of interest: ${keywords.map(sanitizeForPrompt).join(", ")}
730
+
731
+ Reddit Posts:
732
+ ${redditLines || "(none)"}
733
+
734
+ YouTube Videos:
735
+ ${youtubLines || "(none)"}
736
+
737
+ X (Twitter) Posts:
738
+ ${xLines || "(none)"}
739
+
740
+ Format your response as a JSON object with:
741
+ - fullSummary: A comprehensive 2-3 paragraph summary of the day's AI news.
742
+ - keyInsights: A string array of 3-7 actionable or interesting insights.
743
+
744
+ Respond ONLY with the JSON object.`;
745
+ const result = await model.generateContent(prompt);
746
+ const response = await result.response;
747
+ const text = response.text();
748
+ // Robust JSON extraction
749
+ const summaryData = extractJsonFromLlmResponse(text);
750
+ if (summaryData) {
751
+ return {
752
+ fullSummary: typeof summaryData.fullSummary === "string" ? summaryData.fullSummary : "Summary unavailable.",
753
+ keyInsights: Array.isArray(summaryData.keyInsights)
754
+ ? summaryData.keyInsights.filter((i) => typeof i === "string")
755
+ : [],
756
+ topPosts: {
757
+ reddit: topReddit[0],
758
+ youtube: topYouTube[0],
759
+ x: topX[0],
760
+ }
761
+ };
762
+ }
763
+ if (process.env.DEBUG) {
764
+ console.error("[debug] Failed to extract JSON from Gemini response:", text.slice(0, 500));
765
+ }
766
+ }
767
+ catch (error) {
768
+ console.error(" ❌ Failed to generate summary:", formatError(error));
769
+ if (process.env.DEBUG) {
770
+ console.error("[debug] Summary generation error stack:", error instanceof Error ? error.stack : "");
771
+ }
772
+ }
773
+ return null;
774
+ }