feat(backend): conversation tasks are tasks on clients with no Suggested surface

Merged
MD
2mo ago
+222-83

Summary

Since #11974 (Aug 23), action items extracted from a conversation stop existing for anyone on a phone. Two changes landed together in that PR and compound:

  1. The write disappeared. _save_action_items proposes a Candidate per item and writes no action_item.
  2. The prompt got looser. task_intelligence_capture = capture_enabled(uid) is bool(uid) — true for everyone — and that flag switches the extraction prompt from "Only extract action items that are truly important and need tracking. When in doubt, DON'T extract" to "Always extract concrete explicit commands, direct requests, and clear commitments; Candidate policy decides whether they become tasks or quiet suggestions."

Filtering moved from the extractor into a review queue. Desktop has that queue (SuggestedTasksStore). Mobile has none — the Flutter app never calls /v1/candidates, and its generated wire models for candidates are imported by no file. So on a phone every proposal is invisible and expires after SUGGESTION_TTL = 2 days. Even "Hey Omi, remind me to call the dentist" only earns a suggestion: the policy comment says so directly — "A command heard in ambient audio is still a model's reading of speech, not a user gesture against a surface. It proposes; it does not create."

What changed

Capture is now scoped by conversation origin, using the seam process_conversation.py already uses twice for per-client policy (trial paywall, deferred processing):

  • Desktop conversations keep proposing Candidates into the Suggested surface. Unchanged.
  • Every other client — omi, friend, phone, phone_call, apple_watch, sdcard, and any source added later — writes its extracted items as tasks. The same predicate picks the prompt, so those conversations get the conservative filter back: explicit commands and the few concrete commitments, or nothing at all. Restoring the writer also restores what it drove — due-date notifications and task-integration sync.
  • Reprocessing replaces that conversation's items, as it did before #11974.

Screen capture is untouched. It posts to /v1/candidates from TaskAssistant.swift, never enters this path, and stays suggestion-only — the guard rules that hold it there are unchanged.

No app release needed. The items land in /v1/action-items, which the Tasks page already reads.

Product invariants

INV-TASK-2 — rescoped, not deleted. It was written against a measured flood: ~100 Candidates/day on a dogfood account, four code paths auto-writing tasks. That volume is a property of screen capture, which samples continuously; conversation extraction is bounded by the conversation and gated by a prompt that returns nothing when nothing qualifies. The invariant now reads: capture proposes only where a Suggested surface exists to review the proposal on. Its own MUST NOT — "Admit a proposal the Suggested surface will not show. A stored, invisible Candidate is a dropped one" — is what mobile has been violating for a week; this enforces it one level up, by not proposing there at all.

INV-MEM-4 (canonical promotion is the sole Long-term authority) is matched by path: this diff touches process_conversation.py, which also hosts memory postprocessing. It changes no memory intake, consolidation, or promotion path — only how a conversation's action items are persisted.

The guard changes shape with it. The static rule banning a writer inside _save_action_items cannot express which conversations propose, and a source-string check would be a tripwire rather than coverage, so it is replaced by behavioural tests that run both paths through the real function. The three facts the guard can prove — no creating outcome in the policy, no accept inside capture, no accept on the delivery client — plus the manifest rule that a shared-policy source declares no create anchor, all stay. The writer is registered as its own source (mobile_conversation_extraction, direct_command), so it is visible to that manifest rule rather than hidden from it.

Verification

Every command below was run locally in this worktree.

SuiteResult
tests/unit/test_backend_candidate_capture.pyboth paths — desktop proposes and never writes; omi/phone/phone_call/apple_watch/sdcard write and never propose; reprocess replaces
testing/e2e/test_conversation_processing.py5 passed — hermetic lifecycle drives process_conversation for an omi conversation and reads the written item back out of action_items
tests/unit/test_process_conversation_usage_context.py51 passed
tests/unit/test_task_intelligence_contract_freeze.py29 passed
tests/unit/test_conversation_suggestion_visibility.py6 passed
tests/unit/test_surface_parity_capture.py, test_listen_parity_capture.py14 passed
tests/unit/test_upstream_boundary.py5 passed
.github/scripts/test_check_task_capture_authority.py8 passed; guard exits 0 on this checkout

Environment note for anyone reproducing: the checked-in backend venv is missing langchain-anthropic, which requirements.txt pins at 1.1.0. Every task-intelligence test file fails collection without it, on main too. I ran with it supplied on PYTHONPATH rather than mutating the venv.

Not verified: no device run. The behaviour is backend-side and covered by the hermetic lifecycle test, but seeing tasks appear on a phone after a real conversation is worth doing before this reaches users.

Follow-up, deliberately not here

Desktop conversations still propose, so their tasks are reviewable only on desktop. Surfacing those suggestions on mobile is the next piece; when it lands, the split this PR introduces can close in whichever direction the data supports.

Line-Count-Exception: backend/utils/conversations/process_conversation.py | 2476 -> 2561 | restores the action-item writer #11974 deleted from this file; extracting it to a new module would still grow the file and would spread one conversation-postprocess decision across two places