3 Commits

Author SHA1 Message Date
sb-iam 07509c62bd docs(graph): record the shadcn/ruixen component convention
UI composes from the ruixen registry (npx shadcn add ruixen.com/r/[component])
via @/components/ui/* + design tokens; only the SVG canvas is bespoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:48:05 -07:00
sb-iam a49501b3c4 refactor(ui): compose Team Memory from the shadcn/ruixen primitives
Per the unifying template (npx shadcn add ruixen.com/r/[component]): the chrome
now uses Button + Badge + the app's Tailwind utility patterns (matching StatPill/
App.tsx) inside the page shell, instead of a hand-rolled .pm-* stylesheet. Only
the SVG node-link canvas stays bespoke (3 SVG-only CSS rules). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:46:48 -07:00
sb-iam 7c572fbbcb feat(ui): re-theme Team Memory graph to match the shadcn app
The app is light shadcn; GraphView was hardcoded dark-Bauhaus and clashed.
Re-theme to shadcn tokens (var(--card)/--foreground/--muted-foreground/
--border, var(--primary) for active) so the panel is theme-aware (light now,
follows dark mode if toggled) and native to the command-center. Node-kind
encoding kept (shapes + light-readable hues); labels are sentence-case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:39:04 -07:00
186 changed files with 951 additions and 28612 deletions
-3
View File
@@ -11,7 +11,6 @@ GEMINI_API_KEY=
# canonical deployment secret name used by DigitalOcean and docs.
GEMINI_VISION_MODEL=gemini-2.0-flash
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
GEMINI_TTS_VOICE=Charon
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
# --- GitHub (repo state + sync PR artifacts) ---
@@ -33,8 +32,6 @@ NUDGE_COOLDOWN_MS=180000
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
VITE_BACKEND_URL=http://localhost:8787
# Keep off by default so users hear Gemini audio delivered through LiveKit.
VITE_ENABLE_BROWSER_TTS_FALLBACK=false
# --- Deployment verification ---
# Optional override when the deployed SPA and API use different origins.
-2
View File
@@ -35,8 +35,6 @@ Thumbs.db
coverage/
.cache/
.turbo/
__pycache__/
*.py[cod]
# Ramis
.remember/
-1
View File
@@ -1,4 +1,3 @@
link-workspace-packages=true
prefer-workspace-packages=true
auto-install-peers=true
prefix=/home/ramis/.npm-global
-5
View File
@@ -1,12 +1,7 @@
node_modules
.venv
**/.venv
.pytest_cache
**/.pytest_cache
dist
build
pnpm-lock.yaml
.omc
*.log
.agents
examples/livekit-gemini-hacker-starter
-47
View File
@@ -363,50 +363,3 @@ This repo is actively used by **4 engineers at the same time**. Claude sessions
- **Flag merge risk explicitly** before editing a shared file (e.g., `backend/src/index.ts`, `frontend/src/App.tsx`). Say so, then proceed only if the user confirms.
- **Prefer additive changes** — new files, new functions — over modifying existing ones. This minimizes merge conflicts in a concurrent team.
- **When proposing new files**, verify they match the file names listed in the relevant task in `docs/PLAN.md`. Do not invent new paths.
---
## Production deployment & ops — READ BEFORE TOUCHING THE SERVER
The live system runs on a DigitalOcean droplet at `165.22.129.249`
(public: `https://165-22-129-249.sslip.io/` and `podman.live`). Repo on box:
`/root/podman`. SSH is `root@165.22.129.249` (password auth; ask the team for
the password — it is **not** stored in the repo).
### HARD RULE: manage processes with systemd, never manual `node`
The backend API and the LiveKit agent run as **systemd services** with
`Restart=always`:
- `podman-platform-api.service``node backend/dist/server.js` (cwd `/root/podman`)
- `podman-platform-agent.service``node dist/agent.js` (cwd `/root/podman/backend`,
`Environment=POD_ROOM=demo-pod`, `EnvironmentFile=/root/podman/backend/.env`)
**Do not start the agent or server by hand** (`node ...`, `nohup`, `setsid`,
`tsx`). The agent joins LiveKit with a fixed identity (`podman-hermes`); a
second instance with the same identity **evicts the first from the room**, and
they flap forever — silently dropping every intervention/voice update. systemd
keeps exactly one of each alive. If you launched a manual process, kill it and
let systemd own the singleton.
### Frontend is static, served by Caddy
The frontend is a Vite build served by **Caddy** from `/var/www/podman`
(`/etc/caddy/Caddyfile`). Caddy reverse-proxies `/api/*` and `/health` to
`127.0.0.1:8787`. The LiveKit URL reaches the browser via the backend
`/api/token` response, **not** `VITE_LIVEKIT_URL` (intentionally empty in
`frontend/.env`).
### Deploy procedure (run on the box)
```bash
cd /root/podman && git pull && pnpm -r build
rm -rf /var/www/podman/* && cp -r frontend/dist/* /var/www/podman/
systemctl restart podman-platform-api podman-platform-agent
systemctl status podman-platform-agent --no-pager # verify it came up
```
`pnpm -r build` order matters: `@podman/shared` builds first, or backend/frontend
typecheck fails with "Cannot find module '@podman/shared'". MongoDB is
**mandatory** — both services ping Mongo at boot and exit loudly if it is
unreachable (intentional; fix the `.env` creds, do not re-add silent fallbacks).
+1 -6
View File
@@ -148,9 +148,7 @@ sequenceDiagram
memory.
8. PodMan sends the smallest useful intervention: card first, Hermes message for
coordination, voice only when urgent.
9. Urgent voice uses Gemini TTS published as a LiveKit audio track. The browser
unlocks LiveKit audio from a user gesture and attaches remote audio tracks.
10. The user's response is saved as an outcome, closing the continual-learning
9. The user's response is saved as an outcome, closing the continual-learning
loop.
---
@@ -194,9 +192,6 @@ sequenceDiagram
| [`docs/livekit.md`](docs/livekit.md) | LiveKit notes and room model |
| [`docs/gemini.md`](docs/gemini.md) | Gemini vision and voice notes |
| [`docs/mongodb.md`](docs/mongodb.md) | MongoDB memory design |
| [`docs/continual-learning/`](docs/continual-learning/) | Active team-memory learning loop |
| [`docs/graph-discovery/`](docs/graph-discovery/) | Active MongoDB graph materialization |
| [`docs/agent-learning/`](docs/agent-learning/) | Planned narrow strategy-version layer |
| [`docs/digitalocean.md`](docs/digitalocean.md) | Deployment notes |
| [`docs/demo-setup.md`](docs/demo-setup.md) | Demo laptop and stage checklist |
@@ -1,8 +0,0 @@
LIVEKIT_URL=wss://stackauthnov28-kt4gd6fq.livekit.cloud
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
GOOGLE_API_KEY=
GEMINI_CONVERSATION_MODEL=gemini-3.1-flash-live-preview
GEMINI_CONVERSATION_VOICE=Aoede
PODMAN_BACKEND_URL=http://127.0.0.1:8787
INTERNAL_AGENT_TOKEN=
-22
View File
@@ -1,22 +0,0 @@
# PodMan Live Conversation Agent
Private 1:1 LiveKit Agent worker for PodMan Live Conversation.
Run locally:
```bash
cd agents/podman-live-conversation
uv sync --extra test
cp .env.example .env.local
uv run agent.py dev
```
Required env:
- `LIVEKIT_URL`
- `LIVEKIT_API_KEY`
- `LIVEKIT_API_SECRET`
- `GOOGLE_API_KEY` or `GEMINI_API_KEY`
- `PODMAN_BACKEND_URL`
- `INTERNAL_AGENT_TOKEN`
-439
View File
@@ -1,439 +0,0 @@
import asyncio
import json
import logging
import os
import subprocess
import time
from typing import Any
from urllib import error, request
from dotenv import load_dotenv
from livekit import agents
from livekit.agents import Agent, AgentServer, AgentSession, RunContext, function_tool
from livekit.plugins import google
load_dotenv(".env.local")
if not os.getenv("GOOGLE_API_KEY") and os.getenv("GEMINI_API_KEY"):
os.environ["GOOGLE_API_KEY"] = os.environ["GEMINI_API_KEY"]
logger = logging.getLogger("podman-live-conversation")
AGENT_NAME = "podman-live-conversation"
MODEL = os.getenv("GEMINI_CONVERSATION_MODEL", "gemini-3.1-flash-live-preview")
VOICE = os.getenv("GEMINI_CONVERSATION_VOICE", "Aoede")
BACKEND_URL = os.getenv("PODMAN_BACKEND_URL", "http://127.0.0.1:8787").rstrip("/")
INTERNAL_AGENT_TOKEN = os.getenv("INTERNAL_AGENT_TOKEN", "")
REPO_SLUG = os.getenv("PODMAN_REPO_SLUG", "karti-ai/podman")
def _resolve_repo_root() -> str:
override = os.getenv("PODMAN_REPO_ROOT")
if override:
return override
here = os.path.dirname(os.path.abspath(__file__))
try:
out = subprocess.run(
["git", "-C", here, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
timeout=5,
)
if out.returncode == 0 and out.stdout.strip():
return out.stdout.strip()
except Exception:
pass
return os.path.abspath(os.path.join(here, "..", ".."))
REPO_ROOT = _resolve_repo_root()
INSTRUCTIONS = """You are PodMan, a concise real-time engineering teammate.
You are in a private 1:1 voice conversation with one developer.
Use PodMan tools before making claims about current work, git state, collisions, blockers,
team memory, or recent decisions. Keep spoken answers short. Prefer one useful next step.
If a critical collision event arrives, stop the current turn and state the alert immediately.
To find code, files, symbols, or how something is implemented in the repository, call search_repo.
For git commit history, authorship, recent changes, or which commit introduced something, call
repo_recent_commits or repo_find_commits.
For complex repository, terminal, GitHub, MongoDB, build, install, deploy, or multi-step tasks,
call delegate_to_hermes. Do not run those actions directly. If the user says stop, wait, cancel,
or change of plans while Hermes is running, call abort_active_hermes_job immediately.
Do not reveal raw secrets, API keys, private tokens, or another teammate's private notes."""
def parse_metadata(raw: str | None) -> dict[str, str]:
try:
data = json.loads(raw or "{}")
except json.JSONDecodeError:
return {}
return {str(k): str(v) for k, v in data.items() if v is not None}
def request_json(path: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> Any:
if not INTERNAL_AGENT_TOKEN:
raise RuntimeError("INTERNAL_AGENT_TOKEN is not configured")
data = None if body is None else json.dumps(body).encode("utf-8")
req = request.Request(
f"{BACKEND_URL}{path}",
data=data,
method=method,
headers={
"authorization": f"Bearer {INTERNAL_AGENT_TOKEN}",
"content-type": "application/json",
},
)
try:
with request.urlopen(req, timeout=5) as res:
payload = res.read().decode("utf-8")
return json.loads(payload) if payload else {}
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise RuntimeError(f"PodMan backend returned {exc.code}: {detail}") from exc
async def _run_git(args: list[str], timeout: float = 15.0) -> tuple[int, str, str]:
"""Run a read-only git command inside the repo checkout and capture its output."""
try:
proc = await asyncio.create_subprocess_exec(
"git",
"-C",
REPO_ROOT,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
return 124, "", "git command timed out"
except FileNotFoundError:
return 127, "", "git is not available on this host"
return proc.returncode, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
class PodManLiveAgent(Agent):
def __init__(self, pod_id: str, identity: str, session_id: str, conversation_room: str) -> None:
super().__init__(instructions=INSTRUCTIONS)
self.pod_id = pod_id
self.identity = identity
self.session_id = session_id
self.conversation_room = conversation_room
self.active_hermes_job_id: str | None = None
self.last_spoken_progress_at = 0.0
@function_tool()
async def get_active_pod_context(self, context: RunContext) -> str:
"""Get the current PodMan context for this developer and pod."""
data = await asyncio.to_thread(
request_json,
f"/api/internal/pods/{self.pod_id}/live-context?identity={self.identity}",
)
return json.dumps(data, ensure_ascii=True)[:12000]
@function_tool()
async def record_conversation_note(self, context: RunContext, note: str, kind: str = "summary") -> str:
"""Store a useful decision, outcome, or preference learned during this conversation."""
await asyncio.to_thread(
request_json,
f"/api/internal/pods/{self.pod_id}/live-conversation/{self.session_id}/note",
method="POST",
body={"identity": self.identity, "kind": kind, "note": note},
)
return "Saved to PodMan memory."
@function_tool()
async def get_recent_changes(self, context: RunContext) -> str:
"""Get recent local git and activity signals for this developer."""
data = await asyncio.to_thread(
request_json,
f"/api/internal/pods/{self.pod_id}/live-context?identity={self.identity}",
)
focused = {
"identity": data.get("identity"),
"currentGitState": data.get("currentGitState"),
"memberHistory": data.get("memberHistory"),
"recentCollisions": data.get("recentCollisions"),
}
return json.dumps(focused, ensure_ascii=True)[:8000]
@function_tool()
async def search_team_memory(self, context: RunContext, query: str) -> str:
"""Search current compact team memory for information relevant to a query."""
data = await asyncio.to_thread(
request_json,
f"/api/internal/pods/{self.pod_id}/live-context?identity={self.identity}",
)
haystack = json.dumps(data, ensure_ascii=True)
query_terms = [term.lower() for term in query.split() if len(term) > 2]
if not query_terms:
return haystack[:6000]
snippets = []
lower = haystack.lower()
for term in query_terms[:8]:
idx = lower.find(term)
if idx >= 0:
snippets.append(haystack[max(0, idx - 400) : idx + 1200])
return "\n---\n".join(snippets)[:8000] or haystack[:6000]
@function_tool()
async def search_repo(self, context: RunContext, query: str, max_results: int = 12) -> str:
"""Search the team's code repository (github.com/karti-ai/podman) for code, symbols,
filenames, config, or any text. Use this to find where something is implemented or which
files mention a term before answering questions about the codebase. Searches the live
local checkout of the main branch, so results are always current.
"""
cleaned = " ".join(query.split()).strip()
if not cleaned:
return "Provide a non-empty search query."
limit = max(1, min(int(max_results or 12), 40))
cmd = [
"rg",
"--line-number",
"--no-heading",
"--color",
"never",
"--smart-case",
"--max-count",
"3",
"--max-columns",
"240",
"-g",
"!*.lock",
"-g",
"!pnpm-lock.yaml",
"-g",
"!uv.lock",
"-g",
"!*.min.*",
"--",
cleaned,
REPO_ROOT,
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
except asyncio.TimeoutError:
return "Repo search timed out. Try a more specific query."
except FileNotFoundError:
return "Repo search is unavailable on this host (ripgrep is not installed)."
if proc.returncode not in (0, 1): # rg: 0=match, 1=no match, 2=error
return f"Repo search failed: {stderr.decode('utf-8', 'replace')[:300]}"
prefix = REPO_ROOT + os.sep
lines: list[str] = []
for line in stdout.decode("utf-8", "replace").splitlines():
lines.append(line[len(prefix) :] if line.startswith(prefix) else line)
if len(lines) >= limit:
break
if not lines:
return f'No matches for "{cleaned}" in {REPO_SLUG}.'
body = "\n".join(lines)
return f'Matches for "{cleaned}" in {REPO_SLUG} (path:line):\n{body}'[:7000]
@function_tool()
async def repo_recent_commits(
self, context: RunContext, path: str = "", author: str = "", limit: int = 15
) -> str:
"""Show recent git commit history for github.com/karti-ai/podman: who committed what and when.
Optionally scope to a file or folder (path) or filter by author name/email (author).
Use this for questions about recent changes, authorship, or a specific file's history.
"""
n = max(1, min(int(limit or 15), 50))
args = [
"log",
f"--max-count={n}",
"--no-color",
"--date=short",
"--pretty=format:%h | %an | %ad | %s",
]
if author.strip():
args.append(f"--author={author.strip()}")
if path.strip():
args += ["--", path.strip()]
code, out, err = await _run_git(args)
if code != 0:
return f"Git history lookup failed: {err.strip()[:300] or 'unknown error'}"
out = out.strip()
if not out:
scope = f" for {path.strip()}" if path.strip() else ""
who = f" by {author.strip()}" if author.strip() else ""
return f"No commits found{scope}{who}."
return f"Recent commits in {REPO_SLUG} (hash | author | date | subject):\n{out}"[:7000]
@function_tool()
async def repo_find_commits(
self, context: RunContext, query: str, by: str = "message", limit: int = 15
) -> str:
"""Find commits in github.com/karti-ai/podman. by='message' searches commit messages;
by='code' finds commits that added or removed the query text in the code (pickaxe).
Use by='code' for "which commit introduced X"; use by='message' for "commits about X".
"""
cleaned = " ".join(query.split()).strip()
if not cleaned:
return "Provide a non-empty query."
n = max(1, min(int(limit or 15), 50))
args = [
"log",
f"--max-count={n}",
"--no-color",
"--date=short",
"--pretty=format:%h | %an | %ad | %s",
]
mode = by.strip().lower()
if mode == "code":
args.append(f"-S{cleaned}")
else:
mode = "message"
args += ["-i", f"--grep={cleaned}"]
code, out, err = await _run_git(args)
if code != 0:
return f"Commit search failed: {err.strip()[:300] or 'unknown error'}"
out = out.strip()
if not out:
return f'No commits found matching "{cleaned}" (by {mode}).'
return (
f'Commits in {REPO_SLUG} matching "{cleaned}" (by {mode}) — hash | author | date | subject:\n{out}'[
:7000
]
)
@function_tool()
async def delegate_to_hermes(
self,
context: RunContext,
prompt: str,
context_scope: str = "current_repo",
target_repository: str = "",
risk_level: str = "read_only",
requires_confirmation: bool = False,
success_criteria: list[str] | None = None,
) -> str:
"""Hand off a complex engineering task to Hermes, PodMan's autonomous backend execution engine.
Use this for filesystem, terminal, GitHub, MongoDB, build, install, deploy, test,
or multi-step repository tasks. Do not use this for simple conversational answers.
"""
body = {
"prompt": prompt,
"contextScope": context_scope,
"targetRepository": target_repository or "karti-ai/podman",
"riskLevel": risk_level,
"requiresConfirmation": requires_confirmation,
"successCriteria": success_criteria or ["Hermes completes the requested inspection."],
"podId": self.pod_id,
"identity": self.identity,
"sessionId": self.session_id,
"conversationRoom": self.conversation_room,
}
job = await asyncio.to_thread(request_json, "/api/internal/hermes/jobs", method="POST", body=body)
self.active_hermes_job_id = str(job["id"])
return json.dumps(
{
"status": "accepted",
"job_id": self.active_hermes_job_id,
"spoken_ack": "Hermes is starting that now. I will keep you posted.",
},
ensure_ascii=True,
)
@function_tool()
async def abort_active_hermes_job(self, context: RunContext, reason: str = "User changed plans") -> str:
"""Abort the currently running Hermes job immediately."""
if not self.active_hermes_job_id:
return "No active Hermes job is running."
job = await asyncio.to_thread(
request_json,
f"/api/internal/hermes/jobs/{self.active_hermes_job_id}/abort",
method="POST",
body={"reason": reason},
)
return json.dumps(
{
"status": job.get("status", "aborting"),
"job_id": self.active_hermes_job_id,
"spoken_ack": "Stopped. Hermes is aborting the job before making further changes.",
},
ensure_ascii=True,
)
def should_speak_progress(self, event: dict[str, Any]) -> bool:
event_type = event.get("type")
if event_type in {"completed", "failed", "aborted", "needs_confirmation"}:
return True
if event_type not in {"heartbeat", "step_started", "step_completed"}:
return False
monotonic = time.monotonic()
if monotonic - self.last_spoken_progress_at < 8:
return False
self.last_spoken_progress_at = monotonic
return True
server = AgentServer()
@server.rtc_session(agent_name=AGENT_NAME)
async def entrypoint(ctx: agents.JobContext):
metadata = parse_metadata(getattr(ctx.job, "metadata", None))
pod_id = metadata.get("podId", "demo-pod")
identity = metadata.get("identity", "developer")
session_id = metadata.get("sessionId", "unknown")
session = AgentSession(
llm=google.realtime.RealtimeModel(
model=MODEL,
voice=VOICE,
),
)
agent = PodManLiveAgent(
pod_id=pod_id,
identity=identity,
session_id=session_id,
conversation_room=ctx.room.name,
)
def on_data_received(*args: Any):
payload = args[0] if args else b""
if isinstance(payload, str):
raw = payload
else:
raw = bytes(payload).decode("utf-8", "replace")
try:
msg = json.loads(raw)
except json.JSONDecodeError:
return
msg_type = msg.get("type")
if msg_type == "HERMES_JOB_EVENT":
event = msg.get("event") or {}
summary = str(event.get("message") or "").strip()
if str(event.get("type")) in {"completed", "failed", "aborted"}:
agent.active_hermes_job_id = None
elif msg_type == "LIVE_CONVERSATION_EVENT":
event = msg.get("event") or {}
summary = str(event.get("summary") or "").strip()
else:
return
if not summary:
return
async def interrupt_and_say() -> None:
try:
if msg_type == "LIVE_CONVERSATION_EVENT":
await session.interrupt(force=True)
except Exception as exc:
logger.warning("interrupt failed: %s", exc)
if msg_type == "LIVE_CONVERSATION_EVENT" or agent.should_speak_progress(event):
await session.say(summary, allow_interruptions=True, add_to_chat_ctx=True)
asyncio.create_task(interrupt_and_say())
ctx.room.on("data_received", on_data_received)
await session.start(room=ctx.room, agent=agent)
await ctx.connect()
if __name__ == "__main__":
agents.cli.run_app(server)
@@ -1,19 +0,0 @@
[project]
name = "podman-live-conversation"
version = "0.1.0"
description = "PodMan private LiveKit/Gemini live conversation agent"
requires-python = ">=3.10,<3.14"
dependencies = [
"livekit-agents[google]>=1.6.4,<1.7",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
test = ["pytest>=8.0.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]
@@ -1,26 +0,0 @@
from agent import PodManLiveAgent, parse_metadata
def test_parse_metadata_accepts_valid_json():
assert parse_metadata('{"podId":"demo-pod","identity":"yahya","sessionId":"s1"}') == {
"podId": "demo-pod",
"identity": "yahya",
"sessionId": "s1",
}
def test_parse_metadata_handles_bad_json():
assert parse_metadata("not json") == {}
def test_hermes_terminal_events_always_speak():
agent = PodManLiveAgent("demo-pod", "yahya", "s1", "room")
assert agent.should_speak_progress({"type": "completed"}) is True
assert agent.should_speak_progress({"type": "failed"}) is True
assert agent.should_speak_progress({"type": "aborted"}) is True
def test_hermes_progress_is_throttled():
agent = PodManLiveAgent("demo-pod", "yahya", "s1", "room")
assert agent.should_speak_progress({"type": "heartbeat"}) is True
assert agent.should_speak_progress({"type": "step_started"}) is False
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,11 +1,6 @@
import type { Room } from '@livekit/rtc-node';
import { Room as LiveKitRoom } from '@livekit/rtc-node';
import { AccessToken } from 'livekit-server-sdk';
import type { Collision, DataMessage, HermesMessage, Intervention } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared';
import { env } from '../env.js';
import { speak } from '../voice/live.js';
import { notifyCriticalLiveConversations } from '../live-conversation/sessions.js';
const encoder = new TextEncoder();
@@ -42,55 +37,3 @@ export async function publishHermesMessage(
topic: DATA_TOPIC,
});
}
export async function publishHermesIntervention(
room: Room,
collision: Collision,
intervention: Intervention,
voiceLine?: string,
): Promise<void> {
const data: DataMessage = { type: 'COLLISION', collision, intervention };
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
reliable: true,
topic: DATA_TOPIC,
});
await publishHermesMessage(room, collision, intervention);
void notifyCriticalLiveConversations(collision, intervention, voiceLine).catch((err) =>
console.warn(`[live-conversation] critical notify failed: ${(err as Error).message}`),
);
if (voiceLine) await speak(room, voiceLine, { priority: 'critical' });
}
async function hermesToken(roomName: string): Promise<string> {
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
identity: `podman-hermes-${Date.now()}`,
name: 'PodMan Hermes',
ttl: '10m',
});
at.addGrant({
roomJoin: true,
room: roomName,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
return at.toJwt();
}
export async function notifyHermesInterventionInRoom(
roomName: string,
collision: Collision,
intervention: Intervention,
voiceLine?: string,
): Promise<void> {
const room = new LiveKitRoom();
try {
await room.connect(env.LIVEKIT_URL, await hermesToken(roomName), {
autoSubscribe: false,
dynacast: false,
});
await publishHermesIntervention(room, collision, intervention, voiceLine);
} finally {
await room.disconnect().catch(() => {});
}
}
-174
View File
@@ -1,174 +0,0 @@
import type { EngineerContext, MemberWorkHistory, MemberWorkHistoryFile } from '@podman/shared';
import { getDb } from '../memory/db.js';
import { parseGitStatusPath } from '../graph/live.js';
interface EngineerStateDoc {
_id: string;
podId: string;
name: string;
changedFiles?: string[];
branch?: string | null;
recentCommit?: string | null;
gitUpdatedAt?: Date | string;
updatedAt?: Date | string;
}
interface FileAccumulator {
file: string;
observations: number;
gitChanges: number;
firstSeenAt: number;
lastSeenAt: number;
confidenceSum: number;
confidenceCount: number;
activities: Set<string>;
current: boolean;
}
function toIso(ms: number): string {
return new Date(ms).toISOString();
}
function dateMs(value: string | Date | undefined): number {
if (value instanceof Date) return value.getTime();
if (value) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
}
function clean(value: string | undefined): string {
return value?.trim() ?? '';
}
function sameMember(a: string | undefined, b: string): boolean {
return clean(a).toLowerCase() === b.trim().toLowerCase();
}
function addFile(files: Map<string, FileAccumulator>, file: string, at: number): FileAccumulator {
const existing = files.get(file);
if (existing) {
if (at > 0) {
existing.firstSeenAt = Math.min(existing.firstSeenAt || at, at);
existing.lastSeenAt = Math.max(existing.lastSeenAt, at);
}
return existing;
}
const acc: FileAccumulator = {
file,
observations: 0,
gitChanges: 0,
firstSeenAt: at,
lastSeenAt: at,
confidenceSum: 0,
confidenceCount: 0,
activities: new Set<string>(),
current: false,
};
files.set(file, acc);
return acc;
}
export async function getMemberWorkHistory(
podId: string,
member: string,
options: { hours?: number; limit?: number } = {},
): Promise<MemberWorkHistory> {
const db = await getDb();
const windowHours = Math.min(Math.max(options.hours ?? 24, 1), 168);
const limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
const [observations, gitState] = await Promise.all([
db
.collection<EngineerContext>('observations')
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
.sort({ observedAt: -1 })
.limit(500)
.toArray(),
db.collection<EngineerStateDoc>('engineer_states').findOne({
podId,
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
}),
]);
const files = new Map<string, FileAccumulator>();
const timeline: MemberWorkHistory['timeline'] = [];
const memberObservations = observations.filter((doc) => sameMember(doc.engineerId, member));
for (const doc of memberObservations) {
const file = clean(doc.currentFile);
if (!file) continue;
const at = dateMs(doc.observedAt);
const acc = addFile(files, file, at);
acc.observations += 1;
acc.current ||= timeline.length === 0;
if (typeof doc.confidence === 'number') {
acc.confidenceSum += doc.confidence;
acc.confidenceCount += 1;
}
const activity = clean(doc.activity);
if (activity) acc.activities.add(activity);
timeline.push({
id: `vision:${doc.engineerId}:${doc.observedAt}:${file}`,
at: doc.observedAt,
source: 'vision',
file,
title: activity || `Worked in ${file}`,
detail: clean(doc.currentSymbol) ? `symbol ${doc.currentSymbol}` : undefined,
confidence: doc.confidence,
});
}
const gitAt = dateMs(gitState?.gitUpdatedAt ?? gitState?.updatedAt);
for (const raw of gitState?.changedFiles ?? []) {
const file = parseGitStatusPath(raw);
if (!file) continue;
const acc = addFile(files, file, gitAt || Date.now());
acc.gitChanges += 1;
acc.current = true;
timeline.push({
id: `git:${gitState?._id}:${gitAt}:${file}`,
at: toIso(gitAt || Date.now()),
source: 'git',
file,
title: `Local change in ${file}`,
detail: [gitState?.branch ? `branch ${gitState.branch}` : undefined, gitState?.recentCommit]
.filter(Boolean)
.join(' · '),
});
}
const fileRows: MemberWorkHistoryFile[] = [...files.values()]
.sort((a, b) => b.lastSeenAt - a.lastSeenAt || b.observations - a.observations)
.slice(0, 12)
.map((file) => ({
file: file.file,
observations: file.observations,
gitChanges: file.gitChanges,
firstSeenAt: toIso(file.firstSeenAt || file.lastSeenAt || Date.now()),
lastSeenAt: toIso(file.lastSeenAt || file.firstSeenAt || Date.now()),
confidenceAvg: file.confidenceCount
? Math.round((file.confidenceSum / file.confidenceCount) * 100) / 100
: null,
activities: [...file.activities].slice(0, 3),
current: file.current,
}));
timeline.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
return {
podId,
member,
generatedAt: new Date().toISOString(),
windowHours,
totals: {
files: fileRows.length,
observations: memberObservations.length,
gitChanges: gitState?.changedFiles?.length ?? 0,
},
files: fileRows,
timeline: timeline.slice(0, limit),
};
}
-197
View File
@@ -1,197 +0,0 @@
import type {
Collision,
EngineerContext,
Intervention,
InterventionOutcome,
PodActivityEvent,
} from '@podman/shared';
import { getDb } from '../memory/db.js';
interface EngineerStateDoc {
_id: string;
podId: string;
name: string;
changedFiles?: string[];
diffStat?: string | null;
recentCommit?: string | null;
branch?: string | null;
gitUpdatedAt?: Date | string;
updatedAt?: Date | string;
}
function toIso(value: Date | string | undefined): string {
if (value instanceof Date) return value.toISOString();
if (value) return new Date(value).toISOString();
return new Date(0).toISOString();
}
function clean(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
function shortFiles(files: string[] | undefined): string {
if (!files?.length) return 'clean working tree';
const sample = files.slice(0, 3).join(', ');
return files.length > 3 ? `${sample}, +${files.length - 3} more` : sample;
}
function observationEvent(doc: EngineerContext): PodActivityEvent {
const file = clean(doc.currentFile);
const symbol = clean(doc.currentSymbol);
return {
id: `observation:${doc.engineerId}:${doc.observedAt}`,
podId: doc.podId,
kind: 'observation',
source: 'vision',
actor: doc.engineerId,
actors: [doc.engineerId],
file,
imageUrl: doc.screenshotDataUrl,
title: file ? `Working in ${file}` : 'Screen context updated',
detail: [
symbol ? `symbol ${symbol}` : undefined,
clean(doc.activity),
doc.hasUnpushedChanges ? 'unpushed changes visible' : undefined,
`confidence ${Math.round(doc.confidence * 100)}%`,
]
.filter(Boolean)
.join(' · '),
severity: doc.hasUnpushedChanges ? 'warn' : 'info',
at: doc.observedAt,
};
}
function gitEvent(doc: EngineerStateDoc): PodActivityEvent {
const changedFiles = doc.changedFiles ?? [];
return {
id: `git:${doc._id}:${toIso(doc.gitUpdatedAt ?? doc.updatedAt)}`,
podId: doc.podId,
kind: 'git',
source: 'git',
actor: doc.name,
actors: [doc.name],
title: changedFiles.length ? `${changedFiles.length} local file changes` : 'Git state is clean',
detail: [
doc.branch ? `branch ${doc.branch}` : undefined,
shortFiles(changedFiles),
doc.recentCommit ? `head ${doc.recentCommit}` : undefined,
]
.filter(Boolean)
.join(' · '),
severity: changedFiles.length ? 'warn' : 'info',
at: toIso(doc.gitUpdatedAt ?? doc.updatedAt),
};
}
function collisionEvent(doc: Collision): PodActivityEvent {
return {
id: `collision:${doc.id}`,
podId: doc.podId,
kind: 'collision',
source: 'memory',
actor: doc.engineers[0],
actors: doc.engineers,
file: doc.file,
title: `${doc.engineers.join(' + ')} conflict on ${doc.file}`,
detail: [
doc.symbol ? `symbol ${doc.symbol}` : undefined,
doc.githubState?.unpushed ? 'unpushed local changes involved' : undefined,
doc.githubState?.openPrs?.length
? `open PRs ${doc.githubState.openPrs.join(', ')}`
: undefined,
]
.filter(Boolean)
.join(' · '),
severity: doc.severity,
at: doc.detectedAt,
};
}
function interventionEvent(doc: Intervention): PodActivityEvent {
return {
id: `intervention:${doc.id}`,
podId: doc.podId,
kind: 'intervention',
source: 'hermes',
title: `Hermes ${doc.status} ${doc.suggestedAction.kind.replaceAll('_', ' ')}`,
detail: doc.message,
severity: doc.status === 'accepted' ? 'success' : doc.status === 'dismissed' ? 'info' : 'warn',
at: doc.createdAt,
};
}
function outcomeEvent(doc: InterventionOutcome): PodActivityEvent {
return {
id: `outcome:${doc.interventionId}:${doc.recordedAt}`,
podId: doc.podId,
kind: 'outcome',
source: 'policy',
title: doc.accepted ? 'Intervention accepted' : 'Intervention dismissed',
detail: doc.wasRealCollision ? 'confirmed real collision' : 'marked as false positive',
severity: doc.accepted ? 'success' : 'info',
at: doc.recordedAt,
};
}
export async function listPodActivity(podId: string, limit = 80): Promise<PodActivityEvent[]> {
const db = await getDb();
const [observations, gitStates, collisions, interventions, outcomes] = await Promise.all([
db
.collection<EngineerContext>('observations')
.find({ podId }, { projection: { _id: 0 } })
.sort({ observedAt: -1 })
.limit(limit)
.toArray(),
db
.collection<EngineerStateDoc>('engineer_states')
.find(
{ podId },
{
projection: {
_id: 1,
podId: 1,
name: 1,
changedFiles: 1,
diffStat: 1,
recentCommit: 1,
branch: 1,
gitUpdatedAt: 1,
updatedAt: 1,
},
},
)
.sort({ gitUpdatedAt: -1 })
.limit(limit)
.toArray(),
db
.collection<Collision>('collisions')
.find({ podId }, { projection: { _id: 0 } })
.sort({ detectedAt: -1 })
.limit(limit)
.toArray(),
db
.collection<Intervention>('interventions')
.find({ podId }, { projection: { _id: 0 } })
.sort({ createdAt: -1 })
.limit(limit)
.toArray(),
db
.collection<InterventionOutcome>('outcomes')
.find({ podId }, { projection: { _id: 0 } })
.sort({ recordedAt: -1 })
.limit(limit)
.toArray(),
]);
return [
...observations.map(observationEvent),
...gitStates.map(gitEvent),
...collisions.map(collisionEvent),
...interventions.map(interventionEvent),
...outcomes.map(outcomeEvent),
]
.filter((event) => event.at !== new Date(0).toISOString())
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at))
.slice(0, limit);
}
+14 -122
View File
@@ -6,7 +6,6 @@ import {
VideoStream,
VideoBufferType,
dispose,
type VideoFrameEvent,
type RemoteTrack,
type RemoteTrackPublication,
type RemoteParticipant,
@@ -15,13 +14,10 @@ import sharp from 'sharp';
import { AccessToken } from 'livekit-server-sdk';
import { env } from './env.js';
import { PodMan } from './agent/podman.js';
import { initMemory } from './memory/db.js';
const POD_ROOM = process.env.POD_ROOM ?? 'demo-pod';
const HERMES_IDENTITY = 'podman-hermes';
const SAMPLE_INTERVAL_MS = 1000; // ~1 fps to the vision model
const SCREEN_THUMBNAIL_WIDTH = 360;
const SHUTDOWN_GRACE_MS = 5000;
async function agentToken(room: string): Promise<string> {
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
@@ -33,25 +29,7 @@ async function agentToken(room: string): Promise<string> {
return at.toJwt();
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function main() {
// MongoDB is mandatory. Verify the connection before joining the room so bad
// creds / unreachable Atlas fail loudly at boot, not silently mid-demo.
await initMemory();
const room = new Room();
const podman = new PodMan(room, POD_ROOM);
await room.connect(env.LIVEKIT_URL, await agentToken(POD_ROOM), {
@@ -62,57 +40,6 @@ async function main() {
console.log(`[agent] ${HERMES_IDENTITY} joined room ${POD_ROOM}`);
const lastSent = new Map<string, number>();
const inFlight = new Set<string>();
const activeStreams = new Map<string, ReadableStreamDefaultReader<VideoFrameEvent>>();
const streamKey = (
track: RemoteTrack,
pub: RemoteTrackPublication,
participant: RemoteParticipant,
) => `${participant.identity}:${pub.sid ?? track.sid ?? 'screen'}`;
const stopStream = async (key: string) => {
const reader = activeStreams.get(key);
if (!reader) return;
activeStreams.delete(key);
await reader.cancel().catch(() => {});
try {
reader.releaseLock();
} catch {
/* already released */
}
};
const processFrame = async (engineerId: string, event: VideoFrameEvent) => {
const now = Date.now();
if (now - (lastSent.get(engineerId) ?? 0) < SAMPLE_INTERVAL_MS) return;
if (inFlight.has(engineerId)) return;
lastSent.set(engineerId, now);
inFlight.add(engineerId);
try {
const rgba = event.frame.convert(VideoBufferType.RGBA);
const pixels = Buffer.from(rgba.data);
const raw = { width: rgba.width, height: rgba.height, channels: 4 } as const;
const [jpeg, thumbnail] = await Promise.all([
sharp(pixels, { raw })
.resize({ width: 1280, withoutEnlargement: true })
.jpeg({ quality: 70 })
.toBuffer(),
sharp(pixels, { raw })
.resize({ width: SCREEN_THUMBNAIL_WIDTH, withoutEnlargement: true })
.jpeg({ quality: 42 })
.toBuffer(),
]);
await podman.onScreenFrame(
engineerId,
jpeg,
`data:image/jpeg;base64,${thumbnail.toString('base64')}`,
);
} finally {
inFlight.delete(engineerId);
}
};
room.on(
RoomEvent.TrackSubscribed,
@@ -120,65 +47,30 @@ async function main() {
if (track.kind !== TrackKind.KIND_VIDEO || pub.source !== TrackSource.SOURCE_SCREENSHARE)
return;
const id = participant.identity;
const key = streamKey(track, pub, participant);
const stream = new VideoStream(track);
void stopStream(key);
const reader = stream.getReader();
activeStreams.set(key, reader);
void (async () => {
try {
while (activeStreams.get(key) === reader) {
const { done, value } = await reader.read();
if (done) break;
void processFrame(id, value).catch((err) =>
console.error(`[agent] frame sample failed for ${id}: ${(err as Error).message}`),
);
}
} catch (err) {
console.error(`[agent] screen stream failed for ${id}: ${(err as Error).message}`);
} finally {
if (activeStreams.get(key) === reader) activeStreams.delete(key);
await reader.cancel().catch(() => {});
try {
reader.releaseLock();
} catch {
/* already released */
}
for await (const event of stream) {
const now = Date.now();
if (now - (lastSent.get(id) ?? 0) < SAMPLE_INTERVAL_MS) continue; // THROTTLE
lastSent.set(id, now);
const rgba = event.frame.convert(VideoBufferType.RGBA);
const jpeg = await sharp(Buffer.from(rgba.data), {
raw: { width: rgba.width, height: rgba.height, channels: 4 },
})
.resize({ width: 1280, withoutEnlargement: true })
.jpeg({ quality: 70 })
.toBuffer();
await podman.onScreenFrame(id, jpeg);
}
})();
},
);
room.on(
RoomEvent.TrackUnsubscribed,
(track: RemoteTrack, pub: RemoteTrackPublication, participant: RemoteParticipant) => {
void stopStream(streamKey(track, pub, participant));
},
);
room.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
for (const key of [...activeStreams.keys()]) {
if (key.startsWith(`${participant.identity}:`)) void stopStream(key);
}
});
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
await withTimeout(
Promise.all([...activeStreams.keys()].map(stopStream)).then(() => room.disconnect()),
SHUTDOWN_GRACE_MS,
);
await withTimeout(dispose(), SHUTDOWN_GRACE_MS);
await room.disconnect();
await dispose();
process.exit(0);
};
room.on(RoomEvent.Disconnected, () => {
if (!shuttingDown) {
console.error('[agent] LiveKit disconnected; exiting so systemd restarts the worker');
process.exit(1);
}
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
+23 -76
View File
@@ -1,29 +1,19 @@
import { RoomEvent, type Room } from '@livekit/rtc-node';
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared';
import { analyzeFrame } from '../vision/gemini.js';
import { detectCollisions } from '../collision/detector.js';
import { getGithubState } from '../github/client.js';
import {
recordObservation,
recordCollision,
recordIntervention,
updateInterventionStatus,
} from '../memory/store.js';
import { recordObservation, recordCollision, recordIntervention } from '../memory/store.js';
import { getGitStates } from '../memory/db.js';
import { recallSimilar } from '../memory/vectors.js';
import { shouldIntervene, preferredAction } from '../memory/policy.js';
import { publishHermesIntervention } from '../action/hermes.js';
import { speak } from '../voice/live.js';
import { publishHermesMessage } from '../action/hermes.js';
export class PodMan {
private contexts = new Map<string, EngineerContext>();
/**
* Conflicts we have already voiced and that are still unresolved, keyed by
* file (see conflictKey). Edge-triggered alerting: speak once when a conflict
* appears, stay quiet while it persists. A conflict is re-armed (deleted
* here) by onScreenFrame as soon as a detection cycle no longer sees it, so a
* resolved-then-recurring conflict alerts again.
*/
private activeConflicts = new Set<string>();
private encoder = new TextEncoder();
constructor(
private room: Room,
@@ -40,20 +30,14 @@ export class PodMan {
if (c)
c.hasUnpushedChanges = msg.report.unpushedCount > 0 || msg.report.dirtyFiles.length > 0;
}
if (msg.type === 'ACK') {
void updateInterventionStatus(msg.interventionId, msg.status).catch((err) =>
console.error(`[memory] intervention ack failed: ${(err as Error).message}`),
);
}
} catch {
/* ignore malformed */
}
});
}
async onScreenFrame(engineerId: string, jpeg: Buffer, screenshotDataUrl?: string): Promise<void> {
async onScreenFrame(engineerId: string, jpeg: Buffer): Promise<void> {
const ctx = await analyzeFrame(engineerId, this.podId, jpeg);
if (screenshotDataUrl) ctx.screenshotDataUrl = screenshotDataUrl;
this.contexts.set(engineerId, ctx);
await recordObservation(ctx);
@@ -66,64 +50,26 @@ export class PodMan {
}
const github = await getGithubState(); // cached
const collisions = detectCollisions([...this.contexts.values()], github, gitStates);
// Re-arm: any conflict we previously voiced that is no longer present has
// resolved, so allow it to alert again if it recurs.
const current = new Set(collisions.map((c) => this.conflictKey(c)));
for (const key of this.activeConflicts) {
if (!current.has(key)) this.activeConflicts.delete(key);
}
const collisions = detectCollisions([...this.contexts.values()], github);
for (const collision of collisions) await this.handle(collision);
}
/**
* Stable identity for a conflict, independent of the Date.now() baked into
* collision.id. Mirrors comparableFile() in memory/store.ts so keys line up:
* strip any git-status prefix ("M ", "?? ") and reduce to a lowercased
* basename.
*/
private conflictKey(collision: Collision): string {
return (
(collision.file ?? '')
.trim()
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
.split(/[\\/]/)
.pop()
?.toLowerCase() ?? ''
);
}
private async handle(collision: Collision): Promise<void> {
const key = this.conflictKey(collision);
if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
// Only escalate to critical (which triggers the spoken alert) when the
// recalled prior was an *accepted real* collision. Blanket-escalating every
// recall — including dismissed/false-positive priors — masked the learned
// routing in preferredAction and made recalled noise scream "CRITICAL".
// (RSI Step 2 — continual-learning/policy.md:62-63, plan.md:66)
if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) {
collision.severity = 'critical';
}
if (prior) collision.severity = 'critical';
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
await recordCollision(collision);
const action = preferredAction(collision, prior);
const names = collision.engineers.join(' + ');
const shortFile = collision.file.split('/').pop() ?? collision.file;
// Terse, demo-centered alert — short and direct, not chatty AI prose.
const names = collision.engineers.join(' and ');
const message =
`Conflict: ${names} both on ${shortFile}` +
(collision.githubState?.unpushed ? ' (unpushed).' : '.') +
(prior ? ' Seen before.' : '');
// Spoken line stays short, but uses natural phrasing for Gemini TTS prosody.
const voiceLine = `${names} are both editing ${shortFile}. Please sync before pushing.`;
`${names} are both editing ${collision.file}` +
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
(prior?.priorOutcome?.accepted
? ` I've seen this conflict pattern before; last time the team accepted the ${prior.priorIntervention?.suggestedAction.kind.replaceAll('_', ' ') ?? 'suggested'} action.`
: prior
? ` I've seen this conflict pattern before.`
: '');
const intervention: Intervention = {
id: `int_${Date.now()}`,
@@ -144,11 +90,12 @@ export class PodMan {
};
await recordIntervention(intervention);
await publishHermesIntervention(
this.room,
collision,
intervention,
collision.severity === 'critical' ? voiceLine : undefined,
);
const data: DataMessage = { type: 'COLLISION', collision, intervention };
await this.room.localParticipant?.publishData(this.encoder.encode(JSON.stringify(data)), {
reliable: true,
topic: DATA_TOPIC,
});
await publishHermesMessage(this.room, collision, intervention);
if (collision.severity === 'critical') await speak(this.room, message);
}
}
+15 -65
View File
@@ -1,84 +1,34 @@
import type { EngineerContext, Collision, GithubStateSnapshot } from '@podman/shared';
import type { GitState } from '../memory/db.js';
/**
* Collapse any path-ish string to a comparable file key.
*
* Vision reads paths at inconsistent depths ("agent.ts" vs
* "backend/src/agent.ts"), and git status lines carry a status prefix
* ("M README.md", "?? test.txt"). Reduce both to a lowercased basename so the
* same file matches regardless of how it was observed. Basename matching can
* over-group two same-named files in different dirs, but for live coordination
* that bias toward firing is the right trade.
*/
function fileKey(raw?: string): string | undefined {
if (!raw) return undefined;
const stripped = raw.trim().replace(/^(\?\?|[MADRCU!]{1,2})\s+/, ''); // drop git status prefix
const base = stripped.split(/[\\/]/).pop()?.trim();
if (!base) return undefined;
return base.toLowerCase();
function normalize(path?: string): string | undefined {
if (!path) return undefined;
return path.replace(/^\.?\/?(src\/)?/, 'src/').toLowerCase();
}
interface Touch {
engineerId: string;
unpushed: boolean;
display: string; // original path/name to show in the card
}
/**
* Detect same-file collisions from two fused signals:
* 1. Vision — what each engineer currently has on screen.
* 2. Git ground truth — each engineer's dirty/unpushed `changedFiles`.
*
* Git overlap is deterministic and does not require both engineers to have the
* file on screen at the same instant, so it is the reliable demo path.
*/
export function detectCollisions(
contexts: EngineerContext[],
github: GithubStateSnapshot,
gitStates?: Map<string, GitState>,
): Collision[] {
const byFile = new Map<string, Touch[]>();
const add = (key: string | undefined, touch: Touch): void => {
if (!key) return;
(byFile.get(key) ?? byFile.set(key, []).get(key)!).push(touch);
};
// Signal 1: live vision context.
const byFile = new Map<string, EngineerContext[]>();
for (const c of contexts) {
add(fileKey(c.currentFile), {
engineerId: c.engineerId,
unpushed: c.hasUnpushedChanges === true,
display: c.currentFile ?? '',
});
}
// Signal 2: git ground truth (a dirty changed file is unpushed by definition).
if (gitStates) {
for (const [engineerId, git] of gitStates) {
for (const changed of git.changedFiles) {
add(fileKey(changed), { engineerId, unpushed: true, display: changed });
}
}
const f = normalize(c.currentFile);
if (!f) continue;
(byFile.get(f) ?? byFile.set(f, []).get(f)!).push(c);
}
const out: Collision[] = [];
for (const [, touches] of byFile) {
const engineers = [...new Set(touches.map((t) => t.engineerId))];
if (engineers.length < 2) continue; // need two distinct people on one file
for (const [file, group] of byFile) {
const engineers = [...new Set(group.map((g) => g.engineerId))];
if (engineers.length < 2) continue;
const anyUnpushed = touches.some((t) => t.unpushed) || github.unpushed === true;
const anyUnpushed = group.some((g) => g.hasUnpushedChanges) || github.unpushed === true;
if (!anyUnpushed) continue; // the crux GitHub alone cannot answer
// Show the most specific path we saw for this file.
const display =
touches.map((t) => t.display).sort((a, b) => b.length - a.length)[0] ?? touches[0]!.display;
out.push({
id: `col_${fileKey(display)}_${Date.now()}`,
podId: contexts[0]?.podId ?? 'demo-pod',
file: display,
symbol: contexts.find((c) => c.currentSymbol)?.currentSymbol,
id: `col_${file}_${Date.now()}`,
podId: group[0]!.podId,
file,
symbol: group.find((g) => g.currentSymbol)?.currentSymbol,
engineers,
severity: 'warn',
githubState: { ...github, unpushed: anyUnpushed },
-5
View File
@@ -21,14 +21,10 @@ export const env = {
LIVEKIT_URL: req('LIVEKIT_URL'),
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
LIVEKIT_AGENT_NAME: opt('LIVEKIT_AGENT_NAME'),
LIVEKIT_CONVERSATION_AGENT_NAME: opt('LIVEKIT_CONVERSATION_AGENT_NAME', 'podman-live-conversation'),
// Gemini
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-tts-preview'),
GEMINI_CONVERSATION_MODEL: opt('GEMINI_CONVERSATION_MODEL', 'gemini-3.1-flash-live-preview'),
GEMINI_TTS_VOICE: opt('GEMINI_TTS_VOICE', 'Charon'),
GEMINI_EMBEDDING_MODEL: opt('GEMINI_EMBEDDING_MODEL', 'gemini-embedding-001'),
// GitHub
GITHUB_TOKEN: req('GITHUB_TOKEN'),
@@ -40,7 +36,6 @@ export const env = {
// Server
PORT: Number(opt('PORT', '8787')),
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
} as const;
export function repoParts(): { owner: string; repo: string } {
+7 -76
View File
@@ -11,90 +11,21 @@ export function createDemoPodGraph(podId: string): PodGraph {
return {
podId,
generatedAt: new Date().toISOString(),
// Kept consistent with the graph below (3 owner engineers, 1 collision file,
// 1 of 1 interventions accepted) so the numbers never contradict the picture.
metrics: [
{
label: 'Learned owners',
value: '3',
detail: 'Distinct owners retained from accepted interventions.',
value: '5',
detail: 'Ownership edges retained from accepted interventions.',
},
{
label: 'Open risk paths',
value: '1',
detail: 'File with two or more converging editors.',
value: '2',
detail: 'auth.ts and the memory API have converging editors.',
},
{
label: 'Accept rate',
value: '100%',
detail: 'Interventions accepted vs total this session.',
},
],
loop: {
activeStep: 'adapt',
steps: [
{
key: 'observe',
label: 'Observe',
value: '2',
detail: 'Screen context and local git state show two active editors.',
status: 'complete',
},
{
key: 'store',
label: 'Store',
value: '7',
detail: 'Observations, collisions, interventions, and outcomes are in MongoDB.',
status: 'complete',
},
{
key: 'predict',
label: 'Predict',
value: '2',
detail: 'Same-file risk paths are detected before push.',
status: 'complete',
},
{
key: 'outcome',
label: 'Outcome',
value: '6',
detail: 'Accepted and dismissed outcomes supervise future routing.',
status: 'complete',
},
{
key: 'adapt',
label: 'Adapt',
value: '1',
detail: 'Accepted real collision created a learned_from edge.',
status: 'complete',
},
],
},
activity: [
{
id: 'demo-learned-auth',
at: new Date().toISOString(),
kind: 'learned',
title: 'Learned Karti owns auth.ts',
detail: 'Accepted sync PR outcome created a durable learned_from path.',
nodeId: 'engineer:karti',
edgeId: 'e7',
},
{
id: 'demo-intervention-sync-pr',
at: new Date().toISOString(),
kind: 'intervention',
title: 'Intervention: sync PR',
detail: 'PodMan offered a small coordination card before voice.',
nodeId: 'intervention:sync-pr',
},
{
id: 'demo-collision-auth',
at: new Date().toISOString(),
kind: 'collision',
title: 'Collision risk on auth.ts',
detail: 'Karti and Yahya converged on unpushed work.',
nodeId: 'collision:auth',
value: '86%',
detail: 'Interventions accepted this session (+14%).',
},
],
nodes: [
@@ -255,7 +186,7 @@ export function createDemoPodGraph(podId: string): PodGraph {
source: 'collision:auth',
target: 'intervention:sync-pr',
kind: 'warns',
label: 'routes',
label: 'nudges',
strength: 0.9,
},
{
-567
View File
@@ -1,567 +0,0 @@
import type {
PodGraph,
PodGraphNode,
PodGraphEdge,
PodGraphMetric,
PodLearningLoop,
PodGraphActivity,
PodGraphNodeKind,
PodGraphEdgeKind,
PodGraphNodeStatus,
} from '@podman/shared';
import { collections, getGitStates, getDb } from '../memory/db.js';
/**
* Live materializer: build a pod's continual-learning graph from the real
* collections the agent writes (pods, engineer_states, observations, collisions,
* interventions, outcomes) — NOT the hardcoded demo. See docs/live-ui-spec.md §1.
*
* Pure-read and best-effort. Returns `null` when there is no real activity yet
* (only bare roster), so `loadPodGraph` can fall back to the demo graph.
*/
const ACTIVE_WINDOW_MS = 90_000;
const MAX_OBSERVATIONS = 250;
/** Strip a `git status --short` XY code (and rename `old -> new`) to a clean path. */
export function parseGitStatusPath(line: string): string {
let s = line.trim();
const arrow = s.indexOf(' -> ');
if (arrow !== -1) s = s.slice(arrow + 4);
else s = s.replace(/^[ACDMRTU?!]{1,2}\s+/, '');
return normalizeFile(s);
}
/** Normalize a file path so vision (`collisions.file`) and git paths match. */
export function normalizeFile(f: string): string {
return f
.trim()
.replace(/^["']|["']$/g, '')
.replace(/^[ACDMRTU?!]{1,2}\s+/, '')
.replace(/^\.\//, '');
}
const MAX_COLLISIONS = 8;
/** Reject "file" values that aren't real source paths — vision/git noise such as
* URLs, env vars, browser/app names, and scratch/test artifacts. */
const FILE_NOISE =
/(:\/\/|^[#~]|\s|\.env\b|\btett\b|test-change|demo-scratch|podman-test|scratch|sslip)/i;
export function isFilePath(f: string): boolean {
if (!f || FILE_NOISE.test(f)) return false;
return /\.[a-z0-9]{1,6}$/i.test(f); // must end in a real file extension
}
/** Engineer names that are test/verification artifacts, not real teammates. */
const ENGINEER_NOISE = /(^verify\b|^.$|testrepo|-?check\b|\d{4,})/i;
const MAX_FILES = 9;
/** Short, readable node label — last two path segments (full path goes in summary). */
function shortLabel(file: string): string {
const parts = file.split('/').filter(Boolean);
return parts.slice(-2).join('/') || file;
}
const STATUS_RANK: Record<PodGraphNodeStatus, number> = {
stable: 0,
active: 1,
learned: 2,
risk: 3,
};
interface Builder {
nodes: Map<string, PodGraphNode>;
edges: Map<string, PodGraphEdge>;
}
function nodeKey(kind: PodGraphNodeKind, key: string): string {
return `${kind}:${key}`;
}
function upsertNode(
b: Builder,
kind: PodGraphNodeKind,
key: string,
patch: Partial<Omit<PodGraphNode, 'id' | 'kind' | 'x' | 'y'>>,
): string {
const id = nodeKey(kind, kind === 'engineer' ? key.toLowerCase() : key);
const cur = b.nodes.get(id);
if (!cur) {
b.nodes.set(id, {
id,
kind,
label: patch.label ?? key,
summary: patch.summary ?? '',
weight: patch.weight ?? 0.6,
status: patch.status ?? 'stable',
x: 0,
y: 0,
});
return id;
}
if (patch.label) cur.label = patch.label;
if (patch.summary) cur.summary = patch.summary;
if (patch.weight && patch.weight > cur.weight) cur.weight = patch.weight;
if (patch.status && STATUS_RANK[patch.status] > STATUS_RANK[cur.status])
cur.status = patch.status;
return id;
}
function upsertEdge(
b: Builder,
source: string,
target: string,
kind: PodGraphEdgeKind,
label: string,
strength: number,
): void {
const id = `${kind}:${source}->${target}`;
const cur = b.edges.get(id);
if (!cur) b.edges.set(id, { id, source, target, kind, label, strength });
else if (strength > cur.strength) cur.strength = strength;
}
const COLUMN_X: Record<PodGraphNodeKind, number> = {
engineer: 78,
file: 300,
feature: 360,
collision: 470,
intervention: 622,
};
/** Deterministic column layout so the SVG renders stably across refreshes. */
function layout(nodes: PodGraphNode[]): void {
const byKind = new Map<PodGraphNodeKind, PodGraphNode[]>();
for (const n of nodes) {
const list = byKind.get(n.kind) ?? [];
list.push(n);
byKind.set(n.kind, list);
}
for (const [kind, list] of byKind) {
list.sort((a, b) => a.id.localeCompare(b.id));
const n = list.length;
list.forEach((node, i) => {
node.x = COLUMN_X[kind];
node.y = Math.round(((i + 1) / (n + 1)) * 452) + 10;
});
}
}
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 };
function buildLoop(input: {
observations: number;
gitStates: number;
collisions: number;
interventions: number;
outcomes: number;
acceptedReal: number;
learnedEdges: number;
}): PodLearningLoop {
const stored = input.observations + input.gitStates + input.interventions + input.outcomes;
return {
activeStep:
input.acceptedReal > 0
? 'adapt'
: input.outcomes > 0
? 'outcome'
: input.collisions > 0
? 'predict'
: input.observations + input.gitStates > 0
? 'store'
: 'observe',
steps: [
{
key: 'observe',
label: 'Observe',
value: String(input.observations + input.gitStates),
detail: 'Recent vision observations plus local git-state reports.',
status: input.observations + input.gitStates > 0 ? 'complete' : 'quiet',
},
{
key: 'store',
label: 'Store',
value: String(stored),
detail: 'MongoDB records available to recall for this pod.',
status: stored > 0 ? 'complete' : 'quiet',
},
{
key: 'predict',
label: 'Predict',
value: String(input.collisions),
detail: 'Distinct collision signatures detected from live work.',
status: input.collisions > 0 ? 'complete' : 'quiet',
},
{
key: 'outcome',
label: 'Outcome',
value: String(input.outcomes),
detail: 'Accepted and dismissed intervention outcomes.',
status: input.outcomes > 0 ? 'complete' : 'quiet',
},
{
key: 'adapt',
label: 'Adapt',
value: String(input.learnedEdges),
detail: 'Learned graph edges created from accepted real outcomes.',
status: input.acceptedReal > 0 ? 'complete' : 'planned',
},
],
};
}
function pushActivity(
activity: PodGraphActivity[],
item: PodGraphActivity,
seen: Set<string>,
): void {
if (seen.has(item.id)) return;
seen.add(item.id);
activity.push(item);
}
export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
const c = await collections();
const db = await getDb();
const [pod, observations, collisionDocs, interventionDocs, outcomeDocs, gitStates] =
await Promise.all([
c.pods.findOne({ id: podId }),
c.observations.find({ podId }).sort({ observedAt: -1 }).limit(MAX_OBSERVATIONS).toArray(),
c.collisions.find({ podId }).sort({ detectedAt: -1 }).limit(100).toArray(),
c.interventions.find({ podId }).toArray(),
c.outcomes.find({ podId }).toArray(),
getGitStates(podId),
]);
// Optional supervised ownership map (team_model.ownership: file -> engineer).
let ownership: Record<string, string> = {};
try {
const tm = await db
.collection<{ podId: string; ownership?: Record<string, string> }>('team_model')
.findOne({ podId });
ownership = tm?.ownership ?? {};
} catch {
/* ownership is optional */
}
const b: Builder = { nodes: new Map(), edges: new Map() };
const activity: PodGraphActivity[] = [];
const activityIds = new Set<string>();
const now = Date.now();
// 1. Baseline engineer nodes from the roster.
for (const name of pod?.members ?? []) {
upsertNode(b, 'engineer', name, { label: name });
}
// 2. Vision (observations): who is active and on which file, with confidence.
for (const o of observations) {
if (!o.engineerId) continue;
const recent = o.observedAt && now - new Date(o.observedAt).getTime() < ACTIVE_WINDOW_MS;
const eng = upsertNode(b, 'engineer', o.engineerId, {
label: o.engineerId,
status: recent ? 'active' : undefined,
});
const file = o.currentFile ? normalizeFile(o.currentFile) : '';
if (isFilePath(file)) {
const f = upsertNode(b, 'file', file, { label: shortLabel(file), summary: file });
upsertEdge(b, eng, f, 'editing', o.activity ?? 'edits', Math.max(0.4, o.confidence ?? 0.5));
pushActivity(
activity,
{
id: `editing:${o.engineerId}:${file}:${String(o.observedAt ?? '')}`,
at: String(o.observedAt ?? new Date().toISOString()),
kind: 'editing',
title: `${o.engineerId} editing ${shortLabel(file)}`,
detail: o.activity ?? 'Vision observed active work.',
nodeId: f,
},
activityIds,
);
}
}
// Collisions referenced by accepted outcomes are the "learned" money path — they
// always survive the cap so the learned_from beat is never dropped.
const priorityCol = new Set<string>();
for (const out of outcomeDocs) {
if (!out.accepted || !out.wasRealCollision) continue;
if (out.collisionId) priorityCol.add(out.collisionId);
const iv = interventionDocs.find((i) => i.id === out.interventionId);
if (iv?.collisionId) priorityCol.add(iv.collisionId);
}
// 3. Collisions: collapse repeats by signature, keep the most recent, cap to
// MAX_COLLISIONS, skip junk-file collisions. `collisionById` keeps every doc
// (for the outcome join); `colNodeFor` maps each collisionId to its surviving
// collision node (or null when collapsed / capped / filtered out).
const collisionById = new Map<string, (typeof collisionDocs)[number]>();
const colNodeFor = new Map<string, string | null>();
const sigToNode = new Map<string, string>();
let distinctCollisions = 0;
for (const col of collisionDocs) {
collisionById.set(col.id, col);
const file = normalizeFile(col.file);
const sig =
(col as { memorySignature?: string }).memorySignature ?? `${file}#${col.symbol ?? ''}`;
const existing = sigToNode.get(sig);
if (existing) {
colNodeFor.set(col.id, existing);
continue;
}
if (!isFilePath(file)) {
colNodeFor.set(col.id, null);
continue;
}
const isPriority = priorityCol.has(col.id);
if (!isPriority && distinctCollisions >= MAX_COLLISIONS) {
colNodeFor.set(col.id, null);
continue;
}
const cNode = upsertNode(b, 'collision', col.id, {
label: shortLabel(file),
status: 'risk',
weight: SEVERITY_WEIGHT[col.severity] ?? 0.7,
summary: `${col.engineers.join(' + ')} on ${file}${
(col as { memorySignature?: string }).memorySignature ? ' · seen before' : ''
}`,
});
const fNode = upsertNode(b, 'file', file, {
label: shortLabel(file),
summary: file,
status: 'risk',
});
upsertEdge(b, fNode, cNode, 'touches', 'hot', 0.6);
for (const name of col.engineers) {
const eng = upsertNode(b, 'engineer', name, { label: name });
upsertEdge(b, eng, cNode, 'collides', 'in', SEVERITY_WEIGHT[col.severity] ?? 0.7);
}
pushActivity(
activity,
{
id: `collision:${col.id}`,
at: col.detectedAt,
kind: 'collision',
title: `Collision risk on ${shortLabel(file)}`,
detail: `${col.engineers.join(' + ')} converged on ${file}.`,
nodeId: cNode,
},
activityIds,
);
sigToNode.set(sig, cNode);
colNodeFor.set(col.id, cNode);
if (!isPriority) distinctCollisions++;
}
// 4. Git truth (engineer_states): mark unpushed work and confirm editing on
// files vision/collisions already surfaced — not the whole repo diff.
for (const [name, git] of gitStates) {
const files = git.changedFiles.map(parseGitStatusPath).filter(Boolean);
const eng = upsertNode(b, 'engineer', name, {
label: name,
status: files.length > 0 ? 'risk' : 'active',
summary: files.length
? `${files.length} changed file(s) on ${git.branch ?? 'detached'}`
: `on ${git.branch ?? 'detached'}`,
weight: 0.7,
});
for (const file of files) {
const fid = nodeKey('file', file);
if (b.nodes.has(fid)) upsertEdge(b, eng, fid, 'editing', 'edits', 0.6);
}
}
// 5. Interventions: collapse to one (most recent) per surviving collision.
const interventionById = new Map<string, (typeof interventionDocs)[number]>();
const ivNodeForCol = new Map<string, string>();
const sortedIvs = [...interventionDocs].sort((a, b) =>
String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')),
);
for (const iv of sortedIvs) {
interventionById.set(iv.id, iv);
const colNode = colNodeFor.get(iv.collisionId);
if (!colNode || ivNodeForCol.has(colNode)) continue;
const ivNode = upsertNode(b, 'intervention', iv.id, {
label:
iv.suggestedAction?.kind === 'open_sync_pr'
? 'sync PR'
: iv.suggestedAction?.kind === 'ping_teammate'
? 'ping'
: 'watch',
summary: iv.message,
});
upsertEdge(b, colNode, ivNode, 'warns', 'routes', 0.85);
pushActivity(
activity,
{
id: `intervention:${iv.id}`,
at: iv.createdAt,
kind: 'intervention',
title: `Intervention: ${b.nodes.get(ivNode)?.label ?? iv.kind}`,
detail: iv.message,
nodeId: ivNode,
},
activityIds,
);
ivNodeForCol.set(colNode, ivNode);
}
// 6. Outcomes: the supervised learning signal -> learned_from edges + owns.
for (const out of outcomeDocs) {
if (!out.accepted || !out.wasRealCollision) continue;
const iv = interventionById.get(out.interventionId);
const col = iv ? collisionById.get(iv.collisionId) : collisionById.get(out.collisionId);
if (!col) continue;
const file = normalizeFile(col.file);
if (!isFilePath(file)) continue;
const owner =
(out as { learnedOwner?: string }).learnedOwner ?? ownership[file] ?? col.engineers[0];
if (!owner) continue;
const engNode = upsertNode(b, 'engineer', owner, { label: owner, status: 'learned' });
const fNode = upsertNode(b, 'file', file, { label: file });
upsertEdge(b, engNode, fNode, 'owns', 'owns', 0.85);
const cNode = colNodeFor.get(col.id);
const ivNode = cNode ? ivNodeForCol.get(cNode) : undefined;
if (ivNode) {
const ivObj = b.nodes.get(ivNode);
if (ivObj) ivObj.status = 'learned';
const before = b.edges.size;
upsertEdge(b, ivNode, engNode, 'learned_from', `learned: owns ${file}`, 0.6);
const edgeId = `${'learned_from'}:${ivNode}->${engNode}`;
pushActivity(
activity,
{
id: `learned:${out.interventionId}:${owner}:${file}`,
at: out.recordedAt,
kind: 'learned',
title: `Learned ${owner} owns ${shortLabel(file)}`,
detail: 'Accepted real outcome created a durable learned_from path.',
nodeId: engNode,
edgeId: before === b.edges.size ? undefined : edgeId,
},
activityIds,
);
}
pushActivity(
activity,
{
id: `outcome:${out.interventionId}:${out.recordedAt}`,
at: out.recordedAt,
kind: 'outcome',
title: out.accepted ? 'Outcome accepted' : 'Outcome dismissed',
detail: out.wasRealCollision ? 'Marked as a real collision.' : 'Marked as noise.',
},
activityIds,
);
}
// Prune test-artifact engineers, then anything left orphaned by that.
const dropNode = (id: string) => {
b.nodes.delete(id);
for (const [eid, e] of [...b.edges])
if (e.source === id || e.target === id) b.edges.delete(eid);
};
for (const [id, n] of [...b.nodes]) {
if (n.kind === 'engineer' && ENGINEER_NOISE.test(n.label)) dropNode(id);
}
// Collisions with no remaining engineer = test/orphan -> drop.
for (const [id, n] of [...b.nodes]) {
if (n.kind !== 'collision') continue;
if (![...b.edges.values()].some((e) => e.kind === 'collides' && e.target === id)) dropNode(id);
}
// Cap file nodes to the most-connected (collision files first).
const fileNodes = [...b.nodes.values()].filter((n) => n.kind === 'file');
if (fileNodes.length > MAX_FILES) {
const inCollision = (id: string) =>
[...b.edges.values()].some((e) => e.kind === 'touches' && e.source === id);
const degree = (id: string) =>
[...b.edges.values()].filter((e) => e.source === id || e.target === id).length;
fileNodes.sort(
(a, z) =>
Number(inCollision(z.id)) - Number(inCollision(a.id)) || degree(z.id) - degree(a.id),
);
for (const n of fileNodes.slice(MAX_FILES)) dropNode(n.id);
}
// Files / interventions left with no edges -> drop.
for (const [id, n] of [...b.nodes]) {
if (n.kind === 'file' || n.kind === 'intervention') {
if (![...b.edges.values()].some((e) => e.source === id || e.target === id))
b.nodes.delete(id);
}
}
const nodes = [...b.nodes.values()];
// No real activity beyond the bare roster -> let the caller fall back to demo.
const hasActivity = nodes.some((n) => n.kind !== 'engineer');
if (!hasActivity) return null;
layout(nodes);
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
const totalOutcomes = outcomeDocs.length;
// Raw distinct collision signatures — kept for the learning-loop throughput view.
const riskPaths = new Set(
collisionDocs.map(
(col) =>
(col as { memorySignature?: string }).memorySignature ??
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
),
).size;
// Headline metric cards are derived from the FINAL de-noised graph so they match
// what's drawn. Counting raw collision signatures / accepted-outcome rows inflates
// them with test churn (e.g. 50 "risk paths" for 4 files), which reads as fake.
const finalEdges = [...b.edges.values()];
const riskFiles = new Set<string>();
for (const e of finalEdges) {
if (e.kind === 'touches' && b.nodes.get(e.source)?.kind === 'file') riskFiles.add(e.source);
}
const openRiskPaths = riskFiles.size || nodes.filter((n) => n.kind === 'collision').length;
const ownerSet = new Set<string>();
for (const e of finalEdges) {
if (e.kind === 'learned_from') ownerSet.add(e.target);
if (e.kind === 'owns') ownerSet.add(e.source);
}
const learnedOwners = [...ownerSet].filter((id) => b.nodes.get(id)?.kind === 'engineer').length;
const metrics: PodGraphMetric[] = [
{
label: 'Learned owners',
value: String(learnedOwners),
detail: 'Distinct owners retained from accepted interventions.',
},
{
label: 'Open risk paths',
value: String(openRiskPaths),
detail: `${openRiskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`,
},
{
label: 'Accept rate',
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
detail: 'Interventions accepted vs total this session.',
},
];
const learnedEdges = [...b.edges.values()].filter((e) => e.kind === 'learned_from').length;
activity.sort((a, z) => String(z.at).localeCompare(String(a.at)));
return {
podId,
generatedAt: new Date().toISOString(),
nodes,
edges: [...b.edges.values()],
metrics,
loop: buildLoop({
observations: observations.length,
gitStates: gitStates.size,
collisions: riskPaths,
interventions: interventionDocs.length,
outcomes: totalOutcomes,
acceptedReal,
learnedEdges,
}),
activity: activity.slice(0, 12),
};
}
-10
View File
@@ -1,7 +1,6 @@
import type { PodGraph, GraphNodeDoc, GraphEdgeDoc } from '@podman/shared';
import { getDb } from '../memory/db.js';
import { createDemoPodGraph } from './demo.js';
import { materializePodGraph } from './live.js';
interface TeamModelDoc {
podId: string;
@@ -15,14 +14,6 @@ interface TeamModelDoc {
* unreachable — so the demo path never depends on a populated DB.
*/
export async function loadPodGraph(podId: string): Promise<PodGraph> {
// 1. Live: materialize from the real collections (observations/collisions/…).
try {
const live = await materializePodGraph(podId);
if (live) return live;
} catch (err) {
console.warn(`[graph] live materialize failed, falling back: ${(err as Error).message}`);
}
// 2. Seeded snapshot embedded in team_model.
try {
const db = await getDb();
const doc = await db.collection<TeamModelDoc>('team_model').findOne({ podId });
@@ -30,7 +21,6 @@ export async function loadPodGraph(podId: string): Promise<PodGraph> {
} catch (err) {
console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`);
}
// 3. Demo (stage safety — never an empty canvas).
return createDemoPodGraph(podId);
}
-349
View File
@@ -1,349 +0,0 @@
import { randomUUID } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { Room as LiveKitRoom } from '@livekit/rtc-node';
import { AccessToken } from 'livekit-server-sdk';
import {
DATA_TOPIC,
type DataMessage,
type HermesJob,
type HermesJobEvent,
type HermesJobEventType,
type HermesJobInput,
type HermesJobStatus,
type HermesRiskLevel,
} from '@podman/shared';
import { env, repoParts } from '../env.js';
import { getDb } from '../memory/db.js';
const execFileAsync = promisify(execFile);
const encoder = new TextEncoder();
const MAX_OUTPUT = 3_000;
const COMMAND_TIMEOUT_MS = 45_000;
const runners = new Map<string, AbortController>();
function now(): string {
return new Date().toISOString();
}
function truncate(value: string): string {
return value.length > MAX_OUTPUT ? `${value.slice(0, MAX_OUTPUT)}\n...[truncated]` : value;
}
function redact(value: string): string {
return value
.replace(/AIza[0-9A-Za-z_-]{20,}/g, '[redacted-google-key]')
.replace(/API[_-]?SECRET=[^\s]+/gi, 'API_SECRET=[redacted]')
.replace(/TOKEN=[^\s]+/gi, 'TOKEN=[redacted]')
.replace(/mongodb(\+srv)?:\/\/[^@\s]+@/gi, 'mongodb$1://[redacted]@');
}
function normalizeRisk(value: unknown): HermesRiskLevel {
return value === 'safe_write' ||
value === 'commit_allowed' ||
value === 'deploy_allowed' ||
value === 'read_only'
? value
: 'read_only';
}
async function hermesJobs() {
return (await getDb()).collection<HermesJob>('hermes_jobs');
}
async function hermesJobEvents() {
return (await getDb()).collection<HermesJobEvent>('hermes_job_events');
}
export async function ensureHermesJobIndexes(): Promise<void> {
const db = await getDb();
await Promise.allSettled([
db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true }),
db.collection('hermes_jobs').createIndex({ sessionId: 1, status: 1, updatedAt: -1 }),
db.collection('hermes_jobs').createIndex({ podId: 1, updatedAt: -1 }),
db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
db.collection('hermes_job_events').createIndex({ sessionId: 1, createdAt: -1 }),
]);
}
export async function createHermesJob(input: Partial<HermesJobInput>): Promise<HermesJob> {
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
if (!prompt) throw new Error('prompt is required');
const createdAt = now();
const job: HermesJob = {
id: `hermes_job_${randomUUID()}`,
podId: input.podId || 'demo-pod',
identity: input.identity || 'developer',
sessionId: input.sessionId || 'unknown',
conversationRoom: input.conversationRoom,
prompt,
contextScope: input.contextScope || 'current_repo',
targetRepository: input.targetRepository || env.GITHUB_REPO,
riskLevel: normalizeRisk(input.riskLevel),
requiresConfirmation: input.requiresConfirmation === true,
successCriteria: Array.isArray(input.successCriteria)
? input.successCriteria.map(String).filter(Boolean).slice(0, 8)
: ['Hermes reports what it inspected and what changed.'],
parentJobId: input.parentJobId,
status: 'queued',
createdAt,
updatedAt: createdAt,
};
await (await hermesJobs()).insertOne(job);
await appendHermesJobEvent(job.id, 'accepted', 'Hermes accepted the task.', {
riskLevel: job.riskLevel,
contextScope: job.contextScope,
});
void runHermesJob(job.id);
return job;
}
export async function getHermesJob(jobId: string): Promise<HermesJob | null> {
return (await hermesJobs()).findOne({ id: jobId }, { projection: { _id: 0 } });
}
export async function getActiveHermesJobForSession(sessionId: string): Promise<HermesJob | null> {
return (await hermesJobs()).findOne(
{ sessionId, status: { $in: ['queued', 'running', 'waiting_for_confirmation', 'aborting'] } },
{ projection: { _id: 0 }, sort: { updatedAt: -1 } },
);
}
export async function getLatestHermesJobForSession(sessionId: string): Promise<HermesJob | null> {
return (await hermesJobs()).findOne(
{ sessionId },
{ projection: { _id: 0 }, sort: { updatedAt: -1 } },
);
}
export async function listHermesJobEvents(jobId: string, limit = 40): Promise<HermesJobEvent[]> {
return (await hermesJobEvents())
.find({ jobId }, { projection: { _id: 0 } })
.sort({ createdAt: 1 })
.limit(Math.min(limit, 200))
.toArray();
}
export async function appendHermesJobEvent(
jobId: string,
type: HermesJobEventType,
message: string,
data?: Record<string, unknown>,
): Promise<HermesJobEvent> {
const job = await getHermesJob(jobId);
if (!job) throw new Error('job not found');
const event: HermesJobEvent = {
id: `hermes_evt_${randomUUID()}`,
jobId,
podId: job.podId,
sessionId: job.sessionId,
type,
message: redact(truncate(message)),
data,
createdAt: now(),
};
await (await hermesJobEvents()).insertOne(event);
await (
await hermesJobs()
).updateOne(
{ id: jobId },
{ $set: { updatedAt: event.createdAt, lastHeartbeatAt: event.createdAt } },
);
if (job.conversationRoom) {
void publishHermesJobEvent(job.conversationRoom, event).catch((err) =>
console.warn(`[hermes-job] data publish failed: ${(err as Error).message}`),
);
}
return event;
}
export async function abortHermesJob(jobId: string): Promise<HermesJob | null> {
const job = await getHermesJob(jobId);
if (!job) return null;
const abortAt = now();
await (
await hermesJobs()
).updateOne(
{ id: jobId },
{ $set: { status: 'aborting', abortRequestedAt: abortAt, updatedAt: abortAt } },
);
runners.get(jobId)?.abort();
await appendHermesJobEvent(jobId, 'heartbeat', 'Hermes is aborting the current job.');
return getHermesJob(jobId);
}
async function setStatus(jobId: string, status: HermesJobStatus, patch: Partial<HermesJob> = {}) {
await (
await hermesJobs()
).updateOne({ id: jobId }, { $set: { status, updatedAt: now(), ...patch } });
}
async function publishHermesJobEvent(roomName: string, event: HermesJobEvent): Promise<void> {
const room = new LiveKitRoom();
try {
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
identity: `podman-hermes-job-${Date.now()}`,
name: 'PodMan Hermes jobs',
ttl: '5m',
});
at.addGrant({
roomJoin: true,
room: roomName,
canPublish: true,
canSubscribe: false,
canPublishData: true,
});
await room.connect(env.LIVEKIT_URL, await at.toJwt(), {
autoSubscribe: false,
dynacast: false,
});
const data: DataMessage = { type: 'HERMES_JOB_EVENT', event };
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
reliable: true,
topic: DATA_TOPIC,
});
} finally {
await room.disconnect().catch(() => {});
}
}
async function runCommand(
jobId: string,
label: string,
command: string,
args: string[],
signal: AbortSignal,
): Promise<string> {
await appendHermesJobEvent(jobId, 'step_started', `${label} started.`);
const started = Date.now();
const { stdout, stderr } = await execFileAsync(command, args, {
cwd: process.cwd(),
timeout: COMMAND_TIMEOUT_MS,
signal,
maxBuffer: 1024 * 1024,
});
const output = redact(truncate([stdout, stderr].filter(Boolean).join('\n').trim()));
await appendHermesJobEvent(jobId, 'step_output', output || `${label} produced no output.`, {
label,
durationMs: Date.now() - started,
});
await appendHermesJobEvent(jobId, 'step_completed', `${label} completed.`);
return output;
}
function wantsBuild(prompt: string, criteria: string[]): boolean {
const haystack = `${prompt} ${criteria.join(' ')}`.toLowerCase();
return /build|typecheck|test|lint|broken|failing|verify/.test(haystack);
}
function wantsMongo(prompt: string, scope: string): boolean {
return scope === 'mongodb' || /mongo|database|telemetry|logs?|memory/.test(prompt.toLowerCase());
}
function wantsGithub(prompt: string, scope: string): boolean {
return (
scope === 'github' || /github|branch|pr|pull request|commit|diff/.test(prompt.toLowerCase())
);
}
async function inspectMongo(jobId: string) {
await appendHermesJobEvent(jobId, 'step_started', 'MongoDB inspection started.');
const db = await getDb();
const [observations, collisions, interventions, outcomes, jobs] = await Promise.all([
db.collection('observations').estimatedDocumentCount(),
db.collection('collisions').estimatedDocumentCount(),
db.collection('interventions').estimatedDocumentCount(),
db.collection('outcomes').estimatedDocumentCount(),
db.collection('hermes_jobs').estimatedDocumentCount(),
]);
await appendHermesJobEvent(
jobId,
'step_output',
`MongoDB is reachable. Counts: observations=${observations}, collisions=${collisions}, interventions=${interventions}, outcomes=${outcomes}, hermes_jobs=${jobs}.`,
);
await appendHermesJobEvent(jobId, 'step_completed', 'MongoDB inspection completed.');
}
async function inspectGithub(jobId: string) {
await appendHermesJobEvent(jobId, 'step_started', 'GitHub repository inspection started.');
const { owner, repo } = repoParts();
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: {
accept: 'application/vnd.github+json',
authorization: `Bearer ${env.GITHUB_TOKEN}`,
'x-github-api-version': '2022-11-28',
},
});
if (!res.ok) throw new Error(`GitHub repo check returned ${res.status}`);
const body = (await res.json()) as {
full_name?: string;
default_branch?: string;
open_issues_count?: number;
};
await appendHermesJobEvent(
jobId,
'step_output',
`GitHub ${body.full_name ?? `${owner}/${repo}`} is reachable. Default branch=${body.default_branch ?? 'unknown'}, open issue count=${body.open_issues_count ?? 0}.`,
);
await appendHermesJobEvent(jobId, 'step_completed', 'GitHub repository inspection completed.');
}
async function runHermesJob(jobId: string): Promise<void> {
const job = await getHermesJob(jobId);
if (!job) return;
const controller = new AbortController();
runners.set(jobId, controller);
try {
await setStatus(jobId, 'running', { startedAt: now() });
await appendHermesJobEvent(jobId, 'heartbeat', 'Hermes is gathering repository context.');
const outputs: string[] = [];
outputs.push(
await runCommand(
jobId,
'Git status',
'git',
['status', '--short', '--branch'],
controller.signal,
),
);
outputs.push(
await runCommand(jobId, 'Git diff summary', 'git', ['diff', '--stat'], controller.signal),
);
if (wantsGithub(job.prompt, job.contextScope)) await inspectGithub(jobId);
if (wantsMongo(job.prompt, job.contextScope)) await inspectMongo(jobId);
if (wantsBuild(job.prompt, job.successCriteria)) {
outputs.push(
await runCommand(jobId, 'TypeScript typecheck', 'pnpm', ['typecheck'], controller.signal),
);
}
if (job.riskLevel === 'deploy_allowed' && job.requiresConfirmation) {
await setStatus(jobId, 'waiting_for_confirmation');
await appendHermesJobEvent(
jobId,
'needs_confirmation',
'Hermes needs confirmation before deploy-level actions.',
);
return;
}
const finalSummary = `Hermes completed the task. It inspected repository state${wantsMongo(job.prompt, job.contextScope) ? ', MongoDB' : ''}${wantsGithub(job.prompt, job.contextScope) ? ', and GitHub' : ''}. ${outputs.some((o) => /error|failed/i.test(o)) ? 'Review the recorded output for warnings.' : 'No blocking error was reported by the completed checks.'}`;
await setStatus(jobId, 'completed', { completedAt: now(), finalSummary });
await appendHermesJobEvent(jobId, 'completed', finalSummary);
} catch (err) {
const aborted = controller.signal.aborted;
const message = aborted
? 'Hermes aborted the job before making further changes.'
: (err as Error).message;
await setStatus(jobId, aborted ? 'aborted' : 'failed', {
completedAt: now(),
finalSummary: message,
error: aborted ? undefined : message,
});
await appendHermesJobEvent(jobId, aborted ? 'aborted' : 'failed', message);
} finally {
runners.delete(jobId);
}
}
-80
View File
@@ -1,80 +0,0 @@
import { getDb } from '../memory/db.js';
import { getMemberWorkHistory } from '../activity/member-history.js';
const DEFAULT_LIMIT = 8;
function sinceIso(hours: number): string {
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
}
export async function getLiveConversationContext(podId: string, identity: string) {
const db = await getDb();
const since = sinceIso(12);
const [pod, history, gitState, collisions, interventions, outcomes] = await Promise.all([
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
db.collection('engineer_states').findOne(
{ podId, name: identity },
{
projection: {
_id: 0,
name: 1,
branch: 1,
changedFiles: 1,
recentCommit: 1,
gitUpdatedAt: 1,
},
},
),
db
.collection('collisions')
.find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0, embedding: 0 } })
.sort({ detectedAt: -1 })
.limit(DEFAULT_LIMIT)
.toArray(),
db
.collection('interventions')
.find({ podId, createdAt: { $gte: since } }, { projection: { _id: 0 } })
.sort({ createdAt: -1 })
.limit(DEFAULT_LIMIT)
.toArray(),
db
.collection('outcomes')
.find({ podId, recordedAt: { $gte: since } }, { projection: { _id: 0 } })
.sort({ recordedAt: -1 })
.limit(DEFAULT_LIMIT)
.toArray(),
]);
return {
pod,
identity,
generatedAt: new Date().toISOString(),
currentGitState: gitState,
memberHistory: history,
recentCollisions: collisions,
recentInterventions: interventions,
recentOutcomes: outcomes,
};
}
export async function recordLiveConversationNote(input: {
podId: string;
sessionId: string;
identity?: string;
note: string;
kind?: string;
}) {
const note = input.note.trim();
if (!note) throw new Error('note is required');
const doc = {
podId: input.podId,
sessionId: input.sessionId,
identity: input.identity,
kind: input.kind || 'summary',
note: note.slice(0, 4000),
createdAt: new Date().toISOString(),
};
await (await getDb()).collection('conversation_notes').insertOne(doc);
return { ...doc, _id: undefined };
}
-193
View File
@@ -1,193 +0,0 @@
import { randomUUID } from 'node:crypto';
import { AccessToken, RoomAgentDispatch, RoomConfiguration } from 'livekit-server-sdk';
import { Room as LiveKitRoom } from '@livekit/rtc-node';
import type { Collision, DataMessage, Intervention, LiveConversationEvent } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared';
import { env } from '../env.js';
import { closeRoom } from '../livekit/rooms.js';
import { speakInRoom } from '../voice/live.js';
const encoder = new TextEncoder();
const DEFAULT_AGENT = 'podman-live-conversation';
export interface LiveConversationSession {
sessionId: string;
podId: string;
identity: string;
displayName: string;
room: string;
url: string;
startedAt: string;
lastEventAt?: string;
endedAt?: string;
}
const sessions = new Map<string, LiveConversationSession>();
function sessionKey(podId: string, identity: string): string {
return `${podId}:${identity.toLowerCase()}`;
}
function cleanPart(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
function agentName(): string {
return env.LIVEKIT_CONVERSATION_AGENT_NAME || DEFAULT_AGENT;
}
function tokenFor(room: string, identity: string, name: string, metadata: object): Promise<string> {
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
identity,
name,
ttl: '4h',
metadata: JSON.stringify(metadata),
});
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
return at.toJwt();
}
export async function startLiveConversation(input: {
podId: string;
identity: string;
displayName?: string;
}): Promise<LiveConversationSession & { token: string }> {
const identity = input.identity.trim();
if (!identity) throw new Error('identity is required');
const existing = activeLiveConversation(input.podId, identity);
if (existing) {
return {
...existing,
token: await tokenFor(existing.room, identity, existing.displayName, {
podId: input.podId,
identity,
sessionId: existing.sessionId,
mode: 'podman-live-conversation',
}),
};
}
const sessionId = randomUUID();
const room = `podman-live:${cleanPart(input.podId)}:${cleanPart(identity)}:${sessionId.slice(0, 8)}`;
const displayName = input.displayName?.trim() || identity;
const metadata = { podId: input.podId, identity, sessionId, mode: 'podman-live-conversation' };
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
identity,
name: displayName,
ttl: '4h',
metadata: JSON.stringify(metadata),
});
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
at.roomConfig = new RoomConfiguration({
name: room,
emptyTimeout: 60,
departureTimeout: 15,
agents: [
new RoomAgentDispatch({
agentName: agentName(),
metadata: JSON.stringify(metadata),
}),
],
});
const session: LiveConversationSession = {
sessionId,
podId: input.podId,
identity,
displayName,
room,
url: env.LIVEKIT_URL,
startedAt: new Date().toISOString(),
};
sessions.set(sessionKey(input.podId, identity), session);
return { ...session, token: await at.toJwt() };
}
export function activeLiveConversation(
podId: string,
identity: string,
): LiveConversationSession | null {
const session = sessions.get(sessionKey(podId, identity));
return session && !session.endedAt ? session : null;
}
export function listActiveLiveConversations(podId: string): LiveConversationSession[] {
return [...sessions.values()].filter((session) => session.podId === podId && !session.endedAt);
}
export async function stopLiveConversation(
podId: string,
sessionId: string,
): Promise<LiveConversationSession | null> {
const session = [...sessions.values()].find(
(candidate) => candidate.podId === podId && candidate.sessionId === sessionId,
);
if (!session) return null;
session.endedAt = new Date().toISOString();
await closeRoom(session.room);
return session;
}
async function publishPrivateConversationEvent(
roomName: string,
event: LiveConversationEvent,
): Promise<void> {
const room = new LiveKitRoom();
try {
const token = await tokenFor(roomName, `podman-live-router-${Date.now()}`, 'PodMan live router', {
mode: 'podman-live-router',
});
await room.connect(env.LIVEKIT_URL, token, { autoSubscribe: false, dynacast: false });
const data: DataMessage = { type: 'LIVE_CONVERSATION_EVENT', event };
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
reliable: true,
topic: DATA_TOPIC,
});
} finally {
await room.disconnect().catch(() => {});
}
}
export async function notifyCriticalLiveConversations(
collision: Collision,
intervention: Intervention,
voiceLine?: string,
): Promise<void> {
if (collision.severity !== 'critical') return;
const recipients = new Set(collision.engineers.map((name) => name.toLowerCase()));
const active = listActiveLiveConversations(collision.podId).filter((session) =>
recipients.has(session.identity.toLowerCase()),
);
if (active.length === 0) return;
await Promise.allSettled(
active.map(async (session) => {
const createdAt = new Date().toISOString();
session.lastEventAt = createdAt;
const summary =
voiceLine ||
`Critical collision in ${collision.file}. ${collision.engineers.join(
' and ',
)} should sync before pushing.`;
await publishPrivateConversationEvent(session.room, {
id: `live_evt_${Date.now()}_${session.sessionId.slice(0, 8)}`,
podId: collision.podId,
sessionId: session.sessionId,
kind: 'critical_collision',
severity: 'critical',
summary,
interrupt: true,
createdAt,
collisionId: collision.id,
interventionId: intervention.id,
});
await speakInRoom(session.room, summary, { priority: 'critical' });
}),
);
}
-21
View File
@@ -57,8 +57,6 @@ export interface GitState {
gitUpdatedAt: Date | null;
}
const GIT_STATE_TTL_MS = Number(process.env.GIT_STATE_TTL_MS ?? '120000');
/** Fetch latest git state per engineer for a pod from the engineer_states collection.
* Returns a map keyed by engineer name (matches --name arg used in podman-agent.mjs). */
export async function getGitStates(podId: string): Promise<Map<string, GitState>> {
@@ -73,17 +71,7 @@ export async function getGitStates(podId: string): Promise<Map<string, GitState>
}>('engineer_states');
const docs = await col.find({ podId }).toArray();
const map = new Map<string, GitState>();
const now = Date.now();
for (const doc of docs) {
const updatedAt = doc.gitUpdatedAt ? new Date(doc.gitUpdatedAt) : null;
if (
updatedAt &&
!Number.isNaN(updatedAt.getTime()) &&
GIT_STATE_TTL_MS > 0 &&
now - updatedAt.getTime() > GIT_STATE_TTL_MS
) {
continue;
}
map.set(doc.name, {
changedFiles: doc.changedFiles ?? [],
branch: doc.branch ?? null,
@@ -114,15 +102,6 @@ export async function initMemory(): Promise<void> {
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
[
'hermes_jobs.session',
() => db.collection('hermes_jobs').createIndex({ sessionId: 1, status: 1, updatedAt: -1 }),
],
[
'hermes_job_events.job',
() => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
],
];
for (const [name, make] of indexes) {
try {
+2 -7
View File
@@ -12,16 +12,11 @@ export function shouldIntervene(collision: Collision, prior: RecalledCollision |
if (collision.severity === 'info') return false;
const priorOutcome = prior?.priorOutcome;
// Suppress when the identical prior was dismissed (accepted === false). The
// former `&& !priorOutcome.wasRealCollision` term was dead code: outcomes are
// recorded with wasRealCollision hardcoded true, so the gate never fired and
// the 85 real dismissals in Atlas were ignored. Dismissals are the negative
// signal per continual-learning/policy.md:41 + spec.md:163. (RSI Step 1)
if (priorOutcome && !priorOutcome.accepted) return false;
if (priorOutcome && !priorOutcome.accepted && !priorOutcome.wasRealCollision) return false;
const cooldown = cooldownMs();
const last = lastNudgeByPod.get(collision.podId) ?? 0;
if (cooldown > 0 && Date.now() - last < cooldown) {
if (cooldown > 0 && Date.now() - last < cooldown && collision.severity !== 'critical') {
return false;
}
+4 -55
View File
@@ -1,34 +1,17 @@
import type {
EngineerContext,
Collision,
Intervention,
InterventionOutcome,
InterventionStatus,
} from '@podman/shared';
import type { EngineerContext, Collision, Intervention, InterventionOutcome } from '@podman/shared';
import { collections } from './db.js';
import { enrichCollisionMemory } from './vectors.js';
function comparableFile(raw?: string): string {
return (raw ?? '')
.trim()
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
.split(/[\\/]/)
.pop()
?.toLowerCase() ?? '';
}
/**
* Continual-learning memory: persist observations, collisions, interventions,
* and outcomes to MongoDB so later sessions get sharper. MongoDB is mandatory —
* a failed write is surfaced loudly and rethrown, never silently swallowed, so
* a broken memory layer can never masquerade as a working one.
* and outcomes to MongoDB so later sessions get sharper. Writes are best-effort
* — a Mongo hiccup logs a warning rather than crashing the agent/server.
*/
async function persist(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
} catch (err) {
console.error(`[memory] ${name} persist FAILED: ${(err as Error).message}`);
throw err;
console.warn(`[memory] ${name} persist failed: ${(err as Error).message}`);
}
}
@@ -50,40 +33,6 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
);
}
export async function hasRecentInterventionForCollision(
collision: Collision,
windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'),
): Promise<boolean> {
if (windowMs <= 0) return false;
const c = await collections();
const since = new Date(Date.now() - windowMs).toISOString();
const recent = await c.collisions
.find({ podId: collision.podId, detectedAt: { $gte: since } })
.sort({ detectedAt: -1 })
.limit(100)
.toArray();
const targetFile = comparableFile(collision.file);
for (const match of recent) {
if (match.id === collision.id || comparableFile(match.file) !== targetFile) continue;
const existing = await c.interventions.findOne({
collisionId: match.id,
createdAt: { $gte: since },
});
if (existing) return true;
}
return false;
}
export async function updateInterventionStatus(
interventionId: string,
status: InterventionStatus,
): Promise<void> {
await persist('intervention ack', async () =>
(await collections()).interventions.updateOne({ id: interventionId }, { $set: { status } }),
);
}
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
await persist('outcome', async () => {
const c = await collections();
+6 -432
View File
@@ -1,18 +1,11 @@
import express from 'express';
import cors from 'cors';
import { createServer } from 'node:http';
import type { Socket } from 'node:net';
import { WebSocketServer } from 'ws';
import { AccessToken, RoomAgentDispatch, RoomConfiguration } from 'livekit-server-sdk';
import { AccessToken, RoomConfiguration } from 'livekit-server-sdk';
import { env } from './env.js';
import { createSyncPr } from './github/client.js';
import {
recordCollision,
recordIntervention,
recordOutcome,
hasRecentInterventionForCollision,
memoryStats,
} from './memory/store.js';
import { recordOutcome, memoryStats } from './memory/store.js';
import { closeMemory, initMemory } from './memory/db.js';
import {
listPods,
@@ -26,66 +19,13 @@ import {
} from './pods/store.js';
import { getPresence, closeRoom } from './livekit/rooms.js';
import { loadPodGraph, reachFrom } from './graph/store.js';
import { listPodActivity } from './activity/store.js';
import { getMemberWorkHistory } from './activity/member-history.js';
import { speakInRoom } from './voice/live.js';
import { getPodMusic } from './voice/music.js';
import { notifyHermesInterventionInRoom } from './action/hermes.js';
import {
activeLiveConversation,
startLiveConversation,
stopLiveConversation,
} from './live-conversation/sessions.js';
import {
getLiveConversationContext,
recordLiveConversationNote,
} from './live-conversation/context.js';
import {
abortHermesJob,
appendHermesJobEvent,
createHermesJob,
getActiveHermesJobForSession,
getHermesJob,
getLatestHermesJobForSession,
listHermesJobEvents,
} from './hermes/jobs.js';
import type {
Collision,
HermesJobEventType,
Intervention,
InterventionOutcome,
SuggestedActionKind,
} from '@podman/shared';
import type { InterventionOutcome } from '@podman/shared';
const app = express();
app.use(cors());
app.use(express.json());
app.get('/health', (_req, res) => res.json({ ok: true }));
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
}
function suggestedAction(value: unknown): SuggestedActionKind {
return value === 'open_sync_pr' || value === 'ping_teammate' || value === 'none'
? value
: 'ping_teammate';
}
function hermesJobEventType(value: unknown): HermesJobEventType | null {
return value === 'accepted' ||
value === 'heartbeat' ||
value === 'step_started' ||
value === 'step_output' ||
value === 'needs_confirmation' ||
value === 'step_completed' ||
value === 'aborted' ||
value === 'failed' ||
value === 'completed'
? value
: null;
}
// Mint a LiveKit token for an engineer joining a pod.
app.post('/api/token', async (req, res) => {
const { room, identity, name, githubLogin } = req.body ?? {};
@@ -97,22 +37,9 @@ app.post('/api/token', async (req, res) => {
metadata: JSON.stringify({ githubLogin: githubLogin ?? name }),
});
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
const agents = env.LIVEKIT_AGENT_NAME
? [
new RoomAgentDispatch({
agentName: env.LIVEKIT_AGENT_NAME,
metadata: JSON.stringify({ podId: room }),
}),
]
: undefined;
// Auto-clean the room: close 60s after it empties, drop a participant 20s
// after they disconnect. Applied when LiveKit auto-creates the room.
at.roomConfig = new RoomConfiguration({
name: room,
emptyTimeout: 60,
departureTimeout: 20,
agents,
});
at.roomConfig = new RoomConfiguration({ name: room, emptyTimeout: 60, departureTimeout: 20 });
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
});
@@ -209,306 +136,12 @@ app.post('/api/pods/:id/members', async (req, res) => {
}
});
app.post('/api/pods/:id/voice-test', async (req, res) => {
const podId = req.params.id;
const message =
typeof req.body?.message === 'string' && req.body.message.trim()
? req.body.message.trim()
: 'PodMan voice test. Gemini TTS is playing through LiveKit.';
try {
await speakInRoom(podId, message);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
app.post('/api/pods/:id/live-conversation/start', async (req, res) => {
try {
const identity = typeof req.body?.identity === 'string' ? req.body.identity.trim() : '';
const displayName =
typeof req.body?.displayName === 'string' ? req.body.displayName.trim() : identity;
if (!identity) return res.status(400).json({ error: 'identity is required' });
const pod = await getPod(req.params.id);
if (!pod) return res.status(404).json({ error: 'pod not found' });
res.json(await startLiveConversation({ podId: req.params.id, identity, displayName }));
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
app.post('/api/pods/:id/live-conversation/:sessionId/stop', async (req, res) => {
try {
const session = await stopLiveConversation(req.params.id, req.params.sessionId);
if (!session) return res.status(404).json({ error: 'session not found' });
res.json({ ok: true, session });
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
app.get('/api/pods/:id/live-conversation/status', (req, res) => {
const identity = typeof req.query.identity === 'string' ? req.query.identity.trim() : '';
if (!identity) return res.status(400).json({ error: 'identity is required' });
res.json({ active: activeLiveConversation(req.params.id, identity) });
});
app.get('/api/pods/:id/live-conversation/:sessionId/hermes-job', async (req, res) => {
try {
const job = await getLatestHermesJobForSession(req.params.sessionId);
if (!job || job.podId !== req.params.id) return res.json({ job: null, events: [] });
res.json({ job, events: await listHermesJobEvents(job.id, 12) });
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
app.post('/api/pods/:id/live-conversation/:sessionId/hermes-job/abort', async (req, res) => {
try {
const job = await getActiveHermesJobForSession(req.params.sessionId);
if (!job || job.podId !== req.params.id)
return res.status(404).json({ error: 'active job not found' });
res.json({ job: await abortHermesJob(job.id) });
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
function requireInternalAgent(req: express.Request, res: express.Response): boolean {
const expected = env.INTERNAL_AGENT_TOKEN;
if (!expected) {
res.status(503).json({ error: 'INTERNAL_AGENT_TOKEN is not configured' });
return false;
}
const header = req.header('authorization') ?? '';
const actual = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
if (actual !== expected) {
res.status(401).json({ error: 'unauthorized' });
return false;
}
return true;
}
app.get('/api/internal/pods/:id/live-context', async (req, res) => {
if (!requireInternalAgent(req, res)) return;
try {
const identity = typeof req.query.identity === 'string' ? req.query.identity.trim() : '';
if (!identity) return res.status(400).json({ error: 'identity is required' });
res.json(await getLiveConversationContext(req.params.id, identity));
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
app.post('/api/internal/pods/:id/live-conversation/:sessionId/note', async (req, res) => {
if (!requireInternalAgent(req, res)) return;
try {
const note = typeof req.body?.note === 'string' ? req.body.note : '';
const identity = typeof req.body?.identity === 'string' ? req.body.identity : undefined;
const kind = typeof req.body?.kind === 'string' ? req.body.kind : undefined;
res.status(201).json(
await recordLiveConversationNote({
podId: req.params.id,
sessionId: req.params.sessionId,
identity,
kind,
note,
}),
);
} catch (e) {
res.status(400).json({ error: (e as Error).message });
}
});
app.post('/api/internal/hermes/jobs', async (req, res) => {
if (!requireInternalAgent(req, res)) return;
try {
res.status(202).json(await createHermesJob(req.body ?? {}));
} catch (e) {
res.status(400).json({ error: (e as Error).message });
}
});
app.get('/api/internal/hermes/jobs/:jobId', async (req, res) => {
if (!requireInternalAgent(req, res)) return;
const job = await getHermesJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'job not found' });
res.json(job);
});
app.post('/api/internal/hermes/jobs/:jobId/abort', async (req, res) => {
if (!requireInternalAgent(req, res)) return;
const job = await abortHermesJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'job not found' });
res.json(job);
});
app.get('/api/internal/hermes/jobs/:jobId/events', async (req, res) => {
if (!requireInternalAgent(req, res)) return;
const job = await getHermesJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'job not found' });
res.json(await listHermesJobEvents(req.params.jobId, 100));
});
app.post('/api/internal/hermes/jobs/:jobId/events', async (req, res) => {
if (!requireInternalAgent(req, res)) return;
try {
const { type, message, data } = req.body ?? {};
const eventType = hermesJobEventType(type);
if (!eventType || typeof message !== 'string') {
return res.status(400).json({ error: 'type and message are required' });
}
res.status(201).json(await appendHermesJobEvent(req.params.jobId, eventType, message, data));
} catch (e) {
res.status(400).json({ error: (e as Error).message });
}
});
app.get('/api/internal/hermes/jobs/:jobId/events/stream', async (req, res) => {
if (!requireInternalAgent(req, res)) return;
const job = await getHermesJob(req.params.jobId);
if (!job) return res.status(404).json({ error: 'job not found' });
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
let closed = false;
let lastIds = new Set<string>();
const send = async () => {
if (closed) return;
try {
const events = await listHermesJobEvents(req.params.jobId, 100);
const fresh = events.filter((event) => !lastIds.has(event.id));
lastIds = new Set(events.map((event) => event.id));
for (const event of fresh) {
res.write(`event: job-event\n`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
const current = await getHermesJob(req.params.jobId);
if (current && ['completed', 'failed', 'aborted'].includes(current.status)) {
res.write(`event: done\n`);
res.write(`data: ${JSON.stringify(current)}\n\n`);
closed = true;
res.end();
} else {
res.write(`: keepalive ${Date.now()}\n\n`);
}
} catch (e) {
res.write(`event: error\n`);
res.write(`data: ${JSON.stringify({ error: (e as Error).message })}\n\n`);
}
};
await send();
const interval = setInterval(() => void send(), 1500);
req.on('close', () => {
closed = true;
clearInterval(interval);
});
});
// Per-pod background music (Lyria), generated once and cached. Streams MP3 the
// frontend loops as a pod-wide LiveKit track (replaces the synthesized beat).
app.get('/api/pods/:id/music', async (req, res) => {
try {
const pod = await getPod(req.params.id);
if (!pod) return res.status(404).json({ error: 'pod not found' });
const mp3 = await getPodMusic(pod.id, pod.name);
res.set('Content-Type', 'audio/mpeg');
res.set('Cache-Control', 'public, max-age=86400');
res.send(mp3);
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
app.post('/api/pods/:id/hermes/notify', async (req, res) => {
const podId = req.params.id;
const pod = await getPod(podId);
if (!pod) return res.status(404).json({ error: 'pod not found' });
const body = req.body ?? {};
const message = typeof body.message === 'string' ? body.message.trim() : '';
if (!message) return res.status(400).json({ error: 'message is required' });
const now = new Date().toISOString();
const engineers = stringArray(body.engineers);
const recipients = engineers.length ? engineers : pod.members.slice(0, 2);
const file =
typeof body.file === 'string' && body.file.trim() ? body.file.trim() : 'Hermes signal';
const urgent = body.urgency === 'urgent' || body.severity === 'critical';
const collision: Collision = {
id:
typeof body.collisionId === 'string' && body.collisionId
? body.collisionId
: `col_${Date.now()}`,
podId,
file,
symbol: typeof body.symbol === 'string' && body.symbol ? body.symbol : undefined,
engineers: recipients,
severity: urgent ? 'critical' : 'warn',
githubState: { unpushed: body.unpushed !== false },
detectedAt: now,
};
const intervention: Intervention = {
id:
typeof body.interventionId === 'string' && body.interventionId
? body.interventionId
: `int_${Date.now()}`,
collisionId: collision.id,
podId,
kind: urgent ? 'voice' : 'card',
message,
suggestedAction: {
kind: suggestedAction(body.suggestedAction),
params: {
file,
engineers: recipients,
source: 'local-hermes',
},
},
status: 'pending',
createdAt: now,
};
const voiceLine =
urgent && body.speak !== false
? typeof body.voiceLine === 'string' && body.voiceLine.trim()
? body.voiceLine.trim()
: message
: undefined;
try {
if (body.force !== true && (await hasRecentInterventionForCollision(collision))) {
return res.status(202).json({ ok: true, collision, intervention, livekit: 'suppressed' });
}
await recordCollision(collision);
await recordIntervention(intervention);
if (body.dryRun === true) {
return res.status(202).json({ ok: true, collision, intervention, livekit: 'dry-run' });
}
await notifyHermesInterventionInRoom(podId, collision, intervention, voiceLine);
res.status(202).json({ ok: true, collision, intervention, livekit: 'notified' });
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
app.delete('/api/pods/:id/members/:name', async (req, res) => {
const pod = await removeMember(req.params.id, req.params.name);
if (!pod) return res.status(404).json({ error: 'pod not found' });
res.json(pod);
});
app.get('/api/pods/:id/members/:name/history', async (req, res) => {
try {
const hours = Number(req.query.hours ?? 24) || 24;
const limit = Number(req.query.limit ?? 80) || 80;
res.json(await getMemberWorkHistory(req.params.id, req.params.name, { hours, limit }));
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
// --- Continual-learning graph (team_model view) ---
app.get('/api/pods/:id/graph', async (req, res) => {
try {
@@ -526,58 +159,7 @@ app.get('/api/pods/:id/graph/reach/:node', async (req, res) => {
}
});
app.get('/api/pods/:id/activity', async (req, res) => {
try {
const limit = Math.min(Number(req.query.limit ?? 80) || 80, 200);
res.json(await listPodActivity(req.params.id, limit));
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
});
app.get('/api/pods/:id/activity/stream', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
let closed = false;
let lastPayload = '';
const send = async () => {
if (closed) return;
try {
const events = await listPodActivity(req.params.id, 80);
const payload = JSON.stringify(events);
if (payload !== lastPayload) {
lastPayload = payload;
res.write(`event: snapshot\n`);
res.write(`data: ${payload}\n\n`);
} else {
res.write(`: keepalive ${Date.now()}\n\n`);
}
} catch (e) {
res.write(`event: error\n`);
res.write(`data: ${JSON.stringify({ error: (e as Error).message })}\n\n`);
}
};
await send();
const interval = setInterval(() => void send(), 1500);
req.on('close', () => {
closed = true;
clearInterval(interval);
});
});
const http = createServer(app);
const sockets = new Set<Socket>();
http.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
// ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it.
const wss = new WebSocketServer({ server: http, path: '/api/events' });
@@ -595,11 +177,7 @@ http.listen(env.PORT, '0.0.0.0', () => {
console.log(`[server] :${env.PORT}`);
initMemory()
.then(() => seedDefaultPods())
.catch((e) => {
// MongoDB is mandatory — do not run a half-dead API against a broken DB.
console.error(`[memory] init FAILED, exiting: ${(e as Error).message}`);
process.exit(1);
});
.catch((e) => console.warn(`[memory] init failed: ${(e as Error).message}`));
});
let shuttingDown = false;
@@ -609,11 +187,7 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
console.log(`[server] ${signal} received; shutting down`);
for (const client of clients) client.close();
wss.close();
for (const socket of sockets) socket.destroy();
await Promise.race([
new Promise<void>((resolve) => http.close(() => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
]);
await new Promise<void>((resolve) => http.close(() => resolve()));
await closeMemory().catch((e) => console.warn(`[memory] close failed: ${(e as Error).message}`));
process.exit(0);
}
+28 -169
View File
@@ -3,46 +3,19 @@ import {
AudioFrame,
AudioSource,
LocalAudioTrack,
Room,
TrackPublishOptions,
TrackSource,
type LocalParticipant,
type Room,
} from '@livekit/rtc-node';
import { GoogleGenAI, Modality, type LiveServerMessage, type Session } from '@google/genai';
import { AccessToken } from 'livekit-server-sdk';
import { DATA_TOPIC, type DataMessage } from '@podman/shared';
import { env } from '../env.js';
const SAMPLE_RATE = 24_000;
const CHANNELS = 1;
const FRAME_SAMPLES = SAMPLE_RATE / 10;
const SUBSCRIBER_READY_MS = 1_500;
const AUDIO_PREROLL_MS = 800;
const AUDIO_TAIL_MS = 1_500;
const AUDIO_HOLD_MS = 5_000;
const VOICE_QUEUE_MS = 60_000;
const VOICE_TRACK_PREFIX = 'podman-hermes-voice';
const encoder = new TextEncoder();
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
let voiceQueue: Promise<void> = Promise.resolve();
export interface SpeakOptions {
priority?: 'normal' | 'critical';
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function ttsPrompt(message: string): string {
return [
'Speak this PodMan coordination alert as a calm, natural engineering teammate.',
'Use warm human pacing, clear pronunciation, and a brief pause after the first sentence.',
'Do not add extra words, labels, markdown, or sound effects.',
'',
message,
].join('\n');
}
async function publishVoiceCue(room: Room, message: string): Promise<void> {
const cue: DataMessage = { type: 'VOICE_CUE', text: message };
@@ -52,24 +25,12 @@ async function publishVoiceCue(room: Room, message: string): Promise<void> {
});
}
async function unpublishVoiceTracks(localParticipant: LocalParticipant): Promise<void> {
const publications = Array.from(localParticipant.trackPublications.values()).filter(
(publication) => publication.name?.startsWith(VOICE_TRACK_PREFIX) && publication.sid,
);
for (const publication of publications) {
await localParticipant.unpublishTrack(publication.sid!, true).catch((err) => {
console.warn(`[voice] stale track cleanup failed: ${(err as Error).message}`);
});
}
}
function audioFrameFromBase64(data: string, mimeType?: string): AudioFrame | null {
if (mimeType && !mimeType.includes('audio')) return null;
const buf = Buffer.from(data, 'base64');
if (buf.byteLength < 2) return null;
const bytes = buf.byteLength % 2 === 0 ? buf : buf.subarray(0, buf.byteLength - 1);
const samples = new Int16Array(bytes.byteLength / 2);
for (let i = 0; i < samples.length; i += 1) samples[i] = bytes.readInt16LE(i * 2);
const samples = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
return new AudioFrame(samples, SAMPLE_RATE, CHANNELS, samples.length / CHANNELS);
}
@@ -92,7 +53,7 @@ function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] {
const samples = frame.data;
const frames: AudioFrame[] = [];
for (let offset = 0; offset < samples.length; offset += FRAME_SAMPLES) {
const chunk = samples.slice(offset, Math.min(offset + FRAME_SAMPLES, samples.length));
const chunk = samples.subarray(offset, Math.min(offset + FRAME_SAMPLES, samples.length));
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
}
return frames;
@@ -101,11 +62,10 @@ function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] {
async function generateTtsFrames(message: string): Promise<AudioFrame[]> {
const res = await ai.models.generateContent({
model: env.GEMINI_LIVE_MODEL,
contents: [{ parts: [{ text: ttsPrompt(message) }] }],
contents: [{ parts: [{ text: message }] }],
config: {
responseModalities: [Modality.AUDIO],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: env.GEMINI_TTS_VOICE } } },
temperature: 0.8,
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
},
});
const parts = res.candidates?.[0]?.content?.parts ?? [];
@@ -114,60 +74,29 @@ async function generateTtsFrames(message: string): Promise<AudioFrame[]> {
);
}
function fallbackVoiceLine(message: string): string {
const clean = message.replace(/^heads up[.!]?\s*/i, '').trim();
if (clean && clean !== message) return clean;
return 'PodMan noticed a critical conflict. Please sync with the team before pushing.';
}
async function speakWithTts(source: AudioSource, message: string): Promise<number> {
let frames: AudioFrame[];
try {
frames = await generateTtsFrames(message);
} catch (err) {
const fallback = fallbackVoiceLine(message);
console.warn(`[voice] Gemini TTS retrying with fallback line: ${(err as Error).message}`);
frames = await generateTtsFrames(fallback);
}
if (frames.length === 0) throw new Error('Gemini TTS returned no audio frames');
const durationMs = frames.reduce(
(sum, frame) => sum + (frame.samplesPerChannel / frame.sampleRate) * 1000,
0,
);
console.log(
`[voice] publishing Gemini TTS audio frames=${frames.length} durationMs=${Math.round(durationMs)}`,
);
for (const frame of frames) {
async function speakWithTts(source: AudioSource, message: string): Promise<void> {
for (const frame of await generateTtsFrames(message)) {
await source.captureFrame(frame);
}
return durationMs;
}
async function speakWithLive(source: AudioSource, message: string): Promise<number> {
let durationMs = 0;
async function speakWithLive(source: AudioSource, message: string): Promise<void> {
let done: () => void = () => {};
const donePromise = new Promise<void>((resolve) => {
done = resolve;
});
const session: Session = await ai.live.connect({
model: env.GEMINI_LIVE_MODEL,
config: {
responseModalities: [Modality.AUDIO],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: env.GEMINI_TTS_VOICE } } },
temperature: 0.8,
},
config: { responseModalities: [Modality.AUDIO] },
callbacks: {
onmessage: (event) => {
void (async () => {
for (const frame of audioFrames(event)) {
durationMs += (frame.samplesPerChannel / frame.sampleRate) * 1000;
await source.captureFrame(frame);
}
for (const frame of audioFrames(event)) await source.captureFrame(frame);
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete) done();
})();
},
onerror: (event) => {
console.warn(`[voice] Gemini voice error: ${event.message}`);
console.warn(`[voice] Gemini Live error: ${event.message}`);
done();
},
onclose: done,
@@ -175,106 +104,36 @@ async function speakWithLive(source: AudioSource, message: string): Promise<numb
});
session.sendClientContent({
turns: [{ role: 'user', parts: [{ text: ttsPrompt(message) }] }],
turns: [{ role: 'user', parts: [{ text: message }] }],
turnComplete: true,
});
await Promise.race([donePromise, new Promise((resolve) => setTimeout(resolve, 15_000))]);
session.close();
return durationMs;
}
async function waitForVoicePlayout(source: AudioSource): Promise<void> {
if (source.queuedDuration <= 0) return;
const queuedMs = Math.round(source.queuedDuration);
console.log(`[voice] waiting for queued audio playout queuedMs=${queuedMs}`);
await Promise.race([
source.waitForPlayout(),
new Promise((resolve) => setTimeout(resolve, VOICE_QUEUE_MS + 2_000)),
]);
console.log('[voice] queued audio playout complete');
}
async function captureSilence(source: AudioSource, durationMs: number): Promise<void> {
const totalSamples = Math.max(1, Math.round((SAMPLE_RATE * durationMs) / 1000));
for (let offset = 0; offset < totalSamples; offset += FRAME_SAMPLES) {
const samples = Math.min(FRAME_SAMPLES, totalSamples - offset);
await source.captureFrame(new AudioFrame(new Int16Array(samples), SAMPLE_RATE, CHANNELS, samples));
}
}
async function speakAudio(room: Room, message: string): Promise<void> {
const localParticipant = room.localParticipant;
if (!localParticipant) return;
await unpublishVoiceTracks(localParticipant);
const source = new AudioSource(SAMPLE_RATE, CHANNELS, VOICE_QUEUE_MS);
const track = LocalAudioTrack.createAudioTrack(`${VOICE_TRACK_PREFIX}-${Date.now()}`, source);
const options = new TrackPublishOptions();
options.source = TrackSource.SOURCE_MICROPHONE;
let publicationSid: string | undefined;
try {
const publication = await localParticipant.publishTrack(track, options);
publicationSid = publication.sid;
await delay(SUBSCRIBER_READY_MS);
await captureSilence(source, AUDIO_PREROLL_MS);
const audioDurationMs = env.GEMINI_LIVE_MODEL.includes('tts')
? await speakWithTts(source, message)
: await speakWithLive(source, message);
await captureSilence(source, AUDIO_TAIL_MS);
await waitForVoicePlayout(source);
const manualHoldMs = Math.ceil(audioDurationMs + AUDIO_TAIL_MS + AUDIO_HOLD_MS);
console.log(`[voice] holding track for subscriber playout holdMs=${manualHoldMs}`);
await delay(manualHoldMs);
} catch (err) {
console.warn(`[voice] Gemini voice publish failed: ${(err as Error).message}`);
} finally {
if (publicationSid) {
await localParticipant.unpublishTrack(publicationSid, true).catch((err) => {
console.warn(`[voice] track unpublish failed: ${(err as Error).message}`);
});
}
await source.close().catch(() => {});
}
}
/**
* Speak a message into the LiveKit room using Gemini audio. A data-channel
* Speak a message into the LiveKit room using Gemini Live audio. A data-channel
* VOICE_CUE is sent first so clients still get the cue if audio generation or
* publishing fails.
*/
export async function speak(room: Room, message: string, options: SpeakOptions = {}): Promise<void> {
export async function speak(room: Room, message: string): Promise<void> {
await publishVoiceCue(room, message);
if (options.priority === 'critical') {
await speakAudio(room, message);
return;
}
voiceQueue = voiceQueue.catch(() => {}).then(() => speakAudio(room, message));
await voiceQueue;
}
if (!room.localParticipant) return;
const source = new AudioSource(SAMPLE_RATE, CHANNELS);
const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source);
const options = new TrackPublishOptions();
options.source = TrackSource.SOURCE_MICROPHONE;
export async function speakInRoom(
roomName: string,
message: string,
options: SpeakOptions = {},
): Promise<void> {
const room = new Room();
try {
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
identity: `podman-voice-${Date.now()}`,
name: 'PodMan voice',
ttl: '5m',
});
at.addGrant({
roomJoin: true,
room: roomName,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
await room.connect(env.LIVEKIT_URL, await at.toJwt());
await speak(room, message, options);
} finally {
await room.disconnect().catch(() => {});
const publication = await room.localParticipant.publishTrack(track, options);
if (env.GEMINI_LIVE_MODEL.includes('tts')) await speakWithTts(source, message);
else await speakWithLive(source, message);
if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true);
await source.close();
} catch (err) {
console.warn(`[voice] Gemini Live publish failed: ${(err as Error).message}`);
await source.close().catch(() => {});
}
}
-94
View File
@@ -1,94 +0,0 @@
import { Buffer } from 'node:buffer';
import { getDb } from '../memory/db.js';
import { env } from '../env.js';
// Lyria 3 is reached via the Gemini "interactions" endpoint (not :predict, which
// is the Vertex path). The clip model returns a ~30s base64 MP3.
const MUSIC_MODEL = process.env.GEMINI_MUSIC_MODEL ?? 'lyria-3-clip-preview';
const INTERACTIONS_URL = 'https://generativelanguage.googleapis.com/v1beta/interactions';
interface PodMusicDoc {
podId: string;
name: string; // pod name the vocal was generated for
model: string;
mp3Base64: string;
createdAt: string;
}
interface InteractionContent {
type?: string;
data?: string;
text?: string;
}
interface InteractionResponse {
steps?: Array<{ content?: InteractionContent[] }>;
output_audio?: { data?: string };
}
/**
* Background "hold music" prompt: opens with the pod name sung once, then a calm
* instrumental bed that loops. Keep it unobtrusive — this is fill, not a song.
*/
function musicPrompt(podName: string): string {
return [
'Calm soothing instrumental background hold music for a tech app, like gentle on-hold lobby music.',
`It opens in the first three seconds with a soft gentle voice clearly saying the words "${podName}" one time,`,
'and after that opening it is purely instrumental with warm electric piano, gentle synth pads and a soft relaxed beat.',
'Unobtrusive, pleasant and steady with no climax, designed to loop seamlessly as quiet background fill.',
'No other lyrics or vocals after the opening.',
].join(' ');
}
function extractAudioBase64(data: InteractionResponse): string | null {
for (const step of data.steps ?? []) {
for (const c of step.content ?? []) {
if (c.type === 'audio' && c.data) return c.data;
}
}
return data.output_audio?.data ?? null;
}
async function generate(podName: string): Promise<Buffer> {
const res = await fetch(INTERACTIONS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': env.GEMINI_API_KEY },
body: JSON.stringify({ model: MUSIC_MODEL, input: musicPrompt(podName) }),
});
if (!res.ok) {
throw new Error(`Lyria ${res.status}: ${(await res.text()).slice(0, 300)}`);
}
const data = (await res.json()) as InteractionResponse;
const b64 = extractAudioBase64(data);
if (!b64) throw new Error('Lyria returned no audio');
return Buffer.from(b64, 'base64');
}
/**
* The pod's background-music MP3, generated by Lyria on first request and cached
* in the `pod_music` collection. Regenerated if the pod name changes so the sung
* name stays correct. Lyria generation is slow (~20s); the cache makes every
* call after the first instant.
*/
export async function getPodMusic(podId: string, podName: string): Promise<Buffer> {
const db = await getDb();
const col = db.collection<PodMusicDoc>('pod_music');
const cached = await col.findOne({ podId });
if (cached && cached.name === podName && cached.model === MUSIC_MODEL && cached.mp3Base64) {
return Buffer.from(cached.mp3Base64, 'base64');
}
const mp3 = await generate(podName);
await col.updateOne(
{ podId },
{
$set: {
podId,
name: podName,
model: MUSIC_MODEL,
mp3Base64: mp3.toString('base64'),
createdAt: new Date().toISOString(),
},
},
{ upsert: true },
);
return mp3;
}
+9 -55
View File
@@ -4,8 +4,7 @@
> strategy, public interfaces, risks, sponsor story, and next build order.
>
> If this file conflicts with `README.md`, `docs/idea.md`, `docs/livekit.md`,
> `docs/gemini.md`, `docs/mongodb.md`, `docs/continual-learning/`,
> `docs/graph-discovery/`, `docs/agent-learning/`, `docs/digitalocean.md`,
> `docs/gemini.md`, `docs/mongodb.md`, `docs/digitalocean.md`,
> `docs/demo-setup.md`, or `docs/superpowers/specs/*`, follow this file and
> treat the older docs as reference material to reconcile later.
@@ -221,8 +220,7 @@ From the remote plan snapshot and health check on `2026-06-27`:
- Real Gemini inference from a live shared IDE frame using the stage key/model.
- Real data-channel intervention card rendering in the active frontend.
- Hermes message routing to teammates.
- Voice escalation heard by participants through LiveKit, including
duration-based track holding so longer Gemini TTS announcements finish.
- Voice escalation heard by participants through LiveKit.
- A meaningful real sync PR flow with correct GitHub scopes and artifact.
- Atlas Vector Search / Voyage recall path.
- DigitalOcean static site + API service + LiveKit agent worker all running
@@ -294,8 +292,6 @@ scripts together.
- `POST /api/sync-pr`
- `POST /api/outcome`
- `GET /api/memory/stats`
- `GET /api/pods/:id/graph`
- `GET /api/pods/:id/graph/reach/:nodeId`
- `GET /api/pods`
- `POST /api/pods`
- `GET /api/pods/:id`
@@ -323,7 +319,6 @@ LIVEKIT_API_SECRET=
GEMINI_API_KEY=
GEMINI_VISION_MODEL=
GEMINI_LIVE_MODEL=
GEMINI_TTS_VOICE=
GITHUB_TOKEN=
GITHUB_REPO=karti-ai/podman
@@ -366,8 +361,9 @@ Keep all non-`VITE_` secrets server-side.
- Use low media resolution for ambient screen watching; reserve higher
resolution for debugging or targeted inspection.
- Never expose `GEMINI_API_KEY` to the browser.
- Use card + Hermes message first. For urgent stage audio, default to Gemini TTS
published through LiveKit; keep browser TTS only as an explicit fallback flag.
- Gemini Live API is still a risk for the first demo path. Use card + Hermes
message first; add browser TTS or pre-generated voice fallback before relying
on Gemini Live for stage audio.
- Keep model IDs in env so preview/availability changes do not require code
changes.
@@ -474,8 +470,8 @@ artifact.
- Escalate to voice only when urgent.
10. **Action artifact**
- If demo uses same-file collision, click the card to open a real sync PR
artifact or visible GitHub artifact.
- If demo uses same-file collision, click the card to open a real draft sync
PR or visible GitHub artifact.
- If demo uses research recommendation, show the accepted recommendation and
memory outcome instead.
@@ -484,43 +480,11 @@ artifact.
recorded backup.
- Keep backup video on a separate device.
### P0.5 - RSI negative-feedback activation (continual-learning)
The continual-learning loop records outcomes but never feeds the negative
signal back. Live Atlas (2026-06-28): `outcomes` = 22 accepted / 85 dismissed,
yet `wasRealCollision` is `true` in 107/107 (hardcoded), so the suppression
gate is dead and dismissals are unused. These two rungs activate the loop with
no schema change. Owner: RSI track. Independent of the MongoDB-cleanup handoff.
1. **Step 1 - suppress on prior dismissal alone**
- `backend/src/memory/policy.ts` `shouldIntervene`: remove the dead
`&& !priorOutcome.wasRealCollision` term so a prior `accepted === false`
suppresses the next identical-signature nudge.
- Spec: `docs/continual-learning/policy.md:41` (dismissed = negative signal),
`spec.md:163` (dismissals adapt suppression).
- Caveat: recall is single-shot most-recent (`memory/vectors.ts`), so this is
"last-outcome-wins" until Step 3 (derive `wasRealCollision`) lands.
2. **Step 2 - gate the recall severity escalation**
- `backend/src/agent/podman.ts` `handle`: only force `severity = 'critical'`
when the recalled prior was an accepted *real* collision, instead of
blanket-escalating every recall. Surfaces the learned routing in
`preferredAction`; stops dismissed/false priors over-escalating to voice.
- Spec: `docs/continual-learning/policy.md:62-63` (prefer prior accepted
kind), `plan.md:66` (second similar event behaves differently).
Follow-ups (separate rungs, not in this change): Step 3 derive
`wasRealCollision` from git overlap; Step 4-5 `strategy_versions` +
Gemini-proposed `LearningProposal` slice; seed a clean demo pod with a repeated
dismissed signature (the historic 85 dismissals are orphaned — `collisionId`
resolves to no collision — so they cannot drive the demo verifier).
### P1 - polish the money moment
- Add visible live inference captions in the PWA.
- Add a small memory stats panel backed by `/api/memory/stats`.
- Keep browser-side TTS as an explicit demo fallback only; Gemini TTS over
LiveKit is the default urgent-voice path.
- Add browser-side TTS or pre-generated voice fallback for urgent interventions.
- Add Hermes notification bridge once the target channel is chosen.
- Improve research cards with compatibility, install effort, docs quality, repo
health, and security/trust signals.
@@ -540,7 +504,7 @@ resolves to no collision — so they cannot drive the demo verifier).
- Full auth/accounts.
- Slack/Linear/Jira integrations unless Hermes requires one immediately.
- Complex dashboards.
- Live voice polish beyond the Gemini TTS urgent-alert path.
- Server-published audio if browser/pre-generated voice proves escalation.
- Vector Search if exact Mongo recall demonstrates the learning beat.
---
@@ -641,16 +605,6 @@ MongoDB is the learning proof:
- Voyage + Atlas Vector Search is the stronger sponsor-grade version after exact
recall works.
Canonical docs:
- [`docs/continual-learning/`](continual-learning/) owns team memory and
outcome-backed recall.
- [`docs/graph-discovery/`](graph-discovery/) owns graph materialization,
hygiene, and `$graphLookup` reachability.
- [`docs/agent-learning/`](agent-learning/) owns the planned narrow
strategy-version layer. Full autonomous promotion is not implemented unless
backed by records.
### DigitalOcean
DigitalOcean earns its place when:
-43
View File
@@ -1,43 +0,0 @@
# Agent Learning
Status: planned / narrow v1
Agent learning owns how PodMan can improve its own prompts, detector rules,
policies, verifier choices, and routing strategies. This is deliberately
narrower than team memory: it is a versioned strategy layer, not autonomous code
rewriting.
The constraints in [`../../CLAUDE.md`](../../CLAUDE.md) still govern this track:
one visible self-improving loop, demo stability, no broad platform rewrite, no
dashboard-first product, and no overclaiming.
## Files
| File | Purpose |
| --- | --- |
| [`spec.md`](spec.md) | Read-only data contract for runs, traces, strategies, and proposals |
| [`policy.md`](policy.md) | Promotion, rejection, evidence, and safety rules |
| [`prompt.md`](prompt.md) | Evaluator prompt for narrow strategy improvements |
| [`plan.md`](plan.md) | v1 implementation order if this track is added |
## What Is Implemented Now
- Shared TypeScript contracts for `AgentRun`, `AgentTraceEvent`,
`StrategyVersion`, and `LearningProposal`.
- Exact signature recall and accepted/dismissed outcomes that can later feed
strategy decisions.
- Documentation of future collections and indexes.
## What Is Intentionally Cut
- Full autonomous strategy promotion.
- Autonomous code rewriting.
- Multi-agent strategy debates.
- Claims that PodMan trains or rewrites itself from live usage today.
## Demo Proof Path
Observe screen/git state -> detect collision -> send intervention -> accept or
dismiss outcome -> recall similar event -> show changed graph or changed
behavior. In the current demo, this proof is team-memory learning; agent
strategy promotion remains planned unless records are added.
-88
View File
@@ -1,88 +0,0 @@
# Agent Learning Plan
Status: planned / narrow v1
Goal: ship a visible recursive self-improvement loop without overbuilding
## Must-Have
1. Store agent runs.
2. Store trace summaries.
3. Store active and candidate strategy versions.
4. Attach verifier or outcome evidence.
5. Show one strategy improvement in the demo narrative.
## Build Order
### R1: Trace the run
Write one `agent_runs` record for an important coordination decision and append
trace events for:
- observation
- recall
- prediction
- intervention
- outcome
- adaptation
### R2: Version the strategy
Create an active strategy version for one of:
- collision detector threshold
- intervention routing
- graph discovery filter
- card wording prompt
### R3: Score the outcome
Use the simplest verifier:
- accepted real collision = useful
- dismissed = noisy
- no response after cooldown = uncertain
### R4: Propose a narrow change
Examples:
- "For this exact signature, prefer sync PR card."
- "For dismissed docs-only overlaps, suppress voice escalation."
- "For repeated auth.ts collisions, raise severity."
### R5: Promote or reject
Promote only when evidence is strong enough. Otherwise keep the candidate as
rejected or open.
## Demo Path
1. Show baseline strategy.
2. Trigger a collision.
3. Accept or dismiss the intervention.
4. Store outcome.
5. Show a candidate strategy update.
6. Promote it.
7. Trigger a similar event.
8. Show changed behavior.
## Nice-to-Have
- Strategy comparison panel.
- Model-generated prompt patch with verifier.
- Vector recall over strategy history.
- Rollback UI.
## Cut
- Full autonomous code rewriting.
- Multi-agent strategy debates.
- Long-term benchmark suite.
- Training a model.
## Acceptance Criteria
- The demo can point to a MongoDB record proving the agent changed behavior.
- The changed behavior is visible.
- The strategy has a parent and evidence.
- Rejected or failed changes are not deleted.
-83
View File
@@ -1,83 +0,0 @@
# Agent Learning Policy
Status: planned / narrow v1
Scope: guardrails for recursive self-improvement
## Prime Rule
PodMan may improve its agent behavior only when the improvement is narrow,
evidence-backed, versioned, and reversible.
## Allowed Learning
PodMan may learn:
- Which prompt version produces clearer interventions.
- Which detector threshold reduces false positives.
- Which routing channel gets accepted without being intrusive.
- Which verifier best predicts user acceptance.
- Which graph-discovery rule produces cleaner risk paths.
## Disallowed Learning
PodMan must not:
- Promote a strategy because the model says it is better.
- Rewrite broad system behavior from one example.
- Hide failures, dismissals, or rejected candidates.
- Learn from raw screenshots, secrets, or private terminal content.
- Turn voice into the default route.
- Create irreversible actions without human approval.
## Promotion Rules
A candidate strategy can become active only when all are true:
1. It has a parent strategy version.
2. It describes one concrete behavior change.
3. It has a verifier plan.
4. It has evidence from a run, outcome, or test.
5. It improves or fixes the target metric.
6. It does not increase user interruption without payoff.
## Rejection Rules
Reject and retain the candidate when:
- The verifier regresses.
- The change is too broad.
- The evidence is missing.
- The candidate conflicts with privacy rules.
- The candidate makes the demo less stable.
## Evidence Strength
| Evidence | Strength | Use |
| --- | --- | --- |
| Model opinion | Weak | Proposal only |
| Trace observation | Medium | Candidate rationale |
| Human accepted outcome | Strong | Promotion candidate |
| Human dismissed outcome | Strong | Suppression or rejection |
| Automated verifier | Strong | Promotion or rejection |
| Repeated accepted exact signature | Strong | Policy confidence increase |
## Versioning Rules
- Strategy versions are immutable after promotion or rejection.
- There is one active version per `podId + kind`.
- A rollback activates the previous version; it does not edit history.
- Parent-child lineage must be preserved.
## Safety Rules
- Store summaries, not raw sensitive content.
- Prefer deterministic checks over model judgment.
- Use exact MongoDB recall before vector recall.
- Ask for approval before changing code or data with external effects.
- Treat hackathon demo stability as a hard constraint.
## Demo Honesty
Seeded strategy versions are acceptable when labeled as demo-backed. Do not claim
a strategy was learned live unless a run and outcome actually created the
promotion evidence.
-74
View File
@@ -1,74 +0,0 @@
# Agent Learning Prompt
Use this prompt for an agent responsible for improving PodMan's own behavior.
## Prompt
You are PodMan's agent-learning evaluator.
Your job is to inspect a completed agent run, identify one narrow improvement,
define how to verify it, and decide whether to propose, promote, or reject a
strategy change.
You must not claim improvement without evidence. You must not propose broad
rewrites. Keep every change small, reversible, and tied to a run or outcome.
## Inputs
- Current active strategy version.
- Agent run summary.
- Trace events.
- Intervention outcome.
- Verifier result.
- Recent false positives or accepted events.
- Current demo constraints.
## Procedure
1. Identify the target behavior.
2. Identify the failure or success evidence.
3. Decide whether a strategy change is warranted.
4. Propose one narrow change.
5. Define the verifier.
6. Decide status: no change, candidate, promote, reject.
7. Write a short explanation suitable for the Team memory activity stream.
## Output Format
```text
Target
- Strategy kind:
- Active version:
- Behavior under review:
Evidence
- Run:
- Outcome:
- Verifier:
- Confidence:
Decision
- Status:
- Proposed change:
- Why this is narrow:
- Risk:
Verifier
- Metric:
- Passing condition:
- Failing condition:
Memory Write
- Collection:
- Record summary:
- Graph/activity summary:
```
## Hard Rules
- Exact outcomes beat model opinion.
- Rejected candidates stay in memory.
- No raw screenshots or secrets.
- No broad policy change from one weak signal.
- No voice-first behavior.
-198
View File
@@ -1,198 +0,0 @@
# Agent Learning Spec
Status: planned / narrow v1
Scope: how PodMan agents improve their own prompts, policies, detectors, and routing behavior
Owner: agent learning / recursive self-improvement
## Purpose
Agent learning is the recursive self-improvement layer. It is not the same as
team memory. Team memory learns about engineers and work. Agent learning learns
which agent strategies produce better outcomes.
The demo claim:
1. PodMan tries a coordination strategy.
2. The run is traced in MongoDB.
3. A verifier or human outcome scores it.
4. Gemini or another agent proposes a narrow strategy change.
5. The new strategy is versioned.
6. A later run uses the improved strategy and shows a better result.
## What Is Implemented Now
- Shared TypeScript record shapes exist for the core objects below.
- Exact signature recall and accepted/dismissed outcomes exist in the team
memory loop.
- No write path currently promotes autonomous strategy changes.
## What Is Intentionally Cut
- Autonomous code rewriting.
- Full autonomous strategy promotion.
- Multi-agent strategy debates.
- Claims that model self-evaluation alone can promote a strategy.
## Core Objects
### Agent run
One attempt to execute a goal.
```text
agent_runs
runId
podId
goal
trigger
strategyVersionId
status
startedAt
completedAt
score
verifierSummary
inputRefs
outputRefs
```
Allowed `status` values:
```text
running, succeeded, failed, improved, regressed, abandoned
```
### Trace event
Append-only event log for a run.
```text
agent_trace_events
runId
podId
step
phase
eventType
inputSummary
outputSummary
toolName
error
metrics
createdAt
```
### Strategy version
Versioned prompt, detector rule, policy, verifier, or routing strategy.
```text
strategy_versions
strategyVersionId
podId
kind
name
parentVersionId
status
summary
promptText
policy
verifier
metrics
createdAt
promotedAt
```
Allowed `kind` values:
```text
prompt, policy, detector, verifier, routing
```
Allowed `status` values:
```text
candidate, active, retired, rejected
```
### Learning proposal
A candidate change before promotion.
```text
learning_proposals
proposalId
podId
sourceRunId
targetKind
parentVersionId
proposedChange
rationale
verifierPlan
status
createdAt
resolvedAt
```
Allowed `status` values:
```text
open, accepted, rejected, superseded
```
## MongoDB Indexes
| Collection | Index | Purpose |
| --- | --- | --- |
| `agent_runs` | `{ podId: 1, startedAt: -1 }` | Recent run history |
| `agent_runs` | `{ podId: 1, strategyVersionId: 1 }` | Compare strategy performance |
| `agent_trace_events` | `{ runId: 1, step: 1 }` | Reconstruct run |
| `strategy_versions` | `{ podId: 1, kind: 1, status: 1 }` | Find active strategy |
| `strategy_versions` | `{ podId: 1, createdAt: -1 }` | Version history |
| `learning_proposals` | `{ podId: 1, status: 1 }` | Open candidate changes |
## Learning Loop
```text
observe run -> score run -> propose change -> test candidate -> promote or reject
```
Agent learning must always connect these records:
```text
agent_run -> trace_events -> verifier result -> learning_proposal -> strategy_version
```
## Verifier Contract
Every promoted strategy needs a verifier signal.
Allowed verifier types:
- Human accepted or dismissed outcome.
- Test pass or fail result.
- Reduced false positive rate.
- Reduced intervention count with same or better accepted outcomes.
- Faster successful run.
- Better graph discovery precision.
- Explicit demo operator approval.
Self-evaluation alone is not enough to promote a strategy.
## Relationship to Team Graph
Agent learning can appear in the Team memory graph as activity and loop status,
but it should not clutter the main risk graph by default.
Graph discovery may show:
- `agent_run` activity in the stream.
- `strategy_versions` count in the learning loop.
- A selected-node detail saying a policy changed because a prior outcome was
dismissed or accepted.
## Acceptance Criteria
- Every strategy change has a parent.
- Every promoted strategy cites evidence.
- Rejected strategies are retained with a reason.
- Agent traces are append-only.
- The system can answer: "What changed, why, and did it help?"
-37
View File
@@ -1,37 +0,0 @@
# Continual Learning
Status: demo-backed / active
PodMan's continual-learning track owns team memory: what the system learns about
files, collisions, interventions, outcomes, and future routing for a pod.
## Files
| File | Purpose |
| --- | --- |
| [`spec.md`](spec.md) | Data model and observe/store/predict/outcome/adapt loop |
| [`policy.md`](policy.md) | What PodMan may and may not remember |
| [`prompt.md`](prompt.md) | Memory-agent prompt for outcome-backed learning |
| [`plan.md`](plan.md) | Demo build order and acceptance criteria |
## What Is Implemented Now
- MongoDB-backed `observations`, `collisions`, `interventions`, `outcomes`,
`engineer_states`, and `team_model` records.
- Exact signature recall for prior accepted and dismissed outcomes.
- Outcome writes through `POST /api/outcome`.
- Team memory graph edges from accepted real outcomes.
- No raw screenshots or recordings are stored.
## What Is Intentionally Cut
- Autonomous model training.
- Broad cross-pod generalization.
- Raw screen capture retention.
- Vector recall as a dependency for the demo proof.
## Demo Proof Path
Observe screen/git state -> detect collision -> send intervention -> accept or
dismiss outcome -> recall similar event -> show changed graph or changed
behavior.
-68
View File
@@ -1,68 +0,0 @@
# Continual Learning Plan
Status: demo-backed / active
Goal: prove PodMan learns from outcomes in the hackathon demo
## Must-Have Demo Loop
1. Observe two engineers touching the same file.
2. Store the observation and git state in MongoDB.
3. Predict a collision.
4. Send a card or Hermes message.
5. Record accept or dismiss outcome.
6. Adapt `team_model`.
7. Show the learned graph edge or changed future behavior.
## Build Order
### R1: Make exact recall reliable
- Normalize file paths.
- Build stable memory signatures.
- Look up prior accepted and dismissed outcomes.
- Prefer exact recall over vector recall.
### R2: Make outcomes update memory
- Accepted real collision creates or strengthens ownership.
- Accepted real collision creates `learned_from`.
- Dismissed outcome lowers confidence or suppresses route.
### R3: Expose loop data to the graph
- Add optional loop snapshot.
- Add optional activity stream.
- Keep existing `PodGraph` fields stable.
### R4: Show the observatory
- Render observe/store/predict/outcome/adapt.
- Show recent activity.
- Make selected-node detail explain why memory changed.
### R5: Prepare a clean demo chain
- Ensure one collision -> intervention -> accepted outcome exists.
- Ensure repeated signature recalls prior memory.
- Verify graph shows learned ownership.
## Nice-to-Have
- Atlas Vector Search over memory summaries.
- Confidence scoring per ownership edge.
- Per-file memory timeline.
- Strategy promotion tied to outcomes.
## Cut
- Raw screenshot storage.
- Full autonomous training.
- Broad dashboard metrics.
- Multi-pod learning generalization.
## Acceptance Criteria
- A judge can see what changed in memory.
- The second similar event behaves differently.
- Exact MongoDB records prove the loop.
- The graph remains legible with real data.
-96
View File
@@ -1,96 +0,0 @@
# Continual Learning Policy
Status: demo-backed / active
Scope: what PodMan may learn about a team
## Prime Rule
PodMan learns coordination patterns, not personal surveillance profiles.
## Allowed Memory
PodMan may store:
- File and symbol ownership.
- Active file overlap.
- Repeated collision signatures.
- Intervention history.
- Accepted and dismissed outcomes.
- Routing preferences by event type and severity.
- Summaries of decisions relevant to future coordination.
## Forbidden Memory
PodMan must not store:
- Raw screenshots.
- Screen recordings.
- Secrets or credentials.
- Full terminal logs.
- Personal performance judgments.
- Private content unrelated to the coding task.
## Evidence Policy
| Evidence | Can predict? | Can adapt memory? |
| --- | --- | --- |
| Vision only | Yes, low confidence | No |
| Git watcher | Yes | No, unless repeated |
| GitHub state | Yes | No, unless verified |
| Accepted real outcome | Yes | Yes |
| Dismissed outcome | Yes, for suppression | Yes, as negative signal |
| Verifier result | Yes | Yes |
## Intervention Policy
Use the least intrusive channel:
1. Watch quietly.
2. Card.
3. Hermes message.
4. Voice.
Voice is only for urgent, high-confidence, time-sensitive risks.
## Adaptation Policy
Allowed adaptations:
- Add learned ownership after accepted real outcome.
- Raise confidence for repeated accepted signatures.
- Lower confidence for dismissed signatures.
- Prefer the previously accepted intervention kind.
- Suppress repeated low-value warnings.
Disallowed adaptations:
- Broad threshold changes from one example.
- Treating vector similarity as proof.
- Hiding dismissals.
- Making interruption more aggressive without evidence.
## Retention Policy
Keep:
- Outcomes.
- Signatures.
- Team model memory.
- Strategy metrics.
Summarize or expire:
- Old observations.
- Low-confidence vision-only events.
- Detailed trace text.
Delete immediately:
- Secrets.
- Accidental raw sensitive captures.
## Demo Policy
Seeded data is acceptable only if the demo script is honest about it. Live
learning requires a live or staged outcome write that visibly updates the graph
or future decision.
-87
View File
@@ -1,87 +0,0 @@
# Continual Learning Prompt
Use this prompt for the agent that decides what PodMan should remember from a
coordination event.
## Prompt
You are PodMan's continual-learning memory agent.
Your job is to inspect observations, collisions, interventions, and outcomes,
then decide what team memory should be updated. You must separate observed
facts, inferred risks, human outcomes, and durable learned memory.
Do not claim something was learned unless an accepted real outcome, verifier, or
human label supports it.
## Inputs
- Pod id.
- Recent engineer states.
- Recent observations.
- Candidate collision.
- Prior exact-signature memory.
- Intervention record.
- Outcome record.
- Current team model.
## Procedure
1. Normalize file and symbol.
2. Build exact signature.
3. Check prior accepted and dismissed outcomes.
4. Classify the current event.
5. Decide whether memory should change.
6. Emit the graph impact.
7. Write a short explanation.
## Output Format
```text
Event
- Signature:
- Engineers:
- File:
- Symbol:
- Evidence:
Prior Memory
- Accepted matches:
- Dismissed matches:
- Ownership:
Decision
- Memory action:
- Confidence:
- Reason:
Graph Impact
- Nodes:
- Edges:
- Activity text:
Safety
- Sensitive data present:
- Redaction needed:
```
## Memory Actions
Allowed actions:
- no_change
- strengthen_signature
- weaken_signature
- create_learned_owner
- update_route_preference
- suppress_signature
- request_human_label
## Hard Rules
- Exact recall before vector recall.
- Dismissals are learning signals.
- `learned_from` requires accepted real outcome.
- Store summaries, not raw screen content.
- Prefer less intrusive future behavior when uncertain.
-232
View File
@@ -1,232 +0,0 @@
# Continual Learning Spec
Status: demo-backed / active
Scope: how PodMan learns team memory from live work and outcomes
Owner: continual learning / Team memory
## Purpose
Continual learning is the product proof that PodMan gets more useful from use.
It learns team-level coordination memory: ownership, repeated collisions,
accepted interventions, dismissed noise, and preferred routing.
The visible loop:
```text
observe -> store -> predict -> outcome -> adapt
```
## What Is Implemented Now
- `observations`, `collisions`, `interventions`, `outcomes`,
`engineer_states`, `team_model`, `graph_nodes`, and `graph_edges` are the
current memory truth.
- Exact signature recall and accepted/dismissed outcomes exist.
- Accepted real outcomes can produce `learned_from` graph edges and ownership
memory.
- Raw screenshots and recordings are not stored.
## What Is Intentionally Cut
- Full autonomous training.
- Broad threshold changes from one example.
- Making vector search required for the demo learning proof.
## Source Collections
### `engineer_states`
Latest per-engineer state from vision and local git.
Key fields:
- `podId`
- `name`
- `currentFile`
- `changedFiles`
- `branch`
- `confidence`
- `visionUpdatedAt`
- `gitUpdatedAt`
- `updatedAt`
### `observations`
Structured perception events.
Key fields:
- `podId`
- `engineerId`
- `currentFile`
- `symbol`
- `activity`
- `confidence`
- `observedAt`
### `collisions`
Predicted risk events.
Key fields:
- `id`
- `podId`
- `file`
- `symbol`
- `engineers`
- `severity`
- `status`
- `memorySignature`
- `detectedAt`
### `interventions`
Actions PodMan sent or suggested.
Key fields:
- `id`
- `podId`
- `collisionId`
- `kind`
- `channel`
- `message`
- `suggestedAction`
- `createdAt`
### `outcomes`
Human or verifier supervision.
Key fields:
- `id`
- `podId`
- `interventionId`
- `collisionId`
- `accepted`
- `wasRealCollision`
- `learnedOwner`
- `recordedAt`
### `team_model`
Durable pod memory.
Key fields:
- `podId`
- `graph`
- `ownership`
- `collisionSignatures`
- `interventionPolicy`
- `updatedAt`
### `memory_vectors`
Optional semantic recall. Exact recall comes first.
Key fields:
- `podId`
- `sourceKind`
- `sourceId`
- `text`
- `embedding`
- `embeddingModel`
- `tags`
## Learning Rules
### Observe
Write structured evidence from vision, git, GitHub, and agent traces.
### Store
Persist source records and materialized summaries. Do not store raw screenshots
or recordings.
### Predict
Create a collision when multiple engineers converge on the same normalized file
or symbol and at least one signal shows active or unpushed work.
### Outcome
Record whether the intervention was accepted, dismissed, real, or false.
### Adapt
Only accepted real outcomes can create `learned_from` graph edges. Dismissals
adapt suppression, routing, or confidence.
## Exact Signature
Use deterministic signatures:
```text
podId:eventType:normalizedFile:symbol:sortedEngineers
```
Rules:
- Sort engineer names.
- Normalize file paths.
- Use `*` for missing symbol.
- Never include timestamps.
## UI-Facing Loop Snapshot
The graph response may include:
```text
loop
activeStep
steps[]
key
label
value
detail
status
```
Step mapping:
| Step | Source |
| --- | --- |
| Observe | recent observations and git updates |
| Store | team model, graph records, memory vectors |
| Predict | open collisions |
| Outcome | accepted and dismissed outcomes |
| Adapt | learned owners, learned edges, strategy changes |
## Activity Stream
The graph response may include:
```text
activity[]
id
at
kind
title
detail
nodeId
edgeId
```
Allowed `kind` values:
```text
editing, collision, intervention, outcome, learned, agent
```
## Acceptance Criteria
- The system can show one accepted outcome changing future memory.
- Exact recall works without vector search.
- The Team memory graph can explain the learning loop.
- Dismissals and false positives are retained.
- The demo does not rely on raw screenshots or hidden state.
+4 -4
View File
@@ -69,9 +69,9 @@ Pre-create these files in the demo repo before the demo:
| 0:20 | Alice opens `auth/middleware.ts`, starts typing | Alice |
| 0:45 | Bob opens `frontend/login.tsx` | Bob |
| 0:50 | Carol runs `curl` command, sees error | Carol |
| ~1:20 | BLOCKER_DETECTED intervention fires | Hermes auto |
| ~1:20 | BLOCKER_DETECTED nudge fires | Hermes auto |
| 1:50 | Alice starts her server (`node server.js`) | Alice |
| ~2:00 | DEPENDENCY_READY intervention fires | Hermes auto |
| ~2:00 | DEPENDENCY_READY nudge fires | Hermes auto |
| 2:20 | Optional: show session 2 ownership warm-start | Presenter |
| 2:45 | Close | Presenter |
@@ -89,7 +89,7 @@ Pre-create these files in the demo repo before the demo:
## Cooldown note
Hermes has a 3-minute cooldown between urgent voice cues per pod. For the demo, if you need to trigger a second urgent voice event quickly:
Hermes has a 3-minute cooldown between nudges per pod. For the demo, if you need to trigger a second event quickly:
Option 1: restart Hermes between the two demo scenarios (resets cooldown state)
Option 2: set `NUDGE_COOLDOWN_MS=0` via env var during demo (add this override to Hermes)
@@ -102,7 +102,7 @@ If any system fails on stage:
1. **Hermes unreachable:** switch to local (`pnpm --filter backend dev`) — PWA auto-falls back to `localhost:8787`
2. **Gemini Vision low confidence:** presenter narrates what PodMan "saw" while playing the backup video
3. **LiveKit audio not working:** play backup video — show the intervention cards on screen instead
3. **LiveKit audio not working:** play backup video — show the nudge text cards on screen instead
4. **Full system failure:** play the backup recording, narrate the demo live
Always have the backup video on a separate device, not the same laptop running Hermes.
-12
View File
@@ -70,7 +70,6 @@ LIVEKIT_API_SECRET=...
GEMINI_API_KEY=...
GEMINI_VISION_MODEL=gemini-2.0-flash
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
GEMINI_TTS_VOICE=Charon
GITHUB_TOKEN=...
GITHUB_REPO=karti-ai/podman
@@ -96,17 +95,6 @@ Build once:
docker build -f infra/Dockerfile -t podman-backend .
```
Or use the repository script and verifier:
```bash
pnpm build:container
pnpm verify:containers
```
`verify:containers` uses Docker by default to match `build:container`. To verify
against a Podman image store instead, run
`VERIFY_CONTAINER_RUNTIME=podman pnpm verify:containers`.
The image entrypoint runs `node backend/dist/server.js` when
`PODMAN_PROCESS=server`, and `node backend/dist/agent.js` when
`PODMAN_PROCESS=agent`. Do not run the combined Hermes supervisor inside App
+14 -15
View File
@@ -1,6 +1,6 @@
# Gemini Integration Spec
PodMan uses Gemini for two distinct jobs: **vision** (understanding screens) and **voice** (urgent voice cues).
PodMan uses Gemini for two distinct jobs: **vision** (understanding screens) and **voice** (speaking nudges).
---
@@ -75,7 +75,7 @@ Respond with valid JSON only.
---
## 3. Intervention Text Generation
## 3. Nudge Generation — Voice Message
**Model:** `gemini-2.0-flash` (text only)
@@ -112,27 +112,26 @@ Respond with the message text only.
## 4. Voice Output — Gemini TTS via LiveKit
**Model:** `gemini-3.1-flash-tts-preview`
**Default voice:** `Charon`
**Integration:** Hermes asks Gemini TTS for short PCM audio, then publishes that audio into the room as a short LiveKit audio track. The code still preserves a Gemini Live path for future available Live models.
**Integration:** Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track. The code still preserves a Gemini Live path for future available Live models.
**Flow:**
1. Intervention message text generated (step 3)
2. Hermes wraps it in a natural-speaking prompt for Gemini TTS
3. Gemini returns audio with the configured prebuilt voice
4. Hermes publishes the audio into the LiveKit room
5. The frontend still renders the `VOICE_CUE` text, but browser TTS is off unless explicitly enabled
1. Nudge message text generated (step 3)
2. Hermes passes text to Gemini Live via LiveKit Agents
3. Gemini Live streams audio back in real-time
4. LiveKit publishes audio into the pod room
5. All participants hear it through their audio output
**Why Gemini TTS first:**
**Why Gemini Live (not plain TTS):**
- Natural voice quality is better than browser `speechSynthesis`
- Tone and pacing can be steered directly in the prompt
- The voice name is configurable with `GEMINI_TTS_VOICE`
- LiveKit remains the delivery layer, so teammates hear the same room audio
- Streams audio directly — no intermediate WAV file conversion
- Latency ~300500ms from text to first audio packet
- Natural-sounding voice
- Strong prize story: Gemini Live 2.5 is the headline model
---
## Cooldown
Per-pod cooldown of **3 minutes** between urgent voice cues. Prevents spam if multiple risks fire simultaneously. Implemented in Hermes, not in Gemini.
Per-pod cooldown of **3 minutes** between nudges. Prevents spam if multiple events fire simultaneously. Implemented in Hermes, not in Gemini.
-38
View File
@@ -1,38 +0,0 @@
# Graph Discovery
Status: demo-backed / active
Graph discovery owns how MongoDB records become the Team memory graph. It
materializes a sparse, auditable graph from real records first, seeded graph
second, and demo fallback third.
## Files
| File | Purpose |
| --- | --- |
| [`spec.md`](spec.md) | Source data, graph contract, and discovery rules |
| [`policy.md`](policy.md) | Graph hygiene, evidence thresholds, and truthfulness |
| [`prompt.md`](prompt.md) | Graph materialization and review prompt |
| [`plan.md`](plan.md) | Risk-path and observatory build plan |
## What Is Implemented Now
- `GET /api/pods/:podId/graph`.
- `GET /api/pods/:podId/graph/reach/:id` backed by MongoDB `$graphLookup`.
- Live graph materialization from `pods`, `engineer_states`, `observations`,
`collisions`, `interventions`, and `outcomes`.
- Seeded graph in `team_model.graph` and mirrored `graph_nodes` / `graph_edges`.
- Demo graph fallback so the stage never shows an empty canvas.
## What Is Intentionally Cut
- A separate graph database.
- A broad analytics dashboard.
- Showing every historical event by default.
- Treating seeded demo data as live learning.
## Demo Proof Path
Observe screen/git state -> detect collision -> send intervention -> accept or
dismiss outcome -> recall similar event -> show changed graph or changed
behavior.
-68
View File
@@ -1,68 +0,0 @@
# Graph Discovery Plan
Status: demo-backed / active
Goal: make MongoDB graph discovery visible as a dynamic learning observatory
## Must-Have
1. Keep live materializer as source of graph truth.
2. Add optional loop and activity fields.
3. Build a dynamic graph layout.
4. Default to risk path.
5. Make selected-node detail explain the story.
## Build Order
### R1: Stabilize discovered graph
- Keep file and engineer noise filters.
- Keep collision collapse.
- Keep priority for accepted-outcome paths.
- Keep graph size capped.
### R2: Add observatory data
- Compute learning-loop snapshot.
- Compute activity stream.
- Preserve current graph contract.
### R3: Improve path selection
- Pick one primary risk path.
- Include learned path when present.
- Dim unrelated collisions and repeated interventions.
### R4: Render dynamically
- Use `d3-force` or animated layered layout.
- Make nodes draggable.
- Curve or bundle edges.
- Animate `learned_from`.
### R5: Verify with real data
- Fetch live `demo-pod` graph.
- Confirm labels do not collide badly.
- Confirm red edges do not dominate.
- Confirm activity and loop explain the graph.
## Nice-to-Have
- Reachability panel using `$graphLookup`.
- Hover path previews.
- Edge bundling by file or collision.
- Time scrubber for graph snapshots.
## Cut
- Generic analytics dashboard.
- Large graph database migration.
- Rendering every historical event.
- Static fixed-column final layout.
## Acceptance Criteria
- Risk path is obvious in 10 seconds.
- Learned path is visible when data exists.
- Whole graph mode exists but is not the default.
- The graph remains backed by MongoDB, not hardcoded mock data.
-82
View File
@@ -1,82 +0,0 @@
# Graph Discovery Policy
Status: demo-backed / active
Scope: graph hygiene, evidence thresholds, and UI truthfulness
## Prime Rule
The graph must be sparse enough to explain the learning loop and truthful enough
to audit from MongoDB.
## Node Policy
Create nodes only when they add explanation value.
Allowed:
- Current engineers.
- Real files.
- Current or recent collisions.
- Interventions tied to surviving collisions.
- Learned ownership paths.
Avoid:
- Test engineers.
- Scratch files.
- URLs or environment values misread as files.
- Repeated identical intervention diamonds.
- Orphan nodes with no story value.
## Edge Policy
Edges need evidence.
| Edge | Required evidence |
| --- | --- |
| `editing` | observation or git state |
| `touches` | file involved in collision |
| `collides` | collision prediction |
| `warns` | intervention record |
| `learned_from` | accepted real outcome |
| `owns` | learned or configured ownership |
## De-Hairball Policy
Default mode must not show every relationship equally.
Rules:
- Default to risk path.
- Collapse repeated collision signatures.
- Cap files and collisions.
- Dim non-risk edges.
- Bundle or curve dense edges.
- Hide low-priority labels until hover or select.
- Prefer selected-node explanation over labels everywhere.
## Truthfulness Policy
- Do not show `learned_from` for orphaned or dismissed outcomes.
- Do not label vector similarity as learned memory.
- Do not show demo seed as live learning unless labeled.
- Do not hide false positives from activity or memory.
## Privacy Policy
Graph labels should not expose secrets, raw terminal output, or sensitive file
contents. File paths are acceptable when they are repo paths and not secret
values.
## Visual Policy
Semantic colors stay stable:
- Engineer: blue.
- File: slate.
- Feature: amber.
- Collision: red.
- Intervention: violet.
- Learned: violet dashed edge.
Chrome should use the app's light shadcn tokens.
-81
View File
@@ -1,81 +0,0 @@
# Graph Discovery Prompt
Use this prompt for an agent that materializes or reviews PodMan's Team memory
graph.
## Prompt
You are PodMan's graph discovery agent.
Your job is to turn MongoDB records into a sparse, truthful graph that explains
the continual-learning loop. Do not maximize node count. Maximize legibility and
evidence.
The default output should show the risk path and learned path, not every
possible edge.
## Inputs
- Pod id.
- Pod roster.
- Recent engineer states.
- Recent observations.
- Collisions.
- Interventions.
- Outcomes.
- Team model.
- Existing graph nodes and edges.
## Procedure
1. Normalize file paths.
2. Remove noise.
3. Create engineer and file nodes.
4. Collapse repeated collisions by signature.
5. Preserve accepted-outcome paths.
6. Create intervention nodes for surviving collisions.
7. Create learned edges only from accepted real outcomes.
8. Select the primary risk path.
9. Build activity and loop summaries.
10. Explain selected-node stories.
## Output Format
```text
Graph Summary
- Pod:
- Nodes:
- Edges:
- Primary risk path:
- Learned path:
Discovery Decisions
- Collapsed:
- Dropped as noise:
- Preserved because learned:
Loop
- Observe:
- Store:
- Predict:
- Outcome:
- Adapt:
Activity
- Recent events:
Risks
- Missing evidence:
- Potential hairball:
- Demo caveat:
```
## Hard Rules
- No `learned_from` without accepted real outcome.
- No raw screenshots or secrets in labels.
- Do not rewrite the backend materializer unless explicitly asked.
- Prefer additive graph fields.
- Default to risk path.
- Keep whole graph optional.
-159
View File
@@ -1,159 +0,0 @@
# Graph Discovery Spec
Status: demo-backed / active
Scope: how PodMan discovers graph nodes, edges, risk paths, and learning paths from MongoDB
Owner: graph discovery / Team memory observatory
## Purpose
Graph discovery turns MongoDB memory into a legible Team memory graph. It is not
only layout. It decides which relationships matter, which path is highlighted,
and which evidence explains the graph.
The graph must answer:
1. Who is working?
2. Which files or symbols overlap?
3. Where is the risk?
4. What did PodMan do?
5. What outcome changed memory?
## What Is Implemented Now
- Live materializer first: build from current MongoDB records.
- Seeded graph second: read `team_model.graph` and mirrored graph collections.
- Demo fallback third: return a grounded demo graph when live data is empty or
unavailable.
- Reachability uses MongoDB `$graphLookup` over `graph_edges`.
## What Is Intentionally Cut
- A graph database migration.
- Whole-history rendering as the default view.
- Claims that seeded graph data is live learning.
## Source Data
Graph discovery reads:
- `pods`
- `engineer_states`
- `observations`
- `collisions`
- `interventions`
- `outcomes`
- `team_model`
- `graph_nodes`
- `graph_edges`
- optional `memory_vectors`
- optional `agent_runs`
- optional `strategy_versions`
## UI Graph Contract
```text
PodGraph
podId
generatedAt
nodes
edges
metrics
loop?
activity?
```
Node kinds:
```text
engineer, feature, file, collision, intervention
```
Edge kinds:
```text
owns, editing, touches, collides, warns, learned_from
```
## Discovery Rules
### Engineer nodes
Create from pod roster, recent observations, git state, or collision membership.
### File nodes
Create only from normalized real file paths. Reject noise such as URLs, env
values, scratch names, and non-file strings.
### Collision nodes
Create from distinct collision signatures. Collapse repeats. Prioritize
collisions referenced by accepted outcomes.
### Intervention nodes
Create one visible intervention per surviving collision unless whole-graph mode
explicitly expands history.
### Learned paths
Create `learned_from` only when an accepted real outcome links an intervention
to a durable memory update.
## Path Modes
### Risk path
Default mode. Highlight the clearest current chain:
```text
engineer -> file -> collision -> intervention -> learned owner
```
Dim unrelated graph material.
### Learning edges
Highlight `learned_from`, `owns`, and the outcomes that produced them.
### Whole graph
Show all materialized nodes and edges with de-emphasized non-critical edges.
## MongoDB Traversal
Use `graph_edges` for reachability:
```text
source -> target -> next target
```
Primary traversal questions:
- What risks does this engineer reach?
- Which files feed this collision?
- Which intervention came from this collision?
- Which learned owner came from this intervention?
## Metrics
Minimum metrics:
- Learned owners.
- Open risk paths.
- Accept rate.
Optional metrics:
- Observations.
- Interventions.
- Memory vectors.
- Strategy versions.
## Acceptance Criteria
- Default graph is not a hairball.
- Every visible learned edge has outcome evidence.
- Every selected node can explain why it matters.
- Activity stream matches graph events.
- Graph can be rebuilt from MongoDB source records.
+15 -26
View File
@@ -1,10 +1,8 @@
# Continual-Learning Graph Spec
> Owner: graph data + visualization. Status: demo-backed / active.
> Owner: graph data + visualization. Status: demo-backed (live `team_model` reads land later).
> Satisfies the documentation-first gate for the `backend/src/graph/*` and
> `frontend/src/components/GraphView.tsx` files.
>
> Canonical module docs live in [`docs/graph-discovery/`](graph-discovery/).
## What this is (and is NOT)
@@ -28,9 +26,7 @@ The graph lives in two places, both keyed by `podId`:
{ podId, graph: PodGraph, updatedAt }
```
`GET /api/pods/:podId/graph` returns the live materialized graph first, then
`team_model.graph`, then a labeled demo graph when neither live nor seeded
data exists.
`GET /api/pods/:podId/graph` returns `team_model.graph`, or a demo graph when none exists yet.
2. **Normalized (for traversal):** the same nodes/edges are mirrored into two collections so
the model can be walked with MongoDB `$graphLookup` (the graph-database pattern):
@@ -80,30 +76,23 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
- `shared/src/graph.ts` — `PodGraph`, `PodGraphNode/Edge/Metric`, `GraphNodeDoc`, `GraphEdgeDoc`
- `backend/src/graph/demo.ts` — `createDemoPodGraph(podId)` (grounded in the demo-pod crew)
- `backend/src/graph/live.ts` — **`materializePodGraph(podId)`**: builds the graph from the real
collections (pods, engineer_states, observations, collisions, interventions, outcomes)
- `backend/src/graph/store.ts` — `loadPodGraph` (live → seeded → demo), `seedGraph`, `reachFrom` (`$graphLookup`)
- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
- `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`)
## Live data → graph mapping
## Demo-first plan
`materializePodGraph` reads the 5 real collections per pod and emits a `PodGraph`:
1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path).
2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock.
3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it.
| Collection | Produces |
| ----------------- | -------------------------------------------------------------------------- |
| `pods.members` | baseline **engineer** nodes |
| `engineer_states` | engineer `risk` if unpushed; **file** nodes (git paths parsed); `editing` |
| `observations` | engineer `active`; **file** from `currentFile`; `editing` (strength=conf.) |
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
## Component convention
Metrics (learned owners / open risk paths / accept rate) are live counts.
## Fallback order (`loadPodGraph`)
1. **Live** — `materializePodGraph` from the real collections (returns `null` if only bare roster).
2. **Seeded** — `team_model.graph` (from `pnpm graph:seed`).
3. **Demo** — `createDemoPodGraph()` (stage safety; never an empty canvas).
UI is built from the shared **shadcn / ruixen** registry — add primitives with
`npx shadcn@latest add "https://ruixen.com/r/[component]"` and compose from
`@/components/ui/*` (`Button`, `Badge`, `Card`, …) using the design tokens
(`var(--card)` / `--foreground` / `--border` / …). Only the SVG node-link **canvas**
in `GraphView.tsx` is bespoke (3 SVG-only CSS rules); the chrome (header, toggles,
metric cards, detail panel, legend) is composed from the primitives + the app's
Tailwind utility patterns. No hand-rolled component stylesheets.
+13 -13
View File
@@ -2,7 +2,7 @@
## One-line value prop
PodMan is a real-time AI team coordination agent that watches consented work signals, maintains live project memory, and proactively coordinates collaborators when collisions, blockers, or handoffs emerge before anyone has to ask.
PodMan is a real-time AI team coordination agent that watches consented work signals, maintains live project memory, and proactively notifies collaborators when dependencies, blockers, or handoffs emerge before anyone has to ask.
---
@@ -18,11 +18,11 @@ Slack doesn't help. Stand-ups are too slow. GitHub only knows pushed state.
PodMan is an ambient AI agent that:
1. Watches each engineer's consented LiveKit screen-share signal
1. Watches each engineer's screen via periodic snapshots (consented, browser-native)
2. Extracts structured context using Gemini Vision — current file, inferred task, terminal state
3. Maintains a shared live model in MongoDB Atlas — observations, collisions, interventions, outcomes, and graph memory
4. Detects coordination risks: same-file collision, blocker detected, duplicate work
5. Sends the least intrusive intervention first: card, Hermes message, and urgent voice only when needed
3. Maintains a shared live model of the team in MongoDB Atlas — who is doing what, who owns which files
4. Detects coordination events: dependency ready, blocker detected, duplicate work
5. Speaks proactively into the team's LiveKit room — engineers hear PodMan through their earbuds without leaving their editor
**The AI's job is not to chat. It is to notice what teammates miss and say so, exactly when it matters.**
@@ -38,21 +38,21 @@ Small software teams: hackathon squads, startup engineering teams, student dev t
- Maintain per-person live context (file, task, terminal)
- Infer shared project state (who owns what, what's blocked, what's ready)
- Detect 3 coordination risk types:
- Detect 3 coordination event types:
- `DEPENDENCY_READY` — engineer A was waiting on work engineer B just completed
- `BLOCKER_DETECTED` — engineer appears stuck; another teammate can unblock
- `DUPLICATE_WORK` — 2+ engineers working on the same file simultaneously
- Generate a short intervention message
- Deliver it as a LiveKit data message, with Gemini TTS audio reserved for urgent escalation
- Generate a 12 sentence proactive voice nudge
- Deliver it into the LiveKit room via Gemini Live 2.5
---
## How it fits the Continual Learning track
PodMan builds outcome-backed team memory in MongoDB that persists across sessions:
PodMan builds an **ownership map** in MongoDB that persists across sessions:
- Session 1: PodMan observes work, predicts a collision, sends an intervention, and stores the outcome
- Session 2+: PodMan recalls the exact signature and changes the graph or behavior
- Session 1: PodMan needs 35 minutes of screen observations to infer who owns what
- Session 2+: PodMan already knows. First nudge fires in under 30 seconds.
The system gets demonstrably more useful the more it is used, with no user configuration required. That is the track definition met exactly.
@@ -60,7 +60,7 @@ The system gets demonstrably more useful the more it is used, with no user confi
## Architecture (one paragraph)
Each engineer opens a browser PWA on their laptop. The PWA shares live IDE context through LiveKit screen sharing, and the local git watcher writes dirty/unpushed state to MongoDB. The backend agent calls Gemini Vision to extract structured context, writes observations and collisions to MongoDB Atlas, recalls accepted or dismissed outcomes, and routes the smallest useful intervention. Cards and Hermes messages are default; Gemini TTS through LiveKit is reserved for urgent escalation. No Slack. No tab switching. No interruption to the editor flow.
Each engineer opens a browser PWA on their laptop. The PWA captures a screen frame every 30 seconds via `getDisplayMedia` and POSTs it to Hermes, the server-side orchestrator running on DigitalOcean. Hermes calls Gemini Vision to extract structured context, writes it to MongoDB Atlas, updates the ownership map, and runs event detection across all active engineers. When a coordination event fires, Hermes generates a short spoken message and publishes it as audio into the team's LiveKit room via Gemini Live 2.5. Engineers hear PodMan through their earbuds. No Slack. No tab switching. No interruption to the editor flow.
---
@@ -88,6 +88,6 @@ Each engineer opens a browser PWA on their laptop. The PWA shares live IDE conte
| Prize | How PodMan earns it |
| --------------------- | ----------------------------------------------------------------------------------------------------- |
| Best Gemini 3.5 / 2.5 | Gemini Vision for screen understanding + Gemini TTS for urgent voice output |
| Best Gemini 3.5 / 2.5 | Gemini Vision for screen understanding + Gemini Live 2.5 for voice output |
| Best LiveKit | LiveKit is the real-time backbone for room presence and voice delivery — load-bearing, not decorative |
| Best DigitalOcean | Hermes deployed on DigitalOcean App Platform; MongoDB Atlas on DO-adjacent infrastructure |
+15 -27
View File
@@ -24,53 +24,44 @@ LiveKit is the real-time backbone for PodMan. It handles room presence and voice
**Receiving:**
- LiveKit client subscribes to remote Hermes audio tracks and attaches them to
a hidden audio sink in the DOM.
- Browser autoplay restrictions still apply. The PWA calls `room.startAudio()`
from user gestures such as first room click, `Enable audio`, `Test PodMan
voice`, and `Share screen`.
- PWA also listens for data channel messages from Hermes for UI card updates and
`VOICE_CUE` fallback text.
- LiveKit client automatically receives Hermes audio track
- No special subscription needed — LiveKit delivers audio to all participants
- PWA also listens for data channel messages from Hermes for UI card updates
**Data channel listener (PWA):**
```ts
room.on(RoomEvent.DataReceived, (payload, participant) => {
if (participant?.identity !== 'podman-hermes') return;
const intervention = JSON.parse(new TextDecoder().decode(payload));
// intervention: COLLISION, HERMES_MESSAGE, VOICE_CUE, ACK, or GIT_REPORT
appendInterventionToFeed(intervention);
const nudge = JSON.parse(new TextDecoder().decode(payload));
// nudge: { type, message, involvedEngineers, file, sentAt }
appendNudgeToFeed(nudge);
});
```
---
## Hermes side (PodMan LiveKit participant)
## Hermes side (LiveKit Agent)
**Framework:** `@livekit/rtc-node`
**Framework:** LiveKit Agents (Node.js)
**Startup:**
1. Hermes mints its own token via the same `createPodToken` function with `identity: 'podman-hermes'`
2. Connects to the configured room as `podman-hermes`
3. Publishes data-channel cards/messages and Gemini TTS audio tracks
3. Registers as a LiveKit Agent with Gemini Live 2.5 as voice provider
**Voice delivery:**
1. Urgent intervention text is ready (from Gemini text generation)
2. Hermes sends a natural-speaking prompt to Gemini TTS
3. Gemini returns PCM audio using the configured voice
4. Hermes publishes the audio as a LiveKit microphone-source track
5. Hermes keeps the track published for the generated audio duration plus tail
silence and a hold window. This avoids browser-side cutoff when LiveKit's
queued playout signal returns before subscribers finish playing buffered
audio.
6. All participants hear it after browser audio has been unlocked
1. Nudge message text is ready (from Gemini text generation)
2. Hermes passes text to Gemini Live 2.5 via LiveKit Agents voice pipeline
3. Audio streams into the room in real-time
4. All participants hear it
**Data channel message (sent alongside audio):**
```ts
const intervention = {
const nudge = {
type: 'DEPENDENCY_READY' | 'BLOCKER_DETECTED' | 'DUPLICATE_WORK',
message: string, // the spoken text
involvedEngineers: string[],
@@ -78,7 +69,7 @@ const intervention = {
sentAt: string, // ISO timestamp
};
room.localParticipant.publishData(
new TextEncoder().encode(JSON.stringify(intervention)),
new TextEncoder().encode(JSON.stringify(nudge)),
{ reliable: true }
);
```
@@ -101,10 +92,7 @@ Hermes uses the same endpoint. Grants:
## Gemini voice model
- Model ID: `gemini-3.1-flash-tts-preview`
- Default voice: `Charon` (`GEMINI_TTS_VOICE`)
- Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track.
- Voice publishing logs generated frame count, estimated duration, queued
playout, and the final subscriber hold time for diagnostics.
- The backend keeps a Gemini Live path for future model availability, but the verified deployment path uses TTS.
---
+114 -151
View File
@@ -1,180 +1,143 @@
# MongoDB Atlas Integration Spec
Status: demo-backed / active
MongoDB Atlas is PodMan's shared memory. It stores live work observations,
collision predictions, interventions, outcomes, latest engineer state, the
materialized Team memory graph, and optional future recall records.
See also:
- [`docs/continual-learning/`](continual-learning/) for outcome-backed team
memory.
- [`docs/graph-discovery/`](graph-discovery/) for graph materialization and
`$graphLookup` traversal.
- [`docs/agent-learning/`](agent-learning/) for planned strategy-version
records.
MongoDB Atlas is PodMan's shared memory. It stores live engineer state, the ownership map that enables continual learning, coordination events, and nudge history.
---
## Current Collections
## Collections
### `engineer_states`
Latest context per engineer. The local git watcher writes git fields; the vision
pipeline may write screen-derived fields. Each writer updates only its own
fields so MongoDB upserts merge cleanly.
Latest context per engineer. Two writers, one collection — vision pipeline upserts vision fields, git watcher script upserts git fields independently. Hermes reads the merged document for event detection.
Key fields:
```ts
{
_id: string, // engineerId (stable across sessions)
podId: string,
name: string, // display name
- `podId`
- `name`
- `currentFile`
- `inferredTask`
- `confidence`
- `changedFiles`
- `diffStat`
- `recentCommit`
- `branch`
- `visionUpdatedAt`
- `gitUpdatedAt`
- `updatedAt`
// --- Vision fields (written by Hermes via POST /ingest) ---
currentFile: string | null, // active file inferred from screen
inferredTask: string | null, // what engineer appears to be doing
terminalVisible: boolean,
recentTerminalOutput: string | null,
confidence: number, // Gemini Vision confidence (01)
visionUpdatedAt: Date,
Primary use: deterministic dirty/unpushed truth for collision detection and
graph discovery.
// --- Git fields (written directly by scripts/podman-agent.mjs) ---
changedFiles: string[], // files with uncommitted changes (git status)
diffStat: string | null, // e.g. "auth/middleware.ts | 24 +++++"
recentCommit: string | null, // most recent commit message
branch: string | null, // current branch name
gitUpdatedAt: Date,
### `observations`
// --- Shared ---
updatedAt: Date // most recent write from either source
}
```
Structured perception events from consented screen context and agent inference.
**Index:** `{ podId: 1, updatedAt: -1 }`
Key fields:
**Two writers, no conflict:** vision upsert uses `$set` on vision fields only; git upsert uses `$set` on git fields only. MongoDB upsert semantics merge them cleanly.
- `podId`
- `engineerId`
- `currentFile`
- `symbol`
- `activity`
- `confidence`
- `observedAt`
Primary use: observe/store proof and active editing edges in the Team memory
graph.
### `collisions`
Predicted coordination risks.
Key fields:
- `id`
- `podId`
- `file`
- `symbol`
- `engineers`
- `severity`
- `memorySignature`
- `githubState`
- `detectedAt`
Primary use: collision cards, exact signature recall, and graph risk paths.
### `interventions`
Actions PodMan sent or suggested.
Key fields:
- `id`
- `podId`
- `collisionId`
- `kind`
- `message`
- `suggestedAction`
- `status`
- `createdAt`
Primary use: closing the loop from prediction to a visible card, Hermes message,
or urgent voice cue.
### `outcomes`
Human or verifier supervision recorded through `POST /api/outcome`.
Key fields:
- `podId`
- `interventionId`
- `collisionId`
- `accepted`
- `wasRealCollision`
- `recordedAt`
Primary use: accepted and dismissed outcomes drive exact recall, suppression,
and learned graph paths.
### `team_model`
Durable per-pod summary memory.
Key fields:
- `podId`
- `ownership`
- `hotspots`
- `graph`
- `updatedAt`
Primary use: stable Team memory, including seeded `graph` snapshots used after
live materialization and before demo fallback.
### `graph_nodes` and `graph_edges`
Normalized mirror of the Team memory graph for MongoDB traversal.
Indexes:
- `graph_nodes`: `{ podId: 1, id: 1 }` unique
- `graph_edges`: `{ podId: 1, source: 1 }`
Primary use: `GET /api/pods/:podId/graph/reach/:id` with `$graphLookup`.
### Optional Future Collections
These are documented for planned work and should not be treated as active write
paths unless implementation is added:
- `memory_vectors`
- `agent_runs`
- `agent_trace_events`
- `strategy_versions`
- `learning_proposals`
**Usage:** Hermes reads all documents for a given `podId` after each update to run event detection. Both vision and git context are available in the same document — `changedFiles` provides ground truth, `currentFile` provides screen context.
---
## Graph Truth Order
### `ownership_map`
`GET /api/pods/:podId/graph` follows this order:
Tracks who works on which files. Built up over the session. **Persists across sessions** — this is the continual learning artifact.
1. Live graph from real collections.
2. Seeded graph from `team_model.graph` and mirrored graph records.
3. Demo fallback graph for stage safety.
```ts
{
_id: string, // `${podId}:${file}`
podId: string,
file: string,
primaryOwner: string, // engineerId with most recent activity on this file
contributors: string[], // all engineerIds observed on this file
observationCount: number, // total frames where this file was seen
lastSeenAt: Date
}
```
Seeded and fallback graphs are acceptable for demos only when labeled honestly.
**Index:** `{ podId: 1, file: 1 }` (unique)
**Upsert logic:**
- On each context update where `currentFile` is non-null:
- Increment `observationCount`
- Update `primaryOwner` to the engineer with the most recent `lastSeenAt` on this file
- Add engineerId to `contributors` if not present
- Update `lastSeenAt`
**Continual learning:** Hermes loads this collection on startup for the pod. If history exists, it pre-populates the in-memory ownership cache before the first frame arrives.
---
## Demo Proof Path
### `events`
Observe screen/git state -> detect collision -> send intervention -> accept or
dismiss outcome -> recall similar event -> show changed graph or changed
behavior.
Every coordination event detected by Hermes.
```ts
{
_id: ObjectId,
podId: string,
type: 'DEPENDENCY_READY' | 'BLOCKER_DETECTED' | 'DUPLICATE_WORK',
involvedEngineers: string[],
file: string | null,
reason: string, // 1-sentence explanation from Gemini
nudgeSent: boolean, // false if suppressed by cooldown
detectedAt: Date
}
```
**Index:** `{ podId: 1, detectedAt: -1 }`
---
## What MongoDB Does Not Store
### `nudges`
- Raw screenshot frames.
- Screen recordings.
- Secrets or credentials.
- Full terminal logs.
- Full Gemini response objects beyond extracted fields needed for memory.
Every voice nudge sent to the room.
```ts
{
_id: ObjectId,
podId: string,
eventId: ObjectId, // ref to events collection
targetEngineers: string[],
message: string, // the spoken text
sentAt: Date
}
```
**Index:** `{ podId: 1, sentAt: -1 }`
**Cooldown check:** before sending a nudge, Hermes queries this collection for any nudge in the last 3 minutes for the same `podId`. If found, suppresses the new nudge and marks the event as `nudgeSent: false`.
---
## Hermes startup sequence
```
1. Connect to Atlas using MONGODB_URI
2. Load ownership_map for this podId
3. Build in-memory cache: Map<file, { primaryOwner, contributors }>
4. Begin accepting /ingest requests
```
---
## Atlas configuration
- **Cluster tier:** M0 (free) is sufficient for hackathon scale
- **Region:** same as DigitalOcean deployment (e.g. NYC1)
- **Auth:** connection string in `MONGODB_URI` env var
- **Collections created automatically** on first write (no schema migration needed)
---
## What MongoDB does NOT store
- Raw screenshot frames (too large — frames are processed in-memory by Hermes and discarded)
- Full Gemini response objects (only extracted fields are stored)
- Session recordings
-108
View File
@@ -1,108 +0,0 @@
# Stream Categorization — My Stream / Team Stream
## Why
Judges must read the pod stream in 10 seconds. Right now both lanes (`My stream`,
`Team stream`) dump every event into one flat chronological list. The two things
that prove "self-improving agent" are mushed together:
- **Sources of decisions** — raw signals the agent observed (screen vision, git).
- **Reasoning decisions** — what Hermes concluded and did (conflict detected,
intervention spoken, outcome/verifier result).
`source` (vision/git/memory/hermes/policy) is currently buried as a plain outline
badge next to the filename. `kind` is only an icon. No sectioning. Result: bloated,
undifferentiated, doesn't tell the loop story (observe → reason → act → learn).
## Goal
Split each stream lane into clear sections and promote provenance, so a judge sees:
"agent ingests **signals**, then makes **reasoning decisions** from them."
No backend / shared-type changes. The data already carries `kind` + `source`.
Pure presentation change in `frontend/src/components/PodView.tsx`
`ActivitySidebar` + `ActivityItem` + new helpers only. Additive, localized.
## Categorization (the contract)
Two sections, derived from existing `PodActivityKind`:
| Section | Heading | kinds | Meaning |
|---|---|---|---|
| `signal` | **Signals** | `observation`, `git` | Raw inputs the agent saw. The *sources*. |
| `decision` | **Reasoning & decisions** | `collision`, `intervention`, `outcome` | What Hermes reasoned and did. |
```ts
const CATEGORY_OF: Record<PodActivityKind, 'signal' | 'decision'> = {
observation: 'signal',
git: 'signal',
collision: 'decision',
intervention: 'decision',
outcome: 'decision',
};
```
Section order: **Signals** first, **Reasoning & decisions** second (top-to-bottom =
the loop direction). A section with zero events renders nothing.
## Provenance chip (sources)
Promote `source` to a leading color-coded chip with an icon. This is the "source of
decision" tag judges look for.
| source | label | icon | tint |
|---|---|---|---|
| `vision` | Vision | `EyeIcon` | chart-1 |
| `git` | Git | `GitBranchIcon` | chart-2 |
| `memory` | Memory | `BrainIcon` | chart-4 |
| `hermes` | Hermes | `SparklesIcon` | primary |
| `policy` | Policy | `ShieldIcon` | chart-3 |
Chip class pattern (use `variant="outline"` so the default primary fill is overridden):
`border-{tint}/40 bg-{tint}/10 text-{tint}`.
## Kind label
Render `kind` as readable text next to the provenance chip, not just an icon:
| kind | label |
|---|---|
| observation | Observed |
| git | Git |
| collision | Conflict |
| intervention | Intervention |
| outcome | Outcome |
Tag row order in each card: **source chip → kind label → actors → file**.
## Implementation steps (after teammate lands frontend work)
1. **Rebase / pull teammate's PodView.tsx first.** Do not start before it lands —
this file is being actively rewritten right now.
2. Add icon imports: `BrainIcon`, `EyeIcon`, `ShieldIcon`, `WorkflowIcon` (and
reuse `GitBranchIcon`, `SparklesIcon`, `RadioTowerIcon`). Import
`PodActivitySource` type from `@podman/shared`.
3. Add module-level consts: `CATEGORY_OF`, `CATEGORIES` (id/label/hint/icon),
`SOURCE_META` (label/icon/className), `KIND_LABEL`.
4. In `ActivitySidebar`'s expanded `SidebarContent`, replace the flat
`events.map(...)` with a `CATEGORIES.map(...)` that filters events per category,
skips empty sections, and renders a section header (icon + label + count + hint)
above each group.
5. In `ActivityItem`, replace the buried source `<Badge variant="outline">{source}</Badge>`
with the colored provenance chip + a kind-label badge; keep actors and file badges.
6. Collapsed icon-rail (the `group-data-[collapsible=icon]` mini list) stays flat —
no sectioning needed there.
7. Verify: `pnpm --filter @podman/frontend build` typechecks; empty-state and a
live pod with mixed events both render correctly.
## Out of scope (do not do)
- No changes to `shared/src/activity.ts`, the SSE hook, or backend event emission.
- No new event kinds/sources.
- No third section / per-kind lanes — two buckets is the whole point (signals vs
reasoning). Keeps a sparse demo stream from fragmenting.
## Merge-safety note
Single file, two functions. Hold until the teammate improving the frontend pushes,
then pull and apply on top to avoid clobbering their design pass.
@@ -7,16 +7,11 @@ metadata:
# PodMan — System Design
Status: historical reference. Current implementation truth lives in
[`../../PLAN.md`](../../PLAN.md), [`../../mongodb.md`](../../mongodb.md),
[`../../continual-learning/`](../../continual-learning/), and
[`../../graph-discovery/`](../../graph-discovery/).
## Concept
PodMan is a real-time AI team coordination agent for software teams. Engineers join a consented LiveKit room and publish screen share when they want PodMan to observe active work. The backend agent samples the LiveKit screen track, uses Gemini Vision to extract structured context, detects coordination risks, and sends intervention cards, Hermes messages, or urgent voice cues through LiveKit. MongoDB Atlas stores observations, collisions, interventions, outcomes, latest engineer state, and the Team memory graph.
PodMan is a real-time AI team coordination agent for software teams. Engineers join a LiveKit room with earbuds. Each engineer's browser PWA captures their screen every 30s and sends it to Hermes (server-side orchestrator on DigitalOcean). Hermes uses Gemini Vision to extract structured context per engineer, detects coordination events, and speaks proactive nudges into the room via Gemini Live 2.5 through LiveKit. MongoDB Atlas stores team state and an ownership map that persists across sessions.
**Track:** Continual Learning — accepted and dismissed outcomes make later exact-signature recall and graph memory more useful.
**Track:** Continual Learning — the ownership map makes PodMan faster and smarter each session with no user configuration.
---
@@ -24,27 +19,26 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
```
┌──────────────── Engineer laptop (Browser PWA) ──────────────────┐
│ getDisplayMedia → LiveKit screen-share track
Local git watcher → MongoDB engineer_states
│ LiveKit room joined → receives cards, messages, voice cues
│ Earbuds: hears PodMan urgent voice cues │
│ getDisplayMedia → frame every 30s
HTTP POST /ingest → { screenshot, engineerId, podId }
│ LiveKit room joined → receives voice audio from Hermes
│ Earbuds: hears PodMan proactive nudges
└──────────────────────────────────────────────────────────────────┘
LiveKit media + data
POST /ingest
┌────────────────── HERMES (DigitalOcean) ─────────────────────────┐
│ 1. Subscribe to screen-share track → Gemini Vision │
│ 2. Write observations and per-user state to MongoDB
│ 3. Fuse local git truth from engineer_states
│ 4. Run collision detector over active contexts │
│ 5. If risk detected → card/message first, voice only if urgent
│ 6. Push data and optional audio into LiveKit room
│ 1. Receive frame → Gemini Vision → EngineerContext
│ 2. Write context to MongoDB (per-user state)
│ 3. Update ownership map (file → engineer)
│ 4. Run event detector over all active contexts │
│ 5. If event detected → Gemini generates voice message
│ 6. Push audio into LiveKit room via Gemini Live 2.5
└──────────────────────────────────────────────────────────────────┘
│ read/write
MongoDB Atlas
(engineer_states, observations,
collisions, interventions, outcomes,
team_model, graph_nodes, graph_edges)
(engineer_states, ownership_map,
events, nudges)
```
---
@@ -54,53 +48,51 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
### PWA (local agent)
- Joins LiveKit room via existing `joinPod` flow
- Publishes screen share through LiveKit after explicit user action
- Receives Hermes audio track through LiveKit when voice is urgent
- Listens for data channel messages → renders intervention feed
- Captures frame every 30s via `getDisplayMedia`, compresses to JPEG (1280×720, quality 0.7)
- POSTs `{ engineerId, podId, screenshotBase64, capturedAt }` to `POST /ingest`
- Receives Hermes audio track (automatic via LiveKit)
- Listens for data channel messages → renders nudge feed
- Two screens: join screen (built), active session screen (to build)
### Hermes (orchestrator)
- Express server + LiveKit Agent on DigitalOcean
- LiveKit agent worker receives sampled screen-share frames and queues them for vision
- `POST /ingest`: receives frame, queues for vision
- Vision pipeline: Gemini 2.0 Flash → `EngineerContext`
- Confidence gate: discard frames with confidence < 0.6
- State writer: write `observations`, `collisions`, `interventions`, `outcomes`, and `engineer_states`
- State writer: upsert `engineer_states` + `ownership_map` in MongoDB
- Event detector: Gemini text prompt over all active states
- Message generator: Gemini text → short intervention message
- Voice publisher: Gemini TTS via LiveKit audio into room for urgent escalation
- Data channel: sends structured intervention payload
- Cooldown: 3 min between voice cues per pod
- Nudge generator: Gemini text → 12 sentence spoken message
- Voice publisher: Gemini Live 2.5 via LiveKit Agents → audio into room
- Data channel: sends structured nudge payload alongside audio
- Cooldown: 3 min between nudges per pod
### Gemini usage
- **Vision:** `gemini-2.0-flash` — screen → `{ currentFile, inferredTask, terminalVisible, recentTerminalOutput, confidence }`
- **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }`
- **Message generation:** `gemini-2.0-flash`risk → intervention text
- **Nudge generation:** `gemini-2.0-flash`event → spoken message text
- **Voice:** `gemini-3.1-flash-tts-preview` via LiveKit audio publication — text → audio
### MongoDB Atlas
### MongoDB Atlas (4 collections)
- `engineer_states`: latest context per engineer
- `observations`: structured perception records
- `collisions`: detected coordination risks
- `interventions`: cards, messages, and voice cues sent or suggested
- `outcomes`: accepted and dismissed learning signals
- `team_model`: durable per-pod summary and seeded graph
- `graph_nodes` / `graph_edges`: normalized graph records for `$graphLookup`
- `engineer_states`: latest context per engineer, upserted each ingest
- `ownership_map`: file → primaryOwner + contributors, persists across sessions (continual learning)
- `events`: all detected coordination events
- `nudges`: all voice nudges sent + cooldown history
### LiveKit
- One room per pod
- Engineers publish screen-share tracks
- PodMan joins as an agent participant, subscribes to screen share, and publishes audio + data channel messages
- Engineers publish screen track (used client-side for capture — Hermes does not subscribe)
- Hermes joins as `podman-hermes`, publishes audio + data channel messages
- Engineers receive audio automatically
---
## Event types
| Event | Trigger | Example intervention |
| Event | Trigger | Example nudge |
| ------------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `BLOCKER_DETECTED` | Engineer stuck (error in terminal, same file N frames) + teammate can help | "Carol, looks like you're waiting on auth. Alice is actively building it — hang tight." |
| `DEPENDENCY_READY` | Engineer A completes work that Engineer B was waiting on | "Carol, Bob — Alice just got the auth endpoint running. You're clear to integrate." |
@@ -110,17 +102,13 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
## Continual learning story
The `team_model` graph and accepted outcomes persist across sessions. On graph
load:
The `ownership_map` collection persists across sessions. On Hermes startup:
1. Materialize from live MongoDB records when real activity exists.
2. Fall back to seeded `team_model.graph`.
3. Fall back to a labeled demo graph for stage stability.
4. Exact signature recall uses accepted and dismissed outcomes before vector recall.
1. Load ownership map for this pod from Atlas
2. Build in-memory cache: `Map<file, { primaryOwner, contributors }>`
3. Event detection uses priors immediately — no ramp-up phase
**Demo:** The first collision writes an outcome. The second similar collision
recalls that memory and changes the graph or behavior. That is the learning
visible on stage.
**Demo:** Session 1 takes 3 min to first nudge. Session 2 fires in < 30 seconds. That is the learning, visible on stage.
---
-79
View File
@@ -1,79 +0,0 @@
# Shared Background Music — pod-wide audio + connectivity check
> Spec for the `frontend/src/livekit/useBeat.ts` + `PodView.tsx` background-music
> behavior, the `lib/beat.ts` audio source, the `GET /api/pods/:id/music`
> endpoint, and the additive `BEAT_STOP` data message. Satisfies the
> documentation-first gate for those files.
## Why
The **Background Music** button is PodMan's pod-wide audio: a calm, looping
background track unique to each pod, generated by Gemini **Lyria 3**. It opens
with the pod's name sung once, then settles into a soft instrumental bed. It also
doubles as the pre-flight check that the LiveKit audio path works for the whole
pod — the same path the urgent Gemini-TTS voice escalation rides on. Its on/off
state is shared pod-wide so a judge sees it flip on every screen at once.
## Music generation (backend)
- `GET /api/pods/:id/music` → streams the pod's background-music MP3
(`audio/mpeg`). On first request it calls Lyria 3 (`lyria-3-clip-preview`) via
the Gemini **interactions** endpoint with a prompt that sings the pod name in
the first ~3s then stays instrumental, and **caches** the MP3 in the
`pod_music` Mongo collection (keyed by pod id; regenerated if the pod name
changes). Subsequent requests are instant. The Gemini key stays server-side.
- `backend/src/voice/music.ts` owns generation + caching (`getPodMusic`).
- Override the model with `GEMINI_MUSIC_MODEL` (default `lyria-3-clip-preview`).
## Behavior (frontend)
- Any participant clicks **Background Music** → the client fetches the pod's MP3
and loops it (`lib/beat.ts` `startMusic`, Web Audio `AudioBufferSource.loop`),
publishing it as the `podman-beat` track. Everyone auto-subscribes and hears
it; the publisher hears it locally too.
- The shared on/off state is **derived from the track's presence**, not a synced
flag — so it self-syncs across joins/leaves and can't drift. The publisher is
the **owner**.
- Anyone can stop it:
- Owner clicks **Stop music** → unpublishes its own track directly.
- Non-owner clicks **Stop (`<owner>`)** → sends `BEAT_STOP`; the owner
unpublishes. (LiveKit forbids unpublishing another participant's track.)
- `PodView` warms the cache with a fire-and-forget fetch on mount so the first
click plays instantly.
## State derivation (source of truth = the track)
`useBeat(room, musicUrl)` returns `{ beat, toggleBeat }` where `beat` is
`{ on, by, mine }`, recomputed from the presence of a track named `podman-beat`
across `localParticipant` + `remoteParticipants` on these events:
`LocalTrackPublished/Unpublished`, `TrackPublished/Unpublished`,
`TrackSubscribed/Unsubscribed`, `ParticipantConnected/Disconnected`. Owner
disconnect and late-join sync therefore need no extra messaging.
## Contract (additive)
`shared/src/messages.ts``{ type: 'BEAT_STOP' }` on the existing
`podman.intervention` data topic (any participant → owner: stop the shared
track). Additive to the `DataMessage` union; existing consumers ignore unknown
types.
## Known limitation (LiveKit constraint)
A client can only unpublish **its own** tracks, so a non-owner's **Stop** is a
`BEAT_STOP` _request_ the owner must honor. If the owner disconnects **uncleanly**
(crash / network drop), the SFU keeps the track published until it times the
participant out — during that window the music keeps playing and non-owners
can't stop it. A clean disconnect clears it immediately via
`ParticipantDisconnected`. Demo mitigation: have the same person who starts it
also stop it.
## Files
- `backend/src/voice/music.ts` — Lyria generation + `pod_music` cache.
- `backend/src/server.ts``GET /api/pods/:id/music` (streams MP3).
- `frontend/src/lib/api.ts``podMusicUrl(id)` helper.
- `frontend/src/lib/beat.ts``startMusic(url)` (loops the MP3); legacy
`startBeat()` (synthesized kick/hat) kept as a fallback.
- `frontend/src/livekit/useBeat.ts``useBeat(room, musicUrl)` hook.
- `frontend/src/components/PodView.tsx` — button label + cache warm-up.
- `shared/src/messages.ts``BEAT_STOP` message (additive).
+1 -8
View File
@@ -3,14 +3,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'**/dist/**',
'**/build/**',
'**/node_modules/**',
'**/.venv/**',
'**/*.config.*',
'examples/livekit-gemini-hacker-starter/**',
],
ignores: ['**/dist/**', '**/build/**', '**/node_modules/**', '**/*.config.*'],
},
js.configs.recommended,
...tseslint.configs.recommended,
@@ -1,47 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
.env.local
*.egg-info/
dist/
build/
.uv/
# Node.js
node_modules/
.next/
out/
.env.local
.env*.local
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# OS
.DS_Store
Thumbs.db
# LiveKit
.livekit/
# Environment files
.env
.env.local
.env.*.local
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,259 +0,0 @@
# Gemini Hacker Starter
A minimal starting point for building with **Gemini 3.1**, **NanoBanana 2**, and **Lyria RealTime** on LiveKit. Get a working multimodal agent running in under 10 minutes, then make it your own.
Built for the **Google DeepMind × YC Hackathon**.
---
## What's included
| Model | What it does in this starter |
|---|---|
| **Gemini 3.1 Flash Audio** | Real-time voice conversation with native audio and video understanding |
| **NanoBanana 2** (`gemini-3.1-flash-image-preview`) | Generates images from text prompts — agent calls it as a function tool and sends the result to your browser |
| **Lyria RealTime** (`models/lyria-realtime-exp`) | Streams generative music into the LiveKit room as a live audio track |
The agent can see your camera, hear you speak, generate images on demand, and play real-time music — all through a single LiveKit room.
---
## Install the LiveKit MCP server
Install this before you start. It gives your AI coding assistant direct access to LiveKit documentation so you get accurate, current help as you build.
**Cursor** — click to install:
[![Install MCP Server in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=livekit-docs&config=eyJ1cmwiOiJodHRwczovL2RvY3MubGl2ZWtpdC5pby9tY3AifQ%3D%3D)
Or add manually to your MCP settings:
```json
{
"livekit-docs": {
"url": "https://docs.livekit.io/mcp"
}
}
```
**Claude Code**
```bash
claude mcp add --transport http livekit-docs https://docs.livekit.io/mcp
```
**Gemini CLI**
```bash
gemini mcp add --transport http livekit-docs https://docs.livekit.io/mcp
```
---
## Prerequisites
- Python 3.103.13
- Node.js 18+
- [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python package manager)
- LiveKit CLI:
- macOS: `brew install livekit-cli`
- Linux: `curl -sSL https://get.livekit.io/cli | bash`
- Windows: `winget install LiveKit.LiveKitCLI`
- [LiveKit Cloud account](https://cloud.livekit.io) (free)
- Google API key with access to Gemini 3.1, NanoBanana 2, and Lyria
---
## Quick start
### 1. Set up the agent
```bash
cd agent
uv sync
cp .env.example .env.local
```
Edit `.env.local` with your credentials:
```env
LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your_key
LIVEKIT_API_SECRET=your_secret
GOOGLE_API_KEY=your_google_api_key
```
Or use the LiveKit CLI to pull credentials from your cloud project automatically:
```bash
lk cloud auth
lk app env -w -d .env.local
```
### 2. Set up the frontend
```bash
cd ../frontend
pnpm install
cp .env.example .env.local
```
Edit `frontend/.env.local`:
```env
LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your_key
LIVEKIT_API_SECRET=your_secret
```
Or use the LiveKit CLI:
```bash
lk app env -w
```
### 3. Run the agent
```bash
cd agent
uv run agent.py dev
```
### 4. Run the frontend
In a new terminal:
```bash
cd frontend
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000), click **Start hacking**, and talk to your agent.
---
## Try it out
Once running, try these prompts:
- *"Generate an image of a neon-lit street at night in the style of a Studio Ghibli film"*
- *"Play some calm ambient music"*
- *"Stop the music"*
- *"What do you see through my camera?"*
- *"Generate a logo for a company called Quantum Noodle"*
---
## Customization
All the extension points are marked with `# HACK HERE:` comments in `agent/agent.py`. Here are the main ones.
### Change the agent's persona
Edit `PERSONA_INSTRUCTIONS` at the top of `agent/agent.py`:
```python
PERSONA_INSTRUCTIONS = """You are a live sports commentator.
Watch the game through the user's camera and provide real-time strategic analysis.
Call out key moments, track the score, and keep energy high."""
```
### Add a function tool
```python
from livekit.agents import function_tool, RunContext
@function_tool()
async def search_the_web(self, context: RunContext, query: str) -> str:
"""Search the web for current information.
Args:
query: The search query
"""
# your implementation here
return "results..."
```
### Adjust video frame rate
By default, video frames are sampled based on voice activity. For continuous commentary (e.g., watching a game), use a constant frame rate:
```python
from livekit.agents import voice
session = AgentSession(
llm=google.realtime.RealtimeModel(...),
video_sampler=voice.VoiceActivityVideoSampler(speaking_fps=1.0, silent_fps=1.0),
)
```
### Swap the Gemini voice
Change the `voice` parameter in `agent.py`:
```python
llm=google.realtime.RealtimeModel(
model=REALTIME_MODEL,
voice="Kore", # Options: Aoede, Charon, Fenrir, Kore, Puck
)
```
### Customize image generation
The `generate_image` tool in `HackathonAgent` sends the result as a data message to the frontend. You can extend it to:
- Apply a style prefix to every prompt (e.g., always render in watercolor)
- Send multiple images
- Log prompts and images for a gallery view
### Customize Lyria music
The `start_music` tool accepts a `prompt` (text description) and `bpm`. You can extend it to expose more Lyria controls like `density`, `brightness`, and `scale`. See the [Lyria RealTime docs](https://ai.google.dev/gemini-api/docs/music-generation) for all available config options.
---
## Project ideas
These are just starting points. Build whatever seems interesting.
**Live foley engine** — Agent watches your video feed and generates matching ambient sounds and music in real time using Lyria. Point the camera at rain, a fire, a crowd — the agent creates a matching soundscape.
**Live game asset generator** — Sketch character designs or level layouts on paper, show them to the camera, and ask the agent to render polished versions using NanoBanana 2.
**Interactive storytelling** — Narrate a scene out loud. The agent listens, generates an image of what you describe, and plays mood-appropriate music — all simultaneously.
**Spatial design tool** — Point your camera at a room and describe how you'd redesign it. The agent generates photo-realistic renders of the redesigned space.
**Accessibility scene describer** — Agent watches a live video feed and generates detailed audio descriptions plus spatial soundscapes for visually impaired users.
**Real-time style transfer** — Capture frames from the camera, send them through the image model with style prompts, and stream the stylized output back to the screen continuously.
---
## Architecture
```
Frontend (Next.js + Agents UI)
├── Microphone + camera → LiveKit room → agent receives audio/video
├── Agent speech → LiveKit room → browser plays audio
├── "generated-image" data message → browser renders image panel
└── Lyria audio track → browser plays music
Agent (Python)
├── Gemini 3.1 Flash Audio — realtime voice + vision
├── generate_image tool → NanoBanana 2 → publish_data("generated-image")
├── start_music tool → Lyria RealTime → publish AudioTrack
└── stop_music tool → unpublish AudioTrack
```
---
## Resources
- [LiveKit Agents documentation](https://docs.livekit.io/agents/)
- [Gemini Live API documentation](https://ai.google.dev/gemini-api/docs/live)
- [Lyria RealTime documentation](https://ai.google.dev/gemini-api/docs/music-generation)
- [Lyria RealTime cookbook](https://github.com/google-gemini/cookbook/blob/main/quickstarts/Get_started_LyriaRealTime.ipynb)
- [LiveKit Cloud](https://cloud.livekit.io)
- [Google AI Studio](https://aistudio.google.com)
Good luck — build something weird.
@@ -1,7 +0,0 @@
LIVEKIT_API_KEY=<your API Key>
LIVEKIT_API_SECRET=<your API Secret>
LIVEKIT_URL=<your LiveKit server URL>
GOOGLE_API_KEY=<your Google/Gemini API key>
GEMINI_REALTIME_MODEL=gemini-3.1-flash-live-preview
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image
GEMINI_LYRIA_MODEL=models/lyria-realtime-exp
@@ -1 +0,0 @@
3.11
@@ -1,42 +0,0 @@
# Agent Setup
## Installation
### Using uv
```bash
uv sync
```
This will create a virtual environment and install all dependencies.
## Environment Variables
Copy the example environment file:
```bash
cp .env.example .env.local
```
Then edit `.env.local` with your credentials:
- `LIVEKIT_API_KEY` - Your LiveKit API key
- `LIVEKIT_API_SECRET` - Your LiveKit API secret
- `LIVEKIT_URL` - Your LiveKit server URL (e.g., `wss://your-project.livekit.cloud`)
- `GOOGLE_API_KEY` - Your Google/Gemini API key
Or use the LiveKit CLI to auto-populate:
```bash
lk app env -w
```
## Running the Agent
### Using uv
```bash
uv run python agent.py dev
```
The agent will connect to LiveKit and wait for incoming sessions.
@@ -1,294 +0,0 @@
import asyncio
import logging
import os
from dotenv import load_dotenv
from google import genai
from google.genai import types as genai_types
from livekit import agents, rtc
from livekit.agents import AgentServer, AgentSession, Agent, RunContext, function_tool, room_io
from livekit.plugins import google
load_dotenv(".env.local")
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────
# HACK HERE: swap model IDs to experiment
# ─────────────────────────────────────────────
REALTIME_MODEL = os.getenv("GEMINI_REALTIME_MODEL", "gemini-3.1-flash-live-preview")
IMAGE_MODEL = os.getenv("GEMINI_IMAGE_MODEL", "gemini-3.1-flash-image") # Nano Banana 2
LYRIA_MODEL = os.getenv("GEMINI_LYRIA_MODEL", "models/lyria-realtime-exp")
# ─────────────────────────────────────────────
# HACK HERE: change the agent's persona
# ─────────────────────────────────────────────
PERSONA_INSTRUCTIONS = """You are a creative multimodal AI assistant at a Google DeepMind x YC hackathon.
You can see through the user's camera, hear them speak, generate images, and play real-time music.
Your capabilities:
- generate_image: Create images with Nano Banana 2 (Gemini 3.1 Flash Image). Use this when asked to generate, create, render, or visualize anything.
- start_music: Play real-time generative music with Lyria RealTime. Use this for soundtracks, ambience, or any audio atmosphere.
- stop_music: Stop the current music.
IMPORTANT: When the user asks you to generate an image, ALWAYS say a brief acknowledgment first (like "On it!" or "Let me create that for you") before calling generate_image. The image takes a few seconds to generate, so the user needs to know you heard them.
Be concise and creative. Lean into the multimodal possibilities — when a user describes something, offer to generate it."""
class HackathonAgent(Agent):
BASE_VIDEO_AWARENESS = """You can only see video when the user enables their camera or screenshare.
When asked about visuals:
- Only describe what you can actually see in provided video frames.
- Never invent visual details that are not present.
- If no camera is active, tell the user to enable it."""
def __init__(self, room: rtc.Room) -> None:
full_instructions = f"{self.BASE_VIDEO_AWARENESS}\n\n{PERSONA_INSTRUCTIONS}"
super().__init__(instructions=full_instructions)
self._room = room
self._music_task: asyncio.Task | None = None
self._music_stop_event = asyncio.Event()
self._music_track_pub = None
# Standard client for image generation (Nano Banana 2)
self._image_client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
# v1alpha client required for Lyria RealTime
self._lyria_client = genai.Client(
api_key=os.environ["GOOGLE_API_KEY"],
http_options={"api_version": "v1alpha"},
)
# ─────────────────────────────────────────
# HACK HERE: customize the image generation prompt or post-processing
# ─────────────────────────────────────────
@function_tool()
async def generate_image(
self,
context: RunContext,
prompt: str,
) -> str:
"""Generate an image using Nano Banana 2 and display it on the user's screen.
Call this whenever the user asks you to create, generate, render, or visualize something.
Args:
prompt: A detailed description of the image to generate. Be specific about style,
composition, lighting, and content.
"""
logger.info("Generating image: %s", prompt)
try:
response = await asyncio.to_thread(
self._image_client.models.generate_content,
model=IMAGE_MODEL,
contents=prompt,
config=genai_types.GenerateContentConfig(
response_modalities=["Text", "Image"]
),
)
image_bytes = None
mime_type = "image/png"
for part in response.candidates[0].content.parts:
if part.inline_data is not None:
image_bytes = part.inline_data.data
mime_type = part.inline_data.mime_type or "image/png"
break
if image_bytes is None:
return "Image generation did not return any image data."
writer = await self._room.local_participant.stream_bytes(
name="generated-image",
mime_type=mime_type,
total_size=len(image_bytes),
topic="generated-image",
attributes={"prompt": prompt},
)
await writer.write(image_bytes)
await writer.aclose()
return f"Image generated and sent to the screen. Prompt used: {prompt}"
except Exception as exc:
logger.error("Image generation failed: %s", exc)
return f"Image generation failed: {exc}"
# ─────────────────────────────────────────
# HACK HERE: customize Lyria prompts or add BPM/density controls
# ─────────────────────────────────────────
@function_tool()
async def start_music(
self,
context: RunContext,
prompt: str,
bpm: int = 120,
) -> str:
"""Start streaming real-time generative music using Lyria RealTime.
Music plays continuously until stop_music is called. Use this for soundtracks,
atmospheric audio, or any mood-setting music.
Args:
prompt: Description of the music to generate, e.g. "upbeat electronic", "calm ambient piano",
"epic orchestral score", "jazzy lounge". Can combine styles: "lo-fi hip-hop with strings".
bpm: Beats per minute (default: 120). Lower values (60-90) feel slower and more ambient;
higher values (120-160) feel energetic.
"""
await self._stop_music_internal()
logger.info("Starting Lyria music: %s @ %d BPM", prompt, bpm)
self._music_stop_event.clear()
self._music_task = asyncio.create_task(self._stream_lyria(prompt, bpm))
return f"Music started: {prompt} at {bpm} BPM. Call stop_music to stop it."
@function_tool()
async def stop_music(self, context: RunContext) -> str:
"""Stop the currently playing Lyria music."""
if self._music_task is None or self._music_task.done():
return "No music is currently playing."
await self._stop_music_internal()
return "Music stopped."
async def _stop_music_internal(self) -> None:
if self._music_task and not self._music_task.done():
self._music_stop_event.set()
self._music_task.cancel()
try:
await self._music_task
except (asyncio.CancelledError, Exception):
pass
self._music_task = None
if self._music_track_pub is not None:
try:
await self._room.local_participant.unpublish_track(self._music_track_pub.sid)
except Exception:
pass
self._music_track_pub = None
async def _stream_lyria(self, prompt: str, bpm: int) -> None:
"""Stream Lyria audio into the LiveKit room as a published audio track."""
SAMPLE_RATE = 48000
NUM_CHANNELS = 2
audio_source = rtc.AudioSource(sample_rate=SAMPLE_RATE, num_channels=NUM_CHANNELS)
track = rtc.LocalAudioTrack.create_audio_track("lyria-music", audio_source)
options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_UNKNOWN)
pub = await self._room.local_participant.publish_track(track, options)
self._music_track_pub = pub
try:
async with self._lyria_client.aio.live.music.connect(model=LYRIA_MODEL) as session:
await session.set_weighted_prompts(
prompts=[genai_types.WeightedPrompt(text=prompt, weight=1.0)]
)
await session.set_music_generation_config(
config=genai_types.LiveMusicGenerationConfig(bpm=bpm)
)
await session.play()
async for message in session.receive():
if self._music_stop_event.is_set():
break
chunks = message.server_content.audio_chunks
if chunks:
audio_bytes = chunks[0].data
if audio_bytes:
# 16-bit stereo = 4 bytes per sample pair
samples_per_channel = len(audio_bytes) // (NUM_CHANNELS * 2)
frame = rtc.AudioFrame(
data=audio_bytes,
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
samples_per_channel=samples_per_channel,
)
await audio_source.capture_frame(frame)
except asyncio.CancelledError:
pass
except Exception as exc:
logger.error("Lyria streaming error: %s", exc)
finally:
if self._music_track_pub is not None:
try:
await self._room.local_participant.unpublish_track(
self._music_track_pub.sid
)
except Exception:
pass
self._music_track_pub = None
server = AgentServer()
@server.rtc_session(agent_name="gemini-hackathon-agent")
async def entrypoint(ctx: agents.JobContext):
has_video = False
def on_track_subscribed(
track: rtc.Track,
publication: rtc.TrackPublication,
participant: rtc.RemoteParticipant,
):
nonlocal has_video
if track.kind == rtc.TrackKind.KIND_VIDEO:
has_video = True
logger.info("Video track subscribed from %s", participant.identity)
def on_track_unsubscribed(
track: rtc.Track,
publication: rtc.TrackPublication,
participant: rtc.RemoteParticipant,
):
nonlocal has_video
if track.kind == rtc.TrackKind.KIND_VIDEO:
has_video = any(
pub.track and pub.track.kind == rtc.TrackKind.KIND_VIDEO
for p in ctx.room.remote_participants.values()
for pub in p.track_publications.values()
if pub.subscribed
)
ctx.room.on("track_subscribed", on_track_subscribed)
ctx.room.on("track_unsubscribed", on_track_unsubscribed)
for participant in ctx.room.remote_participants.values():
for publication in participant.track_publications.values():
if (
publication.subscribed
and publication.track
and publication.track.kind == rtc.TrackKind.KIND_VIDEO
):
has_video = True
break
session = AgentSession(
llm=google.realtime.RealtimeModel(
model=REALTIME_MODEL,
voice="Aoede",
),
)
await session.start(
room=ctx.room,
agent=HackathonAgent(room=ctx.room),
)
await ctx.connect()
if REALTIME_MODEL != "gemini-3.1-flash-live-preview":
try:
await session.generate_reply(
instructions="Greet the user. Let them know you can generate images with Nano Banana 2 and play real-time music with Lyria. Mention they can enable their camera for visual context."
)
except Exception as exc:
logger.warning("Initial greeting failed: %s", exc)
if __name__ == "__main__":
agents.cli.run_app(server)
@@ -1,17 +0,0 @@
[project]
name = "gemini-hacker-starter"
version = "0.1.0"
description = "Gemini hackathon starter — voice, vision, image generation, and real-time music with LiveKit"
requires-python = ">=3.10,<3.14"
dependencies = [
"livekit-agents[google,images]>=1.6.4,<1.7",
"google-genai>=2.10.0,<3",
"python-dotenv>=1.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]
@@ -1,2 +0,0 @@
livekit-agents[google,images]~=1.3
python-dotenv>=1.0.0
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
# Enviroment variables needed to connect to the LiveKit server.
LIVEKIT_API_KEY=<your_api_key>
LIVEKIT_API_SECRET=<your_api_secret>
LIVEKIT_URL=wss://<project-subdomain>.livekit.cloud
# Agent dispatch (https://docs.livekit.io/agents/server/agent-dispatch)
# Leave AGENT_NAME blank to enable automatic dispatch
# Provide an agent name to enable explicit dispatch
AGENT_NAME=
# Internally used environment variables
NEXT_PUBLIC_APP_CONFIG_ENDPOINT=
SANDBOX_ID=
@@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript", "prettier"]
}
@@ -1,42 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
@@ -1,6 +0,0 @@
dist/
docs/
node_modules/
pnpm-lock.yaml
.next/
.env*
@@ -1,19 +0,0 @@
{
"singleQuote": true,
"trailingComma": "es5",
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"importOrder": [
"^react",
"^next",
"^next/(.*)$",
"<THIRD_PARTY_MODULES>",
"^@[^/](.*)$",
"^@/(.*)$",
"^[./]"
],
"importOrderSeparation": false,
"importOrderSortSpecifiers": true,
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"]
}
@@ -1,165 +0,0 @@
# Agent Starter for React
This is a starter template for [LiveKit Agents](https://docs.livekit.io/agents) that provides a simple voice interface using [Agents UI](https://livekit.io/ui) components and [LiveKit JavaScript SDK](https://github.com/livekit/client-sdk-js). It supports [voice](https://docs.livekit.io/agents/start/voice-ai), [transcriptions](https://docs.livekit.io/agents/build/text/), and [virtual avatars](https://docs.livekit.io/agents/integrations/avatar).
Also available for:
[Android](https://github.com/livekit-examples/agent-starter-android) • [Flutter](https://github.com/livekit-examples/agent-starter-flutter) • [Swift](https://github.com/livekit-examples/agent-starter-swift) • [React Native](https://github.com/livekit-examples/agent-starter-react-native)
<picture>
<source srcset="./.github/assets/readme-hero-dark.webp" media="(prefers-color-scheme: dark)">
<source srcset="./.github/assets/readme-hero-light.webp" media="(prefers-color-scheme: light)">
<img src="./.github/assets/readme-hero-light.webp" alt="App screenshot">
</picture>
### Features:
- Real-time voice interaction with LiveKit Agents
- Camera video streaming support
- Screen sharing capabilities
- Audio visualization and level monitoring
- Virtual avatar integration
- Light/dark theme switching with system preference detection
- Customizable branding, colors, and UI text via configuration
This template is built with Next.js and is free for you to use or modify as you see fit.
### Project structure
This starter uses the [Agents UI](https://livekit.io/ui) components for core UI elements like media controls, audio visualizers, chat transcripts, and providing session data. Shadcn installs components into `components/` folder so you can customize them like any other local component.
```
agent-starter-react/
├── app/
│ ├── api/
├── components/
│ ├── agents-ui/ - Agents UI components
│ ├── ai-elements/ - AI Elements components
│ ├── app/ - App-specific components
│ ├── ui/ - Primitive shadcn/ui components
├── fonts/
├── hooks/
├── lib/
├── public/
└── package.json
```
Business logic lives within the `components/app` folder. It's here where the application's state and behavior is managed and the various Shadcn UI components are composed together.
| File | Description |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session-view.tsx` | Initializes the application, and LiveKit session. Renders the view controller and session UI including chat transcript, media tiles, and control bar. |
| `view-controller.tsx` | Manages the transitions between the welcome and session views based on the LiveKit session state. |
| `welcome-view.tsx` | Renders the welcome UI when the LiveKit session is not connected. |
| `chat-transcript.tsx` | Manages the chat transcript transitions. |
| `tile-layout.tsx` | Manages the layout and transition of media tiles in various application states. |
### Component usage
Most Agents UI components require access to a LiveKit session object for access to values like agent state or audio tracks. A Session object can be created from a [TokenSource](/reference/client-sdk-js/variables/TokenSource.html), and provided by wrapping the component in an [AgentSessionProvider](/reference/components/shadcn/component/agent-session-provider).
See [`components/app/app.tsx`](./components/app/app.tsx) for an example of how this is done in this app.
### Customizing components
Agents UI components, like most Shadcn compopnents, take as many primitive attributes as possible. For example, the [AgentControlBar](/reference/components/shadcn/component/agent-control-bar/page.mdoc) component extends `HTMLAttributes<HTMLDivElement>`, so you can pass any props that a div supports. This makes it easy to extend the component with your own styles or functionality.
You can edit any Agents UI component's source code in the `components/agents-ui` directory. For style changes, we recommend passing in tailwind classes to override the default styles. Take a look at the source code to get a sense of how to override a component's default styles.
### Updating components
To update the Agents UI components to the latest publication, run the following command:
```bash
pnpm shadcn:install
```
> [!NOTE]
> The CLI will ask before overwriting any modified files so you can avoid losing any customizations you might have made.
### Installing components
```bash
pnpm dlx shadcn@latest add @agents-ui/{component-name-a} @agents-ui/{component-name-b}
```
## Getting started
> [!TIP]
> If you'd like to try this application without modification, you can deploy an instance in just a few clicks with [LiveKit Cloud Sandbox](https://cloud.livekit.io/projects/p_/sandbox/templates/agent-starter-react).
[![Open on LiveKit](https://img.shields.io/badge/Open%20on%20LiveKit%20Cloud-002CF2?style=for-the-badge&logo=external-link)](https://cloud.livekit.io/projects/p_/sandbox/templates/agent-starter-react)
Run the following command to automatically clone this template.
```bash
lk app create --template agent-starter-react
```
Then run the app with:
```bash
pnpm install
pnpm dev
```
And open http://localhost:3000 in your browser.
You'll also need an agent to speak with. Try our starter agent for [Python](https://github.com/livekit-examples/agent-starter-python), [Node.js](https://github.com/livekit-examples/agent-starter-node), or [create your own from scratch](https://docs.livekit.io/agents/start/voice-ai/).
## Configuration
This starter is designed to be flexible so you can adapt it to your specific agent use case. You can easily configure it to work with different types of inputs and outputs:
#### Example: App configuration (`app-config.ts`)
```ts
export const APP_CONFIG_DEFAULTS: AppConfig = {
companyName: 'LiveKit',
pageTitle: 'LiveKit Voice Agent',
pageDescription: 'A voice agent built with LiveKit',
supportsChatInput: true,
supportsVideoInput: true,
supportsScreenShare: true,
isPreConnectBufferEnabled: true,
logo: '/lk-logo.svg',
accent: '#002cf2',
logoDark: '/lk-logo-dark.svg',
accentDark: '#1fd5f9',
startButtonText: 'Start call',
// agent dispatch configuration
agentName: undefined,
// LiveKit Cloud Sandbox configuration
sandboxId: undefined,
};
```
You can update these values in [`app-config.ts`](./app-config.ts) to customize branding, features, and UI text for your deployment.
> [!NOTE]
> The `sandboxId` is for the LiveKit Cloud Sandbox environment.
> It is not used for local development.
#### Environment Variables
You'll also need to configure your LiveKit credentials in `.env.local` (copy `.env.example` if you don't have one):
```env
LIVEKIT_API_KEY=your_livekit_api_key
LIVEKIT_API_SECRET=your_livekit_api_secret
LIVEKIT_URL=https://your-livekit-server-url
# Agent dispatch (https://docs.livekit.io/agents/server/agent-dispatch)
# Leave AGENT_NAME blank to enable automatic dispatch
# Provide an agent name to enable explicit dispatch
AGENT_NAME=
```
These are required for the voice agent functionality to work with your LiveKit project.
## Contributing
This template is open source and we welcome contributions! Please open a PR or issue through GitHub, and don't forget to join us in the [LiveKit Community Slack](https://livekit.io/join-slack)!
@@ -1,46 +0,0 @@
export interface AppConfig {
pageTitle: string;
pageDescription: string;
companyName: string;
supportsChatInput: boolean;
supportsVideoInput: boolean;
supportsScreenShare: boolean;
isPreConnectBufferEnabled: boolean;
logo: string;
startButtonText: string;
accent?: string;
logoDark?: string;
accentDark?: string;
// agent dispatch configuration
agentName?: string;
// LiveKit Cloud Sandbox configuration
sandboxId?: string;
}
export const APP_CONFIG_DEFAULTS: AppConfig = {
companyName: 'Gemini Hackathon',
pageTitle: 'Gemini Hacker Starter',
pageDescription:
'Voice, vision, image generation, and real-time music with Gemini 3.1 Live, Nano Banana 2, and Lyria',
supportsChatInput: true,
supportsVideoInput: true,
supportsScreenShare: true,
isPreConnectBufferEnabled: true,
logo: '/lk-logo.svg',
accent: '#4285f4',
logoDark: '/lk-logo-dark.svg',
accentDark: '#1fd5f9',
startButtonText: 'Start hacking',
// agent dispatch configuration
agentName: process.env.AGENT_NAME ?? undefined,
// LiveKit Cloud Sandbox configuration
sandboxId: undefined,
};
@@ -1,91 +0,0 @@
import { NextResponse } from 'next/server';
import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk';
import { RoomConfiguration } from '@livekit/protocol';
type ConnectionDetails = {
serverUrl: string;
roomName: string;
participantName: string;
participantToken: string;
};
// NOTE: you are expected to define the following environment variables in `.env.local`:
const API_KEY = process.env.LIVEKIT_API_KEY;
const API_SECRET = process.env.LIVEKIT_API_SECRET;
const LIVEKIT_URL = process.env.LIVEKIT_URL;
// don't cache the results
export const revalidate = 0;
export async function POST(req: Request) {
try {
if (LIVEKIT_URL === undefined) {
throw new Error('LIVEKIT_URL is not defined');
}
if (API_KEY === undefined) {
throw new Error('LIVEKIT_API_KEY is not defined');
}
if (API_SECRET === undefined) {
throw new Error('LIVEKIT_API_SECRET is not defined');
}
// Parse agent configuration from request body
const body = await req.json();
const agentName: string = body?.room_config?.agents?.[0]?.agent_name;
// Generate participant token
const participantName = 'user';
const participantIdentity = `voice_assistant_user_${Math.floor(Math.random() * 10_000)}`;
const roomName = `voice_assistant_room_${Math.floor(Math.random() * 10_000)}`;
const participantToken = await createParticipantToken(
{ identity: participantIdentity, name: participantName },
roomName,
agentName
);
// Return connection details
const data: ConnectionDetails = {
serverUrl: LIVEKIT_URL,
roomName,
participantToken: participantToken,
participantName,
};
const headers = new Headers({
'Cache-Control': 'no-store',
});
return NextResponse.json(data, { headers });
} catch (error) {
if (error instanceof Error) {
console.error(error);
return new NextResponse(error.message, { status: 500 });
}
}
}
function createParticipantToken(
userInfo: AccessTokenOptions,
roomName: string,
agentName?: string
): Promise<string> {
const at = new AccessToken(API_KEY, API_SECRET, {
...userInfo,
ttl: '15m',
});
const grant: VideoGrant = {
room: roomName,
roomJoin: true,
canPublish: true,
canPublishData: true,
canSubscribe: true,
};
at.addGrant(grant);
if (agentName) {
at.roomConfig = new RoomConfiguration({
agents: [{ agentName }],
});
}
return at.toJwt();
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

@@ -1,111 +0,0 @@
import { Public_Sans } from 'next/font/google';
import localFont from 'next/font/local';
import { headers } from 'next/headers';
import { ThemeProvider } from '@/components/app/theme-provider';
import { ThemeToggle } from '@/components/app/theme-toggle';
import { cn } from '@/lib/shadcn/utils';
import { getAppConfig, getStyles } from '@/lib/utils';
import '@/styles/globals.css';
const publicSans = Public_Sans({
variable: '--font-public-sans',
subsets: ['latin'],
});
const commitMono = localFont({
display: 'swap',
variable: '--font-commit-mono',
src: [
{
path: '../fonts/CommitMono-400-Regular.otf',
weight: '400',
style: 'normal',
},
{
path: '../fonts/CommitMono-700-Regular.otf',
weight: '700',
style: 'normal',
},
{
path: '../fonts/CommitMono-400-Italic.otf',
weight: '400',
style: 'italic',
},
{
path: '../fonts/CommitMono-700-Italic.otf',
weight: '700',
style: 'italic',
},
],
});
interface RootLayoutProps {
children: React.ReactNode;
}
export default async function RootLayout({ children }: RootLayoutProps) {
const hdrs = await headers();
const appConfig = await getAppConfig(hdrs);
const styles = getStyles(appConfig);
const { pageTitle, pageDescription, companyName, logo, logoDark } = appConfig;
return (
<html
lang="en"
suppressHydrationWarning
className={cn(
publicSans.variable,
commitMono.variable,
'scroll-smooth font-sans antialiased'
)}
>
<head>
{styles && <style>{styles}</style>}
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
</head>
<body className="overflow-x-hidden">
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<header className="fixed top-0 left-0 z-50 hidden w-full flex-row justify-between p-6 md:flex">
<a
target="_blank"
rel="noopener noreferrer"
href="https://livekit.io"
className="scale-100 transition-transform duration-300 hover:scale-110"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logo} alt={`${companyName} Logo`} className="block size-6 dark:hidden" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={logoDark ?? logo}
alt={`${companyName} Logo`}
className="hidden size-6 dark:block"
/>
</a>
<span className="text-foreground font-mono text-xs font-bold tracking-wider uppercase">
Built with{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://docs.livekit.io/agents"
className="underline underline-offset-4"
>
LiveKit Agents
</a>
</span>
</header>
{children}
<div className="group fixed bottom-0 left-1/2 z-50 mb-2 -translate-x-1/2">
<ThemeToggle className="translate-y-20 transition-transform delay-150 duration-300 group-hover:translate-y-0" />
</div>
</ThemeProvider>
</body>
</html>
);
}
@@ -1,255 +0,0 @@
import { headers } from 'next/headers';
import { ImageResponse } from 'next/og';
import getImageSize from 'buffer-image-size';
import mime from 'mime';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { APP_CONFIG_DEFAULTS } from '@/app-config';
import { getAppConfig } from '@/lib/utils';
type Dimensions = {
width: number;
height: number;
};
type ImageData = {
base64: string;
dimensions: Dimensions;
};
// Image metadata
export const alt = 'About Acme';
export const size = {
width: 1200,
height: 628,
};
function isRemoteFile(uri: string) {
return uri.startsWith('http');
}
function doesLocalFileExist(uri: string) {
return existsSync(join(process.cwd(), uri));
}
// LOCAL FILES MUST BE IN PUBLIC FOLDER
async function loadFileData(filePath: string): Promise<ArrayBuffer> {
if (isRemoteFile(filePath)) {
const response = await fetch(filePath);
if (!response.ok) {
throw new Error(`Failed to fetch ${filePath} - ${response.status} ${response.statusText}`);
}
return await response.arrayBuffer();
}
// Try file system first (works in local development)
if (doesLocalFileExist(filePath)) {
const buffer = await readFile(join(process.cwd(), filePath));
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
// Fallback to fetching from public URL (works in production)
const publicFilePath = filePath.replace('public/', '');
const fontUrl = `https://${process.env.VERCEL_URL}/${publicFilePath}`;
const response = await fetch(fontUrl);
if (!response.ok) {
throw new Error(`Failed to fetch ${fontUrl} - ${response.status} ${response.statusText}`);
}
return await response.arrayBuffer();
}
async function getImageData(uri: string, fallbackUri?: string): Promise<ImageData> {
try {
const fileData = await loadFileData(uri);
const buffer = Buffer.from(fileData);
const mimeType = mime.getType(uri);
return {
base64: `data:${mimeType};base64,${buffer.toString('base64')}`,
dimensions: getImageSize(buffer),
};
} catch (e) {
if (fallbackUri) {
return getImageData(fallbackUri, fallbackUri);
}
throw e;
}
}
function scaleImageSize(size: { width: number; height: number }, desiredHeight: number) {
const scale = desiredHeight / size.height;
return {
width: size.width * scale,
height: desiredHeight,
};
}
function cleanPageTitle(appName: string) {
if (appName === APP_CONFIG_DEFAULTS.pageTitle) {
return 'Voice agent';
}
return appName;
}
export const contentType = 'image/png';
// Image generation
export default async function Image() {
const hdrs = await headers();
const appConfig = await getAppConfig(hdrs);
const pageTitle = cleanPageTitle(appConfig.pageTitle);
const logoUri = appConfig.logoDark || appConfig.logo;
const isLogoUriLocal = logoUri.includes('lk-logo');
const wordmarkUri = logoUri === APP_CONFIG_DEFAULTS.logoDark ? 'public/lk-wordmark.svg' : logoUri;
// Load fonts - use file system in dev, fetch in production
let commitMonoData: ArrayBuffer | undefined;
let everettLightData: ArrayBuffer | undefined;
try {
commitMonoData = await loadFileData('public/commit-mono-400-regular.woff');
everettLightData = await loadFileData('public/everett-light.woff');
} catch (e) {
console.error('Failed to load fonts:', e);
// Continue without custom fonts - will fall back to system fonts
}
// bg
const { base64: bgSrcBase64 } = await getImageData('public/opengraph-image-bg.png');
// wordmark
const { base64: wordmarkSrcBase64, dimensions: wordmarkDimensions } = isLogoUriLocal
? await getImageData(wordmarkUri)
: await getImageData(logoUri);
const wordmarkSize = scaleImageSize(wordmarkDimensions, isLogoUriLocal ? 32 : 64);
// logo
const { base64: logoSrcBase64, dimensions: logoDimensions } = await getImageData(
logoUri,
'public/lk-logo-dark.svg'
);
const logoSize = scaleImageSize(logoDimensions, 24);
return new ImageResponse(
(
// ImageResponse JSX element
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: size.width,
height: size.height,
backgroundImage: `url(${bgSrcBase64})`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
>
{/* wordmark */}
<div
style={{
position: 'absolute',
top: 30,
left: 30,
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={wordmarkSrcBase64} width={wordmarkSize.width} height={wordmarkSize.height} />
</div>
{/* logo */}
<div
style={{
position: 'absolute',
top: 200,
left: 460,
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={logoSrcBase64} width={logoSize.width} height={logoSize.height} />
</div>
{/* title */}
<div
style={{
position: 'absolute',
bottom: 100,
left: 30,
width: '380px',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
<div
style={{
backgroundColor: '#1F1F1F',
padding: '2px 8px',
borderRadius: 4,
width: 72,
fontSize: 12,
fontFamily: 'CommitMono',
fontWeight: 600,
color: '#999999',
letterSpacing: 0.8,
}}
>
SANDBOX
</div>
<div
style={{
fontSize: 48,
fontWeight: 300,
fontFamily: 'Everett',
color: 'white',
lineHeight: 1,
}}
>
{pageTitle}
</div>
</div>
</div>
),
// ImageResponse options
{
// For convenience, we can re-use the exported opengraph-image
// size config to also set the ImageResponse's width and height.
...size,
fonts: [
...(commitMonoData
? [
{
name: 'CommitMono',
data: commitMonoData,
style: 'normal' as const,
weight: 400 as const,
},
]
: []),
...(everettLightData
? [
{
name: 'Everett',
data: everettLightData,
style: 'normal' as const,
weight: 300 as const,
},
]
: []),
],
}
);
}
@@ -1,10 +0,0 @@
import { headers } from 'next/headers';
import { App } from '@/components/app/app';
import { getAppConfig } from '@/lib/utils';
export default async function Page() {
const hdrs = await headers();
const appConfig = await getAppConfig(hdrs);
return <App appConfig={appConfig} />;
}
@@ -1,25 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/shadcn/utils",
"ui": "@/components/ui",
"lib": "@/lib/shadcn",
"hooks": "@/hooks"
},
"registries": {
"@agents-ui": "https://livekit.io/ui/r/{name}.json",
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json"
}
}
@@ -1,196 +0,0 @@
'use client';
import React, {
type CSSProperties,
Children,
type ComponentProps,
type ReactNode,
cloneElement,
isValidElement,
useMemo,
} from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
import {
type AgentState,
type TrackReferenceOrPlaceholder,
useMultibandTrackVolume,
} from '@livekit/components-react';
import { useAgentAudioVisualizerBarAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-bar';
import { cn } from '@/lib/shadcn/utils';
function cloneSingleChild(
children: ReactNode | ReactNode[],
props?: Record<string, unknown>,
key?: unknown
) {
return Children.map(children, (child) => {
// Checking isValidElement is the safe way and avoids a typescript error too.
if (isValidElement(child) && Children.only(children)) {
const childProps = child.props as Record<string, unknown>;
if (childProps.className) {
// make sure we retain classnames of both passed props and child
props ??= {};
props.className = cn(childProps.className as string, props.className as string);
props.style = {
...(childProps.style as CSSProperties),
...(props.style as CSSProperties),
};
}
return cloneElement(child, { ...props, key: key ? String(key) : undefined });
}
return child;
});
}
export const AgentAudioVisualizerBarVariants = cva(
[
'relative flex items-center justify-center',
'*:rounded-full *:transition-colors *:duration-250 *:ease-linear',
'*:bg-transparent *:data-[lk-highlighted=true]:bg-current',
],
{
variants: {
size: {
icon: ['h-[24px] gap-[2px]', '*:w-[4px] *:min-h-[4px]'],
sm: ['h-[56px] gap-[4px]', '*:w-[8px] *:min-h-[8px]'],
md: ['h-[112px] gap-[8px]', '*:w-[16px] *:min-h-[16px]'],
lg: ['h-[224px] gap-[16px]', '*:w-[32px] *:min-h-[32px]'],
xl: ['h-[448px] gap-[32px]', '*:w-[64px] *:min-h-[64px]'],
},
},
defaultVariants: {
size: 'md',
},
}
);
/**
* Props for the AgentAudioVisualizerBar component.
*/
export interface AgentAudioVisualizerBarProps {
/**
* The size of the visualizer.
* @defaultValue 'md'
*/
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
/**
* The current state of the agent. Determines the animation pattern.
* @defaultValue 'connecting'
*/
state?: AgentState;
/**
* The number of bars to display in the visualizer.
* If not provided, defaults based on size: 3 for 'icon'/'sm', 5 for others.
*/
barCount?: number;
/**
* The audio track to visualize. Can be a local/remote audio track or a track reference.
*/
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
/**
* Additional CSS class names to apply to the container.
*/
className?: string;
/**
* Custom children to render as bars. Each child receives data-lk-index,
* data-lk-highlighted, and style props for height.
*/
children?: ReactNode | ReactNode[];
}
/**
* A bar-style audio visualizer that responds to agent state and audio levels.
* Displays animated bars that react to the current agent state (connecting, thinking, speaking, etc.)
* and audio volume when speaking.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentAudioVisualizerBar
* size="md"
* state="speaking"
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerBar({
size = 'md',
state = 'connecting',
barCount,
audioTrack,
className,
children,
...props
}: AgentAudioVisualizerBarProps &
VariantProps<typeof AgentAudioVisualizerBarVariants> &
ComponentProps<'div'>) {
const _barCount = useMemo(() => {
if (barCount) {
return barCount;
}
switch (size) {
case 'icon':
case 'sm':
return 3;
default:
return 5;
}
}, [barCount, size]);
const volumeBands = useMultibandTrackVolume(audioTrack, {
bands: _barCount,
loPass: 100,
hiPass: 200,
});
const sequencerInterval = useMemo(() => {
switch (state) {
case 'connecting':
return 2000 / _barCount;
case 'initializing':
return 2000;
case 'listening':
return 500;
case 'thinking':
return 150;
default:
return 1000;
}
}, [state, _barCount]);
const highlightedIndices = useAgentAudioVisualizerBarAnimator(
state,
_barCount,
sequencerInterval
);
const bands = useMemo(
() => (state === 'speaking' ? volumeBands : new Array(_barCount).fill(0)),
[state, volumeBands, _barCount]
);
return (
<div className={cn(AgentAudioVisualizerBarVariants({ size }), className)} {...props}>
{bands.map((band: number, idx: number) =>
children ? (
<React.Fragment key={idx}>
{cloneSingleChild(children, {
'data-lk-index': idx,
'data-lk-highlighted': highlightedIndices.includes(idx),
style: { height: `${band * 100}%` },
})}
</React.Fragment>
) : (
<div
key={idx}
data-lk-index={idx}
data-lk-highlighted={highlightedIndices.includes(idx)}
style={{ height: `${band * 100}%` }}
/>
)
)}
</div>
);
}
@@ -1,290 +0,0 @@
'use client';
import React, {
type CSSProperties,
Children,
type ComponentProps,
type ReactNode,
cloneElement,
isValidElement,
memo,
useMemo,
} from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
import {
type AgentState,
type TrackReferenceOrPlaceholder,
useMultibandTrackVolume,
} from '@livekit/components-react';
import {
type Coordinate,
useAgentAudioVisualizerGridAnimator,
} from '@/hooks/agents-ui/use-agent-audio-visualizer-grid';
import { cn } from '@/lib/shadcn/utils';
function cloneSingleChild(
children: ReactNode | ReactNode[],
props?: Record<string, unknown>,
key?: unknown
) {
return Children.map(children, (child) => {
// Checking isValidElement is the safe way and avoids a typescript error too.
if (isValidElement(child) && Children.only(children)) {
const childProps = child.props as Record<string, unknown>;
if (childProps.className) {
// make sure we retain classnames of both passed props and child
props ??= {};
props.className = cn(childProps.className as string, props.className as string);
props.style = {
...(childProps.style as CSSProperties),
...(props.style as CSSProperties),
};
}
return cloneElement(child, { ...props, key: key ? String(key) : undefined });
}
return child;
});
}
export const AgentAudioVisualizerGridVariants = cva(
[
'grid',
'*:size-1 *:rounded-full',
'*:bg-foreground/10 [&_>_[data-lk-highlighted=true]]:bg-foreground [&_>_[data-lk-highlighted=true]]:scale-125 [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_10px_2px_rgba(255,255,255,0.4)]',
],
{
variants: {
size: {
icon: ['gap-[2px] *:size-[4px]'],
sm: ['gap-[4px] *:size-[4px]'],
md: ['gap-[8px] *:size-[8px]'],
lg: ['gap-[8px] *:size-[8px]'],
xl: ['gap-[8px] *:size-[8px]'],
},
},
defaultVariants: {
size: 'md',
},
}
);
/**
* Configuration options for the grid visualizer.
*/
export interface GridOptions {
/**
* The radius for the animation spread effect.
*/
radius?: number;
/**
* The interval in milliseconds between animation frames.
* @defaultValue 100
*/
interval?: number;
/**
* The number of rows in the grid.
* @defaultValue 5
*/
rowCount?: number;
/**
* The number of columns in the grid.
* @defaultValue 5
*/
columnCount?: number;
/**
* A function to transform the style of each grid cell based on its position.
* Receives the cell index, row count, and column count as arguments.
*/
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
/**
* Additional CSS class names to apply to the container.
*/
className?: string;
/**
* Custom children to render as grid cells.
*/
children?: ReactNode;
}
const sizeDefaults = {
icon: 3,
sm: 5,
md: 5,
lg: 5,
xl: 5,
};
function useGrid(
size: VariantProps<typeof AgentAudioVisualizerGridVariants>['size'] = 'md',
columnCount = sizeDefaults[size as keyof typeof sizeDefaults],
rowCount = sizeDefaults[size as keyof typeof sizeDefaults]
) {
return useMemo(() => {
const _columnCount = columnCount;
const _rowCount = rowCount ?? columnCount;
const items = new Array(_columnCount * _rowCount).fill(0).map((_, idx) => idx);
return { columnCount: _columnCount, rowCount: _rowCount, items };
}, [columnCount, rowCount]);
}
interface GridCellProps {
index: number;
state: AgentState;
interval: number;
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
rowCount: number;
columnCount: number;
volumeBands: number[];
highlightedCoordinate: Coordinate;
children: ReactNode;
}
const GridCell = memo(function GridCell({
index,
state,
interval,
transformer,
rowCount,
columnCount,
volumeBands,
highlightedCoordinate,
children,
}: GridCellProps) {
if (state === 'speaking') {
const y = Math.floor(index / columnCount);
const rowMidPoint = Math.floor(rowCount / 2);
const volumeChunks = 1 / (rowMidPoint + 1);
const distanceToMid = Math.abs(rowMidPoint - y);
const threshold = distanceToMid * volumeChunks;
const isHighlighted = (volumeBands[index % columnCount] ?? 0) >= threshold;
return cloneSingleChild(children, {
'data-lk-index': index,
'data-lk-highlighted': isHighlighted,
});
}
let transformerStyle: CSSProperties | undefined;
if (transformer) {
transformerStyle = transformer(index, rowCount, columnCount);
}
const isHighlighted =
highlightedCoordinate.x === index % columnCount &&
highlightedCoordinate.y === Math.floor(index / columnCount);
const transitionDurationInSeconds = interval / (isHighlighted ? 1000 : 100);
return cloneSingleChild(children, {
'data-lk-index': index,
'data-lk-highlighted': isHighlighted,
style: {
transitionProperty: 'all',
transitionDuration: `${transitionDurationInSeconds}s`,
transitionTimingFunction: 'ease-out',
...transformerStyle,
},
});
});
/**
* Props for the AgentAudioVisualizerGrid component.
*/
export type AgentAudioVisualizerGridProps = GridOptions & {
/**
* The size of the visualizer.
* @defaultValue 'md'
*/
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
/**
* The current state of the agent. Determines the animation pattern.
* @defaultValue 'connecting'
*/
state?: AgentState;
/**
* The audio track to visualize. Can be a local/remote audio track or a track reference.
*/
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
/**
* Additional CSS class names to apply to the container.
*/
className?: string;
/**
* Custom children to render as grid cells. Each child receives data-lk-index
* and data-lk-highlighted props.
*/
children?: ReactNode;
} & VariantProps<typeof AgentAudioVisualizerGridVariants>;
/**
* A grid-style audio visualizer that responds to agent state and audio levels.
* Displays an animated grid of cells that react to the current agent state
* and audio volume when speaking.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentAudioVisualizerGrid
* size="md"
* state="speaking"
* rowCount={5}
* columnCount={5}
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerGrid({
size = 'md',
state = 'connecting',
radius,
rowCount: _rowCount = 5,
columnCount: _columnCount = 5,
transformer,
interval = 100,
className,
children,
audioTrack,
style,
...props
}: AgentAudioVisualizerGridProps & ComponentProps<'div'>) {
const { columnCount, rowCount, items } = useGrid(size, _columnCount, _rowCount);
const highlightedCoordinate = useAgentAudioVisualizerGridAnimator(
state,
rowCount,
columnCount,
interval,
radius
);
const volumeBands = useMultibandTrackVolume(audioTrack, {
bands: columnCount,
loPass: 100,
hiPass: 200,
});
return (
<div
className={cn(AgentAudioVisualizerGridVariants({ size }), className)}
style={{ ...style, gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}
{...props}
>
{items.map((idx) => (
<GridCell
key={idx}
index={idx}
state={state}
interval={interval}
transformer={transformer}
rowCount={rowCount}
columnCount={columnCount}
volumeBands={volumeBands}
highlightedCoordinate={highlightedCoordinate}
>
{children ?? <div />}
</GridCell>
))}
</div>
);
}
@@ -1,205 +0,0 @@
'use client';
import { type ComponentProps, useMemo } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
import {
type AgentState,
type TrackReferenceOrPlaceholder,
useMultibandTrackVolume,
} from '@livekit/components-react';
import { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-radial';
import { cn } from '@/lib/shadcn/utils';
export const AgentAudioVisualizerRadialVariants = cva(
[
'relative flex items-center justify-center',
'[&_[data-lk-index]]:absolute [&_[data-lk-index]]:top-1/2 [&_[data-lk-index]]:left-1/2 [&_[data-lk-index]]:origin-bottom [&_[data-lk-index]]:-translate-x-1/2',
'[&_[data-lk-index]]:rounded-full [&_[data-lk-index]]:transition-colors [&_[data-lk-index]]:duration-150 [&_[data-lk-index]]:ease-linear [&_[data-lk-index]]:bg-transparent [&_[data-lk-index]]:data-[lk-highlighted=true]:bg-current',
'has-data-[lk-state=connecting]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=connecting]:[&_[data-lk-index]]:bg-current/10',
'has-data-[lk-state=initializing]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=initializing]:[&_[data-lk-index]]:bg-current/10',
'has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=listening]:[&_[data-lk-index]]:bg-current/10 has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300',
'has-data-[lk-state=thinking]:animate-spin has-data-[lk-state=thinking]:[animation-duration:5s] has-data-[lk-state=thinking]:[&_[data-lk-index]]:bg-current',
],
{
variants: {
size: {
icon: ['h-[24px] gap-[2px]'],
sm: ['h-[56px] gap-[4px]'],
md: ['h-[112px] gap-[8px]'],
lg: ['h-[224px] gap-[16px]'],
xl: ['h-[448px] gap-[32px]'],
},
},
defaultVariants: {
size: 'md',
},
}
);
/**
* Props for the AgentAudioVisualizerRadial component.
*/
export interface AgentAudioVisualizerRadialProps {
/**
* The size of the visualizer.
* @defaultValue 'md'
*/
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
/**
* The current state of the agent. Determines the animation pattern.
* @defaultValue 'connecting'
*/
state?: AgentState;
/**
* The radius (distance from center) for the radial bars.
* If not provided, defaults based on size.
*/
radius?: number;
/**
* The number of bars to display around the circle.
* Should be divisible by 4 for optimal visual results.
* If not provided, defaults to 12 for 'icon'/'sm', 24 for others.
*/
barCount?: number;
/**
* The audio track to visualize. Can be a local/remote audio track or a track reference.
*/
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
/**
* Additional CSS class names to apply to the container.
*/
className?: string;
}
/**
* A radial (circular) audio visualizer that responds to agent state and audio levels.
* Displays animated bars arranged in a circle that react to the current agent state
* and audio volume when speaking.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentAudioVisualizerRadial
* size="lg"
* state="speaking"
* barCount={24}
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerRadial({
size = 'md',
state = 'connecting',
radius,
barCount,
audioTrack,
className,
...props
}: AgentAudioVisualizerRadialProps &
ComponentProps<'div'> &
VariantProps<typeof AgentAudioVisualizerRadialVariants>) {
const _barCount = useMemo(() => {
if (barCount) {
return barCount;
}
switch (size) {
case 'icon':
case 'sm':
return 12;
default:
return 24;
}
}, [barCount, size]);
const volumeBands = useMultibandTrackVolume(audioTrack, {
bands: _barCount,
loPass: 100,
hiPass: 200,
});
const sequencerInterval = useMemo(() => {
switch (state) {
case 'connecting':
case 'listening':
return 500;
case 'initializing':
return 250;
case 'thinking':
return Infinity;
default:
return 1000;
}
}, [state, _barCount]);
const distanceFromCenter = useMemo(() => {
if (radius) {
return radius;
}
switch (size) {
case 'icon':
return 6;
case 'xl':
return 128;
case 'lg':
return 64;
case 'sm':
return 16;
case 'md':
default:
return 32;
}
}, [size, radius]);
if (_barCount % 4 !== 0) {
console.warn('barCount should be divisible by 4 for optimal visual results');
}
const highlightedIndices = useAgentAudioVisualizerRadialAnimator(
state,
_barCount,
sequencerInterval
);
const bands = useMemo(
() => (audioTrack ? volumeBands : new Array(_barCount).fill(0)),
[audioTrack, volumeBands, _barCount]
);
const dotSize = useMemo(() => {
return (distanceFromCenter * Math.PI) / _barCount;
}, [distanceFromCenter, _barCount]);
return (
<div
className={cn(AgentAudioVisualizerRadialVariants({ size }), 'relative', className)}
{...props}
>
{bands.map((band, idx) => {
const angle = (idx / _barCount) * Math.PI * 2;
return (
<div
key={`${_barCount}-${idx}`}
data-lk-state={state}
className="absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2"
style={{
transformOrigin: 'center',
transform: `rotate(${angle}rad) translateY(${distanceFromCenter}px)`,
}}
>
<div
data-lk-index={idx}
data-lk-highlighted={highlightedIndices.includes(idx)}
style={{
width: dotSize,
minHeight: dotSize,
height: state === 'speaking' ? `${dotSize * 10 * band}px` : 0,
}}
/>
</div>
);
})}
</div>
);
}
@@ -1,89 +0,0 @@
import { type Ref } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { type MotionProps, motion } from 'motion/react';
import { cn } from '@/lib/shadcn/utils';
const motionAnimationProps = {
variants: {
hidden: {
opacity: 0,
scale: 0.1,
transition: {
duration: 0.1,
ease: 'linear' as const,
},
},
visible: {
opacity: [0.5, 1],
scale: [1, 1.2],
transition: {
type: 'spring' as const,
bounce: 0,
duration: 0.5,
repeat: Infinity,
repeatType: 'mirror' as const,
},
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
};
const agentChatIndicatorVariants = cva('bg-muted-foreground inline-block size-2.5 rounded-full', {
variants: {
size: {
sm: 'size-2.5',
md: 'size-4',
lg: 'size-6',
},
},
defaultVariants: {
size: 'md',
},
});
/**
* Props for the AgentChatIndicator component.
*/
export interface AgentChatIndicatorProps extends MotionProps {
/**
* The size of the indicator dot.
* @defaultValue 'md'
*/
size?: 'sm' | 'md' | 'lg';
/**
* Additional CSS class names to apply to the indicator.
*/
className?: string;
/**
* Allows getting a ref to the component instance.\nOnce the component unmounts, React will set `ref.current` to `null`\n(or call the ref with `null` if you passed a callback ref).\n@see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
*/
ref?: Ref<HTMLSpanElement>;
}
/**
* An animated indicator that shows the agent is processing or thinking.
* Displays as a pulsing dot, typically used in chat interfaces.
*
* @extends ComponentProps<'span'>
*
* @example
* ```tsx
* {agentState === 'thinking' && <AgentChatIndicator size="md" />}
* ```
*/
export function AgentChatIndicator({
size = 'md',
className,
...props
}: AgentChatIndicatorProps & VariantProps<typeof agentChatIndicatorVariants>) {
return (
<motion.span
{...motionAnimationProps}
transition={{ duration: 0.1, ease: 'linear' as const }}
className={cn(agentChatIndicatorVariants({ size }), className)}
{...props}
/>
);
}
@@ -1,78 +0,0 @@
'use client';
import { AnimatePresence } from 'motion/react';
import { type AgentState, type ReceivedMessage } from '@livekit/components-react';
import { AgentChatIndicator } from '@/components/agents-ui/agent-chat-indicator';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message';
/**
* Props for the AgentChatTranscript component.
*/
export interface AgentChatTranscriptProps {
/**
* The current state of the agent. When 'thinking', displays a loading indicator.
*/
agentState?: AgentState;
/**
* Array of messages to display in the transcript.
* @defaultValue []
*/
messages?: ReceivedMessage[];
/**
* Additional CSS class names to apply to the conversation container.
*/
className?: string;
}
/**
* A chat transcript component that displays a conversation between the user and agent.
* Shows messages with timestamps and origin indicators, plus a thinking indicator
* when the agent is processing.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentChatTranscript
* agentState={agentState}
* messages={chatMessages}
* />
* ```
*/
export function AgentChatTranscript({
agentState,
messages = [],
className,
...props
}: AgentChatTranscriptProps) {
return (
<Conversation className={className} {...props}>
<ConversationContent>
{messages.map((receivedMessage) => {
const { id, timestamp, from, message } = receivedMessage;
const locale = navigator?.language ?? 'en-US';
const messageOrigin = from?.isLocal ? 'user' : 'assistant';
const time = new Date(timestamp);
const title = time.toLocaleTimeString(locale, { timeStyle: 'full' });
return (
<Message key={id} title={title} from={messageOrigin}>
<MessageContent>
<MessageResponse>{message}</MessageResponse>
</MessageContent>
</Message>
);
})}
<AnimatePresence>
{agentState === 'thinking' && <AgentChatIndicator size="sm" />}
</AnimatePresence>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
}
@@ -1,392 +0,0 @@
'use client';
import { type ComponentProps, useEffect, useRef, useState } from 'react';
import { Track } from 'livekit-client';
import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react';
import { motion } from 'motion/react';
import { useChat } from '@livekit/components-react';
import { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button';
import { AgentTrackControl } from '@/components/agents-ui/agent-track-control';
import {
AgentTrackToggle,
agentTrackToggleVariants,
} from '@/components/agents-ui/agent-track-toggle';
import { Button } from '@/components/ui/button';
import { Toggle } from '@/components/ui/toggle';
import {
type UseInputControlsProps,
useInputControls,
usePublishPermissions,
} from '@/hooks/agents-ui/use-agent-control-bar';
import { cn } from '@/lib/shadcn/utils';
const TOGGLE_VARIANT_1 = [
'[&_[data-state=off]]:bg-accent [&_[data-state=off]]:hover:bg-foreground/10',
'[&_[data-state=off]_~_button]:bg-accent [&_[data-state=off]_~_button]:hover:bg-foreground/10',
'[&_[data-state=off]]:border-border [&_[data-state=off]]:hover:border-foreground/12',
'[&_[data-state=off]_~_button]:border-border [&_[data-state=off]_~_button]:hover:border-foreground/12',
'[&_[data-state=off]]:text-destructive [&_[data-state=off]]:hover:text-destructive [&_[data-state=off]]:focus:text-destructive',
'[&_[data-state=off]]:focus-visible:ring-foreground/12 [&_[data-state=off]]:focus-visible:border-ring',
'dark:[&_[data-state=off]_~_button]:bg-accent dark:[&_[data-state=off]_~_button:hover]:bg-foreground/10',
];
const TOGGLE_VARIANT_2 = [
'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12',
'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground',
'data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30',
'data-[state=on]:border-blue-700/10 data-[state=on]:text-blue-700 data-[state=on]:ring-blue-700/30',
'data-[state=on]:focus-visible:border-blue-700/50',
'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',
];
const MOTION_PROPS = {
variants: {
hidden: {
height: 0,
opacity: 0,
marginBottom: 0,
},
visible: {
height: 'auto',
opacity: 1,
marginBottom: 12,
},
},
initial: 'hidden',
transition: {
duration: 0.3,
ease: 'easeOut',
},
};
interface AgentChatInputProps {
chatOpen: boolean;
onSend?: (message: string) => void;
className?: string;
}
function AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentChatInputProps) {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [isSending, setIsSending] = useState(false);
const [message, setMessage] = useState<string>('');
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
setIsSending(true);
await onSend(message);
setMessage('');
} catch (error) {
console.error(error);
} finally {
setIsSending(false);
}
};
const isDisabled = isSending || message.trim().length === 0;
useEffect(() => {
if (chatOpen) return;
// when not disabled refocus on input
inputRef.current?.focus();
}, [chatOpen]);
return (
<form
onSubmit={handleSubmit}
className={cn('mb-3 flex grow items-end gap-2 rounded-md pl-1 text-sm', className)}
>
<textarea
autoFocus
ref={inputRef}
value={message}
disabled={!chatOpen}
placeholder="Type something..."
onChange={(e) => setMessage(e.target.value)}
className="field-sizing-content max-h-16 min-h-8 flex-1 py-2 [scrollbar-width:thin] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
size="icon"
type="submit"
disabled={isDisabled}
variant={isDisabled ? 'secondary' : 'default'}
title={isSending ? 'Sending...' : 'Send'}
className="self-end disabled:cursor-not-allowed"
>
{isSending ? <Loader className="animate-spin" /> : <SendHorizontal />}
</Button>
</form>
);
}
/**
* Configuration for which controls to display in the AgentControlBar.
*/
export interface AgentControlBarControls {
/**
* Whether to show the leave/disconnect button.
* @defaultValue true
*/
leave?: boolean;
/**
* Whether to show the camera toggle control.
* @defaultValue true (if camera publish permission is granted)
*/
camera?: boolean;
/**
* Whether to show the microphone toggle control.
* @defaultValue true (if microphone publish permission is granted)
*/
microphone?: boolean;
/**
* Whether to show the screen share toggle control.
* @defaultValue true (if screen share publish permission is granted)
*/
screenShare?: boolean;
/**
* Whether to show the chat toggle control.
* @defaultValue true (if data publish permission is granted)
*/
chat?: boolean;
}
export interface AgentControlBarProps extends UseInputControlsProps {
/**
* The visual style of the control bar.
* @default 'default'
*/
variant?: 'default' | 'outline' | 'livekit';
/**
* This takes an object with the following keys: `leave`, `microphone`, `screenShare`, `camera`, `chat`.
* Each key maps to a boolean value that determines whether the control is displayed.
*
* @default
* {
* leave: true,
* microphone: true,
* screenShare: true,
* camera: true,
* chat: true,
* }
*/
controls?: AgentControlBarControls;
/**
* Whether to save user choices.
* @default true
*/
saveUserChoices?: boolean;
/**
* Whether the agent is connected to a session.
* @default false
*/
isConnected?: boolean;
/**
* Whether the chat input interface is open.
* @default false
*/
isChatOpen?: boolean;
/**
* The callback for when the user disconnects.
*/
onDisconnect?: () => void;
/**
* The callback for when the chat is opened or closed.
*/
onIsChatOpenChange?: (open: boolean) => void;
/**
* The callback for when a device error occurs.
*/
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
}
/**
* A control bar specifically designed for voice assistant interfaces.
* Provides controls for microphone, camera, screen share, chat, and disconnect.
* Includes an expandable chat input for text-based interaction with the agent.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentControlBar
* variant="livekit"
* isConnected={true}
* onDisconnect={() => handleDisconnect()}
* controls={{
* microphone: true,
* camera: true,
* screenShare: false,
* chat: true,
* leave: true,
* }}
* />
* ```
*/
export function AgentControlBar({
variant = 'default',
controls,
isChatOpen = false,
isConnected = false,
saveUserChoices = true,
onDisconnect,
onDeviceError,
onIsChatOpenChange,
className,
...props
}: AgentControlBarProps & ComponentProps<'div'>) {
const { send } = useChat();
const publishPermissions = usePublishPermissions();
const [isChatOpenUncontrolled, setIsChatOpenUncontrolled] = useState(isChatOpen);
const {
micTrackRef,
cameraToggle,
microphoneToggle,
screenShareToggle,
handleAudioDeviceChange,
handleVideoDeviceChange,
handleMicrophoneDeviceSelectError,
handleCameraDeviceSelectError,
} = useInputControls({ onDeviceError, saveUserChoices });
const handleSendMessage = async (message: string) => {
await send(message);
};
const visibleControls = {
leave: controls?.leave ?? true,
microphone: controls?.microphone ?? publishPermissions.microphone,
screenShare: controls?.screenShare ?? publishPermissions.screenShare,
camera: controls?.camera ?? publishPermissions.camera,
chat: controls?.chat ?? publishPermissions.data,
};
const isEmpty = Object.values(visibleControls).every((value) => !value);
if (isEmpty) {
console.warn('AgentControlBar: `visibleControls` contains only false values.');
return null;
}
return (
<div
aria-label="Voice assistant controls"
className={cn(
'bg-background border-input/50 dark:border-muted flex flex-col border p-3 drop-shadow-md/3',
variant === 'livekit' ? 'rounded-[31px]' : 'rounded-lg',
className
)}
{...props}
>
<motion.div
{...MOTION_PROPS}
inert={!(isChatOpen || isChatOpenUncontrolled)}
animate={isChatOpen || isChatOpenUncontrolled ? 'visible' : 'hidden'}
className="border-input/50 flex w-full items-start overflow-hidden border-b"
>
<AgentChatInput
chatOpen={isChatOpen || isChatOpenUncontrolled}
onSend={handleSendMessage}
className={cn(variant === 'livekit' && '[&_button]:rounded-full')}
/>
</motion.div>
<div className="flex gap-1">
<div className="flex grow gap-1">
{/* Toggle Microphone */}
{visibleControls.microphone && (
<AgentTrackControl
variant={variant === 'outline' ? 'outline' : 'default'}
kind="audioinput"
aria-label="Toggle microphone"
source={Track.Source.Microphone}
pressed={microphoneToggle.enabled}
disabled={microphoneToggle.pending}
audioTrack={micTrackRef}
onPressedChange={microphoneToggle.toggle}
onActiveDeviceChange={handleAudioDeviceChange}
onMediaDeviceError={handleMicrophoneDeviceSelectError}
className={cn(
variant === 'livekit' && [
TOGGLE_VARIANT_1,
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
]
)}
/>
)}
{/* Toggle Camera */}
{visibleControls.camera && (
<AgentTrackControl
variant={variant === 'outline' ? 'outline' : 'default'}
kind="videoinput"
aria-label="Toggle camera"
source={Track.Source.Camera}
pressed={cameraToggle.enabled}
pending={cameraToggle.pending}
disabled={cameraToggle.pending}
onPressedChange={cameraToggle.toggle}
onMediaDeviceError={handleCameraDeviceSelectError}
onActiveDeviceChange={handleVideoDeviceChange}
className={cn(
variant === 'livekit' && [
TOGGLE_VARIANT_1,
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
]
)}
/>
)}
{/* Toggle Screen Share */}
{visibleControls.screenShare && (
<AgentTrackToggle
variant={variant === 'outline' ? 'outline' : 'default'}
aria-label="Toggle screen share"
source={Track.Source.ScreenShare}
pressed={screenShareToggle.enabled}
disabled={screenShareToggle.pending}
onPressedChange={screenShareToggle.toggle}
className={cn(variant === 'livekit' && [TOGGLE_VARIANT_2, 'rounded-full'])}
/>
)}
{/* Toggle Transcript */}
{visibleControls.chat && (
<Toggle
variant={variant === 'outline' ? 'outline' : 'default'}
pressed={isChatOpen || isChatOpenUncontrolled}
aria-label="Toggle transcript"
onPressedChange={(state) => {
if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state);
else onIsChatOpenChange(state);
}}
className={agentTrackToggleVariants({
variant: variant === 'outline' ? 'outline' : 'default',
className: cn(variant === 'livekit' && [TOGGLE_VARIANT_2, 'rounded-full']),
})}
>
<MessageSquareTextIcon />
</Toggle>
)}
</div>
{/* Disconnect */}
{visibleControls.leave && (
<AgentDisconnectButton
onClick={onDisconnect}
disabled={!isConnected}
className={cn(
variant === 'livekit' &&
'bg-destructive/10 dark:bg-destructive/10 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/4 rounded-full font-mono text-xs font-bold tracking-wider'
)}
>
<span className="hidden md:inline">END CALL</span>
<span className="inline md:hidden">END</span>
</AgentDisconnectButton>
)}
</div>
</div>
);
}
@@ -1,35 +0,0 @@
'use client';
import { type VariantProps } from 'class-variance-authority';
import { PhoneOffIcon } from 'lucide-react';
import { useSessionContext } from '@livekit/components-react';
import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/shadcn/utils';
export interface AgentDisconnectButtonProps
extends React.ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {
icon?: React.ReactNode;
children?: React.ReactNode;
}
export function AgentDisconnectButton({
icon,
size = 'default',
children,
onClick,
...props
}: AgentDisconnectButtonProps) {
const { end } = useSessionContext();
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
end();
};
return (
<Button variant="destructive" size={size} onClick={handleClick} {...props}>
{icon ?? <PhoneOffIcon />}
{children ?? <span className={cn(size?.includes('icon') && 'sr-only')}>END CALL</span>}
</Button>
);
}
@@ -1,61 +0,0 @@
import { Room } from 'livekit-client';
import {
RoomAudioRenderer,
type RoomAudioRendererProps,
SessionProvider,
type SessionProviderProps,
type UseSessionReturn,
} from '@livekit/components-react';
/**
* Props for the AgentSessionProvider component.
* Combines SessionProviderProps with RoomAudioRendererProps.
*/
export type AgentSessionProviderProps = SessionProviderProps &
RoomAudioRendererProps & {
/**
* The room to provide.
*/
room?: Room;
/**
* The volume to set for the audio renderer.
*/
volume?: number;
/**
* Whether to mute the audio renderer.
*/
muted?: boolean;
/**
* The session to provide.
*/
session: UseSessionReturn;
/**
* The children to render.
*/
children: React.ReactNode;
};
/**
* A provider component for agent sessions that wraps SessionProvider
* and includes RoomAudioRenderer for audio playback.
*
* @example
* ```tsx
* <AgentSessionProvider session={agentSession}>
* <AgentControlBar />
* <AgentChatTranscript />
* </AgentSessionProvider>
* ```
*/
export function AgentSessionProvider({
session,
children,
...roomAudioRendererProps
}: AgentSessionProviderProps) {
return (
<SessionProvider session={session}>
{children}
<RoomAudioRenderer {...roomAudioRendererProps} />
</SessionProvider>
);
}
@@ -1,323 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';
import {
type TrackReferenceOrPlaceholder,
useMaybeRoomContext,
useMediaDeviceSelect,
} from '@livekit/components-react';
import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar';
import { AgentTrackToggle } from '@/components/agents-ui/agent-track-toggle';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toggleVariants } from '@/components/ui/toggle';
import { cn } from '@/lib/shadcn/utils';
const selectVariants = cva(
[
'rounded-l-none shadow-none pl-2 ',
'text-foreground hover:text-muted-foreground',
'peer-data-[state=on]/track:bg-muted peer-data-[state=on]/track:hover:bg-foreground/10',
'peer-data-[state=off]/track:text-destructive',
'peer-data-[state=off]/track:focus-visible:border-destructive peer-data-[state=off]/track:focus-visible:ring-destructive/30',
'[&_svg]:opacity-100',
],
{
variants: {
variant: {
default: [
'border-none',
'peer-data-[state=off]/track:bg-destructive/10',
'peer-data-[state=off]/track:hover:bg-destructive/15',
'peer-data-[state=off]/track:[&_svg]:!text-destructive',
'dark:peer-data-[state=on]/track:bg-accent',
'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
'dark:peer-data-[state=off]/track:bg-destructive/10',
'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
],
outline: [
'border border-l-0',
'peer-data-[state=off]/track:border-destructive/20',
'peer-data-[state=off]/track:bg-destructive/10',
'peer-data-[state=off]/track:hover:bg-destructive/15',
'peer-data-[state=off]/track:[&_svg]:!text-destructive',
'peer-data-[state=on]/track:hover:border-foreground/12',
'dark:peer-data-[state=off]/track:bg-destructive/10',
'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
'dark:peer-data-[state=on]/track:bg-accent',
'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
],
},
size: {
default: 'w-[180px]',
sm: 'w-auto',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
/**
* Props for the TrackDeviceSelect component. */
type TrackDeviceSelectProps = React.ComponentProps<typeof SelectTrigger> &
VariantProps<typeof selectVariants> & {
/**
* The size of the select.
* @defaultValue 'default'
*/
size?: 'default' | 'sm';
/**
* The variant of the select.
* @defaultValue 'default'
*/
variant?: 'default' | 'outline' | null;
/**
* The type of media device (audioinput or videoinput).
*/
kind: MediaDeviceKind;
/**
* The track source to control (Microphone, Camera, or ScreenShare).
*/
track?: LocalAudioTrack | LocalVideoTrack | undefined;
/**
* Whether to request permissions for the media device.
*/
requestPermissions?: boolean;
/**
* Callback when a media device error occurs.
*/
onMediaDeviceError?: (error: Error) => void;
/**
* Callback when the device list changes.
*/
onDeviceListChange?: (devices: MediaDeviceInfo[]) => void;
/**
* Callback when the active device changes.
*/
onActiveDeviceChange?: (deviceId: string) => void;
};
/**
* A select component for selecting a media device.
*
* @extends ComponentProps<'button'>
*
* @example
* ```tsx
* <TrackDeviceSelect
* size="sm"
* variant="outline"
* kind="audioinput"
* track={micTrackRef}
* />
* ```
*/
function TrackDeviceSelect({
kind,
track,
size = 'default',
variant = 'default',
className,
requestPermissions = false,
onMediaDeviceError,
onDeviceListChange,
onActiveDeviceChange,
...props
}: TrackDeviceSelectProps) {
const room = useMaybeRoomContext();
const [open, setOpen] = useState(false);
const [requestPermissionsState, setRequestPermissionsState] = useState(requestPermissions);
const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({
room,
kind,
track,
requestPermissions: requestPermissionsState,
onError: onMediaDeviceError,
});
useEffect(() => {
onDeviceListChange?.(devices);
}, [devices, onDeviceListChange]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (open) {
setRequestPermissionsState(true);
}
};
const handleActiveDeviceChange = (deviceId: string) => {
setActiveMediaDevice(deviceId);
onActiveDeviceChange?.(deviceId);
};
const filteredDevices = useMemo(() => devices.filter((d) => d.deviceId !== ''), [devices]);
if (filteredDevices.length < 2) {
return null;
}
return (
<Select
open={open}
value={activeDeviceId}
onOpenChange={handleOpenChange}
onValueChange={handleActiveDeviceChange}
>
<SelectTrigger className={cn(selectVariants({ size, variant }), className)} {...props}>
{size !== 'sm' && (
<SelectValue className="font-mono text-sm" placeholder={`Select a ${kind}`} />
)}
</SelectTrigger>
<SelectContent position="popper">
{filteredDevices.map((device) => (
<SelectItem key={device.deviceId} value={device.deviceId} className="font-mono text-xs">
{device.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/**
* Props for the AgentTrackControl component.
*/
export type AgentTrackControlProps = VariantProps<typeof toggleVariants> & {
/**
* The type of media device (audioinput or videoinput).
*/
kind: MediaDeviceKind;
/**
* The track source to control (Microphone, Camera, or ScreenShare).
*/
source: 'camera' | 'microphone' | 'screen_share';
/**
* Whether the track is currently enabled/published.
*/
pressed?: boolean;
/**
* Whether the control is in a pending/loading state.
*/
pending?: boolean;
/**
* Whether the control is disabled.
*/
disabled?: boolean;
/**
* Additional CSS class names to apply to the container.
*/
className?: string;
/**
* The audio track reference for visualization (only for microphone).
*/
audioTrack?: TrackReferenceOrPlaceholder;
/**
* Callback when the pressed state changes.
*/
onPressedChange?: (pressed: boolean) => void;
/**
* Callback when a media device error occurs.
*/
onMediaDeviceError?: (error: Error) => void;
/**
* Callback when the active device changes.
*/
onActiveDeviceChange?: (deviceId: string) => void;
};
/**
* A combined track toggle and device selector control.
* Includes a toggle button and a dropdown to select the active device.
* For microphone tracks, displays an audio visualizer.
*
* @example
* ```tsx
* <AgentTrackControl
* kind="audioinput"
* source={Track.Source.Microphone}
* pressed={isMicEnabled}
* audioTrack={micTrackRef}
* onPressedChange={(pressed) => setMicEnabled(pressed)}
* onActiveDeviceChange={(deviceId) => setMicDevice(deviceId)}
* />
* ```
*/
export function AgentTrackControl({
kind,
variant = 'default',
source,
pressed,
pending,
disabled,
className,
audioTrack,
onPressedChange,
onMediaDeviceError,
onActiveDeviceChange,
}: AgentTrackControlProps) {
return (
<div
className={cn(
'flex items-center gap-0 rounded-md',
variant === 'outline' && 'shadow-xs [&_button]:shadow-none',
className
)}
>
<AgentTrackToggle
variant={variant ?? 'default'}
source={source}
pressed={pressed}
pending={pending}
disabled={disabled}
onPressedChange={onPressedChange}
className="peer/track group/track focus:z-10 has-[.audiovisualizer]:w-auto has-[.audiovisualizer]:px-3 has-[~_button]:rounded-r-none has-[~_button]:border-r-0 has-[~_button]:pr-2 has-[~_button]:pl-3"
>
{audioTrack && (
<AgentAudioVisualizerBar
size="icon"
barCount={3}
state={pressed ? 'speaking' : 'disconnected'}
audioTrack={pressed ? audioTrack : undefined}
className="audiovisualizer flex h-6 w-auto items-center justify-center gap-0.5"
>
<span
className={cn([
'h-full w-0.5 origin-center',
'group-data-[state=on]/track:bg-foreground group-data-[state=off]/track:bg-destructive',
'data-lk-muted:bg-muted',
])}
/>
</AgentAudioVisualizerBar>
)}
</AgentTrackToggle>
{kind && (
<TrackDeviceSelect
size="sm"
kind={kind}
variant={variant}
requestPermissions={false}
onMediaDeviceError={onMediaDeviceError}
onActiveDeviceChange={onActiveDeviceChange}
className={cn([
'relative',
'before:bg-border before:absolute before:inset-y-0 before:left-0 before:my-2.5 before:w-px has-[~_button]:before:content-[""]',
!pressed && 'before:bg-destructive/20',
])}
/>
)}
</div>
);
}
@@ -1,142 +0,0 @@
import { type ComponentProps, Fragment } from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { Track } from 'livekit-client';
import {
LoaderIcon,
MicIcon,
MicOffIcon,
MonitorOffIcon,
MonitorUpIcon,
VideoIcon,
VideoOffIcon,
} from 'lucide-react';
import { Toggle, toggleVariants } from '@/components/ui/toggle';
import { cn } from '@/lib/shadcn/utils';
export const agentTrackToggleVariants = cva(['size-9'], {
variants: {
variant: {
default: [
'data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive',
'data-[state=off]:hover:bg-destructive/15',
'data-[state=off]:focus-visible:ring-destructive/30',
'data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
'data-[state=on]:hover:bg-foreground/10',
],
outline: [
'data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive data-[state=off]:border-destructive/20',
'data-[state=off]:hover:bg-destructive/15 data-[state=off]:hover:text-destructive',
'data-[state=off]:focus:text-destructive',
'data-[state=off]:focus-visible:border-destructive data-[state=off]:focus-visible:ring-destructive/30',
'data-[state=on]:hover:bg-foreground/10 data-[state=on]:hover:border-foreground/12',
'dark:data-[state=on]:hover:bg-foreground/10',
],
},
},
defaultVariants: {
variant: 'default',
},
});
function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) {
if (pending) {
return LoaderIcon;
}
switch (source) {
case Track.Source.Microphone:
return enabled ? MicIcon : MicOffIcon;
case Track.Source.Camera:
return enabled ? VideoIcon : VideoOffIcon;
case Track.Source.ScreenShare:
return enabled ? MonitorUpIcon : MonitorOffIcon;
default:
return Fragment;
}
}
/**
* Props for the AgentTrackToggle component.
*/
export type AgentTrackToggleProps = VariantProps<typeof toggleVariants> &
ComponentProps<'button'> & {
/**
* The variant of the toggle.
* @defaultValue 'default'
*/
variant?: 'default' | 'outline';
/**
* The track source to toggle (Microphone, Camera, or ScreenShare).
*/
source: 'camera' | 'microphone' | 'screen_share';
/**
* Whether the toggle is in a pending/loading state.
* When true, displays a loading spinner icon.
* @defaultValue false
*/
pending?: boolean;
/**
* Whether the toggle is currently pressed/enabled.
* @defaultValue false
*/
pressed?: boolean;
/**
* The default pressed state when uncontrolled.
* @defaultValue false
*/
defaultPressed?: boolean;
/**
* Callback fired when the pressed state changes.
*/
onPressedChange?: (pressed: boolean) => void;
};
/**
* A toggle button for controlling track publishing state.
* Displays appropriate icons based on the track source and state.
*
* @extends ComponentProps<'button'>
*
* @example
* ```tsx
* <AgentTrackToggle
* source={Track.Source.Microphone}
* pressed={isMicEnabled}
* onPressedChange={(pressed) => setMicEnabled(pressed)}
* />
* ```
*/
export function AgentTrackToggle({
size = 'default',
variant = 'default',
source,
pending = false,
pressed = false,
defaultPressed = false,
className,
onPressedChange,
...props
}: AgentTrackToggleProps) {
const IconComponent = getSourceIcon(source as Track.Source, pressed ?? false, pending);
return (
<Toggle
size={size}
variant={variant}
pressed={pressed}
defaultPressed={defaultPressed}
aria-label={`Toggle ${source}`}
onPressedChange={onPressedChange}
className={cn(
agentTrackToggleVariants({
variant: variant ?? 'default',
className,
})
)}
{...props}
>
<IconComponent className={cn(pending && 'animate-spin')} />
{props.children}
</Toggle>
);
}
@@ -1,57 +0,0 @@
import { type ComponentProps } from 'react';
import { Room } from 'livekit-client';
import { useEnsureRoom, useStartAudio } from '@livekit/components-react';
import { Button } from '@/components/ui/button';
/**
* Props for the StartAudioButton component.
*/
export interface StartAudioButtonProps extends ComponentProps<'button'> {
/**
* The size of the button.
* @defaultValue 'default'
*/
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
/**
* The variant of the button.
* @defaultValue 'default'
*/
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
/**
* The LiveKit room instance. If not provided, uses the room from context.
*/
room?: Room;
/**
* The label text to display on the button.
*/
label: string;
}
/**
* A button that allows users to start audio playback.
* Required for browsers that block autoplay of audio.
* Only renders when audio playback is blocked.
*
* @extends ComponentProps<'button'>
*
* @example
* ```tsx
* <StartAudioButton label="Click to allow audio playback" />
* ```
*/
export function StartAudioButton({
size = 'default',
variant = 'default',
label,
room,
...props
}: StartAudioButtonProps) {
const roomEnsured = useEnsureRoom(room);
const { mergedProps } = useStartAudio({ room: roomEnsured, props });
return (
<Button size={size} variant={variant} {...mergedProps}>
{label}
</Button>
);
}
@@ -1,90 +0,0 @@
'use client';
import type { ComponentProps } from 'react';
import { useCallback } from 'react';
import { ArrowDownIcon } from 'lucide-react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/shadcn/utils';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content className={cn('flex flex-col gap-8 p-4', className)} {...props} />
);
export type ConversationEmptyStateProps = ComponentProps<'div'> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = 'No messages yet',
description = 'Start a conversation to see messages here',
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="text-sm font-medium">{title}</h3>
{description && <p className="text-muted-foreground text-sm">{description}</p>}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
'dark:bg-background dark:hover:bg-muted absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
@@ -1,367 +0,0 @@
'use client';
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
import { createContext, memo, useContext, useEffect, useState } from 'react';
import type { FileUIPart, UIMessage } from 'ai';
import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from 'lucide-react';
import { Streamdown } from 'streamdown';
import { Button } from '@/components/ui/button';
import { ButtonGroup, ButtonGroupText } from '@/components/ui/button-group';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/shadcn/utils';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full max-w-[95%] flex-col gap-2',
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<div
className={cn(
'is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm',
'group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3',
'group-[.is-assistant]:text-foreground',
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<'div'>;
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = 'ghost',
size = 'icon-sm',
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(null);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error('MessageBranch components must be used within MessageBranch');
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div className={cn('grid w-full gap-2 [&>div]:pb-0', className)} {...props} />
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
'grid gap-2 overflow-hidden [&>div]:pb-0',
index === currentBranch ? 'block' : 'hidden'
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role'];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({ children, className, ...props }: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn('text-muted-foreground border-none bg-transparent shadow-none', className)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn('size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0', className)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
MessageResponse.displayName = 'MessageResponse';
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart;
className?: string;
onRemove?: () => void;
};
export function MessageAttachment({ data, className, onRemove, ...props }: MessageAttachmentProps) {
const filename = data.filename || '';
const mediaType = data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file';
const isImage = mediaType === 'image';
const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment');
return (
<div className={cn('group relative size-24 overflow-hidden rounded-lg', className)} {...props}>
{isImage ? (
<>
<img
alt={filename || 'attachment'}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
className="bg-background/80 hover:bg-background absolute top-2 right-2 size-6 rounded-full p-0 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="hover:bg-accent size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
);
}
export type MessageAttachmentsProps = ComponentProps<'div'>;
export function MessageAttachments({ children, className, ...props }: MessageAttachmentsProps) {
if (!children) {
return null;
}
return (
<div className={cn('ml-auto flex w-fit flex-wrap items-start gap-2', className)} {...props}>
{children}
</div>
);
}
export type MessageToolbarProps = ComponentProps<'div'>;
export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
<div className={cn('mt-4 flex w-full items-center justify-between gap-4', className)} {...props}>
{children}
</div>
);
@@ -1,53 +0,0 @@
'use client';
import { type CSSProperties, type ElementType, type JSX, memo, useMemo } from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/shadcn/utils';
export type TextShimmerProps = {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
};
const ShimmerComponent = ({
children,
as: Component = 'p',
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(Component as keyof JSX.IntrinsicElements);
const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]);
return (
<MotionComponent
animate={{ backgroundPosition: '0% center' }}
className={cn(
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent',
'[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]',
className
)}
initial={{ backgroundPosition: '100% center' }}
style={
{
'--spread': `${dynamicSpread}px`,
backgroundImage:
'var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))',
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: 'linear',
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
@@ -1,64 +0,0 @@
'use client';
import { useMemo } from 'react';
import { TokenSource } from 'livekit-client';
import { useSession } from '@livekit/components-react';
import { WarningIcon } from '@phosphor-icons/react/dist/ssr';
import type { AppConfig } from '@/app-config';
import { AgentSessionProvider } from '@/components/agents-ui/agent-session-provider';
import { StartAudioButton } from '@/components/agents-ui/start-audio-button';
import { ViewController } from '@/components/app/view-controller';
import { Toaster } from '@/components/ui/sonner';
import { useAgentErrors } from '@/hooks/useAgentErrors';
import { useDebugMode } from '@/hooks/useDebug';
import { getSandboxTokenSource } from '@/lib/utils';
const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production';
function AppSetup() {
useDebugMode({ enabled: IN_DEVELOPMENT });
useAgentErrors();
return null;
}
interface AppProps {
appConfig: AppConfig;
}
export function App({ appConfig }: AppProps) {
const tokenSource = useMemo(() => {
return typeof process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT === 'string'
? getSandboxTokenSource(appConfig)
: TokenSource.endpoint('/api/connection-details');
}, [appConfig]);
const session = useSession(
tokenSource,
appConfig.agentName ? { agentName: appConfig.agentName } : undefined
);
return (
<AgentSessionProvider session={session}>
<AppSetup />
<main className="grid h-svh grid-cols-1 place-content-center">
<ViewController appConfig={appConfig} />
</main>
<StartAudioButton label="Start Audio" />
<Toaster
icons={{
warning: <WarningIcon weight="bold" />,
}}
position="top-center"
className="toaster group"
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
/>
</AgentSessionProvider>
);
}
@@ -1,65 +0,0 @@
'use client';
import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react';
import { type ReceivedMessage, useAgent } from '@livekit/components-react';
import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript';
import { cn } from '@/lib/shadcn/utils';
const MotionContainer = motion.create('div');
const CONTAINER_MOTION_PROPS = {
variants: {
hidden: {
opacity: 0,
transition: {
ease: 'easeOut',
duration: 0.3,
},
},
visible: {
opacity: 1,
transition: {
delay: 0.2,
ease: 'easeOut',
duration: 0.3,
},
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
};
interface ChatTranscriptProps {
hidden?: boolean;
messages?: ReceivedMessage[];
}
export function ChatTranscript({
hidden = false,
messages = [],
className,
...props
}: ChatTranscriptProps & Omit<HTMLMotionProps<'div'>, 'ref'>) {
const { state: agentState } = useAgent();
return (
<div className="absolute top-0 bottom-[135px] flex w-full flex-col md:bottom-[170px]">
<AnimatePresence>
{!hidden && (
<MotionContainer
{...props}
{...CONTAINER_MOTION_PROPS}
className={cn('flex h-full w-full flex-col gap-4', className)}
>
<AgentChatTranscript
agentState={agentState}
messages={messages}
className="mx-auto w-full max-w-2xl [&_.is-user>div]:rounded-[22px] [&>div>div]:px-4 [&>div>div]:pt-40 md:[&>div>div]:px-6"
/>
</MotionContainer>
)}
</AnimatePresence>
</div>
);
}
@@ -1,96 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { XIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
import { cn } from '@/lib/shadcn/utils';
const MotionPanel = motion.create('div');
interface ImageCardProps {
image: GeneratedImage;
onDismiss: () => void;
}
function ImageCard({ image, onDismiss }: ImageCardProps) {
const src = image.imageUrl;
return (
<MotionPanel
key={image.id}
layout
initial={{ opacity: 0, scale: 0.92, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 8 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="bg-background border-input/50 relative overflow-hidden rounded-xl border shadow-xl"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={image.prompt} className="block max-h-[360px] w-full object-contain" />
{image.prompt && (
<div className="bg-background/80 px-3 py-1.5 backdrop-blur-sm">
<p className="text-muted-foreground line-clamp-2 text-xs">{image.prompt}</p>
</div>
)}
<button
onClick={onDismiss}
className={cn(
'bg-background/70 hover:bg-background absolute top-2 right-2 rounded-full p-1 backdrop-blur-sm transition-colors'
)}
aria-label="Dismiss image"
>
<XIcon className="text-muted-foreground size-3.5" />
</button>
</MotionPanel>
);
}
interface GeneratedImagePanelProps {
chatOpen?: boolean;
}
/**
* Listens for images generated by the agent and shows the most recent one.
* When chat is open, repositions to the top-right corner to avoid covering the transcript.
*
* HACK HERE: swap for a gallery view, a fullscreen modal, download button, etc.
*/
export function GeneratedImagePanel({ chatOpen = false }: GeneratedImagePanelProps) {
const images = useGeneratedImages();
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
const prevLengthRef = useRef(0);
// Auto-scroll to latest image
useEffect(() => {
if (images.length > prevLengthRef.current) {
prevLengthRef.current = images.length;
}
}, [images]);
const visible = images.filter((img) => !dismissed.has(img.id));
const latest = visible.at(-1);
const dismiss = (id: string) => {
setDismissed((prev) => new Set([...prev, id]));
};
return (
<div
className={cn(
'pointer-events-none fixed z-40 flex justify-center px-4',
chatOpen
? 'top-16 right-4 bottom-auto left-auto justify-end'
: 'inset-x-0 bottom-36 md:bottom-44'
)}
>
<div className={cn('pointer-events-auto', chatOpen ? 'w-48' : 'w-full max-w-sm')}>
<AnimatePresence mode="popLayout">
{latest && (
<ImageCard key={latest.id} image={latest} onDismiss={() => dismiss(latest.id)} />
)}
</AnimatePresence>
</div>
</div>
);
}
@@ -1,180 +0,0 @@
'use client';
import { useState } from 'react';
import { ChevronLeftIcon, ImagesIcon, XIcon } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
import { cn } from '@/lib/shadcn/utils';
const MotionPanel = motion.create('div');
const MotionOverlay = motion.create('div');
interface ThumbnailProps {
image: GeneratedImage;
onClick: () => void;
}
function Thumbnail({ image, onClick }: ThumbnailProps) {
return (
<button
onClick={onClick}
className="group border-input/50 bg-muted hover:border-foreground/20 focus-visible:ring-ring relative aspect-square overflow-hidden rounded-lg border transition-all hover:shadow-md focus-visible:ring-2 focus-visible:outline-none"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.imageUrl}
alt={image.prompt}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
</button>
);
}
interface FullImageViewProps {
image: GeneratedImage;
onBack: () => void;
}
function FullImageView({ image, onBack }: FullImageViewProps) {
return (
<MotionPanel
key="full"
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 24 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex h-full flex-col"
>
<button
onClick={onBack}
className="text-muted-foreground hover:text-foreground mb-3 flex items-center gap-1 text-sm transition-colors"
>
<ChevronLeftIcon className="size-4" />
All images
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.imageUrl} alt={image.prompt} className="w-full rounded-xl object-contain" />
{image.prompt && <p className="text-muted-foreground mt-3 text-sm">{image.prompt}</p>}
</MotionPanel>
);
}
/**
* Floating gallery button + slide-in panel for all agent-generated images.
* The button appears once at least one image has been generated.
*/
export function ImageGallery() {
const images = useGeneratedImages();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<GeneratedImage | null>(null);
if (images.length === 0) return null;
return (
<>
{/* Floating trigger button */}
<AnimatePresence>
{!open && (
<MotionPanel
key="trigger"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="fixed right-4 bottom-36 z-40 md:bottom-44"
>
<button
onClick={() => setOpen(true)}
aria-label="View generated images"
className={cn(
'relative flex items-center justify-center rounded-full p-3',
'bg-background border-input/50 border shadow-lg',
'hover:bg-accent focus-visible:ring-ring transition-colors focus-visible:ring-2 focus-visible:outline-none'
)}
>
<ImagesIcon className="text-foreground size-5" />
<span className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold">
{images.length}
</span>
</button>
</MotionPanel>
)}
</AnimatePresence>
{/* Gallery panel + backdrop */}
<AnimatePresence>
{open && (
<>
<MotionOverlay
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
onClick={() => {
setSelected(null);
setOpen(false);
}}
/>
<MotionPanel
key="panel"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="border-input/50 bg-background fixed top-0 right-0 bottom-0 z-[60] flex w-80 flex-col overflow-hidden border-l shadow-2xl md:top-16"
>
{/* Header */}
<div className="border-input/50 flex items-center justify-between border-b px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Generated images</h2>
<p className="text-muted-foreground text-xs">
{images.length} {images.length === 1 ? 'image' : 'images'}
</p>
</div>
<button
onClick={() => {
setSelected(null);
setOpen(false);
}}
aria-label="Close gallery"
className="hover:bg-accent focus-visible:ring-ring rounded-full p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<XIcon className="text-muted-foreground size-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<AnimatePresence mode="wait">
{selected ? (
<FullImageView
key={selected.id}
image={selected}
onBack={() => setSelected(null)}
/>
) : (
<MotionPanel
key="grid"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="grid grid-cols-2 gap-2"
>
{[...images].reverse().map((img) => (
<Thumbnail key={img.id} image={img} onClick={() => setSelected(img)} />
))}
</MotionPanel>
)}
</AnimatePresence>
</div>
</MotionPanel>
</>
)}
</AnimatePresence>
</>
);
}
@@ -1,170 +0,0 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useSessionContext, useSessionMessages } from '@livekit/components-react';
import type { AppConfig } from '@/app-config';
import {
AgentControlBar,
type AgentControlBarControls,
} from '@/components/agents-ui/agent-control-bar';
import { ChatTranscript } from '@/components/app/chat-transcript';
import { GeneratedImagePanel } from '@/components/app/generated-image-panel';
import { ImageGallery } from '@/components/app/image-gallery';
import { TileLayout } from '@/components/app/tile-layout';
import { GeneratedImagesProvider } from '@/hooks/useGeneratedImages';
import { cn } from '@/lib/shadcn/utils';
import { Shimmer } from '../ai-elements/shimmer';
const MotionBottom = motion.create('div');
const MotionMessage = motion.create(Shimmer);
const BOTTOM_VIEW_MOTION_PROPS = {
variants: {
visible: {
opacity: 1,
translateY: '0%',
},
hidden: {
opacity: 0,
translateY: '100%',
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
transition: {
duration: 0.3,
delay: 0.5,
ease: 'easeOut',
},
};
const SHIMMER_MOTION_PROPS = {
variants: {
visible: {
opacity: 1,
transition: {
ease: 'easeIn',
duration: 0.5,
delay: 0.8,
},
},
hidden: {
opacity: 0,
transition: {
ease: 'easeIn',
duration: 0.5,
delay: 0,
},
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
};
interface FadeProps {
top?: boolean;
bottom?: boolean;
className?: string;
}
export function Fade({ top = false, bottom = false, className }: FadeProps) {
return (
<div
className={cn(
'from-background pointer-events-none h-4 bg-linear-to-b to-transparent',
top && 'bg-linear-to-b',
bottom && 'bg-linear-to-t',
className
)}
/>
);
}
interface SessionViewProps {
appConfig: AppConfig;
}
export const SessionView = ({
appConfig,
...props
}: React.ComponentProps<'section'> & SessionViewProps) => {
const session = useSessionContext();
const { messages } = useSessionMessages(session);
const [chatOpen, setChatOpen] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const controls: AgentControlBarControls = {
leave: true,
microphone: true,
chat: appConfig.supportsChatInput,
camera: appConfig.supportsVideoInput,
screenShare: appConfig.supportsScreenShare,
};
useEffect(() => {
const lastMessage = messages.at(-1);
const lastMessageIsLocal = lastMessage?.from?.isLocal === true;
if (scrollAreaRef.current && lastMessageIsLocal) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
}
}, [messages]);
return (
<section className="bg-background relative z-10 h-svh w-svw overflow-hidden" {...props}>
<Fade top className="absolute inset-x-4 top-0 z-10 h-40" />
{/* transcript */}
<ChatTranscript
hidden={!chatOpen}
messages={messages}
className="space-y-3 transition-opacity duration-300 ease-out"
/>
{/* Tile layout */}
<TileLayout chatOpen={chatOpen} />
{/* Single provider registers the byte stream handler once for both image components */}
<GeneratedImagesProvider>
{/* Generated image panel — appears when the agent calls generate_image */}
<GeneratedImagePanel chatOpen={chatOpen} />
{/* Gallery — persistent access to all generated images */}
<ImageGallery />
</GeneratedImagesProvider>
{/* Bottom */}
<MotionBottom
{...BOTTOM_VIEW_MOTION_PROPS}
className="fixed inset-x-3 bottom-0 z-50 md:inset-x-12"
>
{/* Pre-connect message */}
{appConfig.isPreConnectBufferEnabled && (
<AnimatePresence>
{messages.length === 0 && (
<MotionMessage
key="pre-connect-message"
duration={2}
aria-hidden={messages.length > 0}
{...SHIMMER_MOTION_PROPS}
className="pointer-events-none mx-auto block w-full max-w-2xl pb-4 text-center text-sm font-semibold"
>
Agent is listening, ask it a question
</MotionMessage>
)}
</AnimatePresence>
)}
<div className="bg-background relative mx-auto max-w-2xl pb-3 md:pb-12">
<Fade bottom className="absolute inset-x-0 top-0 h-4 -translate-y-full" />
<AgentControlBar
variant="livekit"
controls={controls}
isChatOpen={chatOpen}
isConnected={session.isConnected}
onDisconnect={session.end}
onIsChatOpenChange={setChatOpen}
/>
</div>
</MotionBottom>
</section>
);
};

Some files were not shown because too many files have changed in this diff Show More