thepopebot 1.2.67 → 1.2.69-beta.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.
package/README.md CHANGED
@@ -243,6 +243,80 @@ The `templates/` directory contains files scaffolded into user projects by `thep
243
243
 
244
244
  ---
245
245
 
246
+ ## Production Deployment
247
+
248
+ Deploy your agent to a cloud VPS with HTTPS.
249
+
250
+ ### 1. Server prerequisites
251
+
252
+ You need a VPS (any provider — Hetzner, DigitalOcean, AWS, etc.) with:
253
+
254
+ - Docker + Docker Compose
255
+ - Node.js 18+
256
+ - Git
257
+ - GitHub CLI (`gh`)
258
+
259
+ Point a domain (e.g., `mybot.example.com`) to your server's IP address with a DNS A record.
260
+
261
+ ### 2. Scaffold and configure
262
+
263
+ SSH into your server and scaffold the project:
264
+
265
+ ```bash
266
+ mkdir my-agent && cd my-agent
267
+ npx thepopebot@latest init
268
+ npm run setup
269
+ ```
270
+
271
+ When the setup wizard asks for `APP_URL`, enter your production URL with `https://` (e.g., `https://mybot.example.com`).
272
+
273
+ Set the `RUNS_ON` GitHub variable so workflows use your server's self-hosted runner instead of GitHub-hosted runners:
274
+
275
+ ```bash
276
+ gh variable set RUNS_ON --body "self-hosted" --repo OWNER/REPO
277
+ ```
278
+
279
+ ### 3. Enable HTTPS (Let's Encrypt)
280
+
281
+ The `docker-compose.yml` has Let's Encrypt support built in but commented out. Three edits to enable it:
282
+
283
+ **a) Add your email to `.env`:**
284
+
285
+ ```
286
+ LETSENCRYPT_EMAIL=you@example.com
287
+ ```
288
+
289
+ **b) Uncomment the TLS lines in the traefik service command:**
290
+
291
+ ```yaml
292
+ - --entrypoints.web.http.redirections.entrypoint.to=websecure
293
+ - --entrypoints.web.http.redirections.entrypoint.scheme=https
294
+ - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}
295
+ - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
296
+ - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
297
+ ```
298
+
299
+ **c) Switch the event-handler labels from HTTP to HTTPS:**
300
+
301
+ Comment out the HTTP entrypoint and uncomment the two HTTPS lines:
302
+
303
+ ```yaml
304
+ # - traefik.http.routers.event-handler.entrypoints=web
305
+ - traefik.http.routers.event-handler.entrypoints=websecure
306
+ - traefik.http.routers.event-handler.tls.certresolver=letsencrypt
307
+ ```
308
+
309
+ ### 4. Build and launch
310
+
311
+ ```bash
312
+ npm run build
313
+ docker compose up -d
314
+ ```
315
+
316
+ Ports 80 and 443 must be open on your server. Port 80 is required even with HTTPS — Let's Encrypt uses it for the ACME HTTP challenge to verify domain ownership.
317
+
318
+ ---
319
+
246
320
  ## Docs
247
321
 
248
322
  | Document | Description |
