feat: polish life os cards and voice stack

This commit is contained in:
kacper 2026-03-24 08:54:47 -04:00
parent 66362c7176
commit 0edf8c3fef
21 changed files with 3681 additions and 502 deletions

157
app.py
View file

@ -38,6 +38,10 @@ _JSONRPC_VERSION = "2.0"
_TOOL_JOB_TIMEOUT_SECONDS = 300.0
_TOOL_JOB_RETENTION_SECONDS = 15 * 60
_NANOBOT_API_STREAM_LIMIT = 2 * 1024 * 1024
_TTS_SENTENCE_BREAK_RE = re.compile(r"(?<=[.!?])\s+")
_TTS_CLAUSE_BREAK_RE = re.compile(r"(?<=[,;:])\s+")
_TTS_SEGMENT_TARGET_CHARS = 180
_TTS_SEGMENT_MAX_CHARS = 260
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
@ -104,6 +108,19 @@ def _decode_object(raw: str) -> dict[str, Any] | None:
return payload if isinstance(payload, dict) else None
def _parse_iso_datetime(raw: str) -> datetime | None:
value = raw.strip()
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
async def _read_json_request(request: Request) -> dict[str, Any]:
try:
payload = await request.json()
@ -114,6 +131,76 @@ async def _read_json_request(request: Request) -> dict[str, Any]:
return payload
def _wrap_tts_words(text: str, max_chars: int) -> list[str]:
words = text.split()
if not words:
return []
chunks: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if len(candidate) <= max_chars:
current = candidate
continue
chunks.append(current)
current = word
chunks.append(current)
return chunks
def _chunk_tts_text(text: str) -> list[str]:
normalized = text.replace("\r\n", "\n").strip()
if not normalized:
return []
chunks: list[str] = []
paragraphs = [part.strip() for part in re.split(r"\n{2,}", normalized) if part.strip()]
for paragraph in paragraphs:
compact = re.sub(r"\s+", " ", paragraph).strip()
if not compact:
continue
sentences = [
sentence.strip()
for sentence in _TTS_SENTENCE_BREAK_RE.split(compact)
if sentence.strip()
]
if not sentences:
sentences = [compact]
current = ""
for sentence in sentences:
parts = [sentence]
if len(sentence) > _TTS_SEGMENT_MAX_CHARS:
parts = [
clause.strip()
for clause in _TTS_CLAUSE_BREAK_RE.split(sentence)
if clause.strip()
] or [sentence]
for part in parts:
if len(part) > _TTS_SEGMENT_MAX_CHARS:
if current:
chunks.append(current)
current = ""
chunks.extend(_wrap_tts_words(part, _TTS_SEGMENT_MAX_CHARS))
continue
candidate = part if not current else f"{current} {part}"
if len(candidate) <= _TTS_SEGMENT_TARGET_CHARS:
current = candidate
continue
if current:
chunks.append(current)
current = part
if current:
chunks.append(current)
return chunks or [re.sub(r"\s+", " ", normalized).strip()]
def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None:
card_id = _normalize_card_id(str(raw.get("id", "")))
if not card_id:
@ -158,6 +245,7 @@ def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None:
"template_state": template_state,
"context_summary": str(raw.get("context_summary", "")),
"chat_id": str(raw.get("chat_id", "web") or "web"),
"snooze_until": str(raw.get("snooze_until", "") or ""),
"created_at": str(raw.get("created_at", "")),
"updated_at": str(raw.get("updated_at", "")),
}
@ -287,6 +375,7 @@ def _sort_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _load_cards() -> list[dict[str, Any]]:
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
cards: list[dict[str, Any]] = []
now = datetime.now(timezone.utc)
for instance_dir in CARD_INSTANCES_DIR.iterdir():
if not instance_dir.is_dir():
continue
@ -295,6 +384,9 @@ def _load_cards() -> list[dict[str, Any]]:
continue
if card.get("state") == "archived":
continue
snooze_until = _parse_iso_datetime(str(card.get("snooze_until", "") or ""))
if snooze_until is not None and snooze_until > now:
continue
cards.append(card)
return _sort_cards(cards)
@ -817,6 +909,62 @@ async def delete_card(card_id: str) -> JSONResponse:
return JSONResponse({"status": "ok"})
@app.post("/cards/{card_id}/snooze")
async def snooze_card(card_id: str, request: Request) -> JSONResponse:
if not _normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
try:
payload = await _read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
until_raw = str(payload.get("until", "")).strip()
until_dt = _parse_iso_datetime(until_raw)
if until_dt is None:
return JSONResponse({"error": "until must be a valid ISO datetime"}, status_code=400)
card = _load_card(card_id)
if card is None:
return JSONResponse({"error": "card not found"}, status_code=404)
card["snooze_until"] = until_dt.isoformat()
card["updated_at"] = datetime.now(timezone.utc).isoformat()
persisted = _write_card(card)
if persisted is None:
return JSONResponse({"error": "failed to snooze card"}, status_code=500)
return JSONResponse({"status": "ok", "card": persisted})
@app.post("/cards/{card_id}/state")
async def update_card_state(card_id: str, request: Request) -> JSONResponse:
if not _normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
try:
payload = await _read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
template_state = payload.get("template_state")
if not isinstance(template_state, dict):
return JSONResponse({"error": "template_state must be an object"}, status_code=400)
card = _load_card(card_id)
if card is None:
return JSONResponse({"error": "card not found"}, status_code=404)
if str(card.get("kind", "")) != "text":
return JSONResponse({"error": "only text cards support template_state"}, status_code=400)
card["template_state"] = template_state
card["updated_at"] = datetime.now(timezone.utc).isoformat()
persisted = _write_card(card)
if persisted is None:
return JSONResponse({"error": "failed to update card state"}, status_code=500)
return JSONResponse({"status": "ok", "card": persisted})
@app.get("/templates")
async def get_templates() -> JSONResponse:
return JSONResponse(_list_templates())
@ -975,8 +1123,15 @@ async def _sender_loop(
) -> None:
while True:
event = await queue.get()
if event.role == "nanobot-tts-partial":
await voice_session.queue_output_text(event.text, partial=True)
continue
if event.role == "nanobot-tts-flush":
await voice_session.flush_partial_output_text()
continue
if event.role == "nanobot-tts":
await voice_session.queue_output_text(event.text)
for segment in _chunk_tts_text(event.text):
await voice_session.queue_output_text(segment)
continue
typed_event = _to_typed_message(event.to_dict())
if typed_event is None: