@harbinger-ai/harbinger 0.1.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 (317) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +406 -0
  3. package/agents/README.md +76 -0
  4. package/agents/_template/CONFIG.yaml +7 -0
  5. package/agents/_template/HEARTBEAT.md +59 -0
  6. package/agents/_template/IDENTITY.md +4 -0
  7. package/agents/_template/SKILLS.md +1 -0
  8. package/agents/_template/SOUL.md +25 -0
  9. package/agents/_template/TOOLS.md +3 -0
  10. package/agents/binary-reverser/CONFIG.yaml +21 -0
  11. package/agents/binary-reverser/HEARTBEAT.md +65 -0
  12. package/agents/binary-reverser/IDENTITY.md +1 -0
  13. package/agents/binary-reverser/SKILLS.md +1 -0
  14. package/agents/binary-reverser/SOUL.md +23 -0
  15. package/agents/binary-reverser/TOOLS.md +99 -0
  16. package/agents/browser-agent/CONFIG.yaml +20 -0
  17. package/agents/browser-agent/HEARTBEAT.md +79 -0
  18. package/agents/browser-agent/IDENTITY.md +5 -0
  19. package/agents/browser-agent/SKILLS.md +86 -0
  20. package/agents/browser-agent/SOUL.md +23 -0
  21. package/agents/browser-agent/TOOLS.md +186 -0
  22. package/agents/cloud-infiltrator/CONFIG.yaml +22 -0
  23. package/agents/cloud-infiltrator/HEARTBEAT.md +78 -0
  24. package/agents/cloud-infiltrator/IDENTITY.md +1 -0
  25. package/agents/cloud-infiltrator/SKILLS.md +1 -0
  26. package/agents/cloud-infiltrator/SOUL.md +23 -0
  27. package/agents/cloud-infiltrator/TOOLS.md +68 -0
  28. package/agents/coding-assistant/CONFIG.yaml +22 -0
  29. package/agents/coding-assistant/HEARTBEAT.md +57 -0
  30. package/agents/coding-assistant/IDENTITY.md +5 -0
  31. package/agents/coding-assistant/SKILLS.md +69 -0
  32. package/agents/coding-assistant/SOUL.md +60 -0
  33. package/agents/coding-assistant/TOOLS.md +168 -0
  34. package/agents/learning-agent/CONFIG.yaml +21 -0
  35. package/agents/learning-agent/HEARTBEAT.md +63 -0
  36. package/agents/learning-agent/IDENTITY.md +5 -0
  37. package/agents/learning-agent/SKILLS.md +86 -0
  38. package/agents/learning-agent/SOUL.md +77 -0
  39. package/agents/learning-agent/TOOLS.md +145 -0
  40. package/agents/maintainer/CONFIG.yaml +31 -0
  41. package/agents/maintainer/HEARTBEAT.md +28 -0
  42. package/agents/maintainer/IDENTITY.md +33 -0
  43. package/agents/maintainer/SKILLS.md +24 -0
  44. package/agents/maintainer/SOUL.md +61 -0
  45. package/agents/maintainer/TOOLS.md +29 -0
  46. package/agents/maintainer/lib/engine.js +279 -0
  47. package/agents/maintainer/lib/safe-fixer.js +183 -0
  48. package/agents/morning-brief/CONFIG.yaml +22 -0
  49. package/agents/morning-brief/HEARTBEAT.md +60 -0
  50. package/agents/morning-brief/IDENTITY.md +5 -0
  51. package/agents/morning-brief/SKILLS.md +56 -0
  52. package/agents/morning-brief/SOUL.md +64 -0
  53. package/agents/morning-brief/TOOLS.md +112 -0
  54. package/agents/osint-detective/CONFIG.yaml +24 -0
  55. package/agents/osint-detective/HEARTBEAT.md +66 -0
  56. package/agents/osint-detective/IDENTITY.md +1 -0
  57. package/agents/osint-detective/SKILLS.md +1 -0
  58. package/agents/osint-detective/SOUL.md +23 -0
  59. package/agents/osint-detective/TOOLS.md +81 -0
  60. package/agents/recon-scout/CONFIG.yaml +22 -0
  61. package/agents/recon-scout/HEARTBEAT.md +79 -0
  62. package/agents/recon-scout/IDENTITY.md +1 -0
  63. package/agents/recon-scout/SKILLS.md +1 -0
  64. package/agents/recon-scout/SOUL.md +23 -0
  65. package/agents/recon-scout/TOOLS.md +93 -0
  66. package/agents/report-writer/CONFIG.yaml +21 -0
  67. package/agents/report-writer/HEARTBEAT.md +63 -0
  68. package/agents/report-writer/IDENTITY.md +1 -0
  69. package/agents/report-writer/SKILLS.md +1 -0
  70. package/agents/report-writer/SOUL.md +23 -0
  71. package/agents/report-writer/TOOLS.md +69 -0
  72. package/agents/shared/README.md +13 -0
  73. package/agents/web-hacker/CONFIG.yaml +24 -0
  74. package/agents/web-hacker/HEARTBEAT.md +78 -0
  75. package/agents/web-hacker/IDENTITY.md +1 -0
  76. package/agents/web-hacker/SKILLS.md +1 -0
  77. package/agents/web-hacker/SOUL.md +23 -0
  78. package/agents/web-hacker/TOOLS.md +86 -0
  79. package/api/CLAUDE.md +19 -0
  80. package/api/index.js +274 -0
  81. package/bin/cli.js +620 -0
  82. package/bin/local.sh +31 -0
  83. package/bin/postinstall.js +63 -0
  84. package/config/index.js +24 -0
  85. package/config/instrumentation.js +93 -0
  86. package/drizzle/0000_initial.sql +52 -0
  87. package/drizzle/0001_bounty_and_registry.sql +82 -0
  88. package/drizzle/0002_sync_columns.sql +7 -0
  89. package/drizzle/0003_graceful_bloodscream.sql +86 -0
  90. package/drizzle/meta/0000_snapshot.json +321 -0
  91. package/drizzle/meta/0003_snapshot.json +878 -0
  92. package/drizzle/meta/_journal.json +34 -0
  93. package/drizzle/relations.ts +3 -0
  94. package/drizzle/schema.ts +145 -0
  95. package/lib/actions.js +47 -0
  96. package/lib/agents.js +166 -0
  97. package/lib/ai/agent.js +96 -0
  98. package/lib/ai/autonomous-engine.js +261 -0
  99. package/lib/ai/index.js +359 -0
  100. package/lib/ai/model-router.js +254 -0
  101. package/lib/ai/model.js +73 -0
  102. package/lib/ai/tools.js +84 -0
  103. package/lib/auth/actions.js +28 -0
  104. package/lib/auth/config.js +27 -0
  105. package/lib/auth/edge-config.js +27 -0
  106. package/lib/auth/index.js +27 -0
  107. package/lib/auth/middleware.js +53 -0
  108. package/lib/bounty/actions.js +119 -0
  109. package/lib/bounty/findings.js +64 -0
  110. package/lib/bounty/programs.js +34 -0
  111. package/lib/bounty/sync-targets.js +267 -0
  112. package/lib/bounty/targets.js +33 -0
  113. package/lib/channels/base.js +56 -0
  114. package/lib/channels/index.js +15 -0
  115. package/lib/channels/telegram.js +148 -0
  116. package/lib/chat/actions.js +288 -0
  117. package/lib/chat/api.js +135 -0
  118. package/lib/chat/components/app-sidebar.js +237 -0
  119. package/lib/chat/components/app-sidebar.jsx +289 -0
  120. package/lib/chat/components/chat-header.js +27 -0
  121. package/lib/chat/components/chat-header.jsx +37 -0
  122. package/lib/chat/components/chat-input.js +230 -0
  123. package/lib/chat/components/chat-input.jsx +228 -0
  124. package/lib/chat/components/chat-nav-context.js +11 -0
  125. package/lib/chat/components/chat-nav-context.jsx +11 -0
  126. package/lib/chat/components/chat-page.js +81 -0
  127. package/lib/chat/components/chat-page.jsx +100 -0
  128. package/lib/chat/components/chat.js +150 -0
  129. package/lib/chat/components/chat.jsx +182 -0
  130. package/lib/chat/components/chats-page.js +302 -0
  131. package/lib/chat/components/chats-page.jsx +330 -0
  132. package/lib/chat/components/crons-page.js +172 -0
  133. package/lib/chat/components/crons-page.jsx +244 -0
  134. package/lib/chat/components/enhanced-tool-call.js +103 -0
  135. package/lib/chat/components/enhanced-tool-call.jsx +139 -0
  136. package/lib/chat/components/findings-page.js +175 -0
  137. package/lib/chat/components/findings-page.jsx +214 -0
  138. package/lib/chat/components/greeting.js +22 -0
  139. package/lib/chat/components/greeting.jsx +26 -0
  140. package/lib/chat/components/icons.js +777 -0
  141. package/lib/chat/components/icons.jsx +741 -0
  142. package/lib/chat/components/index.js +26 -0
  143. package/lib/chat/components/mcp-page.js +260 -0
  144. package/lib/chat/components/mcp-page.jsx +355 -0
  145. package/lib/chat/components/message.js +289 -0
  146. package/lib/chat/components/message.jsx +315 -0
  147. package/lib/chat/components/messages.js +66 -0
  148. package/lib/chat/components/messages.jsx +77 -0
  149. package/lib/chat/components/notifications-page.js +56 -0
  150. package/lib/chat/components/notifications-page.jsx +87 -0
  151. package/lib/chat/components/page-layout.js +21 -0
  152. package/lib/chat/components/page-layout.jsx +28 -0
  153. package/lib/chat/components/registry-page.js +222 -0
  154. package/lib/chat/components/registry-page.jsx +255 -0
  155. package/lib/chat/components/settings-layout.js +40 -0
  156. package/lib/chat/components/settings-layout.jsx +54 -0
  157. package/lib/chat/components/settings-secrets-page.js +216 -0
  158. package/lib/chat/components/settings-secrets-page.jsx +264 -0
  159. package/lib/chat/components/sidebar-history-item.js +132 -0
  160. package/lib/chat/components/sidebar-history-item.jsx +113 -0
  161. package/lib/chat/components/sidebar-history.js +115 -0
  162. package/lib/chat/components/sidebar-history.jsx +157 -0
  163. package/lib/chat/components/sidebar-user-nav.js +63 -0
  164. package/lib/chat/components/sidebar-user-nav.jsx +73 -0
  165. package/lib/chat/components/status-bar.js +39 -0
  166. package/lib/chat/components/status-bar.jsx +51 -0
  167. package/lib/chat/components/swarm-page.js +157 -0
  168. package/lib/chat/components/swarm-page.jsx +210 -0
  169. package/lib/chat/components/targets-page.js +376 -0
  170. package/lib/chat/components/targets-page.jsx +389 -0
  171. package/lib/chat/components/tool-call.js +86 -0
  172. package/lib/chat/components/tool-call.jsx +104 -0
  173. package/lib/chat/components/tool-panel.js +107 -0
  174. package/lib/chat/components/tool-panel.jsx +145 -0
  175. package/lib/chat/components/triggers-page.js +153 -0
  176. package/lib/chat/components/triggers-page.jsx +221 -0
  177. package/lib/chat/components/ui/confirm-dialog.js +53 -0
  178. package/lib/chat/components/ui/confirm-dialog.jsx +57 -0
  179. package/lib/chat/components/ui/dropdown-menu.js +98 -0
  180. package/lib/chat/components/ui/dropdown-menu.jsx +116 -0
  181. package/lib/chat/components/ui/rename-dialog.js +74 -0
  182. package/lib/chat/components/ui/rename-dialog.jsx +72 -0
  183. package/lib/chat/components/ui/scroll-area.js +13 -0
  184. package/lib/chat/components/ui/scroll-area.jsx +17 -0
  185. package/lib/chat/components/ui/separator.js +21 -0
  186. package/lib/chat/components/ui/separator.jsx +18 -0
  187. package/lib/chat/components/ui/sheet.js +75 -0
  188. package/lib/chat/components/ui/sheet.jsx +95 -0
  189. package/lib/chat/components/ui/sidebar.js +227 -0
  190. package/lib/chat/components/ui/sidebar.jsx +245 -0
  191. package/lib/chat/components/ui/tooltip.js +56 -0
  192. package/lib/chat/components/ui/tooltip.jsx +66 -0
  193. package/lib/chat/components/upgrade-dialog.js +151 -0
  194. package/lib/chat/components/upgrade-dialog.jsx +170 -0
  195. package/lib/chat/utils.js +11 -0
  196. package/lib/cron.js +246 -0
  197. package/lib/db/api-keys.js +163 -0
  198. package/lib/db/chats.js +145 -0
  199. package/lib/db/index.js +52 -0
  200. package/lib/db/notifications.js +99 -0
  201. package/lib/db/schema.js +145 -0
  202. package/lib/db/update-check.js +96 -0
  203. package/lib/db/users.js +89 -0
  204. package/lib/mcp/actions.js +104 -0
  205. package/lib/mcp/client.js +79 -0
  206. package/lib/mcp/handler.js +57 -0
  207. package/lib/mcp/server.js +165 -0
  208. package/lib/paths.js +46 -0
  209. package/lib/registry/actions.js +164 -0
  210. package/lib/registry/catalog.js +137 -0
  211. package/lib/registry/tools.js +71 -0
  212. package/lib/tools/create-job.js +99 -0
  213. package/lib/tools/github.js +217 -0
  214. package/lib/tools/openai.js +35 -0
  215. package/lib/tools/telegram.js +292 -0
  216. package/lib/triggers.js +118 -0
  217. package/lib/utils/render-md.js +102 -0
  218. package/package.json +103 -0
  219. package/setup/lib/auth.mjs +81 -0
  220. package/setup/lib/env.mjs +21 -0
  221. package/setup/lib/fs-utils.mjs +20 -0
  222. package/setup/lib/github.mjs +149 -0
  223. package/setup/lib/prerequisites.mjs +155 -0
  224. package/setup/lib/prompts.mjs +267 -0
  225. package/setup/lib/providers.mjs +48 -0
  226. package/setup/lib/sync.mjs +125 -0
  227. package/setup/lib/targets.mjs +45 -0
  228. package/setup/lib/telegram-verify.mjs +63 -0
  229. package/setup/lib/telegram.mjs +76 -0
  230. package/setup/setup-telegram.mjs +264 -0
  231. package/setup/setup.mjs +842 -0
  232. package/templates/.dockerignore +5 -0
  233. package/templates/.env.example +63 -0
  234. package/templates/.github/workflows/auto-merge.yml +117 -0
  235. package/templates/.github/workflows/build-image.yml +36 -0
  236. package/templates/.github/workflows/notify-job-failed.yml +64 -0
  237. package/templates/.github/workflows/notify-pr-complete.yml +119 -0
  238. package/templates/.github/workflows/rebuild-event-handler.yml +121 -0
  239. package/templates/.github/workflows/run-job.yml +89 -0
  240. package/templates/.github/workflows/upgrade-event-handler.yml +62 -0
  241. package/templates/.gitignore.template +45 -0
  242. package/templates/.pi/extensions/env-sanitizer/index.ts +48 -0
  243. package/templates/.pi/extensions/env-sanitizer/package.json +5 -0
  244. package/templates/CLAUDE.md +29 -0
  245. package/templates/CLAUDE.md.template +307 -0
  246. package/templates/app/api/[...thepopebot]/route.js +1 -0
  247. package/templates/app/api/auth/[...nextauth]/route.js +1 -0
  248. package/templates/app/chat/[chatId]/page.js +8 -0
  249. package/templates/app/chats/page.js +7 -0
  250. package/templates/app/components/ascii-logo.jsx +10 -0
  251. package/templates/app/components/login-form.jsx +92 -0
  252. package/templates/app/components/setup-form.jsx +82 -0
  253. package/templates/app/components/theme-provider.jsx +11 -0
  254. package/templates/app/components/theme-toggle.jsx +38 -0
  255. package/templates/app/components/ui/button.jsx +21 -0
  256. package/templates/app/components/ui/card.jsx +23 -0
  257. package/templates/app/components/ui/input.jsx +10 -0
  258. package/templates/app/components/ui/label.jsx +10 -0
  259. package/templates/app/crons/page.js +5 -0
  260. package/templates/app/findings/page.js +7 -0
  261. package/templates/app/globals.css +90 -0
  262. package/templates/app/layout.js +19 -0
  263. package/templates/app/login/page.js +15 -0
  264. package/templates/app/notifications/page.js +7 -0
  265. package/templates/app/page.js +7 -0
  266. package/templates/app/settings/crons/page.js +5 -0
  267. package/templates/app/settings/layout.js +7 -0
  268. package/templates/app/settings/mcp/page.js +5 -0
  269. package/templates/app/settings/page.js +5 -0
  270. package/templates/app/settings/secrets/page.js +5 -0
  271. package/templates/app/settings/triggers/page.js +5 -0
  272. package/templates/app/stream/chat/route.js +1 -0
  273. package/templates/app/swarm/page.js +7 -0
  274. package/templates/app/targets/page.js +7 -0
  275. package/templates/app/toolbox/page.js +7 -0
  276. package/templates/app/triggers/page.js +5 -0
  277. package/templates/config/AGENT.md +34 -0
  278. package/templates/config/CRONS.json +56 -0
  279. package/templates/config/EVENT_HANDLER.md +224 -0
  280. package/templates/config/HEARTBEAT.md +3 -0
  281. package/templates/config/JOB_SUMMARY.md +130 -0
  282. package/templates/config/MCP_SERVERS.json +1 -0
  283. package/templates/config/SKILL_BUILDING_GUIDE.md +90 -0
  284. package/templates/config/SOUL.md +17 -0
  285. package/templates/config/TRIGGERS.json +58 -0
  286. package/templates/docker/event-handler/Dockerfile +20 -0
  287. package/templates/docker/event-handler/ecosystem.config.cjs +8 -0
  288. package/templates/docker/job-claude-code/Dockerfile +34 -0
  289. package/templates/docker/job-claude-code/entrypoint.sh +139 -0
  290. package/templates/docker/job-pi-coding-agent/Dockerfile +44 -0
  291. package/templates/docker/job-pi-coding-agent/entrypoint.sh +163 -0
  292. package/templates/docker-compose.yml +63 -0
  293. package/templates/instrumentation.js +6 -0
  294. package/templates/middleware.js +1 -0
  295. package/templates/next.config.mjs +3 -0
  296. package/templates/postcss.config.mjs +5 -0
  297. package/templates/skills/LICENSE +21 -0
  298. package/templates/skills/README.md +119 -0
  299. package/templates/skills/brave-search/SKILL.md +79 -0
  300. package/templates/skills/brave-search/content.js +86 -0
  301. package/templates/skills/brave-search/package-lock.json +621 -0
  302. package/templates/skills/brave-search/package.json +14 -0
  303. package/templates/skills/brave-search/search.js +199 -0
  304. package/templates/skills/browser-tools/SKILL.md +196 -0
  305. package/templates/skills/browser-tools/browser-content.js +103 -0
  306. package/templates/skills/browser-tools/browser-cookies.js +35 -0
  307. package/templates/skills/browser-tools/browser-eval.js +53 -0
  308. package/templates/skills/browser-tools/browser-hn-scraper.js +108 -0
  309. package/templates/skills/browser-tools/browser-nav.js +44 -0
  310. package/templates/skills/browser-tools/browser-pick.js +162 -0
  311. package/templates/skills/browser-tools/browser-screenshot.js +34 -0
  312. package/templates/skills/browser-tools/browser-start.js +87 -0
  313. package/templates/skills/browser-tools/package-lock.json +2556 -0
  314. package/templates/skills/browser-tools/package.json +19 -0
  315. package/templates/skills/llm-secrets/SKILL.md +34 -0
  316. package/templates/skills/llm-secrets/llm-secrets.js +33 -0
  317. package/templates/skills/modify-self/SKILL.md +12 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * GitHub REST API helper with authentication
