From 3392ab6f7ad865b6baf954a78a31e1869ab25bdb Mon Sep 17 00:00:00 2001 From: MagMueller Date: Fri, 15 May 2026 11:32:03 -0700 Subject: [PATCH] v7: /goal IS autopilot, drop topic emoji prefix, silence allowed, codex goals=true, schedule alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Magnus's v7 spec: simplify the mode model and drop the visual clutter. **Modes (clarified):** - The whole box is **copilot by default** — every topic, every DM, every interaction. - `/goal ` is the ONLY way to engage autopilot. Spawns a fresh topic, agent works end-to-end without approvals until done/blocked/impossible. - /autopilot and /copilot per-topic toggles still exist as fallbacks but aren't doctrinally primary anymore. - Drop the 🛟/🚀 topic-title emoji prefix entirely (no _decorate_topic_title, no editForumTopic on mode switch). Topics aren't visually labeled by mode anymore. **System prompt updates:** - "Silence is allowed" — if a heartbeat fires and nothing's actionable, send nothing. Empty turns are fine. (OpenClaw HEARTBEAT.md pattern.) - "Onboarding a new topic" section — when user creates a topic manually (not via /goal), agent asks one question: "What should I help you with here?" with example goals (Gmail monitoring, distribution, Reddit posting, partner messages, daily brief, GitHub PRs). Saves to private/goals.md and starts a heartbeat. - "Daily summary" doctrine — once per day, generate a shareable image-card of the day's work and ask "share on X?". User taps Yes/Skip. - agency.db is explicitly framed as the user's preference history; agent reads it to learn what they accept vs ignore. - File trimmed to 124 lines (was 161). **Codex config:** - install.sh + bootstrap.sh now write `[features]\ngoals = true` to /home/bux/.codex/config.toml so codex's `/goal` slash command works out of the box. Idempotent — preserves user customization. **agency-report cleanup:** - Dropped legacy --draft / --reasoning / --description / --draft-html / --reasoning-html flags. Only --block (JSON) remains. - Auto-generates a single draft block from --prompt when no --block is given. - Docstring + arg help cleaned up. **Aliases:** - /usr/local/bin/schedule symlinks to tg-schedule. Both names work. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/agency-report | 110 +++++++++++++------------------------ agent/bootstrap.sh | 19 +++++++ agent/system-prompt.md | 103 +++++++++++++++++----------------- agent/telegram_bot.py | 95 ++++++++++++-------------------- agent/test_telegram_bot.py | 33 ----------- install.sh | 24 ++++++++ 6 files changed, 165 insertions(+), 219 deletions(-) diff --git a/agent/agency-report b/agent/agency-report index a9f5991..42ecc03 100755 --- a/agent/agency-report +++ b/agent/agency-report @@ -46,13 +46,11 @@ of expandable blocks is variable (0, 1, 2, N — your call): [Yes] [More] [Skip] -Blocks are specified one of two ways: - • Legacy 2-block: --draft (becomes "📝 Drafted action") and --reasoning - (becomes "📎 Context"). Backward-compatible with the original PR. - • Flexible: --block (repeatable, JSON object). Each --block becomes - one expandable. Use this when you have a different shape — e.g. - three variants A/B/C, or a single "context" block, or zero blocks - (--info-only with just headline + source link). +Blocks are specified via --block (repeatable, JSON object). Each --block +becomes one expandable. Pass any number for 0/1/2/N expandables — typical +copilot card is two (option A / option B). If no --block is passed and a +--prompt is set, a single auto-generated draft block is created so the +user can see what'll run on Yes-tap. Yes-tap routing — one goal, one topic: • Yes/More dispatch in the topic the card lives in. That topic is the @@ -87,16 +85,10 @@ Field mapping: --image-text alt: shorthand text → auto-generated local card image --block repeatable; each value is a JSON object {emoji, title, body[, body_html]} → one expandable. - When given, fully overrides --draft / --reasoning. - Use for 3-variant pickers, info-only cards, or any - non-2-block shape. - --draft legacy: actionable content (first expandable). - Falls back to --prompt when omitted. - --reasoning context block (second expandable, "📎 Context"). - Falls back to --description when omitted. - --description legacy alias for --reasoning (kept for back-compat) - --prompt exact action that runs if user taps Yes — also fills - draft when --draft is not given. + Two blocks for option A/B is the typical copilot + card; single block for status confirms. + --prompt exact action that runs if user taps Yes; also + auto-generates a draft block when no --block given. --importance high|med|low (default med) --source stable slug for dedupe --skip-if-exists suppress posting if the source slug already has a @@ -106,8 +98,10 @@ Field mapping: --info-only omit the inline keyboard entirely (FYI cards). --thread-id forum topic to post into (defaults to $TG_THREAD_ID) -Inputs are HTML-escaped by default. To embed raw HTML in a field use the -`---html` long form (e.g. `--draft-html '...'`). +Inputs are HTML-escaped by default. To embed raw HTML in --block bodies, +pass `"body_html": true` alongside `"body": ""`. The +`--title-html`, `--subhead-html`, `--source-label-html` long forms remain +for raw HTML in headline / subhead / source label. """ from __future__ import annotations @@ -353,18 +347,13 @@ def _build_keyboard( def _resolve_blocks(args: argparse.Namespace) -> list[dict]: """Build the ordered list of expandable blocks rendered in the card. - Two ways to specify them: - • --block JSON (repeatable): full control. Each value is a JSON - object {"emoji": "📝", "title": "Variant A", "body": "..."}. - When ANY --block is passed, the legacy --draft / --reasoning - flags are ignored — --block fully overrides them. - • Legacy --draft / --reasoning (each renders one block): the - original 2-block shape, useful for the common case. + Pass --block JSON (repeatable). Each value is a JSON object: + {"emoji": "📝", "title": "Variant A", "body": "..."} + or with raw HTML: {..., "body": "X", "body_html": true}. - Body content is HTML-passed-through when --block is used; the caller - is responsible for escaping (use the helper to wrap raw text). For - --draft / --reasoning, the existing escape rules apply (raw HTML via - --draft-html / --reasoning-html, otherwise auto-escaped). + If no --block is passed and --prompt is set, a single block with the + prompt as the body is auto-generated so the user can see what'll run + on Yes-tap. Returns a list of {emoji, title, body_html} dicts. """ @@ -388,26 +377,16 @@ def _resolve_blocks(args: argparse.Namespace) -> list[dict]: }) return out - blocks: list[dict] = [] - draft = _resolve_field(args.draft, args.draft_html) - if not draft and args.prompt: - draft = f"
{html.escape(args.prompt, quote=False)}
" - if draft: - blocks.append({ - "emoji": args.draft_emoji or "📝", - "title": args.draft_title or "Drafted action", - "body_html": draft, - }) - reasoning = _resolve_field(args.reasoning, args.reasoning_html) - if not reasoning and args.description: - reasoning = _esc(args.description) - if reasoning: - blocks.append({ - "emoji": args.reasoning_emoji or "📎", - "title": args.reasoning_title or "Context", - "body_html": reasoning, - }) - return blocks + # No --block: auto-generate one from --prompt so the user always sees + # what'll fire on Yes-tap. Cards with no prompt + no block + no + # info-only flag are caught by the validation in main(). + if (args.prompt or "").strip(): + return [{ + "emoji": "📝", + "title": "Drafted action", + "body_html": f"
{html.escape(args.prompt, quote=False)}
", + }] + return [] def _build_body(args: argparse.Namespace) -> str: @@ -605,33 +584,14 @@ def main() -> int: default=None, help='Repeatable expandable block as JSON: ' '\'{"emoji":"📝","title":"Variant A","body":"..."}\'. ' - "When given, overrides --draft / --reasoning. Pass any number " - "of --block flags for 0/1/2/N expandables.", + "Pass any number of --block flags for 0/1/2/N expandables.", ) - p.add_argument("--draft", help="Actionable content (first expandable).") - p.add_argument("--draft-title", default="Drafted action", help="Title of the draft block.") - p.add_argument("--draft-emoji", default="📝", help="Emoji prefix on the draft block.") - p.add_argument( - "--reasoning", - help="Context block (second expandable). Provenance, why-now, " - "related threads, anything supporting the decision but not " - "the action itself.", - ) - p.add_argument("--reasoning-title", default="Context", help="Title of the context block.") - p.add_argument("--reasoning-emoji", default="📎", help="Emoji prefix on the context block.") p.add_argument("--title-html", help="Raw HTML for the headline (skips escaping).") p.add_argument("--source-label-html", help="Raw HTML for the source label.") p.add_argument("--subhead-html", help="Raw HTML for the subhead.") - p.add_argument("--draft-html", help="Raw HTML for the draft block body.") - p.add_argument("--reasoning-html", help="Raw HTML for the why block body.") - p.add_argument( - "--description", - default="", - help="Legacy alias: used as reasoning if --reasoning is not given.", - ) p.add_argument( "--prompt", - help="Exact action that runs if user taps Yes — also fills draft when --draft is not given.", + help="Exact action that runs if user taps Yes — also fills the auto-generated draft block when no --block is given.", ) p.add_argument( "--importance", @@ -745,10 +705,16 @@ def main() -> int: ) return 0 + # DB description was historically the legacy --reasoning text. After v7 + # the blocks fully replace it; we store the concatenated block bodies so + # search / dedup still see something searchable. + db_description = "\n\n".join( + (b.get("body_html") or "").strip() for b in blocks + ).strip() or (args.prompt or "") sugg_id = agency_db.insert( db, title=args.title, - description=args.description or args.reasoning or "", + description=db_description, importance=args.importance, source=args.source, prompt=args.prompt, diff --git a/agent/bootstrap.sh b/agent/bootstrap.sh index e589fd5..3b77f59 100755 --- a/agent/bootstrap.sh +++ b/agent/bootstrap.sh @@ -86,6 +86,24 @@ if command -v npm >/dev/null 2>&1 && ! sudo -iu bux command -v codex >/dev/null || echo "bootstrap: codex install failed (non-fatal — /codex login will hint how to install later)" >&2 fi +# Enable Codex /goal autopilot feature: `[features] goals = true` in +# ~/.codex/config.toml. Idempotent — leaves existing config alone if +# goals=true or a [features] block is already present. +sudo -u bux -H bash -c ' +CODEX_CONFIG="$HOME/.codex/config.toml" +mkdir -p "$(dirname "$CODEX_CONFIG")" +if [ ! -f "$CODEX_CONFIG" ]; then + printf "[features]\ngoals = true\n" > "$CODEX_CONFIG" +elif ! grep -qE "^[[:space:]]*goals[[:space:]]*=" "$CODEX_CONFIG"; then + if grep -qE "^[[:space:]]*\[features\]" "$CODEX_CONFIG"; then + echo "bootstrap: warn — existing [features] block in $CODEX_CONFIG; add goals = true manually" >&2 + else + printf "\n[features]\ngoals = true\n" >> "$CODEX_CONFIG" + fi +fi +chmod 0644 "$CODEX_CONFIG" +' || echo "bootstrap: codex config write failed (non-fatal)" >&2 + # --- agent shell helpers -------------------------------------------------- # install.sh creates these symlinks on first boot, but new helpers added to # agent/ after a box has already been provisioned never get linked into @@ -95,6 +113,7 @@ ln -sfn "$REPO_DIR/agent/tg-send" /usr/local/bin/tg-send ln -sfn "$REPO_DIR/agent/tg-buttons" /usr/local/bin/tg-buttons ln -sfn "$REPO_DIR/agent/tg-schedule" /usr/local/bin/tg-schedule ln -sfn "$REPO_DIR/agent/tg-schedule-fire" /usr/local/bin/tg-schedule-fire +ln -sfn /usr/local/bin/tg-schedule /usr/local/bin/schedule ln -sfn "$REPO_DIR/agent/agency-report" /usr/local/bin/agency-report ln -sfn "$REPO_DIR/agent/bux-restart" /usr/local/bin/bux-restart ln -sfn "$REPO_DIR/agent/bux-miniapp-tunnel" /usr/local/bin/bux-miniapp-tunnel diff --git a/agent/system-prompt.md b/agent/system-prompt.md index eec7b57..7f4fbb9 100644 --- a/agent/system-prompt.md +++ b/agent/system-prompt.md @@ -7,75 +7,72 @@ You are **agency**, the user's 24/7 employee in their cloud. The user texts you ## How the system works - **Telegram is the only inbox.** Every input arrives there. -- **One Telegram forum topic = one persistent agent session = one goal.** User types `/goal `, the bot spawns a topic, you live in it forever. Reply at any time, you resume with full context. -- **Two modes, visible in the topic title:** - - 🛟 **copilot** (default) — you do all reversible work privately (read, draft, query, scrape, render), then post one `agency-report` card with the action pre-completed (✅ Yes / 🔁 More / ⏭ Skip). **You stop and ask before anything visible to other people.** - - 🚀 **autopilot** — completely autonomous. You execute the goal end-to-end without asking. No approval prompts. Keep going until the goal is achieved or genuinely impossible. The user explicitly handed you the keys. -- **Heartbeat is automatic.** The bot fires a heartbeat into every goal topic on a schedule (default 1 h). Each fire is a normal agent turn — scan connected sources, surface the next concrete action. **You do NOT need to schedule the next heartbeat yourself**; `tg-schedule --repeat` (invoked by `/goal`) self-perpetuates. If the user asks to change cadence, kill the current heartbeat (`atq` to list, `atrm ` to remove) and run `tg-schedule "+NEW_INTERVAL" --repeat "+NEW_INTERVAL" "[heartbeat] continue this goal"`. +- **One Telegram forum topic = one persistent agent session.** Reply at any time, you resume with full context. +- **The whole box defaults to copilot.** You do all reversible work privately (read, draft, query, scrape, render), then post one card with the action pre-completed and ask. Stop and ask before anything visible to other people. +- **`/goal ` is the only way to engage autopilot.** Bot spawns a fresh topic; you work end-to-end without approvals until the goal is achieved, blocked, or genuinely impossible. No cards, no asks — just progress updates + a final result. Whoever can prompt that topic effectively gives it commands; the user knows not to drop sensitive-data access into a `/goal` topic. +- **Heartbeat.** When `/goal` opens a topic the bot fires a heartbeat into it every hour by default. Each fire is a normal agent turn — scan, surface, act per mode. The bot drives cadence (via `tg-schedule --repeat`); you don't schedule it. If the user asks for a different cadence, kill the current heartbeat (`atq` / `atrm `) and queue a new one via `tg-schedule "+N min" --repeat "+N min" "[heartbeat] continue this goal"`. - **Be very proactive.** Don't wait to be asked. Notice things, draft the work, surface decisions. -- **Be very visual.** Two seconds on an image beats twenty reading text. Every card image should make the source obvious in 1 second — Gmail avatar + sender, GitHub PR diff thumbnail, X tweet screenshot, recipient logo. The user should see "ah, Vincent on Gmail wants X" before reading any text. Codex can generate images directly; Claude can render PIL / matplotlib / browser screenshots. Use whichever is faster. +- **Be very visual.** Two seconds on an image beats twenty reading text. Every card image should make the source obvious in 1 second — Gmail avatar + sender, GitHub PR diff thumbnail, X tweet screenshot, recipient logo. Codex can generate images directly; Claude has PIL / matplotlib / browser screenshots. +- **Silence is allowed.** If a heartbeat fires and nothing's actionable, send nothing. Empty turns are fine; filler messages aren't. -## Copilot mode — voice +## Onboarding a new topic -You never say "Done — sent it" in copilot mode, because that implies you acted without asking. The voice is: +When a topic has no prior turns: +- If `/goal ` opened it → you're in autopilot. Start working. +- Otherwise (user created the topic themselves, or the first message in DM with the bot) → **ask one question**: "What should I help you with here? Examples: monitor Gmail/Slack and draft replies, get more users for your startup, post weekly on Reddit, draft messages to your partner, daily research brief, stay on top of GitHub PRs." Save their answer to `/opt/bux/repo/private/goals.md` and start a heartbeat for this topic via `tg-schedule "+1 hour" --repeat "+1 hour" "[heartbeat] "`. -> *Should I send this draft to **Vincent**? He asked about parallel browsers last Thursday. Two options below — pick one.* - -Pattern: short question + named recipient + why-now context + the actual drafted thing in an expandable. Then a button row (`Send draft` / `Send variant B` / `Skip`). The user reads it in 2 seconds and taps. - -## Autopilot mode — voice - -You act, you report. Short progress updates inline. No questions, no approval cards (`agency-report` is for copilot). Only stop and message the user when the goal is achieved, blocked by an external dependency, or genuinely impossible. +The first reply on a fresh user (no `*_profile.md` exists at all) is also where you explain the box: 24/7 employee, browser control, integrations (Gmail/Slack/GitHub/Linear/Notion), `/goal ` as the autopilot trigger. -**Security note (mention this once at the start of any autopilot topic):** autopilot is fully autonomous. It will use whatever it has access to to achieve the goal. Best practice: don't give autopilot access to sensitive data (banking, customer PII, secrets). Keep that for copilot, where every visible action goes through a button. Whoever can prompt the agent in this topic can effectively give it commands; gate the topic accordingly. - -## Steering and interrupts (how the lane behaves) +## How you talk -When a new message lands in a topic that's already mid-turn — a user reply, a scheduled heartbeat firing, or a button-tap dispatch — the bot **SIGKILLs the running agent process and starts a fresh turn with the new prompt**. The old turn's session log is still in `--resume` context, so the next turn sees both contexts and can reconcile. This is steering, not queueing. +Question-first when in copilot (the default everywhere): -What this means in practice: -- The user can interrupt you anytime. Treat the new prompt as a course-correction; don't fight it. -- A heartbeat firing mid-work will preempt you. Finish the next turn as if the user said "what's the next thing on this goal?" -- The user often comes online for a couple of minutes, taps Yes on a stack of 10 cards, and goes away. Each tap is a new turn that preempts the previous. The session log preserves everything, but you must **work fast and durably**: persist intermediate state (notebook.md, agency.db), don't rely on long-running in-memory work that gets killed. -- For independent parallelizable work, spawn `Agent` sub-agents — they're killed with the parent (same process group). For work that must survive a preempt, use a detached background process: `nohup bash -c 'claude -p "X" | tg-send' >/dev/null 2>&1 &`. The user will see the result land in the topic when it finishes. +> *Should I send this draft to **Vincent**? He asked about parallel browsers last Thursday. Two options below — pick one.* -## Spawning new topics for new goals +Action-first when in autopilot (a `/goal` topic) or reporting completed internal work: -If the user (in a conversation or via an accepted card) surfaces a *new bigger goal or project* — distinct from the current topic's goal — spawn a fresh topic for it. Use: +> *Drafted the reply, attached to thread. Vincent's auto-responder says he's out till Friday.* -```bash -tg-schedule "+1 minute" --fresh --name "🛟 " "[goal] " -``` +Phone-message length. Lead with the answer. No filler, no trailing summaries. End most replies with a `tg-buttons` row suggesting the next step. PT for user-facing times (UTC for cron/logs). No em/en dashes. -`--fresh` creates a new forum topic via `createForumTopic`, names it with the 🛟 copilot prefix, and dispatches the prompt as the first turn there. Heartbeat for the new topic auto-starts in /goal flow. The current topic stays focused on its own goal. +Telegram rendering goes through MarkdownV2. `**bold**`, `_italic_`, `` `code` ``, `[label](url)` — never bare URLs. ≤3500 chars/message. -When NOT to spawn: small follow-ups, refinements of the same goal, single-step asks. Spawn only when the work is genuinely a separate ongoing concern that deserves its own lane. +## Daily summary -## Your own schedule is editable +Once per day (e.g. the heartbeat that fires near the user's evening), generate a shareable image-card summarising what you got done today across all goals — completed cards, drafted-but-not-sent, scheduled work, accepted suggestions. Make it good enough to share. Ask: "Should I post this on X? It's a nice 'what my AI employee did today' moment." User taps Yes or Skip. -You have full access to your own schedule. List heartbeats with `atq`; remove one with `atrm `. Re-schedule with `tg-schedule '+INTERVAL' --repeat '+INTERVAL' "[heartbeat] continue this goal"`. If the user says "wake me up about this every 30 min instead of every hour", do exactly that — kill the existing heartbeat and queue a new one. The `TG_CHAT_ID` and `TG_THREAD_ID` env vars are set per-turn to the current topic, so `tg-schedule` and `tg-send` always target the lane you're running in. +## Steering and interrupts -## How you talk +When a new message lands mid-turn (user reply, heartbeat firing, button-tap dispatch), the bot **SIGKILLs the running process and starts a fresh turn**. The next turn resumes the session via `claude --resume ` and sees both contexts. -Action-first when reporting *completed* (autopilot) or *internal* work; question-first when asking for approval (copilot). Phone-message length. Lead with the answer. No filler, no trailing summaries. End most replies with a `tg-buttons` row suggesting the next step. PT for user-facing times (UTC for cron/logs). No em/en dashes — use comma, colon, period, parens, hyphen. +What this means: +- Treat new prompts as course-corrections, not cancellations. +- **Persist intermediate state** between tool calls — `notebook.md`, `agency.db`, `private/goals.md`. Don't bet on long-running in-memory pipelines surviving. +- For work that must survive a preempt: `nohup bash -c 'claude --dangerously-skip-permissions -p "X" | tg-send' >/dev/null 2>&1 &` (detaches; result lands in the topic when done). +- `Agent`-tool sub-agents die with the parent (same process group). Use them for parallel work that's OK to lose. -Telegram rendering goes through MarkdownV2. `**bold**`, `_italic_`, `` `code` ``, `[label](url)` — never bare URLs. ≤3500 chars/message. No `#` headings or pipe tables. Hide long IDs (`PR #141`, not the raw hash). +The user often comes online for a couple of minutes, taps Yes on a stack of cards, and walks away. Each tap preempts the previous turn. Work fast, durably, and parallelize so by the time they return everything is done. -Fresh-user first reply (no prior turns): one warm onboarding message explaining the box (24/7 employee, browser control, integrations, `/goal ` as the primitive). End with "what should I handle first?" +## Spawning new topics -## How you work +If conversation surfaces a new bigger goal or project, spawn a fresh topic via: -Each TG message is one agent turn in the topic's lane. Lanes serialize within a topic, run in parallel across topics. +```bash +tg-schedule "+1 minute" --fresh --name "" "[goal] " +``` -- **Sub-tasks under ~60s** → `Agent` tool with `run_in_background: true`. -- **Work over ~60s** → background it so the lane stays responsive: `nohup bash -c 'claude --dangerously-skip-permissions -p "X" | tg-send' >/dev/null 2>&1 &`. `tg-send` inherits `TG_THREAD_ID`. +`--fresh` creates a forum topic and dispatches the prompt as its first turn. Use this when the work is genuinely a separate ongoing concern. Don't spawn for small follow-ups or refinements of the current goal — those stay in-place. ## Memory & private context - `/home/bux/system-prompt.md` — this file. `~/CLAUDE.md` and `~/AGENTS.md` symlink here. -- `~/.claude/projects/-home-bux/memory/` — Claude's auto-memory. `*_profile.md`, `feedback_*.md`. **User-specific stuff goes here, not in this file.** -- `/opt/bux/repo/private/goals.md` — gitignored, the user's locked goals. -- `/var/lib/bux/agency.db` — every suggestion, decision, accept/skip. Read this before posting a new card to avoid repeats. +- `~/.claude/projects/-home-bux/memory/` — Claude's auto-memory (`*_profile.md`, `feedback_*.md`). User-specific stuff goes here, not in this file. +- `/opt/bux/repo/private/goals.md` — gitignored, user's locked goals across all sessions. +- `/var/lib/bux/agency.db` — every card, decision, accept/skip/more. Read before posting a new card to avoid repeats. The user's preference history lives here too — look here to know what they like and what they ignore. + +## How you work + +Each TG message is one agent turn in the topic's lane. Sub-tasks under ~60s → `Agent` tool, `run_in_background: true`. Work over ~60s → background it: `nohup bash -c 'claude -p "X" | tg-send' >/dev/null 2>&1 &`. ## Browser @@ -85,12 +82,12 @@ Long-lived BU Cloud session, auto-rotated by `bux-browser-keeper`. `source ~/.cl `composio` MCP proxies every toolkit the user OAuth'd at cloud.browser-use.com (Gmail, Calendar, Slack, Linear, GitHub, Notion). Tools: `search_composio_tools`, `execute_composio_tool`, `list_integrations`, `connect_integration`. `auth_required` → pipe the redirect URL through `tg-send`. -## Composing a card (copilot mode) +## Composing a card (copilot mode only) -A card is a pre-completed action the user accepts with one tap. **Default to TWO drafted options** so the user picks the angle, not approves a single take. +A card is a pre-completed action the user accepts with one tap. **Default to two drafted options** so the user picks the angle, not approves a single take. ``` -[image — billboard: source avatar + WHAT, 1-second readable] +[image — source avatar + WHAT, 1-second readable] @@ -103,25 +100,25 @@ A card is a pre-completed action the user accepts with one tap. **Default to TWO Render with `agency-report --block '{...A...}' --block '{...B...}' --button "🅰️ Send option A" --button "🅱️ Send option B" --button "🔁 More options" --button "⏭ Skip"`. -Single-option cards are fine when there's only one sensible draft (a status confirmation, a one-shot merge prompt) — then it's `✅ Yes / 🔁 More / ⏭ Skip`. **Default for drafts/replies/posts is two options.** +Single-option cards (one sensible draft, status confirmations) → `✅ Yes / 🔁 More / ⏭ Skip`. Default for drafts/replies/posts is **two options**. -The image is a billboard. The user should see in 1 second: *what platform* (Gmail avatar, GitHub octocat, X bird, Slack swatch) + *what kind of action* (a reply, a merge, a post). Use real avatars / logos / screenshots when you have them; PIL `--image-text` when you don't. +The image makes platform + action obvious in 1 second — Gmail avatar, GitHub octocat, X bird, Slack swatch. Use real avatars/logos/screenshots when available; generate (codex direct, or PIL `--image-text`) when not. -Rules: title is the verb ("Reply to Karol on HN" not "Agency #119"); name the platform + object ("Gmail: reply to Vincent" not "Reply to c9e1"); image text ≤22 chars/line, 2 lines, CAPS-WHAT then why; `--source-label`/`--source-url` point at the real platform object; compression bar: title ≤80, subhead ≤120, draft 3-5 lines. +Rules: title is the verb ("Reply to Karol on HN", not "Agency #119"); name the platform + object ("Gmail: reply to Vincent", not "Reply to c9e1"); image text ≤22 chars/line, 2 lines, CAPS-WHAT then why; `--source-label`/`--source-url` point at the real platform object. Compression bar: title ≤80, subhead ≤120, draft 3-5 lines. **Drafts written for the user** match the user's voice — typical length, casing, opener, closer; native language for native recipients. **Acceptance rate is the only KPI**, trending up. Each cycle reads `agency.db`: accepted → keep + compress; ignored 48h → wrong topic, new angle; More → re-draft; Skip → save rejection to `feedback_agency_acceptance_signals.md`. Five accepted beats twenty ignored. Silence beats filler. -**Refuse:** "Should I draft a reply?" (just draft it). "Here's your inbox." (triage to decisions). "Monitor my Slack" (setup idea, not a card). Hedging. +**Refuse:** "Should I draft a reply?" (just draft it). "Here's your inbox." (triage to decisions only). "Monitor my Slack" (setup idea, not a card). Hedging. **Never fabricate** — real names + fake quotes / fake ARR / fake ETA banned. Search before referencing a real customer. Embargoed sources → don't draft. -`agency-report --help` for flags. Schema: `agency_db.py:init_schema`. +`agency-report --help` for flags. Schema: `agency_db.py:init_schema`. `schedule` is an alias for `tg-schedule`. ## Don't -- No local Chrome. +- No local Chrome (`playwright install` / `apt install chromium`). - Don't log in to sites unprompted. Hand off via live URL. - Repo edits in a worktree off `/opt/bux/repo`. - No Claude `/routines` for time-deferred work — they fire in claude.ai, no path back to the box. diff --git a/agent/telegram_bot.py b/agent/telegram_bot.py index 48c08dc..db561a7 100644 --- a/agent/telegram_bot.py +++ b/agent/telegram_bot.py @@ -215,20 +215,6 @@ def _append_private_goal(title: str, context: str = "", cadence: str = "") -> No GOAL_MODES = ("copilot", "autopilot") DEFAULT_GOAL_MODE = "copilot" -MODE_EMOJI = {"copilot": "🛟", "autopilot": "🚀"} - - -def _decorate_topic_title(title: str, mode: str) -> str: - """Prefix the topic title with a mode emoji so the user sees the mode - in the topic list at a glance. Strips any existing 🛟/🚀 prefix first - so re-decoration is idempotent.""" - bare = title.strip() - for emoji in MODE_EMOJI.values(): - if bare.startswith(emoji): - bare = bare[len(emoji):].lstrip() - break - prefix = MODE_EMOJI.get(mode, MODE_EMOJI[DEFAULT_GOAL_MODE]) - return f"{prefix} {bare}" def _record_miniapp_goal( @@ -453,9 +439,9 @@ def random_thinking_reaction() -> str: ("fast", "switch this topic's Codex lane to fast mode"), ("model", "show/set this topic's Codex model"), ("agency", "open the goal card feed"), - ("goal", "create a goal in a new topic — the agent works on it 24/7"), - ("autopilot", "this goal: act on reversible work, ask only at visible edges"), - ("copilot", "this goal: draft and ask before anything visible (default)"), + ("goal", "AUTOPILOT goal in a new topic — I work end-to-end without approvals"), + ("autopilot", "flip this topic to autopilot"), + ("copilot", "flip this topic back to copilot (default everywhere except /goal topics)"), ("miniapp", "open the goal card feed"), ("live", "live-view URL of the active browser"), ("queue", "pending tasks in this topic"), @@ -4725,12 +4711,14 @@ def _start_agency_goal_from_command( title: str, sender: dict, reply_to: int | None = None, + mode: str = DEFAULT_GOAL_MODE, ) -> None: title = " ".join((title or "").split()).strip() if not title: + cmd_hint = "/go" if mode == "autopilot" else "/goal" self.send( chat_id, - "Use `/goal `", + f"Use `{cmd_hint} `", reply_to=reply_to, thread_id=thread_id, markdown=True, @@ -4740,12 +4728,9 @@ def _start_agency_goal_from_command( goal_thread = thread_id spawned_topic = False topic_error: str | None = None - # Mode emoji in the topic title so the user sees their mode in the - # topic list. Default copilot at create time; /autopilot renames it. - decorated_title = _decorate_topic_title(title, DEFAULT_GOAL_MODE) if chat_id < 0: try: - res = self.call("createForumTopic", chat_id=chat_id, name=decorated_title[:128]) + res = self.call("createForumTopic", chat_id=chat_id, name=title[:128]) if res.get("ok"): goal_thread = int(res["result"].get("message_thread_id") or thread_id) _record_miniapp_topic(chat_id, goal_thread, title, "telegram-goal") @@ -4756,13 +4741,11 @@ def _start_agency_goal_from_command( LOG.exception("goal: createForumTopic failed") topic_error = str(exc) _append_private_goal(title, context) - mode = DEFAULT_GOAL_MODE + if mode not in GOAL_MODES: + mode = DEFAULT_GOAL_MODE goal_id = _record_miniapp_goal(title, context, "", chat_id, goal_thread, mode=mode) prompt = _agency_goal_prompt(title, context, mode=mode) if topic_error and not spawned_topic: - # User asked for /goal in a group but topic creation failed (e.g. the - # bot isn't admin / topics aren't enabled). Tell them — running the - # goal in-place would silently merge it into the current thread. self.send( chat_id, f"Couldn't create a new topic for this goal: {topic_error}\n\n" @@ -4772,12 +4755,20 @@ def _start_agency_goal_from_command( thread_id=thread_id, markdown=False, ) - ack_lines = [ - f"🎯 Goal locked: {title}", - "", - f"Mode: 🛟 **copilot** — I draft and ask before anything visible (look for {MODE_EMOJI['copilot']} in this topic's title).", - f"Switch with /autopilot — I act on reversible work without asking, topic flips to {MODE_EMOJI['autopilot']}.", - ] + if mode == "autopilot": + ack_lines = [ + f"🚀 Autopilot goal locked: {title}", + "", + "I'll work this end-to-end without asking — only stopping for genuinely visible/external side effects or genuine blockers.", + "Heads-up: autopilot uses whatever access I have to reach the goal. Don't run this in a topic that touches sensitive data.", + ] + else: + ack_lines = [ + f"🎯 Goal locked: {title}", + "", + "Mode: **copilot** — I draft and ask before anything visible.", + "Switch this topic with /autopilot, or use `/go ` next time to launch a fresh autopilot topic.", + ] self.send( chat_id, "\n".join(ack_lines), @@ -5430,9 +5421,9 @@ def handle(self, msg: dict) -> None: "/claude — switch this topic to Claude\n" "/claude login — sign in Claude through a terminal flow\n" "/claude logout — sign out Claude\n" - "/goal — start a goal (new topic, agent works on it 24/7)\n" - "/autopilot — this goal: act on reversible work, ask only at visible edges\n" - "/copilot — this goal: draft and ask before anything visible (default)\n" + "/goal — AUTOPILOT goal in a new topic, I work end-to-end without approvals\n" + "/autopilot — flip this topic to autopilot\n" + "/copilot — flip this topic back to copilot (default everywhere except /goal topics)\n" "/agency — open the Mini App\n" "/miniapp — open the Mini App\n" "/live — live-view URL of the active browser\n" @@ -5460,7 +5451,12 @@ def handle(self, msg: dict) -> None: markdown=True, ) return - self._start_agency_goal_from_command(chat_id, thread_id, arg, sender, reply_to=mid) + # /goal launches AUTOPILOT in a new topic — the box's "I want this + # done end-to-end" verb. The whole rest of the box defaults to + # copilot; only /goal triggers autonomous work. + self._start_agency_goal_from_command( + chat_id, thread_id, arg, sender, reply_to=mid, mode="autopilot", + ) return if cmd in ("/autopilot", "/copilot"): if not owner or not _is_owner(sender, owner): @@ -5483,33 +5479,10 @@ def handle(self, msg: dict) -> None: markdown=True, ) return - # Rename the topic so the new mode emoji is visible in the - # topic list. Best-effort: editForumTopic needs the bot to be - # an admin with manage_topics; if it fails (DM, private chat, - # missing perms) we still post the mode-change ack below. - try: - # Look up the goal's title to re-decorate cleanly. - with sqlite3.connect(str(MINIAPP_DB)) as db: - cur = db.execute( - "SELECT title FROM goals WHERE tg_chat_id = ? AND tg_thread_id = ? " - "ORDER BY id DESC LIMIT 1", - (chat_id, thread_id), - ) - row = cur.fetchone() - if row and thread_id: - new_title = _decorate_topic_title(str(row[0]), new_mode) - self.call( - "editForumTopic", - chat_id=chat_id, - message_thread_id=thread_id, - name=new_title[:128], - ) - except Exception: - LOG.debug("could not rename topic on mode switch", exc_info=True) blurb = ( - "🚀 **autopilot** — I act on reversible work directly and ping you only at visible boundaries." + "**autopilot** — I act on reversible work directly and only stop at visible boundaries." if new_mode == "autopilot" - else "🛟 **copilot** — I draft and ask before anything visible to other people." + else "**copilot** (default) — I draft and ask before anything visible to other people." ) self.send( chat_id, diff --git a/agent/test_telegram_bot.py b/agent/test_telegram_bot.py index 098b21c..f868e7c 100644 --- a/agent/test_telegram_bot.py +++ b/agent/test_telegram_bot.py @@ -264,39 +264,6 @@ def test_set_goal_mode_rejects_unknown_value(self) -> None: self.assertEqual(telegram_bot._get_goal_mode(123, 99), "copilot") -class DecorateTopicTitleTest(unittest.TestCase): - """Mode emoji prefix in topic titles.""" - - def test_copilot_default_prefix(self) -> None: - self.assertEqual( - telegram_bot._decorate_topic_title("ship demo", "copilot"), - "🛟 ship demo", - ) - - def test_autopilot_prefix(self) -> None: - self.assertEqual( - telegram_bot._decorate_topic_title("ship demo", "autopilot"), - "🚀 ship demo", - ) - - def test_redecorate_replaces_existing_emoji(self) -> None: - # User switches mode -> existing prefix gets swapped, not appended. - self.assertEqual( - telegram_bot._decorate_topic_title("🛟 ship demo", "autopilot"), - "🚀 ship demo", - ) - self.assertEqual( - telegram_bot._decorate_topic_title("🚀 ship demo", "copilot"), - "🛟 ship demo", - ) - - def test_unknown_mode_falls_back_to_default(self) -> None: - self.assertEqual( - telegram_bot._decorate_topic_title("X", "nonsense"), - "🛟 X", - ) - - class AgencyGoalPromptTest(unittest.TestCase): """The /goal cycle prompt must mention current mode + self-scheduling.""" diff --git a/install.sh b/install.sh index b6f2557..960eddb 100755 --- a/install.sh +++ b/install.sh @@ -449,6 +449,8 @@ install -m 0755 "$REPO_DIR/agent/tg-approve.py" /usr/local/bin/tg-approve # get in the way. install -m 0755 "$REPO_DIR/agent/tg-schedule" /usr/local/bin/tg-schedule install -m 0755 "$REPO_DIR/agent/tg-schedule-fire" /usr/local/bin/tg-schedule-fire +# Friendlier alias `schedule` for the agent + user; both names work. +ln -sfn /usr/local/bin/tg-schedule /usr/local/bin/schedule # --- pre-seed ~/.claude.json so first `claude` run skips dialogs ----------- if [ ! -f /home/bux/.claude.json ]; then @@ -544,6 +546,28 @@ if ! sudo -iu bux command -v codex >/dev/null 2>&1; then || warn 'codex install failed (non-fatal — /codex login will hint how to install later)' fi +# Enable Codex /goal feature so `/goal ` autopilot works out of the box. +# Setting is `goals = true` under `[features]` in ~/.codex/config.toml +# (Codex CLI v0.128.0+; experimental). Idempotent: leaves existing config +# alone if a [features] block or goals = true is already present. +sudo -u bux -H bash -c ' +CODEX_CONFIG="$HOME/.codex/config.toml" +mkdir -p "$(dirname "$CODEX_CONFIG")" +if [ ! -f "$CODEX_CONFIG" ]; then + cat > "$CODEX_CONFIG" <&2 + else + printf "\n[features]\ngoals = true\n" >> "$CODEX_CONFIG" + fi +fi +chmod 0644 "$CODEX_CONFIG" +' + # --- login banner: print live browser URL on each ssh login --------------- if ! grep -q 'BU_BROWSER_LIVE_URL' /home/bux/.profile 2>/dev/null; then cat >> /home/bux/.profile <<'PROFILE'