package/bin/cli.js CHANGED
@@ -50,11 +50,15 @@ function printUsage() {
50
50
  Usage: thepopebot <command>
51
51
 
52
52
  Commands:
53
- init Scaffold a new thepopebot project
54
- setup Run interactive setup wizard
55
- setup-telegram Reconfigure Telegram webhook
56
- reset-auth Regenerate AUTH_SECRET (invalidates all sessions)
57
- reset [file] Restore a template file (or list available templates)
53
+ init Scaffold a new thepopebot project
54
+ setup Run interactive setup wizard
55
+ setup-telegram Reconfigure Telegram webhook
56
+ reset-auth Regenerate AUTH_SECRET (invalidates all sessions)
57
+ reset [file] Restore a template file (or list available templates)
58
+ diff [file] Show differences between project files and package templates
59
+ set-agent-secret <KEY> [VALUE] Set a GitHub secret with AGENT_ prefix (also updates .env)
60
+ set-agent-llm-secret <KEY> [VALUE] Set a GitHub secret with AGENT_LLM_ prefix
61
+ set-var <KEY> [VALUE] Set a GitHub repository variable
58
62
  `);
59
63
  }
60
64
 
@@ -199,6 +203,17 @@ async function init() {
199
203
  }
200
204
  }
201
205
 
206
+ // Create default skill symlinks (brave-search, browser-tools)
207
+ const defaultSkills = ['brave-search', 'browser-tools'];
208
+ for (const skill of defaultSkills) {
209
+ const symlink = path.join(cwd, '.pi', 'skills', skill);
210
+ if (!fs.existsSync(symlink)) {
211
+ fs.mkdirSync(path.dirname(symlink), { recursive: true });
212
+ fs.symlinkSync(`../../pi-skills/${skill}`, symlink);
213
+ console.log(` Created .pi/skills/${skill} → ../../pi-skills/${skill}`);
214
+ }
215
+ }
216
+
202
217
  // Report updated managed files
203
218
  if (updated.length > 0) {
204
219
  console.log('\n Updated managed files:');
@@ -399,6 +414,115 @@ async function resetAuth() {
399
414
  console.log(' Restart your server for the change to take effect.\n');
400
415
  }
401
416
 
417
+ /**
418
+ * Load GH_OWNER and GH_REPO from .env
419
+ */
420
+ function loadRepoInfo() {
421
+ const envPath = path.join(process.cwd(), '.env');
422
+ if (!fs.existsSync(envPath)) {
423
+ console.error('\n No .env file found. Run "npm run setup" first.\n');
424
+ process.exit(1);
425
+ }
426
+ const content = fs.readFileSync(envPath, 'utf-8');
427
+ const env = {};
428
+ for (const line of content.split('\n')) {
429
+ const match = line.match(/^([^#=]+)=(.*)$/);
430
+ if (match) env[match[1].trim()] = match[2].trim();
431
+ }
432
+ if (!env.GH_OWNER || !env.GH_REPO) {
433
+ console.error('\n GH_OWNER and GH_REPO not found in .env. Run "npm run setup" first.\n');
434
+ process.exit(1);
435
+ }
436
+ return { owner: env.GH_OWNER, repo: env.GH_REPO };
437
+ }
438
+
439
+ /**
440
+ * Prompt for a secret value interactively if not provided as an argument
441
+ */
442
+ async function promptForValue(key) {
443
+ const { default: inquirer } = await import('inquirer');
444
+ const { value } = await inquirer.prompt([{
445
+ type: 'password',
446
+ name: 'value',
447
+ message: `Enter value for ${key}:`,
448
+ mask: '*',
449
+ validate: (input) => input ? true : 'Value is required',
450
+ }]);
451
+ return value;
452
+ }
453
+
454
+ async function setAgentSecret(key, value) {
455
+ if (!key) {
456
+ console.error('\n Usage: thepopebot set-agent-secret <KEY> [VALUE]\n');
457
+ console.error(' Example: thepopebot set-agent-secret ANTHROPIC_API_KEY\n');
458
+ process.exit(1);
459
+ }
460
+
461
+ if (!value) value = await promptForValue(key);
462
+
463
+ const { owner, repo } = loadRepoInfo();
464
+ const prefixedName = `AGENT_${key}`;
465
+
466
+ const { setSecret } = await import(path.join(__dirname, '..', 'setup', 'lib', 'github.mjs'));
467
+ const { updateEnvVariable } = await import(path.join(__dirname, '..', 'setup', 'lib', 'auth.mjs'));
468
+
469
+ const result = await setSecret(owner, repo, prefixedName, value);
470
+ if (result.success) {
471
+ console.log(`\n Set GitHub secret: ${prefixedName}`);
472
+ updateEnvVariable(key, value);
473
+ console.log(` Updated .env: ${key}`);
474
+ console.log('');
475
+ } else {
476
+ console.error(`\n Failed to set ${prefixedName}: ${result.error}\n`);
477
+ process.exit(1);
478
+ }
479
+ }
480
+
481
+ async function setAgentLlmSecret(key, value) {
482
+ if (!key) {
483
+ console.error('\n Usage: thepopebot set-agent-llm-secret <KEY> [VALUE]\n');
484
+ console.error(' Example: thepopebot set-agent-llm-secret BRAVE_API_KEY\n');
485
+ process.exit(1);
486
+ }
487
+
488
+ if (!value) value = await promptForValue(key);
489
+
490
+ const { owner, repo } = loadRepoInfo();
491
+ const prefixedName = `AGENT_LLM_${key}`;
492
+
493
+ const { setSecret } = await import(path.join(__dirname, '..', 'setup', 'lib', 'github.mjs'));
494
+
495
+ const result = await setSecret(owner, repo, prefixedName, value);
496
+ if (result.success) {
497
+ console.log(`\n Set GitHub secret: ${prefixedName}\n`);
498
+ } else {
499
+ console.error(`\n Failed to set ${prefixedName}: ${result.error}\n`);
500
+ process.exit(1);
501
+ }
502
+ }
503
+
504
+ async function setVar(key, value) {
505
+ if (!key) {
506
+ console.error('\n Usage: thepopebot set-var <KEY> [VALUE]\n');
507
+ console.error(' Example: thepopebot set-var LLM_MODEL claude-sonnet-4-5-20250929\n');
508
+ process.exit(1);
509
+ }
510
+
511
+ if (!value) value = await promptForValue(key);
512
+
513
+ const { owner, repo } = loadRepoInfo();
514
+
515
+ const { setVariable } = await import(path.join(__dirname, '..', 'setup', 'lib', 'github.mjs'));
516
+
517
+ const result = await setVariable(owner, repo, key, value);
518
+ if (result.success) {
519
+ console.log(`\n Set GitHub variable: ${key}\n`);
520
+ } else {
521
+ console.error(`\n Failed to set ${key}: ${result.error}\n`);
522
+ process.exit(1);
523
+ }
524
+ }
525
+
402
526
  switch (command) {
403
527
  case 'init':
404
528
  await init();
@@ -418,6 +542,15 @@ switch (command) {
418
542
  case 'diff':
419
543
  diff(args[0]);
420
544
  break;
545
+ case 'set-agent-secret':
546
+ await setAgentSecret(args[0], args[1]);
547
+ break;
548
+ case 'set-agent-llm-secret':
549
+ await setAgentLlmSecret(args[0], args[1]);
550
+ break;
551
+ case 'set-var':
552
+ await setVar(args[0], args[1]);
553
+ break;
421
554
  default:
422
555
  printUsage();
423
556
  process.exit(command ? 1 : 0);
package/lib/ai/agent.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createReactAgent } from '@langchain/langgraph/prebuilt';
2
+ import { SystemMessage } from '@langchain/core/messages';
2
3
  import { createModel } from './model.js';
3
4
  import { createJobTool, getJobStatusTool } from './tools.js';
4
5
  import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite';
@@ -10,19 +11,19 @@ let _agent = null;
10
11
  /**
11
12
  * Get or create the LangGraph agent singleton.
12
13
  * Uses createReactAgent which handles the tool loop automatically.
14
+ * Prompt is a function so {{datetime}} resolves fresh each invocation.
13
15
  */
14
16
  export async function getAgent() {
15
17
  if (!_agent) {
16
18
  const model = await createModel();
17
19
  const tools = [createJobTool, getJobStatusTool];
18
20
  const checkpointer = SqliteSaver.fromConnString(thepopebotDb);
19
- const systemPrompt = render_md(chatbotMd);
20
21
 
21
22
  _agent = createReactAgent({
22
23
  llm: model,
23
24
  tools,
24
25
  checkpointSaver: checkpointer,
25
- prompt: systemPrompt,
26
+ prompt: (state) => [new SystemMessage(render_md(chatbotMd)), ...state.messages],
26
27
  });
27
28
  }
28
29
  return _agent;
package/lib/cron.js CHANGED
@@ -35,8 +35,11 @@ function setUpdateAvailable(v) {
35
35
  * @returns {boolean} true if candidate > baseline
36
36
  */
37
37
  function isVersionNewer(candidate, baseline) {
38
+ // Pre-release candidate is never "newer" for upgrade purposes
39
+ if (candidate.includes('-')) return false;
40
+
38
41
  const a = candidate.split('.').map(Number);
39
- const b = baseline.split('.').map(Number);
42
+ const b = baseline.replace(/-.*$/, '').split('.').map(Number);
40
43
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
41
44
  const av = a[i] || 0;
42
45
  const bv = b[i] || 0;
@@ -3,9 +3,27 @@ import path from 'path';
3
3
  import { PROJECT_ROOT } from '../paths.js';
4
4
 
5
5
  const INCLUDE_PATTERN = /\{\{([^}]+\.md)\}\}/g;
6
+ const VARIABLE_PATTERN = /\{\{(datetime)\}\}/gi;
6
7
 
7
8
  /**
8
- * Render a markdown file, resolving {{filepath}} includes recursively.
9
+ * Resolve built-in variables like {{datetime}}.
10
+ * @param {string} content - Content with possible variable placeholders
11
+ * @returns {string} Content with variables resolved
12
+ */
13
+ function resolveVariables(content) {
14
+ return content.replace(VARIABLE_PATTERN, (match, variable) => {
15
+ switch (variable.toLowerCase()) {
16
+ case 'datetime':
17
+ return new Date().toISOString();
18
+ default:
19
+ return match;
20
+ }
21
+ });
22
+ }
23
+
24
+ /**
25
+ * Render a markdown file, resolving {{filepath}} includes recursively
26
+ * and {{datetime}} built-in variables.
9
27
  * Referenced file paths resolve relative to the project root.
10
28
  * @param {string} filePath - Absolute path to the markdown file
11
29
  * @param {string[]} [chain=[]] - Already-resolved file paths (for circular detection)
@@ -27,13 +45,15 @@ function render_md(filePath, chain = []) {
27
45
  const content = fs.readFileSync(resolved, 'utf8');
28
46
  const currentChain = [...chain, resolved];
29
47
 
30
- return content.replace(INCLUDE_PATTERN, (match, includePath) => {
48
+ const withIncludes = content.replace(INCLUDE_PATTERN, (match, includePath) => {
31
49
  const includeResolved = path.resolve(PROJECT_ROOT, includePath.trim());
32
50
  if (!fs.existsSync(includeResolved)) {
33
51
  return match;
34
52
  }
35
53
  return render_md(includeResolved, currentChain);
36
54
  });
55
+
56
+ return resolveVariables(withIncludes);
37
57
  }
38
58
 
39
59
  export { render_md };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.67",
3
+ "version": "1.2.69-beta.0",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
@@ -39,41 +39,6 @@ export async function validateAnthropicKey(key) {
39
39
  }
40
40
  }
41
41
 
42
- /**
43
- * Build flat secrets JSON for SECRETS GitHub secret.
44
- * Takes a flat { ENV_VAR: value } map of collected keys.
45
- * entrypoint.sh decodes and exports each as an env var.
46
- */
47
- export function buildSecretsJson(pat, collectedKeys) {
48
- return { GH_TOKEN: pat, ...collectedKeys };
49
- }
50
-
51
- /**
52
- * Encode secrets to base64 for SECRETS GitHub secret
53
- */
54
- export function encodeSecretsBase64(pat, collectedKeys) {
55
- const secrets = buildSecretsJson(pat, collectedKeys);
56
- return Buffer.from(JSON.stringify(secrets)).toString('base64');
57
- }
58
-
59
- /**
60
- * Build LLM secrets JSON for LLM_SECRETS GitHub secret.
61
- * These credentials are accessible to the LLM (not filtered by env-sanitizer).
62
- */
63
- export function buildLlmSecretsJson(llmKeys) {
64
- return { ...llmKeys };
65
- }
66
-
67
- /**
68
- * Encode LLM secrets to base64 for LLM_SECRETS GitHub secret.
69
- * Returns null if no LLM secrets are configured.
70
- */
71
- export function encodeLlmSecretsBase64(llmKeys) {
72
- const secrets = buildLlmSecretsJson(llmKeys);
73
- if (Object.keys(secrets).length === 0) return null;
74
- return Buffer.from(JSON.stringify(secrets)).toString('base64');
75
- }
76
-
77
42
  /**
78
43
  * Generate .pi/agent/models.json for providers not built into PI.
79
44
  * PI resolves the apiKey field as an env var name at runtime ($ENV_VAR).
package/setup/setup.mjs CHANGED
@@ -37,8 +37,6 @@ import {
37
37
  writeEnvFile,
38
38
  updateEnvVariable,
39
39
  writeModelsJson,
40
- encodeSecretsBase64,
41
- encodeLlmSecretsBase64,
42
40
  } from './lib/auth.mjs';
43
41
 
44
42
  const logo = `
@@ -575,24 +573,24 @@ async function main() {
575
573
  }
576
574
 
577
575
  // Skip GitHub secrets if nothing changed on re-run
578
- let llmSecretsBase64 = null;
576
+ let secrets = null;
579
577
  if (isRerun && !credentialsChanged && env?.GH_WEBHOOK_SECRET) {
580
578
  printSuccess('GitHub secrets unchanged');
581
579
  webhookSecret = env.GH_WEBHOOK_SECRET;
582
580
  } else {
583
581
  webhookSecret = generateWebhookSecret();
584
- const secretsBase64 = encodeSecretsBase64(pat, collectedKeys);
585
- const llmKeys = {};
586
- if (braveKey) llmKeys.BRAVE_API_KEY = braveKey;
587
- llmSecretsBase64 = encodeLlmSecretsBase64(llmKeys);
588
582
 
589
- const secrets = {
590
- SECRETS: secretsBase64,
583
+ secrets = {
591
584
  GH_WEBHOOK_SECRET: webhookSecret,
585
+ AGENT_GH_TOKEN: pat,
592
586
  };
593
587
 
594
- if (llmSecretsBase64) {
595
- secrets.LLM_SECRETS = llmSecretsBase64;
588
+ for (const [key, value] of Object.entries(collectedKeys)) {
589
+ if (value) secrets[`AGENT_${key}`] = value;
590
+ }
591
+
592
+ if (braveKey) {
593
+ secrets.AGENT_LLM_BRAVE_API_KEY = braveKey;
596
594
  }
597
595
 
598
596
  let allSecretsSet = false;
@@ -623,6 +621,12 @@ async function main() {
623
621
  LLM_PROVIDER: agentProvider,
624
622
  LLM_MODEL: agentModel,
625
623
  };
624
+ if (openaiBaseUrl) {
625
+ defaultVars.OPENAI_BASE_URL = openaiBaseUrl;
626
+ }
627
+ if (agentProvider === 'custom') {
628
+ defaultVars.RUNS_ON = 'self-hosted';
629
+ }
626
630
 
627
631
  let allVarsSet = false;
628
632
  while (!allVarsSet) {
@@ -821,11 +825,11 @@ async function main() {
821
825
  }
822
826
  if (braveKey) console.log(` ${chalk.dim('Brave Search:')} ${maskSecret(braveKey)}`);
823
827
 
824
- if (!isRerun || credentialsChanged) {
828
+ if (secrets) {
825
829
  console.log(chalk.bold('\n GitHub Secrets Set:\n'));
826
- console.log(' \u2022 SECRETS');
827
- if (llmSecretsBase64) console.log(' \u2022 LLM_SECRETS');
828
- console.log(' \u2022 GH_WEBHOOK_SECRET');
830
+ for (const name of Object.keys(secrets)) {
831
+ console.log(` \u2022 ${name}`);
832
+ }
829
833
 
830
834
  console.log(chalk.bold('\n GitHub Variables Set:\n'));
831
835
  console.log(' \u2022 APP_URL');
@@ -833,6 +837,8 @@ async function main() {
833
837
  console.log(' \u2022 ALLOWED_PATHS = /logs');
834
838
  console.log(` \u2022 LLM_PROVIDER = ${agentProvider}`);
835
839
  console.log(` \u2022 LLM_MODEL = ${agentModel}`);
840
+ if (openaiBaseUrl) console.log(` \u2022 OPENAI_BASE_URL = ${openaiBaseUrl}`);
841
+ if (agentProvider === 'custom') console.log(' \u2022 RUNS_ON = self-hosted');
836
842
  }
837
843
 
838
844
  console.log(chalk.bold.green('\n You\'re all set!\n'));
@@ -35,10 +35,12 @@ jobs:
35
35
 
36
36
  - name: Run thepopebot Agent
37
37
  env:
38
+ ALL_SECRETS: ${{ toJson(secrets) }}
38
39
  JOB_IMAGE_URL: ${{ vars.JOB_IMAGE_URL }}
39
40
  THEPOPEBOT_VERSION: ${{ steps.version.outputs.tag }}
40
41
  LLM_MODEL: ${{ vars.LLM_MODEL }}
41
42
  LLM_PROVIDER: ${{ vars.LLM_PROVIDER }}
43
+ OPENAI_BASE_URL: ${{ vars.OPENAI_BASE_URL }}
42
44
  run: |
43
45
  if [ -n "$JOB_IMAGE_URL" ]; then
44
46
  IMAGE="${JOB_IMAGE_URL}:latest"
@@ -46,11 +48,23 @@ jobs:
46
48
  IMAGE="stephengpope/thepopebot:job-${THEPOPEBOT_VERSION}"
47
49
  fi
48
50
  echo "Using image: $IMAGE"
51
+
52
+ # Build SECRETS JSON from AGENT_* (excluding AGENT_LLM_*) secrets
53
+ export SECRETS=$(echo "$ALL_SECRETS" | jq -c '
54
+ [to_entries[] | select(.key | startswith("AGENT_")) | select(.key | startswith("AGENT_LLM_") | not)]
55
+ | map({key: (.key | sub("^AGENT_"; "")), value: .value}) | from_entries')
56
+
57
+ # Build LLM_SECRETS JSON from AGENT_LLM_* secrets
58
+ export LLM_SECRETS=$(echo "$ALL_SECRETS" | jq -c '
59
+ [to_entries[] | select(.key | startswith("AGENT_LLM_"))]
60
+ | map({key: (.key | sub("^AGENT_LLM_"; "")), value: .value}) | from_entries')
61
+
49
62
  docker run --rm \
50
63
  -e REPO_URL="${{ github.server_url }}/${{ github.repository }}.git" \
51
64
  -e BRANCH="${{ github.ref_name }}" \
52
- -e SECRETS='${{ secrets.SECRETS }}' \
53
- -e LLM_SECRETS='${{ secrets.LLM_SECRETS }}' \
65
+ -e SECRETS \
66
+ -e LLM_SECRETS \
54
67
  -e LLM_MODEL="${LLM_MODEL}" \
55
68
  -e LLM_PROVIDER="${LLM_PROVIDER}" \
69
+ -e OPENAI_BASE_URL="${OPENAI_BASE_URL}" \
56
70
  "$IMAGE"
@@ -10,8 +10,8 @@
10
10
  # Pi system prompt (generated at runtime from SOUL.md)
11
11
  .pi/SYSTEM.md
12
12
 
13
- # Pi skills symlinked at runtime from /pi-skills in Docker
14
- .pi/skills/brave-search
13
+ # Pi skills dependencies (installed at runtime in Docker for correct arch)
14
+ pi-skills/*/node_modules/
15
15
 
16
16
  # Node
17
17
  node_modules/
@@ -9,16 +9,15 @@
9
9
  * To get a value, use: echo $KEY_NAME
10
10
  */
11
11
 
12
- const secretsBase64 = process.env.LLM_SECRETS;
12
+ const secretsJson = process.env.LLM_SECRETS;
13
13
 
14
- if (!secretsBase64) {
14
+ if (!secretsJson) {
15
15
  console.log('No LLM_SECRETS configured.');
16
16
  process.exit(0);
17
17
  }
18
18
 
19
19
  try {
20
- const decoded = Buffer.from(secretsBase64, 'base64').toString('utf-8');
21
- const parsed = JSON.parse(decoded);
20
+ const parsed = JSON.parse(secretsJson);
22
21
  const keys = Object.keys(parsed);
23
22
 
24
23
  if (keys.length === 0) {
@@ -9,7 +9,8 @@ This is an autonomous AI agent powered by [thepopebot](https://github.com/stephe
9
9
  ```
10
10
  /
11
11
  ├── .github/workflows/ # CI/CD workflows (managed by thepopebot)
12
- ├── .pi/skills/ # Custom Pi agent skills
12
+ ├── .pi/skills/ # Symlinks to active Pi agent skills
13
+ ├── pi-skills/ # All available Pi agent skills (from thepopebot package)
13
14
  ├── config/ # Agent configuration
14
15
  │ ├── SOUL.md # Agent personality and identity
15
16
  │ ├── CHATBOT.md # Telegram chat system prompt
@@ -39,7 +40,8 @@ This is an autonomous AI agent powered by [thepopebot](https://github.com/stephe
39
40
  ## Customization
40
41
 
41
42
  - **config/** — Agent personality, prompts, crons, triggers
42
- - **.pi/skills/** — Custom Pi agent skills
43
+ - **pi-skills/** — All available Pi agent skills (brave-search, browser-tools, youtube-transcript, etc.)
44
+ - **.pi/skills/** — Symlinks to active skills (e.g., `ln -s ../../pi-skills/youtube-transcript .pi/skills/youtube-transcript`)
43
45
  - **cron/** and **triggers/** — Shell scripts for command-type actions
44
46
  - **app/** — Add Next.js pages, API routes, components
45
47
 
@@ -29,4 +29,6 @@ So you can assume that:
29
29
 
30
30
  **Always** use `/job/tmp/` for any temporary files you create.
31
31
 
32
- Scripts in `/job/tmp/` can use `__dirname`-relative paths (e.g., `../docs/data.json`) to reference repo files, because they're inside the repo tree. The `.gitignore` excludes `tmp/` so nothing in this directory gets committed.
32
+ Scripts in `/job/tmp/` can use `__dirname`-relative paths (e.g., `../docs/data.json`) to reference repo files, because they're inside the repo tree. The `.gitignore` excludes `tmp/` so nothing in this directory gets committed.
33
+
34
+ Current datetime: {{datetime}}
@@ -83,3 +83,5 @@ Below are technical details on how thepopebot is built.
83
83
  - Use these to help generate a solid plan when creating tasks or jobs that modify thepopebot codebase
84
84
 
85
85
  {{CLAUDE.md}}
86
+
87
+ Current datetime: {{datetime}}
@@ -36,13 +36,6 @@ RUN npm install -g @mariozechner/pi-coding-agent
36
36
  # Create Pi config directory (extension loaded from repo at runtime)
37
37
  RUN mkdir -p /root/.pi/agent
38
38
 
39
- # Clone pi-skills and install browser-tools (includes Puppeteer + Chromium)
40
- RUN git clone https://github.com/badlogic/pi-skills.git /pi-skills
41
- WORKDIR /pi-skills/browser-tools
42
- RUN npm install
43
- WORKDIR /pi-skills/brave-search
44
- RUN npm install
45
-
46
39
  COPY entrypoint.sh /entrypoint.sh
47
40
  RUN chmod +x /entrypoint.sh
48
41
 
@@ -9,25 +9,16 @@ else
9
9
  fi
10
10
  echo "Job ID: ${JOB_ID}"
11
11
 
12
- # Start Chrome (using Puppeteer's chromium from pi-skills browser-tools)
13
- CHROME_BIN=$(find /root/.cache/puppeteer -name "chrome" -type f | head -1)
14
- $CHROME_BIN --headless --no-sandbox --disable-gpu --remote-debugging-port=9222 2>/dev/null &
15
- CHROME_PID=$!
16
- sleep 2
17
-
18
- # Export SECRETS (base64 JSON) as flat env vars (GH_TOKEN, ANTHROPIC_API_KEY, etc.)
12
+ # Export SECRETS (JSON) as flat env vars (GH_TOKEN, ANTHROPIC_API_KEY, etc.)
19
13
  # These are filtered from LLM's bash subprocess by env-sanitizer extension
20
14
  if [ -n "$SECRETS" ]; then
21
- SECRETS_JSON=$(echo "$SECRETS" | base64 -d)
22
- eval $(echo "$SECRETS_JSON" | jq -r 'to_entries | .[] | "export \(.key)=\"\(.value)\""')
23
- export SECRETS="$SECRETS_JSON" # Keep decoded for extension to parse
15
+ eval $(echo "$SECRETS" | jq -r 'to_entries | .[] | "export \(.key)=\"\(.value)\""')
24
16
  fi
25
17
 
26
- # Export LLM_SECRETS (base64 JSON) as flat env vars
18
+ # Export LLM_SECRETS (JSON) as flat env vars
27
19
  # These are NOT filtered - LLM can access these (browser logins, skill API keys, etc.)
28
20
  if [ -n "$LLM_SECRETS" ]; then
29
- LLM_SECRETS_JSON=$(echo "$LLM_SECRETS" | base64 -d)
30
- eval $(echo "$LLM_SECRETS_JSON" | jq -r 'to_entries | .[] | "export \(.key)=\"\(.value)\""')
21
+ eval $(echo "$LLM_SECRETS" | jq -r 'to_entries | .[] | "export \(.key)=\"\(.value)\""')
31
22
  fi
32
23
 
33
24
  # Git setup - derive identity from GitHub token
@@ -50,8 +41,22 @@ cd /job
50
41
  # Create temp directory for agent use (gitignored via tmp/)
51
42
  mkdir -p /job/tmp
52
43
 
53
- # Symlink pi-skills into .pi/skills/ so Pi discovers them
54
- ln -sf /pi-skills/brave-search /job/.pi/skills/brave-search
44
+ # Install npm deps for symlinked skills (native deps need correct Linux arch)
45
+ for skill_dir in /job/.pi/skills/*/; do
46
+ if [ -f "${skill_dir}package.json" ]; then
47
+ echo "Installing skill deps: $(basename "$skill_dir")"
48
+ (cd "$skill_dir" && npm install --production)
49
+ fi
50
+ done
51
+
52
+ # Start Chrome if available (installed by browser-tools skill via Puppeteer)
53
+ CHROME_PID=""
54
+ CHROME_BIN=$(find /root/.cache/puppeteer -name "chrome" -type f 2>/dev/null | head -1)
55
+ if [ -n "$CHROME_BIN" ]; then
56
+ $CHROME_BIN --headless --no-sandbox --disable-gpu --remote-debugging-port=9222 2>/dev/null &
57
+ CHROME_PID=$!
58
+ sleep 2
59
+ fi
55
60
 
56
61
  # Setup logs
57
62
  LOG_DIR="/job/logs/${JOB_ID}"
@@ -67,6 +72,9 @@ for i in "${!SYSTEM_FILES[@]}"; do
67
72
  fi
68
73
  done
69
74
 
75
+ # Resolve {{datetime}} variable in SYSTEM.md
76
+ sed -i "s/{{datetime}}/$(date -u +"%Y-%m-%dT%H:%M:%SZ")/g" /job/.pi/SYSTEM.md
77
+
70
78
  PROMPT="
71
79
 
72
80
  # Your Job
@@ -80,7 +88,27 @@ if [ -n "$LLM_MODEL" ]; then
80
88
  MODEL_FLAGS="$MODEL_FLAGS --model $LLM_MODEL"
81
89
  fi
82
90
 
83
- # Copy custom models.json to PI's global config if present in repo
91
+ # Generate models.json for custom provider (OpenAI-compatible endpoints like Ollama)
92
+ if [ "$LLM_PROVIDER" = "custom" ] && [ -n "$OPENAI_BASE_URL" ]; then
93
+ # If no API key was provided, set a dummy so Pi doesn't send empty auth
94
+ if [ -z "$CUSTOM_API_KEY" ]; then
95
+ export CUSTOM_API_KEY="not-needed"
96
+ fi
97
+ cat > /root/.pi/agent/models.json <<MODELS
98
+ {
99
+ "providers": {
100
+ "custom": {
101
+ "baseUrl": "$OPENAI_BASE_URL",
102
+ "api": "openai-completions",
103
+ "apiKey": "CUSTOM_API_KEY",
104
+ "models": [{ "id": "$LLM_MODEL" }]
105
+ }
106
+ }
107
+ }
108
+ MODELS
109
+ fi
110
+
111
+ # Copy custom models.json to PI's global config if present in repo (overrides generated)
84
112
  if [ -f "/job/.pi/agent/models.json" ]; then
85
113
  mkdir -p /root/.pi/agent
86
114
  cp /job/.pi/agent/models.json /root/.pi/agent/models.json
@@ -104,5 +132,7 @@ git push origin
104
132
  gh pr create --title "thepopebot: job ${JOB_ID}" --body "Automated job" --base main || true
105
133
 
106
134
  # Cleanup
107
- kill $CHROME_PID 2>/dev/null || true
135
+ if [ -n "$CHROME_PID" ]; then
136
+ kill $CHROME_PID 2>/dev/null || true
137
+ fi
108
138
  echo "Done. Job ID: ${JOB_ID}"