thepopebot 1.0.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/api/index.js +357 -0
  4. package/bin/cli.js +278 -0
  5. package/config/index.js +29 -0
  6. package/config/instrumentation.js +29 -0
  7. package/docker/Dockerfile +51 -0
  8. package/docker/entrypoint.sh +100 -0
  9. package/lib/actions.js +40 -0
  10. package/lib/claude/conversation.js +76 -0
  11. package/lib/claude/index.js +142 -0
  12. package/lib/claude/tools.js +54 -0
  13. package/lib/cron.js +60 -0
  14. package/lib/paths.js +30 -0
  15. package/lib/tools/create-job.js +40 -0
  16. package/lib/tools/github.js +122 -0
  17. package/lib/tools/openai.js +35 -0
  18. package/lib/tools/telegram.js +222 -0
  19. package/lib/triggers.js +105 -0
  20. package/lib/utils/render-md.js +39 -0
  21. package/package.json +57 -0
  22. package/pi/extensions/env-sanitizer/index.ts +48 -0
  23. package/pi/extensions/env-sanitizer/package.json +5 -0
  24. package/pi/skills/llm-secrets/SKILL.md +34 -0
  25. package/pi/skills/llm-secrets/llm-secrets.js +34 -0
  26. package/setup/lib/auth.mjs +160 -0
  27. package/setup/lib/github.mjs +148 -0
  28. package/setup/lib/prerequisites.mjs +135 -0
  29. package/setup/lib/prompts.mjs +268 -0
  30. package/setup/lib/telegram-verify.mjs +66 -0
  31. package/setup/lib/telegram.mjs +76 -0
  32. package/setup/package.json +6 -0
  33. package/setup/setup-telegram.mjs +236 -0
  34. package/setup/setup.mjs +540 -0
  35. package/templates/.env.example +38 -0
  36. package/templates/.github/workflows/auto-merge.yml +117 -0
  37. package/templates/.github/workflows/docker-build.yml +34 -0
  38. package/templates/.github/workflows/run-job.yml +40 -0
  39. package/templates/.github/workflows/update-event-handler.yml +126 -0
  40. package/templates/.pi/skills/modify-self/SKILL.md +12 -0
  41. package/templates/CLAUDE.md +52 -0
  42. package/templates/app/api/[...thepopebot]/route.js +1 -0
  43. package/templates/app/layout.js +12 -0
  44. package/templates/app/page.js +8 -0
  45. package/templates/instrumentation.js +1 -0
  46. package/templates/next.config.mjs +3 -0
  47. package/templates/operating_system/AGENT.md +32 -0
  48. package/templates/operating_system/CHATBOT.md +74 -0
  49. package/templates/operating_system/CRONS.json +16 -0
  50. package/templates/operating_system/HEARTBEAT.md +3 -0
  51. package/templates/operating_system/JOB_SUMMARY.md +36 -0
  52. package/templates/operating_system/SOUL.md +17 -0
  53. package/templates/operating_system/TELEGRAM.md +21 -0
  54. package/templates/operating_system/TRIGGERS.json +18 -0
