Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion agent/AGENCY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions agent/mini_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down Expand Up @@ -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."
Expand Down
29 changes: 29 additions & 0 deletions agent/mini_app_static/tinder.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
67 changes: 62 additions & 5 deletions agent/mini_app_static/tinder.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ function actionsHtml(buttons, hasAction) {
<button class="round no" data-delete type="button" aria-label="Skip">${xSvg()}</button>
<button class="round comment" data-comment type="button" aria-label="Comment">${commentSvg()}</button>
</div>
<form class="inline-comment" data-comment-form>
<input data-comment-input type="text" autocomplete="off" placeholder="Add context..." />
</form>
<div class="choices">
${buttons.map((button) => `<button class="choice" data-start data-button="${escapeAttr(button.raw)}">${escapeHtml(button.text)}</button>`).join("")}
</div>
Expand All @@ -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;
Expand Down Expand Up @@ -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 ` · <a class="source-link" href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(card.source_label || "Source")}</a>`;
}

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] || "";
}
Expand All @@ -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"],
Expand All @@ -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) };
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -532,6 +586,9 @@ async function refresh(options = {}) {

try {
await refresh();
setInterval(() => {
refresh().catch((error) => toast(error.message));
}, 15000);
} catch (error) {
els.deck.innerHTML = `<article class="empty"><strong>Login failed</strong><p>${escapeHtml(error.message)}</p></article>`;
}
47 changes: 47 additions & 0 deletions agent/telegram_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading