feat: unify card runtime and event-driven web ui
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s

This commit is contained in:
kacper 2026-04-06 15:42:53 -04:00
parent 0edf8c3fef
commit 4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions

157
inbox_service.py Normal file
View file

@ -0,0 +1,157 @@
from __future__ import annotations
import importlib.util
import sys
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
NotificationEventFactory = Callable[[dict[str, Any]], dict[str, Any] | None]
RunNanobotTurn = Callable[..., Awaitable[dict[str, Any]]]
class InboxService:
def __init__(self, *, repo_dir: Path, inbox_dir: Path) -> None:
self._repo_dir = repo_dir
self._inbox_dir = inbox_dir
self._module: Any | None = None
def _load_module(self):
if self._module is not None:
return self._module
script_path = self._repo_dir / "scripts" / "inbox_board.py"
if not script_path.exists():
raise RuntimeError(f"missing helper script: {script_path}")
spec = importlib.util.spec_from_file_location("nanobot_web_inbox_board", script_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"failed to load helper script: {script_path}")
module = importlib.util.module_from_spec(spec)
sys.modules.setdefault("nanobot_web_inbox_board", module)
spec.loader.exec_module(module)
module.ensure_inbox(self._inbox_dir)
self._module = module
return module
def list_items(
self,
*,
status_filter: str | None,
kind_filter: str | None,
include_closed: bool,
limit: int | None,
tags: list[str],
) -> list[dict[str, Any]]:
inbox_board = self._load_module()
items = inbox_board.filter_items(
inbox_board.collect_items(self._inbox_dir),
status=status_filter,
kind=kind_filter,
tags=tags,
include_closed=include_closed,
)
if limit is not None:
items = items[:limit]
return [item.to_dict() for item in items]
def open_items(self, limit: int = 4) -> list[dict[str, Any]]:
return self.list_items(
status_filter=None,
kind_filter=None,
include_closed=False,
limit=limit,
tags=[],
)
def capture_item(
self,
*,
title: str,
text: str,
kind: str,
source: str,
due: str,
body: str,
session_id: str,
tags: list[str],
confidence: int | float | None,
) -> dict[str, Any]:
inbox_board = self._load_module()
created_path = inbox_board.create_item(
self._inbox_dir,
title=title.strip(),
kind=kind,
source=source,
confidence=confidence,
suggested_due=due.strip(),
tags=tags,
body=body.strip(),
raw_text=text.strip(),
metadata={"source_session": session_id.strip()} if session_id.strip() else None,
)
return inbox_board.parse_item(created_path).to_dict()
def dismiss_item(self, item: str) -> dict[str, Any]:
inbox_board = self._load_module()
updated_path = inbox_board.dismiss_item(self._inbox_dir, item)
return inbox_board.parse_item(updated_path).to_dict()
async def accept_item_as_task(
self,
*,
item: str,
lane: str,
title: str,
due: str,
body_text: str | None,
tags: list[str] | None,
run_nanobot_turn: RunNanobotTurn,
notification_to_event: NotificationEventFactory,
) -> dict[str, Any]:
inbox_board = self._load_module()
parsed_item = inbox_board.parse_item(inbox_board.resolve_item_path(self._inbox_dir, item))
item_path = str(parsed_item.path)
instruction_lines = [
"This is a UI inbox action. Groom the inbox item into a task if it is actionable.",
f"Inbox item path: {item_path}",
"Use the inbox_board tool and the task_helper_card tool.",
"First inspect the item with action=show.",
"Then improve the title, description, tags, or due date only if the capture supports it.",
"If it is actionable, accept it into the task board.",
"After accepting it, decide whether the task would be easier to execute immediately with a linked helper card.",
"Create a helper card whenever it would clearly reduce friction for the user instead of making them search or prepare things manually.",
"Use these patterns by default when they fit:",
"- watch/learn -> create a watch helper card",
"- read/research -> create a reading/reference helper card",
"- go somewhere -> create a travel/map helper card",
"- buy/order -> create a shopping/product helper card",
"- call/email/reach out -> create an outreach draft helper card",
"If you have enough information to search or enrich the helper card, do that first.",
"If not, still create the fallback helper card from the task so the user has something actionable in the feed.",
"Prefer backlog unless it is clearly committed or in-progress work.",
"Do not invent details and do not ask follow-up questions.",
"Reply with a short confirmation of what you did, including whether you created a helper card.",
]
if title.strip():
instruction_lines.append(f"Title hint from UI: {title.strip()}")
if body_text is not None and body_text.rstrip():
instruction_lines.append(f"Description hint from UI: {body_text.rstrip()}")
if due.strip():
instruction_lines.append(f"Due hint from UI: {due.strip()}")
if tags:
instruction_lines.append(f"Tag hint from UI: {', '.join(str(tag) for tag in tags)}")
if lane and lane != "backlog":
instruction_lines.append(f"Preferred destination lane: {lane}")
return await run_nanobot_turn(
"\n".join(instruction_lines),
chat_id="inbox-groomer",
metadata={
"source": "web-inbox-card",
"ui_action": "accept_task",
"inbox_item": item_path,
},
timeout_seconds=90.0,
notification_to_event=notification_to_event,
)