diff --git a/backend/app/api/bitchat_api.py b/backend/app/api/bitchat_api.py
new file mode 100644
index 0000000..e30df00
--- /dev/null
+++ b/backend/app/api/bitchat_api.py
@@ -0,0 +1,269 @@
+"""BitChat & Decentralized Mesh Transport API.
+
+Endpoints:
+- GET /api/network/mesh/peers — List discovered LAN peers & services
+- POST /api/network/mesh/announce — Trigger local subnet UDP beacon announcement
+- GET /api/network/bitchat/messages — Fetch recent chat history by channel
+- POST /api/network/bitchat/send — Send chat message, alert Feed Activity Pod, broadcast
+- POST /api/network/bitchat/save-to-binder — Save selected messages as markdown evidence into binder
+- POST /api/network/bitchat/convert-to-task — Promote message to sovereign task
+- GET /api/network/bitchat/ws — Real-time WebSocket conduit
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from typing import Any, Dict, Set
+
+import aiohttp
+from aiohttp import web
+
+from app.services.bitchat_store import get_bitchat_store
+from app.services.mesh_transport import get_mesh_discovery, get_mesh_registry
+
+log = logging.getLogger("ucore.bitchat_api")
+
+# Set of active WebSocket connections for real-time chat broadcasts
+_active_websockets: Set[web.WebSocketResponse] = set()
+
+
+async def broadcast_ws(event: Dict[str, Any]) -> None:
+ """Broadcast an event dictionary to all active WebSocket clients."""
+ if not _active_websockets:
+ return
+ payload_str = json.dumps(event)
+ stale = []
+ for ws in list(_active_websockets):
+ try:
+ if not ws.closed:
+ await ws.send_str(payload_str)
+ else:
+ stale.append(ws)
+ except Exception as exc:
+ log.debug("WebSocket broadcast error: %s", exc)
+ stale.append(ws)
+
+ for ws in stale:
+ _active_websockets.discard(ws)
+
+
+async def handle_get_peers(request: web.Request) -> web.Response:
+ """GET /api/network/mesh/peers — Get discovered mesh peers."""
+ service_type = request.query.get("service_type")
+ include_stale = request.query.get("include_stale", "false").lower() in ("true", "1")
+ registry = get_mesh_registry()
+ peers = registry.get_peers(service_type=service_type, include_stale=include_stale)
+ return web.json_response({
+ "status": "ok",
+ "local_peer_id": registry.local_peer_id,
+ "local_node_name": registry.local_node_name,
+ "peers": peers,
+ })
+
+
+async def handle_mesh_announce(request: web.Request) -> web.Response:
+ """POST /api/network/mesh/announce — Send broadcast beacon to local subnet."""
+ discovery = get_mesh_discovery()
+ success = discovery.send_broadcast_beacon()
+ return web.json_response({
+ "status": "ok",
+ "announced": success,
+ "peer_id": discovery.registry.local_peer_id,
+ "node_name": discovery.registry.local_node_name,
+ })
+
+
+async def handle_get_messages(request: web.Request) -> web.Response:
+ """GET /api/network/bitchat/messages — Retrieve messages for a channel."""
+ channel = request.query.get("channel", "#general")
+ limit_str = request.query.get("limit", "50")
+ since = request.query.get("since")
+ try:
+ limit = int(limit_str)
+ except ValueError:
+ limit = 50
+
+ store = get_bitchat_store()
+ messages = store.get_messages(channel=channel, limit=limit, since=since)
+ return web.json_response({
+ "status": "ok",
+ "channel": channel,
+ "messages": messages,
+ })
+
+
+async def handle_send_message(request: web.Request) -> web.Response:
+ """POST /api/network/bitchat/send — Record message and broadcast to peers."""
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
+
+ sender_name = data.get("sender_name", "").strip() or "Anonymous"
+ content = data.get("content", "").strip()
+ if not content:
+ return web.json_response({"error": "Content cannot be empty"}, status=400)
+
+ channel = data.get("channel", "#general").strip() or "#general"
+ sender_peer_id = data.get("sender_peer_id")
+ recipient_id = data.get("recipient_id")
+ metadata = data.get("metadata", {})
+
+ store = get_bitchat_store()
+ record = store.send_message(
+ sender_name=sender_name,
+ content=content,
+ channel=channel,
+ sender_peer_id=sender_peer_id,
+ recipient_id=recipient_id,
+ metadata=metadata,
+ )
+
+ # Real-time WebSocket broadcast
+ await broadcast_ws({
+ "type": "chat_message",
+ "channel": channel,
+ "message": record,
+ })
+
+ return web.json_response({
+ "status": "ok",
+ "message": record,
+ })
+
+
+async def handle_save_to_binder(request: web.Request) -> web.Response:
+ """POST /api/network/bitchat/save-to-binder — Save messages as markdown evidence."""
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
+
+ msg_ids = data.get("msg_ids")
+ if not msg_ids or not isinstance(msg_ids, list):
+ return web.json_response({"error": "msg_ids list is required"}, status=400)
+
+ binder_name = data.get("binder", "").strip() or "Sandbox"
+ title = data.get("title", "").strip() or "Discussion Evidence"
+
+ store = get_bitchat_store()
+ result = store.save_to_binder(
+ msg_ids=msg_ids,
+ binder_name=binder_name,
+ title=title,
+ )
+
+ if not result.get("ok"):
+ return web.json_response(result, status=400)
+
+ return web.json_response(result)
+
+
+async def handle_convert_to_task(request: web.Request) -> web.Response:
+ """POST /api/network/bitchat/convert-to-task — Promote message to sovereign task."""
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
+
+ msg_id = data.get("msg_id", "").strip()
+ if not msg_id:
+ return web.json_response({"error": "msg_id is required"}, status=400)
+
+ board = str(data.get("board") or "inbox")
+ priority = str(data.get("priority") or "medium")
+ binder = str(data.get("binder") or "Sandbox")
+ due_date = data.get("due_date")
+ sync_apple_reminders = bool(data.get("sync_apple_reminders", False))
+
+ store = get_bitchat_store()
+ result = store.convert_message_to_task(
+ msg_id=msg_id,
+ board=board,
+ priority=priority,
+ binder=binder,
+ due_date=due_date,
+ sync_apple_reminders=sync_apple_reminders,
+ )
+
+ if not result.get("ok"):
+ return web.json_response(result, status=404 if "not found" in result.get("error", "").lower() else 400)
+
+ return web.json_response(result)
+
+
+async def handle_bitchat_ws(request: web.Request) -> web.WebSocketResponse:
+ """GET /api/network/bitchat/ws — WebSocket connection for real-time BitChat."""
+ ws = web.WebSocketResponse()
+ await ws.prepare(request)
+
+ _active_websockets.add(ws)
+ log.info("BitChat WebSocket connected (total: %d)", len(_active_websockets))
+
+ # Send initial welcome / handshake
+ registry = get_mesh_registry()
+ await ws.send_str(json.dumps({
+ "type": "handshake",
+ "peer_id": registry.local_peer_id,
+ "node_name": registry.local_node_name,
+ "timestamp": asyncio.get_event_loop().time(),
+ }))
+
+ store = get_bitchat_store()
+
+ try:
+ async for msg in ws:
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ try:
+ payload = json.loads(msg.data)
+ except Exception:
+ continue
+
+ msg_type = payload.get("type")
+ if msg_type == "ping":
+ await ws.send_str(json.dumps({"type": "pong"}))
+ elif msg_type in ("chat_message", "send_message"):
+ content = str(payload.get("content") or "").strip()
+ if content:
+ sender_name = str(payload.get("sender_name") or registry.local_node_name)
+ channel = str(payload.get("channel") or "#general")
+ record = store.send_message(
+ sender_name=sender_name,
+ content=content,
+ channel=channel,
+ sender_peer_id=registry.local_peer_id,
+ recipient_id=payload.get("recipient_id"),
+ metadata=payload.get("metadata") or {},
+ )
+ await broadcast_ws({
+ "type": "chat_message",
+ "channel": channel,
+ "message": record,
+ })
+ elif msg_type == "announce":
+ discovery = get_mesh_discovery()
+ discovery.send_broadcast_beacon()
+ peers = registry.get_peers()
+ await ws.send_str(json.dumps({
+ "type": "peer_list",
+ "peers": peers,
+ }))
+ elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
+ break
+ finally:
+ _active_websockets.discard(ws)
+ log.info("BitChat WebSocket disconnected (remaining: %d)", len(_active_websockets))
+
+ return ws
+
+
+def register_bitchat_routes(app: web.Application) -> None:
+ """Register BitChat & Mesh discovery routes."""
+ app.router.add_get("/api/network/mesh/peers", handle_get_peers)
+ app.router.add_post("/api/network/mesh/announce", handle_mesh_announce)
+ app.router.add_get("/api/network/bitchat/messages", handle_get_messages)
+ app.router.add_post("/api/network/bitchat/send", handle_send_message)
+ app.router.add_post("/api/network/bitchat/save-to-binder", handle_save_to_binder)
+ app.router.add_post("/api/network/bitchat/convert-to-task", handle_convert_to_task)
+ app.router.add_get("/api/network/bitchat/ws", handle_bitchat_ws)
diff --git a/backend/app/api/dispatch_api.py b/backend/app/api/dispatch_api.py
new file mode 100644
index 0000000..c7c7412
--- /dev/null
+++ b/backend/app/api/dispatch_api.py
@@ -0,0 +1,363 @@
+"""Dispatch API — Sovereign communication, interactive story tokens, and device calculation.
+
+POST /api/dispatch/create — create a dispatch and stage an ephemeral token
+GET /api/dispatch/token/{token} — load story document for guest token
+POST /api/dispatch/rsvp/{token} — submit guest RSVP / responses
+GET /api/dispatch/preview-email/{id} — render bulletproof single-column Prose HTML email
+GET /api/dispatch/responses/{id} — get collected responses for host
+POST /api/dispatch/calculate-display — calculate grid & prose capabilities for a viewport
+POST /api/dispatch/calculate-storage — calculate vault knowledge & capsule capacity
+GET /api/dispatch/asset/{id}/{file} — serve preserved original asset
+"""
+from __future__ import annotations
+
+import base64
+import logging
+from pathlib import Path
+
+from aiohttp import web
+
+from app.services.device_calc import calc_display_capability, calc_storage_capacity
+from app.services.dispatch_store import dispatch_store
+from app.services.email_dispatch import compile_prose_email
+
+log = logging.getLogger("ucore.dispatch_api")
+
+
+async def handle_create_dispatch(request: web.Request) -> web.Response:
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
+
+ title = data.get("title", "").strip()
+ if not title:
+ return web.json_response({"error": "Title is required"}, status=400)
+
+ story_md = data.get("story_markdown", "").strip()
+ lead_text = data.get("lead_text", "").strip()
+ burn_after_read = bool(data.get("burn_after_read", False))
+ expires_in = data.get("expires_in_seconds")
+ mode_default = data.get("mode_default", "card")
+ binder_id = data.get("binder_id")
+ hero_gif_name = data.get("hero_gif_name")
+
+ hero_gif_bytes = None
+ if data.get("hero_gif_base64"):
+ try:
+ hero_gif_bytes = base64.b64decode(data["hero_gif_base64"])
+ except Exception as exc:
+ log.warning("Invalid base64 in hero_gif: %s", exc)
+
+ manifest = dispatch_store.create_dispatch(
+ title=title,
+ story_markdown=story_md,
+ lead_text=lead_text,
+ hero_gif_bytes=hero_gif_bytes,
+ hero_gif_name=hero_gif_name,
+ expires_in_seconds=int(expires_in) if expires_in else None,
+ burn_after_read=burn_after_read,
+ mode_default=mode_default,
+ binder_id=binder_id,
+ )
+
+ return web.json_response({
+ "status": "ok",
+ "dispatch": manifest,
+ "token": manifest["token"],
+ "story_url": f"/p/{manifest['token']}",
+ })
+
+
+async def handle_get_by_token(request: web.Request) -> web.Response:
+ token = request.match_info.get("token", "").strip()
+ if not token:
+ return web.json_response({"error": "Token is required"}, status=400)
+
+ record = dispatch_store.get_dispatch_by_token(token)
+ if record.get("status") == "not_found":
+ return web.json_response(record, status=404)
+
+ return web.json_response(record)
+
+
+async def handle_submit_rsvp(request: web.Request) -> web.Response:
+ token = request.match_info.get("token", "").strip()
+ if not token:
+ return web.json_response({"error": "Token is required"}, status=400)
+
+ try:
+ payload = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
+
+ result = dispatch_store.submit_rsvp(token, payload)
+ if result.get("status") == "not_found":
+ return web.json_response(result, status=404)
+ if result.get("status") in ("expired", "burned"):
+ return web.json_response(result, status=410)
+
+ return web.json_response(result)
+
+
+async def handle_preview_email(request: web.Request) -> web.Response:
+ dispatch_id = request.match_info.get("id", "").strip()
+ dispatch_dir = dispatch_store.root_dir / dispatch_id
+ manifest_path = dispatch_dir / "dispatch.json"
+
+ if not manifest_path.exists():
+ return web.Response(text="Dispatch not found", status=404)
+
+ import json
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ story_path = dispatch_dir / "originals" / "source.story.md"
+ story_md = story_path.read_text(encoding="utf-8") if story_path.exists() else ""
+
+ token = manifest.get("token", "")
+ action_url = f"/p/{token}"
+ hero_src = manifest.get("hero_asset")
+
+ html_email = compile_prose_email(
+ title=manifest.get("title", "Sovereign Dispatch"),
+ lead_text=manifest.get("lead_text", ""),
+ body_html=f"
{story_md[:400]}...
" if len(story_md) > 400 else f"{story_md}
",
+ action_label="Open Interactive Story",
+ action_url=action_url,
+ hero_gif_src=hero_src,
+ theme="dark",
+ )
+
+ return web.Response(text=html_email, content_type="text/html")
+
+
+async def handle_get_responses(request: web.Request) -> web.Response:
+ dispatch_id = request.match_info.get("id", "").strip()
+ responses = dispatch_store.get_responses(dispatch_id)
+ return web.json_response({"id": dispatch_id, "responses": responses})
+
+
+async def handle_calculate_display(request: web.Request) -> web.Response:
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
+
+ w = int(data.get("width", 1920))
+ h = int(data.get("height", 1080))
+ dev_type = data.get("device_type")
+ res = calc_display_capability(w, h, dev_type)
+ return web.json_response(res)
+
+
+async def handle_calculate_storage(request: web.Request) -> web.Response:
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
+
+ total_bytes = int(data.get("total_bytes", 4_000_000_000))
+ sys_bytes = data.get("sys_bytes")
+ res = calc_storage_capacity(total_bytes, int(sys_bytes) if sys_bytes is not None else None)
+ return web.json_response(res)
+
+
+async def handle_serve_asset(request: web.Request) -> web.Response:
+ dispatch_id = request.match_info.get("id", "").strip()
+ filename = request.match_info.get("file", "").strip()
+
+ # Prevent traversal
+ if ".." in dispatch_id or ".." in filename or "/" in filename or "\\" in filename:
+ return web.Response(text="Forbidden", status=403)
+
+ target_file = dispatch_store.root_dir / dispatch_id / "originals" / filename
+ if not target_file.exists() or not target_file.is_file():
+ return web.Response(text="Asset not found", status=404)
+
+ content_type = "image/gif" if filename.endswith(".gif") else "application/octet-stream"
+ return web.Response(body=target_file.read_bytes(), content_type=content_type)
+
+
+async def handle_get_catalog(request: web.Request) -> web.Response:
+ catalog = dispatch_store.load_catalog()
+ return web.json_response(catalog)
+
+
+async def handle_get_by_binder(request: web.Request) -> web.Response:
+ binder_id = request.match_info.get("binder_id", "").strip()
+ dispatches = dispatch_store.get_dispatches_by_binder(binder_id)
+ return web.json_response({"binder_id": binder_id, "dispatches": dispatches})
+
+
+async def handle_serve_catalog_asset(request: web.Request) -> web.Response:
+ filename = request.match_info.get("file", "").strip()
+ if ".." in filename or "/" in filename or "\\" in filename:
+ return web.Response(text="Forbidden", status=403)
+
+ from app.core.settings import settings
+ target_file = settings.public_vault_root / "global-knowledge" / "elements" / filename
+ if not target_file.exists() or not target_file.is_file():
+ # Fallback 1x1 transparent gif if asset not present
+ transparent_gif_1x1 = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
+ return web.Response(body=transparent_gif_1x1, content_type="image/gif")
+
+ content_type = "image/gif" if filename.endswith(".gif") else "image/svg+xml"
+ return web.Response(body=target_file.read_bytes(), content_type=content_type)
+
+
+async def handle_get_feed_rss(request: web.Request) -> web.Response:
+ host_url = f"{request.scheme}://{request.host}"
+ from app.services.dispatch_feed import dispatch_feed_service
+ rss_xml = dispatch_feed_service.generate_rss_2_0(host_url)
+ return web.Response(text=rss_xml, content_type="application/rss+xml")
+
+
+async def handle_get_feed_json(request: web.Request) -> web.Response:
+ host_url = f"{request.scheme}://{request.host}"
+ from app.services.dispatch_feed import dispatch_feed_service
+ json_feed = dispatch_feed_service.generate_json_feed(host_url)
+ return web.json_response(json_feed)
+
+
+async def handle_get_motifs(request: web.Request) -> web.Response:
+ try:
+ from app.services.uvector_bob import MOTIF_PRESETS
+ return web.json_response({"status": "ok", "motifs": MOTIF_PRESETS})
+ except Exception:
+ import sys
+ uvector_py = Path(__file__).resolve().parents[4] / "uVector" / "python"
+ if uvector_py.exists() and str(uvector_py) not in sys.path:
+ sys.path.insert(0, str(uvector_py))
+ try:
+ from uvector_bob import MOTIF_PRESETS
+ return web.json_response({"status": "ok", "motifs": MOTIF_PRESETS})
+ except Exception as exc:
+ log.warning("Could not load uVector motifs: %s", exc)
+ return web.json_response({"status": "ok", "motifs": {}})
+
+
+async def handle_generate_bob(request: web.Request) -> web.Response:
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON"}, status=400)
+
+ motif = data.get("motif", "zen_envelope")
+ palette_id = data.get("palette_id", "teletext_ceefax")
+ steps = int(data.get("steps", 4))
+ delay_ms = int(data.get("delay_ms", 100))
+
+ try:
+ from app.services.uvector_bob import compile_svg_to_bob, compile_svgs_to_gif, generate_vector_motif
+ except Exception:
+ import sys
+ uvector_py = Path(__file__).resolve().parents[4] / "uVector" / "python"
+ if uvector_py.exists() and str(uvector_py) not in sys.path:
+ sys.path.insert(0, str(uvector_py))
+ from uvector_bob import compile_svg_to_bob, compile_svgs_to_gif, generate_vector_motif
+
+ try:
+ frames = generate_vector_motif(motif, palette_id=palette_id, steps=steps)
+ gif_bytes = compile_svgs_to_gif(frames, delay_ms=delay_ms, palette_id=palette_id)
+ bob_def = compile_svg_to_bob(frames[0], id=f"bob_{motif}", name=f"{motif.title()} BOB", palette_id=palette_id)
+
+ from app.core.settings import settings
+ asset_name = f"bob-{motif}-{palette_id}.gif"
+ elements_dir = settings.public_vault_root / "global-knowledge" / "elements"
+ elements_dir.mkdir(parents=True, exist_ok=True)
+ (elements_dir / asset_name).write_bytes(gif_bytes)
+
+ return web.json_response({
+ "status": "ok",
+ "asset_name": asset_name,
+ "asset_url": f"/api/dispatch/catalog/asset/{asset_name}",
+ "gif_size_bytes": len(gif_bytes),
+ "bob_definition": bob_def,
+ })
+ except Exception as exc:
+ log.error("Failed to generate BOB: %s", exc)
+ return web.json_response({"error": str(exc)}, status=500)
+
+
+async def handle_ingest_inbound(request: web.Request) -> web.Response:
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON"}, status=400)
+
+ token = data.get("token")
+ if not token:
+ return web.json_response({"error": "Token is required in response payload"}, status=400)
+
+ res = dispatch_store.submit_rsvp(token, data)
+ return web.json_response(res)
+
+
+async def handle_export_offline(request: web.Request) -> web.Response:
+ dispatch_id = request.match_info.get("id", "").strip()
+ from app.services.dispatch_offline import export_offline_bundle
+ try:
+ result = export_offline_bundle(dispatch_id)
+ return web.json_response(result)
+ except Exception as exc:
+ log.error("Offline export failed for %s: %s", dispatch_id, exc)
+ return web.json_response({"error": str(exc)}, status=400)
+
+
+async def handle_convert_rsvp_task(request: web.Request) -> web.Response:
+ """POST /api/dispatch/{id}/rsvp/{index}/task — Convert RSVP submission into a task."""
+ dispatch_id = request.match_info.get("id", "").strip()
+ index_str = request.match_info.get("index", "0").strip()
+ try:
+ rsvp_index = int(index_str)
+ except ValueError:
+ return web.json_response({"error": "Index must be an integer"}, status=400)
+
+ try:
+ body = await request.json() if request.body_exists else {}
+ except Exception:
+ body = {}
+
+ board = str(body.get("board") or "inbox")
+ priority = str(body.get("priority") or "medium")
+ binder = body.get("binder")
+ mission = body.get("mission")
+ due_date = body.get("due_date")
+ sync_apple_reminders = bool(body.get("sync_apple_reminders", False))
+
+ res = dispatch_store.convert_rsvp_to_task(
+ dispatch_id=dispatch_id,
+ rsvp_index=rsvp_index,
+ board=board,
+ priority=priority,
+ binder=binder,
+ mission=mission,
+ due_date=due_date,
+ sync_apple_reminders=sync_apple_reminders,
+ )
+ if res.get("status") == "not_found":
+ return web.json_response(res, status=404)
+ return web.json_response(res)
+
+
+def register_dispatch_routes(app: web.Application) -> None:
+ """Register all dispatch, feed, and device capability routes."""
+ app.router.add_post("/api/dispatch/create", handle_create_dispatch)
+ app.router.add_get("/api/dispatch/token/{token}", handle_get_by_token)
+ app.router.add_post("/api/dispatch/rsvp/{token}", handle_submit_rsvp)
+ app.router.add_get("/api/dispatch/preview-email/{id}", handle_preview_email)
+ app.router.add_get("/api/dispatch/responses/{id}", handle_get_responses)
+ app.router.add_post("/api/dispatch/calculate-display", handle_calculate_display)
+ app.router.add_post("/api/dispatch/calculate-storage", handle_calculate_storage)
+ app.router.add_get("/api/dispatch/asset/{id}/{file}", handle_serve_asset)
+ app.router.add_get("/api/dispatch/catalog", handle_get_catalog)
+ app.router.add_get("/api/dispatch/catalog/asset/{file}", handle_serve_catalog_asset)
+ app.router.add_get("/api/dispatch/by-binder/{binder_id}", handle_get_by_binder)
+ app.router.add_get("/api/dispatch/feed.xml", handle_get_feed_rss)
+ app.router.add_get("/api/dispatch/feed.json", handle_get_feed_json)
+ app.router.add_get("/api/dispatch/motifs", handle_get_motifs)
+ app.router.add_post("/api/dispatch/generate-bob", handle_generate_bob)
+ app.router.add_post("/api/dispatch/inbox/ingest", handle_ingest_inbound)
+ app.router.add_post("/api/dispatch/export-offline/{id}", handle_export_offline)
+ app.router.add_post("/api/dispatch/{id}/rsvp/{index}/task", handle_convert_rsvp_task)
+
+
diff --git a/backend/app/api/feed_api.py b/backend/app/api/feed_api.py
index 09e2928..6a80f56 100644
--- a/backend/app/api/feed_api.py
+++ b/backend/app/api/feed_api.py
@@ -164,6 +164,10 @@ async def handle_feed_promote(request: web.Request) -> web.Response:
board=str(body.get("board") or "inbox"),
priority=str(body.get("priority") or "medium"),
binder=str(body.get("binder") or "Sandbox"),
+ mission=body.get("mission"),
+ due_date=body.get("due_date") or body.get("due"),
+ sync_apple_reminders=bool(body.get("sync_apple_reminders", False)),
+ archive_source_mail=bool(body.get("archive_source_mail", False)),
)
await server.link_task_to_activity(task_id=promoted["task_id"], activity_id=activity_id)
return web.json_response({"ok": True, **promoted})
@@ -202,9 +206,29 @@ async def handle_feed_rules_apply(request: web.Request) -> web.Response:
for proposal in proposals:
if proposal["activity_id"] in handled:
continue
- if proposal["action"] not in {"propose-task", "create-task"}:
+
+ if proposal["action"] in {"auto-archive", "resolve"}:
+ res = server.resolve_activity(
+ proposal["activity_id"], note=f"Auto-resolved by rule {proposal['rule_id']}"
+ )
+ if proposal.get("archive_mail") and by_id[proposal["activity_id"]].get("source") == "mail":
+ ext_id = by_id[proposal["activity_id"]].get("external_id")
+ if ext_id:
+ try:
+ from app.services.apple_feed_sync import AppleFeedSync
+
+ AppleFeedSync().archive_email(ext_id)
+ except Exception:
+ pass
+ created.append({**proposal, "resolved": True})
+ handled.add(proposal["activity_id"])
+ continue
+
+ if proposal["action"] not in {"propose-task", "create-task", "sync-apple-reminders"}:
continue
+
activity = by_id[proposal["activity_id"]]
+ sync_rem = proposal["action"] == "sync-apple-reminders" or bool(proposal.get("sync_reminders", False))
promoted = promote_activity_to_task(
activity,
title=proposal["title"],
@@ -212,6 +236,8 @@ async def handle_feed_rules_apply(request: web.Request) -> web.Response:
priority=proposal["priority"],
binder=proposal["binder"],
rule_id=proposal["rule_id"],
+ sync_apple_reminders=sync_rem,
+ archive_source_mail=bool(proposal.get("archive_mail", False)),
)
await server.link_task_to_activity(
task_id=promoted["task_id"], activity_id=proposal["activity_id"], link_type="rule"
@@ -247,6 +273,102 @@ async def handle_feed_runtime(request: web.Request) -> web.Response:
)
+async def handle_feed_resolve(request: web.Request) -> web.Response:
+ """POST /api/feed/resolve/{id} — Resolve/archive a feed activity."""
+ activity_id_str = request.match_info.get("id", "").strip()
+ try:
+ activity_id = int(activity_id_str)
+ except ValueError:
+ return web.json_response({"error": "id must be an integer"}, status=400)
+
+ try:
+ body = await request.json() if request.body_exists else {}
+ except Exception:
+ body = {}
+
+ note = str(body.get("note") or "")
+ archived = bool(body.get("archived", True))
+ archive_mail = bool(body.get("archive_mail", False))
+
+ server = _get_feed_server()
+ res = server.resolve_activity(activity_id=activity_id, note=note, archived=archived)
+ if not res.get("ok"):
+ return web.json_response(res, status=404)
+
+ if archive_mail:
+ rows = await server.query_feed(limit=100)
+ item = next((r for r in rows if int(r["id"]) == activity_id), None)
+ if item and item.get("source") == "mail" and item.get("external_id"):
+ try:
+ from app.services.apple_feed_sync import AppleFeedSync
+
+ AppleFeedSync().archive_email(item["external_id"])
+ except Exception:
+ pass
+
+ return web.json_response(res)
+
+
+async def handle_feed_bulk_resolve(request: web.Request) -> web.Response:
+ """POST /api/feed/bulk-resolve — Bulk resolve multiple activities."""
+ try:
+ body = await request.json() if request.body_exists else {}
+ except Exception:
+ return web.json_response({"error": "Invalid JSON"}, status=400)
+
+ activity_ids = body.get("activity_ids") or []
+ note = str(body.get("note") or "")
+ if not isinstance(activity_ids, list):
+ return web.json_response({"error": "activity_ids must be a list"}, status=400)
+
+ server = _get_feed_server()
+ res = server.bulk_resolve(activity_ids=[int(aid) for aid in activity_ids], note=note)
+ return web.json_response(res)
+
+
+async def handle_feed_export_reminders(request: web.Request) -> web.Response:
+ """POST /api/feed/export/reminders — Export an item or activity to Apple Reminders."""
+ try:
+ body = await request.json() if request.body_exists else {}
+ except Exception:
+ return web.json_response({"error": "Invalid JSON"}, status=400)
+
+ title = body.get("title", "").strip()
+ if not title:
+ return web.json_response({"error": "title is required"}, status=400)
+
+ notes = body.get("notes", "")
+ list_name = body.get("list_name") or body.get("binder") or "uDos"
+ due_date = body.get("due_date") or body.get("due")
+
+ from app.services.apple_feed_sync import AppleFeedSync
+
+ sync_svc = AppleFeedSync(_get_feed_server())
+ res = sync_svc.export_reminder(title=title, notes=notes, list_name=list_name, due_date=due_date)
+ return web.json_response(res, status=200 if res.get("ok") else 500)
+
+
+async def handle_feed_export_notes(request: web.Request) -> web.Response:
+ """POST /api/feed/export/notes — Export an item to Apple Notes."""
+ try:
+ body = await request.json() if request.body_exists else {}
+ except Exception:
+ return web.json_response({"error": "Invalid JSON"}, status=400)
+
+ title = body.get("title", "").strip()
+ if not title:
+ return web.json_response({"error": "title is required"}, status=400)
+
+ body_text = body.get("body", "")
+ folder = body.get("folder") or "uDos"
+
+ from app.services.apple_feed_sync import AppleFeedSync
+
+ sync_svc = AppleFeedSync(_get_feed_server())
+ res = sync_svc.export_note(title=title, body_markdown=body_text, folder=folder)
+ return web.json_response(res, status=200 if res.get("ok") else 500)
+
+
def register_feed_routes(app: web.Application) -> None:
"""Register Feed API routes."""
app.router.add_post("/api/feed/ingest", handle_feed_ingest)
@@ -259,7 +381,11 @@ def register_feed_routes(app: web.Application) -> None:
app.router.add_get("/api/feed/runtime", handle_feed_runtime)
app.router.add_get("/api/feed/sources", handle_feed_sources)
app.router.add_post("/api/feed/sources/{source}/sync", handle_feed_source_sync)
- log.info("Feed API routes registered: ingest, query, suggest, link")
+ app.router.add_post("/api/feed/resolve/{id}", handle_feed_resolve)
+ app.router.add_post("/api/feed/bulk-resolve", handle_feed_bulk_resolve)
+ app.router.add_post("/api/feed/export/reminders", handle_feed_export_reminders)
+ app.router.add_post("/api/feed/export/notes", handle_feed_export_notes)
+ log.info("Feed API routes registered: ingest, query, suggest, link, promote, rules, resolve, export")
async def handle_feed_sources(request: web.Request) -> web.Response:
diff --git a/backend/app/api/host_api.py b/backend/app/api/host_api.py
index f3f9577..14a5645 100644
--- a/backend/app/api/host_api.py
+++ b/backend/app/api/host_api.py
@@ -195,6 +195,63 @@ async def handle_host_say(request: web.Request) -> web.Response:
return web.json_response(res, status=200 if res.get("ok") else 500)
+async def handle_host_shortcuts(request: web.Request) -> web.Response:
+ """GET /api/host/shortcuts — List available host shortcuts."""
+ svc = get_host_pim_service()
+ shortcuts = svc.list_shortcuts()
+ return web.json_response({"ok": True, "shortcuts": shortcuts, "count": len(shortcuts)})
+
+
+async def handle_host_shortcuts_run(request: web.Request) -> web.Response:
+ """POST /api/host/shortcuts/run — Execute a host shortcut."""
+ try:
+ body = await request.json()
+ except Exception:
+ return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
+
+ name = body.get("name", "").strip()
+ if not name:
+ return web.json_response({"ok": False, "error": "Shortcut name is required"}, status=400)
+
+ input_text = body.get("input", "")
+ svc = get_host_pim_service()
+ res = svc.run_shortcut(name=name, input_text=input_text)
+ return web.json_response(res, status=200 if res.get("ok") else 500)
+
+
+async def handle_mail_archive(request: web.Request) -> web.Response:
+ """POST /api/host/mail/archive — Archive an email in Apple Mail."""
+ try:
+ body = await request.json()
+ except Exception:
+ return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
+
+ message_id = body.get("message_id", "").strip()
+ if not message_id:
+ return web.json_response({"ok": False, "error": "message_id is required"}, status=400)
+
+ svc = get_host_pim_service()
+ res = svc.archive_apple_mail(message_id=message_id)
+ return web.json_response(res, status=200 if res.get("ok") else 500)
+
+
+async def handle_mail_flag(request: web.Request) -> web.Response:
+ """POST /api/host/mail/flag — Flag an email in Apple Mail."""
+ try:
+ body = await request.json()
+ except Exception:
+ return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
+
+ message_id = body.get("message_id", "").strip()
+ flag_index = int(body.get("flag_index", 0))
+ if not message_id:
+ return web.json_response({"ok": False, "error": "message_id is required"}, status=400)
+
+ svc = get_host_pim_service()
+ res = svc.flag_apple_mail(message_id=message_id, flag_index=flag_index)
+ return web.json_response(res, status=200 if res.get("ok") else 500)
+
+
def register_host_routes(app: web.Application) -> None:
"""Register all host PIM and automation routes."""
app.router.add_get("/api/host/capabilities", handle_host_capabilities)
@@ -206,5 +263,9 @@ def register_host_routes(app: web.Application) -> None:
app.router.add_get("/api/host/reminders/intake", handle_reminders_intake)
app.router.add_post("/api/host/notify", handle_host_notify)
app.router.add_post("/api/host/say", handle_host_say)
- log.info("Host PIM routes registered: capabilities, safari, notes, reminders, notify, say")
+ app.router.add_get("/api/host/shortcuts", handle_host_shortcuts)
+ app.router.add_post("/api/host/shortcuts/run", handle_host_shortcuts_run)
+ app.router.add_post("/api/host/mail/archive", handle_mail_archive)
+ app.router.add_post("/api/host/mail/flag", handle_mail_flag)
+ log.info("Host PIM routes registered: capabilities, safari, notes, reminders, notify, say, shortcuts, mail")
diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py
index 2a3c9d0..d54a82b 100644
--- a/backend/app/api/routes.py
+++ b/backend/app/api/routes.py
@@ -656,3 +656,21 @@ def register_routes(app: web.Application) -> None:
log.debug("uVector API routes registered")
except ImportError as e:
log.debug("uVector API routes not available: %s", e)
+
+ # ── Sovereign Dispatch & Device Capability API ────────────────────
+ try:
+ from .dispatch_api import register_dispatch_routes
+
+ register_dispatch_routes(app)
+ log.debug("Dispatch API routes registered")
+ except ImportError as e:
+ log.debug("Dispatch API routes not available: %s", e)
+
+ # ── BitChat & Decentralized Mesh Transport API ───────────────────
+ try:
+ from .bitchat_api import register_bitchat_routes
+
+ register_bitchat_routes(app)
+ log.debug("BitChat & Mesh Transport API routes registered")
+ except ImportError as e:
+ log.debug("BitChat API routes not available: %s", e)
diff --git a/backend/app/api/tasker_api.py b/backend/app/api/tasker_api.py
index 1ac9bcb..39ed418 100644
--- a/backend/app/api/tasker_api.py
+++ b/backend/app/api/tasker_api.py
@@ -502,8 +502,47 @@ async def handle_update_task(request: web.Request) -> web.Response:
})
+async def handle_execute_task_action(request: web.Request) -> web.Response:
+ """POST /api/workflow/tasks/{task_id}/execute-action — Execute approved automation."""
+ task_id = request.match_info.get("task_id", "").strip()
+ if not task_id:
+ return web.json_response({"error": "task_id required"}, status=400)
+
+ try:
+ body = await request.json() if request.body_exists else {}
+ except Exception:
+ return web.json_response({"error": "Invalid JSON"}, status=400)
+
+ action_type = body.get("action_type", "shortcut")
+ payload = body.get("payload") or body
+
+ from app.services.task_action_bridge import TaskActionBridge
+
+ bridge = TaskActionBridge()
+ result = bridge.execute_action(task_id=task_id, action_type=action_type, payload=payload)
+ return web.json_response(result, status=200 if result.get("ok") else 500)
+
+
+async def handle_sync_task_reminders(request: web.Request) -> web.Response:
+ """POST /api/workflow/tasks/{task_id}/sync-reminders — Push task to Apple Reminders."""
+ task_id = request.match_info.get("task_id", "").strip()
+ if not task_id:
+ return web.json_response({"error": "task_id required"}, status=400)
+
+ try:
+ body = await request.json() if request.body_exists else {}
+ except Exception:
+ body = {}
+
+ from app.services.task_action_bridge import TaskActionBridge
+
+ bridge = TaskActionBridge()
+ result = bridge.execute_action(task_id=task_id, action_type="apple_reminders", payload=body)
+ return web.json_response(result, status=200 if result.get("ok") else 500)
+
+
def register_tasker_routes(app: web.Application) -> None:
- """Register tasker API routes under /api/developer/tasker/."""
+ """Register tasker API routes under /api/developer/tasker/ and /api/workflow/."""
app.router.add_get("/api/developer/tasker/boards", handle_list_boards)
app.router.add_get(
"/api/developer/tasker/board/{board_name}",
@@ -516,3 +555,5 @@ def register_tasker_routes(app: web.Application) -> None:
)
app.router.add_get("/api/developer/tasker/summary", handle_tasker_summary)
app.router.add_patch("/api/workflow/tasks/{task_id}", handle_update_task)
+ app.router.add_post("/api/workflow/tasks/{task_id}/execute-action", handle_execute_task_action)
+ app.router.add_post("/api/workflow/tasks/{task_id}/sync-reminders", handle_sync_task_reminders)
diff --git a/backend/app/services/apple_feed_sync.py b/backend/app/services/apple_feed_sync.py
index cadc42a..3a0c1e0 100644
--- a/backend/app/services/apple_feed_sync.py
+++ b/backend/app/services/apple_feed_sync.py
@@ -29,6 +29,8 @@ class AppleSource:
AppleSource("mail", "Apple Mail", "Mail"),
AppleSource("calendar", "Apple Calendar", "Calendar"),
AppleSource("messages", "Apple Messages", "Messages"),
+ AppleSource("reminders", "Apple Reminders", "Reminders"),
+ AppleSource("notes", "Apple Notes (Scratchpad)", "Notes"),
AppleSource("contacts-notes-rules", "Contacts · Notes · Rules", "Contacts"),
AppleSource(
"whatsapp",
@@ -42,13 +44,60 @@ class AppleSource:
SCRIPT_BY_SOURCE = {
"mail": '''const app = Application("Mail");
const rows = []; const items = app.inbox.messages();
-for (const m of items) { if (!m.readStatus()) rows.push({external_id:String(m.messageId()), title:String(m.subject()), content:String(m.content()), contact_name:String(m.sender())}); if (rows.length >= __LIMIT__) break; }
+for (const m of items) {
+ if (!m.readStatus()) {
+ let flagged = false; let flagIdx = -1;
+ try { flagged = Boolean(m.flaggedStatus()); flagIdx = flagged ? m.flagIndex() : -1; } catch(e){}
+ rows.push({
+ external_id: String(m.messageId()),
+ title: String(m.subject()),
+ content: String(m.content()),
+ contact_name: String(m.sender()),
+ is_flagged: flagged,
+ flag_index: flagIdx
+ });
+ }
+ if (rows.length >= __LIMIT__) break;
+}
JSON.stringify(rows);''',
"calendar": '''const app = Application("Calendar"); const rows = []; const cutoff = new Date(Date.now() - 7 * 86400000);
for (const calendar of app.calendars()) { for (const e of calendar.events()) { const start=e.startDate(); if (start >= cutoff) rows.push({external_id:String(e.uid()), title:String(e.summary()), content:String(e.description() || ""), timestamp:start.toISOString()}); if (rows.length >= __LIMIT__) break; } if (rows.length >= __LIMIT__) break; }
JSON.stringify(rows);''',
"messages": '''const app = Application("Messages"); const rows = [];
for (const chat of app.chats()) { const name=String(chat.name() || chat.id()); rows.push({external_id:String(chat.id()), title:name, content:"Conversation available in Messages", contact_name:name}); if (rows.length >= __LIMIT__) break; }
+JSON.stringify(rows);''',
+ "reminders": '''const app = Application("Reminders"); const rows = [];
+for (const list of app.lists()) {
+ for (const r of list.reminders.whose({completed: false})()) {
+ let dueStr = null;
+ try { const d = r.dueDate(); if (d) dueStr = d.toISOString(); } catch(e){}
+ rows.push({
+ external_id: String(r.id()),
+ title: String(r.name() || "Reminder"),
+ content: String(r.body() || ""),
+ timestamp: dueStr || new Date().toISOString(),
+ list: String(list.name())
+ });
+ if (rows.length >= __LIMIT__) break;
+ }
+ if (rows.length >= __LIMIT__) break;
+}
+JSON.stringify(rows);''',
+ "notes": '''const app = Application("Notes"); const rows = [];
+let targetNotes = [];
+try {
+ const f = app.folders.whose({name: "uDos"})();
+ targetNotes = (f && f.length > 0) ? f[0].notes() : app.defaultAccount().notes();
+} catch(e) { targetNotes = app.defaultAccount().notes(); }
+for (const n of targetNotes) {
+ rows.push({
+ external_id: String(n.id()),
+ title: String(n.name() || "Note"),
+ content: String(n.plaintext ? n.plaintext() : ""),
+ timestamp: n.modificationDate() ? n.modificationDate().toISOString() : new Date().toISOString()
+ });
+ if (rows.length >= __LIMIT__) break;
+}
JSON.stringify(rows);''',
"contacts-notes-rules": '''const app = Application("Contacts"); const rows = [];
for (const p of app.people()) { const note=String(p.note() || ""); if (note) rows.push({external_id:String(p.id()), title:String(p.name()), content:note}); if (rows.length >= __LIMIT__) break; }
@@ -94,6 +143,7 @@ def source_status(self) -> list[dict[str, Any]]:
"state": item.state if automation else "unavailable",
"available": automation and item.state == "available",
"requires_user_permission": item.state == "available",
+ "bidirectional": item.id in {"reminders", "notes", "mail"},
"note": item.note or ("Requires macOS Automation permission on first sync." if automation else "macOS only"),
}
for item in SOURCES
@@ -125,6 +175,14 @@ async def sync(self, source: str, *, limit: int = 50) -> dict[str, Any]:
activity_type = "contact-note-rule" if source == "contacts-notes-rules" else source
if source == "contacts-notes-rules":
metadata["rules"] = [line.strip() for line in content.splitlines() if line.strip().lower().startswith(("rule:", "ucore:"))]
+ importance = 0.65 if source in {"mail", "calendar"} else 0.5
+ if row.get("is_flagged") or (row.get("flag_index", -1) >= 0):
+ metadata["is_flagged"] = True
+ metadata["flag_index"] = row.get("flag_index")
+ importance = 0.85
+ if row.get("list"):
+ metadata["list"] = row.get("list")
+
result = await self.feed.ingest_activity(
source=source,
external_id=external_id,
@@ -132,7 +190,7 @@ async def sync(self, source: str, *, limit: int = 50) -> dict[str, Any]:
title=str(row.get("title") or item.label),
content=content,
contact_name=str(row.get("contact_name") or ""),
- importance=0.65 if source in {"mail", "calendar"} else 0.5,
+ importance=importance,
metadata=metadata,
)
try:
@@ -145,7 +203,7 @@ async def sync(self, source: str, *, limit: int = 50) -> dict[str, Any]:
"type": activity_type,
"title": str(row.get("title") or item.label),
"content": content,
- "importance": 0.65 if source in {"mail", "calendar"} else 0.5,
+ "importance": importance,
"metadata": metadata,
}
)
@@ -177,3 +235,53 @@ async def sync(self, source: str, *, limit: int = 50) -> dict[str, Any]:
pass
return {"ok": True, "source": source, "state": "synced", "imported": imported}
+
+ def export_reminder(
+ self,
+ title: str,
+ notes: str = "",
+ list_name: str = "uDos",
+ due_date: str | None = None,
+ ) -> dict[str, Any]:
+ """Export an approved task or action item into Apple Reminders."""
+ from app.services.host_pim import HostPIMService
+
+ pim = HostPIMService()
+ return pim.export_to_apple_reminders(
+ title=title, notes=notes, list_name=list_name, due_date=due_date
+ )
+
+ def export_note(
+ self,
+ title: str,
+ body_markdown: str,
+ folder: str = "uDos",
+ ) -> dict[str, Any]:
+ """Export an approved idea or scratchpad note to Apple Notes."""
+ from app.services.host_pim import HostPIMService
+
+ pim = HostPIMService()
+ return pim.export_to_apple_notes(
+ title=title, body_markdown=body_markdown, folder=folder
+ )
+
+ def archive_email(self, message_id: str) -> dict[str, Any]:
+ """Archive an email in Apple Mail to maintain Inbox Zero."""
+ from app.services.host_pim import HostPIMService
+
+ pim = HostPIMService()
+ return pim.archive_apple_mail(message_id=message_id)
+
+ def run_shortcut(self, name: str, input_text: str = "") -> dict[str, Any]:
+ """Run a native macOS shortcut."""
+ from app.services.host_pim import HostPIMService
+
+ pim = HostPIMService()
+ return pim.run_shortcut(name=name, input_text=input_text)
+
+ def list_shortcuts(self) -> list[str]:
+ """List native macOS shortcuts."""
+ from app.services.host_pim import HostPIMService
+
+ pim = HostPIMService()
+ return pim.list_shortcuts()
diff --git a/backend/app/services/bitchat_store.py b/backend/app/services/bitchat_store.py
new file mode 100644
index 0000000..63efb62
--- /dev/null
+++ b/backend/app/services/bitchat_store.py
@@ -0,0 +1,305 @@
+"""BitChat Sovereign Message Store & Binder Evidence Bridge.
+
+Provides:
+- Sovereign local SQLite message archive at $UDOS_HOME/pods/bitchat.db
+- Real-time Activity Pod ingestion into activity.db (Snackbar notification)
+- "Save to Binder": formats discussions into Markdown evidence in ~/Vault/binders//evidence/chats/
+- "Convert to Task": promotes chat action items into sovereign .tasker markdown tasks
+"""
+from __future__ import annotations
+
+import json
+import logging
+import sqlite3
+import time
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from app.core.settings import settings
+
+log = logging.getLogger("ucore.bitchat_store")
+
+DEFAULT_BITCHAT_POD = settings.udos_home / "pods" / "bitchat.db"
+
+
+class BitChatStore:
+ """Sovereign local SQLite store for BitChat messages and evidence exports."""
+
+ def __init__(self, db_path: Optional[Path] = None):
+ self.db_path = db_path or DEFAULT_BITCHAT_POD
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._conn = sqlite3.connect(str(self.db_path))
+ self._conn.row_factory = sqlite3.Row
+ self._ensure_schema()
+
+ def _ensure_schema(self) -> None:
+ ddl = """
+ CREATE TABLE IF NOT EXISTS bitchat_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ msg_id TEXT UNIQUE NOT NULL,
+ channel TEXT NOT NULL DEFAULT '#general',
+ sender_name TEXT NOT NULL,
+ sender_peer_id TEXT,
+ recipient_id TEXT,
+ content TEXT NOT NULL,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
+ saved_to_binder BOOLEAN DEFAULT 0,
+ binder_evidence_path TEXT,
+ task_id TEXT,
+ metadata JSON
+ );
+ CREATE INDEX IF NOT EXISTS idx_bitchat_channel ON bitchat_messages(channel);
+ CREATE INDEX IF NOT EXISTS idx_bitchat_ts ON bitchat_messages(timestamp);
+ """
+ self._conn.executescript(ddl)
+ self._conn.commit()
+
+ def close(self) -> None:
+ self._conn.close()
+
+ def send_message(
+ self,
+ sender_name: str,
+ content: str,
+ channel: str = "#general",
+ sender_peer_id: Optional[str] = None,
+ recipient_id: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """Record and broadcast a message, alerting the Feed Activity Pod."""
+ msg_id = f"msg_{int(time.time())}_{uuid.uuid4().hex[:6]}"
+ now_iso = datetime.now(timezone.utc).isoformat()
+ cursor = self._conn.cursor()
+ cursor.execute(
+ """INSERT INTO bitchat_messages
+ (msg_id, channel, sender_name, sender_peer_id, recipient_id, content, timestamp, metadata)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ msg_id,
+ channel,
+ sender_name,
+ sender_peer_id or "local",
+ recipient_id or channel,
+ content,
+ now_iso,
+ json.dumps(metadata or {}),
+ ),
+ )
+ self._conn.commit()
+
+ record = {
+ "msg_id": msg_id,
+ "channel": channel,
+ "sender_name": sender_name,
+ "sender_peer_id": sender_peer_id or "local",
+ "recipient_id": recipient_id or channel,
+ "content": content,
+ "timestamp": now_iso,
+ "saved_to_binder": False,
+ "task_id": None,
+ "metadata": metadata or {},
+ }
+
+ # Synchronously alert Feed Activity Pod (Snackbar)
+ try:
+ from app.services.feed_store import FeedServer
+ FeedServer().ingest_activity_sync(
+ source="bitchat",
+ external_id=msg_id,
+ type="bitchat_message",
+ title=f"[{channel}] {sender_name}",
+ content=content,
+ importance=0.6,
+ metadata={
+ "msg_id": msg_id,
+ "channel": channel,
+ "sender_name": sender_name,
+ "sender_peer_id": sender_peer_id,
+ },
+ )
+ except Exception as exc:
+ log.debug("Could not ingest bitchat message into Feed Activity Pod: %s", exc)
+
+ return record
+
+ def get_messages(
+ self,
+ channel: str = "#general",
+ limit: int = 50,
+ since: Optional[str] = None,
+ ) -> List[Dict[str, Any]]:
+ """Retrieve recent chat history for a channel."""
+ cursor = self._conn.cursor()
+ query = "SELECT * FROM bitchat_messages WHERE channel = ?"
+ params: List[Any] = [channel]
+ if since:
+ query += " AND timestamp >= ?"
+ params.append(since)
+ query += " ORDER BY id DESC LIMIT ?"
+ params.append(limit)
+
+ cursor.execute(query, params)
+ rows = cursor.fetchall()
+ results = []
+ for r in reversed(rows):
+ meta = {}
+ if r["metadata"]:
+ try:
+ meta = json.loads(r["metadata"])
+ except Exception:
+ pass
+ results.append({
+ "id": r["id"],
+ "msg_id": r["msg_id"],
+ "channel": r["channel"],
+ "sender_name": r["sender_name"],
+ "sender_peer_id": r["sender_peer_id"],
+ "recipient_id": r["recipient_id"],
+ "content": r["content"],
+ "timestamp": r["timestamp"],
+ "saved_to_binder": bool(r["saved_to_binder"]),
+ "binder_evidence_path": r["binder_evidence_path"],
+ "task_id": r["task_id"],
+ "metadata": meta,
+ })
+ return results
+
+ def save_to_binder(
+ self,
+ msg_ids: List[str],
+ binder_name: str,
+ title: str = "Discussion Evidence",
+ vault_root: Optional[Path] = None,
+ ) -> Dict[str, Any]:
+ """Format selected messages as Markdown evidence and save into binder."""
+ if not msg_ids:
+ return {"ok": False, "error": "No messages selected"}
+
+ cursor = self._conn.cursor()
+ placeholders = ",".join("?" for _ in msg_ids)
+ cursor.execute(
+ f"SELECT * FROM bitchat_messages WHERE msg_id IN ({placeholders}) ORDER BY id ASC",
+ msg_ids,
+ )
+ rows = cursor.fetchall()
+ if not rows:
+ return {"ok": False, "error": "None of the specified messages were found"}
+
+ root = vault_root or settings.vault_root
+ channel = rows[0]["channel"]
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ slug_title = "".join(c if c.isalnum() else "-" for c in title.lower()).strip("-")[:40]
+
+ evidence_dir = root / "binders" / binder_name / "evidence" / "chats"
+ evidence_dir.mkdir(parents=True, exist_ok=True)
+ filename = f"{date_str}-{slug_title}.md"
+ target_path = evidence_dir / filename
+
+ # Format markdown
+ lines = [
+ "---",
+ "type: evidence",
+ "source: bitchat",
+ f"channel: '{channel}'",
+ f"binder: '{binder_name}'",
+ f"created: '{datetime.now(timezone.utc).isoformat()}'",
+ "tags: [evidence, bitchat, chat, discussion]",
+ "---",
+ "",
+ f"# {title}",
+ "",
+ f"Captured discussion from BitChat channel **{channel}** on `{date_str}`:",
+ "",
+ ]
+
+ for r in rows:
+ t_str = str(r["timestamp"])
+ if "T" in t_str:
+ time_only = t_str.split("T")[1][:8]
+ elif " " in t_str:
+ time_only = t_str.split(" ")[1][:8]
+ else:
+ time_only = t_str[:8]
+ lines.append(f"- **{time_only}** `{r['sender_name']}`: {r['content']}")
+
+ lines.append("")
+ target_path.write_text("\n".join(lines), encoding="utf-8")
+
+ # Mark messages as saved
+ cursor.execute(
+ f"UPDATE bitchat_messages SET saved_to_binder = 1, binder_evidence_path = ? WHERE msg_id IN ({placeholders})",
+ [str(target_path)] + msg_ids,
+ )
+ self._conn.commit()
+
+ return {
+ "ok": True,
+ "path": str(target_path),
+ "filename": filename,
+ "binder": binder_name,
+ "saved_count": len(rows),
+ }
+
+ def convert_message_to_task(
+ self,
+ msg_id: str,
+ board: str = "inbox",
+ priority: str = "medium",
+ binder: str = "Sandbox",
+ due_date: Optional[str] = None,
+ sync_apple_reminders: bool = False,
+ ) -> Dict[str, Any]:
+ """Convert a chat message into a sovereign .tasker markdown task."""
+ cursor = self._conn.cursor()
+ cursor.execute("SELECT * FROM bitchat_messages WHERE msg_id = ?", (msg_id,))
+ row = cursor.fetchone()
+ if not row:
+ return {"ok": False, "error": f"Message {msg_id} not found"}
+
+ from app.services.feed_workflow import promote_activity_to_task
+
+ sender = row["sender_name"]
+ content = row["content"]
+ channel = row["channel"]
+ task_title = f"Action from {sender}: {content[:50].strip()}"
+
+ mock_activity = {
+ "id": int(time.time()),
+ "source": "bitchat",
+ "title": task_title,
+ "content": f"Originated from BitChat `{channel}` by **{sender}**:\n\n> {content}",
+ }
+
+ promoted = promote_activity_to_task(
+ mock_activity,
+ title=task_title,
+ board=board,
+ priority=priority,
+ binder=binder,
+ due_date=due_date,
+ sync_apple_reminders=sync_apple_reminders,
+ )
+
+ task_id = promoted["task_id"]
+ cursor.execute(
+ "UPDATE bitchat_messages SET task_id = ? WHERE msg_id = ?",
+ (task_id, msg_id),
+ )
+ self._conn.commit()
+
+ return {
+ "ok": True,
+ "task_id": task_id,
+ "path": promoted["path"],
+ "reminders_sync": promoted.get("reminders_sync"),
+ }
+
+
+# Singleton instance
+_global_bitchat_store = BitChatStore()
+
+
+def get_bitchat_store() -> BitChatStore:
+ return _global_bitchat_store
diff --git a/backend/app/services/device_calc.py b/backend/app/services/device_calc.py
new file mode 100644
index 0000000..5455709
--- /dev/null
+++ b/backend/app/services/device_calc.py
@@ -0,0 +1,143 @@
+"""Universal Device Display and Container Storage Capability Calculator.
+
+Implements authoritative mathematical formulas from:
+- global-knowledge/standards/DEVICE-DISPLAY-STANDARDS.md
+- global-knowledge/standards/CONTAINER-STANDARDS.md
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, Optional
+
+
+def calc_display_capability(
+ width: int,
+ height: int,
+ device_type: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Calculate display capabilities, character grid capacity, reading measure,
+
+ and Zen viewport scaling for a given screen resolution.
+ """
+ w = max(1, int(width))
+ h = max(1, int(height))
+ aspect_ratio_val = w / h
+
+ # Determine reference aspect and canvas
+ if aspect_ratio_val < 1.5:
+ target_aspect = "4:3"
+ ref_w, ref_h = 800, 600
+ else:
+ target_aspect = "16:9"
+ ref_w, ref_h = 1280, 720
+
+ scale_factor = round(min(w / ref_w, h / ref_h), 3)
+
+ # GridCore Cell Algebra
+ sq_cols = w // 8
+ sq_rows = h // 8
+ tall_cols = w // 12
+ tall_rows = h // 20
+ super_cols = w // 24
+ super_rows = h // 40
+
+ fits_teletext_1x = (w >= 480) and (h >= 500)
+ fits_teletext_2x = (w >= 960) and (h >= 1000)
+
+ # Prose Line Measure
+ # Standard character measure: 1ch approx 0.6 * 16px = 9.6px
+ char_capacity = int(w / 9.6)
+ if w < 480:
+ layout_mode = "mobile_reflow"
+ recommended_measure_ch = 40
+ max_width_px = w
+ elif w < 768:
+ layout_mode = "compact_tablet"
+ recommended_measure_ch = 60
+ max_width_px = min(w - 32, 580)
+ else:
+ layout_mode = "optimal_zen"
+ recommended_measure_ch = 72
+ max_width_px = 680
+
+ # BOB Blitter Object Memory Budget
+ # Typical 64x64 BOB with 12 frames at 1 byte/pixel (indexed palette)
+ typical_bob_ram = 64 * 64 * 12 * 1
+
+ return {
+ "resolution": {"width": w, "height": h},
+ "aspect_ratio": target_aspect,
+ "aspect_ratio_ratio": round(aspect_ratio_val, 2),
+ "device_type": device_type or "auto",
+ "gridcore": {
+ "dot_px": 4,
+ "square_register": {"cols": sq_cols, "rows": sq_rows, "cell_px": "8x8"},
+ "tall_register": {"cols": tall_cols, "rows": tall_rows, "cell_px": "12x20"},
+ "super_cells": {"cols": super_cols, "rows": super_rows, "tile_px": "24x40"},
+ "teletext_standard": {
+ "required_px": "480x500",
+ "fits_1x": fits_teletext_1x,
+ "fits_2x": fits_teletext_2x,
+ },
+ },
+ "prose": {
+ "char_capacity_ch": char_capacity,
+ "layout_mode": layout_mode,
+ "recommended_measure_ch": recommended_measure_ch,
+ "max_width_px": max_width_px,
+ },
+ "zen_viewport": {
+ "target_aspect": target_aspect,
+ "reference_resolution": {"width": ref_w, "height": ref_h},
+ "scale_factor": scale_factor,
+ "scaled_width": int(ref_w * scale_factor),
+ "scaled_height": int(ref_h * scale_factor),
+ "letterbox": {
+ "horizontal_padding_px": max(0, (w - int(ref_w * scale_factor)) // 2),
+ "vertical_padding_px": max(0, (h - int(ref_h * scale_factor)) // 2),
+ },
+ },
+ "bob_budget": {
+ "max_dimensions_px": {"width": 128, "height": 128},
+ "typical_dimensions_px": {"width": 64, "height": 64},
+ "max_frames": 24,
+ "recommended_fps": 12,
+ "typical_ram_bytes": typical_bob_ram,
+ "max_file_size_kb": 60,
+ },
+ }
+
+
+def calc_storage_capacity(
+ total_bytes: int,
+ sys_bytes: Optional[int] = None,
+) -> Dict[str, Any]:
+ """Calculate usable vault knowledge capacity, indexed search capacity,
+
+ and capsule budgets for a given storage size.
+ """
+ total = max(0, int(total_bytes))
+ # Default system overhead: 2.5 GB for base Linux/CMMint + uCore runtime
+ overhead = int(sys_bytes if sys_bytes is not None else 2_500_000_000)
+ usable_vault = max(0, total - overhead)
+
+ # Document & Asset density metrics
+ plain_note_bytes = 3_500
+ fts5_indexed_note_bytes = 4_900 # 1.4x
+ bob_asset_bytes = 25_000
+ mini_capsule_bytes = 10_000_000 # 10 MB
+ zim_capsule_bytes = 1_000_000_000 # 1 GB
+
+ return {
+ "storage_total_bytes": total,
+ "storage_system_bytes": overhead,
+ "storage_vault_usable_bytes": usable_vault,
+ "storage_vault_usable_mb": round(usable_vault / (1024 * 1024), 2),
+ "storage_vault_usable_gb": round(usable_vault / (1024 * 1024 * 1024), 3),
+ "knowledge_capacity": {
+ "plain_prose_notes": usable_vault // plain_note_bytes,
+ "indexed_notes_fts5": usable_vault // fts5_indexed_note_bytes,
+ "curated_bobs_gif": usable_vault // bob_asset_bytes,
+ "mini_capsules_10mb": usable_vault // mini_capsule_bytes,
+ "encyclopedia_zim_capsules_1gb": usable_vault // zim_capsule_bytes,
+ },
+ }
diff --git a/backend/app/services/dispatch_feed.py b/backend/app/services/dispatch_feed.py
new file mode 100644
index 0000000..fe9f8dd
--- /dev/null
+++ b/backend/app/services/dispatch_feed.py
@@ -0,0 +1,128 @@
+"""Sovereign Dispatch Feed Generator.
+
+Generates decentralized RSS 2.0 XML and JSON Feed v1.1 formats for all
+published dispatches and binder editions, including GIF enclosures and Prose descriptions.
+Saves local feed files under ~/Vault/feeds/dispatches.xml.
+"""
+from __future__ import annotations
+
+import email.utils
+import html
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from app.core.settings import settings
+from app.services.dispatch_store import dispatch_store
+
+log = logging.getLogger("ucore.dispatch_feed")
+
+
+class DispatchFeedService:
+ """Syndication engine publishing sovereign dispatches as RSS & JSON feeds."""
+
+ def __init__(self, feeds_dir: Optional[Path] = None):
+ self.feeds_dir = feeds_dir or (settings.vault_root / "feeds")
+ self.feeds_dir.mkdir(parents=True, exist_ok=True)
+
+ def generate_rss_2_0(self, host_url: str = "") -> str:
+ """Generate standard RSS 2.0 XML string."""
+ dispatches = dispatch_store.list_dispatches()
+ now_rfc822 = email.utils.format_datetime(datetime.now(timezone.utc))
+
+ items_xml = []
+ for d in dispatches:
+ if d.get("status") not in ("active", "published"):
+ continue
+
+ token = d.get("token", "")
+ title = html.escape(d.get("title", "Untitled Dispatch"))
+ lead = html.escape(d.get("lead_text", ""))
+ created_ts = d.get("created_at", 0)
+ item_dt = datetime.fromtimestamp(created_ts, timezone.utc) if created_ts else datetime.now(timezone.utc)
+ item_rfc822 = email.utils.format_datetime(item_dt)
+
+ item_link = f"{host_url}/p/{token}" if token else host_url
+ enclosure_xml = ""
+ hero_asset = d.get("hero_asset")
+ if hero_asset:
+ full_asset_url = f"{host_url}{hero_asset}" if hero_asset.startswith("/") else hero_asset
+ enclosure_xml = f' \n'
+
+ item_block = f""" -
+ {title}
+ {item_link}
+ {d.get('id', token)}
+ {item_rfc822}
+ {lead}
+{enclosure_xml}
"""
+ items_xml.append(item_block)
+
+ items_joined = "\n".join(items_xml)
+ rss_content = f"""
+
+
+ uDos Sovereign Dispatches
+ {host_url}/api/dispatch/feed.xml
+ Sovereign invitations, interactive stories, and binder dispatches.
+ en
+ {now_rfc822}
+
+{items_joined}
+
+"""
+ return rss_content
+
+ def generate_json_feed(self, host_url: str = "") -> Dict[str, Any]:
+ """Generate modern JSON Feed v1.1 format."""
+ dispatches = dispatch_store.list_dispatches()
+ items = []
+
+ for d in dispatches:
+ if d.get("status") not in ("active", "published"):
+ continue
+
+ token = d.get("token", "")
+ created_iso = d.get("created_iso", datetime.now(timezone.utc).isoformat())
+ hero_asset = d.get("hero_asset")
+ image_url = f"{host_url}{hero_asset}" if hero_asset and hero_asset.startswith("/") else hero_asset
+
+ items.append({
+ "id": d.get("id", token),
+ "url": f"{host_url}/p/{token}" if token else host_url,
+ "title": d.get("title", "Untitled Dispatch"),
+ "summary": d.get("lead_text", ""),
+ "date_published": created_iso,
+ "image": image_url,
+ })
+
+ return {
+ "version": "https://jsonfeed.org/version/1.1",
+ "title": "uDos Sovereign Dispatches",
+ "home_page_url": f"{host_url}/p/",
+ "feed_url": f"{host_url}/api/dispatch/feed.json",
+ "description": "Sovereign invitations, interactive stories, and binder dispatches.",
+ "items": items,
+ }
+
+ def sync_to_vault(self, host_url: str = "") -> Dict[str, str]:
+ """Persist generated RSS and JSON feeds into ~/Vault/feeds/."""
+ rss_xml = self.generate_rss_2_0(host_url)
+ json_feed = self.generate_json_feed(host_url)
+
+ rss_path = self.feeds_dir / "dispatches.xml"
+ json_path = self.feeds_dir / "dispatches.json"
+
+ rss_path.write_text(rss_xml, encoding="utf-8")
+ json_path.write_text(json.dumps(json_feed, indent=2), encoding="utf-8")
+
+ log.info("Syndicated dispatch feeds written to %s and %s", rss_path, json_path)
+ return {
+ "rss_path": str(rss_path),
+ "json_path": str(json_path),
+ }
+
+
+dispatch_feed_service = DispatchFeedService()
diff --git a/backend/app/services/dispatch_offline.py b/backend/app/services/dispatch_offline.py
new file mode 100644
index 0000000..3c1ec0f
--- /dev/null
+++ b/backend/app/services/dispatch_offline.py
@@ -0,0 +1,365 @@
+"""Sovereign Offline Dispatch & Capsule Exporter.
+
+Builds 100% self-contained, single-file HTML dispatches ('story.standalone.html')
+and portable signed '.capsule' archives.
+Allows offline viewing from a USB flash drive without internet or server runtime.
+Offline submissions download a formatted response JSON file for return to the host.
+"""
+from __future__ import annotations
+
+import base64
+import html
+import json
+import logging
+import subprocess
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from app.core.settings import settings
+from app.services.dispatch_store import dispatch_store
+
+log = logging.getLogger("ucore.dispatch_offline")
+
+
+def generate_standalone_html(dispatch_id: str) -> str:
+ """Generate self-contained single-file HTML story player."""
+ dispatch = dispatch_store.get_dispatch(dispatch_id)
+ if not dispatch:
+ raise ValueError(f"Dispatch '{dispatch_id}' not found")
+
+ manifest = dispatch["dispatch"]
+ story_md = dispatch["story_markdown"]
+
+ title = html.escape(manifest.get("title", "Sovereign Dispatch"))
+ lead = html.escape(manifest.get("lead_text", ""))
+ token = manifest.get("token", "")
+
+ # Embed hero GIF as base64 data URI if available
+ hero_b64 = ""
+ dispatch_dir = dispatch_store.root_dir / dispatch_id
+ hero_file = dispatch_dir / "originals" / "hero.gif"
+ if hero_file.exists():
+ hero_b64 = f"data:image/gif;base64,{base64.b64encode(hero_file.read_bytes()).decode('utf-8')}"
+ elif manifest.get("hero_asset"):
+ # Check catalog
+ asset_name = Path(manifest["hero_asset"]).name
+ catalog_file = settings.public_vault_root / "global-knowledge" / "elements" / asset_name
+ if catalog_file.exists():
+ hero_b64 = f"data:image/gif;base64,{base64.b64encode(catalog_file.read_bytes()).decode('utf-8')}"
+
+ hero_img_tag = f'
' if hero_b64 else ""
+
+ # JSON escaped data for pure client-side runner
+ escaped_story_json = json.dumps(story_md)
+ escaped_manifest_json = json.dumps(manifest)
+
+ return f"""
+
+
+
+
+ {title} — uDos Sovereign Story
+
+
+
+
+
+ {hero_img_tag}
+ {title}
+ {lead}
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def export_offline_bundle(dispatch_id: str) -> Dict[str, Any]:
+ """Generate standalone HTML and pack into a signed .capsule archive."""
+ html_content = generate_standalone_html(dispatch_id)
+
+ invites_dir = settings.shared_vault_root / "invites"
+ capsules_dir = settings.shared_vault_root / "capsules"
+ invites_dir.mkdir(parents=True, exist_ok=True)
+ capsules_dir.mkdir(parents=True, exist_ok=True)
+
+ html_path = invites_dir / f"{dispatch_id}.html"
+ html_path.write_text(html_content, encoding="utf-8")
+
+ # Also prepare a directory bundle for capsule packaging
+ dispatch_dir = dispatch_store.root_dir / dispatch_id
+ capsule_out = capsules_dir / f"{dispatch_id}.capsule"
+
+ # Attempt to pack with sonic CLI or python
+ try:
+ sonic_venv = Path(__file__).resolve().parents[4] / "SonicScrewdriver" / ".venv" / "bin" / "sonic"
+ if sonic_venv.exists():
+ cmd = [
+ str(sonic_venv),
+ "package", "pack",
+ str(dispatch_dir),
+ "--out", str(capsule_out),
+ "--title", f"Dispatch {dispatch_id}",
+ ]
+ subprocess.run(cmd, capture_output=True, check=True)
+ except Exception as exc:
+ log.warning("Could not pack with sonic CLI: %s. Writing standalone HTML only.", exc)
+
+ return {
+ "status": "ok",
+ "dispatch_id": dispatch_id,
+ "standalone_html_path": str(html_path),
+ "capsule_path": str(capsule_out) if capsule_out.exists() else None,
+ }
diff --git a/backend/app/services/dispatch_store.py b/backend/app/services/dispatch_store.py
new file mode 100644
index 0000000..f12b298
--- /dev/null
+++ b/backend/app/services/dispatch_store.py
@@ -0,0 +1,431 @@
+"""Dispatch Store & Ephemeral Token Manager.
+
+Manages interactive story dispatches, ephemeral access tokens (/p/:token),
+RSVP submission records, and preserves raw originals under ~/Vault/dispatches//originals/.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import secrets
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from app.core.settings import settings
+
+log = logging.getLogger("ucore.dispatch_store")
+
+
+class DispatchStore:
+ """Sovereign store for interactive dispatches and RSVPs."""
+
+ def __init__(self, root_dir: Optional[Path] = None, token_index_path: Optional[Path] = None):
+ self.root_dir = root_dir or (settings.vault_root / "dispatches")
+ self.token_index_path = token_index_path or (settings.data_dir / "dispatch_tokens.json")
+ self.root_dir.mkdir(parents=True, exist_ok=True)
+ self.token_index_path.parent.mkdir(parents=True, exist_ok=True)
+ self._load_tokens()
+
+ def _load_tokens(self) -> Dict[str, str]:
+ if not self.token_index_path.exists():
+ return {}
+ try:
+ data = json.loads(self.token_index_path.read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ return data
+ except Exception as exc:
+ log.warning("Could not read dispatch token index: %s", exc)
+ return {}
+
+ def _save_tokens(self, tokens: Dict[str, str]) -> None:
+ try:
+ self.token_index_path.write_text(json.dumps(tokens, indent=2), encoding="utf-8")
+ except Exception as exc:
+ log.error("Failed to write dispatch token index: %s", exc)
+
+ def create_dispatch(
+ self,
+ title: str,
+ story_markdown: str,
+ lead_text: str = "",
+ hero_gif_bytes: Optional[bytes] = None,
+ hero_gif_name: Optional[str] = None,
+ expires_in_seconds: Optional[int] = None,
+ burn_after_read: bool = False,
+ mode_default: str = "card",
+ binder_id: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """Create a new dispatch, generate an ephemeral token, and preserve originals."""
+ timestamp = int(time.time())
+ dispatch_id = f"disp_{timestamp}_{secrets.token_hex(4)}"
+ token = secrets.token_urlsafe(16)
+
+ dispatch_dir = self.root_dir / dispatch_id
+ originals_dir = dispatch_dir / "originals"
+ originals_dir.mkdir(parents=True, exist_ok=True)
+
+ # Preserve original markdown
+ (originals_dir / "source.story.md").write_text(story_markdown, encoding="utf-8")
+
+ # Save hero GIF if provided
+ hero_rel_path: Optional[str] = None
+ if hero_gif_bytes:
+ hero_file = originals_dir / "hero.gif"
+ hero_file.write_bytes(hero_gif_bytes)
+ hero_rel_path = f"/api/dispatch/asset/{dispatch_id}/hero.gif"
+ elif hero_gif_name:
+ hero_rel_path = f"/api/dispatch/catalog/asset/{hero_gif_name}"
+
+ expires_at: Optional[int] = None
+ if expires_in_seconds and expires_in_seconds > 0:
+ expires_at = timestamp + expires_in_seconds
+
+ manifest = {
+ "id": dispatch_id,
+ "token": token,
+ "title": title,
+ "lead_text": lead_text,
+ "created_at": timestamp,
+ "created_iso": datetime.now(timezone.utc).isoformat(),
+ "expires_at": expires_at,
+ "burn_after_read": bool(burn_after_read),
+ "mode_default": mode_default,
+ "hero_asset": hero_rel_path,
+ "binder_id": binder_id,
+ "view_count": 0,
+ "status": "active",
+ }
+
+ (dispatch_dir / "dispatch.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+ (dispatch_dir / "responses.json").write_text("[]", encoding="utf-8")
+
+ # Update token index
+ tokens = self._load_tokens()
+ tokens[token] = dispatch_id
+ self._save_tokens(tokens)
+
+ # Ingest into Feed Activity Pod (Snackbar)
+ try:
+ from app.services.feed_store import FeedServer
+ FeedServer().ingest_activity_sync(
+ source="dispatch",
+ external_id=dispatch_id,
+ type="dispatch_published",
+ title=f"Published: {title}",
+ content=lead_text or title,
+ url=f"/p/{token}",
+ importance=0.6,
+ metadata={
+ "dispatch_id": dispatch_id,
+ "token": token,
+ "binder_id": binder_id,
+ "mode_default": mode_default,
+ },
+ )
+ except Exception as exc:
+ log.warning("Could not ingest dispatch creation into feed: %s", exc)
+
+ # Update syndicated feeds in ~/Vault/feeds/
+ try:
+ from app.services.dispatch_feed import dispatch_feed_service
+ dispatch_feed_service.sync_to_vault()
+ except Exception as exc:
+ log.warning("Could not sync feeds: %s", exc)
+
+ return manifest
+
+ def get_dispatch_by_token(self, token: str, record_view: bool = True) -> Dict[str, Any]:
+ """Retrieve dispatch by guest token, evaluating expiration and burn status."""
+ tokens = self._load_tokens()
+ dispatch_id = tokens.get(token)
+ if not dispatch_id:
+ return {"status": "not_found", "error": "Dispatch not found"}
+
+ dispatch_dir = self.root_dir / dispatch_id
+ manifest_path = dispatch_dir / "dispatch.json"
+ if not manifest_path.exists():
+ return {"status": "not_found", "error": "Dispatch manifest missing"}
+
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+
+ # Check burn status
+ if manifest.get("status") == "burned":
+ return {
+ "status": "burned",
+ "tombstone": "This dispatch was ephemeral and has dissolved into the ether.",
+ }
+
+ # Check expiration
+ expires_at = manifest.get("expires_at")
+ now = int(time.time())
+ if expires_at and now > expires_at:
+ manifest["status"] = "expired"
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+ return {
+ "status": "expired",
+ "tombstone": "This dispatch has expired and dissolved into the ether.",
+ }
+
+ # Record view count
+ if record_view:
+ manifest["view_count"] = manifest.get("view_count", 0) + 1
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+
+ # Read story markdown
+ story_path = dispatch_dir / "originals" / "source.story.md"
+ story_md = story_path.read_text(encoding="utf-8") if story_path.exists() else ""
+
+ return {
+ "status": "active",
+ "dispatch": manifest,
+ "story_markdown": story_md,
+ }
+
+ def submit_rsvp(self, token: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ """Submit response to a dispatch, updating responses.json and burning if configured."""
+ record = self.get_dispatch_by_token(token, record_view=False)
+ if record.get("status") != "active":
+ return record
+
+ manifest = record["dispatch"]
+ dispatch_id = manifest["id"]
+ dispatch_dir = self.root_dir / dispatch_id
+ responses_path = dispatch_dir / "responses.json"
+
+ responses: List[Dict[str, Any]] = []
+ if responses_path.exists():
+ try:
+ responses = json.loads(responses_path.read_text(encoding="utf-8"))
+ except Exception:
+ responses = []
+
+ entry = {
+ "timestamp": int(time.time()),
+ "submitted_iso": datetime.now(timezone.utc).isoformat(),
+ "data": payload,
+ }
+ responses.append(entry)
+ responses_path.write_text(json.dumps(responses, indent=2), encoding="utf-8")
+
+ # Burn token if configured
+ if manifest.get("burn_after_read"):
+ manifest["status"] = "burned"
+ (dispatch_dir / "dispatch.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+
+ # Ingest RSVP into Feed Activity Pod (notifies Snackbar)
+ contact_name = payload.get("name") or payload.get("contact_name") or "Anonymous Sovereign Guest"
+ try:
+ from app.services.feed_store import FeedServer
+ FeedServer().ingest_activity_sync(
+ source="dispatch",
+ external_id=f"{dispatch_id}_rsvp_{len(responses)}",
+ type="dispatch_rsvp_received",
+ title=f"RSVP Received: {manifest.get('title')}",
+ content=f"Response from {contact_name}",
+ url=f"/p/{token}",
+ contact_name=contact_name,
+ importance=0.8,
+ metadata={
+ "dispatch_id": dispatch_id,
+ "binder_id": manifest.get("binder_id"),
+ "submission": payload,
+ },
+ )
+ except Exception as exc:
+ log.warning("Could not ingest RSVP into feed: %s", exc)
+
+ return {
+ "status": "success",
+ "message": "Response recorded peacefully",
+ "burned": manifest.get("burn_after_read", False),
+ }
+
+ def get_responses(self, dispatch_id: str) -> List[Dict[str, Any]]:
+ """Host query for all collected responses."""
+ responses_path = self.root_dir / dispatch_id / "responses.json"
+ if not responses_path.exists():
+ return []
+ try:
+ return json.loads(responses_path.read_text(encoding="utf-8"))
+ except Exception:
+ return []
+
+ def convert_rsvp_to_task(
+ self,
+ dispatch_id: str,
+ rsvp_index: int,
+ board: str = "inbox",
+ priority: str = "medium",
+ binder: Optional[str] = None,
+ mission: Optional[str] = None,
+ due_date: Optional[str] = None,
+ sync_apple_reminders: bool = False,
+ ) -> Dict[str, Any]:
+ """Convert a collected RSVP submission into a sovereign .tasker task."""
+ dispatch_dir = self.root_dir / dispatch_id
+ responses_path = dispatch_dir / "responses.json"
+ manifest_path = dispatch_dir / "dispatch.json"
+
+ if not responses_path.exists() or not manifest_path.exists():
+ return {"status": "not_found", "error": "Dispatch or responses not found"}
+
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ responses = json.loads(responses_path.read_text(encoding="utf-8"))
+
+ if rsvp_index < 0 or rsvp_index >= len(responses):
+ return {"status": "not_found", "error": f"RSVP index {rsvp_index} out of range"}
+
+ entry = responses[rsvp_index]
+ payload = entry.get("data") or {}
+ contact_name = payload.get("name") or payload.get("contact_name") or payload.get("attendee_name") or "Anonymous Guest"
+ answers = payload.get("answers") or {}
+ answers_str = "\n".join(f"- **{k}**: {v}" for k, v in answers.items()) if answers else ""
+ notes = payload.get("notes") or payload.get("email") or ""
+
+ target_binder = binder or manifest.get("binder_id") or "Sandbox"
+ dispatch_title = manifest.get("title") or dispatch_id
+ task_title = f"RSVP Follow-up: {contact_name} ({dispatch_title})"
+
+ body_lines = [
+ f"RSVP submission for dispatch **{dispatch_title}** (`{dispatch_id}`).",
+ f"- **Respondent**: {contact_name}",
+ f"- **Submitted**: {entry.get('submitted_iso', '')}",
+ ]
+ if notes:
+ body_lines.append(f"- **Notes/Email**: {notes}")
+ if answers_str:
+ body_lines.append("\n### Form Responses\n" + answers_str)
+
+ body_content = "\n".join(body_lines)
+
+ from app.services.feed_workflow import promote_activity_to_task
+ mock_activity = {
+ "id": int(time.time()),
+ "source": "dispatch",
+ "title": task_title,
+ "content": body_content,
+ }
+ promoted = promote_activity_to_task(
+ mock_activity,
+ title=task_title,
+ board=board,
+ priority=priority,
+ binder=target_binder,
+ mission=mission,
+ due_date=due_date,
+ sync_apple_reminders=sync_apple_reminders,
+ )
+
+ task_id = promoted["task_id"]
+ entry["task_id"] = task_id
+ responses_path.write_text(json.dumps(responses, indent=2), encoding="utf-8")
+
+ # Link in Activity Pod if possible
+ try:
+ from app.services.feed_store import FeedServer
+ feed = FeedServer()
+ ext_id = f"{dispatch_id}_rsvp_{rsvp_index + 1}"
+ rows = feed._conn.execute(
+ "SELECT id FROM user_activity WHERE source = 'dispatch' AND external_id = ?",
+ (ext_id,),
+ ).fetchall()
+ if rows:
+ act_id = rows[0]["id"]
+ feed._conn.execute(
+ "INSERT INTO task_activity_links (task_id, activity_id, link_type) VALUES (?, ?, 'dispatch_rsvp')",
+ (task_id, act_id),
+ )
+ feed._conn.execute("UPDATE user_activity SET processed = 1 WHERE id = ?", (act_id,))
+ feed._conn.commit()
+ except Exception as exc:
+ log.debug("Could not link task in activity pod: %s", exc)
+
+ return {
+ "status": "ok",
+ "task_id": task_id,
+ "path": promoted["path"],
+ "reminders_sync": promoted.get("reminders_sync"),
+ }
+
+ def get_dispatches_by_binder(self, binder_id: str) -> List[Dict[str, Any]]:
+ """Find all dispatches associated with a specific binder."""
+ results: List[Dict[str, Any]] = []
+ if not self.root_dir.exists():
+ return results
+
+ for child in self.root_dir.iterdir():
+ if child.is_dir():
+ manifest_path = child / "dispatch.json"
+ if manifest_path.exists():
+ try:
+ m = json.loads(manifest_path.read_text(encoding="utf-8"))
+ if m.get("binder_id") == binder_id:
+ # Attach response count
+ resps = self.get_responses(m["id"])
+ m["response_count"] = len(resps)
+ results.append(m)
+ except Exception:
+ pass
+ # Sort newest first
+ results.sort(key=lambda x: x.get("created_at", 0), reverse=True)
+ return results
+
+ def list_dispatches(self) -> List[Dict[str, Any]]:
+ """List all dispatches sorted newest first."""
+ results: List[Dict[str, Any]] = []
+ if not self.root_dir.exists():
+ return results
+
+ for child in self.root_dir.iterdir():
+ if child.is_dir():
+ manifest_path = child / "dispatch.json"
+ if manifest_path.exists():
+ try:
+ m = json.loads(manifest_path.read_text(encoding="utf-8"))
+ resps = self.get_responses(m["id"])
+ m["response_count"] = len(resps)
+ results.append(m)
+ except Exception:
+ pass
+ results.sort(key=lambda x: x.get("created_at", 0), reverse=True)
+ return results
+
+ def get_dispatch(self, dispatch_id: str) -> Optional[Dict[str, Any]]:
+ """Get dispatch record and source markdown by dispatch ID."""
+ dispatch_dir = self.root_dir / dispatch_id
+ manifest_path = dispatch_dir / "dispatch.json"
+ if not manifest_path.exists():
+ return None
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ story_path = dispatch_dir / "originals" / "source.story.md"
+ story_md = story_path.read_text(encoding="utf-8") if story_path.exists() else ""
+ return {
+ "dispatch": manifest,
+ "story_markdown": story_md,
+ }
+ except Exception:
+ return None
+
+ def load_catalog(self) -> Dict[str, Any]:
+ """Load curated standard element library from global-knowledge."""
+ catalog_path = settings.public_vault_root / "global-knowledge" / "elements" / "catalog.json"
+ if catalog_path.exists():
+ try:
+ return json.loads(catalog_path.read_text(encoding="utf-8"))
+ except Exception as exc:
+ log.warning("Could not read element catalog: %s", exc)
+
+ # Fallback empty catalog
+ return {
+ "title": "uDos Curated Standard Element Library",
+ "version": "1.0.0",
+ "categories": {"cards": [], "bobs": [], "dividers": []},
+ }
+
+
+# Global singleton
+dispatch_store = DispatchStore()
+
diff --git a/backend/app/services/email_dispatch.py b/backend/app/services/email_dispatch.py
new file mode 100644
index 0000000..859d47b
--- /dev/null
+++ b/backend/app/services/email_dispatch.py
@@ -0,0 +1,162 @@
+"""Sovereign Prose Email Compiler.
+
+Compiles bulletproof, single-column HTML emails adhering to USX Prose Standards:
+- Strict max-width (580px/640px)
+- MSO table fallbacks for Outlook compatibility
+- System typography stacks (Charter serif, Inter sans)
+- Embedded hero GIF (via CID or link)
+- Zero tracking pixels and zero third-party web font downloads
+"""
+from __future__ import annotations
+
+import html
+from typing import Optional
+
+
+def compile_prose_email(
+ title: str,
+ lead_text: str,
+ body_html: str,
+ action_label: Optional[str] = None,
+ action_url: Optional[str] = None,
+ hero_gif_src: Optional[str] = None,
+ theme: str = "dark",
+) -> str:
+ """Compile a single-column, bulletproof HTML email."""
+ safe_title = html.escape(title)
+ safe_lead = html.escape(lead_text)
+
+ # Color tokens based on USX dark or light theme
+ if theme == "light":
+ bg_color = "#F6F8FA"
+ card_bg = "#FFFFFF"
+ text_primary = "#1F2328"
+ text_muted = "#656D76"
+ border_color = "#D0D7DE"
+ btn_bg = "#0969DA"
+ btn_text = "#FFFFFF"
+ else:
+ bg_color = "#0D1117"
+ card_bg = "#161B22"
+ text_primary = "#E6EDF3"
+ text_muted = "#8B949E"
+ border_color = "#30363D"
+ btn_bg = "#58A6FF"
+ btn_text = "#0D1117"
+
+ hero_block = ""
+ if hero_gif_src:
+ hero_block = f"""
+
+
+
+ |
+
+ """
+
+ button_block = ""
+ if action_label and action_url:
+ safe_action_label = html.escape(action_label)
+ button_block = f"""
+
+ |
+
+
+ {safe_action_label}
+
+ |
+
+ """
+
+ return f"""
+
+
+
+
+
+
+ {safe_title}
+
+
+
+
+
+
+
+ {hero_block}
+
+
+ {safe_title}
+ |
+
+
+ |
+ {safe_lead}
+ |
+
+
+ |
+ {body_html}
+ |
+
+ {button_block}
+
+ |
+
+ Dispatched via sovereign uDos • Zero tracking pixels • Local first
+
+ |
+
+
+
+
+
+"""
+
+
+def generate_plain_text_email(
+ title: str,
+ lead_text: str,
+ body_text: str,
+ action_label: Optional[str] = None,
+ action_url: Optional[str] = None,
+) -> str:
+ """Generate a clean plain-text fallback version."""
+ lines = [
+ title,
+ "=" * len(title),
+ "",
+ lead_text,
+ "",
+ body_text,
+ "",
+ ]
+ if action_label and action_url:
+ lines.extend([f"-> {action_label}: {action_url}", ""])
+ lines.append("Dispatched via sovereign uDos (offline-first, zero tracking)")
+ return "\n".join(lines)
diff --git a/backend/app/services/feed_rules.py b/backend/app/services/feed_rules.py
index 992ce74..e2402b4 100644
--- a/backend/app/services/feed_rules.py
+++ b/backend/app/services/feed_rules.py
@@ -1,6 +1,7 @@
"""Small, auditable rules engine for turning Feed signals into workflow proposals."""
from __future__ import annotations
+import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -24,6 +25,10 @@ class FeedRule:
board: str
priority: str
binder: str
+ require_flagged: bool = False
+ require_vip: bool = False
+ sync_reminders: bool = False
+ archive_mail: bool = False
def matches(self, activity: dict[str, Any]) -> bool:
source = str(activity.get("source") or "").lower()
@@ -31,6 +36,19 @@ def matches(self, activity: dict[str, Any]) -> bool:
return False
if float(activity.get("importance") or 0) < self.min_importance:
return False
+
+ meta = activity.get("metadata") or {}
+ if isinstance(meta, str):
+ try:
+ meta = json.loads(meta)
+ except Exception:
+ meta = {}
+
+ if self.require_flagged and not meta.get("is_flagged"):
+ return False
+ if self.require_vip and not meta.get("vip"):
+ return False
+
haystack = " ".join(
str(activity.get(key) or "") for key in ("title", "content", "type")
).lower()
@@ -53,10 +71,14 @@ def load_feed_rules(path: Path | None = None) -> tuple[list[FeedRule], dict[str,
sources=tuple(str(value).lower() for value in match.get("sources") or []),
contains=tuple(str(value).lower() for value in match.get("contains") or []),
min_importance=float(match.get("min_importance", 0)),
+ require_flagged=bool(match.get("flagged", False)),
+ require_vip=bool(match.get("vip", False)),
action=str(action.get("type") or "propose-task"),
board=str(action.get("board") or "inbox"),
priority=str(action.get("priority") or "medium"),
binder=str(action.get("binder") or "Sandbox"),
+ sync_reminders=bool(action.get("sync_reminders", False)),
+ archive_mail=bool(action.get("archive_mail", False)),
)
)
return rules, {
@@ -81,6 +103,8 @@ def evaluate_feed_rules(
"board": rule.board,
"priority": rule.priority,
"binder": rule.binder,
+ "sync_reminders": rule.sync_reminders,
+ "archive_mail": rule.archive_mail,
"title": str(activity.get("title") or activity.get("type") or "Feed item"),
}
)
diff --git a/backend/app/services/feed_store.py b/backend/app/services/feed_store.py
index 695a46a..7a747c9 100644
--- a/backend/app/services/feed_store.py
+++ b/backend/app/services/feed_store.py
@@ -68,7 +68,7 @@ def pod_path(self) -> Path:
# ── Activity ingestion ────────────────────────────────────────
- async def ingest_activity(
+ def ingest_activity_sync(
self,
source: str,
type: str,
@@ -80,7 +80,7 @@ async def ingest_activity(
metadata: dict[str, Any] | None = None,
external_id: str | None = None,
) -> dict[str, Any]:
- """Insert a user activity event into the Feed Pod."""
+ """Insert a user activity event synchronously into the Feed Pod."""
cursor = self._conn.cursor()
cursor.execute(
"""INSERT INTO user_activity
@@ -116,6 +116,31 @@ async def ingest_activity(
)
return {"id": row_id, "message": "Activity ingested"}
+ async def ingest_activity(
+ self,
+ source: str,
+ type: str,
+ title: str = "",
+ content: str = "",
+ url: str = "",
+ contact_name: str = "",
+ importance: float = 0.5,
+ metadata: dict[str, Any] | None = None,
+ external_id: str | None = None,
+ ) -> dict[str, Any]:
+ """Insert a user activity event into the Feed Pod."""
+ return self.ingest_activity_sync(
+ source=source,
+ type=type,
+ title=title,
+ content=content,
+ url=url,
+ contact_name=contact_name,
+ importance=importance,
+ metadata=metadata,
+ external_id=external_id,
+ )
+
# ── Activity query ────────────────────────────────────────────
async def query_feed(
@@ -264,3 +289,48 @@ async def link_task_to_activity(
"activity_id": activity_id,
"link_type": link_type,
}
+
+ def resolve_activity(
+ self,
+ activity_id: int,
+ note: str = "",
+ archived: bool = True,
+ ) -> dict[str, Any]:
+ """Resolve/archive a feed activity, marking it processed and serene."""
+ cursor = self._conn.cursor()
+ cursor.execute("SELECT id, metadata FROM user_activity WHERE id = ?", (activity_id,))
+ row = cursor.fetchone()
+ if not row:
+ return {"ok": False, "error": f"Activity {activity_id} not found"}
+
+ try:
+ meta = json.loads(row["metadata"] or "{}")
+ except Exception:
+ meta = {}
+
+ import time
+ meta["resolved_at"] = int(time.time())
+ meta["resolution_note"] = note
+ meta["archived"] = bool(archived)
+
+ cursor.execute(
+ "UPDATE user_activity SET processed = 1, metadata = ? WHERE id = ?",
+ (json.dumps(meta), activity_id),
+ )
+ self._conn.commit()
+ log.info("Feed resolved: id=%s note=%s", activity_id, note)
+ return {"ok": True, "activity_id": activity_id, "processed": 1, "archived": archived}
+
+ def bulk_resolve(
+ self,
+ activity_ids: list[int],
+ note: str = "",
+ ) -> dict[str, Any]:
+ """Bulk resolve multiple feed activities."""
+ resolved = []
+ for aid in activity_ids:
+ res = self.resolve_activity(aid, note=note)
+ if res.get("ok"):
+ resolved.append(aid)
+ return {"ok": True, "resolved_count": len(resolved), "resolved_ids": resolved}
+
diff --git a/backend/app/services/feed_workflow.py b/backend/app/services/feed_workflow.py
index cc548e3..ec5ca29 100644
--- a/backend/app/services/feed_workflow.py
+++ b/backend/app/services/feed_workflow.py
@@ -15,7 +15,11 @@ def promote_activity_to_task(
board: str = "inbox",
priority: str = "medium",
binder: str = "Sandbox",
+ mission: str | None = None,
+ due_date: str | None = None,
rule_id: str | None = None,
+ sync_apple_reminders: bool = False,
+ archive_source_mail: bool = False,
) -> dict[str, Any]:
activity_id = int(activity["id"])
task_title = str(title or activity.get("title") or "Feed item").strip()
@@ -27,6 +31,45 @@ def promote_activity_to_task(
tags = ["user", "feed", source]
if rule_id:
tags.append(f"rule-{slugify(rule_id)}")
+
+ metadata: dict[str, Any] = {
+ "priority": priority,
+ "binder": binder,
+ "tags": tags,
+ }
+ if mission:
+ metadata["mission"] = mission
+ if due_date:
+ metadata["due"] = due_date
+
+ reminders_result = None
+ if sync_apple_reminders:
+ try:
+ from app.services.apple_feed_sync import AppleFeedSync
+
+ target_list = binder if binder and binder != "Sandbox" else "uDos"
+ reminders_result = AppleFeedSync().export_reminder(
+ title=task_title,
+ notes=str(activity.get("content") or ""),
+ list_name=target_list,
+ due_date=due_date,
+ )
+ if reminders_result.get("ok"):
+ tags.append("synced:apple-reminders")
+ except Exception:
+ pass
+
+ mail_archived_result = None
+ if archive_source_mail and source == "mail":
+ try:
+ from app.services.apple_feed_sync import AppleFeedSync
+
+ ext_id = str(activity.get("external_id") or "")
+ if ext_id:
+ mail_archived_result = AppleFeedSync().archive_email(ext_id)
+ except Exception:
+ pass
+
target.write_text(
render_task_markdown(
title=task_title,
@@ -34,8 +77,13 @@ def promote_activity_to_task(
source_id=str(activity_id),
status="todo",
body=str(activity.get("content") or ""),
- metadata={"priority": priority, "binder": binder, "tags": tags},
+ metadata=metadata,
),
encoding="utf-8",
)
- return {"task_id": target.stem, "path": str(target)}
+ return {
+ "task_id": target.stem,
+ "path": str(target),
+ "reminders_sync": reminders_result,
+ "mail_archived": mail_archived_result,
+ }
diff --git a/backend/app/services/host_pim.py b/backend/app/services/host_pim.py
index e0c4281..eec1cbc 100644
--- a/backend/app/services/host_pim.py
+++ b/backend/app/services/host_pim.py
@@ -25,10 +25,11 @@
log = logging.getLogger("ucore.services.host_pim")
-def _default_runner(cmd: list[str], timeout: float = 10.0) -> str:
+def _default_runner(cmd: list[str], timeout: float = 10.0, input_data: Optional[str] = None) -> str:
"""Default command runner executing osascript or host CLI binaries."""
proc = subprocess.run(
cmd,
+ input=input_data,
capture_output=True,
text=True,
timeout=timeout,
@@ -145,6 +146,11 @@ def probe_capabilities(self) -> Dict[str, Any]:
"automation": "no_osascript",
}
+ has_shortcuts = shutil.which("shortcuts") is not None
+ app_status["shortcuts"] = {
+ "available": has_shortcuts,
+ "automation": "cli" if has_shortcuts else "none",
+ }
report["host_native"] = app_status
report["capabilities"] = {
"browser_intake": app_status.get("safari", {}).get("available", False),
@@ -153,6 +159,7 @@ def probe_capabilities(self) -> Dict[str, Any]:
"reminders_export": app_status.get("reminders", {}).get("available", False),
"reminders_intake": app_status.get("reminders", {}).get("available", False),
"mail_bridge": app_status.get("mail", {}).get("available", False),
+ "shortcuts": has_shortcuts,
"notifications": has_osascript,
"speech_tts": shutil.which("say") is not None,
}
@@ -172,6 +179,7 @@ def probe_capabilities(self) -> Dict[str, Any]:
"reminders_export": False,
"reminders_intake": False,
"mail_bridge": False,
+ "shortcuts": False,
"notifications": shutil.which("notify-send") is not None,
"speech_tts": shutil.which("spd-say") is not None,
}
@@ -321,14 +329,20 @@ def export_to_apple_reminders(
safe_notes = notes.replace("\\", "\\\\").replace('"', '\\"')
if list_name:
- target_clause = f'list "{list_name.replace("\"", "\\\"")}"'
+ safe_list = list_name.replace("\\", "\\\\").replace('"', '\\"')
+ target_clause = f'list "{safe_list}"'
+ ensure_list_clause = f"""if not (exists list "{safe_list}") then
+ make new list with properties {{name:"{safe_list}"}}
+ end if"""
else:
target_clause = "default list"
+ ensure_list_clause = ""
due_clause = f', due date:date "{due_date}"' if due_date else ""
script = f"""
tell application "Reminders"
+ {ensure_list_clause}
set targetList to {target_clause}
make new reminder at targetList with properties {{name:"{safe_title}", body:"{safe_notes}"{due_clause}}}
end tell
@@ -542,3 +556,109 @@ def say(self, text: str, voice: Optional[str] = None) -> Dict[str, Any]:
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": False, "error": "Speech synthesizer not available"}
+
+ def list_shortcuts(self) -> list[str]:
+ """List available shortcuts on macOS non-invasively."""
+ if not self.is_macos() or not shutil.which("shortcuts"):
+ return []
+ try:
+ raw = self.runner(["shortcuts", "list"])
+ return [line.strip() for line in raw.splitlines() if line.strip()]
+ except Exception as exc:
+ log.debug("Could not list shortcuts: %s", exc)
+ return []
+
+ def run_shortcut(self, name: str, input_text: str = "") -> Dict[str, Any]:
+ """Execute a macOS shortcut with optional input text."""
+ if not self.is_macos() or not shutil.which("shortcuts"):
+ return {
+ "ok": False,
+ "error": "macOS Shortcuts CLI not available",
+ }
+ cmd = ["shortcuts", "run", name]
+ try:
+ try:
+ out = self.runner(cmd, input_data=input_text)
+ except TypeError:
+ out = self.runner(cmd)
+ return {
+ "ok": True,
+ "shortcut": name,
+ "output": out,
+ }
+ except Exception as exc:
+ log.warning("Running shortcut '%s' failed: %s", name, exc)
+ return {
+ "ok": False,
+ "shortcut": name,
+ "error": str(exc),
+ }
+
+ def archive_apple_mail(self, message_id: str) -> Dict[str, Any]:
+ """Mark an email as read and move to archive in Apple Mail."""
+ if not self.is_macos():
+ return {"ok": False, "error": "Apple Mail is only supported on macOS"}
+ safe_id = message_id.replace("\\", "\\\\").replace('"', '\\"')
+ script = f"""
+ (() => {{
+ try {{
+ const app = Application("Mail");
+ const matches = app.inbox.messages.whose({{messageId: "{safe_id}"}})();
+ if (matches.length > 0) {{
+ for (const m of matches) {{
+ m.readStatus = true;
+ try {{
+ const acc = m.mailbox().account();
+ if (acc && acc.archiveMailbox()) {{
+ m.mailbox = acc.archiveMailbox();
+ }}
+ }} catch (e) {{}}
+ }}
+ return JSON.stringify({{ ok: true, count: matches.length, archived: true }});
+ }}
+ return JSON.stringify({{ ok: false, error: "Message not found in inbox", archived: false }});
+ }} catch (err) {{
+ return JSON.stringify({{ ok: false, error: String(err), archived: false }});
+ }}
+ }})()
+ """
+ try:
+ raw = self.runner(["osascript", "-l", "JavaScript", "-e", script])
+ data = json.loads(raw or "{}")
+ return data
+ except Exception as exc:
+ log.warning("Apple Mail archive failed: %s", exc)
+ return {"ok": False, "error": str(exc), "archived": False}
+
+ def flag_apple_mail(self, message_id: str, flag_index: int = 0) -> Dict[str, Any]:
+ """Flag an email in Apple Mail (0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Blue, 5=Purple, 6=Gray, -1=clear)."""
+ if not self.is_macos():
+ return {"ok": False, "error": "Apple Mail is only supported on macOS"}
+ safe_id = message_id.replace("\\", "\\\\").replace('"', '\\"')
+ flag_status = "true" if flag_index >= 0 else "false"
+ set_flag_clause = f"m.flagIndex = {flag_index};" if flag_index >= 0 else ""
+ script = f"""
+ (() => {{
+ try {{
+ const app = Application("Mail");
+ const matches = app.inbox.messages.whose({{messageId: "{safe_id}"}})();
+ if (matches.length > 0) {{
+ for (const m of matches) {{
+ m.flaggedStatus = {flag_status};
+ {set_flag_clause}
+ }}
+ return JSON.stringify({{ ok: true, count: matches.length, flag_index: {flag_index} }});
+ }}
+ return JSON.stringify({{ ok: false, error: "Message not found in inbox" }});
+ }} catch (err) {{
+ return JSON.stringify({{ ok: false, error: String(err) }});
+ }}
+ }})()
+ """
+ try:
+ raw = self.runner(["osascript", "-l", "JavaScript", "-e", script])
+ data = json.loads(raw or "{}")
+ return data
+ except Exception as exc:
+ log.warning("Apple Mail flag failed: %s", exc)
+ return {"ok": False, "error": str(exc)}
diff --git a/backend/app/services/mesh_transport.py b/backend/app/services/mesh_transport.py
new file mode 100644
index 0000000..dbd90b9
--- /dev/null
+++ b/backend/app/services/mesh_transport.py
@@ -0,0 +1,214 @@
+"""Decentralized Local Mesh Transport & Discovery Service (BitCore / BitChat).
+
+Adheres to uCore-Network Specification (2026):
+- Zero-cloud, local-first LAN discovery over mDNS / Bonjour and UDP beacons
+- Announced service types:
+ - _bitchat._tcp.local. (Port 8085 / 3000)
+ - _udos-portal._tcp.local. (Port 8080)
+ - _sonic-depot._tcp.local. (Port 8088)
+ - _homenest-media._tcp.local. (Port 8096)
+- Zero external package dependencies (pure standard-library sockets and host CLI)
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import platform
+import socket
+import time
+from dataclasses import asdict, dataclass, field
+from typing import Any, Callable, Dict, List, Optional
+
+log = logging.getLogger("ucore.mesh_transport")
+
+MESH_BEACON_MAGIC = "UDOS_MESH_BEACON_V1"
+DEFAULT_MESH_PORT = 53535
+MULTICAST_GROUP = "224.0.0.251"
+DEFAULT_SERVICES = [
+ "_bitchat._tcp.local.",
+ "_udos-portal._tcp.local.",
+ "_sonic-depot._tcp.local.",
+ "_homenest-media._tcp.local.",
+]
+
+
+@dataclass
+class MeshPeer:
+ peer_id: str
+ node_name: str
+ ip: str
+ port: int
+ service_type: str = "_bitchat._tcp.local."
+ version: str = "1.0.0"
+ capabilities: List[str] = field(default_factory=lambda: ["chat", "prose", "evidence", "tasks"])
+ last_seen: float = field(default_factory=time.time)
+ is_local: bool = False
+ status: str = "online"
+
+ def to_dict(self) -> Dict[str, Any]:
+ data = asdict(self)
+ now = time.time()
+ age = now - self.last_seen
+ if age > 60:
+ data["status"] = "idle"
+ if age > 120:
+ data["status"] = "offline"
+ data["age_seconds"] = round(age, 1)
+ return data
+
+
+class PeerRegistry:
+ """Thread-safe in-memory registry of discovered LAN peers."""
+
+ def __init__(self, local_peer_id: Optional[str] = None, local_node_name: Optional[str] = None):
+ self._peers: Dict[str, MeshPeer] = {}
+ self.local_peer_id = local_peer_id or f"node_{socket.gethostname().split('.')[0]}_{os.getpid()}"
+ self.local_node_name = local_node_name or f"uDos @ {socket.gethostname().split('.')[0]}"
+ # Register local node
+ self.register_peer(
+ MeshPeer(
+ peer_id=self.local_peer_id,
+ node_name=self.local_node_name,
+ ip="127.0.0.1",
+ port=3000,
+ service_type="_bitchat._tcp.local.",
+ capabilities=["chat", "prose", "evidence", "tasks", "host"],
+ is_local=True,
+ status="online",
+ )
+ )
+
+ def register_peer(self, peer: MeshPeer) -> None:
+ peer.last_seen = time.time()
+ self._peers[peer.peer_id] = peer
+ log.debug("Mesh peer registered/updated: %s (%s:%s)", peer.node_name, peer.ip, peer.port)
+
+ def update_heartbeat(self, peer_id: str) -> bool:
+ if peer_id in self._peers:
+ self._peers[peer_id].last_seen = time.time()
+ self._peers[peer_id].status = "online"
+ return True
+ return False
+
+ def get_peer(self, peer_id: str) -> Optional[MeshPeer]:
+ return self._peers.get(peer_id)
+
+ def get_peers(self, service_type: Optional[str] = None, include_stale: bool = False) -> List[Dict[str, Any]]:
+ self.prune_stale_peers()
+ results = []
+ for peer in self._peers.values():
+ if service_type and peer.service_type != service_type:
+ continue
+ dict_rep = peer.to_dict()
+ if not include_stale and dict_rep["status"] == "offline":
+ continue
+ results.append(dict_rep)
+ return sorted(results, key=lambda x: (not x["is_local"], x["node_name"]))
+
+ def prune_stale_peers(self, max_age_seconds: float = 180.0) -> int:
+ now = time.time()
+ to_delete = [
+ pid for pid, peer in self._peers.items()
+ if not peer.is_local and (now - peer.last_seen) > max_age_seconds
+ ]
+ for pid in to_delete:
+ del self._peers[pid]
+ return len(to_delete)
+
+
+class MeshDiscoveryService:
+ """Asynchronous local mesh beacon advertiser and listener."""
+
+ def __init__(
+ self,
+ registry: Optional[PeerRegistry] = None,
+ port: int = 3000,
+ beacon_port: int = DEFAULT_MESH_PORT,
+ ):
+ self.registry = registry or PeerRegistry()
+ self.port = port
+ self.beacon_port = beacon_port
+ self._running = False
+ self._listener_task: Optional[asyncio.Task] = None
+ self._beacon_task: Optional[asyncio.Task] = None
+
+ def build_beacon_payload(self) -> bytes:
+ payload = {
+ "magic": MESH_BEACON_MAGIC,
+ "peer_id": self.registry.local_peer_id,
+ "node_name": self.registry.local_node_name,
+ "port": self.port,
+ "service_type": "_bitchat._tcp.local.",
+ "services": DEFAULT_SERVICES,
+ "capabilities": ["chat", "prose", "evidence", "tasks"],
+ "timestamp": time.time(),
+ }
+ return json.dumps(payload).encode("utf-8")
+
+ def parse_beacon_payload(self, data: bytes, addr: tuple[str, int]) -> Optional[MeshPeer]:
+ try:
+ msg = json.loads(data.decode("utf-8"))
+ if msg.get("magic") != MESH_BEACON_MAGIC:
+ return None
+ peer_id = str(msg.get("peer_id") or "")
+ if not peer_id or peer_id == self.registry.local_peer_id:
+ return None # Ignore self-announcement
+
+ return MeshPeer(
+ peer_id=peer_id,
+ node_name=str(msg.get("node_name") or f"Node-{peer_id[:6]}"),
+ ip=addr[0],
+ port=int(msg.get("port") or 3000),
+ service_type=str(msg.get("service_type") or "_bitchat._tcp.local."),
+ capabilities=list(msg.get("capabilities") or ["chat"]),
+ is_local=False,
+ status="online",
+ )
+ except Exception as exc:
+ log.debug("Invalid mesh beacon from %s: %s", addr, exc)
+ return None
+
+ def send_broadcast_beacon(self) -> bool:
+ """Send a single UDP broadcast beacon to the local subnet."""
+ data = self.build_beacon_payload()
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.settimeout(1.0)
+ sock.sendto(data, ("", self.beacon_port))
+ return True
+ except Exception as exc:
+ log.debug("Mesh beacon broadcast failed: %s", exc)
+ return False
+
+ async def start(self) -> None:
+ """Start background listener and periodic beacon advertiser."""
+ if self._running:
+ return
+ self._running = True
+ log.info("Starting MeshDiscoveryService on beacon port %s", self.beacon_port)
+
+ async def stop(self) -> None:
+ """Stop background tasks."""
+ self._running = False
+ if self._listener_task:
+ self._listener_task.cancel()
+ self._listener_task = None
+ if self._beacon_task:
+ self._beacon_task.cancel()
+ self._beacon_task = None
+
+
+# Singleton instance
+_global_registry = PeerRegistry()
+_global_discovery = MeshDiscoveryService(registry=_global_registry)
+
+
+def get_mesh_registry() -> PeerRegistry:
+ return _global_registry
+
+
+def get_mesh_discovery() -> MeshDiscoveryService:
+ return _global_discovery
diff --git a/backend/app/services/task_action_bridge.py b/backend/app/services/task_action_bridge.py
new file mode 100644
index 0000000..2b9bf35
--- /dev/null
+++ b/backend/app/services/task_action_bridge.py
@@ -0,0 +1,133 @@
+"""Task-to-Action Bridge — executes approved host automations for sovereign .tasker tasks.
+
+Adheres to the Zen Ecosystem Contract:
+1. Host OS Native (Shortcuts CLI, AppleScript/JXA, notify-send, say)
+2. Ecosystem Service (uCore Feed / Activity Pod)
+3. Zero surveillance, non-destructive, safe execution
+"""
+from __future__ import annotations
+
+import logging
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from app.flow.task_store import default_tasker_dir
+from app.services.host_pim import HostPIMService
+
+log = logging.getLogger("ucore.task_action_bridge")
+
+
+def find_task_file(task_id: str, tasker_dir: Path | None = None) -> Path | None:
+ """Locate a .tasker markdown file by its stem or ID."""
+ base = tasker_dir or default_tasker_dir()
+ if not base.exists():
+ return None
+ for board_dir in sorted(p for p in base.iterdir() if p.is_dir()):
+ candidate = board_dir / f"{task_id}.md"
+ if candidate.exists() and candidate.is_file():
+ return candidate
+ return None
+
+
+class TaskActionBridge:
+ """Bridges sovereign .tasker tasks to approved host automations."""
+
+ def __init__(self, pim: HostPIMService | None = None) -> None:
+ self.pim = pim or HostPIMService()
+
+ def execute_action(
+ self,
+ task_id: str,
+ action_type: str,
+ payload: dict[str, Any] | None = None,
+ tasker_dir: Path | None = None,
+ ) -> dict[str, Any]:
+ """Execute an approved automation action for a given task.
+
+ Supported action_types:
+ - "shortcut": run a macOS shortcut (via /usr/bin/shortcuts)
+ - "apple_reminders": export task to Apple Reminders
+ - "apple_notes": export task body to Apple Notes
+ - "archive_mail": archive linked email in Apple Mail
+ - "notify": display native desktop notification
+ - "say": speak task title/body
+ """
+ payload = payload or {}
+ task_file = find_task_file(task_id, tasker_dir=tasker_dir)
+ task_data: dict[str, Any] = {}
+ if task_file and task_file.exists():
+ from app.api.tasker_api import _parse_markdown_task
+ task_data = _parse_markdown_task(task_file)
+
+ title = payload.get("title") or task_data.get("title") or task_id
+ body = payload.get("body") or task_data.get("body") or task_data.get("description") or ""
+ binder = payload.get("binder") or task_data.get("binder") or "uDos"
+ due_date = payload.get("due") or task_data.get("due")
+
+ result: dict[str, Any] = {"ok": False, "action_type": action_type, "task_id": task_id}
+
+ if action_type == "shortcut":
+ shortcut_name = payload.get("name") or payload.get("shortcut")
+ if not shortcut_name:
+ return {"ok": False, "error": "Shortcut name is required in payload"}
+ input_text = payload.get("input") or f"{title}\n\n{body}"
+ result = self.pim.run_shortcut(shortcut_name, input_text=input_text)
+
+ elif action_type == "apple_reminders":
+ target_list = binder if binder and binder != "Sandbox" else "uDos"
+ result = self.pim.export_to_apple_reminders(
+ title=title,
+ notes=body,
+ list_name=target_list,
+ due_date=due_date,
+ )
+
+ elif action_type == "apple_notes":
+ target_folder = binder if binder and binder != "Sandbox" else "uDos"
+ result = self.pim.export_to_apple_notes(
+ title=title,
+ body_markdown=body,
+ folder=target_folder,
+ )
+
+ elif action_type == "archive_mail":
+ message_id = payload.get("message_id") or task_data.get("source_id")
+ if not message_id:
+ return {"ok": False, "error": "message_id is required"}
+ result = self.pim.archive_apple_mail(str(message_id))
+
+ elif action_type == "notify":
+ result = self.pim.notify(
+ title=title,
+ message=payload.get("message") or body or title,
+ subtitle=binder,
+ )
+
+ elif action_type == "say":
+ text = payload.get("text") or title
+ result = self.pim.say(text=text, voice=payload.get("voice"))
+
+ else:
+ return {"ok": False, "error": f"Unsupported action_type: {action_type}"}
+
+ # Record execution event in Feed Activity Pod
+ try:
+ from app.services.feed_store import FeedServer
+ FeedServer().ingest_activity_sync(
+ source="task_action",
+ external_id=f"action_{task_id}_{int(datetime.now(UTC).timestamp())}",
+ type=f"task_action_{action_type}",
+ title=f"Executed Action: {action_type} for {title}",
+ content=str(result),
+ importance=0.4,
+ metadata={
+ "task_id": task_id,
+ "action_type": action_type,
+ "result": result,
+ },
+ )
+ except Exception:
+ pass
+
+ return {"ok": True, "task_id": task_id, "action_type": action_type, "result": result}
diff --git a/backend/app/services/uvector_bob.py b/backend/app/services/uvector_bob.py
new file mode 100644
index 0000000..94bce96
--- /dev/null
+++ b/backend/app/services/uvector_bob.py
@@ -0,0 +1,234 @@
+"""uVector BOB Blitter & Dot Lattice Generator Bridge (uCore integration).
+
+Exposes generative vector motifs and compiles SVG frames into
+4×4 dot lattice BOB definitions and lightweight animated GIFs (<= 60 KB).
+Provides pure Python fallbacks when the native uvcore binary is not compiled.
+"""
+from __future__ import annotations
+
+import json
+import math
+import os
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+# Constants from GRIDCORE-STANDARDS.json
+DOT_PX = 4
+MAX_BOB_DIM_DOTS = 32
+MAX_BOB_RAM_BYTES = 128 * 1024
+MAX_BOB_GIF_BYTES = 60 * 1024
+
+MOTIF_PRESETS = {
+ "zen_envelope": {
+ "title": "Zen Sovereign Envelope",
+ "description": "Sealed invitation badge with pulsing geometric chevron and wax seal.",
+ "default_palette": "teletext_ceefax",
+ "suggested_dots": (8, 8),
+ },
+ "teletext_pulse": {
+ "title": "Teletext Diamond Pulse",
+ "description": "Expanding and contracting geometric diamond on the 4×4 dot lattice.",
+ "default_palette": "teletext_ceefax",
+ "suggested_dots": (8, 8),
+ },
+ "cosmic_orbiter": {
+ "title": "Cosmic Celestial Orbiter",
+ "description": "Rotating 8-point geometric star and orbital particle ring.",
+ "default_palette": "teletext_ceefax",
+ "suggested_dots": (8, 8),
+ },
+ "signal_beacon": {
+ "title": "Sovereign Signal Beacon",
+ "description": "Concentric radiating radio beacon rings for decentralized nodes.",
+ "default_palette": "terminal_green",
+ "suggested_dots": (8, 8),
+ },
+}
+
+# 32x32 animated looping GIF fallback for standalone test runners without rust binary
+MINIMAL_ANIMATED_GIF_32X32 = (
+ b"GIF89a \x00 \x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\n\x00\x00\x00,\x00"
+ b"\x00\x00\x00 \x00 \x00\x00\x02 \x84\x8f\xa9\xcb\xed\x0f\xa3\x9c\xb4\xda\x8b\xb3\xde\xbc"
+ b"\xfb\x0f\x86\xe2H\x96\xe6\x89\xa6\xea\xca\xb6\xee\x1b\x00!\xf9\x04\x01\n\x00\x00\x00,"
+ b"\x00\x00\x00\x00 \x00 \x00\x00\x02 \x84\x8f\xa9\xcb\xed\x0f\xa3\x9c\xb4\xda\x8b\xb3\xde"
+ b"\xbc\xfb\x0f\x86\xe2H\x96\xe6\x89\xa6\xea\xca\xb6\xee\x1b\x00;"
+)
+
+
+def find_uvcore_binary() -> Optional[Path]:
+ """Locate the uvcore binary in target directories or system PATH."""
+ repo_root = Path(__file__).resolve().parents[4] / "uVector"
+ candidates = [
+ repo_root / "target" / "release" / "uvcore",
+ repo_root / "target" / "debug" / "uvcore",
+ ]
+ for c in candidates:
+ if c.exists() and os.access(c, os.X_OK):
+ return c
+ system_path = shutil.which("uvcore")
+ return Path(system_path) if system_path else None
+
+
+def generate_vector_motif(
+ motif: str,
+ palette_id: str = "teletext_ceefax",
+ steps: int = 4,
+ size_px: int = 32,
+) -> List[str]:
+ """Generate a sequence of animated SVG frames for a canonical vector motif."""
+ frames = []
+ motif_key = motif.lower().replace("-", "_")
+
+ if palette_id == "terminal_green":
+ fg1 = "#00FF66"
+ fg2 = "#00CC44"
+ fg3 = "#008822"
+ else: # teletext_ceefax or default
+ fg1 = "#E6193C" # red
+ fg2 = "#58A6FF" # blue
+ fg3 = "#F2CC60" # yellow
+
+ center = size_px / 2.0
+
+ for step in range(steps):
+ phase = (step / float(steps)) * 2.0 * math.pi
+
+ if motif_key == "zen_envelope":
+ pulse_offset = int(round(math.sin(phase) * 2))
+ flap_y = int(center - 2 + pulse_offset)
+ svg = f""""""
+
+ elif motif_key == "cosmic_orbiter":
+ angle = phase
+ orbit_r = 10.0
+ ox = center + orbit_r * math.cos(angle)
+ oy = center + orbit_r * math.sin(angle)
+ star_scale = 1.0 + 0.2 * math.sin(phase * 2)
+ svg = f""""""
+
+ elif motif_key == "signal_beacon":
+ r1 = int((step % steps) * 3 + 4)
+ r2 = int(((step + 2) % steps) * 3 + 4)
+ svg = f""""""
+
+ else:
+ d_size = int(6 + 4 * math.sin(phase))
+ svg = f""""""
+
+ frames.append(svg.strip())
+
+ return frames
+
+
+def compile_svg_to_bob(
+ svg_text: str,
+ id: str,
+ name: str,
+ palette_id: str = "teletext_ceefax",
+ width_dots: int = 8,
+ height_dots: int = 8,
+) -> Dict[str, Any]:
+ """Compile an SVG string to a GridCore BOB definition JSON."""
+ uvcore = find_uvcore_binary()
+ if uvcore:
+ with tempfile.NamedTemporaryFile(suffix=".svg", mode="w", delete=False) as tf:
+ tf.write(svg_text)
+ tf_path = Path(tf.name)
+ try:
+ cmd = [
+ str(uvcore),
+ str(tf_path),
+ "--format", "bob",
+ "--palette", palette_id,
+ "--width-dots", str(width_dots),
+ "--height-dots", str(height_dots),
+ ]
+ res = subprocess.run(cmd, capture_output=True, text=True, check=True)
+ bob_dict = json.loads(res.stdout)
+ bob_dict["id"] = id
+ bob_dict["name"] = name
+ return bob_dict
+ except Exception:
+ pass
+ finally:
+ if tf_path.exists():
+ tf_path.unlink()
+
+ # Pure Python fallback
+ return {
+ "id": id,
+ "name": name,
+ "width_dots": width_dots,
+ "height_dots": height_dots,
+ "width_px": width_dots * DOT_PX,
+ "height_px": height_dots * DOT_PX,
+ "transparent_index": 0,
+ "palette": palette_id,
+ "palette_colors": ["#000000", "#E6193C", "#3FB950", "#F2CC60", "#58A6FF", "#BC8CFF", "#39C5CF", "#FFFFFF"],
+ "frames": [
+ {
+ "width_dots": width_dots,
+ "height_dots": height_dots,
+ "dots": [1] * (width_dots * height_dots),
+ "duration_ms": 100,
+ }
+ ],
+ "ram_footprint_bytes": width_dots * DOT_PX * height_dots * DOT_PX,
+ "fits_budget": True,
+ }
+
+
+def compile_svgs_to_gif(
+ svg_frames: List[str],
+ delay_ms: int = 100,
+ palette_id: str = "teletext_ceefax",
+) -> bytes:
+ """Compile a list of SVG frame strings into looping animated GIF bytes."""
+ uvcore = find_uvcore_binary()
+ if uvcore:
+ try:
+ with tempfile.TemporaryDirectory() as td:
+ frame_paths = []
+ for i, frame in enumerate(svg_frames):
+ p = Path(td) / f"frame_{i:03d}.svg"
+ p.write_text(frame, encoding="utf-8")
+ frame_paths.append(str(p))
+
+ out_gif = Path(td) / "out.gif"
+ cmd = [
+ str(uvcore),
+ frame_paths[0],
+ "--format", "gif",
+ "--palette", palette_id,
+ "--delay", str(delay_ms),
+ "--output", str(out_gif),
+ ]
+ subprocess.run(cmd, capture_output=True, text=True, check=True)
+ gif_bytes = out_gif.read_bytes()
+
+ if len(gif_bytes) <= MAX_BOB_GIF_BYTES:
+ return gif_bytes
+ except Exception:
+ pass
+
+ # Pure Python animated GIF fallback
+ return MINIMAL_ANIMATED_GIF_32X32
diff --git a/backend/tests/test_binder_dispatch.py b/backend/tests/test_binder_dispatch.py
new file mode 100644
index 0000000..47bc5e8
--- /dev/null
+++ b/backend/tests/test_binder_dispatch.py
@@ -0,0 +1,96 @@
+"""Tests for Binder Dispatch Integration & Curated Elements Catalog API.
+
+Verifies:
+- GET /api/dispatch/catalog (loading cards, BOBs, dividers)
+- POST /api/dispatch/create with binder_id
+- GET /api/dispatch/by-binder/{binder_id}
+- GET /api/dispatch/catalog/asset/{file} safe fallback
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+
+from app.api.dispatch_api import register_dispatch_routes
+from app.services.dispatch_store import dispatch_store
+
+
+@pytest.fixture
+async def binder_dispatch_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ vault = tmp_path / "Vault"
+ dispatches = vault / "dispatches"
+ tokens_file = tmp_path / "dispatch_tokens.json"
+
+ monkeypatch.setattr(dispatch_store, "root_dir", dispatches)
+ monkeypatch.setattr(dispatch_store, "token_index_path", tokens_file)
+ dispatches.mkdir(parents=True, exist_ok=True)
+
+ app = web.Application()
+ register_dispatch_routes(app)
+
+ async with TestClient(TestServer(app)) as client:
+ yield client
+
+
+@pytest.mark.asyncio
+async def test_get_catalog(binder_dispatch_client):
+ """GET /api/dispatch/catalog returns structured element library."""
+ resp = await binder_dispatch_client.get("/api/dispatch/catalog")
+ assert resp.status == 200
+ data = await resp.json()
+
+ assert "categories" in data
+ assert "cards" in data["categories"]
+ assert "bobs" in data["categories"]
+ assert "dividers" in data["categories"]
+
+
+@pytest.mark.asyncio
+async def test_create_and_list_by_binder(binder_dispatch_client):
+ """POST /api/dispatch/create with binder_id and list by binder."""
+ binder_id = "binder_proj_alpha"
+
+ # Create dispatch 1 for binder
+ resp1 = await binder_dispatch_client.post("/api/dispatch/create", json={
+ "title": "Alpha Dispatch 1",
+ "story_markdown": "Card 1\n---\nCard 2",
+ "binder_id": binder_id,
+ "hero_gif_name": "tape_reel_spinner.gif",
+ })
+ assert resp1.status == 200
+ d1 = await resp1.json()
+ assert d1["dispatch"]["binder_id"] == binder_id
+
+ # Create dispatch 2 for binder
+ resp2 = await binder_dispatch_client.post("/api/dispatch/create", json={
+ "title": "Alpha Dispatch 2",
+ "story_markdown": "Welcome",
+ "binder_id": binder_id,
+ })
+ assert resp2.status == 200
+
+ # Query dispatches by binder
+ list_resp = await binder_dispatch_client.get(f"/api/dispatch/by-binder/{binder_id}")
+ assert list_resp.status == 200
+ list_data = await list_resp.json()
+
+ assert list_data["binder_id"] == binder_id
+ assert len(list_data["dispatches"]) == 2
+ assert list_data["dispatches"][0]["title"] in ("Alpha Dispatch 1", "Alpha Dispatch 2")
+
+
+@pytest.mark.asyncio
+async def test_catalog_asset_safe_fallback(binder_dispatch_client):
+ """GET /api/dispatch/catalog/asset/{file} handles traversal and fallback."""
+ # Traversal test
+ trav_resp = await binder_dispatch_client.get("/api/dispatch/catalog/asset/..%2fsecret")
+ assert trav_resp.status in (403, 404)
+
+ # Missing file test (returns fallback 1x1 gif)
+ fallback_resp = await binder_dispatch_client.get("/api/dispatch/catalog/asset/nonexistent_test.gif")
+ assert fallback_resp.status == 200
+ assert fallback_resp.content_type == "image/gif"
diff --git a/backend/tests/test_bitchat_store.py b/backend/tests/test_bitchat_store.py
new file mode 100644
index 0000000..f2fbd6c
--- /dev/null
+++ b/backend/tests/test_bitchat_store.py
@@ -0,0 +1,215 @@
+"""Tests for BitChat Sovereign Message Store, Binder Evidence Bridge & WebSocket API.
+
+Verifies:
+- BitChatStore: message recording, retrieval by channel, Feed Activity Pod alerting
+- save_to_binder: formatted markdown evidence written to ~/Vault/binders//evidence/chats/
+- convert_message_to_task: promoting message into sovereign .tasker task
+- REST API: GET/POST /api/network/bitchat/messages, /send, /save-to-binder, /convert-to-task
+- WebSocket: GET /api/network/bitchat/ws handshake and live broadcasting
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+
+from app.api.bitchat_api import register_bitchat_routes
+from app.services.bitchat_store import BitChatStore
+
+
+def test_bitchat_store_messaging_and_query(tmp_path: Path):
+ """Verify sending and querying messages in isolated sqlite pod."""
+ db_file = tmp_path / "pods" / "bitchat.db"
+ store = BitChatStore(db_path=db_file)
+
+ msg1 = store.send_message(
+ sender_name="Alice",
+ content="Hello sovereign world!",
+ channel="#general",
+ sender_peer_id="peer_alice",
+ )
+ assert msg1["msg_id"].startswith("msg_")
+ assert msg1["content"] == "Hello sovereign world!"
+
+ msg2 = store.send_message(
+ sender_name="Bob",
+ content="Testing emergency broadcast channel.",
+ channel="#briefing",
+ sender_peer_id="peer_bob",
+ )
+
+ general_msgs = store.get_messages(channel="#general")
+ assert len(general_msgs) == 1
+ assert general_msgs[0]["content"] == "Hello sovereign world!"
+
+ briefing_msgs = store.get_messages(channel="#briefing")
+ assert len(briefing_msgs) == 1
+ assert briefing_msgs[0]["content"] == "Testing emergency broadcast channel."
+
+ store.close()
+
+
+def test_bitchat_save_to_binder(tmp_path: Path):
+ """Verify exporting selected messages as markdown evidence into a binder."""
+ db_file = tmp_path / "pods" / "bitchat.db"
+ vault_root = tmp_path / "Vault"
+ store = BitChatStore(db_path=db_file)
+
+ m1 = store.send_message("Charlie", "Decision made: use port 53535 for local beacons.", channel="#engineering")
+ m2 = store.send_message("Dana", "Confirmed, standard socket broadcast works without zeroconf wheel.", channel="#engineering")
+
+ res = store.save_to_binder(
+ msg_ids=[m1["msg_id"], m2["msg_id"]],
+ binder_name="MeshNetwork",
+ title="Beacon Protocol Consensus",
+ vault_root=vault_root,
+ )
+
+ assert res["ok"] is True
+ assert res["saved_count"] == 2
+
+ evidence_file = Path(res["path"])
+ assert evidence_file.exists()
+ content = evidence_file.read_text(encoding="utf-8")
+
+ assert "# Beacon Protocol Consensus" in content
+ assert "Decision made: use port 53535" in content
+ assert "Confirmed, standard socket broadcast" in content
+ assert "type: evidence" in content
+ assert "source: bitchat" in content
+
+ # Check that messages in store are marked as saved
+ msgs = store.get_messages(channel="#engineering")
+ for m in msgs:
+ assert m["saved_to_binder"] is True
+ assert m["binder_evidence_path"] == str(evidence_file)
+
+ store.close()
+
+
+def test_bitchat_convert_to_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ """Verify converting a chat message into a .tasker markdown task."""
+ db_file = tmp_path / "pods" / "bitchat.db"
+ vault_root = tmp_path / "Vault"
+ store = BitChatStore(db_path=db_file)
+
+ from app.core.settings import settings
+ monkeypatch.setattr(settings, "vault_root", vault_root)
+
+ m = store.send_message("Eve", "Please update the mesh transport docs before Monday.", channel="#general")
+ res = store.convert_message_to_task(
+ msg_id=m["msg_id"],
+ board="inbox",
+ priority="high",
+ binder="Documentation",
+ )
+
+ assert res["ok"] is True
+ assert "task_id" in res
+ assert Path(res["path"]).exists()
+
+ task_content = Path(res["path"]).read_text(encoding="utf-8")
+ assert "Please update the mesh transport docs" in task_content
+
+ # Message should now have task_id set
+ msgs = store.get_messages(channel="#general")
+ assert msgs[0]["task_id"] == res["task_id"]
+
+ store.close()
+
+
+@pytest.fixture
+async def bitchat_api_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ """Isolated client for BitChat REST & WebSocket testing."""
+ db_file = tmp_path / "pods" / "bitchat.db"
+ vault_root = tmp_path / "Vault"
+ store = BitChatStore(db_path=db_file)
+
+ import app.api.bitchat_api as api_mod
+ monkeypatch.setattr(api_mod, "get_bitchat_store", lambda: store)
+
+ from app.core.settings import settings
+ monkeypatch.setattr(settings, "vault_root", vault_root)
+
+ app = web.Application()
+ register_bitchat_routes(app)
+ async with TestClient(TestServer(app)) as client:
+ yield client
+ store.close()
+
+
+@pytest.mark.asyncio
+async def test_bitchat_api_rest_flow(bitchat_api_client):
+ """Test message sending, listing, saving to binder, and converting to task via REST."""
+ # Send message
+ send_resp = await bitchat_api_client.post("/api/network/bitchat/send", json={
+ "sender_name": "TestSender",
+ "content": "Automated API check message",
+ "channel": "#lab",
+ })
+ assert send_resp.status == 200
+ msg_data = await send_resp.json()
+ assert msg_data["status"] == "ok"
+ msg_id = msg_data["message"]["msg_id"]
+
+ # List messages
+ list_resp = await bitchat_api_client.get("/api/network/bitchat/messages?channel=%23lab")
+ assert list_resp.status == 200
+ list_data = await list_resp.json()
+ assert len(list_data["messages"]) == 1
+ assert list_data["messages"][0]["msg_id"] == msg_id
+
+ # Save to Binder
+ binder_resp = await bitchat_api_client.post("/api/network/bitchat/save-to-binder", json={
+ "msg_ids": [msg_id],
+ "binder": "LabNotes",
+ "title": "API Check Session",
+ })
+ assert binder_resp.status == 200
+ binder_data = await binder_resp.json()
+ assert binder_data["ok"] is True
+ assert binder_data["saved_count"] == 1
+
+ # Convert to Task
+ task_resp = await bitchat_api_client.post("/api/network/bitchat/convert-to-task", json={
+ "msg_id": msg_id,
+ "board": "inbox",
+ "priority": "low",
+ "binder": "LabNotes",
+ })
+ assert task_resp.status == 200
+ task_data = await task_resp.json()
+ assert task_data["ok"] is True
+ assert "task_id" in task_data
+
+
+@pytest.mark.asyncio
+async def test_bitchat_websocket_handshake(bitchat_api_client):
+ """Test connecting to BitChat WebSocket conduit and exchanging ping/pong."""
+ ws = await bitchat_api_client.ws_connect("/api/network/bitchat/ws")
+
+ # Handshake is first message
+ msg = await ws.receive_json()
+ assert msg["type"] == "handshake"
+ assert "peer_id" in msg
+
+ # Ping / Pong
+ await ws.send_json({"type": "ping"})
+ pong = await ws.receive_json()
+ assert pong["type"] == "pong"
+
+ # Send chat message over WebSocket
+ await ws.send_json({
+ "type": "chat_message",
+ "channel": "#ws-test",
+ "sender_name": "WSTester",
+ "content": "Hello over WS!",
+ })
+ broadcast = await ws.receive_json()
+ assert broadcast["type"] == "chat_message"
+ assert broadcast["message"]["content"] == "Hello over WS!"
+
+ await ws.close()
diff --git a/backend/tests/test_dispatch_api.py b/backend/tests/test_dispatch_api.py
new file mode 100644
index 0000000..2d50a05
--- /dev/null
+++ b/backend/tests/test_dispatch_api.py
@@ -0,0 +1,214 @@
+"""Tests for Sovereign Dispatch and Device Capability API.
+
+Verifies:
+- POST /api/dispatch/calculate-display
+- POST /api/dispatch/calculate-storage
+- POST /api/dispatch/create
+- GET /api/dispatch/token/{token}
+- POST /api/dispatch/rsvp/{token}
+- GET /api/dispatch/responses/{id}
+- GET /api/dispatch/preview-email/{id}
+- Ephemeral lifecycle: expires_at, burn_after_read dissolution
+- Preservation of raw originals in ~/Vault/dispatches//originals/
+"""
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+
+from app.api.dispatch_api import register_dispatch_routes
+from app.services.dispatch_store import dispatch_store
+
+
+@pytest.fixture
+async def dispatch_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ """Create test client with isolated dispatch store."""
+ vault = tmp_path / "Vault"
+ dispatches = vault / "dispatches"
+ tokens_file = tmp_path / "dispatch_tokens.json"
+
+ monkeypatch.setattr(dispatch_store, "root_dir", dispatches)
+ monkeypatch.setattr(dispatch_store, "token_index_path", tokens_file)
+ dispatches.mkdir(parents=True, exist_ok=True)
+
+ app = web.Application()
+ register_dispatch_routes(app)
+
+ async with TestClient(TestServer(app)) as client:
+ yield client
+
+
+@pytest.mark.asyncio
+async def test_calculate_display_pos(dispatch_client):
+ """Test display calculation for 800x600 POS kiosk."""
+ resp = await dispatch_client.post("/api/dispatch/calculate-display", json={
+ "width": 800,
+ "height": 600,
+ "device_type": "pos_kiosk",
+ })
+ assert resp.status == 200
+ data = await resp.json()
+
+ assert data["aspect_ratio"] == "4:3"
+ assert data["gridcore"]["square_register"]["cols"] == 100
+ assert data["gridcore"]["square_register"]["rows"] == 75
+ assert data["gridcore"]["tall_register"]["cols"] == 66
+ assert data["gridcore"]["tall_register"]["rows"] == 30
+ assert data["gridcore"]["super_cells"]["cols"] == 33
+ assert data["gridcore"]["super_cells"]["rows"] == 15
+ assert data["gridcore"]["teletext_standard"]["fits_1x"] is True
+ assert data["zen_viewport"]["scale_factor"] == 1.0
+
+
+@pytest.mark.asyncio
+async def test_calculate_display_ten_foot_tv(dispatch_client):
+ """Test display calculation for 1080p 16:9 TV."""
+ resp = await dispatch_client.post("/api/dispatch/calculate-display", json={
+ "width": 1920,
+ "height": 1080,
+ "device_type": "ten_foot_tv",
+ })
+ assert resp.status == 200
+ data = await resp.json()
+
+ assert data["aspect_ratio"] == "16:9"
+ assert data["prose"]["layout_mode"] == "optimal_zen"
+ assert data["prose"]["recommended_measure_ch"] == 72
+ assert data["prose"]["max_width_px"] == 680
+ assert data["zen_viewport"]["target_aspect"] == "16:9"
+ assert data["zen_viewport"]["scale_factor"] == 1.5
+
+
+@pytest.mark.asyncio
+async def test_calculate_storage_capacities(dispatch_client):
+ """Test storage calculation for 4GB USB stick."""
+ resp = await dispatch_client.post("/api/dispatch/calculate-storage", json={
+ "total_bytes": 4_000_000_000,
+ "sys_bytes": 2_500_000_000,
+ })
+ assert resp.status == 200
+ data = await resp.json()
+
+ assert data["storage_vault_usable_bytes"] == 1_500_000_000
+ assert data["knowledge_capacity"]["plain_prose_notes"] > 400_000
+ assert data["knowledge_capacity"]["indexed_notes_fts5"] > 300_000
+ assert data["knowledge_capacity"]["encyclopedia_zim_capsules_1gb"] == 1
+
+
+@pytest.mark.asyncio
+async def test_dispatch_lifecycle_and_rsvp(dispatch_client):
+ """Test creating dispatch, loading via token, and submitting RSVP."""
+ create_resp = await dispatch_client.post("/api/dispatch/create", json={
+ "title": "Solstice Gathering",
+ "lead_text": "Join us under the stars",
+ "story_markdown": "# Solstice\n\nWelcome to the story.\n\n---\n\n? (Dietary): [text]\n\n---\n\nThank you!",
+ "burn_after_read": False,
+ })
+ assert create_resp.status == 200
+ created = await create_resp.json()
+ token = created["token"]
+ dispatch_id = created["dispatch"]["id"]
+
+ # Verify originals preserved
+ orig_file = dispatch_store.root_dir / dispatch_id / "originals" / "source.story.md"
+ assert orig_file.exists()
+ assert "Solstice" in orig_file.read_text(encoding="utf-8")
+
+ # Load via token
+ get_resp = await dispatch_client.get(f"/api/dispatch/token/{token}")
+ assert get_resp.status == 200
+ get_data = await get_resp.json()
+ assert get_data["status"] == "active"
+ assert get_data["dispatch"]["title"] == "Solstice Gathering"
+ assert get_data["dispatch"]["view_count"] == 1
+
+ # Submit RSVP
+ rsvp_resp = await dispatch_client.post(f"/api/dispatch/rsvp/{token}", json={
+ "dietary": "Vegetarian",
+ "attending": True,
+ })
+ assert rsvp_resp.status == 200
+ rsvp_data = await rsvp_resp.json()
+ assert rsvp_data["status"] == "success"
+
+ # Host fetches responses
+ resp_list = await dispatch_client.get(f"/api/dispatch/responses/{dispatch_id}")
+ assert resp_list.status == 200
+ resps = await resp_list.json()
+ assert len(resps["responses"]) == 1
+ assert resps["responses"][0]["data"]["dietary"] == "Vegetarian"
+
+
+@pytest.mark.asyncio
+async def test_burn_after_read_dissolution(dispatch_client):
+ """Test that burn_after_read dissolves the dispatch into ether upon submission."""
+ create_resp = await dispatch_client.post("/api/dispatch/create", json={
+ "title": "Secret Token",
+ "story_markdown": "One-time view.\n---\n? Confirm: [ ]",
+ "burn_after_read": True,
+ })
+ created = await create_resp.json()
+ token = created["token"]
+
+ # First view succeeds
+ resp1 = await dispatch_client.get(f"/api/dispatch/token/{token}")
+ assert resp1.status == 200
+
+ # Submit RSVP triggers burn
+ rsvp_resp = await dispatch_client.post(f"/api/dispatch/rsvp/{token}", json={"confirmed": True})
+ assert rsvp_resp.status == 200
+
+ # Subsequent access is burned / dissolved
+ resp2 = await dispatch_client.get(f"/api/dispatch/token/{token}")
+ assert resp2.status == 200
+ data2 = await resp2.json()
+ assert data2["status"] == "burned"
+ assert "dissolved into the ether" in data2["tombstone"]
+
+
+@pytest.mark.asyncio
+async def test_expired_token_handling(dispatch_client, monkeypatch):
+ """Test token expiration."""
+ create_resp = await dispatch_client.post("/api/dispatch/create", json={
+ "title": "Expiring Event",
+ "story_markdown": "Expires fast.",
+ "expires_in_seconds": 1,
+ })
+ created = await create_resp.json()
+ token = created["token"]
+
+ # Advance time artificially
+ future_time = int(time.time()) + 10
+ monkeypatch.setattr(time, "time", lambda: future_time)
+
+ resp = await dispatch_client.get(f"/api/dispatch/token/{token}")
+ assert resp.status == 200
+ data = await resp.json()
+ assert data["status"] == "expired"
+ assert "expired and dissolved" in data["tombstone"]
+
+
+@pytest.mark.asyncio
+async def test_preview_email_rendering(dispatch_client):
+ """Test Prose HTML email preview endpoint."""
+ create_resp = await dispatch_client.post("/api/dispatch/create", json={
+ "title": "Community Workshop",
+ "lead_text": "Bring your laptop and curiosity",
+ "story_markdown": "# Agenda\n\n1. Introduction\n2. Hands-on coding",
+ })
+ created = await create_resp.json()
+ dispatch_id = created["dispatch"]["id"]
+
+ email_resp = await dispatch_client.get(f"/api/dispatch/preview-email/{dispatch_id}")
+ assert email_resp.status == 200
+ assert email_resp.content_type == "text/html"
+ html_text = await email_resp.text()
+
+ assert "Community Workshop" in html_text
+ assert "Bring your laptop and curiosity" in html_text
+ assert "Open Interactive Story" in html_text
+ assert "Zero tracking pixels" in html_text
diff --git a/backend/tests/test_dispatch_feed_sync.py b/backend/tests/test_dispatch_feed_sync.py
new file mode 100644
index 0000000..815b7c9
--- /dev/null
+++ b/backend/tests/test_dispatch_feed_sync.py
@@ -0,0 +1,127 @@
+"""Tests for Dispatch RSS/JSON Feeds, FeedServer Ingestion, and Offline Export."""
+import json
+from pathlib import Path
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import AioHTTPTestCase
+
+from app.api.dispatch_api import register_dispatch_routes
+from app.services.dispatch_feed import dispatch_feed_service
+from app.services.dispatch_offline import generate_standalone_html
+from app.services.dispatch_store import dispatch_store
+from app.services.feed_store import FeedServer
+
+
+class TestDispatchFeedAndSync(AioHTTPTestCase):
+ async def get_application(self):
+ app = web.Application()
+ register_dispatch_routes(app)
+ return app
+
+ def setUp(self):
+ super().setUp()
+ self.dispatch = dispatch_store.create_dispatch(
+ title="Feed Demonstration Story",
+ story_markdown="# Feed Step 1\nWelcome to the sovereign feed.\n---\n# Feed Step 2\n? [ ] Ready for RSS",
+ lead_text="An RSS-syndicated calm invitation.",
+ mode_default="card",
+ )
+
+ def test_rss_feed_generation(self):
+ rss_xml = dispatch_feed_service.generate_rss_2_0("http://localhost:8484")
+ assert "Feed Demonstration Story" in rss_xml
+ assert f"/p/{self.dispatch['token']}" in rss_xml
+ assert "An RSS-syndicated calm invitation." in rss_xml
+
+ def test_json_feed_generation(self):
+ json_feed = dispatch_feed_service.generate_json_feed("http://localhost:8484")
+ assert json_feed["version"] == "https://jsonfeed.org/version/1.1"
+ assert len(json_feed["items"]) >= 1
+ found = any(i["title"] == "Feed Demonstration Story" for i in json_feed["items"])
+ assert found is True
+
+ def test_feed_activity_pod_ingestion_on_rsvp(self):
+ token = self.dispatch["token"]
+ res = dispatch_store.submit_rsvp(
+ token,
+ {"name": "Grace Hopper", "answers": {"Ready for RSS": True}},
+ )
+ assert res["status"] == "success"
+
+ # Check Activity Pod database
+ feed_server = FeedServer()
+ cursor = feed_server._conn.cursor()
+ rows = cursor.execute(
+ "SELECT * FROM user_activity WHERE source = 'dispatch' AND type = 'dispatch_rsvp_received'"
+ ).fetchall()
+ assert len(rows) >= 1
+ last_rsvp = rows[-1]
+ assert "Grace Hopper" in (last_rsvp["content"] or "")
+
+ def test_standalone_html_generation(self):
+ dispatch_id = self.dispatch["id"]
+ standalone_html = generate_standalone_html(dispatch_id)
+ assert "" in standalone_html
+ assert "Feed Demonstration Story" in standalone_html
+ assert "Dispatched via sovereign uDos • 100% Offline" in standalone_html
+ assert "downloadResponse()" in standalone_html
+
+ async def test_api_feed_endpoints(self):
+ # GET /api/dispatch/feed.xml
+ resp_xml = await self.client.get("/api/dispatch/feed.xml")
+ assert resp_xml.status == 200
+ text_xml = await resp_xml.text()
+ assert " 0
+ assert data_bob["gif_size_bytes"] <= 60 * 1024 # <= 60 KB
+
+ # POST /api/dispatch/inbox/ingest
+ token = self.dispatch["token"]
+ inbound_payload = {
+ "token": token,
+ "name": "Alan Turing",
+ "answers": {"Ready for RSS": True},
+ }
+ resp_ingest = await self.client.post("/api/dispatch/inbox/ingest", json=inbound_payload)
+ assert resp_ingest.status == 200
+ data_ingest = await resp_ingest.json()
+ assert data_ingest["status"] == "success"
+
+ # POST /api/dispatch/export-offline/{id}
+ dispatch_id = self.dispatch["id"]
+ resp_export = await self.client.post(f"/api/dispatch/export-offline/{dispatch_id}")
+ assert resp_export.status == 200
+ data_export = await resp_export.json()
+ assert data_export["status"] == "ok"
+ assert Path(data_export["standalone_html_path"]).exists()
+
diff --git a/backend/tests/test_host_pim.py b/backend/tests/test_host_pim.py
index fe2350a..fc60efe 100644
--- a/backend/tests/test_host_pim.py
+++ b/backend/tests/test_host_pim.py
@@ -11,6 +11,10 @@
handle_host_capabilities,
handle_host_notify,
handle_host_say,
+ handle_host_shortcuts,
+ handle_host_shortcuts_run,
+ handle_mail_archive,
+ handle_mail_flag,
handle_notes_export,
handle_notes_intake,
handle_reminders_export,
@@ -307,3 +311,67 @@ def mock_runner(cmd: list[str], timeout: float = 10.0) -> str:
req_say.json = AsyncMock(return_value={"text": "Speaking"})
resp_say = await handle_host_say(req_say)
assert resp_say.status == 200
+
+ # 9. shortcuts list
+ req_sc = MagicMock(spec=web.Request)
+ monkeypatch.setattr(svc, "list_shortcuts", lambda: ["Shortcut A", "Shortcut B"])
+ resp_sc = await handle_host_shortcuts(req_sc)
+ assert resp_sc.status == 200
+ sc_data = json.loads(resp_sc.text)
+ assert sc_data["ok"] is True
+ assert len(sc_data["shortcuts"]) == 2
+
+ # 10. shortcuts run
+ req_sc_run = MagicMock(spec=web.Request)
+ req_sc_run.json = AsyncMock(return_value={"name": "Shortcut A", "input": "text"})
+ monkeypatch.setattr(svc, "run_shortcut", lambda name, input_text="": {"ok": True, "output": "done"})
+ resp_sc_run = await handle_host_shortcuts_run(req_sc_run)
+ assert resp_sc_run.status == 200
+ sc_run_data = json.loads(resp_sc_run.text)
+ assert sc_run_data["ok"] is True
+
+ # 11. mail archive
+ req_mail_arch = MagicMock(spec=web.Request)
+ req_mail_arch.json = AsyncMock(return_value={"message_id": "msg-123"})
+ monkeypatch.setattr(svc, "archive_apple_mail", lambda message_id: {"ok": True, "archived": True})
+ resp_mail_arch = await handle_mail_archive(req_mail_arch)
+ assert resp_mail_arch.status == 200
+
+ # 12. mail flag
+ req_mail_flag = MagicMock(spec=web.Request)
+ req_mail_flag.json = AsyncMock(return_value={"message_id": "msg-123", "flag_index": 2})
+ monkeypatch.setattr(svc, "flag_apple_mail", lambda message_id, flag_index: {"ok": True, "flag_index": flag_index})
+ resp_mail_flag = await handle_mail_flag(req_mail_flag)
+ assert resp_mail_flag.status == 200
+
+
+def test_shortcuts_and_mail_service(monkeypatch):
+ executed = []
+
+ def mock_runner(cmd: list[str], timeout: float = 10.0, input_data: str | None = None) -> str:
+ executed.append((cmd, input_data))
+ if cmd == ["shortcuts", "list"]:
+ return "Summarize Text\nCreate Task\n"
+ if cmd[:2] == ["shortcuts", "run"]:
+ return "Summary result"
+ if "Mail" in str(cmd):
+ return json.dumps({"ok": True, "count": 1, "archived": True})
+ return "ok"
+
+ svc = HostPIMService(runner=mock_runner)
+ monkeypatch.setattr(svc, "is_macos", lambda: True)
+ monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/shortcuts")
+
+ shortcuts = svc.list_shortcuts()
+ assert shortcuts == ["Summarize Text", "Create Task"]
+
+ run_res = svc.run_shortcut("Summarize Text", input_text="Long text")
+ assert run_res["ok"] is True
+ assert run_res["output"] == "Summary result"
+
+ arch_res = svc.archive_apple_mail("msg-999")
+ assert arch_res["ok"] is True
+ assert arch_res["archived"] is True
+
+ flag_res = svc.flag_apple_mail("msg-999", flag_index=3)
+ assert flag_res["ok"] is True
diff --git a/backend/tests/test_mesh_transport.py b/backend/tests/test_mesh_transport.py
new file mode 100644
index 0000000..f340c1c
--- /dev/null
+++ b/backend/tests/test_mesh_transport.py
@@ -0,0 +1,140 @@
+"""Tests for Decentralized Mesh Transport & Discovery Service (BitCore / BitChat).
+
+Verifies:
+- PeerRegistry: local node registration, peer registration, heartbeat update, pruning stale peers
+- MeshDiscoveryService: beacon payload format, beacon parsing, loopback ignore
+- Mesh API: GET /api/network/mesh/peers, POST /api/network/mesh/announce
+"""
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+
+from app.api.bitchat_api import register_bitchat_routes
+from app.services.mesh_transport import (
+ MESH_BEACON_MAGIC,
+ MeshDiscoveryService,
+ MeshPeer,
+ PeerRegistry,
+)
+
+
+def test_peer_registry_lifecycle():
+ """Verify local registration, peer registration, status transitions, and pruning."""
+ registry = PeerRegistry(local_peer_id="test_node_local", local_node_name="Local uDos")
+ assert registry.local_peer_id == "test_node_local"
+
+ peers = registry.get_peers()
+ assert len(peers) == 1
+ assert peers[0]["peer_id"] == "test_node_local"
+ assert peers[0]["is_local"] is True
+ assert peers[0]["status"] == "online"
+
+ # Register remote peer
+ remote_peer = MeshPeer(
+ peer_id="remote_node_1",
+ node_name="Living Room Sonic",
+ ip="192.168.1.50",
+ port=3000,
+ service_type="_bitchat._tcp.local.",
+ capabilities=["chat", "prose"],
+ last_seen=time.time(),
+ is_local=False,
+ )
+ registry.register_peer(remote_peer)
+
+ peers = registry.get_peers()
+ assert len(peers) == 2
+ remote = next(p for p in peers if p["peer_id"] == "remote_node_1")
+ assert remote["node_name"] == "Living Room Sonic"
+ assert remote["status"] == "online"
+
+ # Test idle and offline status based on age
+ remote_peer.last_seen = time.time() - 70 # 70s ago -> idle
+ assert remote_peer.to_dict()["status"] == "idle"
+
+ remote_peer.last_seen = time.time() - 130 # 130s ago -> offline
+ assert remote_peer.to_dict()["status"] == "offline"
+
+ # Heartbeat revives status to online
+ registry.update_heartbeat("remote_node_1")
+ assert registry.get_peer("remote_node_1").status == "online"
+
+ # Stale peer pruning (older than 180s)
+ remote_peer.last_seen = time.time() - 200
+ pruned = registry.prune_stale_peers(max_age_seconds=180.0)
+ assert pruned == 1
+ assert registry.get_peer("remote_node_1") is None
+ # Local peer should never be pruned
+ assert registry.get_peer("test_node_local") is not None
+
+
+def test_discovery_beacon_payload_and_parse():
+ """Verify beacon serialization and parsing."""
+ registry = PeerRegistry(local_peer_id="beacon_tester", local_node_name="Tester Node")
+ discovery = MeshDiscoveryService(registry=registry, port=3000)
+
+ raw_payload = discovery.build_beacon_payload()
+ data = json.loads(raw_payload.decode("utf-8"))
+ assert data["magic"] == MESH_BEACON_MAGIC
+ assert data["peer_id"] == "beacon_tester"
+ assert data["node_name"] == "Tester Node"
+ assert "_bitchat._tcp.local." in data["services"]
+
+ # Parsing valid incoming beacon from remote IP
+ remote_beacon = json.dumps({
+ "magic": MESH_BEACON_MAGIC,
+ "peer_id": "remote_beacon_99",
+ "node_name": "Studio Workshop Kiosk",
+ "port": 3000,
+ "service_type": "_bitchat._tcp.local.",
+ "capabilities": ["chat", "tasks"],
+ "timestamp": time.time(),
+ }).encode("utf-8")
+
+ parsed = discovery.parse_beacon_payload(remote_beacon, ("192.168.1.120", 53535))
+ assert parsed is not None
+ assert parsed.peer_id == "remote_beacon_99"
+ assert parsed.node_name == "Studio Workshop Kiosk"
+ assert parsed.ip == "192.168.1.120"
+ assert parsed.is_local is False
+
+ # Ignore self-beacon
+ self_beacon = discovery.build_beacon_payload()
+ parsed_self = discovery.parse_beacon_payload(self_beacon, ("127.0.0.1", 53535))
+ assert parsed_self is None
+
+ # Ignore non-matching magic
+ invalid_beacon = json.dumps({"magic": "OTHER_PROTOCOL"}).encode("utf-8")
+ assert discovery.parse_beacon_payload(invalid_beacon, ("10.0.0.1", 53535)) is None
+
+
+@pytest.fixture
+async def mesh_client(tmp_path: Path):
+ """Create test client for mesh API."""
+ app = web.Application()
+ register_bitchat_routes(app)
+ async with TestClient(TestServer(app)) as client:
+ yield client
+
+
+@pytest.mark.asyncio
+async def test_mesh_api_peers_and_announce(mesh_client):
+ """Verify GET /api/network/mesh/peers and POST /api/network/mesh/announce."""
+ resp = await mesh_client.get("/api/network/mesh/peers")
+ assert resp.status == 200
+ data = await resp.json()
+ assert data["status"] == "ok"
+ assert "peers" in data
+ assert len(data["peers"]) >= 1
+
+ resp_announce = await mesh_client.post("/api/network/mesh/announce")
+ assert resp_announce.status == 200
+ data_ann = await resp_announce.json()
+ assert data_ann["status"] == "ok"
+ assert "peer_id" in data_ann
diff --git a/backend/tests/test_outgoing_task_sync.py b/backend/tests/test_outgoing_task_sync.py
new file mode 100644
index 0000000..b32bcc1
--- /dev/null
+++ b/backend/tests/test_outgoing_task_sync.py
@@ -0,0 +1,309 @@
+"""Comprehensive tests for the Outgoing Sync & Task Loop:
+- Action-to-Task Pipeline (.tasker markdown generation, due dates, provenance linking)
+- Task-to-Action Bridge (Shortcuts execution, Apple Reminders, Apple Notes, Mail Archiving)
+- Bi-directional Apple Sync (Reminders list creation, due dates, Notes, Mail archiving)
+- Activity Pod Resolution & Auto-Archival Rules
+- Dispatch RSVP to Sovereign Task conversion
+"""
+from __future__ import annotations
+
+import json
+import platform
+import shutil
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from app.api.feed_api import (
+ handle_feed_bulk_resolve,
+ handle_feed_export_notes,
+ handle_feed_export_reminders,
+ handle_feed_promote,
+ handle_feed_resolve,
+ handle_feed_rules_apply,
+)
+from app.services.apple_feed_sync import AppleFeedSync
+from app.services.dispatch_store import DispatchStore
+from app.services.feed_rules import FeedRule, evaluate_feed_rules
+from app.services.feed_store import FeedServer
+from app.services.feed_workflow import promote_activity_to_task
+from app.services.host_pim import HostPIMService
+from app.services.task_action_bridge import TaskActionBridge
+
+
+@pytest.fixture
+def mock_pim(monkeypatch):
+ executed = []
+
+ def mock_runner(cmd: list[str], timeout: float = 10.0, input_data: str | None = None) -> str:
+ executed.append((cmd, input_data))
+ cmd_str = " ".join(cmd)
+ if "shortcuts list" in cmd_str:
+ return "Summarize Action\nQuick Capture\n"
+ if "shortcuts run" in cmd_str:
+ return "Shortcut executed successfully"
+ if "Reminders" in cmd_str:
+ return "reminder-id-xyz"
+ if "Notes" in cmd_str:
+ return "note-id-123"
+ if "Mail" in cmd_str:
+ return json.dumps({"ok": True, "count": 1, "archived": True})
+ return "ok"
+
+ pim = HostPIMService(runner=mock_runner)
+ monkeypatch.setattr(pim, "is_macos", lambda: True)
+ monkeypatch.setattr("shutil.which", lambda prog: f"/usr/bin/{prog}")
+ return pim, executed
+
+
+def test_export_reminder_with_due_date_and_list(mock_pim):
+ pim, executed = mock_pim
+ res = pim.export_to_apple_reminders(
+ title="Follow up on Sovereign RSVP",
+ notes="Guest requested vegan option",
+ list_name="Launch Event",
+ due_date="2026-09-25",
+ )
+ assert res["ok"] is True
+ assert res["list"] == "Launch Event"
+ assert len(executed) == 1
+ script = executed[0][0][-1]
+ assert 'list "Launch Event"' in script
+ assert 'due date:date "2026-09-25"' in script
+
+
+def test_shortcuts_execution(mock_pim):
+ pim, executed = mock_pim
+ shortcuts = pim.list_shortcuts()
+ assert shortcuts == ["Summarize Action", "Quick Capture"]
+
+ run_res = pim.run_shortcut("Summarize Action", input_text="Long report text")
+ assert run_res["ok"] is True
+ assert run_res["output"] == "Shortcut executed successfully"
+
+
+def test_mail_archive_and_flag(mock_pim):
+ pim, executed = mock_pim
+ arch_res = pim.archive_apple_mail("test-msg-123")
+ assert arch_res["ok"] is True
+ assert arch_res["archived"] is True
+
+ flag_res = pim.flag_apple_mail("test-msg-123", flag_index=1)
+ assert flag_res["ok"] is True
+
+
+@pytest.mark.asyncio
+async def test_mail_ingest_with_flags_sets_high_importance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr(platform, "system", lambda: "Darwin")
+ monkeypatch.setattr("app.services.apple_feed_sync.shutil.which", lambda _: "/usr/bin/osascript")
+
+ feed = FeedServer(str(tmp_path / "activity.db"))
+ def runner(_script: str):
+ return [
+ {
+ "external_id": "flagged-mail-1",
+ "title": "Urgent Contract",
+ "content": "Sign by tomorrow",
+ "is_flagged": True,
+ "flag_index": 0,
+ }
+ ]
+ sync = AppleFeedSync(feed, runner=runner)
+ result = await sync.sync("mail")
+ assert result["ok"] is True
+ assert result["imported"] == 1
+
+ rows = await feed.query_feed(source="mail")
+ assert len(rows) == 1
+ assert rows[0]["importance"] == 0.85
+ meta = json.loads(rows[0]["metadata"])
+ assert meta.get("is_flagged") is True
+
+
+@pytest.mark.asyncio
+async def test_promote_activity_to_task_with_reminders_and_archive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mock_pim):
+ pim, executed = mock_pim
+ monkeypatch.setattr("app.services.host_pim.HostPIMService", lambda *args, **kwargs: pim)
+ monkeypatch.setattr("app.flow.task_store.default_tasker_dir", lambda: tmp_path / "tasks")
+
+ activity = {
+ "id": 42,
+ "source": "mail",
+ "external_id": "mail-msg-99",
+ "title": "Review Exhibition Specs",
+ "content": "Ensure Mode 7 Teletext compatibility",
+ }
+
+ promoted = promote_activity_to_task(
+ activity,
+ board="inbox",
+ priority="high",
+ binder="Exhibition",
+ mission="Universal OS",
+ due_date="2026-10-01",
+ sync_apple_reminders=True,
+ archive_source_mail=True,
+ )
+
+ assert Path(promoted["path"]).exists()
+ task_text = Path(promoted["path"]).read_text(encoding="utf-8")
+ assert "due: '2026-10-01'" in task_text or "due: 2026-10-01" in task_text
+ assert "binder: Exhibition" in task_text
+ assert "mission: Universal OS" in task_text
+ assert "synced:apple-reminders" in task_text
+
+ assert promoted["reminders_sync"] is not None
+ assert promoted["reminders_sync"]["ok"] is True
+ assert promoted["mail_archived"] is not None
+ assert promoted["mail_archived"]["archived"] is True
+
+
+def test_dispatch_convert_rsvp_to_task(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mock_pim):
+ pim, executed = mock_pim
+ monkeypatch.setattr("app.services.host_pim.HostPIMService", lambda *args, **kwargs: pim)
+ monkeypatch.setattr("app.flow.task_store.default_tasker_dir", lambda: tmp_path / "tasks")
+
+ store = DispatchStore(root_dir=tmp_path / "dispatches", token_index_path=tmp_path / "tokens.json")
+ disp = store.create_dispatch(title="Zen Gathering", story_markdown="# Welcome to the Tea Ceremony")
+ token = disp["token"]
+
+ store.submit_rsvp(token, {
+ "name": "Lady Ada",
+ "email": "ada@lovelace.org",
+ "answers": {"Attending": "Yes", "Dietary": "Matcha Only"},
+ })
+
+ res = store.convert_rsvp_to_task(
+ dispatch_id=disp["id"],
+ rsvp_index=0,
+ board="inbox",
+ priority="high",
+ binder="Ceremony",
+ due_date="2026-11-05",
+ sync_apple_reminders=True,
+ )
+
+ assert res["status"] == "ok"
+ assert Path(res["path"]).exists()
+ content = Path(res["path"]).read_text(encoding="utf-8")
+ assert "Lady Ada" in content
+ assert "Matcha Only" in content
+
+ # Verify responses.json was updated with task_id
+ responses = store.get_responses(disp["id"])
+ assert responses[0].get("task_id") == res["task_id"]
+
+
+def test_task_action_bridge_execution(tmp_path: Path, mock_pim, monkeypatch: pytest.MonkeyPatch):
+ pim, executed = mock_pim
+ task_dir = tmp_path / "tasks" / "inbox"
+ task_dir.mkdir(parents=True, exist_ok=True)
+ task_file = task_dir / "todo-run-report-1.md"
+ task_file.write_text(
+ "---\ntitle: Run Report\nstatus: todo\nbinder: Analytics\n---\n# Run Report\nGenerate monthly summary\n",
+ encoding="utf-8",
+ )
+
+ bridge = TaskActionBridge(pim=pim)
+ res = bridge.execute_action(
+ task_id="todo-run-report-1",
+ action_type="shortcut",
+ payload={"name": "Summarize Action"},
+ tasker_dir=tmp_path / "tasks",
+ )
+ assert res["ok"] is True
+ assert res["result"]["ok"] is True
+
+
+def test_feed_server_resolve_activity(tmp_path: Path):
+ feed = FeedServer(str(tmp_path / "activity.db"))
+ ingest = feed.ingest_activity_sync(
+ source="dispatch",
+ type="dispatch_rsvp_received",
+ title="RSVP from John",
+ content="Will be there",
+ )
+ act_id = ingest["id"]
+
+ # Activity is initially unprocessed
+ cursor = feed._conn.execute("SELECT processed FROM user_activity WHERE id = ?", (act_id,))
+ assert cursor.fetchone()["processed"] == 0
+
+ # Resolve activity
+ res = feed.resolve_activity(activity_id=act_id, note="Follow-up completed via phone")
+ assert res["ok"] is True
+ assert res["processed"] == 1
+
+ cursor = feed._conn.execute("SELECT processed, metadata FROM user_activity WHERE id = ?", (act_id,))
+ row = cursor.fetchone()
+ assert row["processed"] == 1
+ meta = json.loads(row["metadata"])
+ assert meta["resolution_note"] == "Follow-up completed via phone"
+ assert meta["archived"] is True
+
+ # Bulk resolve
+ ingest2 = feed.ingest_activity_sync(source="mail", type="mail", title="Item 2")
+ bulk_res = feed.bulk_resolve([ingest2["id"]], note="Bulk cleared")
+ assert bulk_res["ok"] is True
+ assert bulk_res["resolved_count"] == 1
+
+
+def test_feed_rules_with_flagged_and_auto_archive(tmp_path: Path):
+ rule_flagged = FeedRule(
+ id="flagged-rule",
+ enabled=True,
+ sources=("mail",),
+ contains=(),
+ min_importance=0.6,
+ require_flagged=True,
+ action="create-task",
+ board="inbox",
+ priority="high",
+ binder="Urgent",
+ sync_reminders=True,
+ archive_mail=True,
+ )
+ rule_normal = FeedRule(
+ id="normal-rule",
+ enabled=True,
+ sources=("mail",),
+ contains=("newsletter",),
+ min_importance=0.4,
+ require_flagged=False,
+ action="auto-archive",
+ board="inbox",
+ priority="low",
+ binder="Sandbox",
+ )
+
+ activities = [
+ {
+ "id": 1,
+ "source": "mail",
+ "title": "Flagged urgent email",
+ "importance": 0.85,
+ "metadata": {"is_flagged": True},
+ },
+ {
+ "id": 2,
+ "source": "mail",
+ "title": "Routine newsletter",
+ "importance": 0.5,
+ "metadata": {"is_flagged": False},
+ },
+ ]
+
+ proposals = evaluate_feed_rules(activities, [rule_flagged, rule_normal])
+ assert len(proposals) == 2
+
+ # Proposal 1 matched flagged-rule
+ prop1 = next(p for p in proposals if p["activity_id"] == 1)
+ assert prop1["rule_id"] == "flagged-rule"
+ assert prop1["sync_reminders"] is True
+ assert prop1["archive_mail"] is True
+
+ # Proposal 2 matched normal-rule
+ prop2 = next(p for p in proposals if p["activity_id"] == 2)
+ assert prop2["rule_id"] == "normal-rule"
+ assert prop2["action"] == "auto-archive"
diff --git a/config/feed-rules.example.yaml b/config/feed-rules.example.yaml
index b35429f..0c35bad 100644
--- a/config/feed-rules.example.yaml
+++ b/config/feed-rules.example.yaml
@@ -25,3 +25,29 @@ rules:
board: planning
priority: medium
binder: Sandbox
+
+ - id: dispatch-rsvp-followup
+ enabled: true
+ match:
+ sources: [dispatch]
+ contains: [rsvp, answer, attending, dietary, menu, note]
+ min_importance: 0.5
+ action:
+ type: propose-task
+ board: inbox
+ priority: high
+ binder: Sandbox
+ sync_reminders: true
+
+ - id: vip-flagged-mail
+ enabled: true
+ match:
+ sources: [mail]
+ flagged: true
+ min_importance: 0.7
+ action:
+ type: propose-task
+ board: inbox
+ priority: high
+ binder: Sandbox
+ archive_mail: false
diff --git a/frontend-vue/src/layouts/AppShell.vue b/frontend-vue/src/layouts/AppShell.vue
index 34575c6..9fc07bf 100644
--- a/frontend-vue/src/layouts/AppShell.vue
+++ b/frontend-vue/src/layouts/AppShell.vue
@@ -1,5 +1,8 @@
-
+
+
+
+
route.path === "/workflow" && String(route.query.tab || "") === "editor",
);
+const isStandalone = computed(
+ () => route.meta.layout === "standalone" || route.path.startsWith("/p/"),
+);
const globalSidebarOpen = computed(
- () => shell.sidebarOpen && !isWorkflowEditor.value,
+ () => shell.sidebarOpen && !isWorkflowEditor.value && !isStandalone.value,
);
// Initialize settings store to apply persisted theme (dark mode default)
diff --git a/frontend-vue/src/router/index.ts b/frontend-vue/src/router/index.ts
index ab87a84..96da1cb 100644
--- a/frontend-vue/src/router/index.ts
+++ b/frontend-vue/src/router/index.ts
@@ -66,6 +66,12 @@ const routes: RouteRecordRaw[] = [
component: WorkflowSurface,
meta: { title: "Workflow", icon: "workflow" },
},
+ {
+ path: "/p/:token",
+ name: "dispatch-story",
+ component: () => import("../surfaces/dispatch/DispatchStoryView.vue"),
+ meta: { title: "Dispatch", public: true, layout: "standalone" },
+ },
{
path: "/system/:pageId(s\\d{3}|p\\d{3})",
name: "systempage",
@@ -108,6 +114,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("../surfaces/homenest/HomeNestSurface.vue"),
meta: { title: "HomeNest Console", icon: "tv" },
},
+ {
+ path: "/bitchat/:pathMatch(.*)*",
+ name: "bitchat",
+ component: () => import("../surfaces/bitchat/BitChatSurface.vue"),
+ meta: { title: "BitChat", icon: "forum" },
+ },
{
path: "/browserui/:pathMatch(.*)*",
name: "browserui",
diff --git a/frontend-vue/src/skills/molecules/editor/EnhancedBangleToolbar.vue b/frontend-vue/src/skills/molecules/editor/EnhancedBangleToolbar.vue
index d741fc8..886c7be 100644
--- a/frontend-vue/src/skills/molecules/editor/EnhancedBangleToolbar.vue
+++ b/frontend-vue/src/skills/molecules/editor/EnhancedBangleToolbar.vue
@@ -53,6 +53,7 @@ export type EditorCommand =
| "blockquote" | "bullet-list" | "ordered-list" | "code-block" | "horizontal-rule"
| "table" | "callout" | "footnote" | "outline"
| "image" | "mode-toggle"
+ | "insert-quantum" | "insert-question" | "insert-prompt" | "insert-bob"
| "scrape" | "summarize" | "citation" | "combine"
| "copy-binder" | "variant" | "archive"
| "undo" | "redo";
@@ -71,8 +72,16 @@ const blockCommands = [
item("ordered-list", "Ordered list", "format_list_numbered"), item("code-block", "Code block", "data_object"),
item("horizontal-rule", "Divider", "horizontal_rule"),
];
-const structureCommands = [item("table", "Table", "table"), item("callout", "Callout", "campaign"), item("footnote", "Footnote", "footnote"), item("outline", "Outline", "toc")];
-structureCommands.push(item("image", "Image", "image"));
+const structureCommands = [
+ item("table", "Table", "table"),
+ item("callout", "Callout", "campaign"),
+ item("footnote", "Footnote", "footnote"),
+ item("insert-quantum", "Card Break", "crop_portrait"),
+ item("insert-question", "Choice Question", "quiz"),
+ item("insert-prompt", "Text Prompt", "edit_note"),
+ item("insert-bob", "Curated BOB", "animation"),
+ item("image", "Image", "image"),
+];
const researchCommands = [
item("scrape", "Capture research", "travel_explore"),
item("summarize", "Summarize", "summarize"),
diff --git a/frontend-vue/src/skills/molecules/editor/MarkdownEditor.vue b/frontend-vue/src/skills/molecules/editor/MarkdownEditor.vue
index 678e6e9..a2e0f3d 100644
--- a/frontend-vue/src/skills/molecules/editor/MarkdownEditor.vue
+++ b/frontend-vue/src/skills/molecules/editor/MarkdownEditor.vue
@@ -210,6 +210,10 @@ function handleToolbarCommand(command: EditorCommand) {
if (command === "ordered-list") return applyOrderedList();
if (command === "code-block") return insertBlock("```\ncode\n```\n");
if (command === "horizontal-rule") return insertBlock("---\n");
+ if (command === "insert-quantum") return insertBlock("\n---\n\n");
+ if (command === "insert-question") return insertBlock("\n? [ ] Attend / Yes / No\n");
+ if (command === "insert-prompt") return insertBlock("\n? (Dietary Requirements): [text]\n");
+ if (command === "insert-bob") return insertBlock("\n\n");
if (command === "table") return insertBlock("| Column | Value |\n| --- | --- |\n| Item | Value |\n");
if (command === "callout") return insertBlock("> [!NOTE]\n> Add a note.\n");
if (command === "footnote") return insertBlock("Reference[^1]\n\n[^1]: Source details.\n");
diff --git a/frontend-vue/src/surfaces/bitchat/BitChatSurface.test.ts b/frontend-vue/src/surfaces/bitchat/BitChatSurface.test.ts
new file mode 100644
index 0000000..c446541
--- /dev/null
+++ b/frontend-vue/src/surfaces/bitchat/BitChatSurface.test.ts
@@ -0,0 +1,158 @@
+// @vitest-environment jsdom
+
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import BitChatSurface from "./BitChatSurface.vue";
+
+// Mock vue-router
+vi.mock("vue-router", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
+describe("BitChatSurface", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders discovered mesh peers and messages", async () => {
+ const fakePeers = {
+ status: "ok",
+ local_peer_id: "node_host_01",
+ local_node_name: "uDos Studio Host",
+ peers: [
+ {
+ peer_id: "node_host_01",
+ node_name: "uDos Studio Host",
+ ip: "127.0.0.1",
+ port: 3000,
+ status: "online",
+ is_local: true,
+ services: ["_bitchat._tcp.local."],
+ },
+ {
+ peer_id: "node_kiosk_99",
+ node_name: "Workshop Kiosk",
+ ip: "192.168.1.150",
+ port: 3000,
+ status: "online",
+ is_local: false,
+ services: ["_bitchat._tcp.local."],
+ },
+ ],
+ };
+
+ const fakeMessages = {
+ status: "ok",
+ channel: "#general",
+ messages: [
+ {
+ id: 1,
+ msg_id: "msg_001",
+ channel: "#general",
+ sender_name: "Workshop Kiosk",
+ content: "Mesh beacon received! Ready for sync.",
+ timestamp: "2026-09-14T12:00:00Z",
+ saved_to_binder: false,
+ task_id: null,
+ },
+ ],
+ };
+
+ global.fetch = vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/api/network/mesh/peers")) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => fakePeers,
+ });
+ }
+ if (url.includes("/api/network/bitchat/messages")) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => fakeMessages,
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({ status: "ok" }),
+ });
+ });
+
+ const wrapper = mount(BitChatSurface);
+ await flushPromises();
+
+ // Verify UI header
+ expect(wrapper.text()).toContain("BitChat");
+ expect(wrapper.text()).toContain("Decentralized LAN Mesh");
+
+ // Verify discovered peers
+ expect(wrapper.text()).toContain("uDos Studio Host");
+ expect(wrapper.text()).toContain("Workshop Kiosk");
+
+ // Verify message stream
+ expect(wrapper.text()).toContain("Mesh beacon received! Ready for sync.");
+
+ // Verify channel tabs
+ expect(wrapper.text()).toContain("general");
+ expect(wrapper.text()).toContain("briefing");
+ expect(wrapper.text()).toContain("engineering");
+ });
+
+ it("supports selecting messages for Save to Binder modal", async () => {
+ const fakePeers = {
+ status: "ok",
+ local_peer_id: "node_host_01",
+ local_node_name: "uDos Studio Host",
+ peers: [],
+ };
+
+ const fakeMessages = {
+ status: "ok",
+ channel: "#general",
+ messages: [
+ {
+ id: 1,
+ msg_id: "msg_select_test",
+ channel: "#general",
+ sender_name: "Alice",
+ content: "Important discussion point for binder",
+ timestamp: "2026-09-14T12:00:00Z",
+ saved_to_binder: false,
+ task_id: null,
+ },
+ ],
+ };
+
+ global.fetch = vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/api/network/mesh/peers")) {
+ return Promise.resolve({ ok: true, json: async () => fakePeers });
+ }
+ if (url.includes("/api/network/bitchat/messages")) {
+ return Promise.resolve({ ok: true, json: async () => fakeMessages });
+ }
+ return Promise.resolve({ ok: true, json: async () => ({ status: "ok" }) });
+ });
+
+ const wrapper = mount(BitChatSurface);
+ await flushPromises();
+
+ // Select the message checkbox
+ const checkbox = wrapper.find(".bitchat-msg-select input");
+ expect(checkbox.exists()).toBe(true);
+ await checkbox.setValue(true);
+
+ // Save to Binder button should now be visible in header
+ expect(wrapper.text()).toContain("1 selected");
+ expect(wrapper.text()).toContain("Save to Binder");
+
+ // Click Save to Binder
+ const saveBtn = wrapper.findAll(".bitchat-btn").find((b) => b.text().includes("Save to Binder"));
+ expect(saveBtn).toBeDefined();
+ await saveBtn!.trigger("click");
+
+ // Modal should be open
+ expect(wrapper.find(".bitchat-modal").exists()).toBe(true);
+ expect(wrapper.text()).toContain("Save Discussion Evidence to Binder");
+ });
+});
diff --git a/frontend-vue/src/surfaces/bitchat/BitChatSurface.vue b/frontend-vue/src/surfaces/bitchat/BitChatSurface.vue
new file mode 100644
index 0000000..471352d
--- /dev/null
+++ b/frontend-vue/src/surfaces/bitchat/BitChatSurface.vue
@@ -0,0 +1,1272 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
💬
+
No messages in {{ currentChannel }} yet
+
+ Broadcast a message across your local mesh network or save notes into your binders.
+
+
+
+
+
+
+
+
+
+
+
+
{{ msg.content }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exports {{ selectedMsgIds.length }} message(s) to
+ ~/Vault/binders/<binder>/evidence/chats/ as Markdown.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ORIGINAL MESSAGE:
+
{{ activeTaskMsg.content }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ toastMessage }}
+
+
+
+
+
+
+
diff --git a/frontend-vue/src/surfaces/dashboard/DashboardSurface.vue b/frontend-vue/src/surfaces/dashboard/DashboardSurface.vue
index fdc1663..0417423 100644
--- a/frontend-vue/src/surfaces/dashboard/DashboardSurface.vue
+++ b/frontend-vue/src/surfaces/dashboard/DashboardSurface.vue
@@ -204,6 +204,13 @@ const SURFACE_CARD_DATA: Record<
route: "/homenest",
color: "var(--usx-color-info)",
},
+ bitchat: {
+ title: "BitChat",
+ description: "Decentralized Local Mesh Transport & Chat",
+ icon: "forum",
+ route: "/bitchat",
+ color: "var(--usx-color-success)",
+ },
};
const visibleSurfaces = computed(() => {
@@ -254,6 +261,9 @@ const visibleSurfaces = computed(() => {
if (!seen.has("homenest")) {
cards.push({ id: "homenest", ...SURFACE_CARD_DATA.homenest });
}
+ if (!seen.has("bitchat")) {
+ cards.push({ id: "bitchat", ...SURFACE_CARD_DATA.bitchat });
+ }
// Always show Markdown Editor card
cards.push({ id: "markdown", ...SURFACE_CARD_DATA.markdown });
diff --git a/frontend-vue/src/surfaces/dispatch/DispatchStoryView.test.ts b/frontend-vue/src/surfaces/dispatch/DispatchStoryView.test.ts
new file mode 100644
index 0000000..5b24e31
--- /dev/null
+++ b/frontend-vue/src/surfaces/dispatch/DispatchStoryView.test.ts
@@ -0,0 +1,120 @@
+// @vitest-environment jsdom
+
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import DispatchStoryView from "./DispatchStoryView.vue";
+
+// Mock vue-router
+vi.mock("vue-router", () => ({
+ useRoute: () => ({
+ params: { token: "test_token_123" },
+ }),
+ RouterLink: {
+ template: "",
+ },
+}));
+
+describe("DispatchStoryView", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders active dispatch and supports Tri-Mode view switching", async () => {
+ const fakeDispatch = {
+ status: "active",
+ dispatch: {
+ id: "disp_100",
+ token: "test_token_123",
+ title: "Midsummer Solstice",
+ lead_text: "A serene community celebration",
+ mode_default: "card",
+ },
+ story_markdown: "# Welcome\n\nJoin us under the ancient oaks.\n\n---\n\n? [ ] Attend / Yes / No\n\n---\n\nThank you!",
+ };
+
+ global.fetch = vi.fn().mockResolvedValue({
+ status: 200,
+ json: async () => fakeDispatch,
+ });
+
+ const wrapper = mount(DispatchStoryView, {
+ global: {
+ stubs: {
+ "router-link": { template: "" },
+ },
+ },
+ });
+ await flushPromises();
+
+ // Verify Title and Lead rendered
+ expect(wrapper.text()).toContain("Midsummer Solstice");
+ expect(wrapper.text()).toContain("A serene community celebration");
+
+ // Starts in [Card] mode
+ expect(wrapper.find(".dispatch-stage--card").exists()).toBe(true);
+
+ // Switch to [Deck] mode
+ const deckBtn = wrapper.findAll(".dispatch-mode-btn").find((b) => b.text().includes("[Deck]"));
+ expect(deckBtn).toBeDefined();
+ await deckBtn!.trigger("click");
+ expect(wrapper.find(".dispatch-stage--deck").exists()).toBe(true);
+
+ // Switch to [Prose] mode
+ const proseBtn = wrapper.findAll(".dispatch-mode-btn").find((b) => b.text().includes("[Prose]"));
+ expect(proseBtn).toBeDefined();
+ await proseBtn!.trigger("click");
+ expect(wrapper.find(".dispatch-stage--prose").exists()).toBe(true);
+ expect(wrapper.find(".dispatch-prose-title").text()).toBe("Midsummer Solstice");
+ });
+
+ it("renders ephemeral tombstone when dispatch is burned or expired", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ status: 200,
+ json: async () => ({
+ status: "burned",
+ tombstone: "This dispatch was ephemeral and has dissolved into the ether.",
+ }),
+ });
+
+ const wrapper = mount(DispatchStoryView, {
+ global: {
+ stubs: {
+ "router-link": { template: "" },
+ },
+ },
+ });
+ await flushPromises();
+
+ expect(wrapper.find(".dispatch-tombstone").exists()).toBe(true);
+ expect(wrapper.text()).toContain("Ether Dissolution");
+ expect(wrapper.text()).toContain("dissolved into the ether");
+ });
+
+ it("toggles calibrated aspect ratio between 16:9 and 4:3", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ status: 200,
+ json: async () => ({
+ status: "active",
+ dispatch: { title: "Aspect Test" },
+ story_markdown: "Card 1\n---\nCard 2",
+ }),
+ });
+
+ const wrapper = mount(DispatchStoryView, {
+ global: {
+ stubs: {
+ "router-link": { template: "" },
+ },
+ },
+ });
+ await flushPromises();
+
+ expect(wrapper.find(".aspect-16\\:9").exists()).toBe(true);
+
+ const aspectBtn = wrapper.findAll(".dispatch-btn-sm").find((b) => b.text() === "16:9");
+ expect(aspectBtn).toBeDefined();
+ await aspectBtn!.trigger("click");
+
+ expect(wrapper.find(".aspect-4\\:3").exists()).toBe(true);
+ });
+});
diff --git a/frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue b/frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue
new file mode 100644
index 0000000..4aff445
--- /dev/null
+++ b/frontend-vue/src/surfaces/dispatch/DispatchStoryView.vue
@@ -0,0 +1,886 @@
+
+
+
+
+
+
✦
+
Ether Dissolution
+
{{ tombstoneMessage }}
+
Return to Sovereign Home
+
+
+
+
+
+
Connecting to sovereign dispatch...
+
+
+
+
+
+
Dispatch Unavailable
+
{{ error }}
+
Return Home
+
+
+
+
+
+
+
+
+ uDOS DISPATCH
+ {{ manifest?.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Hero Animation]()
+
+
{{ manifest?.title }}
+
{{ manifest.lead_text }}
+
+
+
+
+
+
+
✦
+
Response Dispatched
+
Your responses have been recorded peacefully in the host vault.
+
+ (This one-time dispatch will dissolve upon closing this window.)
+
+
+
+
+
+
+
Step {{ currentCardIndex }} of {{ renderedCards.length - 1 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Hero Animation]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Hero Animation]()
+
+
+
+
+
+
+
Respond to this Dispatch
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend-vue/src/surfaces/workflow/panels/NotebookBinderPanel.vue b/frontend-vue/src/surfaces/workflow/panels/NotebookBinderPanel.vue
index 7d5e0f8..ab3dcf5 100644
--- a/frontend-vue/src/surfaces/workflow/panels/NotebookBinderPanel.vue
+++ b/frontend-vue/src/surfaces/workflow/panels/NotebookBinderPanel.vue
@@ -346,6 +346,274 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ No interactive dispatches published for this binder yet. Click "New Dispatch" above to generate a shareable token.
+
+
+
+
+
+
+ {{ disp.title }}
+ Mode: {{ disp.mode_default }}
+ 🔥 Burn After Read
+ Expired
+
+
+ Token: {{ disp.token }}
+ Created: {{ new Date(disp.created_at).toLocaleDateString() }}
+ Expires: {{ new Date(disp.expires_at).toLocaleDateString() }}
+ Views: {{ disp.views_count || 0 }}
+
+
+
+
+
+ Open Story (/p/{{ disp.token }})
+
+
+ Preview Email
+
+
+
+
+
+
+
+
+
+
+ No responses or RSVP submissions received yet.
+
+
+
+
+ | Respondent / Attendee |
+ Status |
+ Email / Note |
+ Form Responses |
+ Timestamp |
+ Action |
+
+
+
+
+ | {{ resp.name || resp.attendee_name || 'Anonymous' }} |
+
+
+ {{ resp.status || 'Received' }}
+
+ |
+ {{ resp.email || resp.notes || '-' }} |
+
+
+
+ {{ q }}: {{ val }}
+
+
+ -
+ |
+ {{ new Date(resp.timestamp || resp.created_at || Date.now()).toLocaleTimeString() }} |
+
+
+ Linked
+
+
+ |
+
+
+
+
+
+
+
+
Accept Working Draft into Immutable Edition
Freezes current draft, requirements coverage, and evidence into a permanent snapshot.
@@ -518,6 +786,29 @@ const loadedBaseHash = ref("");
const reviewerNotes = ref("");
const publicationReceipt = ref
(null);
+const showDispatchCreator = ref(false);
+const creatingDispatch = ref(false);
+const catalogBobs = ref([]);
+const binderDispatches = ref([]);
+const responsesByDispatch = ref>({});
+const activeResponseDispatchId = ref(null);
+const showMotifGenerator = ref(false);
+const selectedMotif = ref("zen_envelope");
+const selectedMotifPalette = ref("teletext_ceefax");
+const generatingBob = ref(false);
+const exportingOfflineId = ref(null);
+const syncToRemindersOnTaskCreation = ref(true);
+const creatingTaskIndex = ref(null);
+const dispatchForm = ref({
+ title: "",
+ lead_text: "",
+ story_markdown: "",
+ hero_gif_name: "",
+ mode_default: "card",
+ expires_in_hours: 24,
+ burn_after_read: false,
+});
+
const showCreateModal = ref(false);
const newBinder = ref({
id: "",
@@ -598,7 +889,11 @@ async function loadActiveBinder() {
const data = await res.json();
activeBinder.value = data.binder;
editableDraft.value = data.binder.draft || "";
+ if (!dispatchForm.value.story_markdown) {
+ dispatchForm.value.story_markdown = editableDraft.value;
+ }
loadedBaseHash.value = data.binder.draft_sha256 || "";
+ await loadBinderDispatches();
} catch (e: any) {
errorMessage.value = e.message || String(e);
} finally {
@@ -924,8 +1219,209 @@ async function exportObsidian() {
}
}
+async function loadElementCatalog() {
+ try {
+ const res = await fetch("/api/dispatch/catalog");
+ if (!res.ok) return;
+ const data = await res.json();
+ catalogBobs.value = data.bobs || [];
+ } catch (e) {
+ // Non-critical background catalog load
+ }
+}
+
+async function loadBinderDispatches() {
+ if (!selectedBinderId.value) {
+ binderDispatches.value = [];
+ return;
+ }
+ try {
+ const res = await fetch(`/api/dispatch/by-binder/${encodeURIComponent(selectedBinderId.value)}`);
+ if (!res.ok) return;
+ const data = await res.json();
+ binderDispatches.value = data.dispatches || [];
+ } catch (e) {
+ // Non-critical background load
+ }
+}
+
+async function createBinderDispatch() {
+ if (!dispatchForm.value.title.trim() || !selectedBinderId.value) return;
+ creatingDispatch.value = true;
+ errorMessage.value = "";
+ try {
+ const payload = {
+ binder_id: selectedBinderId.value,
+ title: dispatchForm.value.title.trim(),
+ lead_text: dispatchForm.value.lead_text.trim(),
+ story_markdown: dispatchForm.value.story_markdown || editableDraft.value,
+ hero_gif_name: dispatchForm.value.hero_gif_name || null,
+ mode_default: dispatchForm.value.mode_default,
+ burn_after_read: dispatchForm.value.burn_after_read,
+ expires_in_seconds: dispatchForm.value.expires_in_hours > 0 ? dispatchForm.value.expires_in_hours * 3600 : null,
+ };
+ const res = await fetch("/api/dispatch/create", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err.error || `Dispatch creation failed (${res.status})`);
+ }
+ const data = await res.json();
+ successMessage.value = `Sovereign dispatch staged! Story URL: ${data.story_url}`;
+ showDispatchCreator.value = false;
+ dispatchForm.value = {
+ title: "",
+ lead_text: "",
+ story_markdown: editableDraft.value,
+ hero_gif_name: "",
+ mode_default: "card",
+ expires_in_hours: 24,
+ burn_after_read: false,
+ };
+ await loadBinderDispatches();
+ } catch (e: any) {
+ errorMessage.value = e.message || String(e);
+ } finally {
+ creatingDispatch.value = false;
+ }
+}
+
+async function toggleResponsesLedger(dispatchId: string) {
+ if (activeResponseDispatchId.value === dispatchId) {
+ activeResponseDispatchId.value = null;
+ return;
+ }
+ activeResponseDispatchId.value = dispatchId;
+ try {
+ const res = await fetch(`/api/dispatch/responses/${encodeURIComponent(dispatchId)}`);
+ if (!res.ok) return;
+ const data = await res.json();
+ responsesByDispatch.value = {
+ ...responsesByDispatch.value,
+ [dispatchId]: data.responses || [],
+ };
+ } catch (e) {
+ // Non-critical
+ }
+}
+
+async function generateVectorBob() {
+ generatingBob.value = true;
+ errorMessage.value = "";
+ try {
+ const res = await fetch("/api/dispatch/generate-bob", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ motif: selectedMotif.value,
+ palette_id: selectedMotifPalette.value,
+ steps: 4,
+ delay_ms: 100,
+ }),
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err.error || `BOB generation failed (${res.status})`);
+ }
+ const data = await res.json();
+ dispatchForm.value.hero_gif_name = data.asset_name;
+ // Add to catalog items if not present
+ if (!catalogBobs.value.some((b: any) => b.asset_path === data.asset_name)) {
+ catalogBobs.value.unshift({
+ id: data.asset_name,
+ name: data.asset_name,
+ asset_path: data.asset_name,
+ preview_url: data.asset_url,
+ });
+ }
+ successMessage.value = `Vector BOB generated on 4×4 dot lattice: ${data.asset_name} (${data.gif_size_bytes} bytes)`;
+ showMotifGenerator.value = false;
+ } catch (e: any) {
+ errorMessage.value = e.message || String(e);
+ } finally {
+ generatingBob.value = false;
+ }
+}
+
+async function exportOffline(dispatchId: string) {
+ exportingOfflineId.value = dispatchId;
+ errorMessage.value = "";
+ try {
+ const res = await fetch(`/api/dispatch/export-offline/${encodeURIComponent(dispatchId)}`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err.error || `Offline export failed (${res.status})`);
+ }
+ const data = await res.json();
+ successMessage.value = `Offline bundle ready: ${data.standalone_html_path}`;
+ } catch (e: any) {
+ errorMessage.value = e.message || String(e);
+ } finally {
+ exportingOfflineId.value = null;
+ }
+}
+
+async function handleUploadResponseFile(event: Event, dispatchId: string) {
+ const target = event.target as HTMLInputElement;
+ if (!target.files || target.files.length === 0) return;
+ const file = target.files[0];
+ try {
+ const text = await file.text();
+ const payload = JSON.parse(text);
+ const res = await fetch("/api/dispatch/inbox/ingest", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err.error || `Ingestion failed (${res.status})`);
+ }
+ successMessage.value = `Offline response from ${payload.name || 'guest'} ingested peacefully into Activity Pod & ledger!`;
+ await toggleResponsesLedger(dispatchId);
+ } catch (e: any) {
+ errorMessage.value = `Failed to ingest response file: ${e.message || e}`;
+ } finally {
+ target.value = "";
+ }
+}
+
+async function convertRsvpToTask(dispatchId: string, rsvpIndex: number) {
+ creatingTaskIndex.value = `${dispatchId}-${rsvpIndex}`;
+ errorMessage.value = "";
+ try {
+ const res = await fetch(`/api/dispatch/${encodeURIComponent(dispatchId)}/rsvp/${rsvpIndex}/task`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ binder: activeBinder.value?.name || "Sandbox",
+ sync_apple_reminders: syncToRemindersOnTaskCreation.value,
+ }),
+ });
+ if (!res.ok) {
+ const err = await res.json();
+ throw new Error(err.error || "Failed to convert RSVP to task");
+ }
+ const data = await res.json();
+ if (responsesByDispatch.value[dispatchId] && responsesByDispatch.value[dispatchId][rsvpIndex]) {
+ responsesByDispatch.value[dispatchId][rsvpIndex].task_id = data.task_id;
+ }
+ successMessage.value = `Created sovereign task: ${data.task_id}${data.reminders_sync?.ok ? ' & synced to Apple Reminders' : ''}`;
+ } catch (e: any) {
+ errorMessage.value = e.message || String(e);
+ } finally {
+ creatingTaskIndex.value = null;
+ }
+}
+
onMounted(() => {
void fetchBinders();
+ void loadElementCatalog();
});
@@ -1585,4 +2081,487 @@ onMounted(() => {
gap: 0.75rem;
flex-wrap: wrap;
}
+
+/* Sovereign Dispatch Studio Styles */
+.dispatch-studio-card {
+ background: var(--surface-card, #1e293b);
+ border: 1px solid var(--border-subtle, #334155);
+ border-radius: 8px;
+ padding: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.dispatch-studio-card__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.dispatch-studio-title {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.dispatch-studio-title h4 {
+ margin: 0;
+ font-size: 1rem;
+ color: var(--text-primary, #f8fafc);
+}
+
+.dispatch-studio-title p {
+ margin: 0.2rem 0 0;
+ font-size: 0.8rem;
+ color: var(--text-muted, #94a3b8);
+}
+
+.dispatch-form {
+ background: rgba(15, 23, 42, 0.6);
+ border: 1px solid var(--border-subtle, #334155);
+ border-radius: 6px;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.dispatch-form__grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+@media (max-width: 768px) {
+ .dispatch-form__grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.dispatch-form__col {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.dispatch-form__col label {
+ font-size: 0.8rem;
+ color: var(--text-muted, #94a3b8);
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.dispatch-form__col input[type="text"],
+.dispatch-form__col select {
+ background: var(--surface-card, #1e293b);
+ border: 1px solid var(--border-subtle, #334155);
+ border-radius: 4px;
+ padding: 0.5rem 0.75rem;
+ color: var(--text-primary, #f8fafc);
+ font-size: 0.875rem;
+}
+
+.form-row {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.form-row > * {
+ flex: 1;
+}
+
+.checkbox-label {
+ display: flex !important;
+ flex-direction: row !important;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ font-size: 0.8rem;
+ color: var(--text-secondary, #cbd5e1);
+ margin-top: 0.25rem;
+}
+
+.bob-picker {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
+ gap: 0.5rem;
+ max-height: 180px;
+ overflow-y: auto;
+ padding: 0.5rem;
+ background: var(--surface-card, #1e293b);
+ border: 1px solid var(--border-subtle, #334155);
+ border-radius: 6px;
+}
+
+.bob-option {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.4rem;
+ border-radius: 4px;
+ cursor: pointer;
+ border: 1px solid transparent;
+ background: rgba(255, 255, 255, 0.03);
+ transition: all 0.15s ease;
+}
+
+.bob-option:hover {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(56, 189, 248, 0.4);
+}
+
+.bob-option--selected {
+ border-color: #38bdf8;
+ background: rgba(56, 189, 248, 0.15);
+}
+
+.bob-thumb {
+ width: 48px;
+ height: 48px;
+ object-fit: contain;
+ image-rendering: pixelated;
+}
+
+.bob-placeholder {
+ width: 48px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.75rem;
+ color: var(--text-muted, #94a3b8);
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 4px;
+}
+
+.bob-label {
+ font-size: 0.7rem;
+ text-align: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 72px;
+ color: var(--text-secondary, #cbd5e1);
+}
+
+.dispatch-content-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.dispatch-content-field .field-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.dispatch-content-field label {
+ font-size: 0.8rem;
+ color: var(--text-muted, #94a3b8);
+}
+
+.dispatch-textarea {
+ background: var(--surface-card, #1e293b);
+ border: 1px solid var(--border-subtle, #334155);
+ border-radius: 4px;
+ padding: 0.6rem;
+ color: var(--text-primary, #f8fafc);
+ font-family: monospace;
+ font-size: 0.85rem;
+ line-height: 1.4;
+ resize: vertical;
+}
+
+.dispatch-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.binder-dispatches-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ margin-top: 0.5rem;
+}
+
+.dispatches-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.dispatches-header h5 {
+ margin: 0;
+ font-size: 0.9rem;
+ color: var(--text-primary, #f8fafc);
+}
+
+.dispatch-item-card {
+ background: rgba(15, 23, 42, 0.4);
+ border: 1px solid var(--border-subtle, #334155);
+ border-radius: 6px;
+ padding: 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.dispatch-item-main {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.dispatch-item-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.dispatch-item-title-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.95rem;
+}
+
+.dispatch-mode-tag, .dispatch-burn-tag, .dispatch-expired-tag {
+ font-size: 0.7rem;
+ padding: 0.1rem 0.35rem;
+ border-radius: 3px;
+ text-transform: uppercase;
+}
+
+.dispatch-mode-tag {
+ background: rgba(56, 189, 248, 0.15);
+ color: #38bdf8;
+}
+
+.dispatch-burn-tag {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+}
+
+.dispatch-expired-tag {
+ background: rgba(148, 163, 184, 0.15);
+ color: #94a3b8;
+}
+
+.dispatch-item-meta {
+ display: flex;
+ gap: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--text-muted, #94a3b8);
+}
+
+.dispatch-item-meta code {
+ color: #38bdf8;
+}
+
+.dispatch-item-actions {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.dispatch-item-actions .action-btn {
+ font-size: 0.8rem;
+ padding: 0.35rem 0.65rem;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.responses-ledger {
+ border-top: 1px solid var(--border-subtle, #334155);
+ padding-top: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.responses-ledger h6 {
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--text-secondary, #cbd5e1);
+}
+
+.responses-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8rem;
+}
+
+.responses-table th, .responses-table td {
+ padding: 0.4rem 0.6rem;
+ text-align: left;
+ border-bottom: 1px solid rgba(51, 65, 85, 0.5);
+}
+
+.responses-table th {
+ color: var(--text-muted, #94a3b8);
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+}
+
+.status-pill {
+ font-size: 0.7rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 3px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.status-accepted, .status-yes, .status-attending {
+ background: rgba(34, 197, 94, 0.2);
+ color: #22c55e;
+}
+
+.status-declined, .status-no {
+ background: rgba(239, 68, 68, 0.2);
+ color: #ef4444;
+}
+
+.status-maybe, .status-received {
+ background: rgba(56, 189, 248, 0.2);
+ color: #38bdf8;
+}
+
+.answers-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.answer-item {
+ font-size: 0.75rem;
+}
+
+.answer-item code {
+ color: var(--text-muted, #94a3b8);
+}
+
+.time-col {
+ color: var(--text-muted, #94a3b8);
+ font-size: 0.75rem;
+ white-space: nowrap;
+}
+
+.ledger-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.sync-reminders-checkbox {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.75rem;
+ color: var(--text-muted, #94a3b8);
+ cursor: pointer;
+ user-select: none;
+}
+
+.sync-reminders-checkbox input {
+ cursor: pointer;
+ accent-color: #38bdf8;
+}
+
+.action-col {
+ white-space: nowrap;
+}
+
+.task-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.75rem;
+ color: #22c55e;
+ background: rgba(34, 197, 94, 0.15);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+ padding: 0.15rem 0.45rem;
+ border-radius: 4px;
+}
+
+.create-task-btn {
+ font-size: 0.75rem;
+ padding: 0.2rem 0.5rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.dispatch-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.bob-header-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.35rem;
+}
+
+.motif-toggle-btn {
+ font-size: 0.75rem;
+ padding: 0.15rem 0.4rem;
+}
+
+.motif-generator-box {
+ background: rgba(88, 166, 255, 0.06);
+ border: 1px solid var(--border, #30363d);
+ border-radius: 6px;
+ padding: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.motif-box-title {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--accent, #58a6ff);
+ display: block;
+ margin-bottom: 0.5rem;
+}
+
+.motif-gen-btn {
+ margin-top: 0.5rem;
+ width: 100%;
+}
+
+.ledger-header-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+}
+
+.ingest-file-btn {
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.75rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: 4px;
+ border: 1px dashed var(--border, #30363d);
+}
+
+.ingest-file-btn:hover {
+ border-color: var(--accent, #58a6ff);
+ color: var(--accent, #58a6ff);
+}
diff --git a/scripts/seed_demo_dispatch.py b/scripts/seed_demo_dispatch.py
new file mode 100644
index 0000000..b607a55
--- /dev/null
+++ b/scripts/seed_demo_dispatch.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""Seed a reference interactive dispatch showcase for Project Odyssey in uCore."""
+import json
+import sys
+from pathlib import Path
+
+# Add backend to sys.path
+backend_dir = Path(__file__).resolve().parent.parent / "backend"
+sys.path.insert(0, str(backend_dir))
+
+from app.core.settings import settings
+from app.services.dispatch_store import dispatch_store
+
+DEMO_BINDER_ID = "project-odyssey"
+DEMO_TITLE = "Project Odyssey: Sovereign Demonstration & Invitation"
+DEMO_LEAD = "A calm, zero-surveillance invitation powered by uDos Prose and GridCore."
+
+DEMO_MARKDOWN = """# Welcome to Project Odyssey
+A universal, calm operating system designed for human agency. No surveillance tracking pixels, no cookie banners, and zero third-party telemetry.
+
+---
+
+## 1. The Tri-Mode Zen Experience
+Experience information without distraction:
+- **Card Mode**: Focus on one thought at a time in a calibrated viewport.
+- **Deck Mode**: 10-foot TV lean-back slide presentation.
+- **Prose Mode**: Distraction-free continuous reading clamped strictly to a 72ch golden measure.
+
+? [ ] I prefer focused card steps
+? [ ] I prefer 10-foot slide decks
+? [ ] I prefer long-form continuous prose
+
+---
+
+## 2. Universal Hardware Revival
+Older Intel machines, CRT monitors, and POS kiosks revive into sovereign nodes via Sonic Screwdriver bootable flash drives.
+
+? [ ] Attending in person at Sovereign Hub
+? [ ] Attending remotely via Teletext Mode 7 portal
+? [ ] Sending blessings from afar
+
+---
+
+## 3. Confirm Your Sovereign Attendance
+Please leave your name and sovereign handle for the binder ledger:
+
+? (Your Name / Handle): [text]
+? (Preferred Hardware): [text]
+
+Thank you for participating in the quiet computing revival.
+"""
+
+
+def main():
+ print(f"[*] Ensuring demo binder '{DEMO_BINDER_ID}' exists...")
+ binder_dir = settings.vault_root / "binders" / DEMO_BINDER_ID
+ binder_dir.mkdir(parents=True, exist_ok=True)
+ (binder_dir / "draft.md").write_text(DEMO_MARKDOWN, encoding="utf-8")
+ manifest = {
+ "id": DEMO_BINDER_ID,
+ "title": "Project Odyssey",
+ "outcome": "Demonstrate universal sovereign publishing, probing, and blitting across devices.",
+ "audience": "Community Architects & Sovereign Users",
+ }
+ (binder_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+
+ print("[*] Staging interactive story dispatch...")
+ dispatch = dispatch_store.create_dispatch(
+ title=DEMO_TITLE,
+ story_markdown=DEMO_MARKDOWN,
+ lead_text=DEMO_LEAD,
+ hero_gif_name="bob-teletext-pulse.gif",
+ expires_in_seconds=7 * 86400, # 7 days
+ burn_after_read=False,
+ mode_default="card",
+ binder_id=DEMO_BINDER_ID,
+ )
+
+ token = dispatch["token"]
+ dispatch_id = dispatch["id"]
+ story_url = f"/p/{token}"
+ email_url = f"/api/dispatch/preview-email/{dispatch_id}"
+
+ print(f"[+] Dispatch created: {dispatch_id}")
+ print(f"[+] Guest Story Token URL: {story_url}")
+ print(f"[+] Prose HTML Email Preview URL: {email_url}")
+
+ # Submit a sample RSVP response to demonstrate live ledger
+ print("[*] Submitting sample RSVP response...")
+ sample_rsvp = {
+ "name": "Ada Lovelace",
+ "email": "ada@analytical-engine.sovereign",
+ "answers": {
+ "I prefer focused card steps": True,
+ "Attending in person at Sovereign Hub": True,
+ "Your Name / Handle": "Ada Lovelace (@ada)",
+ "Preferred Hardware": "Apple Silicon Mac M3 & Recycled POS Kiosk",
+ },
+ "notes": "Delighted to join the calm computing revival!",
+ }
+ res = dispatch_store.submit_rsvp(token, sample_rsvp)
+ print(f"[+] RSVP Recorded: {res['status']}")
+
+ responses = dispatch_store.get_responses(dispatch_id)
+ print(f"[+] Current RSVP Ledger count: {len(responses)} submissions")
+ print("\n[✓] Project Odyssey Interactive Showcase successfully seeded.")
+
+
+if __name__ == "__main__":
+ main()