3
+ * @param {string} endpoint - API endpoint (e.g., '/repos/owner/repo/...')
4
+ * @param {object} options - Fetch options (method, body, headers)
5
+ * @returns {Promise<object>} - Parsed JSON response
6
+ */
7
+ async function githubApi(endpoint, options = {}) {
8
+ const { GH_TOKEN } = process.env;
9
+ if (!GH_TOKEN) throw new Error('GH_TOKEN environment variable is required for GitHub API calls');
10
+ const res = await fetch(`https://api.github.com${endpoint}`, {
11
+ ...options,
12
+ headers: {
13
+ 'Authorization': `Bearer ${GH_TOKEN}`,
14
+ 'Accept': 'application/vnd.github+json',
15
+ 'X-GitHub-Api-Version': '2022-11-28',
16
+ ...options.headers,
17
+ },
18
+ });
19
+
20
+ if (!res.ok) {
21
+ const error = await res.text();
22
+ throw new Error(`GitHub API error: ${res.status} ${error}`);
23
+ }
24
+
25
+ return res.json();
26
+ }
27
+
28
+ /**
29
+ * Get workflow runs with optional status and workflow filter
30
+ * @param {string} [status] - Filter by status (in_progress, queued, completed)
31
+ * @param {string} [workflow] - Workflow filename to scope to (e.g., 'run-job.yml')
32
+ * @returns {Promise<object>} - Workflow runs response
33
+ */
34
+ async function getWorkflowRuns(status, { workflow, page = 1, perPage = 100 } = {}) {
35
+ const { GH_OWNER, GH_REPO } = process.env;
36
+ const params = new URLSearchParams();
37
+ if (status) params.set('status', status);
38
+ params.set('per_page', String(perPage));
39
+ params.set('page', String(page));
40
+
41
+ const query = params.toString();
42
+ const path = workflow
43
+ ? `/repos/${GH_OWNER}/${GH_REPO}/actions/workflows/${workflow}/runs?${query}`
44
+ : `/repos/${GH_OWNER}/${GH_REPO}/actions/runs?${query}`;
45
+ return githubApi(path);
46
+ }
47
+
48
+ /**
49
+ * Get jobs for a specific workflow run
50
+ * @param {number} runId - Workflow run ID
51
+ * @returns {Promise<object>} - Jobs response with steps
52
+ */
53
+ async function getWorkflowRunJobs(runId) {
54
+ const { GH_OWNER, GH_REPO } = process.env;
55
+ return githubApi(`/repos/${GH_OWNER}/${GH_REPO}/actions/runs/${runId}/jobs`);
56
+ }
57
+
58
+ /**
59
+ * Get job status for running/recent jobs
60
+ * @param {string} [jobId] - Optional specific job ID to filter by
61
+ * @returns {Promise<object>} - Status summary with jobs array
62
+ */
63
+ async function getJobStatus(jobId) {
64
+ // Fetch both in_progress and queued runs (scoped to run-job.yml)
65
+ const [inProgress, queued] = await Promise.all([
66
+ getWorkflowRuns('in_progress', { workflow: 'run-job.yml' }),
67
+ getWorkflowRuns('queued', { workflow: 'run-job.yml' }),
68
+ ]);
69
+
70
+ const allRuns = [...(inProgress.workflow_runs || []), ...(queued.workflow_runs || [])];
71
+
72
+ // Filter to only job/* branches
73
+ const jobRuns = allRuns.filter(run => run.head_branch?.startsWith('job/'));
74
+
75
+ // If specific job requested, filter further
76
+ const filteredRuns = jobId
77
+ ? jobRuns.filter(run => run.head_branch === `job/${jobId}`)
78
+ : jobRuns;
79
+
80
+ // Get detailed job info for each run
81
+ const jobs = await Promise.all(
82
+ filteredRuns.map(async (run) => {
83
+ const extractedJobId = run.head_branch.slice(4); // Remove 'job/' prefix
84
+ const startedAt = new Date(run.created_at);
85
+ const durationMinutes = Math.round((Date.now() - startedAt.getTime()) / 60000);
86
+
87
+ let currentStep = null;
88
+ let stepsCompleted = 0;
89
+ let stepsTotal = 0;
90
+
91
+ try {
92
+ const jobsData = await getWorkflowRunJobs(run.id);
93
+ if (jobsData.jobs?.length > 0) {
94
+ const job = jobsData.jobs[0];
95
+ stepsTotal = job.steps?.length || 0;
96
+ stepsCompleted = job.steps?.filter(s => s.status === 'completed').length || 0;
97
+ currentStep = job.steps?.find(s => s.status === 'in_progress')?.name || null;
98
+ }
99
+ } catch (err) {
100
+ // Jobs endpoint may fail if run hasn't started yet
101
+ }
102
+
103
+ return {
104
+ job_id: extractedJobId,
105
+ branch: run.head_branch,
106
+ status: run.status,
107
+ started_at: run.created_at,
108
+ duration_minutes: durationMinutes,
109
+ current_step: currentStep,
110
+ steps_completed: stepsCompleted,
111
+ steps_total: stepsTotal,
112
+ run_id: run.id,
113
+ };
114
+ })
115
+ );
116
+
117
+ // Count only job/* branches, not all workflows
118
+ const runningCount = jobs.filter(j => j.status === 'in_progress').length;
119
+ const queuedCount = jobs.filter(j => j.status === 'queued').length;
120
+
121
+ return {
122
+ jobs,
123
+ queued: queuedCount,
124
+ running: runningCount,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Get full swarm status: unified list of all workflow runs with counts
130
+ * @param {number} [page=1] - Page number for pagination
131
+ * @returns {Promise<object>} - { runs, hasMore, counts }
132
+ */
133
+ async function getSwarmStatus(page = 1) {
134
+ const data = await getWorkflowRuns(null, { page, perPage: 25 });
135
+
136
+ const runs = (data.workflow_runs || []).map(run => ({
137
+ run_id: run.id,
138
+ branch: run.head_branch,
139
+ status: run.status,
140
+ conclusion: run.conclusion,
141
+ workflow_name: run.name,
142
+ started_at: run.created_at,
143
+ updated_at: run.updated_at,
144
+ duration_seconds: Math.round((Date.now() - new Date(run.created_at).getTime()) / 1000),
145
+ html_url: run.html_url,
146
+ }));
147
+
148
+ return {
149
+ runs,
150
+ hasMore: page * 25 < (data.total_count || 0),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Trigger a workflow via workflow_dispatch
156
+ * @param {string} workflowId - Workflow file name (e.g., 'upgrade-event-handler.yml')
157
+ * @param {string} [ref='main'] - Git ref to run the workflow on
158
+ * @param {object} [inputs={}] - Workflow inputs
159
+ */
160
+ async function triggerWorkflowDispatch(workflowId, ref = 'main', inputs = {}) {
161
+ const { GH_OWNER, GH_REPO } = process.env;
162
+ const res = await fetch(
163
+ `https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/actions/workflows/${workflowId}/dispatches`,
164
+ {
165
+ method: 'POST',
166
+ headers: {
167
+ 'Authorization': `Bearer ${process.env.GH_TOKEN}`,
168
+ 'Accept': 'application/vnd.github+json',
169
+ 'X-GitHub-Api-Version': '2022-11-28',
170
+ 'Content-Type': 'application/json',
171
+ },
172
+ body: JSON.stringify({ ref, inputs }),
173
+ }
174
+ );
175
+ if (!res.ok && res.status !== 204) {
176
+ const error = await res.text();
177
+ throw new Error(`GitHub API error: ${res.status} ${error}`);
178
+ }
179
+ return { success: true };
180
+ }
181
+
182
+ /**
183
+ * Fetch the session log (.jsonl) for a job from the GitHub repo at a specific commit.
184
+ * @param {string} jobId - The job ID (used to locate logs/{jobId}/)
185
+ * @param {string} commitSha - Git commit SHA to read from
186
+ * @returns {Promise<string>} - Log content or empty string if unavailable
187
+ */
188
+ async function fetchJobLog(jobId, commitSha) {
189
+ if (!commitSha) return '';
190
+ const { GH_OWNER, GH_REPO } = process.env;
191
+ try {
192
+ const files = await githubApi(
193
+ `/repos/${GH_OWNER}/${GH_REPO}/contents/logs/${jobId}?ref=${encodeURIComponent(commitSha)}`
194
+ );
195
+ if (!Array.isArray(files)) return '';
196
+ const logFile = files.find(f => f.name.endsWith('.jsonl'));
197
+ if (!logFile || !logFile.download_url) return '';
198
+ const res = await fetch(logFile.download_url, {
199
+ headers: { 'Authorization': `Bearer ${process.env.GH_TOKEN}` },
200
+ });
201
+ if (!res.ok) return '';
202
+ return await res.text();
203
+ } catch (err) {
204
+ console.error('Failed to fetch job log:', err.message);
205
+ return '';
206
+ }
207
+ }
208
+
209
+ export {
210
+ githubApi,
211
+ getWorkflowRuns,
212
+ getWorkflowRunJobs,
213
+ getJobStatus,
214
+ getSwarmStatus,
215
+ triggerWorkflowDispatch,
216
+ fetchJobLog,
217
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Check if Whisper transcription is enabled
3
+ * @returns {boolean}
4
+ */
5
+ function isWhisperEnabled() {
6
+ return Boolean(process.env.OPENAI_API_KEY);
7
+ }
8
+
9
+ /**
10
+ * Transcribe audio using OpenAI Whisper API
11
+ * @param {Buffer} audioBuffer - Audio file buffer
12
+ * @param {string} filename - Original filename (e.g., "voice.ogg")
13
+ * @returns {Promise<string>} Transcribed text
14
+ */
15
+ async function transcribeAudio(audioBuffer, filename) {
16
+ const formData = new FormData();
17
+ formData.append('file', new Blob([audioBuffer]), filename);
18
+ formData.append('model', 'whisper-1');
19
+
20
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
21
+ method: 'POST',
22
+ headers: { 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}` },
23
+ body: formData,
24
+ });
25
+
26
+ if (!response.ok) {
27
+ const error = await response.text();
28
+ throw new Error(`OpenAI API error: ${response.status} ${error}`);
29
+ }
30
+
31
+ const result = await response.json();
32
+ return result.text;
33
+ }
34
+
35
+ export { isWhisperEnabled, transcribeAudio };
@@ -0,0 +1,292 @@
1
+ import { Bot } from 'grammy';
2
+ import parseModePlugin from '@grammyjs/parse-mode';
3
+ const { hydrateReply } = parseModePlugin;
4
+
5
+ const MAX_LENGTH = 4096;
6
+
7
+ /**
8
+ * Convert markdown to Telegram-compatible HTML.
9
+ * Handles: code blocks, inline code, links, bold, italic, strikethrough, headings, lists.
10
+ * Strips unsupported HTML tags.
11
+ * @param {string} text - Markdown text
12
+ * @returns {string} Telegram HTML
13
+ */
14
+ function markdownToTelegramHtml(text) {
15
+ if (!text) return '';
16
+
17
+ const placeholders = [];
18
+ function placeholder(content) {
19
+ const id = `\x00PH${placeholders.length}\x00`;
20
+ placeholders.push(content);
21
+ return id;
22
+ }
23
+
24
+ // 1. Protect existing supported HTML tags (so they survive escaping)
25
+ text = text.replace(/<(\/?(b|i|s|u|code|pre|a)\b[^>]*)>/g, (match) => {
26
+ return placeholder(match);
27
+ });
28
+
29
+ // 2. Extract fenced code blocks (``` ... ```)
30
+ text = text.replace(/```[\w]*\n([\s\S]*?)```/g, (_, code) => {
31
+ return placeholder(`<pre>${escapeHtml(code.replace(/\n$/, ''))}</pre>`);
32
+ });
33
+
34
+ // 3. Extract inline code (` ... `)
35
+ text = text.replace(/`([^`\n]+)`/g, (_, code) => {
36
+ return placeholder(`<code>${escapeHtml(code)}</code>`);
37
+ });
38
+
39
+ // 4. Escape remaining HTML special chars (after code + existing tags are protected)
40
+ text = text.replace(/&/g, '&amp;');
41
+ text = text.replace(/</g, '&lt;');
42
+ text = text.replace(/>/g, '&gt;');
43
+
44
+ // 5. Links: [text](url)
45
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
46
+
47
+ // 6. Bold: **text** or __text__
48
+ text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
49
+ text = text.replace(/__(.+?)__/g, '<b>$1</b>');
50
+
51
+ // 7. Italic: *text* or _text_ (but not inside words for underscores)
52
+ text = text.replace(/(?<!\w)\*([^*\n<]+)\*(?!\w)/g, '<i>$1</i>');
53
+ text = text.replace(/(?<!\w)_([^_\n<]+)_(?!\w)/g, '<i>$1</i>');
54
+
55
+ // 8. Strikethrough: ~~text~~
56
+ text = text.replace(/~~(.+?)~~/g, '<s>$1</s>');
57
+
58
+ // 9. Headings: ## text → bold (must be at line start)
59
+ text = text.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>');
60
+
61
+ // 10. List items: - item or * item → bullet
62
+ text = text.replace(/^[\s]*[-*]\s+/gm, '• ');
63
+
64
+ // 11. Numbered list items: 1. item → keep as-is (already plain text friendly)
65
+
66
+ // 12. Restore placeholders
67
+ for (let i = 0; i < placeholders.length; i++) {
68
+ text = text.replace(`\x00PH${i}\x00`, placeholders[i]);
69
+ }
70
+
71
+ return text;
72
+ }
73
+
74
+ let bot = null;
75
+ let currentToken = null;
76
+
77
+ /**
78
+ * Get or create bot instance
79
+ * @param {string} token - Bot token from @BotFather
80
+ * @returns {Bot} grammY Bot instance
81
+ */
82
+ function getBot(token) {
83
+ if (!bot || currentToken !== token) {
84
+ bot = new Bot(token);
85
+ bot.use(hydrateReply);
86
+ currentToken = token;
87
+ }
88
+ return bot;
89
+ }
90
+
91
+ /**
92
+ * Set webhook for a Telegram bot
93
+ * @param {string} botToken - Bot token from @BotFather
94
+ * @param {string} webhookUrl - HTTPS URL to receive updates
95
+ * @param {string} [secretToken] - Optional secret token for verification
96
+ * @returns {Promise<boolean>} - Success status
97
+ */
98
+ async function setWebhook(botToken, webhookUrl, secretToken) {
99
+ const b = getBot(botToken);
100
+ const options = {};
101
+ if (secretToken) {
102
+ options.secret_token = secretToken;
103
+ }
104
+ return b.api.setWebhook(webhookUrl, options);
105
+ }
106
+
107
+ /**
108
+ * Smart split text into chunks that fit Telegram's limit
109
+ * Prefers splitting at paragraph > newline > sentence > space
110
+ * @param {string} text - Text to split
111
+ * @param {number} maxLength - Maximum chunk length
112
+ * @returns {string[]} Array of chunks
113
+ */
114
+ function smartSplit(text, maxLength = MAX_LENGTH) {
115
+ if (text.length <= maxLength) return [text];
116
+
117
+ const chunks = [];
118
+ let remaining = text;
119
+
120
+ while (remaining.length > 0) {
121
+ if (remaining.length <= maxLength) {
122
+ chunks.push(remaining);
123
+ break;
124
+ }
125
+
126
+ const chunk = remaining.slice(0, maxLength);
127
+ let splitAt = -1;
128
+
129
+ // Try to split at natural boundaries (prefer earlier ones)
130
+ for (const delim of ['\n\n', '\n', '. ', ' ']) {
131
+ const idx = chunk.lastIndexOf(delim);
132
+ if (idx > maxLength * 0.3) {
133
+ splitAt = idx + delim.length;
134
+ break;
135
+ }
136
+ }
137
+
138
+ if (splitAt === -1) splitAt = maxLength;
139
+
140
+ chunks.push(remaining.slice(0, splitAt).trimEnd());
141
+ remaining = remaining.slice(splitAt).trimStart();
142
+ }
143
+
144
+ return chunks;
145
+ }
146
+
147
+ /**
148
+ * Escape HTML special characters
149
+ * @param {string} text - Text to escape
150
+ * @returns {string} Escaped text
151
+ */
152
+ function escapeHtml(text) {
153
+ if (!text) return '';
154
+ return text
155
+ .replace(/&/g, '&amp;')
156
+ .replace(/</g, '&lt;')
157
+ .replace(/>/g, '&gt;');
158
+ }
159
+
160
+ /**
161
+ * Send a message to a Telegram chat with HTML formatting
162
+ * Automatically splits long messages
163
+ * @param {string} botToken - Bot token from @BotFather
164
+ * @param {number|string} chatId - Chat ID to send message to
165
+ * @param {string} text - Message text (HTML formatted)
166
+ * @param {Object} [options] - Additional options
167
+ * @param {boolean} [options.disablePreview] - Disable link previews
168
+ * @returns {Promise<Object>} - Last message sent
169
+ */
170
+ async function sendMessage(botToken, chatId, text, options = {}) {
171
+ const b = getBot(botToken);
172
+ text = markdownToTelegramHtml(text);
173
+ // Strip HTML comments — Telegram's HTML parser doesn't support them
174
+ text = text.replace(/<!--[\s\S]*?-->/g, '');
175
+ const chunks = smartSplit(text, MAX_LENGTH);
176
+
177
+ let lastMessage;
178
+ for (const chunk of chunks) {
179
+ lastMessage = await b.api.sendMessage(chatId, chunk, {
180
+ parse_mode: 'HTML',
181
+ link_preview_options: { is_disabled: options.disablePreview ?? false },
182
+ });
183
+ }
184
+
185
+ return lastMessage;
186
+ }
187
+
188
+ /**
189
+ * Format a job notification message
190
+ * @param {Object} params - Notification parameters
191
+ * @param {string} params.jobId - Full job ID
192
+ * @param {boolean} params.success - Whether job succeeded
193
+ * @param {string} params.summary - Job summary text
194
+ * @param {string} params.prUrl - PR URL
195
+ * @returns {string} Formatted HTML message
196
+ */
197
+ function formatJobNotification({ jobId, success, summary, prUrl }) {
198
+ const emoji = success ? '\u2705' : '\u26a0\ufe0f';
199
+ const status = success ? 'complete' : 'had issues';
200
+ const shortId = jobId.slice(0, 8);
201
+
202
+ return `${emoji} <b>Job ${shortId}</b> ${status}
203
+
204
+ ${escapeHtml(summary)}
205
+
206
+ <a href="${prUrl}">View PR</a>`;
207
+ }
208
+
209
+ /**
210
+ * Download a file from Telegram servers
211
+ * @param {string} botToken - Bot token from @BotFather
212
+ * @param {string} fileId - Telegram file_id
213
+ * @returns {Promise<{buffer: Buffer, filename: string}>}
214
+ */
215
+ async function downloadFile(botToken, fileId) {
216
+ // Get file path from Telegram
217
+ const fileInfoRes = await fetch(
218
+ `https://api.telegram.org/bot${botToken}/getFile?file_id=${fileId}`
219
+ );
220
+ const fileInfo = await fileInfoRes.json();
221
+ if (!fileInfo.ok) {
222
+ throw new Error(`Telegram API error: ${fileInfo.description}`);
223
+ }
224
+
225
+ const filePath = fileInfo.result.file_path;
226
+
227
+ // Download file
228
+ const fileRes = await fetch(
229
+ `https://api.telegram.org/file/bot${botToken}/${filePath}`
230
+ );
231
+ const buffer = Buffer.from(await fileRes.arrayBuffer());
232
+ const filename = filePath.split('/').pop();
233
+
234
+ return { buffer, filename };
235
+ }
236
+
237
+ /**
238
+ * React to a message with an emoji
239
+ * @param {string} botToken - Bot token from @BotFather
240
+ * @param {number|string} chatId - Chat ID
241
+ * @param {number} messageId - Message ID to react to
242
+ * @param {string} [emoji='\ud83d\udc4d'] - Emoji to react with
243
+ */
244
+ async function reactToMessage(botToken, chatId, messageId, emoji = '\ud83d\udc4d') {
245
+ const b = getBot(botToken);
246
+ await b.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }]);
247
+ }
248
+
249
+ /**
250
+ * Start a repeating typing indicator for a chat.
251
+ * Returns a stop function. The indicator naturally expires after 5s,
252
+ * so we re-send with random gaps (5.5-8s) to look human.
253
+ * @param {string} botToken - Bot token from @BotFather
254
+ * @param {number|string} chatId - Chat ID
255
+ * @returns {Function} Call to stop the typing indicator
256
+ */
257
+ function startTypingIndicator(botToken, chatId) {
258
+ const b = getBot(botToken);
259
+ let timeout;
260
+ let stopped = false;
261
+
262
+ function scheduleNext() {
263
+ if (stopped) return;
264
+ const delay = 5500 + Math.random() * 2500;
265
+ timeout = setTimeout(() => {
266
+ if (stopped) return;
267
+ b.api.sendChatAction(chatId, 'typing').catch(() => {});
268
+ scheduleNext();
269
+ }, delay);
270
+ }
271
+
272
+ b.api.sendChatAction(chatId, 'typing').catch(() => {});
273
+ scheduleNext();
274
+
275
+ return () => {
276
+ stopped = true;
277
+ clearTimeout(timeout);
278
+ };
279
+ }
280
+
281
+ export {
282
+ getBot,
283
+ setWebhook,
284
+ sendMessage,
285
+ smartSplit,
286
+ escapeHtml,
287
+ markdownToTelegramHtml,
288
+ formatJobNotification,
289
+ downloadFile,
290
+ reactToMessage,
291
+ startTypingIndicator,
292
+ };
@@ -0,0 +1,118 @@
1
+ import fs from 'fs';
2
+ import { triggersFile, triggersDir } from './paths.js';
3
+ import { executeAction } from './actions.js';
4
+
5
+ /**
6
+ * Shell-escape a string to prevent command injection when used in shell commands.
7
+ * Wraps in single quotes and escapes any embedded single quotes.
8
+ */
9
+ function shellEscape(str) {
10
+ return "'" + String(str).replace(/'/g, "'\\''") + "'";
11
+ }
12
+
13
+ /**
14
+ * Replace {{body.field}} templates with values from request context.
15
+ * @param {string} template - String with {{body.field}} placeholders
16
+ * @param {Object} context - { body, query, headers }
17
+ * @param {Object} [opts] - { escape: 'shell' } to shell-escape substituted values
18
+ * @returns {string}
19
+ */
20
+ function resolveTemplate(template, context, opts = {}) {
21
+ const escape = opts.escape === 'shell' ? shellEscape : (v) => v;
22
+ return template.replace(/\{\{(\w+)(?:\.(\w+))?\}\}/g, (match, source, field) => {
23
+ const data = context[source];
24
+ if (data === undefined) return match;
25
+ if (!field) {
26
+ const raw = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
27
+ return escape(raw);
28
+ }
29
+ if (data[field] !== undefined) return escape(String(data[field]));
30
+ return match;
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Execute all actions for a trigger (fire-and-forget)
36
+ * @param {Object} trigger - Trigger config object
37
+ * @param {Object} context - { body, query, headers }
38
+ */
39
+ async function executeActions(trigger, context) {
40
+ for (const action of trigger.actions) {
41
+ try {
42
+ const resolved = { ...action };
43
+ // Shell-escape template values in commands to prevent injection from HTTP input
44
+ if (resolved.command) resolved.command = resolveTemplate(resolved.command, context, { escape: 'shell' });
45
+ if (resolved.job) resolved.job = resolveTemplate(resolved.job, context);
46
+ const result = await executeAction(resolved, { cwd: triggersDir, data: context.body });
47
+ console.log(`[TRIGGER] ${trigger.name}: ${result || 'ran'}`);
48
+ } catch (err) {
49
+ console.error(`[TRIGGER] ${trigger.name}: error - ${err.message}`);
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Load triggers from TRIGGERS.json and return trigger map + fire function
56
+ * @returns {{ triggerMap: Map, fireTriggers: Function }}
57
+ */
58
+ function loadTriggers() {
59
+ const triggerFile = triggersFile;
60
+ const triggerMap = new Map();
61
+
62
+ console.log('\n--- Triggers ---');
63
+
64
+ if (!fs.existsSync(triggerFile)) {
65
+ console.log('No TRIGGERS.json found');
66
+ console.log('----------------\n');
67
+ return { triggerMap, fireTriggers: () => {} };
68
+ }
69
+
70
+ const triggers = JSON.parse(fs.readFileSync(triggerFile, 'utf8'));
71
+
72
+ for (const trigger of triggers) {
73
+ if (trigger.enabled === false) continue;
74
+
75
+ if (!triggerMap.has(trigger.watch_path)) {
76
+ triggerMap.set(trigger.watch_path, []);
77
+ }
78
+ triggerMap.get(trigger.watch_path).push(trigger);
79
+ }
80
+
81
+ const activeCount = [...triggerMap.values()].reduce((sum, arr) => sum + arr.length, 0);
82
+
83
+ if (activeCount === 0) {
84
+ console.log('No active triggers');
85
+ } else {
86
+ for (const [watchPath, pathTriggers] of triggerMap) {
87
+ for (const t of pathTriggers) {
88
+ const actionTypes = t.actions.map(a => a.type || 'agent').join(', ');
89
+ console.log(` ${t.name}: ${watchPath} (${actionTypes})`);
90
+ }
91
+ }
92
+ }
93
+
94
+ console.log('----------------\n');
95
+
96
+ /**
97
+ * Fire matching triggers for a given path (non-blocking)
98
+ * @param {string} path - Request path (e.g., '/webhook')
99
+ * @param {Object} body - Request body
100
+ * @param {Object} [query={}] - Query parameters
101
+ * @param {Object} [headers={}] - Request headers
102
+ */
103
+ function fireTriggers(path, body, query = {}, headers = {}) {
104
+ const matched = triggerMap.get(path);
105
+ if (matched) {
106
+ const context = { body, query, headers };
107
+ for (const trigger of matched) {
108
+ executeActions(trigger, context).catch(err => {
109
+ console.error(`[TRIGGER] ${trigger.name}: unhandled error - ${err.message}`);
110
+ });
111
+ }
112
+ }
113
+ }
114
+
115
+ return { triggerMap, fireTriggers };
116
+ }
117
+
118
+ export { loadTriggers };