diff --git a/agent/AGENCY.md b/agent/AGENCY.md index 1dc528b..e24ef98 100644 --- a/agent/AGENCY.md +++ b/agent/AGENCY.md @@ -11,28 +11,45 @@ Personal preferences (voice, team, filters, user-specific patterns) belong in pr ## Architecture ``` -agent → agency-report → agency.db + TG card - │ - ▼ - user taps button - │ - ▼ - bot._handle_agency_callback - ├─ records decision (DB) - ├─ marks picked button visually - └─ routes per kind: - • action → run_task in (new or current) thread - • dismiss → 1-line ack, no dispatch - • refine → "what would you change?" + wait - • custom → synthesized [agency-button] dispatch +generator agent/topic → agency-report → agency.db + TG/Mini App card + │ + ▼ + user taps button + │ + ▼ + bot._handle_agency_callback / Mini App API + ├─ records decision (DB) + ├─ marks picked button visually + └─ routes per kind: + • action → fresh worker agent session for that card + • dismiss → 1-line ack/delete, no dispatch + • refine → comment/context into the card's worker session + • custom → synthesized [agency-button] dispatch ``` ## Concept -One topic, one goal, one ongoing mission. Each forum topic is a long-running lane working a single high-level goal. Every card ties back to that goal. The agent re-checks on a cadence set during onboarding (every 30 min / hour / twice a day / only-when-asked). +Agency is a personalized social feed fully managed by the generator agent. The feed exists to create the next best thing the user can approve, refine, or skip. The optimization target is not volume; it is useful accepted actions that move the user's goals. + +One generator lane keeps creating cards. Each accepted action card becomes its own fresh worker session. If that worker later needs another decision, it posts a follow-up version of the same card family, and the Mini App shows the newest version first while preserving the older versions and comments. If the task is fully done, the final card is an info/done card the user can acknowledge or comment on. + +High-level goals live in the private goals file at `/opt/bux/repo/private/goals.md` (or `BUX_GOALS_FILE` when configured). This file is never committed. It contains what the user cares about, current preferences, rejected themes, cadence, and goal-level learnings. The concrete history lives in `/var/lib/bux/agency.db`: every card, decision, skipped idea, accepted action, worker topic, and completion signal. + +If the feed is empty, that is not a valid steady state. Generate more cards from the goals file and decision history. If the goals file is empty or vague, generate goal-discovery cards or ask one short high-level question like "Should I optimize for more users, health, inbox peace, or founder focus?" Once the user accepts a high-level goal, save it to the goals file and start creating more specific cards. + +Every generator cycle reads: + +1. `/opt/bux/repo/private/goals.md` for high-level goals and preferences. +2. `MEMORY.md` / private profile memory for voice, relationships, integrations, and source priorities. +3. `/var/lib/bux/agency.db` for accepted, skipped, regenerated, completed, and stale cards. +4. Connected sources such as Gmail, Slack, GitHub, WhatsApp, Calendar, Linear, Datadog, and browser context. + +The generator runs on a cadence, default hourly unless the user chose another schedule. It should continuously monitor connected context, generate cards that are concrete to the user's goals, and learn from every tap. If the user repeatedly skips a theme, record that as a preference and stop repitching it with different wording. **Be ruthlessly proactive.** Don't ask "should I look?" — look. Don't ask "want me to draft?" — draft, attach, ask `send?`. Don't ask "which option?" — show 2-3 as variant buttons. Maximize accepted suggestions per tap. +Do all reversible/private work before the card. Draft the reply, inspect the PR, fetch the screenshot, prepare the launch copy, query the dashboard, or build the asset. Stop only at the visible boundary where another person, public system, money, or irreversible state would be affected. + **The user has 2 seconds.** Phone screen, late-night, mid-workout, between meetings. Every card must answer in one glance: 1. **What** would happen if I tap Yes? *(title, verb-led)* @@ -49,7 +66,7 @@ Never post generic channel/workflow cards like "monitor Slack", "automate browse A real Agency card must name a concrete user problem or goal and a concrete object: person, company, thread, repository, PR, incident, signup, customer, page, post, or file. If the card cannot say exactly **who/what/where** it acts on, do not post it. -If the user's goal is unknown, ask one short goal-lock question before generating cards. If you must infer, assume the default goal is "make my startup successful" and generate only cards that directly help distribution, revenue, users, product quality, fundraising, hiring, or founder focus. Still ground every card in real context; never invent plausible startup chores. +If the user's goal is unknown, ask one short goal-lock question or post high-level goal cards before generating concrete cards. Suggested first goals can include: make my startup successful, get more users, monitor important inboxes, keep relationships warm, stay healthy, improve distribution, ship faster, or find demo/case-study opportunities. If you must infer, assume the default goal is "make my startup successful" and generate only cards that directly help distribution, revenue, users, product quality, fundraising, hiring, or founder focus. Still ground every card in real context; never invent plausible startup chores. ## Two zones @@ -75,7 +92,7 @@ No profile in private memory (`~/.claude/projects/-home-bux/memory/_profil 1. **Read mode.** Parallel `Agent` sub-agents over connected surfaces (Gmail headers + sent samples, Slack channels, GitHub activity, Calendar, Linear / Notion, `list_integrations`). Each returns one paragraph: who they are, what they're working on, who they work with, voice cues. Read headers / samples / top-N — never whole inboxes. 2. **Save profile** to `_profile.md` + index line in `MEMORY.md`. Private, never echoed, never committed. -3. **Button-ask the goal.** `tg-buttons` with options derived from the scan (startup success / fitness / shipping `` / customer calls / something else). Save as `_endgoal.md`. +3. **Button-ask the goal.** `tg-buttons` with options derived from the scan (startup success / fitness / shipping `` / customer calls / something else). Save the universal high-level goals and preferences to `/opt/bux/repo/private/goals.md` (or `BUX_GOALS_FILE`). Older per-user goal memories may still exist; treat the private goals file as the canonical Agency input. 4. **Button-ask the cadence.** `tg-buttons`: every 30 min / hour / twice a day / only when I ask. Wire `tg-schedule` self-pings for non-manual choices. 5. **Then go proactive.** Acceptance-rate doctrine applies — post nothing if nothing's high-impact. @@ -83,18 +100,20 @@ Profile exists but no goal → run a lighter goal-lock card first (options: `com ## Scan process -When the trigger fires ("start agency", "what's pending", "scan everything") and profile + goal are locked: +When the trigger fires ("start agency", "what's pending", "scan everything", heartbeat, or Mini App "generate more") and profile + goal are locked: -1. **Read MEMORY.md** for voice, delegation map, spam heuristics, key relationships, current priorities. Don't re-derive. -2. **Dispatch parallel sub-agents in one assistant message** — one per surface. Defaults: +1. **Read `/opt/bux/repo/private/goals.md` first.** This is the generator's product brief for the user's personal feed. +2. **Read MEMORY.md** for voice, delegation map, spam heuristics, key relationships, current priorities. Don't re-derive. +3. **Read agency.db history.** Check accepted, skipped, completed, regenerated, and ignored cards before proposing anything. Do not recreate a skipped idea unless the context materially changed. +4. **Dispatch parallel sub-agents in one assistant message** — one per surface. Defaults: - **Email** — last 14 days unread + in-flight. Triage: NEEDS REPLY (drafts saved) / DRAFTABLE FORWARD (saved to the right teammate) / IMPORTANT FYI / SPAM (counted). - **Slack** — last 3-7 days of personal channels (`#wall-*`, DMs, mentions, hot customer channels). Identify what's blocked on the user. Paste-ready 1-liners. - **GitHub** — review-requested PRs, user's own open PRs (merge/close call per PR), assigned issues, flagship-repo CI health. - **Calendar** — week ahead in user's TZ, conflicts, prep flags. Also: integrations not yet authed + exact connect step. - **Observability** — fires first (open incidents, firing monitors, error spikes), then opportunities (demo traces, eval candidates). -3. **Brief each sub-agent** like a colleague (no shared context): who the user is, scope, tools to load, triage rules, hard boundaries (DO NOT SEND / POST / MERGE — drafts only), return format. -4. **Save drafts to private surfaces** (Gmail drafts, local files). Capture IDs. Surface only snippet + action. For Slack / GitHub (no draft surface), write paste-ready text in the card. -5. **Compose cards, not one summary.** One `agency-report` card per decision. The user can't button-tap a wall of text. +5. **Brief each sub-agent** like a colleague (no shared context): who the user is, scope, tools to load, triage rules, hard boundaries (DO NOT SEND / POST / MERGE — drafts only), return format. +6. **Save drafts to private surfaces** (Gmail drafts, local files). Capture IDs. Surface only snippet + action. For Slack / GitHub (no draft surface), write paste-ready text in the card. +7. **Compose cards, not one summary.** One `agency-report` card per decision. The user can't button-tap a wall of text. When a brief explicitly asks for a "report" shape, use: @@ -114,11 +133,11 @@ End with a numbered concrete follow-up list. Each item self-contained. Never ask `(accepted + completed) / posted`. Every other choice — title, length, image, urgency — serves that. 5 accepted beats 20 ignored. Each ignored card costs trust; two in a row, the user starts skimming; five, they mute. -**If nothing's high-impact this cycle, post nothing.** Silence beats slop. +**If nothing's high-impact this cycle but the feed is empty, ask/suggest goals instead of posting slop.** If there are still pending cards, silence beats filler. If there are no pending cards, the generator must create either useful goal-grounded cards or high-level goal-discovery cards. ### Tie every card to the locked goal -The user's locked goal is in `_endgoal.md`. Each card must tell the user why this action matters for that goal in simple language: +The user's locked goals are in `/opt/bux/repo/private/goals.md` (or `BUX_GOALS_FILE`). Each card must tell the user why this action matters for one of those goals in simple language: - ❌ "submit to Smithery, virgin slot" *(so what?)* - ✅ "Ship this now so more MCP devs discover the project while the launch window is hot." @@ -327,9 +346,9 @@ Each interaction costs one tap, not one keystroke. `--button` overrides defaults - Any forum topic → in-place. Treat the topic as the goal/session lane. - No forum topic → spawn a fresh forum topic. -Override with `--spawn-topic` / `--no-spawn-topic`. Use `--spawn-topic` only when the card truly needs a separate lane; Mini App cards are rendered inside one goal section and should stay in that goal by default. +Override with `--spawn-topic` / `--no-spawn-topic` for Telegram-only cards when needed. -Policy: default to in-place `✅ Yes` for short work. If the accepted action is likely one small action or a few tool calls, keep it in the current topic/session. Use `🧵 Yes (new thread)` / `--spawn-topic` only for bigger projects: recurring monitors, multi-step investigations, work likely to take >10 tool calls, or anything that will produce multiple follow-ups over time. +Policy: Mini App/Tinder accepted cards launch a fresh worker session by default because each card is its own actionable ticket. Telegram cards outside the Mini App may still default to in-place for tiny one-step work. Use a new topic/session for recurring monitors, multi-step investigations, work likely to take >10 tool calls, anything that will produce multiple follow-ups over time, or any accepted Mini App card. **Multi-tap dedupes the worker topic.** Tapping Yes twice doesn't spawn two; subsequent taps reuse the first `worker_topic_id`. @@ -422,10 +441,12 @@ Check each topic's brief before drafting. `/miniapp` opens the per-box Telegram Mini App. "Agency start " or a new Mini App goal creates/uses one Telegram topic as the goal lane, records context there, and asks the agent to generate initial cards. "Generate more" means: continue from that topic's current context and produce more high-signal action cards, not a new goal. Cards should use short, phone-readable copy, clickable sources, real images/videos when available, and no internal IDs. -When a Mini App card is accepted, run it in the same goal topic/session by default. If the user accepts 10 cards from one goal, push all 10 into that goal's agent session. The agent can create sub-agents or new Telegram topics later only when it is clearly useful: recurring monitor, larger project, or >10-tool-call investigation. +When a Mini App card is accepted, start a fresh worker session for that card. The goal topic remains the generator lane. If the user accepts 10 cards from one goal, that creates 10 worker sessions tied back to the same goal and card history. The generator should still learn from their outcomes before creating the next batch. When you receive an accepted Mini App card, treat the card as a full ticket. Read the title, why-it-matters sentence, source, expandable sections, and picked button before acting. If the ticket is a bigger project or recurring monitor, create a dedicated Telegram topic and send the agent there; otherwise keep working in the current goal session. +If a worker needs more user input, do not lose the original ticket. Create a follow-up Agency card linked to the same task/version family. The Mini App should show the newest version first and let the user inspect older versions/comments. A fully completed task can end with an info card and an acknowledgement button. + ## Honor access gaps When a tool can't see a surface (no auth, missing key), name the gap **and the exact next step** to unblock it. Not "I couldn't access X" but "X needs auth: run `/mcp` → connect Y → I can scan it next cycle." Make the next scan strictly more useful than this one. diff --git a/agent/mini_app.py b/agent/mini_app.py index 6a0f40f..c8f3567 100644 --- a/agent/mini_app.py +++ b/agent/mini_app.py @@ -32,6 +32,7 @@ TG_STATE = Path("/etc/bux/tg-state.json") TG_ALLOWED = Path("/etc/bux/tg-allowed.txt") MINI_DB = Path(os.environ.get("BUX_MINIAPP_DB", "/var/lib/bux/miniapp.db")) +GOALS_FILE = Path(os.environ.get("BUX_GOALS_FILE", "/opt/bux/repo/private/goals.md")) HOST = os.environ.get("BUX_MINIAPP_HOST", "127.0.0.1") PORT = int(os.environ.get("BUX_MINIAPP_PORT", "8787")) AUTH_MAX_AGE_SEC = int(os.environ.get("BUX_MINIAPP_AUTH_MAX_AGE", "86400")) @@ -857,6 +858,43 @@ def _settings() -> dict[str, str]: return {str(row["key"]): str(row["value"]) for row in db.execute("SELECT * FROM settings")} +def _goals_file_text() -> str: + try: + text = GOALS_FILE.read_text().strip() + except FileNotFoundError: + return "" + except Exception as exc: + print(f"bux-miniapp: goals file read failed: {exc}", file=sys.stderr) + return "" + return text[:12000] + + +def _append_goal_file_entry(title: str, context: str, cadence: str = "") -> None: + now = time.strftime("%Y-%m-%d", time.gmtime()) + lines = [ + "", + f"## {title}", + f"- Added: {now}", + ] + if cadence: + lines.append(f"- Cadence: {cadence}") + if context: + lines.append(f"- Context: {context.strip()}") + lines.append("- Preference signals: learn from accepted, skipped, and completed Agency cards before suggesting more.") + try: + GOALS_FILE.parent.mkdir(parents=True, exist_ok=True) + if not GOALS_FILE.exists() or not GOALS_FILE.read_text().strip(): + GOALS_FILE.write_text( + "# Goals\n\n" + "Private high-level goals and Agency preferences for this box.\n" + "The Agency generator reads this before creating cards and updates it when the user clarifies goals.\n" + ) + with GOALS_FILE.open("a") as fh: + fh.write("\n".join(lines) + "\n") + except Exception as exc: + print(f"bux-miniapp: goals file append failed: {exc}", file=sys.stderr) + + def _can_create_telegram_topics() -> bool: return os.environ.get("BUX_MINIAPP_DEV") != "1" and MINI_DB == Path("/var/lib/bux/miniapp.db") @@ -871,16 +909,26 @@ def _goal_agent_prompt( count = "10 more" if mode == "more" else "10" header = "Generate more Mini App action items." if mode == "more" else "Mini App goal created." cadence_line = f"\nCadence or schedule mentioned by the user: {cadence}" if cadence else "" + goals_text = _goals_file_text() + goals_block = ( + f"\n\nPrivate goals file ({GOALS_FILE}):\n{goals_text}" + if goals_text + else f"\n\nPrivate goals file ({GOALS_FILE}) is empty or missing. First lock the user's high-level goals." + ) return ( f"{header}\n\n" f"Goal: {title}\n\n" f"User context:\n{context or title}" - f"{cadence_line}\n\n" + f"{cadence_line}" + f"{goals_block}\n\n" "Use the Agency skill and /opt/bux/repo/agent/AGENCY.md. " f"Scan the user's available context and generate {count} high-signal action items for this goal. " + "This is the generator lane for a personal social feed: create cards the user will want to accept. " + "Read the goals file and agency.db history first so you do not repeat skipped ideas. " + "Do all reversible/internal work before posting a card, then ask only at the visible boundary. " "Do not generate generic channel ideas like 'monitor Slack' or 'check GitHub'. " - "Every card must name a concrete person, company, thread, repo, PR, incident, signup, page, post, or file. " - "If there is not enough concrete context, ask one short goal/context question instead of filling the feed. " + "Every concrete card must name a person, company, thread, repo, PR, incident, signup, page, post, or file. " + "If goals or context are still unknown, create high-level goal-lock cards or ask one short goal question instead of leaving the feed empty. " "If the goal is vague, assume the user is a startup founder trying to make the startup successful, " "but still ground every card in real context and a specific action. " "Post them as Agency cards in this same Telegram topic using the normal agency-report/agency-card flow " @@ -904,14 +952,22 @@ def _topic_generate_prompt(thread_id: int, title: str) -> str: ).fetchall() recent = [str(row["title"] or "").strip() for row in rows if str(row["title"] or "").strip()] context = "\n".join(f"- {item}" for item in recent[:8]) or "- No existing cards in this topic yet." + goals_text = _goals_file_text() + goals_block = ( + f"\nPrivate goals file ({GOALS_FILE}):\n{goals_text}\n" + if goals_text + else f"\nPrivate goals file ({GOALS_FILE}) is empty or missing; ask or suggest high-level goals first.\n" + ) return ( "Generate more Mini App action items.\n\n" f"Topic: {title}\n" - f"Existing recent cards:\n{context}\n\n" + f"Existing recent cards:\n{context}\n" + f"{goals_block}\n" "Use the Agency skill and /opt/bux/repo/agent/AGENCY.md. " "The user explicitly wants more cards/action items for this topic. " + "Treat this topic as a generator lane. Read the private goals and the existing card history, learn from skipped/accepted decisions, and avoid duplicates. " "Do not generate generic channel/workflow ideas. Each card must name a specific person, company, thread, repo, PR, incident, signup, page, post, or file and explain why it moves the topic goal. " - "If the topic goal is unclear, ask one short clarifying goal question instead of posting filler. " + "If the topic goal is unclear, generate high-level goal-lock cards or ask one short clarifying goal question instead of posting filler. " "Generate 10 more high-signal cards in this same Telegram topic through the normal agency-report/agency-card flow " "so they appear in the Mini App feed for this topic." ) @@ -1051,8 +1107,9 @@ def _start_agent_prompt(row: dict[str, Any], action_prompt: str, button_label: s picked = button_label or "Mini App Start" return ( "The user accepted this Mini App card. Work from the full card context below.\n" - "If this is a bigger ongoing project or recurring monitor, you may create a dedicated Telegram topic " - "and continue the agent session there. Otherwise continue in the current goal topic/session.\n\n" + "This accepted card is now its own worker session. Complete the task in this session when possible. " + "If you need more user confirmation, post a follow-up Agency card linked to this task instead of asking vaguely. " + "Do all private/reversible work first and stop only before a visible third-party action.\n\n" f"Picked button: {picked}\n" f"Card title: {row.get('title') or 'Action'}\n" f"Why it matters: {row.get('description') or ''}\n" @@ -1084,10 +1141,10 @@ def _start_agent_work( if not chat_id: return {"started": False, "error": "no Telegram chat bound"} thread_id = int(row.get("tg_thread_id") or 0) - work_thread = thread_id + work_thread = int(row.get("worker_topic_id") or 0) or thread_id topic_created = False topic_name = (row.get("title") or "Mini App task")[:128] - if bool(row.get("spawn_topic")): + if not int(row.get("worker_topic_id") or 0): res = bot.call("createForumTopic", chat_id=chat_id, name=topic_name) if res.get("ok"): work_thread = int(res["result"].get("message_thread_id") or thread_id) @@ -1318,6 +1375,7 @@ def do_POST(self) -> None: ) db.commit() goal_id = int(cur.lastrowid) + _append_goal_file_entry(title, context, cadence) chat_id, thread_id = _ensure_goal_topic(goal_id, title) if chat_id and thread_id: active_id = f"topic:{thread_id}" diff --git a/agent/test_mini_app.py b/agent/test_mini_app.py index c07042e..610a559 100644 --- a/agent/test_mini_app.py +++ b/agent/test_mini_app.py @@ -45,6 +45,7 @@ def setUp(self) -> None: os.environ["TG_OWNER_ID"] = "42" os.environ["BUX_AGENCY_DB"] = str(root / "agency.db") os.environ["BUX_MINIAPP_DB"] = str(root / "miniapp.db") + os.environ["BUX_GOALS_FILE"] = str(root / "private" / "goals.md") os.environ["BUX_MINIAPP_SEED_STARTERS"] = "0" os.environ.pop("BUX_MINIAPP_DEV", None) for name in ("agency_db", "mini_app"): @@ -55,6 +56,7 @@ def setUp(self) -> None: def tearDown(self) -> None: os.environ.pop("BUX_MINIAPP_SEED_STARTERS", None) + os.environ.pop("BUX_GOALS_FILE", None) self.tmp.cleanup() def test_validate_init_data_rejects_wrong_owner(self) -> None: @@ -174,7 +176,15 @@ def test_http_goal_and_cards_flow(self) -> None: thread.start() base = f"http://127.0.0.1:{server.server_port}" try: - self._request(base + "/api/goals", method="POST", body={"title": "Win"}) + self._request( + base + "/api/goals", + method="POST", + body={"title": "Win", "context": "Get more users", "cadence": "hourly"}, + ) + goals_text = Path(os.environ["BUX_GOALS_FILE"]).read_text() + self.assertIn("## Win", goals_text) + self.assertIn("Get more users", goals_text) + self.assertIn("Cadence: hourly", goals_text) cards = self._request(base + "/api/cards") self.assertEqual(cards["cards"][0]["id"], suggestion_id) self._request( @@ -190,7 +200,7 @@ def test_http_goal_and_cards_flow(self) -> None: server.shutdown() server.server_close() - def test_start_dispatch_runs_in_goal_topic_by_default(self) -> None: + def test_start_dispatch_creates_worker_topic_by_default(self) -> None: calls: list[tuple[str, dict]] = [] runs: list[tuple[tuple[int, int], str]] = [] @@ -232,10 +242,11 @@ def run_task( time.sleep(0.02) self.assertTrue(result["started"]) - self.assertFalse(result["topic_created"]) - self.assertEqual(result["thread_id"], 123) - self.assertEqual(calls[0][0], "sendMessage") - self.assertEqual(runs[0][0], (100, 123)) + self.assertTrue(result["topic_created"]) + self.assertEqual(result["thread_id"], 777) + self.assertEqual(calls[0][0], "createForumTopic") + self.assertEqual(calls[1][0], "sendMessage") + self.assertEqual(runs[0][0], (100, 777)) self.assertIn("The user accepted this Mini App card", runs[0][1]) self.assertIn("Card title: Start visible work", runs[0][1]) self.assertIn("Action prompt:\nDo the work", runs[0][1]) @@ -245,7 +256,7 @@ def run_task( (suggestion_id,), ).fetchone() self.assertEqual(row["status"], "accepted") - self.assertEqual(row["worker_topic_id"], 123) + self.assertEqual(row["worker_topic_id"], 777) def test_start_dispatch_applies_miniapp_provider_setting(self) -> None: runs: list[tuple[tuple[int, int], str]] = [] @@ -258,6 +269,8 @@ def __init__(self, token: str, setup_token: str) -> None: self.state = {"agents": {}} def call(self, method: str, **params: object) -> dict: + if method == "createForumTopic": + return {"ok": True, "result": {"message_thread_id": 777}} return {"ok": True, "result": {"message_id": 55}} def run_task( @@ -295,8 +308,8 @@ def fake_set_agent_for(key: tuple[int, int], provider: str, state: dict) -> None time.sleep(0.02) self.assertTrue(result["started"]) - self.assertEqual(bindings, [((100, 123), "codex")]) - self.assertEqual(runs[0][0], (100, 123)) + self.assertEqual(bindings, [((100, 777), "codex")]) + self.assertEqual(runs[0][0], (100, 777)) def test_start_dispatch_custom_button_includes_card_context(self) -> None: calls: list[tuple[str, dict]] = []