@@ -0,0 +1,142 @@
1
+ const paths = require('../paths');
2
+ const { render_md } = require('../utils/render-md');
3
+
4
+ const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
5
+
6
+ // Web search tool definition (Anthropic built-in)
7
+ const WEB_SEARCH_TOOL = {
8
+ type: 'web_search_20250305',
9
+ name: 'web_search',
10
+ max_uses: 5,
11
+ };
12
+
13
+ /**
14
+ * Get Anthropic API key from environment
15
+ * @returns {string} API key
16
+ */
17
+ function getApiKey() {
18
+ if (process.env.ANTHROPIC_API_KEY) {
19
+ return process.env.ANTHROPIC_API_KEY;
20
+ }
21
+ throw new Error('ANTHROPIC_API_KEY environment variable is required');
22
+ }
23
+
24
+ /**
25
+ * Call Claude API
26
+ * @param {Array} messages - Conversation messages
27
+ * @param {Array} tools - Tool definitions
28
+ * @returns {Promise<Object>} API response
29
+ */
30
+ async function callClaude(messages, tools) {
31
+ const apiKey = getApiKey();
32
+ const model = process.env.EVENT_HANDLER_MODEL || DEFAULT_MODEL;
33
+ const systemPrompt = render_md(paths.chatbotMd);
34
+
35
+ // Combine user tools with web search
36
+ const allTools = [WEB_SEARCH_TOOL, ...tools];
37
+
38
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ 'x-api-key': apiKey,
43
+ 'anthropic-version': '2023-06-01',
44
+ 'anthropic-beta': 'web-search-2025-03-05',
45
+ },
46
+ body: JSON.stringify({
47
+ model,
48
+ max_tokens: 4096,
49
+ system: systemPrompt,
50
+ messages,
51
+ tools: allTools,
52
+ }),
53
+ });
54
+
55
+ if (!response.ok) {
56
+ const error = await response.text();
57
+ throw new Error(`Claude API error: ${response.status} ${error}`);
58
+ }
59
+
60
+ return response.json();
61
+ }
62
+
63
+ /**
64
+ * Process a conversation turn with Claude, handling tool calls
65
+ * @param {string} userMessage - User's message
66
+ * @param {Array} history - Conversation history
67
+ * @param {Array} toolDefinitions - Available tools
68
+ * @param {Object} toolExecutors - Tool executor functions
69
+ * @returns {Promise<{response: string, history: Array}>}
70
+ */
71
+ async function chat(userMessage, history, toolDefinitions, toolExecutors) {
72
+ // Add user message to history
73
+ const messages = [...history, { role: 'user', content: userMessage }];
74
+
75
+ let response = await callClaude(messages, toolDefinitions);
76
+ let assistantContent = response.content;
77
+
78
+ // Add assistant response to history
79
+ messages.push({ role: 'assistant', content: assistantContent });
80
+
81
+ // Handle tool use loop
82
+ while (response.stop_reason === 'tool_use') {
83
+ const toolResults = [];
84
+
85
+ for (const block of assistantContent) {
86
+ if (block.type === 'tool_use') {
87
+ // Skip web_search - it's a server-side tool executed by Anthropic
88
+ if (block.name === 'web_search') {
89
+ continue;
90
+ }
91
+
92
+ const executor = toolExecutors[block.name];
93
+ let result;
94
+
95
+ if (executor) {
96
+ try {
97
+ result = await executor(block.input);
98
+ } catch (err) {
99
+ result = { error: err.message };
100
+ }
101
+ } else {
102
+ result = { error: `Unknown tool: ${block.name}` };
103
+ }
104
+
105
+ toolResults.push({
106
+ type: 'tool_result',
107
+ tool_use_id: block.id,
108
+ content: JSON.stringify(result),
109
+ });
110
+ }
111
+ }
112
+
113
+ // If no client-side tools to execute, we're done
114
+ if (toolResults.length === 0) {
115
+ break;
116
+ }
117
+
118
+ // Add tool results to messages
119
+ messages.push({ role: 'user', content: toolResults });
120
+
121
+ // Get next response from Claude
122
+ response = await callClaude(messages, toolDefinitions);
123
+ assistantContent = response.content;
124
+
125
+ // Add new assistant response to history
126
+ messages.push({ role: 'assistant', content: assistantContent });
127
+ }
128
+
129
+ // Extract text response
130
+ const textBlocks = assistantContent.filter((block) => block.type === 'text');
131
+ const responseText = textBlocks.map((block) => block.text).join('\n');
132
+
133
+ return {
134
+ response: responseText,
135
+ history: messages,
136
+ };
137
+ }
138
+
139
+ module.exports = {
140
+ chat,
141
+ getApiKey,
142
+ };
@@ -0,0 +1,54 @@
1
+ const { createJob } = require('../tools/create-job');
2
+ const { getJobStatus } = require('../tools/github');
3
+
4
+ const toolDefinitions = [
5
+ {
6
+ name: 'create_job',
7
+ description:
8
+ 'Create an autonomous job for thepopebot to execute. Use this tool liberally - if the user asks for ANY task to be done, create a job. Jobs can handle code changes, file updates, research tasks, web scraping, data analysis, or anything requiring autonomous work. When the user explicitly asks for a job, ALWAYS use this tool. Returns the job ID and branch name.',
9
+ input_schema: {
10
+ type: 'object',
11
+ properties: {
12
+ job_description: {
13
+ type: 'string',
14
+ description:
15
+ 'Detailed job description including context and requirements. Be specific about what needs to be done.',
16
+ },
17
+ },
18
+ required: ['job_description'],
19
+ },
20
+ },
21
+ {
22
+ name: 'get_job_status',
23
+ description:
24
+ 'Check status of running jobs. Returns list of active workflow runs with timing and current step. Use when user asks about job progress, running jobs, or job status.',
25
+ input_schema: {
26
+ type: 'object',
27
+ properties: {
28
+ job_id: {
29
+ type: 'string',
30
+ description:
31
+ 'Optional: specific job ID to check. If omitted, returns all running jobs.',
32
+ },
33
+ },
34
+ required: [],
35
+ },
36
+ },
37
+ ];
38
+
39
+ const toolExecutors = {
40
+ create_job: async (input) => {
41
+ const result = await createJob(input.job_description);
42
+ return {
43
+ success: true,
44
+ job_id: result.job_id,
45
+ branch: result.branch,
46
+ };
47
+ },
48
+ get_job_status: async (input) => {
49
+ const result = await getJobStatus(input.job_id);
50
+ return result;
51
+ },
52
+ };
53
+
54
+ module.exports = { toolDefinitions, toolExecutors };
package/lib/cron.js ADDED
@@ -0,0 +1,60 @@
1
+ const cron = require('node-cron');
2
+ const fs = require('fs');
3
+ const paths = require('./paths');
4
+
5
+ const { executeAction } = require('./actions');
6
+
7
+ /**
8
+ * Load and schedule crons from CRONS.json
9
+ * @returns {Array} - Array of scheduled cron tasks
10
+ */
11
+ function loadCrons() {
12
+ const cronFile = paths.cronsFile;
13
+
14
+ console.log('\n--- Cron Jobs ---');
15
+
16
+ if (!fs.existsSync(cronFile)) {
17
+ console.log('No CRONS.json found');
18
+ console.log('-----------------\n');
19
+ return [];
20
+ }
21
+
22
+ const crons = JSON.parse(fs.readFileSync(cronFile, 'utf8'));
23
+ const tasks = [];
24
+
25
+ for (const cronEntry of crons) {
26
+ const { name, schedule, type = 'agent', enabled } = cronEntry;
27
+ if (enabled === false) continue;
28
+
29
+ if (!cron.validate(schedule)) {
30
+ console.error(`Invalid schedule for "${name}": ${schedule}`);
31
+ continue;
32
+ }
33
+
34
+ const task = cron.schedule(schedule, async () => {
35
+ try {
36
+ const result = await executeAction(cronEntry, { cwd: paths.cronDir });
37
+ console.log(`[CRON] ${name}: ${result || 'ran'}`);
38
+ console.log(`[CRON] ${name}: completed!`);
39
+ } catch (err) {
40
+ console.error(`[CRON] ${name}: error - ${err.message}`);
41
+ }
42
+ });
43
+
44
+ tasks.push({ name, schedule, type, task });
45
+ }
46
+
47
+ if (tasks.length === 0) {
48
+ console.log('No active cron jobs');
49
+ } else {
50
+ for (const { name, schedule, type } of tasks) {
51
+ console.log(` ${name}: ${schedule} (${type})`);
52
+ }
53
+ }
54
+
55
+ console.log('-----------------\n');
56
+
57
+ return tasks;
58
+ }
59
+
60
+ module.exports = { loadCrons };
package/lib/paths.js ADDED
@@ -0,0 +1,30 @@
1
+ const path = require('path');
2
+
3
+ /**
4
+ * Central path resolver for thepopebot.
5
+ * All paths resolve from process.cwd() (the user's project root).
6
+ */
7
+
8
+ const PROJECT_ROOT = process.cwd();
9
+
10
+ module.exports = {
11
+ PROJECT_ROOT,
12
+
13
+ // operating_system/ files
14
+ operatingSystemDir: path.join(PROJECT_ROOT, 'operating_system'),
15
+ cronsFile: path.join(PROJECT_ROOT, 'operating_system', 'CRONS.json'),
16
+ triggersFile: path.join(PROJECT_ROOT, 'operating_system', 'TRIGGERS.json'),
17
+ chatbotMd: path.join(PROJECT_ROOT, 'operating_system', 'CHATBOT.md'),
18
+ jobSummaryMd: path.join(PROJECT_ROOT, 'operating_system', 'JOB_SUMMARY.md'),
19
+ soulMd: path.join(PROJECT_ROOT, 'operating_system', 'SOUL.md'),
20
+
21
+ // Working directories for command-type actions
22
+ cronDir: path.join(PROJECT_ROOT, 'cron'),
23
+ triggersDir: path.join(PROJECT_ROOT, 'triggers'),
24
+
25
+ // Logs
26
+ logsDir: path.join(PROJECT_ROOT, 'logs'),
27
+
28
+ // .env
29
+ envFile: path.join(PROJECT_ROOT, '.env'),
30
+ };
@@ -0,0 +1,40 @@
1
+ const { v4: uuidv4 } = require('uuid');
2
+ const { githubApi } = require('./github');
3
+
4
+ /**
5
+ * Create a new job branch with updated job.md
6
+ * @param {string} jobDescription - The job description to write to job.md
7
+ * @returns {Promise<{job_id: string, branch: string}>} - Job ID and branch name
8
+ */
9
+ async function createJob(jobDescription) {
10
+ const { GH_OWNER, GH_REPO } = process.env;
11
+ const jobId = uuidv4();
12
+ const branch = `job/${jobId}`;
13
+
14
+ // 1. Get main branch SHA
15
+ const mainRef = await githubApi(`/repos/${GH_OWNER}/${GH_REPO}/git/ref/heads/main`);
16
+ const mainSha = mainRef.object.sha;
17
+
18
+ // 2. Create new branch
19
+ await githubApi(`/repos/${GH_OWNER}/${GH_REPO}/git/refs`, {
20
+ method: 'POST',
21
+ body: JSON.stringify({
22
+ ref: `refs/heads/${branch}`,
23
+ sha: mainSha,
24
+ }),
25
+ });
26
+
27
+ // 3. Create logs/${jobId}/job.md with job content
28
+ await githubApi(`/repos/${GH_OWNER}/${GH_REPO}/contents/logs/${jobId}/job.md`, {
29
+ method: 'PUT',
30
+ body: JSON.stringify({
31
+ message: `job: ${jobId}`,
32
+ content: Buffer.from(jobDescription).toString('base64'),
33
+ branch: branch,
34
+ }),
35
+ });
36
+
37
+ return { job_id: jobId, branch };
38
+ }
39
+
40
+ module.exports = { createJob };
@@ -0,0 +1,122 @@
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
+ const res = await fetch(`https://api.github.com${endpoint}`, {
10
+ ...options,
11
+ headers: {
12
+ 'Authorization': `Bearer ${GH_TOKEN}`,
13
+ 'Accept': 'application/vnd.github+json',
14
+ 'X-GitHub-Api-Version': '2022-11-28',
15
+ ...options.headers,
16
+ },
17
+ });
18
+
19
+ if (!res.ok) {
20
+ const error = await res.text();
21
+ throw new Error(`GitHub API error: ${res.status} ${error}`);
22
+ }
23
+
24
+ return res.json();
25
+ }
26
+
27
+ /**
28
+ * Get workflow runs with optional status filter
29
+ * @param {string} [status] - Filter by status (in_progress, queued, completed)
30
+ * @returns {Promise<object>} - Workflow runs response
31
+ */
32
+ async function getWorkflowRuns(status) {
33
+ const { GH_OWNER, GH_REPO } = process.env;
34
+ const params = new URLSearchParams();
35
+ if (status) params.set('status', status);
36
+ params.set('per_page', '20');
37
+
38
+ const query = params.toString();
39
+ return githubApi(`/repos/${GH_OWNER}/${GH_REPO}/actions/runs?${query}`);
40
+ }
41
+
42
+ /**
43
+ * Get jobs for a specific workflow run
44
+ * @param {number} runId - Workflow run ID
45
+ * @returns {Promise<object>} - Jobs response with steps
46
+ */
47
+ async function getWorkflowRunJobs(runId) {
48
+ const { GH_OWNER, GH_REPO } = process.env;
49
+ return githubApi(`/repos/${GH_OWNER}/${GH_REPO}/actions/runs/${runId}/jobs`);
50
+ }
51
+
52
+ /**
53
+ * Get job status for running/recent jobs
54
+ * @param {string} [jobId] - Optional specific job ID to filter by
55
+ * @returns {Promise<object>} - Status summary with jobs array
56
+ */
57
+ async function getJobStatus(jobId) {
58
+ // Fetch both in_progress and queued runs
59
+ const [inProgress, queued] = await Promise.all([
60
+ getWorkflowRuns('in_progress'),
61
+ getWorkflowRuns('queued'),
62
+ ]);
63
+
64
+ const allRuns = [...(inProgress.workflow_runs || []), ...(queued.workflow_runs || [])];
65
+
66
+ // Filter to only job/* branches
67
+ const jobRuns = allRuns.filter(run => run.head_branch?.startsWith('job/'));
68
+
69
+ // If specific job requested, filter further
70
+ const filteredRuns = jobId
71
+ ? jobRuns.filter(run => run.head_branch === `job/${jobId}`)
72
+ : jobRuns;
73
+
74
+ // Get detailed job info for each run
75
+ const jobs = await Promise.all(
76
+ filteredRuns.map(async (run) => {
77
+ const extractedJobId = run.head_branch.slice(4); // Remove 'job/' prefix
78
+ const startedAt = new Date(run.created_at);
79
+ const durationMinutes = Math.round((Date.now() - startedAt.getTime()) / 60000);
80
+
81
+ let currentStep = null;
82
+ let stepsCompleted = 0;
83
+ let stepsTotal = 0;
84
+
85
+ try {
86
+ const jobsData = await getWorkflowRunJobs(run.id);
87
+ if (jobsData.jobs?.length > 0) {
88
+ const job = jobsData.jobs[0];
89
+ stepsTotal = job.steps?.length || 0;
90
+ stepsCompleted = job.steps?.filter(s => s.status === 'completed').length || 0;
91
+ currentStep = job.steps?.find(s => s.status === 'in_progress')?.name || null;
92
+ }
93
+ } catch (err) {
94
+ // Jobs endpoint may fail if run hasn't started yet
95
+ }
96
+
97
+ return {
98
+ job_id: extractedJobId,
99
+ branch: run.head_branch,
100
+ status: run.status,
101
+ started_at: run.created_at,
102
+ duration_minutes: durationMinutes,
103
+ current_step: currentStep,
104
+ steps_completed: stepsCompleted,
105
+ steps_total: stepsTotal,
106
+ run_id: run.id,
107
+ };
108
+ })
109
+ );
110
+
111
+ // Count only job/* branches, not all workflows
112
+ const runningCount = jobs.filter(j => j.status === 'in_progress').length;
113
+ const queuedCount = jobs.filter(j => j.status === 'queued').length;
114
+
115
+ return {
116
+ jobs,
117
+ queued: queuedCount,
118
+ running: runningCount,
119
+ };
120
+ }
121
+
122
+ module.exports = { githubApi, getWorkflowRuns, getWorkflowRunJobs, getJobStatus };
@@ -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
+ module.exports = { isWhisperEnabled, transcribeAudio };