mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(x): Barfly drafts become standalone link-posts
X 403s programmatic replies into conversations that don't mention the account (every non-Enterprise tier), which is Barfly's entire discovery surface - so its drafts now carry commentary plus the conversation's /i/web/status/ URL instead of a reply target. Handles are stripped (mentions in plain posts are rejected too), the 280 budget accounts for the URL, the exploration prompt asks for standalone commentary, and the reject->redraft path re-appends a dropped link.
This commit is contained in:
@@ -428,7 +428,7 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
|
|||||||
|
|
||||||
**Board Program registry (per-program default-off).** Twelve new programs plus the two pre-existing origination cycles (`roadmap`/Printer, `x_feature`/spotlight — migrated onto the registry byte-for-byte, their own flags kept working as legacy aliases) all now ride one generic engine instead of bespoke per-engine loops. `BoardProgram` (`roboco/foundation/policy/board_programs.py`) is a frozen registry entry — `key`, `role` (the solo explorer), `trigger` (`cron`/`metric`/`event`), `source` (the exploration task's `tasks.source` marker), `default_interval_seconds`, `max_items_per_cycle`, `scope` (`project`/`org`) — for all fourteen entries in `PROGRAMS`. `BoardProgramEngine` (`roboco/services/board_programs.py`) runs the uniform lifecycle TRIGGER → EXPLORE → PROPOSE → DECIDE → MATERIALIZE → LEARN: the orchestrator's `_board_program_loop` ticks `run_due_programs` on a floor interval (the shortest registered cadence, clamped 300s-3600s), which for each enabled CRON program checks the `board_program_cycles` dedup ledger (migration 087 — one row per cycle, auto-closes when its exploration task goes terminal) and `program_due`, then originates via a per-program `_ORIGINATORS` callable (each program's own engine's `run_cycle`) and records the cycle row; `open_program_cycle` is the same path minus the cron-due check, used by the CEO's panel "run now" and by event/metric triggers. **Arming has no master flag**: `program_armed` (the single chokepoint every origination path routes through) reads a per-program settings-store row (`board_program.{key}.enabled`) falling back to the legacy boot flag only for `roadmap`/`x_feature` — every new program is settings-store-only, defaulting OFF until the CEO flips its toggle. **Scope + dual polarity**: `projects.board_programs` (migration 088, nullable jsonb) — a `scope="project"` program (it reads one repo: Pest Control, Spackle, Dogfood, Mirror) needs an affirmative per-project opt-in (`"pest_control"`; null/absent = out) before a cycle even opens (`_scope_gate`); a `scope="org"` program (it reads the org's process/market: Printer, Scales, Periscope, Megaphone, Barfly, War Room, Coroner, Librarian, Sentinel, x_feature) runs org-wide by default and is excluded per-project only by the opposite-polarity `"!key"` entry — one pure helper, `project_participates`, implements both. Panel: the project settings page's budget/ops card exposes both forms as checkboxes. **LEARN**: `BoardProgramEngine.record_decision` accrues each CEO approve/reject onto the cycle row's `decisions` jsonb; `prior_cycle_context` renders the last two closed cycles ("proposed N, approved N; rejected: X — reason") back into the next cycle's exploration prompt, closing the amnesia the old roadmap/spotlight engines had. Dispatchers skip every program source (`_is_non_dev_dispatch_source` / `_dispatch_board_program_exploration`'s dict-dispatch table) exactly like `board_roadmap` before; every exploration is a solo one-shot spawn reusing the `_board_dispatched` tracker + respawn breaker, bypassing the two-reviewer board-review gate. Panel: the Board Programs page (Business section, `board-programs-card.tsx` → `GET /api/board-programs`, `POST /api/board-programs/{key}/run-now`, CEO-only) lists every entry's live enablement/cadence/last-cycle summary/opted-in projects with a toggle and a "run now" button.
|
**Board Program registry (per-program default-off).** Twelve new programs plus the two pre-existing origination cycles (`roadmap`/Printer, `x_feature`/spotlight — migrated onto the registry byte-for-byte, their own flags kept working as legacy aliases) all now ride one generic engine instead of bespoke per-engine loops. `BoardProgram` (`roboco/foundation/policy/board_programs.py`) is a frozen registry entry — `key`, `role` (the solo explorer), `trigger` (`cron`/`metric`/`event`), `source` (the exploration task's `tasks.source` marker), `default_interval_seconds`, `max_items_per_cycle`, `scope` (`project`/`org`) — for all fourteen entries in `PROGRAMS`. `BoardProgramEngine` (`roboco/services/board_programs.py`) runs the uniform lifecycle TRIGGER → EXPLORE → PROPOSE → DECIDE → MATERIALIZE → LEARN: the orchestrator's `_board_program_loop` ticks `run_due_programs` on a floor interval (the shortest registered cadence, clamped 300s-3600s), which for each enabled CRON program checks the `board_program_cycles` dedup ledger (migration 087 — one row per cycle, auto-closes when its exploration task goes terminal) and `program_due`, then originates via a per-program `_ORIGINATORS` callable (each program's own engine's `run_cycle`) and records the cycle row; `open_program_cycle` is the same path minus the cron-due check, used by the CEO's panel "run now" and by event/metric triggers. **Arming has no master flag**: `program_armed` (the single chokepoint every origination path routes through) reads a per-program settings-store row (`board_program.{key}.enabled`) falling back to the legacy boot flag only for `roadmap`/`x_feature` — every new program is settings-store-only, defaulting OFF until the CEO flips its toggle. **Scope + dual polarity**: `projects.board_programs` (migration 088, nullable jsonb) — a `scope="project"` program (it reads one repo: Pest Control, Spackle, Dogfood, Mirror) needs an affirmative per-project opt-in (`"pest_control"`; null/absent = out) before a cycle even opens (`_scope_gate`); a `scope="org"` program (it reads the org's process/market: Printer, Scales, Periscope, Megaphone, Barfly, War Room, Coroner, Librarian, Sentinel, x_feature) runs org-wide by default and is excluded per-project only by the opposite-polarity `"!key"` entry — one pure helper, `project_participates`, implements both. Panel: the project settings page's budget/ops card exposes both forms as checkboxes. **LEARN**: `BoardProgramEngine.record_decision` accrues each CEO approve/reject onto the cycle row's `decisions` jsonb; `prior_cycle_context` renders the last two closed cycles ("proposed N, approved N; rejected: X — reason") back into the next cycle's exploration prompt, closing the amnesia the old roadmap/spotlight engines had. Dispatchers skip every program source (`_is_non_dev_dispatch_source` / `_dispatch_board_program_exploration`'s dict-dispatch table) exactly like `board_roadmap` before; every exploration is a solo one-shot spawn reusing the `_board_dispatched` tracker + respawn breaker, bypassing the two-reviewer board-review gate. Panel: the Board Programs page (Business section, `board-programs-card.tsx` → `GET /api/board-programs`, `POST /api/board-programs/{key}/run-now`, CEO-only) lists every entry's live enablement/cadence/last-cycle summary/opted-in projects with a toggle and a "run now" button.
|
||||||
|
|
||||||
The fourteen programs: **Printer**/`roadmap` (PO, weekly cron, org) — the pre-existing roadmap cycle, now LEARN-fed and Periscope-briefed; `propose_roadmap` → backlog tasks. **Pest Control**/`pest_control` (PO, weekly cron + a rework-rate-spike metric accelerator, project-scoped) — hunts latent bugs in the findings ledger/rework hotspots/`ponytail:` debt; `propose_bug_hunt` (evidence-required) → ≤5 backlog tasks. **Spackle**/`spackle` (PO, biweekly cron, project-scoped) — audits half-shipped surface area (routes with no panel, flags with no docs); `propose_gap_fill` → ≤5 backlog tasks. **Scales**/`scales` (PO, monthly cron, org) — reviews the live backlog against the charter; `propose_rebalance` → a held per-item plan whose approval MUTATES the live task in place (reprioritize or cancel), never creates one. **Dogfood**/`dogfood` (PO, event — release-publish hook or CEO run-now, project-scoped) — walks the product as a user; the one program whose spawn also mounts the Playwright MCP, task-scoped via `AgentOrchestrator._is_dogfood_spawn` (not a role-wide grant); `propose_friction_fixes` (walked-path evidence required) → ≤5 backlog tasks. **Periscope**/`periscope` (HoM, weekly cron, org) — market/competitor research with mandatory `source_url` citations; `propose_market_brief` → a held `ceo_report`, no task, and feeds forward into Printer's prompt. **Megaphone**/`megaphone` (HoM, 3-day cron, org) — the standing editorial calendar off shipped-task/CHANGELOG digests; `propose_editorial_post` → the existing X held-draft queue. **Mirror**/`mirror` (HoM, quarterly cron, project-scoped) — audits README/docs-site/website messaging against shipped reality; `propose_messaging_fixes` → ≤5 backlog docs tasks. **Barfly**/`barfly` (HoM, 2-day cron, org) — replies to adjacent X conversations from a screened candidate list (`injection_guard.screen_external_text`, candidate-id-bound so a reply can't target an invented tweet); `propose_conversation_replies` → held X drafts. **War Room**/`war_room` (HoM, event — release-publish hook `WarRoomEngine.open_for_release` or CEO run-now, org) — plans a 2-6 post campaign with strictly-ascending `publish_after` timestamps (guidance only, nothing auto-schedules); `propose_campaign` → held X drafts as one batch. **Coroner**/`coroner` (Auditor, event only — task bounced `revision_count>=3`, cancelled after work started, or budget-blocked, wired at `TaskService`'s bounce/cancel chokepoints + the orchestrator's budget-block path, org) — one autopsy at a time, no cron; `propose_postmortem` (`process_change.kind` one of `playbook`/`prompt_fix`/`conventions_rule`/`other`) → a held process-change item, or drafts straight into the pending-playbook queue when `kind='playbook'`. **Librarian**/`librarian` (Auditor, biweekly cron, org) — mines journals/learnings for undrafted repeated patterns; `propose_playbook_drafts` (the Auditor's one exception to "curates but doesn't draft") → 1-3 real DRAFT playbooks via `PlaybookService` directly, landing in the same curation queue a later Auditor spawn reviews. **Sentinel**/`sentinel` (Auditor, weekly cron, org) — waiver/findings/conventions/budget drift watch; `propose_quality_report` → a held `ceo_report`, no task. **Feature spotlight**/`x_feature` (HoM — unchanged, see "RoboCo X account" below). Every artifact is HELD; the CEO is the only path to materialization — nothing auto-starts, auto-posts, or auto-merges.
|
The fourteen programs: **Printer**/`roadmap` (PO, weekly cron, org) — the pre-existing roadmap cycle, now LEARN-fed and Periscope-briefed; `propose_roadmap` → backlog tasks. **Pest Control**/`pest_control` (PO, weekly cron + a rework-rate-spike metric accelerator, project-scoped) — hunts latent bugs in the findings ledger/rework hotspots/`ponytail:` debt; `propose_bug_hunt` (evidence-required) → ≤5 backlog tasks. **Spackle**/`spackle` (PO, biweekly cron, project-scoped) — audits half-shipped surface area (routes with no panel, flags with no docs); `propose_gap_fill` → ≤5 backlog tasks. **Scales**/`scales` (PO, monthly cron, org) — reviews the live backlog against the charter; `propose_rebalance` → a held per-item plan whose approval MUTATES the live task in place (reprioritize or cancel), never creates one. **Dogfood**/`dogfood` (PO, event — release-publish hook or CEO run-now, project-scoped) — walks the product as a user; the one program whose spawn also mounts the Playwright MCP, task-scoped via `AgentOrchestrator._is_dogfood_spawn` (not a role-wide grant); `propose_friction_fixes` (walked-path evidence required) → ≤5 backlog tasks. **Periscope**/`periscope` (HoM, weekly cron, org) — market/competitor research with mandatory `source_url` citations; `propose_market_brief` → a held `ceo_report`, no task, and feeds forward into Printer's prompt. **Megaphone**/`megaphone` (HoM, 3-day cron, org) — the standing editorial calendar off shipped-task/CHANGELOG digests; `propose_editorial_post` → the existing X held-draft queue. **Mirror**/`mirror` (HoM, quarterly cron, project-scoped) — audits README/docs-site/website messaging against shipped reality; `propose_messaging_fixes` → ≤5 backlog docs tasks. **Barfly**/`barfly` (HoM, 2-day cron, org) — engages adjacent X conversations from a screened candidate list (`injection_guard.screen_external_text`, candidate-id-bound so a draft can't target an invented tweet); `propose_conversation_replies` → held X drafts, materialized as **standalone link-posts** (commentary + the conversation's `/i/web/status/` URL, @handles stripped) — never threaded replies, since X's 2026-02-23 policy 403s programmatic replies into conversations that don't @mention the account on every non-Enterprise API tier (the same policy the mentions-poll `x_reply` drafts DO satisfy — they thread via `in_reply_to_tweet_id`; note a URL-bearing post costs $0.20 vs $0.015 on pay-per-use). Every post outcome (posted / post_failed) writes an `x_post.*` audit row at the `XPostService._post` chokepoint. **War Room**/`war_room` (HoM, event — release-publish hook `WarRoomEngine.open_for_release` or CEO run-now, org) — plans a 2-6 post campaign with strictly-ascending `publish_after` timestamps (guidance only, nothing auto-schedules); `propose_campaign` → held X drafts as one batch. **Coroner**/`coroner` (Auditor, event only — task bounced `revision_count>=3`, cancelled after work started, or budget-blocked, wired at `TaskService`'s bounce/cancel chokepoints + the orchestrator's budget-block path, org) — one autopsy at a time, no cron; `propose_postmortem` (`process_change.kind` one of `playbook`/`prompt_fix`/`conventions_rule`/`other`) → a held process-change item, or drafts straight into the pending-playbook queue when `kind='playbook'`. **Librarian**/`librarian` (Auditor, biweekly cron, org) — mines journals/learnings for undrafted repeated patterns; `propose_playbook_drafts` (the Auditor's one exception to "curates but doesn't draft") → 1-3 real DRAFT playbooks via `PlaybookService` directly, landing in the same curation queue a later Auditor spawn reviews. **Sentinel**/`sentinel` (Auditor, weekly cron, org) — waiver/findings/conventions/budget drift watch; `propose_quality_report` → a held `ceo_report`, no task. **Feature spotlight**/`x_feature` (HoM — unchanged, see "RoboCo X account" below). Every artifact is HELD; the CEO is the only path to materialization — nothing auto-starts, auto-posts, or auto-merges.
|
||||||
|
|
||||||
**Obsidian vault V1+V2 (default-off).** The org's human-readable memory palace as a rebuildable DB projection — tasks, journals, and A2A digests as wikilinked markdown — gated by `ROBOCO_OBSIDIAN_VAULT_ENABLED` + `ROBOCO_VAULT_PATH` (default off, but both compose files arm it `true`). `VaultWriter` (`roboco/services/vault_writer.py`) is a pure, DB-free materializer under `RoboCo/{Tasks/<project-slug>,Journals/<agent-slug>,A2A,Agents,Archive/<year>/Tasks/<project-slug>,Reports}/`; every note carries a stable `aliases: [<id8>]` so a title rename (or an archival move) never breaks a `[[id8|title]]` cross-link, and private journals are excluded. Four best-effort event seams (`TaskService.create`'s materialize-on-create, `TaskService`'s status-transition frontmatter touch, `JournalService`, `A2AService`) patch/append on the relevant transition — a vault write failure never blocks the real action; materialize-on-create means a task's note exists from the moment it's created, not just at curation/rebuild. `python -m roboco.vault rebuild` re-projects every entity from the DB (preserving an existing task's Auditor-authored `## Narrative`, archive-aware so an old terminal task lands directly in `Archive/<year>/`) and materializes the shipped `.obsidian/` config (Dataview, Kanban, graph groups) + `RoboCo/_meta/` dashboards + `.base` Bases views from `roboco/vault_assets/`; `relocate <path>` moves the tree, grafting `RoboCo/` into an existing personal vault without touching its own config. The Auditor gets a one-shot `curate_vault(task_id, narrative)` do-tool, spawned by the orchestrator on each completed root task, writing the `## Narrative` section a deterministic write otherwise leaves as a placeholder. A second, independently-gated `ROBOCO_VAULT_INTAKE_ENABLED` watcher (`VaultIntakeEngine`) turns `#roboco`-tagged notes under the vault's inbox folder into PENDING, Product-Owner-assigned board-review drafts (`source=vault_note`) — the identical board-review path a chat-confirmed draft takes, never straight into delivery. Extraction runs on the local model with a deterministic fallback; the note body is screened through `foundation/policy/injection_guard.screen_external_text` (the same untrusted-content envelope `XEngine` applies to X mentions — flags an injection-pattern line inline, never removes content) before it reaches the prompt or the fallback. Deduped per `(vault-relative path, content hash)` via `vault_seen_notes`, so an edit re-qualifies a previously-seen note — the same hashing convention (every RoboCo feedback callout stripped first, `foundation/policy/vault_notes.py`) is now shared with the KB engine below. V2 adds three things on top: a **drift janitor** (`services/vault_janitor.py`, `_vault_janitor_loop`) hourly-ticked but gated by a `RoboCo/_meta/.janitor_state.json` state file so real work (a daily changed-task re-projection + random-sample drift check + archival pass, each capped at 200/cycle and per-item isolated so one bad row never wedges the sweep) and a weekly org-report (`vault_report_enabled`, default true — `VaultWriter.write_org_report` from `MetricsService`/`UsageService`, best-effort CEO notification) each fire exactly once per elapsed period regardless of loop/restart cadence; **archival** (`vault_archive_days`, default 30, `0`=off) moving old terminal tasks' notes into `RoboCo/Archive/<year>/Tasks/<project>/` during the sweep, alias links making the move free and the shipped Dataview/graph assets `Archive/`-aware; and **KB ingest** (`vault_kb_enabled`, default false — NAS compose arms it `true`, registry compose leaves it `false`) embedding the CEO's own `RoboCo/Notes/` (config `vault_kb_dirs`, csv, load-time-validated against traversal/overlap with reserved projection dirs) into a new `IndexType.VAULT_NOTES` corpus via `_vault_kb_loop` (`services/vault_kb_engine.py`, default 900s), with every note re-checked for symlink/path-escape at read time and screened through the injection guard as a hard GATE (a flagged note is quarantined — skipped, logged, callout-marked, never embedded) rather than the intake watcher's screen-and-still-process posture — reaching `roboco_kb_search`, `MentorService`'s default domain, and `EvidenceRepo.similar_memory` (claim-time briefings, relevance-floored, labeled `vault_note`) so the CEO's own writing finally becomes fleet-retrievable institutional memory.
|
**Obsidian vault V1+V2 (default-off).** The org's human-readable memory palace as a rebuildable DB projection — tasks, journals, and A2A digests as wikilinked markdown — gated by `ROBOCO_OBSIDIAN_VAULT_ENABLED` + `ROBOCO_VAULT_PATH` (default off, but both compose files arm it `true`). `VaultWriter` (`roboco/services/vault_writer.py`) is a pure, DB-free materializer under `RoboCo/{Tasks/<project-slug>,Journals/<agent-slug>,A2A,Agents,Archive/<year>/Tasks/<project-slug>,Reports}/`; every note carries a stable `aliases: [<id8>]` so a title rename (or an archival move) never breaks a `[[id8|title]]` cross-link, and private journals are excluded. Four best-effort event seams (`TaskService.create`'s materialize-on-create, `TaskService`'s status-transition frontmatter touch, `JournalService`, `A2AService`) patch/append on the relevant transition — a vault write failure never blocks the real action; materialize-on-create means a task's note exists from the moment it's created, not just at curation/rebuild. `python -m roboco.vault rebuild` re-projects every entity from the DB (preserving an existing task's Auditor-authored `## Narrative`, archive-aware so an old terminal task lands directly in `Archive/<year>/`) and materializes the shipped `.obsidian/` config (Dataview, Kanban, graph groups) + `RoboCo/_meta/` dashboards + `.base` Bases views from `roboco/vault_assets/`; `relocate <path>` moves the tree, grafting `RoboCo/` into an existing personal vault without touching its own config. The Auditor gets a one-shot `curate_vault(task_id, narrative)` do-tool, spawned by the orchestrator on each completed root task, writing the `## Narrative` section a deterministic write otherwise leaves as a placeholder. A second, independently-gated `ROBOCO_VAULT_INTAKE_ENABLED` watcher (`VaultIntakeEngine`) turns `#roboco`-tagged notes under the vault's inbox folder into PENDING, Product-Owner-assigned board-review drafts (`source=vault_note`) — the identical board-review path a chat-confirmed draft takes, never straight into delivery. Extraction runs on the local model with a deterministic fallback; the note body is screened through `foundation/policy/injection_guard.screen_external_text` (the same untrusted-content envelope `XEngine` applies to X mentions — flags an injection-pattern line inline, never removes content) before it reaches the prompt or the fallback. Deduped per `(vault-relative path, content hash)` via `vault_seen_notes`, so an edit re-qualifies a previously-seen note — the same hashing convention (every RoboCo feedback callout stripped first, `foundation/policy/vault_notes.py`) is now shared with the KB engine below. V2 adds three things on top: a **drift janitor** (`services/vault_janitor.py`, `_vault_janitor_loop`) hourly-ticked but gated by a `RoboCo/_meta/.janitor_state.json` state file so real work (a daily changed-task re-projection + random-sample drift check + archival pass, each capped at 200/cycle and per-item isolated so one bad row never wedges the sweep) and a weekly org-report (`vault_report_enabled`, default true — `VaultWriter.write_org_report` from `MetricsService`/`UsageService`, best-effort CEO notification) each fire exactly once per elapsed period regardless of loop/restart cadence; **archival** (`vault_archive_days`, default 30, `0`=off) moving old terminal tasks' notes into `RoboCo/Archive/<year>/Tasks/<project>/` during the sweep, alias links making the move free and the shipped Dataview/graph assets `Archive/`-aware; and **KB ingest** (`vault_kb_enabled`, default false — NAS compose arms it `true`, registry compose leaves it `false`) embedding the CEO's own `RoboCo/Notes/` (config `vault_kb_dirs`, csv, load-time-validated against traversal/overlap with reserved projection dirs) into a new `IndexType.VAULT_NOTES` corpus via `_vault_kb_loop` (`services/vault_kb_engine.py`, default 900s), with every note re-checked for symlink/path-escape at read time and screened through the injection guard as a hard GATE (a flagged note is quarantined — skipped, logged, callout-marked, never embedded) rather than the intake watcher's screen-and-still-process posture — reaching `roboco_kb_search`, `MentorService`'s default domain, and `EvidenceRepo.similar_memory` (claim-time briefings, relevance-floored, labeled `vault_note`) so the CEO's own writing finally becomes fleet-retrievable institutional memory.
|
||||||
|
|
||||||
|
|||||||
@@ -73,9 +73,13 @@ def _exploration_description(candidate_count: int) -> str:
|
|||||||
"Review the screened candidate X conversations already gathered for "
|
"Review the screened candidate X conversations already gathered for "
|
||||||
"you on this task — search results where RoboCo is relevant but "
|
"you on this task — search results where RoboCo is relevant but "
|
||||||
"unmentioned. Pick up to "
|
"unmentioned. Pick up to "
|
||||||
f"{candidate_count} worth replying to and draft replies via "
|
f"{candidate_count} worth engaging and draft commentary via "
|
||||||
"propose_conversation_replies(). Only reply to a REAL candidate "
|
"propose_conversation_replies(). Only target a REAL candidate "
|
||||||
"already on this task — never invent a tweet."
|
"already on this task — never invent a tweet. Each draft posts as a "
|
||||||
|
"STANDALONE post carrying a link to the conversation (X forbids "
|
||||||
|
"programmatic replies into unmentioning threads), so write "
|
||||||
|
"commentary that stands on its own with no @handles — assume the "
|
||||||
|
"reader sees your post first and the linked thread second."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -168,10 +168,20 @@ def _hom_voice(product_name: str) -> str:
|
|||||||
|
|
||||||
def _clamp_tweet(text: str) -> str:
|
def _clamp_tweet(text: str) -> str:
|
||||||
"""Collapse whitespace and hard-enforce the 280-char limit."""
|
"""Collapse whitespace and hard-enforce the 280-char limit."""
|
||||||
|
return _clamp_to(text, MAX_TWEET_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_to(text: str, limit: int) -> str:
|
||||||
collapsed = " ".join(text.split())
|
collapsed = " ".join(text.split())
|
||||||
if len(collapsed) <= MAX_TWEET_CHARS:
|
if len(collapsed) <= limit:
|
||||||
return collapsed
|
return collapsed
|
||||||
return collapsed[: MAX_TWEET_CHARS - 1].rstrip() + "…"
|
return collapsed[: limit - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_handles(text: str) -> str:
|
||||||
|
"""Drop @-prefixes: X (2026-02-28) rejects @mentions inside plain posts on
|
||||||
|
non-Enterprise API tiers, and Barfly link-posts post plain, not as replies."""
|
||||||
|
return re.sub(r"@(\w)", r"\1", text)
|
||||||
|
|
||||||
|
|
||||||
def _fallback_release_body(version: str, product_name: str) -> str:
|
def _fallback_release_body(version: str, product_name: str) -> str:
|
||||||
@@ -1233,16 +1243,27 @@ class XEngine(BaseService):
|
|||||||
reply_body: str,
|
reply_body: str,
|
||||||
rationale: str,
|
rationale: str,
|
||||||
) -> TaskTable:
|
) -> TaskTable:
|
||||||
"""Materialize ONE Barfly-authored reply draft through the shared
|
"""Materialize ONE Barfly-authored draft through the shared
|
||||||
``_originate_post`` chokepoint (source=X_BARFLY_SOURCE) — called once
|
``_originate_post`` chokepoint (source=X_BARFLY_SOURCE) — called once
|
||||||
per approved-shape item from the ``propose_conversation_replies``
|
per approved-shape item from the ``propose_conversation_replies``
|
||||||
content verb (N per cycle, unlike ``materialize_feature_spotlight``'s
|
content verb (N per cycle, unlike ``materialize_feature_spotlight``'s
|
||||||
single call). Does NOT touch ``exploration_task``'s own status — the
|
single call). Does NOT touch ``exploration_task``'s own status — the
|
||||||
verb completes the exploration once, after every item in the batch
|
verb completes the exploration once, after every item in the batch
|
||||||
has materialized."""
|
has materialized.
|
||||||
|
|
||||||
|
The draft is a standalone LINK-POST (commentary + the conversation
|
||||||
|
URL), never a threaded reply: X's 2026-02-23 policy 403s programmatic
|
||||||
|
replies into conversations that don't mention the account — Barfly's
|
||||||
|
entire discovery surface. The ``/i/web/status/`` URL form needs no
|
||||||
|
author handle, and handles are stripped from the commentary (plain
|
||||||
|
posts with @mentions are rejected on our tier too)."""
|
||||||
|
link = f"https://x.com/i/web/status/{candidate.get('id')}"
|
||||||
|
commentary = _clamp_to(
|
||||||
|
_strip_handles(reply_body), MAX_TWEET_CHARS - len(link) - 1
|
||||||
|
)
|
||||||
task = await self._originate_post(
|
task = await self._originate_post(
|
||||||
title=f"X reply: conversation {candidate.get('id')}",
|
title=f"X link-post: conversation {candidate.get('id')}",
|
||||||
body=_clamp_tweet(reply_body),
|
body=f"{commentary} {link}",
|
||||||
source=X_BARFLY_SOURCE,
|
source=X_BARFLY_SOURCE,
|
||||||
project_id=cast("UUID", exploration_task.project_id),
|
project_id=cast("UUID", exploration_task.project_id),
|
||||||
)
|
)
|
||||||
@@ -1443,6 +1464,7 @@ class XEngine(BaseService):
|
|||||||
task_id=str(post_task.id),
|
task_id=str(post_task.id),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
body = self._restore_barfly_link(post_task, body)
|
||||||
task = await self._originate_post(
|
task = await self._originate_post(
|
||||||
title=post_task.title or "X post revision",
|
title=post_task.title or "X post revision",
|
||||||
body=body,
|
body=body,
|
||||||
@@ -1492,6 +1514,23 @@ class XEngine(BaseService):
|
|||||||
builder = _REDRAFT_CONTEXT_BUILDERS.get(post_task.source)
|
builder = _REDRAFT_CONTEXT_BUILDERS.get(post_task.source)
|
||||||
return builder(post_task) if builder is not None else ""
|
return builder(post_task) if builder is not None else ""
|
||||||
|
|
||||||
|
def _restore_barfly_link(self, post_task: TaskTable, body: str) -> str:
|
||||||
|
"""A barfly redraft must keep its conversation link — the local
|
||||||
|
model's revision may drop or mangle the URL the link-post exists to
|
||||||
|
carry. Re-append the canonical link (and re-strip handles) whenever
|
||||||
|
it's missing; non-barfly sources pass through untouched."""
|
||||||
|
if post_task.source != X_BARFLY_SOURCE:
|
||||||
|
return body
|
||||||
|
tweet_id = (markers.get_barfly_reply_ref(post_task) or {}).get("tweet_id")
|
||||||
|
if not tweet_id:
|
||||||
|
return body
|
||||||
|
link = f"https://x.com/i/web/status/{tweet_id}"
|
||||||
|
stripped = _strip_handles(body)
|
||||||
|
if link in stripped:
|
||||||
|
return stripped
|
||||||
|
commentary = _clamp_to(stripped, MAX_TWEET_CHARS - len(link) - 1)
|
||||||
|
return f"{commentary} {link}"
|
||||||
|
|
||||||
def _carry_redraft_markers(self, new_task: TaskTable, post_task: TaskTable) -> None:
|
def _carry_redraft_markers(self, new_task: TaskTable, post_task: TaskTable) -> None:
|
||||||
"""Copy the rejected draft's source-specific reference marker onto the
|
"""Copy the rejected draft's source-specific reference marker onto the
|
||||||
redraft so it renders identically in the panel queue/history and is
|
redraft so it renders identically in the panel queue/history and is
|
||||||
|
|||||||
@@ -1214,7 +1214,37 @@ async def test_materialize_barfly_reply_holds_draft_through_originate_post(
|
|||||||
assert ref["author_handle"] == "someone"
|
assert ref["author_handle"] == "someone"
|
||||||
assert ref["rationale"] == "Directly answers their question."
|
assert ref["rationale"] == "Directly answers their question."
|
||||||
body = markers.get_x_draft_body(draft)
|
body = markers.get_x_draft_body(draft)
|
||||||
assert body == "That's exactly what request_sandbox() gives you."
|
assert body == (
|
||||||
|
"That's exactly what request_sandbox() gives you. "
|
||||||
|
"https://x.com/i/web/status/111"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_materialize_barfly_reply_strips_handles(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Link-posts are plain posts: @handles are stripped (X rejects mentions
|
||||||
|
in plain posts on non-Enterprise tiers)."""
|
||||||
|
await _seed(db_session)
|
||||||
|
project = (
|
||||||
|
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
|
||||||
|
).scalar_one()
|
||||||
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
|
|
||||||
|
class _Exploration:
|
||||||
|
project_id = project.id
|
||||||
|
|
||||||
|
draft = await engine.materialize_barfly_reply(
|
||||||
|
exploration_task=cast("TaskTable", _Exploration()),
|
||||||
|
candidate=_BARFLY_CANDIDATE,
|
||||||
|
reply_body="Great point @someone — @roboco_ai does this.",
|
||||||
|
rationale="whatever",
|
||||||
|
)
|
||||||
|
body = markers.get_x_draft_body(draft)
|
||||||
|
assert body is not None
|
||||||
|
assert "@" not in body
|
||||||
|
assert body.endswith("https://x.com/i/web/status/111")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1426,7 +1456,12 @@ async def test_redraft_from_rejection_barfly_carries_reply_ref(
|
|||||||
ref = markers.get_barfly_reply_ref(redraft)
|
ref = markers.get_barfly_reply_ref(redraft)
|
||||||
assert ref is not None
|
assert ref is not None
|
||||||
assert ref["tweet_id"] == "111"
|
assert ref["tweet_id"] == "111"
|
||||||
assert markers.get_x_draft_body(redraft) == "A sharper reply."
|
# The redraft re-appends the conversation link the link-post exists to
|
||||||
|
# carry (the revision model was mocked without it).
|
||||||
|
assert (
|
||||||
|
markers.get_x_draft_body(redraft)
|
||||||
|
== "A sharper reply. https://x.com/i/web/status/111"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user