From cbfb4466419ce1eac8cbd60a9351bf754c5c1864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20M=C3=BCller?= Date: Wed, 13 May 2026 20:30:05 +0000 Subject: [PATCH] Improve agency tinder source and comments --- agent/AGENCY.md | 18 ++++++++- agent/mini_app.py | 2 + agent/mini_app_static/tinder.css | 29 ++++++++++++++ agent/mini_app_static/tinder.js | 67 +++++++++++++++++++++++++++++--- agent/telegram_bot.py | 47 ++++++++++++++++++++++ 5 files changed, 157 insertions(+), 6 deletions(-) diff --git a/agent/AGENCY.md b/agent/AGENCY.md index e24ef98..7bc30a3 100644 --- a/agent/AGENCY.md +++ b/agent/AGENCY.md @@ -46,6 +46,8 @@ Every generator cycle reads: 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. +On a fresh box, Agency is active by default. The bot should nudge the user toward goals, connections, and useful starter cards without waiting for the user to discover the mode. Default heartbeat: every 30 minutes. On each heartbeat, read the goals file and DB history, observe connected context, ask for missing goals/access when needed, and create cards when there is a concrete useful action. + **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. @@ -249,6 +251,20 @@ agency-report --emoji "✍️" \ **Hard rule for reply/message cards:** create 3 contrasting options by default (for example yes / no / neutral, or warm / terse / technical). Each option must be its own `--block`, and each option must have a matching `--button` (`Send A`, `Send B`, `Send C`). A card with variant buttons but no matching expandable variant blocks is invalid. +### Source and icon correctness + +`--source-label` and `--source-url` must describe the real object the card acts on. Never use the bux repo URL as a generic source for a LinkedIn, X, Reddit, Gmail, Slack, Bookface, Product Hunt, Datadog, or browser action card. + +Examples: + +- LinkedIn draft -> `--source-label "LinkedIn draft"` and no URL unless you have the actual LinkedIn URL. +- X thread -> `--source-label "X draft"` and actual X URL only if real. +- Reddit post -> subreddit/thread URL. +- GitHub PR/issue/repo -> GitHub URL. +- Local/generated asset -> source label like `Bux demo clip` and the asset path in a block, not a fake source URL. + +The Mini App icon is derived from this metadata. Wrong source metadata makes the feed look wrong and trains the user not to trust it. + ### Build the asset before posting If the action is "make a video / chart / screenshot / draft", **build it first**, attach to the card, ask Yes/No on whether to *publish*. Never `should I make a video?` — by the time the card lands, the asset must already exist. @@ -443,7 +459,7 @@ Check each topic's brief before drafting. 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. +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, media, comments, and picked button before acting. The Mini App and Telegram cards are two views of the same `agency.db` row: Telegram button taps and Mini App taps must both update the row status/decision so the other view stops showing stale pending cards. 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. diff --git a/agent/mini_app.py b/agent/mini_app.py index c8f3567..dcf79b7 100644 --- a/agent/mini_app.py +++ b/agent/mini_app.py @@ -933,6 +933,7 @@ def _goal_agent_prompt( "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 " "so they appear in the Mini App feed for this topic. " + "Set source_label/source_url to the real platform object; never use https://github.com/browser-use/bux as a generic source for non-GitHub cards. " "Keep each card short, concrete, and easy to swipe. Prefer real useful images when available. " "If the user mentioned a schedule, set up or propose the recurring monitoring cadence instead of treating it as a one-off." ) @@ -967,6 +968,7 @@ def _topic_generate_prompt(thread_id: int, title: str) -> str: "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. " + "Set source_label/source_url to the real platform object; never use the bux GitHub repo URL as a generic source for non-GitHub cards. " "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." diff --git a/agent/mini_app_static/tinder.css b/agent/mini_app_static/tinder.css index a17b5ef..dd7e8b8 100644 --- a/agent/mini_app_static/tinder.css +++ b/agent/mini_app_static/tinder.css @@ -407,6 +407,35 @@ details a { background: rgba(29, 155, 240, 0.12); } +.inline-comment { + display: none; + min-width: 0; +} + +.inline-comment.open { + display: block; +} + +.inline-comment.open + .choices { + display: none; +} + +.inline-comment input { + width: 100%; + min-height: 38px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--ink); + background: #101214; + outline: none; +} + +.inline-comment input:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(29, 155, 240, 0.16); +} + .choices { display: flex; flex-wrap: wrap; diff --git a/agent/mini_app_static/tinder.js b/agent/mini_app_static/tinder.js index aa34c1f..a042129 100644 --- a/agent/mini_app_static/tinder.js +++ b/agent/mini_app_static/tinder.js @@ -183,6 +183,9 @@ function actionsHtml(buttons, hasAction) { +
+ +
${buttons.map((button) => ``).join("")}
@@ -193,7 +196,8 @@ function actionsHtml(buttons, hasAction) { function bindCard(card) { const item = els.deck.querySelector(".card"); els.deck.querySelectorAll("[data-delete]").forEach((button) => button.addEventListener("click", () => dismissCard(card.id, item))); - els.deck.querySelectorAll("[data-comment]").forEach((button) => button.addEventListener("click", openContext)); + els.deck.querySelectorAll("[data-comment]").forEach((button) => button.addEventListener("click", () => openInlineComment(item))); + els.deck.querySelectorAll("[data-comment-form]").forEach((form) => form.addEventListener("submit", (event) => sendInlineComment(event, card, item))); els.deck.querySelectorAll("[data-start]").forEach((button) => button.addEventListener("click", () => startCard(card.id, button.dataset.button || "", item))); let startX = 0; let startY = 0; @@ -265,11 +269,21 @@ function blocksHtml(card) { } function sourceInline(card) { - const url = card.source_url || firstUrl([card.title, card.why].join(" ")); + const url = displaySourceUrl(card) || firstUrl([card.title, card.why].join(" ")); if (!url) return ""; return ` · ${escapeHtml(card.source_label || "Source")}`; } +function displaySourceUrl(card) { + const url = String(card.source_url || "").trim(); + if (!url) return ""; + const host = sourceHost(url); + const labelText = [card.source_label, card.source, card.title, card.why].join(" ").toLowerCase(); + const genericBuxRepo = host === "github.com" && /github\.com\/browser-use\/bux\/?$/i.test(url); + if (genericBuxRepo && !/\b(github|pull request|pr #|repo|issue)\b/i.test(labelText)) return ""; + return url; +} + function firstUrl(value) { return String(value || "").match(/https?:\/\/[^\s)"']+/)?.[0] || ""; } @@ -293,9 +307,18 @@ function buttonText(label) { } function sourceMeta(card) { - const host = sourceHost(card.source_url); + const url = displaySourceUrl(card); + const host = sourceHost(url); const brands = [ ["producthunt.com", "Product Hunt", "producthunt.com"], + ["product hunt", "Product Hunt", "producthunt.com"], + ["linkedin.com", "LinkedIn", "linkedin.com"], + ["linkedin", "LinkedIn", "linkedin.com"], + ["news.ycombinator.com", "Hacker News", "news.ycombinator.com"], + ["hacker news", "Hacker News", "news.ycombinator.com"], + ["show hn", "Hacker News", "news.ycombinator.com"], + ["bookface", "YC", "ycombinator.com"], + ["ycombinator.com", "YC", "ycombinator.com"], ["mail.google.com", "Gmail", "mail.google.com"], ["gmail", "Gmail", "mail.google.com"], ["slack.com", "Slack", "slack.com"], @@ -309,9 +332,15 @@ function sourceMeta(card) { ["x.com", "X", "x.com"], ["twitter.com", "X", "x.com"], ["tweet", "X", "x.com"], + ["linear.app", "Linear", "linear.app"], + ["linear", "Linear", "linear.app"], + ["datadoghq.com", "Datadog", "datadoghq.com"], + ["datadog", "Datadog", "datadoghq.com"], ]; - const sourceText = [host, card.source_label, card.source, card.title].join(" ").toLowerCase(); - const found = brands.find(([needle]) => sourceText.includes(needle)); + const explicitText = [card.source_label, card.source, card.title, card.why].join(" ").toLowerCase(); + const explicit = brands.find(([needle]) => explicitText.includes(needle)); + if (explicit) return { name: explicit[1], domain: explicit[2], mark: explicit[1][0] }; + const found = brands.find(([needle]) => host.toLowerCase().includes(needle)); if (found) return { name: found[1], domain: found[2], mark: found[1][0] }; const name = String(card.source || "Agency").split("-").filter(Boolean).slice(0, 2).join(" ") || "Agency"; return { name: titleCase(name), mark: initials(name) }; @@ -399,6 +428,31 @@ function openContext() { els.input.focus({ preventScroll: true }); } +function openInlineComment(item) { + const form = item?.querySelector("[data-comment-form]"); + const input = item?.querySelector("[data-comment-input]"); + if (!form || !input) return openContext(); + form.classList.add("open"); + input.focus({ preventScroll: true }); +} + +async function sendInlineComment(event, card, item) { + event.preventDefault(); + const input = item?.querySelector("[data-comment-input]"); + const comment = input?.value.trim() || ""; + if (!comment) return; + input.disabled = true; + toast("Refining it..."); + try { + await api(`/api/cards/${card.id}/comment`, { method: "POST", body: JSON.stringify({ comment }) }); + input.value = ""; + removeLocal(card.id); + } catch (error) { + input.disabled = false; + toast(error.message); + } +} + async function sendContext(event) { event.preventDefault(); const comment = els.input.value.trim(); @@ -532,6 +586,9 @@ async function refresh(options = {}) { try { await refresh(); + setInterval(() => { + refresh().catch((error) => toast(error.message)); + }, 15000); } catch (error) { els.deck.innerHTML = `
Login failed

${escapeHtml(error.message)}

`; } diff --git a/agent/telegram_bot.py b/agent/telegram_bot.py index b83b7c7..7210b77 100644 --- a/agent/telegram_bot.py +++ b/agent/telegram_bot.py @@ -4610,6 +4610,7 @@ def _bind_chat(self, chat_id: int, sender: dict | None = None) -> None: "Pick the agent you want to drive this box:", reply_markup=_login_picker_reply_markup(), ) + self._ensure_default_agency_heartbeat(chat_id) def _auto_allow_chat( self, @@ -4639,6 +4640,52 @@ def _auto_allow_chat( ) except Exception: LOG.exception("auto-allow welcome send failed for chat_id=%s", chat_id) + self._ensure_default_agency_heartbeat(chat_id) + + def _ensure_default_agency_heartbeat(self, chat_id: int) -> None: + """Schedule the first Agency heartbeat once per allowed chat. + + The heartbeat prompt asks the agent to reschedule itself. This keeps + Agency proactive by default without adding another always-on worker. + """ + key = str(chat_id) + heartbeats = self.state.setdefault("agency_heartbeats", {}) + if heartbeats.get(key): + return + prompt = ( + "Agency heartbeat. Use /opt/bux/repo/agent/AGENCY.md. " + "Read /opt/bux/repo/private/goals.md and /var/lib/bux/agency.db. " + "If the user has no clear goals, ask one short goal question or create high-level goal cards. " + "If goals are clear, observe connected context and create concrete Agency cards in Telegram and the Mini App. " + "Avoid duplicates and skipped ideas. Schedule your next heartbeat for +30 minutes." + ) + env = os.environ.copy() + env["TG_CHAT_ID"] = str(chat_id) + env["TG_THREAD_ID"] = "0" + try: + result = subprocess.run( + [ + "/usr/local/bin/tg-schedule", + "+30 minutes", + "--fresh", + "--name", + "Agency heartbeat", + prompt, + ], + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=10, + ) + if result.returncode == 0: + heartbeats[key] = {"scheduled_at": int(time.time()), "cadence": "30m"} + save_state(self.state) + LOG.info("agency heartbeat scheduled for chat_id=%s: %s", chat_id, result.stdout.strip()) + else: + LOG.warning("agency heartbeat schedule failed for chat_id=%s: %s", chat_id, result.stdout.strip()) + except Exception: + LOG.exception("agency heartbeat schedule failed for chat_id=%s", chat_id) def _handle_my_chat_member(self, update: dict) -> None: """React to the bot's own membership changing in some chat.