Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1901010996 | |||
| d9776452b2 | |||
| 0d69f82139 | |||
| 1d097b13af | |||
| ab8ea07c12 | |||
| b8b6b16531 | |||
| 604bf9d5ac | |||
| 87c271995c | |||
| 277cee8a7e | |||
| 5ac66c0a02 | |||
| a80a737ea7 | |||
| 97e5af4487 | |||
| 1adb8413b5 | |||
| 6083f45f05 | |||
| c4c65f8802 | |||
| 46d277d203 | |||
| aaf1dc5ede | |||
| c1ac687dcf | |||
| 7269098ea3 | |||
| e0757088ef | |||
| 1c491866ba | |||
| 562bc1979a | |||
| 721fe2b8d9 | |||
| c771e1594c | |||
| 1080bfd85f | |||
| 95a9bde5ee | |||
| a21a2895c4 | |||
| dc937a783d | |||
| aeb75c310e | |||
| 4427e18bb9 | |||
| 866558e100 | |||
| 2006096cda | |||
| 4f012aa54a | |||
| cfdbbb3ad7 | |||
| 4142876b23 | |||
| 16e959072b | |||
| 412d3f7df6 | |||
| 563bc6f8fd | |||
| 59f92f18df | |||
| 85ab8da598 | |||
| e474fef228 | |||
| dd99050be8 | |||
| d9e6e8cef4 | |||
| 745f0032f7 | |||
| 6a6fbca9e0 | |||
| fe37f35522 | |||
| 924552a025 | |||
| 7497318cde | |||
| 702026fbd4 | |||
| 4639db11b1 | |||
| 39a06499af | |||
| 146d7e4bd2 | |||
| 4253921532 | |||
| 89893110f1 | |||
| 4726e8ce80 |
+22
-1
@@ -7,8 +7,12 @@ LIVEKIT_API_SECRET=
|
||||
|
||||
# --- Gemini (vision + event detection + voice) ---
|
||||
GEMINI_API_KEY=
|
||||
# GOOGLE_API_KEY= also works as a local alias, but GEMINI_API_KEY is the
|
||||
# canonical deployment secret name used by DigitalOcean and docs.
|
||||
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||
GEMINI_LIVE_MODEL=gemini-live-2.5-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) ---
|
||||
GITHUB_TOKEN=
|
||||
@@ -29,7 +33,24 @@ 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.
|
||||
FRONTEND_URL=http://localhost:4173
|
||||
|
||||
# --- Hermes operations watchdog ---
|
||||
PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
|
||||
PODMAN_PUBLIC_API_URL=https://165-22-129-249.sslip.io/api/pods
|
||||
PODMAN_PUBLIC_HEALTH_URL=https://165-22-129-249.sslip.io/health
|
||||
PODMAN_HERMES_REMEDIATE=1
|
||||
PODMAN_HERMES_STRICT=0
|
||||
PODMAN_HERMES_STATE_DIR=/var/log/podman
|
||||
# Optional Discord/Slack/generic webhook for failed watchdog runs.
|
||||
PODMAN_ALERT_WEBHOOK_URL=
|
||||
|
||||
# --- Hermes git-sync deploy loop ---
|
||||
PODMAN_DEPLOY_REMOTE=origin
|
||||
PODMAN_DEPLOY_BRANCH=main
|
||||
PODMAN_DEPLOY_RESTART_SERVICES=podman-platform-api.service,podman-platform-agent.service,caddy.service
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Hermes verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.32.1
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm -r typecheck
|
||||
- run: pnpm -r build
|
||||
@@ -35,6 +35,8 @@ Thumbs.db
|
||||
coverage/
|
||||
.cache/
|
||||
.turbo/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Ramis
|
||||
.remember/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
link-workspace-packages=true
|
||||
prefer-workspace-packages=true
|
||||
auto-install-peers=true
|
||||
prefix=/home/ramis/.npm-global
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
node_modules
|
||||
.venv
|
||||
**/.venv
|
||||
.pytest_cache
|
||||
**/.pytest_cache
|
||||
dist
|
||||
build
|
||||
pnpm-lock.yaml
|
||||
.omc
|
||||
*.log
|
||||
.agents
|
||||
examples/livekit-gemini-hacker-starter
|
||||
|
||||
@@ -363,3 +363,50 @@ 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).
|
||||
|
||||
@@ -148,7 +148,9 @@ sequenceDiagram
|
||||
memory.
|
||||
8. PodMan sends the smallest useful intervention: card first, Hermes message for
|
||||
coordination, voice only when urgent.
|
||||
9. The user's response is saved as an outcome, closing the continual-learning
|
||||
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
|
||||
loop.
|
||||
|
||||
---
|
||||
@@ -192,6 +194,9 @@ 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 |
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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=
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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`
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
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)
|
||||
@@ -0,0 +1,19 @@
|
||||
[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 = ["."]
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
Generated
+2275
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
|
||||
function teammateText(collision: Collision, intervention: Intervention): string {
|
||||
return `${collision.engineers.join(', ')}: ${intervention.message}`;
|
||||
}
|
||||
|
||||
export function createHermesMessage(
|
||||
collision: Collision,
|
||||
intervention: Intervention,
|
||||
): HermesMessage {
|
||||
return {
|
||||
id: `hermes_${Date.now()}`,
|
||||
podId: collision.podId,
|
||||
interventionId: intervention.id,
|
||||
recipients: collision.engineers,
|
||||
text: teammateText(collision, intervention),
|
||||
urgency: collision.severity === 'critical' ? 'urgent' : 'normal',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishHermesMessage(
|
||||
room: Room,
|
||||
collision: Collision,
|
||||
intervention: Intervention,
|
||||
): Promise<void> {
|
||||
const data: DataMessage = {
|
||||
type: 'HERMES_MESSAGE',
|
||||
message: createHermesMessage(collision, intervention),
|
||||
};
|
||||
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
|
||||
reliable: true,
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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);
|
||||
}
|
||||
+122
-14
@@ -6,6 +6,7 @@ import {
|
||||
VideoStream,
|
||||
VideoBufferType,
|
||||
dispose,
|
||||
type VideoFrameEvent,
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
type RemoteParticipant,
|
||||
@@ -14,10 +15,13 @@ 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, {
|
||||
@@ -29,7 +33,25 @@ 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), {
|
||||
@@ -40,6 +62,57 @@ 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,
|
||||
@@ -47,30 +120,65 @@ 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 () => {
|
||||
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);
|
||||
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 */
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
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 () => {
|
||||
await room.disconnect();
|
||||
await dispose();
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
+116
-20
@@ -1,18 +1,60 @@
|
||||
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 } from '../memory/store.js';
|
||||
import { getGitStates } from '../memory/db.js';
|
||||
import {
|
||||
recordObservation,
|
||||
recordCollision,
|
||||
recordIntervention,
|
||||
updateInterventionStatus,
|
||||
} from '../memory/store.js';
|
||||
import { getGitStates, type GitState } from '../memory/db.js';
|
||||
import { recallSimilar } from '../memory/vectors.js';
|
||||
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
||||
import { speak } from '../voice/live.js';
|
||||
import { publishHermesIntervention } from '../action/hermes.js';
|
||||
|
||||
/** Strip a git-status prefix ("M ", "?? ") and reduce a path to its lowercased
|
||||
* basename — matches comparableFile() in memory/store.ts so keys line up. */
|
||||
function comparableBasename(raw?: string): string {
|
||||
return (
|
||||
(raw ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase() ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/** Canonicalize an engineer name for case/whitespace-insensitive matching, so
|
||||
* "Karti" and "karti" resolve to the same engineer's git state. */
|
||||
function canonicalName(raw?: string): string {
|
||||
return (raw ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Git ground truth: do ALL involved engineers currently have the collided file
|
||||
* in their changedFiles? Computed at detection time while git state is fresh. */
|
||||
function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, GitState>): boolean {
|
||||
const target = comparableBasename(collision.file);
|
||||
if (!target || collision.engineers.length < 2) return false;
|
||||
const byCanon = new Map<string, string[]>();
|
||||
for (const [name, st] of gitStates) byCanon.set(canonicalName(name), st.changedFiles);
|
||||
return collision.engineers.every((e) =>
|
||||
(byCanon.get(canonicalName(e)) ?? []).some((f) => comparableBasename(f) === target),
|
||||
);
|
||||
}
|
||||
|
||||
export class PodMan {
|
||||
private contexts = new Map<string, EngineerContext>();
|
||||
private encoder = new TextEncoder();
|
||||
/**
|
||||
* 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>();
|
||||
|
||||
constructor(
|
||||
private room: Room,
|
||||
@@ -29,14 +71,20 @@ 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): Promise<void> {
|
||||
async onScreenFrame(engineerId: string, jpeg: Buffer, screenshotDataUrl?: string): Promise<void> {
|
||||
const ctx = await analyzeFrame(engineerId, this.podId, jpeg);
|
||||
if (screenshotDataUrl) ctx.screenshotDataUrl = screenshotDataUrl;
|
||||
this.contexts.set(engineerId, ctx);
|
||||
await recordObservation(ctx);
|
||||
|
||||
@@ -49,22 +97,63 @@ export class PodMan {
|
||||
}
|
||||
|
||||
const github = await getGithubState(); // cached
|
||||
const collisions = detectCollisions([...this.contexts.values()], github);
|
||||
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);
|
||||
}
|
||||
|
||||
// Capture git ground-truth overlap now, while engineer_states are fresh, so
|
||||
// the outcome-time verifier never depends on a stale sidecar or a late click.
|
||||
for (const collision of collisions) {
|
||||
collision.gitOverlap = engineersOverlapOnFile(collision, gitStates);
|
||||
}
|
||||
|
||||
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 comparableBasename(collision.file);
|
||||
}
|
||||
|
||||
private async handle(collision: Collision): Promise<void> {
|
||||
const prior = await recallSimilar(collision); // Loop A: vector recall raises confidence
|
||||
if (prior) collision.severity = 'critical';
|
||||
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 (!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(' and ');
|
||||
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 message =
|
||||
`${names} are both editing ${collision.file}` +
|
||||
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
|
||||
(prior ? ` I've seen this conflict pattern before.` : '');
|
||||
`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.`;
|
||||
|
||||
const intervention: Intervention = {
|
||||
id: `int_${Date.now()}`,
|
||||
@@ -72,17 +161,24 @@ export class PodMan {
|
||||
podId: this.podId,
|
||||
kind: 'card',
|
||||
message,
|
||||
suggestedAction: { kind: action },
|
||||
suggestedAction: {
|
||||
kind: action,
|
||||
params: {
|
||||
file: collision.file,
|
||||
summary: message,
|
||||
engineers: collision.engineers,
|
||||
},
|
||||
},
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await recordIntervention(intervention);
|
||||
|
||||
const data: DataMessage = { type: 'COLLISION', collision, intervention };
|
||||
await this.room.localParticipant?.publishData(this.encoder.encode(JSON.stringify(data)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
await speak(this.room, message); // gemini-3.1-flash-live voice into the room
|
||||
await publishHermesIntervention(
|
||||
this.room,
|
||||
collision,
|
||||
intervention,
|
||||
collision.severity === 'critical' ? voiceLine : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,84 @@
|
||||
import type { EngineerContext, Collision, GithubStateSnapshot } from '@podman/shared';
|
||||
import type { GitState } from '../memory/db.js';
|
||||
|
||||
function normalize(path?: string): string | undefined {
|
||||
if (!path) return undefined;
|
||||
return path.replace(/^\.?\/?(src\/)?/, 'src/').toLowerCase();
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
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, EngineerContext[]>();
|
||||
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.
|
||||
for (const c of contexts) {
|
||||
const f = normalize(c.currentFile);
|
||||
if (!f) continue;
|
||||
(byFile.get(f) ?? byFile.set(f, []).get(f)!).push(c);
|
||||
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 out: Collision[] = [];
|
||||
for (const [file, group] of byFile) {
|
||||
const engineers = [...new Set(group.map((g) => g.engineerId))];
|
||||
if (engineers.length < 2) continue;
|
||||
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
|
||||
|
||||
const anyUnpushed = group.some((g) => g.hasUnpushedChanges) || github.unpushed === true;
|
||||
const anyUnpushed = touches.some((t) => t.unpushed) || 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_${file}_${Date.now()}`,
|
||||
podId: group[0]!.podId,
|
||||
file,
|
||||
symbol: group.find((g) => g.currentSymbol)?.currentSymbol,
|
||||
id: `col_${fileKey(display)}_${Date.now()}`,
|
||||
podId: contexts[0]?.podId ?? 'demo-pod',
|
||||
file: display,
|
||||
symbol: contexts.find((c) => c.currentSymbol)?.currentSymbol,
|
||||
engineers,
|
||||
severity: 'warn',
|
||||
githubState: { ...github, unpushed: anyUnpushed },
|
||||
|
||||
+16
-2
@@ -5,6 +5,13 @@ function req(name: string): string {
|
||||
if (!v) throw new Error(`Missing required env var: ${name}`);
|
||||
return v;
|
||||
}
|
||||
function reqAny(primary: string, aliases: string[] = []): string {
|
||||
for (const name of [primary, ...aliases]) {
|
||||
const v = process.env[name];
|
||||
if (v) return v;
|
||||
}
|
||||
throw new Error(`Missing required env var: ${primary}`);
|
||||
}
|
||||
function opt(name: string, fallback = ''): string {
|
||||
return process.env[name] ?? fallback;
|
||||
}
|
||||
@@ -14,10 +21,15 @@ 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: req('GEMINI_API_KEY'),
|
||||
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-live-2.5-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'),
|
||||
GITHUB_REPO: req('GITHUB_REPO'), // owner/name
|
||||
@@ -27,6 +39,8 @@ export const env = {
|
||||
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
||||
// 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 } {
|
||||
|
||||
@@ -39,13 +39,45 @@ export async function createSyncPr(input: { headBranch: string; file: string; su
|
||||
ref: `refs/heads/${branch}`,
|
||||
sha: mainRef.object.sha,
|
||||
});
|
||||
|
||||
const artifactPath = `podman-sync-artifacts/${branch}.md`;
|
||||
const body = [
|
||||
'# PodMan Sync Artifact',
|
||||
'',
|
||||
`- File: \`${input.file || 'unknown'}\``,
|
||||
`- Source branch hint: \`${input.headBranch || 'not provided'}\``,
|
||||
`- Created: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'## Coordination Summary',
|
||||
'',
|
||||
input.summary || 'PodMan detected a coordination risk before the relevant work was pushed.',
|
||||
'',
|
||||
'## Suggested Next Step',
|
||||
'',
|
||||
'Coordinate ownership before pushing or merging overlapping local work.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await gh.rest.repos.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
path: artifactPath,
|
||||
message: `PodMan sync artifact for ${input.file || 'active work'}`,
|
||||
content: Buffer.from(body).toString('base64'),
|
||||
});
|
||||
|
||||
const { data: pr } = await gh.rest.pulls.create({
|
||||
owner,
|
||||
repo,
|
||||
title: `PodMan: sync ${input.file} before collision`,
|
||||
head: branch,
|
||||
base: 'main',
|
||||
body: input.summary,
|
||||
body: [
|
||||
input.summary,
|
||||
'',
|
||||
`PodMan created a visible sync artifact at \`${artifactPath}\` so the team can coordinate before pushing overlapping work.`,
|
||||
].join('\n'),
|
||||
});
|
||||
return pr;
|
||||
}
|
||||
|
||||
@@ -11,21 +11,90 @@ 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: '5',
|
||||
detail: 'Ownership edges retained from accepted interventions.',
|
||||
value: '3',
|
||||
detail: 'Distinct owners retained from accepted interventions.',
|
||||
},
|
||||
{
|
||||
label: 'Open risk paths',
|
||||
value: '2',
|
||||
detail: 'auth.ts and the memory API have converging editors.',
|
||||
value: '1',
|
||||
detail: 'File with two or more converging editors.',
|
||||
},
|
||||
{
|
||||
label: 'Accept rate',
|
||||
value: '86%',
|
||||
detail: 'Interventions accepted this session (+14%).',
|
||||
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',
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
@@ -186,7 +255,7 @@ export function createDemoPodGraph(podId: string): PodGraph {
|
||||
source: 'collision:auth',
|
||||
target: 'intervention:sync-pr',
|
||||
kind: 'warns',
|
||||
label: 'nudges',
|
||||
label: 'routes',
|
||||
strength: 0.9,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -14,6 +15,14 @@ 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 });
|
||||
@@ -21,6 +30,7 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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' });
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,8 @@ 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>> {
|
||||
@@ -71,7 +73,17 @@ 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,
|
||||
@@ -99,8 +111,18 @@ export async function initMemory(): Promise<void> {
|
||||
'collisions.memorySignature',
|
||||
() => c.collisions.createIndex({ podId: 1, memorySignature: 1 }),
|
||||
],
|
||||
['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 {
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
import type { Collision, SuggestedActionKind } from '@podman/shared';
|
||||
import type { RecalledCollision } from './vectors.js';
|
||||
|
||||
/**
|
||||
* Policy gate: decides whether PodMan should intervene.
|
||||
* Stub: always intervene on warn/critical.
|
||||
*/
|
||||
export function shouldIntervene(collision: Collision, _prior: unknown): boolean {
|
||||
return collision.severity !== 'info';
|
||||
const lastNudgeByPod = new Map<string, number>();
|
||||
|
||||
function cooldownMs(): number {
|
||||
return Number(process.env.NUDGE_COOLDOWN_MS ?? '180000');
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferred action selection based on collision + prior history.
|
||||
* Stub: open sync PR for critical, ping teammate otherwise.
|
||||
*/
|
||||
export function preferredAction(collision: Collision, _prior: unknown): SuggestedActionKind {
|
||||
/** Policy gate: combines severity, exact recall outcomes, and per-pod cooldown. */
|
||||
export function shouldIntervene(collision: Collision, prior: RecalledCollision | null): boolean {
|
||||
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;
|
||||
|
||||
const cooldown = cooldownMs();
|
||||
const last = lastNudgeByPod.get(collision.podId) ?? 0;
|
||||
if (cooldown > 0 && Date.now() - last < cooldown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastNudgeByPod.set(collision.podId, Date.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Preferred action selection based on collision severity and prior accepted actions. */
|
||||
export function preferredAction(
|
||||
collision: Collision,
|
||||
prior: RecalledCollision | null,
|
||||
): SuggestedActionKind {
|
||||
const acceptedKind = prior?.priorOutcome?.accepted
|
||||
? prior.priorIntervention?.suggestedAction.kind
|
||||
: undefined;
|
||||
if (acceptedKind && acceptedKind !== 'none') return acceptedKind;
|
||||
return collision.severity === 'critical' ? 'open_sync_pr' : 'ping_teammate';
|
||||
}
|
||||
|
||||
+104
-6
@@ -1,17 +1,34 @@
|
||||
import type { EngineerContext, Collision, Intervention, InterventionOutcome } from '@podman/shared';
|
||||
import { collections } from './db.js';
|
||||
import type {
|
||||
EngineerContext,
|
||||
Collision,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
InterventionStatus,
|
||||
} from '@podman/shared';
|
||||
import { collections, getGitStates } 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. Writes are best-effort
|
||||
* — a Mongo hiccup logs a warning rather than crashing the agent/server.
|
||||
* 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.
|
||||
*/
|
||||
async function persist(name: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.warn(`[memory] ${name} persist failed: ${(err as Error).message}`);
|
||||
console.error(`[memory] ${name} persist FAILED: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +50,89 @@ 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 } }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 — derive whether a flagged collision was REAL from git ground truth,
|
||||
* instead of trusting the client (which historically hardcoded `true`). A
|
||||
* collision counts as real only if BOTH named engineers currently have the
|
||||
* collided file in their git `changedFiles`. Conservative: returns false when
|
||||
* the collision is orphaned/missing or git state is stale/unavailable.
|
||||
* Verifier supervision per docs/continual-learning/spec.md:98-108, policy.md:35-42.
|
||||
*/
|
||||
export async function deriveWasRealCollision(outcome: InterventionOutcome): Promise<boolean> {
|
||||
try {
|
||||
const c = await collections();
|
||||
const collision = await c.collisions.findOne({ id: outcome.collisionId });
|
||||
if (!collision) return false;
|
||||
// Prefer the overlap evidence captured at detection time (fresh git state):
|
||||
// immune to late clicks, stale sidecars, and the engineer_states TTL.
|
||||
if (typeof collision.gitOverlap === 'boolean') return collision.gitOverlap;
|
||||
// Fallback for collisions detected before gitOverlap was captured: re-derive
|
||||
// from latest git state, matching engineers on case/whitespace-canonical names.
|
||||
if (!Array.isArray(collision.engineers) || collision.engineers.length < 2) return false;
|
||||
const target = comparableFile(collision.file);
|
||||
if (!target) return false;
|
||||
const byCanon = new Map<string, string[]>();
|
||||
for (const [name, st] of await getGitStates(outcome.podId)) {
|
||||
byCanon.set(name.trim().toLowerCase(), st.changedFiles);
|
||||
}
|
||||
return collision.engineers.every((e) =>
|
||||
(byCanon.get(e.trim().toLowerCase()) ?? []).some((f) => comparableFile(f) === target),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[memory] wasRealCollision verifier failed: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||
await persist('outcome', async () => (await collections()).outcomes.insertOne({ ...outcome }));
|
||||
// Backend is authoritative for wasRealCollision: derive it from git overlap
|
||||
// rather than trusting the client-supplied value. (RSI Step 3)
|
||||
const verified: InterventionOutcome = {
|
||||
...outcome,
|
||||
wasRealCollision: await deriveWasRealCollision(outcome),
|
||||
};
|
||||
await persist('outcome', async () => {
|
||||
const c = await collections();
|
||||
await c.outcomes.insertOne({ ...verified });
|
||||
await c.interventions.updateOne(
|
||||
{ id: verified.interventionId },
|
||||
{ $set: { status: verified.accepted ? 'accepted' : 'dismissed' } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Document counts per collection — used by the /api/memory/stats endpoint. */
|
||||
|
||||
+149
-17
@@ -1,4 +1,4 @@
|
||||
import type { Collision } from '@podman/shared';
|
||||
import type { Collision, Intervention, InterventionOutcome } from '@podman/shared';
|
||||
import { env } from '../env.js';
|
||||
import { getDb } from './db.js';
|
||||
|
||||
@@ -6,18 +6,35 @@ type StoredCollision = Collision & {
|
||||
memorySignature?: string;
|
||||
memoryText?: string;
|
||||
embedding?: number[];
|
||||
embeddingProvider?: string;
|
||||
};
|
||||
|
||||
export type RecalledCollision = Collision & {
|
||||
priorIntervention?: Intervention;
|
||||
priorOutcome?: InterventionOutcome;
|
||||
};
|
||||
|
||||
interface VoyageEmbeddingResponse {
|
||||
data?: Array<{ embedding?: number[] }>;
|
||||
}
|
||||
|
||||
interface GeminiEmbeddingResponse {
|
||||
embedding?: { values?: number[] };
|
||||
}
|
||||
|
||||
function normalize(value: string | undefined): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function signature(collision: Collision): string {
|
||||
return [normalize(collision.file), normalize(collision.symbol)].filter(Boolean).join('#');
|
||||
return [
|
||||
normalize(collision.file),
|
||||
normalize(collision.symbol),
|
||||
[...collision.engineers].sort().map(normalize).join('+'),
|
||||
'collision',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('#');
|
||||
}
|
||||
|
||||
function memoryText(collision: Collision): string {
|
||||
@@ -32,7 +49,30 @@ function memoryText(collision: Collision): string {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function cosine(a: number[], b: number[]): number {
|
||||
const n = Math.min(a.length, b.length);
|
||||
let dot = 0;
|
||||
let aNorm = 0;
|
||||
let bNorm = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
dot += av * bv;
|
||||
aNorm += av * av;
|
||||
bNorm += bv * bv;
|
||||
}
|
||||
if (!aNorm || !bNorm) return -1;
|
||||
return dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
|
||||
}
|
||||
|
||||
async function embed(text: string, inputType: 'document' | 'query'): Promise<number[] | null> {
|
||||
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
|
||||
}
|
||||
|
||||
async function embedWithVoyage(
|
||||
text: string,
|
||||
inputType: 'document' | 'query',
|
||||
): Promise<number[] | null> {
|
||||
if (!env.VOYAGE_API_KEY) return null;
|
||||
try {
|
||||
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
|
||||
@@ -59,6 +99,38 @@ async function embed(text: string, inputType: 'document' | 'query'): Promise<num
|
||||
}
|
||||
}
|
||||
|
||||
async function embedWithGemini(
|
||||
text: string,
|
||||
inputType: 'document' | 'query',
|
||||
): Promise<number[] | null> {
|
||||
try {
|
||||
const taskType = inputType === 'document' ? 'RETRIEVAL_DOCUMENT' : 'RETRIEVAL_QUERY';
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||
env.GEMINI_EMBEDDING_MODEL,
|
||||
)}:embedContent?key=${encodeURIComponent(env.GEMINI_API_KEY)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: { parts: [{ text }] },
|
||||
taskType,
|
||||
outputDimensionality: 768,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn(`[memory] gemini embedding failed: ${res.status} ${await res.text()}`);
|
||||
return null;
|
||||
}
|
||||
const body = (await res.json()) as GeminiEmbeddingResponse;
|
||||
return body.embedding?.values ?? null;
|
||||
} catch (err) {
|
||||
console.warn(`[memory] gemini embedding failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichCollisionMemory(collision: Collision): Promise<StoredCollision> {
|
||||
const text = memoryText(collision);
|
||||
const embedding = await embed(text, 'document');
|
||||
@@ -66,16 +138,45 @@ export async function enrichCollisionMemory(collision: Collision): Promise<Store
|
||||
...collision,
|
||||
memorySignature: signature(collision),
|
||||
memoryText: text,
|
||||
...(embedding ? { embedding } : {}),
|
||||
...(embedding
|
||||
? { embedding, embeddingProvider: env.VOYAGE_API_KEY ? 'voyage' : 'gemini' }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function recallByVector(collision: Collision): Promise<Collision | null> {
|
||||
async function attachOutcome(match: StoredCollision): Promise<RecalledCollision> {
|
||||
const db = await getDb();
|
||||
const intervention = await db
|
||||
.collection<Intervention>('interventions')
|
||||
.findOne({ collisionId: match.id }, { sort: { createdAt: -1 }, projection: { _id: 0 } });
|
||||
const outcome = intervention
|
||||
? await db
|
||||
.collection<InterventionOutcome>('outcomes')
|
||||
.findOne(
|
||||
{ interventionId: intervention.id },
|
||||
{ sort: { recordedAt: -1 }, projection: { _id: 0 } },
|
||||
)
|
||||
: null;
|
||||
const {
|
||||
memorySignature: _memorySignature,
|
||||
memoryText: _memoryText,
|
||||
embedding: _embedding,
|
||||
embeddingProvider: _embeddingProvider,
|
||||
...collision
|
||||
} = match;
|
||||
return {
|
||||
...collision,
|
||||
...(intervention ? { priorIntervention: intervention } : {}),
|
||||
...(outcome ? { priorOutcome: outcome } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function recallByVector(collision: Collision): Promise<RecalledCollision | null> {
|
||||
const queryVector = await embed(memoryText(collision), 'query');
|
||||
if (!queryVector) return null;
|
||||
|
||||
try {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const [match] = await db
|
||||
.collection<StoredCollision>('collisions')
|
||||
.aggregate<StoredCollision>([
|
||||
@@ -93,31 +194,62 @@ async function recallByVector(collision: Collision): Promise<Collision | null> {
|
||||
{ $project: { _id: 0, embedding: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
return match ?? null;
|
||||
return match ? attachOutcome(match) : null;
|
||||
} catch (err) {
|
||||
console.warn(`[memory] vector recall unavailable: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
console.warn(`[memory] atlas vector recall unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
async function recallBySignature(collision: Collision): Promise<Collision | null> {
|
||||
const candidates = await db
|
||||
.collection<StoredCollision>('collisions')
|
||||
.find(
|
||||
{
|
||||
podId: collision.podId,
|
||||
id: { $ne: collision.id },
|
||||
embedding: { $exists: true },
|
||||
},
|
||||
{ projection: { _id: 0 }, limit: 100 },
|
||||
)
|
||||
.toArray();
|
||||
let best: { match: StoredCollision; score: number } | null = null;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.embedding?.length) continue;
|
||||
const score = cosine(queryVector, candidate.embedding);
|
||||
if (!best || score > best.score) best = { match: candidate, score };
|
||||
}
|
||||
return best && best.score > 0.5 ? attachOutcome(best.match) : null;
|
||||
}
|
||||
|
||||
async function recallBySignature(collision: Collision): Promise<RecalledCollision | null> {
|
||||
const db = await getDb();
|
||||
const sig = signature(collision);
|
||||
const match = await db.collection<StoredCollision>('collisions').findOne(
|
||||
const matches = await db
|
||||
.collection<StoredCollision>('collisions')
|
||||
.find(
|
||||
{
|
||||
podId: collision.podId,
|
||||
id: { $ne: collision.id },
|
||||
$or: [{ memorySignature: sig }, { file: collision.file }],
|
||||
},
|
||||
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 } },
|
||||
);
|
||||
return match ?? null;
|
||||
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 }, limit: 10 },
|
||||
)
|
||||
.toArray();
|
||||
|
||||
let fallback: RecalledCollision | null = null;
|
||||
for (const match of matches) {
|
||||
const recalled = await attachOutcome(match);
|
||||
if (!fallback) fallback = recalled;
|
||||
if (recalled.priorOutcome?.accepted && recalled.priorOutcome.wasRealCollision) {
|
||||
return recalled;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recall prior collision patterns. Exact Mongo recall is always available;
|
||||
* Voyage + Atlas Vector Search is used first when configured.
|
||||
* Recall prior collision patterns. Atlas Vector Search is preferred when
|
||||
* available; standalone MongoDB falls back to app-side cosine search over
|
||||
* stored embeddings, then exact signature/file matching.
|
||||
*/
|
||||
export async function recallSimilar(collision: Collision): Promise<Collision | null> {
|
||||
export async function recallSimilar(collision: Collision): Promise<RecalledCollision | null> {
|
||||
return (await recallByVector(collision)) ?? recallBySignature(collision);
|
||||
}
|
||||
|
||||
+432
-6
@@ -1,11 +1,18 @@
|
||||
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, RoomConfiguration } from 'livekit-server-sdk';
|
||||
import { AccessToken, RoomAgentDispatch, RoomConfiguration } from 'livekit-server-sdk';
|
||||
import { env } from './env.js';
|
||||
import { createSyncPr } from './github/client.js';
|
||||
import { recordOutcome, memoryStats } from './memory/store.js';
|
||||
import {
|
||||
recordCollision,
|
||||
recordIntervention,
|
||||
recordOutcome,
|
||||
hasRecentInterventionForCollision,
|
||||
memoryStats,
|
||||
} from './memory/store.js';
|
||||
import { closeMemory, initMemory } from './memory/db.js';
|
||||
import {
|
||||
listPods,
|
||||
@@ -19,13 +26,66 @@ import {
|
||||
} from './pods/store.js';
|
||||
import { getPresence, closeRoom } from './livekit/rooms.js';
|
||||
import { loadPodGraph, reachFrom } from './graph/store.js';
|
||||
import type { InterventionOutcome } from '@podman/shared';
|
||||
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';
|
||||
|
||||
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 ?? {};
|
||||
@@ -37,9 +97,22 @@ 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 });
|
||||
at.roomConfig = new RoomConfiguration({
|
||||
name: room,
|
||||
emptyTimeout: 60,
|
||||
departureTimeout: 20,
|
||||
agents,
|
||||
});
|
||||
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
||||
});
|
||||
|
||||
@@ -136,12 +209,306 @@ 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 {
|
||||
@@ -159,7 +526,58 @@ 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' });
|
||||
@@ -177,7 +595,11 @@ http.listen(env.PORT, '0.0.0.0', () => {
|
||||
console.log(`[server] :${env.PORT}`);
|
||||
initMemory()
|
||||
.then(() => seedDefaultPods())
|
||||
.catch((e) => console.warn(`[memory] init failed: ${(e as Error).message}`));
|
||||
.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);
|
||||
});
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
@@ -187,7 +609,11 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
|
||||
console.log(`[server] ${signal} received; shutting down`);
|
||||
for (const client of clients) client.close();
|
||||
wss.close();
|
||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||
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 closeMemory().catch((e) => console.warn(`[memory] close failed: ${(e as Error).message}`));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
+206
-27
@@ -1,19 +1,48 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import {
|
||||
AudioFrame,
|
||||
AudioSource,
|
||||
LocalAudioTrack,
|
||||
Room,
|
||||
TrackPublishOptions,
|
||||
TrackSource,
|
||||
type Room,
|
||||
type LocalParticipant,
|
||||
} 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 };
|
||||
@@ -23,12 +52,24 @@ 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.buffer, bytes.byteOffset, bytes.byteLength / 2);
|
||||
const samples = new Int16Array(bytes.byteLength / 2);
|
||||
for (let i = 0; i < samples.length; i += 1) samples[i] = bytes.readInt16LE(i * 2);
|
||||
return new AudioFrame(samples, SAMPLE_RATE, CHANNELS, samples.length / CHANNELS);
|
||||
}
|
||||
|
||||
@@ -44,41 +85,89 @@ function audioFrames(message: LiveServerMessage): AudioFrame[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): Promise<void> {
|
||||
await publishVoiceCue(room, message);
|
||||
if (!room.localParticipant) return;
|
||||
function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] {
|
||||
const frame = audioFrameFromBase64(data, mimeType);
|
||||
if (!frame) 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;
|
||||
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));
|
||||
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
async function generateTtsFrames(message: string): Promise<AudioFrame[]> {
|
||||
const res = await ai.models.generateContent({
|
||||
model: env.GEMINI_LIVE_MODEL,
|
||||
contents: [{ parts: [{ text: ttsPrompt(message) }] }],
|
||||
config: {
|
||||
responseModalities: [Modality.AUDIO],
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: env.GEMINI_TTS_VOICE } } },
|
||||
temperature: 0.8,
|
||||
},
|
||||
});
|
||||
const parts = res.candidates?.[0]?.content?.parts ?? [];
|
||||
return parts.flatMap((part) =>
|
||||
framesFromPcmBase64(part.inlineData?.data ?? '', part.inlineData?.mimeType),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
const publication = await room.localParticipant.publishTrack(track, options);
|
||||
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) {
|
||||
await source.captureFrame(frame);
|
||||
}
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
async function speakWithLive(source: AudioSource, message: string): Promise<number> {
|
||||
let durationMs = 0;
|
||||
let done: () => void = () => {};
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
done = resolve;
|
||||
});
|
||||
let session: Session | null = null;
|
||||
|
||||
session = await ai.live.connect({
|
||||
const session: Session = await ai.live.connect({
|
||||
model: env.GEMINI_LIVE_MODEL,
|
||||
config: { responseModalities: [Modality.AUDIO] },
|
||||
config: {
|
||||
responseModalities: [Modality.AUDIO],
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: env.GEMINI_TTS_VOICE } } },
|
||||
temperature: 0.8,
|
||||
},
|
||||
callbacks: {
|
||||
onmessage: (event) => {
|
||||
void (async () => {
|
||||
for (const frame of audioFrames(event)) await source.captureFrame(frame);
|
||||
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete)
|
||||
done();
|
||||
for (const frame of audioFrames(event)) {
|
||||
durationMs += (frame.samplesPerChannel / frame.sampleRate) * 1000;
|
||||
await source.captureFrame(frame);
|
||||
}
|
||||
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete) done();
|
||||
})();
|
||||
},
|
||||
onerror: (event) => {
|
||||
console.warn(`[voice] Gemini Live error: ${event.message}`);
|
||||
console.warn(`[voice] Gemini voice error: ${event.message}`);
|
||||
done();
|
||||
},
|
||||
onclose: done,
|
||||
@@ -86,16 +175,106 @@ export async function speak(room: Room, message: string): Promise<void> {
|
||||
});
|
||||
|
||||
session.sendClientContent({
|
||||
turns: [{ role: 'user', parts: [{ text: message }] }],
|
||||
turns: [{ role: 'user', parts: [{ text: ttsPrompt(message) }] }],
|
||||
turnComplete: true,
|
||||
});
|
||||
|
||||
await Promise.race([donePromise, new Promise((resolve) => setTimeout(resolve, 15_000))]);
|
||||
session.close();
|
||||
if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true);
|
||||
await source.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 Live publish failed: ${(err as Error).message}`);
|
||||
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
|
||||
* 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> {
|
||||
await publishVoiceCue(room, message);
|
||||
if (options.priority === 'critical') {
|
||||
await speakAudio(room, message);
|
||||
return;
|
||||
}
|
||||
voiceQueue = voiceQueue.catch(() => {}).then(() => speakAudio(room, message));
|
||||
await voiceQueue;
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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;
|
||||
}
|
||||
+101
-34
@@ -4,7 +4,8 @@
|
||||
> 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/digitalocean.md`,
|
||||
> `docs/gemini.md`, `docs/mongodb.md`, `docs/continual-learning/`,
|
||||
> `docs/graph-discovery/`, `docs/agent-learning/`, `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.
|
||||
|
||||
@@ -178,36 +179,41 @@ From the remote plan snapshot and health check on `2026-06-27`:
|
||||
- Treat this as operational evidence, not architecture truth. Reverify before
|
||||
demo.
|
||||
|
||||
### Partial / stubbed
|
||||
### Partial / completed since the original audit
|
||||
|
||||
- `backend/src/voice/live.ts` logs only; it does not publish real voice/audio
|
||||
into LiveKit yet.
|
||||
- Hermes is a product/action/messaging layer in the plan, but the current repo
|
||||
does not yet implement a complete Hermes notification bridge.
|
||||
- `backend/src/memory/vectors.ts` is not a real Voyage/Atlas Vector Search
|
||||
implementation yet.
|
||||
- Exact-signature recall is the required MVP fallback before vectors.
|
||||
- `backend/src/memory/policy.ts` is a simple gate; it does not learn thresholds
|
||||
from outcomes yet.
|
||||
- `POST /api/sync-pr` creates a PR artifact path but does not yet build a
|
||||
meaningful sync diff.
|
||||
- Frontend `PodView` has only a placeholder intervention area unless/until live
|
||||
intervention rendering is wired.
|
||||
- `backend/src/voice/live.ts` now publishes a `VOICE_CUE` fallback and attempts
|
||||
Gemini audio publication into LiveKit. The agent only calls it for critical
|
||||
interventions so voice remains an urgent escalation path.
|
||||
- Hermes now has a data-channel teammate message path via `HERMES_MESSAGE` on
|
||||
the existing `podman.intervention` topic. This is the MVP notification bridge,
|
||||
not a Slack/Discord integration.
|
||||
- `backend/src/memory/vectors.ts` implements exact-signature recall first and
|
||||
can use Voyage/Gemini embeddings with Atlas Vector Search when configured.
|
||||
- Exact-signature recall now attaches prior interventions/outcomes and prefers
|
||||
accepted real collisions, giving the learning beat deterministic MongoDB
|
||||
proof before vector search.
|
||||
- `backend/src/memory/policy.ts` now uses severity, per-pod cooldown, and prior
|
||||
outcome history. It is still a simple policy, not a trained threshold model.
|
||||
- `POST /api/sync-pr` now creates a visible Markdown sync artifact commit before
|
||||
opening the PR.
|
||||
- Frontend `PodView` renders intervention cards, Hermes messages, voice cues,
|
||||
and the accepted sync PR artifact link.
|
||||
- Browser screen publishing exists, but the active join path must be proven to
|
||||
tag tracks as screen share so the backend agent can filter them correctly. The
|
||||
`origin/main` screen-share button appears to address this; local code remains
|
||||
behind until that commit is merged.
|
||||
- `GIT_REPORT` exists in shared types and agent handling. `scripts/podman-agent.mjs`
|
||||
is the finished per-laptop git sidecar — polls every 15 s, upserts git fields
|
||||
to `engineer_states` collection. Not yet wired to publish a `GIT_REPORT` data
|
||||
channel message into the LiveKit room (agent fusion step still needed).
|
||||
to `engineer_states` collection. The backend agent now fuses those Mongo
|
||||
git-state fields into live contexts before collision detection; direct
|
||||
LiveKit `GIT_REPORT` publication from the sidecar remains optional.
|
||||
- Background research recommendations are a product requirement and demo goal,
|
||||
not an implemented research agent yet.
|
||||
- Deployment reliability is partial; API health is reachable, but API/static
|
||||
site/worker together must still be reverified before demo.
|
||||
- Env docs are inconsistent: backend defaults are `gemini-3.5-flash` and
|
||||
`gemini-3.1-flash-live-preview`, while `.env.example` still lists older
|
||||
Gemini model names.
|
||||
- Env docs now align on `gemini-3.5-flash` for vision and
|
||||
`gemini-3.1-flash-tts-preview` for voice. The backend still preserves a Gemini
|
||||
Live path for future available Live models.
|
||||
|
||||
### Not yet proven
|
||||
|
||||
@@ -215,7 +221,8 @@ 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.
|
||||
- Voice escalation heard by participants through LiveKit, including
|
||||
duration-based track holding so longer Gemini TTS announcements finish.
|
||||
- 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
|
||||
@@ -287,6 +294,8 @@ 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`
|
||||
@@ -314,6 +323,7 @@ LIVEKIT_API_SECRET=
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_VISION_MODEL=
|
||||
GEMINI_LIVE_MODEL=
|
||||
GEMINI_TTS_VOICE=
|
||||
|
||||
GITHUB_TOKEN=
|
||||
GITHUB_REPO=karti-ai/podman
|
||||
@@ -356,9 +366,8 @@ 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Keep model IDs in env so preview/availability changes do not require code
|
||||
changes.
|
||||
|
||||
@@ -465,8 +474,8 @@ artifact.
|
||||
- Escalate to voice only when urgent.
|
||||
|
||||
10. **Action artifact**
|
||||
- If demo uses same-file collision, click the card to open a real draft sync
|
||||
PR or visible GitHub artifact.
|
||||
- If demo uses same-file collision, click the card to open a real sync PR
|
||||
artifact or visible GitHub artifact.
|
||||
- If demo uses research recommendation, show the accepted recommendation and
|
||||
memory outcome instead.
|
||||
|
||||
@@ -475,11 +484,58 @@ 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).
|
||||
|
||||
3. **Step 3 - derive `wasRealCollision` from git overlap (backend-authoritative)** ✅
|
||||
- Overlap is captured AT detection time as `Collision.gitOverlap`
|
||||
(`backend/src/agent/podman.ts`), while `engineer_states` are still fresh —
|
||||
true only if ALL involved engineers have the collided file in their git
|
||||
`changedFiles`, matched on case/whitespace-canonical names.
|
||||
- `backend/src/memory/store.ts` `recordOutcome` overrides the client value
|
||||
with `deriveWasRealCollision()`, which prefers the stored `gitOverlap`
|
||||
(immune to late clicks / stale sidecars / the 120s TTL) and only falls back
|
||||
to a live canonical-name re-derivation for pre-existing collisions.
|
||||
`frontend/.../useInterventions.ts` stops sending hardcoded `true`.
|
||||
- Restores the (accepted × wasReal) 2×2 the spec assumes; keeps `learned_from`
|
||||
edges (`graph/live.ts:413`) from being silently zeroed on stage.
|
||||
- Spec: `docs/continual-learning/spec.md:98-108`, `policy.md:35-42`.
|
||||
- Hardened per Codex review (name canonicalization + detection-time capture).
|
||||
|
||||
Follow-ups (separate rungs, not in this change): Step 4-5 `strategy_versions` +
|
||||
Gemini-proposed `LearningProposal` slice; Step 6 durable `owns` write; seed a
|
||||
clean demo pod with a repeated dismissed signature (the historic 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`.
|
||||
- Add browser-side TTS or pre-generated voice fallback for urgent interventions.
|
||||
- Keep browser-side TTS as an explicit demo fallback only; Gemini TTS over
|
||||
LiveKit is the default urgent-voice path.
|
||||
- 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.
|
||||
@@ -499,7 +555,7 @@ artifact.
|
||||
- Full auth/accounts.
|
||||
- Slack/Linear/Jira integrations unless Hermes requires one immediately.
|
||||
- Complex dashboards.
|
||||
- Server-published audio if browser/pre-generated voice proves escalation.
|
||||
- Live voice polish beyond the Gemini TTS urgent-alert path.
|
||||
- Vector Search if exact Mongo recall demonstrates the learning beat.
|
||||
|
||||
---
|
||||
@@ -600,6 +656,16 @@ 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:
|
||||
@@ -667,15 +733,16 @@ Before saying PodMan is demo-ready:
|
||||
- [ ] Backend agent subscribes to the screen-share track.
|
||||
- [ ] Agent logs at least one parsed Gemini context from a real IDE screen.
|
||||
- [x] Local git report supplies dirty/unpushed truth on a schedule (`scripts/podman-agent.mjs` — 15 s poll → MongoDB `engineer_states`). Agent fusion still needed.
|
||||
- [ ] Frontend renders a real intervention card.
|
||||
- [ ] Hermes notification path works for teammate messages.
|
||||
- [x] Frontend renders a real intervention card.
|
||||
- [x] Hermes notification path works for teammate messages over the LiveKit data
|
||||
channel.
|
||||
- [ ] Voice is heard only for urgent escalation or a fallback is declared.
|
||||
- [ ] Outcome ACK writes to MongoDB.
|
||||
- [ ] `/api/memory/stats` shows counts increasing.
|
||||
- [ ] Second similar situation uses prior memory in the message.
|
||||
- [x] Outcome ACK writes to MongoDB and updates intervention status.
|
||||
- [x] `/api/memory/stats` shows counts increasing.
|
||||
- [x] Second similar situation uses prior exact memory in the message.
|
||||
- [ ] Research recommendation card is evidence-backed, or fallback collision demo
|
||||
is used.
|
||||
- [ ] Sync PR action creates a visible GitHub artifact if used in demo.
|
||||
- [x] Sync PR action creates a visible GitHub artifact if used in demo.
|
||||
- [ ] DigitalOcean deployment or local fallback is rehearsed.
|
||||
- [ ] Backup recording is ready on a separate device.
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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?"
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# 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
@@ -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 nudge fires | Hermes auto |
|
||||
| ~1:20 | BLOCKER_DETECTED intervention fires | Hermes auto |
|
||||
| 1:50 | Alice starts her server (`node server.js`) | Alice |
|
||||
| ~2:00 | DEPENDENCY_READY nudge fires | Hermes auto |
|
||||
| ~2:00 | DEPENDENCY_READY intervention 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 nudges per pod. For the demo, if you need to trigger a second event quickly:
|
||||
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:
|
||||
|
||||
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 nudge text cards on screen instead
|
||||
3. **LiveKit audio not working:** play backup video — show the intervention 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.
|
||||
|
||||
+51
-2
@@ -69,7 +69,8 @@ LIVEKIT_API_SECRET=...
|
||||
|
||||
GEMINI_API_KEY=...
|
||||
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||
GEMINI_LIVE_MODEL=gemini-live-2.5-flash
|
||||
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
|
||||
GEMINI_TTS_VOICE=Charon
|
||||
|
||||
GITHUB_TOKEN=...
|
||||
GITHUB_REPO=karti-ai/podman
|
||||
@@ -95,6 +96,17 @@ 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
|
||||
@@ -170,14 +182,51 @@ The droplet production fallback uses systemd units from `infra/systemd/`:
|
||||
```bash
|
||||
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||
```
|
||||
|
||||
Expected runtime proof:
|
||||
|
||||
```bash
|
||||
systemctl is-active podman-platform-api podman-platform-agent
|
||||
systemctl is-active podman-hermes-watchdog.timer
|
||||
systemctl is-active podman-hermes-sync-deploy.timer
|
||||
curl http://127.0.0.1:8787/health
|
||||
journalctl -u podman-platform-agent -n 20 --no-pager
|
||||
journalctl -u podman-hermes-watchdog -n 40 --no-pager
|
||||
```
|
||||
|
||||
## Hermes-managed operations layer
|
||||
|
||||
The app processes are still supervised by systemd, but Hermes now owns the
|
||||
operations loop around them:
|
||||
|
||||
- `pnpm hermes:watchdog` checks systemd services, public routes, `/health`,
|
||||
`/api/pods`, and `pnpm deploy:doctor`.
|
||||
- `podman-hermes-watchdog.timer` runs that watchdog every five minutes.
|
||||
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes. If
|
||||
the tree is clean and the remote moved, it fast-forwards, installs, builds,
|
||||
publishes `frontend/dist` to `/var/www/podman`, restarts the API/agent/Caddy,
|
||||
and runs the strict watchdog.
|
||||
- Failed URL checks trigger restarts of the PodMan API, PodMan agent, and Caddy.
|
||||
- Failed service checks restart only the unhealthy service.
|
||||
- Caddy is validated and reloaded after public route failures.
|
||||
- Reports are written to `/var/log/podman/hermes-watchdog-latest.json`.
|
||||
- Set `PODMAN_ALERT_WEBHOOK_URL` to send failed reports to Discord, Slack, or a
|
||||
generic webhook receiver.
|
||||
- `pnpm hermes:install` installs the timer units and a local pre-push hook that
|
||||
gates major pushes with typecheck, lint, and a non-remediating watchdog check.
|
||||
|
||||
The strict gate for production readiness is:
|
||||
|
||||
```bash
|
||||
pnpm hermes:watchdog:strict
|
||||
```
|
||||
|
||||
Manual deploy-sync run:
|
||||
|
||||
```bash
|
||||
pnpm hermes:sync-deploy
|
||||
```
|
||||
|
||||
+17
-16
@@ -1,6 +1,6 @@
|
||||
# Gemini Integration Spec
|
||||
|
||||
PodMan uses Gemini for two distinct jobs: **vision** (understanding screens) and **voice** (speaking nudges).
|
||||
PodMan uses Gemini for two distinct jobs: **vision** (understanding screens) and **voice** (urgent voice cues).
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +75,7 @@ Respond with valid JSON only.
|
||||
|
||||
---
|
||||
|
||||
## 3. Nudge Generation — Voice Message
|
||||
## 3. Intervention Text Generation
|
||||
|
||||
**Model:** `gemini-2.0-flash` (text only)
|
||||
|
||||
@@ -109,29 +109,30 @@ Respond with the message text only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Voice Output — Gemini Live 2.5 via LiveKit
|
||||
## 4. Voice Output — Gemini TTS via LiveKit
|
||||
|
||||
**Model:** `gemini-live-2.5-flash` (confirm exact ID from LiveKit Agents docs)
|
||||
**Model:** `gemini-3.1-flash-tts-preview`
|
||||
**Default voice:** `Charon`
|
||||
|
||||
**Integration:** LiveKit Agents framework — Hermes runs as a LiveKit Agent with Gemini Live 2.5 as the voice provider
|
||||
**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.
|
||||
|
||||
**Flow:**
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
**Why Gemini Live (not plain TTS):**
|
||||
**Why Gemini TTS first:**
|
||||
|
||||
- Streams audio directly — no intermediate WAV file conversion
|
||||
- Latency ~300–500ms from text to first audio packet
|
||||
- Natural-sounding voice
|
||||
- Strong prize story: Gemini Live 2.5 is the headline model
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## Cooldown
|
||||
|
||||
Per-pod cooldown of **3 minutes** between nudges. Prevents spam if multiple events fire simultaneously. Implemented in Hermes, not in Gemini.
|
||||
Per-pod cooldown of **3 minutes** between urgent voice cues. Prevents spam if multiple risks fire simultaneously. Implemented in Hermes, not in Gemini.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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.
|
||||
+29
-8
@@ -1,8 +1,10 @@
|
||||
# Continual-Learning Graph Spec
|
||||
|
||||
> Owner: graph data + visualization. Status: demo-backed (live `team_model` reads land later).
|
||||
> Owner: graph data + visualization. Status: demo-backed / active.
|
||||
> 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)
|
||||
|
||||
@@ -26,7 +28,9 @@ The graph lives in two places, both keyed by `podId`:
|
||||
{ podId, graph: PodGraph, updatedAt }
|
||||
```
|
||||
|
||||
`GET /api/pods/:podId/graph` returns `team_model.graph`, or a demo graph when none exists yet.
|
||||
`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.
|
||||
|
||||
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):
|
||||
@@ -76,13 +80,30 @@ 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/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
|
||||
- `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/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
|
||||
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
|
||||
- `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`)
|
||||
- `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`)
|
||||
|
||||
## Demo-first plan
|
||||
## Live data → graph mapping
|
||||
|
||||
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.
|
||||
`materializePodGraph` reads the 5 real collections per pod and emits a `PodGraph`:
|
||||
|
||||
| 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` |
|
||||
|
||||
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).
|
||||
|
||||
+13
-13
@@ -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 notifies collaborators when dependencies, 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 coordinates collaborators when collisions, 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 screen via periodic snapshots (consented, browser-native)
|
||||
1. Watches each engineer's consented LiveKit screen-share signal
|
||||
2. Extracts structured context using Gemini Vision — current file, inferred task, terminal state
|
||||
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
|
||||
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
|
||||
|
||||
**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 event types:
|
||||
- Detect 3 coordination risk 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 1–2 sentence proactive voice nudge
|
||||
- Deliver it into the LiveKit room via Gemini Live 2.5
|
||||
- Generate a short intervention message
|
||||
- Deliver it as a LiveKit data message, with Gemini TTS audio reserved for urgent escalation
|
||||
|
||||
---
|
||||
|
||||
## How it fits the Continual Learning track
|
||||
|
||||
PodMan builds an **ownership map** in MongoDB that persists across sessions:
|
||||
PodMan builds outcome-backed team memory in MongoDB that persists across sessions:
|
||||
|
||||
- Session 1: PodMan needs 3–5 minutes of screen observations to infer who owns what
|
||||
- Session 2+: PodMan already knows. First nudge fires in under 30 seconds.
|
||||
- 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
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -88,6 +88,6 @@ Each engineer opens a browser PWA on their laptop. The PWA captures a screen fra
|
||||
|
||||
| Prize | How PodMan earns it |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Best Gemini 3.5 / 2.5 | Gemini Vision for screen understanding + Gemini Live 2.5 for voice output |
|
||||
| Best Gemini 3.5 / 2.5 | Gemini Vision for screen understanding + Gemini TTS for urgent 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 |
|
||||
|
||||
+31
-19
@@ -24,44 +24,53 @@ LiveKit is the real-time backbone for PodMan. It handles room presence and voice
|
||||
|
||||
**Receiving:**
|
||||
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
**Data channel listener (PWA):**
|
||||
|
||||
```ts
|
||||
room.on(RoomEvent.DataReceived, (payload, participant) => {
|
||||
if (participant?.identity !== 'podman-hermes') return;
|
||||
const nudge = JSON.parse(new TextDecoder().decode(payload));
|
||||
// nudge: { type, message, involvedEngineers, file, sentAt }
|
||||
appendNudgeToFeed(nudge);
|
||||
const intervention = JSON.parse(new TextDecoder().decode(payload));
|
||||
// intervention: COLLISION, HERMES_MESSAGE, VOICE_CUE, ACK, or GIT_REPORT
|
||||
appendInterventionToFeed(intervention);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hermes side (LiveKit Agent)
|
||||
## Hermes side (PodMan LiveKit participant)
|
||||
|
||||
**Framework:** LiveKit Agents (Node.js)
|
||||
**Framework:** `@livekit/rtc-node`
|
||||
|
||||
**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. Registers as a LiveKit Agent with Gemini Live 2.5 as voice provider
|
||||
3. Publishes data-channel cards/messages and Gemini TTS audio tracks
|
||||
|
||||
**Voice delivery:**
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
**Data channel message (sent alongside audio):**
|
||||
|
||||
```ts
|
||||
const nudge = {
|
||||
const intervention = {
|
||||
type: 'DEPENDENCY_READY' | 'BLOCKER_DETECTED' | 'DUPLICATE_WORK',
|
||||
message: string, // the spoken text
|
||||
involvedEngineers: string[],
|
||||
@@ -69,7 +78,7 @@ const nudge = {
|
||||
sentAt: string, // ISO timestamp
|
||||
};
|
||||
room.localParticipant.publishData(
|
||||
new TextEncoder().encode(JSON.stringify(nudge)),
|
||||
new TextEncoder().encode(JSON.stringify(intervention)),
|
||||
{ reliable: true }
|
||||
);
|
||||
```
|
||||
@@ -89,11 +98,14 @@ Hermes uses the same endpoint. Grants:
|
||||
|
||||
---
|
||||
|
||||
## Gemini Live 2.5 model
|
||||
## Gemini voice model
|
||||
|
||||
- Model ID: `gemini-live-2.5-flash` — confirm exact ID from LiveKit Agents + Gemini docs at build time
|
||||
- LiveKit Agents has native Gemini Live integration — no manual audio encoding needed
|
||||
- Hermes passes text string → Agents handles streaming audio publication
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+151
-114
@@ -1,143 +1,180 @@
|
||||
# MongoDB Atlas Integration Spec
|
||||
|
||||
MongoDB Atlas is PodMan's shared memory. It stores live engineer state, the ownership map that enables continual learning, coordination events, and nudge history.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Collections
|
||||
## Current Collections
|
||||
|
||||
### `engineer_states`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```ts
|
||||
{
|
||||
_id: string, // engineerId (stable across sessions)
|
||||
podId: string,
|
||||
name: string, // display name
|
||||
Key fields:
|
||||
|
||||
// --- 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 (0–1)
|
||||
visionUpdatedAt: Date,
|
||||
- `podId`
|
||||
- `name`
|
||||
- `currentFile`
|
||||
- `inferredTask`
|
||||
- `confidence`
|
||||
- `changedFiles`
|
||||
- `diffStat`
|
||||
- `recentCommit`
|
||||
- `branch`
|
||||
- `visionUpdatedAt`
|
||||
- `gitUpdatedAt`
|
||||
- `updatedAt`
|
||||
|
||||
// --- 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,
|
||||
Primary use: deterministic dirty/unpushed truth for collision detection and
|
||||
graph discovery.
|
||||
|
||||
// --- Shared ---
|
||||
updatedAt: Date // most recent write from either source
|
||||
}
|
||||
```
|
||||
### `observations`
|
||||
|
||||
**Index:** `{ podId: 1, updatedAt: -1 }`
|
||||
Structured perception events from consented screen context and agent inference.
|
||||
|
||||
**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.
|
||||
Key fields:
|
||||
|
||||
**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.
|
||||
- `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`
|
||||
|
||||
---
|
||||
|
||||
### `ownership_map`
|
||||
## Graph Truth Order
|
||||
|
||||
Tracks who works on which files. Built up over the session. **Persists across sessions** — this is the continual learning artifact.
|
||||
`GET /api/pods/:podId/graph` follows this order:
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
1. Live graph from real collections.
|
||||
2. Seeded graph from `team_model.graph` and mirrored graph records.
|
||||
3. Demo fallback graph for stage safety.
|
||||
|
||||
**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.
|
||||
Seeded and fallback graphs are acceptable for demos only when labeled honestly.
|
||||
|
||||
---
|
||||
|
||||
### `events`
|
||||
## Demo Proof Path
|
||||
|
||||
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 }`
|
||||
Observe screen/git state -> detect collision -> send intervention -> accept or
|
||||
dismiss outcome -> recall similar event -> show changed graph or changed
|
||||
behavior.
|
||||
|
||||
---
|
||||
|
||||
### `nudges`
|
||||
## What MongoDB Does Not Store
|
||||
|
||||
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
|
||||
- Raw screenshot frames.
|
||||
- Screen recordings.
|
||||
- Secrets or credentials.
|
||||
- Full terminal logs.
|
||||
- Full Gemini response objects beyond extracted fields needed for memory.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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,11 +7,16 @@ 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 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.
|
||||
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.
|
||||
|
||||
**Track:** Continual Learning — the ownership map makes PodMan faster and smarter each session with no user configuration.
|
||||
**Track:** Continual Learning — accepted and dismissed outcomes make later exact-signature recall and graph memory more useful.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,26 +24,27 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
|
||||
|
||||
```
|
||||
┌──────────────── Engineer laptop (Browser PWA) ──────────────────┐
|
||||
│ getDisplayMedia → frame every 30s │
|
||||
│ HTTP POST /ingest → { screenshot, engineerId, podId } │
|
||||
│ LiveKit room joined → receives voice audio from Hermes │
|
||||
│ Earbuds: hears PodMan proactive nudges │
|
||||
│ 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 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│ POST /ingest
|
||||
│ LiveKit media + data
|
||||
▼
|
||||
┌────────────────── HERMES (DigitalOcean) ─────────────────────────┐
|
||||
│ 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 │
|
||||
│ 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 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│ read/write
|
||||
▼
|
||||
MongoDB Atlas
|
||||
(engineer_states, ownership_map,
|
||||
events, nudges)
|
||||
(engineer_states, observations,
|
||||
collisions, interventions, outcomes,
|
||||
team_model, graph_nodes, graph_edges)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -48,51 +54,53 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
|
||||
### PWA (local agent)
|
||||
|
||||
- Joins LiveKit room via existing `joinPod` flow
|
||||
- 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
|
||||
- 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
|
||||
- Two screens: join screen (built), active session screen (to build)
|
||||
|
||||
### Hermes (orchestrator)
|
||||
|
||||
- Express server + LiveKit Agent on DigitalOcean
|
||||
- `POST /ingest`: receives frame, queues for vision
|
||||
- LiveKit agent worker receives sampled screen-share frames and queues them for vision
|
||||
- Vision pipeline: Gemini 2.0 Flash → `EngineerContext`
|
||||
- Confidence gate: discard frames with confidence < 0.6
|
||||
- State writer: upsert `engineer_states` + `ownership_map` in MongoDB
|
||||
- State writer: write `observations`, `collisions`, `interventions`, `outcomes`, and `engineer_states`
|
||||
- Event detector: Gemini text prompt over all active states
|
||||
- Nudge generator: Gemini text → 1–2 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
|
||||
- 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
|
||||
|
||||
### 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 }`
|
||||
- **Nudge generation:** `gemini-2.0-flash` — event → spoken message text
|
||||
- **Voice:** `gemini-live-2.5-flash` via LiveKit Agents — text → streaming audio
|
||||
- **Message generation:** `gemini-2.0-flash` — risk → intervention text
|
||||
- **Voice:** `gemini-3.1-flash-tts-preview` via LiveKit audio publication — text → audio
|
||||
|
||||
### MongoDB Atlas (4 collections)
|
||||
### MongoDB Atlas
|
||||
|
||||
- `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
|
||||
- `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`
|
||||
|
||||
### LiveKit
|
||||
|
||||
- One room per pod
|
||||
- Engineers publish screen track (used client-side for capture — Hermes does not subscribe)
|
||||
- Hermes joins as `podman-hermes`, publishes audio + data channel messages
|
||||
- Engineers publish screen-share tracks
|
||||
- PodMan joins as an agent participant, subscribes to screen share, and publishes audio + data channel messages
|
||||
- Engineers receive audio automatically
|
||||
|
||||
---
|
||||
|
||||
## Event types
|
||||
|
||||
| Event | Trigger | Example nudge |
|
||||
| Event | Trigger | Example intervention |
|
||||
| ------------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `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." |
|
||||
@@ -102,13 +110,17 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
|
||||
|
||||
## Continual learning story
|
||||
|
||||
The `ownership_map` collection persists across sessions. On Hermes startup:
|
||||
The `team_model` graph and accepted outcomes persist across sessions. On graph
|
||||
load:
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
**Demo:** Session 1 takes 3 min to first nudge. Session 2 fires in < 30 seconds. That is the learning, visible on stage.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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).
|
||||
+8
-1
@@ -3,7 +3,14 @@ import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['**/dist/**', '**/build/**', '**/node_modules/**', '**/*.config.*'],
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/node_modules/**',
|
||||
'**/.venv/**',
|
||||
'**/*.config.*',
|
||||
'examples/livekit-gemini-hacker-starter/**',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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
|
||||
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
@@ -0,0 +1,259 @@
|
||||
# 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:
|
||||
|
||||
[](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.10–3.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.
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,294 @@
|
||||
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)
|
||||
@@ -0,0 +1,17 @@
|
||||
[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 = ["."]
|
||||
@@ -0,0 +1,2 @@
|
||||
livekit-agents[google,images]~=1.3
|
||||
python-dotenv>=1.0.0
|
||||
+2265
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
# 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=
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript", "prettier"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
@@ -0,0 +1,6 @@
|
||||
dist/
|
||||
docs/
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
.next/
|
||||
.env*
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
# 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).
|
||||
|
||||
[](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)!
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
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.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,111 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
'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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
'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>
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
'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);
|
||||
@@ -0,0 +1,64 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user