Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82e5378658 | |||
| 26c851e2bb | |||
| 28f3236d76 | |||
| 70b41104ad | |||
| a97142cf09 | |||
| bc50406587 | |||
| 88abd8efb7 | |||
| 454fdab13b | |||
| 63a411490c | |||
| faa9769470 | |||
| 79620bccaa | |||
| d8eaf8d2e2 | |||
| e6e7a42d90 | |||
| 9658d700fa | |||
| 06098f3c4e | |||
| 65764f0a1c | |||
| b6447bb12c | |||
| f7521fb5df | |||
| 91403abe9f | |||
| f4ad3bec63 | |||
| 9fb252ec6a | |||
| 8d17663734 | |||
| 3cb66e3f27 | |||
| dec6557cad | |||
| 50cc4a2900 | |||
| d1570a44d6 | |||
| 9459203ffc | |||
| 0077e0d7c3 | |||
| 12dbf69431 | |||
| 6f01571edb | |||
| cf9cca933c | |||
| 1901010996 | |||
| d9776452b2 | |||
| 0d69f82139 | |||
| 7f922c0332 | |||
| 9f509e5f34 | |||
| e7acab2c56 | |||
| 1d097b13af | |||
| ab8ea07c12 |
@@ -26,13 +26,16 @@ VOYAGE_EMBEDDING_MODEL=voyage-4-lite
|
|||||||
# --- Backend server ---
|
# --- Backend server ---
|
||||||
PORT=8787
|
PORT=8787
|
||||||
POD_ROOM=demo-pod
|
POD_ROOM=demo-pod
|
||||||
|
CLERK_SECRET_KEY=
|
||||||
|
|
||||||
# --- Nudge cooldown (ms) — set to 0 during demo if needed ---
|
# --- Nudge cooldown (ms) — set to 0 during demo if needed ---
|
||||||
NUDGE_COOLDOWN_MS=180000
|
NUDGE_COOLDOWN_MS=180000
|
||||||
|
RESEARCH_OVERLAP_THRESHOLD=0.6
|
||||||
|
|
||||||
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
|
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
|
||||||
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
|
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||||
VITE_BACKEND_URL=http://localhost:8787
|
VITE_BACKEND_URL=http://localhost:8787
|
||||||
|
VITE_CLERK_PUBLISHABLE_KEY=
|
||||||
# Keep off by default so users hear Gemini audio delivered through LiveKit.
|
# Keep off by default so users hear Gemini audio delivered through LiveKit.
|
||||||
VITE_ENABLE_BROWSER_TTS_FALLBACK=false
|
VITE_ENABLE_BROWSER_TTS_FALLBACK=false
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ __pycache__/
|
|||||||
# Ramis
|
# Ramis
|
||||||
.remember/
|
.remember/
|
||||||
.claude/
|
.claude/
|
||||||
|
.hermes/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -318,30 +318,31 @@ Everything should serve that outcome.
|
|||||||
|
|
||||||
## Documentation-first enforcement — HARD RULE
|
## Documentation-first enforcement — HARD RULE
|
||||||
|
|
||||||
**Every line of code must trace back to a task in `docs/PLAN.md` or a spec in `docs/`.**
|
**Every line of code must trace back to a spec in `docs/`.**
|
||||||
|
|
||||||
This is not a guideline. This is a gate.
|
This is not a guideline. This is a gate. The canonical specs are
|
||||||
|
`docs/gemini.md`, `docs/livekit.md`, `docs/mongodb.md`, `docs/cont_learning.md`,
|
||||||
|
`docs/hermes.md`, and `docs/digitalocean.md`. `docs/demo.md` is the demo script.
|
||||||
|
|
||||||
### Before writing any code, verify:
|
### Before writing any code, verify:
|
||||||
|
|
||||||
1. **Is this task in `docs/PLAN.md`?** Find the exact task number. If it's not there, stop.
|
1. **Is the approach consistent with the relevant spec?** Check `docs/gemini.md`, `docs/livekit.md`, `docs/mongodb.md`, `docs/cont_learning.md`, `docs/hermes.md`, `docs/digitalocean.md` as applicable.
|
||||||
2. **Is the approach consistent with the relevant spec?** Check `docs/gemini.md`, `docs/livekit.md`, `docs/mongodb.md`, `docs/digitalocean.md` as applicable.
|
2. **Do the file names and API shapes match what's documented?** If a spec says `backend/src/memory/store.ts`, do not create `backend/src/database/engineStates.ts` without updating the spec first.
|
||||||
3. **Do the file names and API shapes match what's documented?** If the plan says `backend/src/db/states.ts`, do not create `backend/src/database/engineStates.ts` without updating the spec first.
|
|
||||||
|
|
||||||
### If a developer asks for something not in the plan:
|
### If a developer asks for something not in the specs:
|
||||||
|
|
||||||
**Do not write the code.** Instead:
|
**Do not write the code.** Instead:
|
||||||
|
|
||||||
1. Say explicitly: _"This isn't in the current plan. Let me understand what you're trying to do."_
|
1. Say explicitly: _"This isn't in the current specs. Let me understand what you're trying to do."_
|
||||||
2. Ask what problem they're solving and whether it's required for the demo path.
|
2. Ask what problem they're solving and whether it's required for the demo path.
|
||||||
3. Evaluate whether it fits within scope or replaces something planned.
|
3. Evaluate whether it fits within scope or replaces something documented.
|
||||||
4. If it's valid: **update `docs/PLAN.md` and the relevant spec first**, then proceed to code.
|
4. If it's valid: **update the relevant spec first**, then proceed to code.
|
||||||
5. If it's scope creep: say so directly and recommend the nearest in-plan alternative.
|
5. If it's scope creep: say so directly and recommend the nearest in-spec alternative.
|
||||||
|
|
||||||
### Signs a request is off-plan (stop and consult):
|
### Signs a request is off-spec (stop and consult):
|
||||||
|
|
||||||
- Introducing a new file not mentioned in any task's **Files** list
|
- Introducing a new file or API route not described in any spec
|
||||||
- Changing an API signature documented in a spec (`/ingest`, `/health`, `/pods/:podId/token`, `/pods/:podId/state`)
|
- Changing a documented API signature (`/health`, `POST /api/token`, `POST /api/outcome`, `GET /api/pods/:id/...`)
|
||||||
- Adding a dependency not in the existing `package.json` files without a clear spec reason
|
- Adding a dependency not in the existing `package.json` files without a clear spec reason
|
||||||
- Building a feature in the **Cut immediately** list
|
- Building a feature in the **Cut immediately** list
|
||||||
- Touching another engineer's ownership area without explicit cross-team coordination
|
- Touching another engineer's ownership area without explicit cross-team coordination
|
||||||
@@ -359,10 +360,24 @@ This repo is actively used by **4 engineers at the same time**. Claude sessions
|
|||||||
### What this means for how you help
|
### What this means for how you help
|
||||||
|
|
||||||
- **Assume other files are actively being edited.** Never refactor code outside the immediate task scope without explicit coordination from the user.
|
- **Assume other files are actively being edited.** Never refactor code outside the immediate task scope without explicit coordination from the user.
|
||||||
- **Treat integration points as contracts.** The shared types in `shared/src/` and the API shapes of `POST /ingest`, `GET /pods/:podId/token`, and `GET /pods/:podId/state` are the interfaces between all teammates — do not change their signatures unilaterally.
|
- **Treat integration points as contracts.** The shared types in `shared/src/` and the API shapes of `POST /api/token`, `POST /api/outcome`, and the `GET /api/pods/:id/*` routes are the interfaces between all teammates — do not change their signatures unilaterally.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **When proposing new files**, verify they match the file names and paths described in the relevant spec in `docs/`. Do not invent new paths.
|
||||||
|
|
||||||
|
### Git workflow — push directly to `main`, stay in sync
|
||||||
|
|
||||||
|
This repo **does not use feature branches**. Commit straight to `main` and push.
|
||||||
|
There is no branch-first step. Because several people push concurrently:
|
||||||
|
|
||||||
|
- **Always sync before pushing:** `git pull --rebase origin main` immediately
|
||||||
|
before `git push`. Never force-push `main`.
|
||||||
|
- **Keep commits small and additive** so rebases stay clean — prefer new files
|
||||||
|
and new functions over editing shared hot files (`backend/src/agent/podman.ts`,
|
||||||
|
`backend/src/server.ts`, `frontend/src/App.tsx`).
|
||||||
|
- **If a rebase conflicts**, resolve it locally and re-run the pull-rebase before
|
||||||
|
pushing; do not overwrite a teammate's commit.
|
||||||
|
- Commit/push only when the user asks (overrides any default branch-first habit).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +1,384 @@
|
|||||||
# PodMan - Real-time AI Team Coordination Agent
|
# PodMan
|
||||||
|
|
||||||
[](https://www.typescriptlang.org/)
|
**An ambient pair programmer for engineering teams.**
|
||||||
[](https://react.dev/)
|
|
||||||
[](https://livekit.io/)
|
|
||||||
[](https://www.mongodb.com/)
|
|
||||||
[](https://ai.google.dev/)
|
|
||||||
[](https://www.digitalocean.com/)
|
|
||||||
|
|
||||||
**2026 AI Engineer World's Fair Hackathon** - Track: **Continual Learning**
|
PodMan watches the work happening inside a shared LiveKit room, understands what
|
||||||
|
each engineer is doing, remembers which interventions helped, and nudges the
|
||||||
|
team before duplicated work, merge collisions, or missed handoffs slow everyone
|
||||||
|
down.
|
||||||
|
|
||||||
PodMan is a non-intrusive AI teammate for active coding. It watches consented
|
<p align="center">
|
||||||
LiveKit screen-share context, combines it with local git truth and shared team
|
<a href="https://podman.live/">
|
||||||
memory, and coordinates teammates before a problem becomes a GitHub problem.
|
<img
|
||||||
|
alt="Try PodMan live in production"
|
||||||
|
src="https://img.shields.io/badge/TRY%20PODMAN%20LIVE-https%3A%2F%2Fpodman.live-1f6feb?style=for-the-badge"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
> GitHub sees pushed work. PodMan sees work while it is still happening.
|
<h2 align="center">
|
||||||
|
<a href="https://podman.live/">Open the live production app: https://podman.live/</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
PodMan is not a dashboard and not a raw screenshot analyzer. Its job is to
|
<p align="center">
|
||||||
notice useful coordination moments, remember what helped before, and route the
|
PodMan is already deployed. Click the link above and try the production app.
|
||||||
least intrusive intervention: a small card first, a Hermes message when teammates
|
</p>
|
||||||
need coordination, and voice only for urgent escalation.
|
|
||||||
|
|
||||||
---
|
<h2 align="center">
|
||||||
|
<a href="https://youtu.be/bWJIsIWTgr0">Watch the demo video</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
## Architecture
|
<p align="center">
|
||||||
|
<a href="https://youtu.be/bWJIsIWTgr0">
|
||||||
|
<img
|
||||||
|
src="https://img.youtube.com/vi/bWJIsIWTgr0/maxresdefault.jpg"
|
||||||
|
alt="PodMan demo video thumbnail"
|
||||||
|
width="760"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
PodMan is split into a browser PWA, an HTTP API service, a LiveKit agent worker,
|
[LiveKit](https://livekit.io/) · [MongoDB](https://www.mongodb.com/) ·
|
||||||
and a persistence/action layer. The screen signal flows through LiveKit, not a
|
[Gemini](https://ai.google.dev/) · [Hermes](https://hermes-agent.nousresearch.com/) ·
|
||||||
manual screenshot upload endpoint.
|
[Modular MAX](https://www.modular.com/max) ·
|
||||||
|
[DigitalOcean](https://www.digitalocean.com/)
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img
|
||||||
|
src="docs/assets/interruption-flow-recovery.jpg"
|
||||||
|
alt="A chart showing that a five minute interruption can create a much longer recovery period before a developer returns to flow state."
|
||||||
|
width="760"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> PodMan exists to prevent the five-minute interruption from becoming a
|
||||||
|
> half-hour recovery tax.
|
||||||
|
|
||||||
|
Source image: `~/pic.jpg`, committed as
|
||||||
|
`docs/assets/interruption-flow-recovery.jpg` so it renders on GitHub.
|
||||||
|
|
||||||
|
## Read This First
|
||||||
|
|
||||||
|
| Need | Use this |
|
||||||
|
| ----------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
|
| Open the product | `https://podman.live` |
|
||||||
|
| Check the app | `curl https://podman.live/health` |
|
||||||
|
| Check pods and memory | `curl https://podman.live/api/pods && curl https://podman.live/api/memory/stats` |
|
||||||
|
| Use the LLM externally | Base URL `https://llm.alhinai.dev/v1`, model `gemma-4-31B-it` |
|
||||||
|
| Test Hermes | `hermes -z 'Reply with exactly: working' --provider gemma4-31b-max --model gemma-4-31B-it` |
|
||||||
|
| Start local development | API, vision agent, and frontend commands are in [Local Development](#local-development) |
|
||||||
|
| Debug production | Public checks first, then systemd services in [Production Operations](#production-operations) |
|
||||||
|
|
||||||
|
## Hackathon Fit
|
||||||
|
|
||||||
|
PodMan is built for the **2026 AI Engineer World's Fair Hackathon** theme:
|
||||||
|
**Continual Learning**.
|
||||||
|
|
||||||
|
It is not a wrapper chatbot or static dashboard. It is a production-running
|
||||||
|
agentic coordination system that gets better from real use: every observation,
|
||||||
|
collision, intervention, accept, dismiss, suppression, and conversation note
|
||||||
|
feeds MongoDB-backed memory so future interventions become sharper and less
|
||||||
|
annoying.
|
||||||
|
|
||||||
|
| Hackathon target | How PodMan addresses it |
|
||||||
|
| ---------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| Continual Learning | Learns from real team behavior and accepted/dismissed interventions. |
|
||||||
|
| Self-Improvement Stack | Uses memory stats, Hermes jobs, production watchdogs, and deployment checks. |
|
||||||
|
| Recursive Intelligence | Gives agents operational memory and feedback loops for improving future coordination. |
|
||||||
|
| Live Demo | Already deployed at [`https://podman.live/`](https://podman.live/). |
|
||||||
|
| Technicality | Combines realtime media, vision, vector recall, graph traversal, memory, and ops agents. |
|
||||||
|
| Creativity | Solves the coordination cost around AI-assisted engineering teams. |
|
||||||
|
|
||||||
|
### Sponsor Tech Used
|
||||||
|
|
||||||
|
| Sponsor / tech | Usage in PodMan |
|
||||||
|
| -------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| DigitalOcean | Hosts the production app, API, workers, Caddy, and systemd-supervised services. |
|
||||||
|
| LiveKit | Realtime room layer for screen share, audio, data messages, and agent presence. |
|
||||||
|
| Gemini | Screen understanding, live room conversation, urgent TTS, embeddings, and music. |
|
||||||
|
| MongoDB Atlas | Durable memory, `$vectorSearch`, `$graphLookup`, user learning, and job logs. |
|
||||||
|
| Modular MAX | Serves `gemma-4-31B-it` as the long-context reasoning model. |
|
||||||
|
|
||||||
|
### What To Try
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph Laptop["Engineer laptop"]
|
A["Open podman.live"] --> B["Join a pod"]
|
||||||
PWA["React PWA"]
|
B --> C["Share screen via LiveKit"]
|
||||||
Screen["Screen share track"]
|
C --> D["Gemini extracts work context"]
|
||||||
Git["Git watcher<br/>scripts/podman-agent.mjs"]
|
D --> E["MongoDB recalls prior outcomes"]
|
||||||
end
|
E --> F["PodMan nudges only when useful"]
|
||||||
|
F --> G["Accept or dismiss"]
|
||||||
|
G --> H["Memory improves next run"]
|
||||||
|
|
||||||
subgraph Realtime["LiveKit room"]
|
classDef action fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
Room["Pod room"]
|
classDef ai fill:#f4edff,stroke:#805ad5,color:#2d1857;
|
||||||
Data["Data topic<br/>podman.intervention"]
|
classDef memory fill:#fff6df,stroke:#c47f00,color:#3d2b00;
|
||||||
end
|
class A,B,C,F,G action;
|
||||||
|
class D ai;
|
||||||
subgraph Backend["PodMan backend"]
|
class E,H memory;
|
||||||
API["API service<br/>/api/token /api/pods /api/outcome"]
|
|
||||||
Agent["Agent worker<br/>@livekit/rtc-node"]
|
|
||||||
Vision["Gemini Vision<br/>structured JSON"]
|
|
||||||
Detector["Coordination detector<br/>collisions, blockers, dead ends"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Memory["Memory and actions"]
|
|
||||||
Mongo["MongoDB<br/>observations, outcomes, pods"]
|
|
||||||
GitHub["GitHub<br/>repo state + sync PR artifact"]
|
|
||||||
Hermes["Hermes action layer<br/>cards, messages, urgent voice"]
|
|
||||||
end
|
|
||||||
|
|
||||||
PWA -->|"POST /api/token"| API
|
|
||||||
API -->|"LiveKit JWT"| PWA
|
|
||||||
PWA --> Screen
|
|
||||||
Screen --> Room
|
|
||||||
Room --> Agent
|
|
||||||
Agent --> Vision
|
|
||||||
Vision --> Detector
|
|
||||||
Git --> Mongo
|
|
||||||
Detector --> Mongo
|
|
||||||
Detector --> GitHub
|
|
||||||
Detector --> Hermes
|
|
||||||
Hermes --> Data
|
|
||||||
Data --> PWA
|
|
||||||
PWA -->|"POST /api/outcome"| API
|
|
||||||
API --> Mongo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Runtime shape
|
1. Open [`https://podman.live/`](https://podman.live/).
|
||||||
|
2. Create or join a pod.
|
||||||
|
3. Add teammates and share screens.
|
||||||
|
4. Watch PodMan build live context, detect overlap, and surface intervention
|
||||||
|
cards.
|
||||||
|
5. Accept or dismiss a card; that feedback becomes memory for the next similar
|
||||||
|
event.
|
||||||
|
|
||||||
| Layer | Runtime | Responsibility |
|
---
|
||||||
| ------------ | ------------------- | --------------------------------------------------------------------------------------------------- |
|
|
||||||
| Frontend PWA | React + Vite | Join pods, publish screen share, show live room state, render interventions |
|
|
||||||
| Backend API | Express | Mint LiveKit tokens, manage pods, record outcomes, expose memory stats, create sync PR artifacts |
|
|
||||||
| Agent worker | `@livekit/rtc-node` | Join the room as PodMan, subscribe to screen-share tracks, sample frames, publish intervention data |
|
|
||||||
| Vision loop | Gemini | Convert sampled IDE frames into structured work context |
|
|
||||||
| Team memory | MongoDB | Store observations, collisions, interventions, outcomes, pods, and git watcher state |
|
|
||||||
| Git watcher | Node script | Poll each laptop's local git state so dirty/unpushed work is not guessed from vision alone |
|
|
||||||
| Action layer | Hermes concept | Route cards, teammate messages, optional research summaries, and urgent voice escalation |
|
|
||||||
| Deployment | DigitalOcean | Static site for frontend, HTTP service for API, worker for the LiveKit agent |
|
|
||||||
|
|
||||||
### Data flow
|
## Current Live System
|
||||||
|
|
||||||
|
| Surface | Running now | Purpose |
|
||||||
|
| ------------- | ---------------------------- | -------------------------------------- |
|
||||||
|
| Product | `https://podman.live` | Team room, screen context, cards |
|
||||||
|
| API | `https://podman.live/api/*` | Pods, tokens, outcomes, memory |
|
||||||
|
| Health | `https://podman.live/health` | Backend readiness |
|
||||||
|
| Local API | `127.0.0.1:8787` | Express service behind Caddy |
|
||||||
|
| Reasoning LLM | `https://llm.alhinai.dev/v1` | OpenAI-compatible Modular/MAX endpoint |
|
||||||
|
| API key | `not-needed` | Placeholder key for OpenAI clients |
|
||||||
|
| Hermes model | `gemma-4-31B-it` | 262K-context tool-using agent |
|
||||||
|
| Hermes config | `gemma4-31b-max` | Custom provider used by Hermes locally |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Browser["Engineer Browser<br/>React + Vite PWA"]
|
||||||
|
Room["LiveKit Room<br/>screen share + audio + data messages"]
|
||||||
|
|
||||||
|
subgraph Droplet["DigitalOcean PodMan Droplet"]
|
||||||
|
Caddy["Caddy<br/>static app + /api proxy"]
|
||||||
|
API["Express API<br/>127.0.0.1:8787"]
|
||||||
|
Vision["Vision Agent<br/>screen frame observer"]
|
||||||
|
Voice["Live Conversation Agent<br/>Python + Gemini Live"]
|
||||||
|
Ops["Hermes Ops Timers<br/>watchdog + sync deploy"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Memory["MongoDB Atlas"]
|
||||||
|
Observations["observations"]
|
||||||
|
State["engineer_states"]
|
||||||
|
Outcomes["interventions + outcomes"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Google["Google Gemini APIs"]
|
||||||
|
GeminiVision["Vision"]
|
||||||
|
GeminiVoice["Live voice + TTS"]
|
||||||
|
GeminiEmbed["Embeddings fallback"]
|
||||||
|
Lyria["Music"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Reasoning["External Reasoning Endpoint"]
|
||||||
|
Tunnel["Cloudflare Tunnel<br/>llm.alhinai.dev"]
|
||||||
|
MaxServe["MAX OpenAI Server<br/>gemma-4-31B-it<br/>262144 context"]
|
||||||
|
Hermes["Hermes Agent<br/>provider: gemma4-31b-max"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Browser --> Caddy --> API
|
||||||
|
Browser <-->|screen, audio, cards| Room
|
||||||
|
API --> Room
|
||||||
|
API --> Observations
|
||||||
|
API --> State
|
||||||
|
API --> Outcomes
|
||||||
|
Vision <-->|screen tracks| Room
|
||||||
|
Vision --> GeminiVision
|
||||||
|
Vision --> Observations
|
||||||
|
Vision --> Outcomes
|
||||||
|
Voice <-->|conversation| Room
|
||||||
|
Voice --> GeminiVoice
|
||||||
|
Vision --> GeminiEmbed
|
||||||
|
Voice --> GeminiEmbed
|
||||||
|
Ops --> Hermes --> Tunnel --> MaxServe
|
||||||
|
Tunnel -. "public route<br/>llm.alhinai.dev/v1" .-> MaxServe
|
||||||
|
API --> Hermes
|
||||||
|
Lyria --> Voice
|
||||||
|
|
||||||
|
classDef user fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
|
classDef app fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||||
|
classDef data fill:#fff6df,stroke:#c47f00,color:#3d2b00;
|
||||||
|
classDef ai fill:#f4edff,stroke:#805ad5,color:#2d1857;
|
||||||
|
class Browser,Room user;
|
||||||
|
class Caddy,API,Vision,Voice,Ops app;
|
||||||
|
class Observations,State,Outcomes data;
|
||||||
|
class GeminiVision,GeminiVoice,GeminiEmbed,Lyria,Tunnel,MaxServe,Hermes ai;
|
||||||
|
```
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Engineer shares screen"] --> B["Gemini extracts work context"]
|
||||||
|
B --> C["PodMan detects overlap<br/>files, symbols, research, unpushed work"]
|
||||||
|
C --> D["MongoDB recalls<br/>similar prior events"]
|
||||||
|
D --> E{"Policy gate"}
|
||||||
|
E -->|"seen false alarm"| F["stay quiet"]
|
||||||
|
E -->|"seen real collision"| G["raise urgency"]
|
||||||
|
E -->|"new useful signal"| H["show card or message"]
|
||||||
|
G --> I["Hermes / voice escalation"]
|
||||||
|
H --> J["teammate accepts or dismisses"]
|
||||||
|
I --> J
|
||||||
|
J --> K["outcome becomes future memory"]
|
||||||
|
K --> D
|
||||||
|
```
|
||||||
|
|
||||||
|
PodMan removes the reason to interrupt. It gives the team a live picture of work
|
||||||
|
in progress, then improves from every accepted or dismissed intervention.
|
||||||
|
|
||||||
|
> GitHub sees pushed work. PodMan sees work while it is still happening.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Model Stack
|
||||||
|
|
||||||
|
PodMan uses multiple AI surfaces. They are intentionally split by job.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Work["Screen + room activity"] --> VisionRoute["Perception route"]
|
||||||
|
VoiceInput["Engineer speech"] --> ConversationRoute["Conversation route"]
|
||||||
|
OpsNeed["Ops / autonomous task"] --> HermesRoute["Reasoning route"]
|
||||||
|
MemoryNeed["Similarity search"] --> EmbedRoute["Memory route"]
|
||||||
|
Ambient["Session atmosphere"] --> MusicRoute["Audio route"]
|
||||||
|
|
||||||
|
VisionRoute --> Gemini20["gemini-2.0-flash<br/>screen understanding"]
|
||||||
|
ConversationRoute --> GeminiLive["gemini-3.1-flash-live-preview<br/>live Q&A"]
|
||||||
|
ConversationRoute --> GeminiTTS["gemini-3.1-flash-tts-preview<br/>urgent speech"]
|
||||||
|
HermesRoute --> Gemma["gemma-4-31B-it<br/>served by Modular MAX"]
|
||||||
|
EmbedRoute --> Voyage["voyage-4-lite<br/>primary embeddings"]
|
||||||
|
EmbedRoute --> GeminiEmbed["gemini-embedding-001<br/>fallback embeddings"]
|
||||||
|
MusicRoute --> Lyria["lyria-3-clip-preview<br/>background music"]
|
||||||
|
|
||||||
|
classDef signal fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
|
classDef route fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||||
|
classDef model fill:#f4edff,stroke:#805ad5,color:#2d1857;
|
||||||
|
class Work,VoiceInput,OpsNeed,MemoryNeed,Ambient signal;
|
||||||
|
class VisionRoute,ConversationRoute,HermesRoute,EmbedRoute,MusicRoute route;
|
||||||
|
class Gemini20,GeminiLive,GeminiTTS,Gemma,Voyage,GeminiEmbed,Lyria model;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemma 4 31B via Modular MAX
|
||||||
|
|
||||||
|
Hermes uses the external OpenAI-compatible endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Base URL: https://llm.alhinai.dev/v1
|
||||||
|
API key: not-needed
|
||||||
|
Model: gemma-4-31B-it
|
||||||
|
Context: 262144 tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
The Modular/MAX server is configured for long-context Gemma 4 serving:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--max-length 262144
|
||||||
|
--device-memory-utilization 0.85
|
||||||
|
--kv-cache-format float8_e4m3fn
|
||||||
|
--enable-prefix-caching
|
||||||
|
--enable-chunked-prefill
|
||||||
|
--max-batch-size 1
|
||||||
|
--max-batch-input-tokens 16384
|
||||||
|
```
|
||||||
|
|
||||||
|
Hermes should point at that endpoint with this provider shape:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model:
|
||||||
|
default: gemma-4-31B-it
|
||||||
|
provider: gemma4-31b-max
|
||||||
|
|
||||||
|
providers:
|
||||||
|
gemma4-31b-max:
|
||||||
|
name: Gemma 4 31B Modular MAX (256K)
|
||||||
|
api: https://llm.alhinai.dev/v1
|
||||||
|
api_key: not-needed
|
||||||
|
transport: chat_completions
|
||||||
|
default_model: gemma-4-31B-it
|
||||||
|
discover_models: true
|
||||||
|
models:
|
||||||
|
gemma-4-31B-it:
|
||||||
|
context_length: 262144
|
||||||
|
|
||||||
|
agent:
|
||||||
|
tool_use_enforcement: auto
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this matters: Hermes sends OpenAI tool schemas and `tool_choice: "auto"`.
|
||||||
|
The model endpoint must support automatic tool choice so Hermes can initialize
|
||||||
|
the agent without a client-side workaround.
|
||||||
|
|
||||||
|
### Modular MAX
|
||||||
|
|
||||||
|
Gemma 4 is served through Modular MAX on the GB10 machine.
|
||||||
|
|
||||||
|
| MAX support | Value |
|
||||||
|
| ------------ | ---------------------------------------- |
|
||||||
|
| Architecture | `Gemma4ForConditionalGeneration` |
|
||||||
|
| Example | `google/gemma-4-31B-it` |
|
||||||
|
| Encodings | `float4_e2m1fnx2`, `float16`, `bfloat16` |
|
||||||
|
|
||||||
|
| Runtime | Role |
|
||||||
|
| ----------------- | ----------------------------------------- |
|
||||||
|
| Modular MAX | OpenAI-compatible Gemma 4 serving runtime |
|
||||||
|
| `gemma-4-31B-it` | Primary long-context reasoning model |
|
||||||
|
| `262144` tokens | Reported model context window |
|
||||||
|
| `llm.alhinai.dev` | Public Cloudflare-routed model endpoint |
|
||||||
|
|
||||||
|
### Gemini Surfaces
|
||||||
|
|
||||||
|
Gemini remains the realtime perception and voice layer inside PodMan.
|
||||||
|
|
||||||
|
| Use | Model | Code |
|
||||||
|
| -------------------------- | ------------------------------- | ------------------------------------------ |
|
||||||
|
| Screen understanding | `gemini-2.0-flash` | `backend/src/vision/gemini.ts` |
|
||||||
|
| Urgent spoken alerts | `gemini-3.1-flash-tts-preview` | `backend/src/voice/live.ts` |
|
||||||
|
| Live room conversation | `gemini-3.1-flash-live-preview` | `agents/podman-live-conversation/agent.py` |
|
||||||
|
| Memory embeddings fallback | `gemini-embedding-001` | `backend/src/memory/vectors.ts` |
|
||||||
|
| Ambient background music | `lyria-3-clip-preview` | `backend/src/voice/music.ts` |
|
||||||
|
|
||||||
|
Voyage embeddings can be used first when `VOYAGE_API_KEY` is set. Gemini
|
||||||
|
embeddings remain the fallback. If no embedding provider is available, PodMan
|
||||||
|
falls back to exact-signature matching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How The Learning Loop Works
|
||||||
|
|
||||||
|
The learning loop is the product. A teammate only has to accept or dismiss an
|
||||||
|
intervention; the rest is captured automatically.
|
||||||
|
|
||||||
|
```text
|
||||||
|
observe -> detect -> recall prior outcomes -> policy gate -> act -> record outcome
|
||||||
|
^ |
|
||||||
|
+-------------------------- next recall -----------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stage | What happens | Code |
|
||||||
|
| ------- | --------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
|
| Observe | Gemini Vision turns sampled screen frames into structured work context. | `backend/src/vision/gemini.ts` |
|
||||||
|
| Detect | PodMan detects overlapping files, symbols, research, and unpushed work. | `backend/src/collision/detector.ts` |
|
||||||
|
| Recall | MongoDB Atlas recalls similar prior events and outcomes. | `backend/src/memory/vectors.ts` |
|
||||||
|
| Gate | Policy suppresses dismissed false alarms and escalates recurring real ones. | `backend/src/memory/policy.ts` |
|
||||||
|
| Act | PodMan publishes a card, Hermes message, or urgent voice cue. | `backend/src/action/hermes.ts` |
|
||||||
|
| Learn | Accept/dismiss feedback is written back to memory. | `backend/src/memory/store.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime Components
|
||||||
|
|
||||||
|
| Layer | Runtime | Responsibility |
|
||||||
|
| ------------------ | ----------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
| Frontend | React + Vite | Pod rooms, screen share, cards, voice controls, member state |
|
||||||
|
| Backend API | Express on `:8787` | LiveKit tokens, pod CRUD, outcomes, memory stats, sync PRs |
|
||||||
|
| Vision agent | Node + `@livekit/rtc-node` | Subscribes to screen tracks, samples frames, publishes interventions |
|
||||||
|
| Live voice agent | Python LiveKit Agents + Gemini Live | Real-time voice Q&A in a pod room |
|
||||||
|
| Memory | MongoDB Atlas | Observations, engineer state, collisions, interventions, outcomes |
|
||||||
|
| Reasoning agent | Hermes + Gemma Modular/MAX | Tool-using autonomous assistant and ops layer |
|
||||||
|
| Realtime transport | LiveKit Cloud | Screen tracks, audio tracks, data messages |
|
||||||
|
| Hosting | DigitalOcean + Caddy + systemd | Static frontend, API, workers, watchdog timers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -92,168 +386,291 @@ sequenceDiagram
|
|||||||
participant Dev as Engineer PWA
|
participant Dev as Engineer PWA
|
||||||
participant API as Backend API
|
participant API as Backend API
|
||||||
participant LK as LiveKit Room
|
participant LK as LiveKit Room
|
||||||
participant Agent as PodMan Agent
|
participant Agent as Vision Agent
|
||||||
participant Gemini as Gemini Vision
|
participant Gemini as Gemini APIs
|
||||||
participant Mongo as MongoDB Memory
|
participant Mongo as MongoDB Atlas
|
||||||
participant Hermes as Hermes / Action Layer
|
participant Hermes as Hermes/Gemma
|
||||||
participant GH as GitHub
|
|
||||||
|
|
||||||
Dev->>API: POST /api/token
|
Dev->>API: POST /api/token
|
||||||
API-->>Dev: LiveKit URL + JWT
|
API-->>Dev: LiveKit URL + JWT
|
||||||
Dev->>LK: Join pod room
|
Dev->>LK: Join pod room + publish screen share
|
||||||
Dev->>LK: Publish screen-share track
|
|
||||||
Agent->>LK: Subscribe to screen-share video
|
Agent->>LK: Subscribe to screen-share video
|
||||||
Agent->>Gemini: Sampled JPEG frame
|
Agent->>Gemini: Sampled frame
|
||||||
Gemini-->>Agent: Structured work context
|
Gemini-->>Agent: Structured work context
|
||||||
Agent->>Mongo: Record observation
|
Agent->>Mongo: Store observation
|
||||||
Agent->>GH: Read public repo state
|
Agent->>Mongo: Recall similar prior events
|
||||||
Agent->>Mongo: Recall prior patterns
|
Mongo-->>Agent: Prior outcome + policy hints
|
||||||
Agent->>Hermes: Create intervention
|
Agent->>Hermes: Escalate when autonomous help is useful
|
||||||
Hermes->>LK: Publish small data packet
|
Agent->>LK: Publish card / message / voice cue
|
||||||
LK-->>Dev: Render card / message / urgent voice cue
|
|
||||||
Dev->>API: POST /api/outcome
|
Dev->>API: POST /api/outcome
|
||||||
API->>Mongo: Store learning signal
|
API->>Mongo: Store accept/dismiss feedback
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why this architecture matters
|
|
||||||
|
|
||||||
- **LiveKit is the realtime spine.** Screens and intervention data move through a
|
|
||||||
shared room, so PodMan can react before code is pushed.
|
|
||||||
- **Gemini is the perception layer.** The agent samples frames and asks Gemini
|
|
||||||
for structured JSON such as current file, symbol, activity, unpushed hints,
|
|
||||||
and confidence.
|
|
||||||
- **MongoDB is the learning loop.** Outcomes and repeated patterns make later
|
|
||||||
interventions quieter and more useful.
|
|
||||||
- **Local git is the truth source.** The watcher reports dirty files and branch
|
|
||||||
state directly from each laptop, which avoids relying on vision for facts
|
|
||||||
GitHub cannot see.
|
|
||||||
- **Hermes keeps it non-intrusive.** Most events are cards. Team messages and
|
|
||||||
voice are escalation paths, not the default.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works
|
## Public Interfaces
|
||||||
|
|
||||||
1. Engineers open the PWA and join a pod room.
|
|
||||||
2. The backend API mints a LiveKit token via `POST /api/token`.
|
|
||||||
3. The PWA publishes screen share into the pod room when the engineer chooses
|
|
||||||
"Share my screen".
|
|
||||||
4. The PodMan agent worker joins the same room and subscribes to screen-share
|
|
||||||
tracks.
|
|
||||||
5. The agent samples frames, sends them to Gemini Vision, and records structured
|
|
||||||
observations in MongoDB.
|
|
||||||
6. Each engineer runs the git watcher so PodMan has deterministic dirty/unpushed
|
|
||||||
state.
|
|
||||||
7. The detector combines live screen context, git truth, GitHub state, and team
|
|
||||||
memory.
|
|
||||||
8. PodMan sends the smallest useful intervention: card first, Hermes message for
|
|
||||||
coordination, voice only when urgent.
|
|
||||||
9. Urgent voice uses Gemini TTS published as a LiveKit audio track. The browser
|
|
||||||
unlocks LiveKit audio from a user gesture and attaches remote audio tracks.
|
|
||||||
10. The user's response is saved as an outcome, closing the continual-learning
|
|
||||||
loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Public interfaces
|
|
||||||
|
|
||||||
| Interface | Purpose |
|
| Interface | Purpose |
|
||||||
| ----------------------------------------------------------- | ---------------------------------------------- |
|
| ----------------------------------------------------------- | ---------------------------------------------- |
|
||||||
| `GET /health` | API health check |
|
| `GET /health` | API health check |
|
||||||
| `POST /api/token` | Mint LiveKit room tokens |
|
| `POST /api/token` | Mint LiveKit room tokens |
|
||||||
| `POST /api/sync-pr` | Create a visible sync PR artifact |
|
| `GET /api/pods` | List pods |
|
||||||
| `POST /api/outcome` | Store accepted/dismissed intervention outcomes |
|
|
||||||
| `GET /api/memory/stats` | Show memory collection counts |
|
|
||||||
| `GET/POST/PATCH/DELETE /api/pods` | Pod CRUD |
|
| `GET/POST/PATCH/DELETE /api/pods` | Pod CRUD |
|
||||||
| `POST/DELETE /api/pods/:id/members` | Pod membership |
|
| `POST/DELETE /api/pods/:id/members` | Pod membership |
|
||||||
|
| `GET /api/pods/:id/members/:name/history` | Recent member work history |
|
||||||
|
| `POST /api/outcome` | Store accepted/dismissed intervention outcomes |
|
||||||
|
| `GET /api/memory/stats` | Live memory collection counts |
|
||||||
|
| `POST /api/sync-pr` | Create a visible sync PR artifact |
|
||||||
| LiveKit topic `podman.intervention` | Intervention data channel |
|
| LiveKit topic `podman.intervention` | Intervention data channel |
|
||||||
| Wire messages `COLLISION`, `ACK`, `GIT_REPORT`, `VOICE_CUE` | Shared agent/PWA message contract |
|
| Wire messages `COLLISION`, `ACK`, `GIT_REPORT`, `VOICE_CUE` | Agent/PWA contract |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Monorepo layout
|
## Monorepo Layout
|
||||||
|
|
||||||
| Folder | What |
|
| Folder | Purpose |
|
||||||
| ----------- | -------------------------------------------------------------------------------- |
|
| ----------- | ----------------------------------------------------------------------- |
|
||||||
| `frontend/` | React + Vite PWA for pods, LiveKit room UI, screen share, and intervention cards |
|
| `frontend/` | React + Vite PWA |
|
||||||
| `backend/` | Express API plus separate LiveKit agent worker |
|
| `backend/` | Express API, vision agent, memory, collision detection, Hermes job APIs |
|
||||||
| `shared/` | Shared TypeScript types and LiveKit data message contracts |
|
| `agents/` | Python LiveKit conversation agent |
|
||||||
| `database/` | MongoDB setup and seed utilities |
|
| `shared/` | Shared TypeScript contracts |
|
||||||
| `infra/` | DigitalOcean App Platform specs and Dockerfile |
|
| `database/` | MongoDB setup and seed utilities |
|
||||||
| `scripts/` | Local git watcher for demo laptops |
|
| `infra/` | Caddy, Docker, DigitalOcean, systemd units |
|
||||||
| `docs/` | Canonical plan and deeper sponsor/integration notes |
|
| `scripts/` | Git watcher, deploy doctor, watchdog, verification tooling |
|
||||||
|
| `docs/` | Demo, deployment, learning, graph, and architecture notes |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docs
|
## Local Development
|
||||||
|
|
||||||
| File | What |
|
Run the core app in three terminals:
|
||||||
| ---------------------------------------------- | ----------------------------------------- |
|
|
||||||
| [`docs/PLAN.md`](docs/PLAN.md) | Canonical master plan and source of truth |
|
|
||||||
| [`docs/idea.md`](docs/idea.md) | Product concept and demo framing |
|
|
||||||
| [`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 |
|
|
||||||
|
|
||||||
---
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Env["1. Configure .env"] --> Install["2. pnpm install"]
|
||||||
|
Install --> API["Terminal A<br/>backend API :8787"]
|
||||||
|
Install --> Agent["Terminal B<br/>vision agent"]
|
||||||
|
Install --> UI["Terminal C<br/>frontend :5173"]
|
||||||
|
Install --> Voice["Optional<br/>conversation agent"]
|
||||||
|
API --> Browser["Open local PWA"]
|
||||||
|
Agent --> Browser
|
||||||
|
UI --> Browser
|
||||||
|
Voice --> Browser
|
||||||
|
|
||||||
## Prizes targeted
|
classDef step fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||||
|
classDef run fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
- **Best Gemini:** structured vision over live IDE context, with voice as an
|
class Env,Install step;
|
||||||
optional escalation path.
|
class API,Agent,UI,Voice,Browser run;
|
||||||
- **Best LiveKit:** realtime screen-share tracks, presence, data packets, and
|
```
|
||||||
eventual voice in one pod room.
|
|
||||||
- **Best DigitalOcean:** frontend static site, API service, and LiveKit agent
|
|
||||||
worker deployment.
|
|
||||||
- **MongoDB + Voyage story:** persistent memory first, vector recall once exact
|
|
||||||
signature recall is proven.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# fill in LIVEKIT_*, GEMINI_*, GITHUB_*, and MONGODB_URI
|
# Fill LIVEKIT_*, GEMINI_*, GITHUB_*, MONGODB_URI.
|
||||||
|
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm --filter @podman/backend dev # API on :8787
|
pnpm --filter @podman/backend dev # API on :8787
|
||||||
pnpm --filter @podman/backend dev:agent # PodMan LiveKit agent
|
pnpm --filter @podman/backend dev:agent # LiveKit vision agent
|
||||||
pnpm --filter @podman/frontend dev # PWA on :5173
|
pnpm --filter @podman/frontend dev # PWA on :5173
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run the Python live conversation agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm livekit:conversation:agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the local git watcher on each demo laptop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/podman-agent.mjs --name <engineer-name> --pod <pod-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Demo identities:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/podman-agent.mjs --name alice --pod demo-pod
|
||||||
|
node scripts/podman-agent.mjs --name bob --pod demo-pod
|
||||||
|
node scripts/podman-agent.mjs --name carol --pod demo-pod
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Git watcher - run this on every demo laptop
|
## Production Operations
|
||||||
|
|
||||||
Each engineer runs this in a terminal before the demo. It polls the local git
|
The production droplet is systemd-supervised. Caddy serves the built frontend
|
||||||
working tree every 15 seconds and writes git state to MongoDB so PodMan has
|
and proxies `/api/*` to the backend on `127.0.0.1:8787`.
|
||||||
deterministic dirty/unpushed truth that vision alone cannot reliably infer.
|
|
||||||
|
|
||||||
```bash
|
```mermaid
|
||||||
# from the repo root
|
flowchart LR
|
||||||
node scripts/podman-agent.mjs --name <yourname> --pod <podId>
|
Public["https://podman.live"] --> Caddy["caddy.service"]
|
||||||
|
Caddy --> Static["/var/www/podman"]
|
||||||
|
Caddy --> API["podman-platform-api.service<br/>:8787"]
|
||||||
|
API --> Agent["podman-platform-agent.service"]
|
||||||
|
API --> Voice["podman-live-conversation-agent.service"]
|
||||||
|
Watchdog["podman-hermes-watchdog.timer"] --> Public
|
||||||
|
Sync["podman-hermes-sync-deploy.timer"] --> API
|
||||||
|
|
||||||
|
classDef public fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
|
classDef service fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||||
|
classDef timer fill:#fff6df,stroke:#c47f00,color:#3d2b00;
|
||||||
|
class Public public;
|
||||||
|
class Caddy,Static,API,Agent,Voice service;
|
||||||
|
class Watchdog,Sync timer;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Demo setup:**
|
| Service / timer | Purpose |
|
||||||
|
| ---------------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| `podman-platform-api.service` | Built backend API on port `8787` |
|
||||||
|
| `podman-platform-agent.service` | Node LiveKit vision agent |
|
||||||
|
| `podman-live-conversation-agent.service` | Python Gemini Live conversation agent |
|
||||||
|
| `podman-hermes-watchdog.timer` | Periodic public health and remediation |
|
||||||
|
| `podman-hermes-sync-deploy.timer` | Clean-tree fast-forward deploy loop |
|
||||||
|
| `caddy.service` | Serves `/var/www/podman`, proxies `/api/*` to `127.0.0.1:8787` |
|
||||||
|
|
||||||
|
Check the app from the outside first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/podman-agent.mjs --name alice --pod demo-pod
|
curl https://podman.live/
|
||||||
node scripts/podman-agent.mjs --name bob --pod demo-pod
|
curl https://podman.live/health
|
||||||
node scripts/podman-agent.mjs --name carol --pod demo-pod
|
curl https://podman.live/api/pods
|
||||||
|
curl https://podman.live/api/presence
|
||||||
|
curl https://podman.live/api/memory/stats
|
||||||
```
|
```
|
||||||
|
|
||||||
The script logs one line per cycle: branch, changed file count, and latest
|
Then check the droplet services:
|
||||||
commit. Leave it running in a background terminal tab throughout the session.
|
|
||||||
Stop with `Ctrl+C`.
|
|
||||||
|
|
||||||
**Requirements:**
|
```bash
|
||||||
|
systemctl is-active podman-platform-api podman-platform-agent
|
||||||
|
systemctl is-active podman-live-conversation-agent
|
||||||
|
systemctl is-active podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||||
|
```
|
||||||
|
|
||||||
- `MONGODB_URI` must be exported in the shell or present in `backend/.env`.
|
Hermes operations scripts:
|
||||||
- Run `pnpm install` first so workspace dependencies are available.
|
|
||||||
- Run from the repo root.
|
```bash
|
||||||
|
pnpm hermes:watchdog
|
||||||
|
pnpm hermes:watchdog:strict
|
||||||
|
pnpm hermes:sync-deploy
|
||||||
|
pnpm deploy:doctor:strict
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemma Modular/MAX endpoint checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://llm.alhinai.dev/v1/models \
|
||||||
|
-H "Authorization: Bearer not-needed"
|
||||||
|
|
||||||
|
curl https://llm.alhinai.dev/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer not-needed" \
|
||||||
|
-d '{
|
||||||
|
"model": "gemma-4-31B-it",
|
||||||
|
"messages": [{"role": "user", "content": "Reply with exactly: working"}],
|
||||||
|
"temperature": 0,
|
||||||
|
"max_tokens": 512
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LIVEKIT_URL=wss://your-livekit-server.livekit.cloud
|
||||||
|
LIVEKIT_API_KEY=...
|
||||||
|
LIVEKIT_API_SECRET=...
|
||||||
|
LIVEKIT_CONVERSATION_AGENT_NAME=podman-live-conversation
|
||||||
|
|
||||||
|
GEMINI_API_KEY=...
|
||||||
|
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||||
|
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
|
||||||
|
GEMINI_CONVERSATION_MODEL=gemini-3.1-flash-live-preview
|
||||||
|
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
|
||||||
|
GEMINI_TTS_VOICE=Charon
|
||||||
|
|
||||||
|
GITHUB_TOKEN=...
|
||||||
|
GITHUB_REPO=karti-ai/podman
|
||||||
|
|
||||||
|
MONGODB_URI=mongodb+srv://...
|
||||||
|
VOYAGE_API_KEY=...
|
||||||
|
VOYAGE_EMBEDDING_MODEL=voyage-4-lite
|
||||||
|
|
||||||
|
PORT=8787
|
||||||
|
POD_ROOM=demo-pod
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemma/Hermes provider values live in Hermes config, not PodMan `.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider: gemma4-31b-max
|
||||||
|
model: gemma-4-31B-it
|
||||||
|
base_url: https://llm.alhinai.dev/v1
|
||||||
|
api_key: not-needed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Before calling a deployment healthy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm verify
|
||||||
|
pnpm verify:infra
|
||||||
|
pnpm deploy:doctor:strict
|
||||||
|
pnpm hermes:watchdog:strict
|
||||||
|
```
|
||||||
|
|
||||||
|
For the public site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://podman.live/
|
||||||
|
curl https://podman.live/health
|
||||||
|
curl https://podman.live/api/pods
|
||||||
|
curl https://podman.live/api/presence
|
||||||
|
curl https://podman.live/api/memory/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
For Hermes/Gemma:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes -z 'Reply with exactly: working' \
|
||||||
|
--provider gemma4-31b-max \
|
||||||
|
--model gemma-4-31B-it
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
working
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Check |
|
||||||
|
| ----------------------------- | ------------------------------------------------ | ---------------------------------------------------- |
|
||||||
|
| Frontend loads but API fails | Backend or Caddy proxy issue | `systemctl status podman-platform-api caddy` |
|
||||||
|
| `/api/*` returns 502 | API not listening on `8787` | `ss -ltnp`, `curl http://127.0.0.1:8787/health` |
|
||||||
|
| No screen observations | Vision agent not in LiveKit room | `journalctl -u podman-platform-agent -n 80` |
|
||||||
|
| Live voice missing | Python conversation agent down | `journalctl -u podman-live-conversation-agent -n 80` |
|
||||||
|
| Memory empty | MongoDB unavailable or env missing | `curl /api/memory/stats`, backend logs |
|
||||||
|
| Hermes tool calls fail | Modular/MAX endpoint does not accept tool schema | verify Hermes provider and `/v1/chat/completions` |
|
||||||
|
| `llm.alhinai.dev` returns 502 | Gemma server still loading or tunnel target down | `curl /v1/models`, MAX logs on Gemma host |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why It Gets Better
|
||||||
|
|
||||||
|
PodMan is not a static alert system. It remembers what actually helped.
|
||||||
|
|
||||||
|
- A dismissed false alarm lowers future urgency.
|
||||||
|
- An accepted real collision raises future urgency for similar work.
|
||||||
|
- Exact-signature recall catches repeats even without vector search.
|
||||||
|
- MongoDB outcomes become the policy signal for the next session.
|
||||||
|
- Hermes and Gemma give the system a tool-using agent when the coordination
|
||||||
|
problem needs active investigation instead of a passive card.
|
||||||
|
|
||||||
|
The goal is simple: fewer interruptions, fewer duplicate branches, and a team
|
||||||
|
that can move fast without constantly asking what everyone else is doing.
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ 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,
|
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.
|
team memory, or recent decisions. Keep spoken answers short. Prefer one useful next step.
|
||||||
|
For questions about a person's style, goals, past work, collaboration habits, personal context,
|
||||||
|
or what they know across pods/sessions, call get_user_learning_profile before answering.
|
||||||
If a critical collision event arrives, stop the current turn and state the alert immediately.
|
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.
|
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
|
For git commit history, authorship, recent changes, or which commit introduced something, call
|
||||||
@@ -130,6 +132,20 @@ class PodManLiveAgent(Agent):
|
|||||||
)
|
)
|
||||||
return json.dumps(data, ensure_ascii=True)[:12000]
|
return json.dumps(data, ensure_ascii=True)[:12000]
|
||||||
|
|
||||||
|
@function_tool()
|
||||||
|
async def get_user_learning_profile(self, context: RunContext) -> str:
|
||||||
|
"""Get persistent cross-pod, cross-session knowledge about this developer:
|
||||||
|
collaboration style, goals, known work, recent activity, and Hermes history.
|
||||||
|
"""
|
||||||
|
data = await asyncio.to_thread(
|
||||||
|
request_json,
|
||||||
|
f"/api/internal/pods/{self.pod_id}/live-context?identity={self.identity}",
|
||||||
|
)
|
||||||
|
profile = data.get("userLearningProfile")
|
||||||
|
if not profile:
|
||||||
|
return "No persistent user learning profile has been built for this developer yet."
|
||||||
|
return json.dumps(profile, ensure_ascii=True)[:10000]
|
||||||
|
|
||||||
@function_tool()
|
@function_tool()
|
||||||
async def record_conversation_note(self, context: RunContext, note: str, kind: str = "summary") -> str:
|
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."""
|
"""Store a useful decision, outcome, or preference learned during this conversation."""
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/express": "^2.1.32",
|
||||||
"@google/genai": "^2.10.0",
|
"@google/genai": "^2.10.0",
|
||||||
"@livekit/rtc-node": "^0.13.29",
|
"@livekit/rtc-node": "^0.13.29",
|
||||||
"@podman/shared": "workspace:*",
|
"@podman/shared": "workspace:*",
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ export async function publishHermesIntervention(
|
|||||||
void notifyCriticalLiveConversations(collision, intervention, voiceLine).catch((err) =>
|
void notifyCriticalLiveConversations(collision, intervention, voiceLine).catch((err) =>
|
||||||
console.warn(`[live-conversation] critical notify failed: ${(err as Error).message}`),
|
console.warn(`[live-conversation] critical notify failed: ${(err as Error).message}`),
|
||||||
);
|
);
|
||||||
if (voiceLine) await speak(room, voiceLine, { priority: 'critical' });
|
if (voiceLine)
|
||||||
|
await speak(room, voiceLine, {
|
||||||
|
priority: collision.severity === 'critical' ? 'critical' : 'normal',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hermesToken(roomName: string): Promise<string> {
|
async function hermesToken(roomName: string): Promise<string> {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { EngineerContext, MemberWorkHistory, MemberWorkHistoryFile } from '@podman/shared';
|
import type {
|
||||||
|
Collision,
|
||||||
|
EngineerContext,
|
||||||
|
MemberWorkHistory,
|
||||||
|
MemberWorkHistoryFile,
|
||||||
|
MemberWorkHistoryRoi,
|
||||||
|
} from '@podman/shared';
|
||||||
import { getDb } from '../memory/db.js';
|
import { getDb } from '../memory/db.js';
|
||||||
import { parseGitStatusPath } from '../graph/live.js';
|
import { parseGitStatusPath } from '../graph/live.js';
|
||||||
|
|
||||||
@@ -80,7 +86,7 @@ export async function getMemberWorkHistory(
|
|||||||
const limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
|
const limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
|
||||||
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
const [observations, gitState] = await Promise.all([
|
const [observations, gitState, collisions, interventions] = await Promise.all([
|
||||||
db
|
db
|
||||||
.collection<EngineerContext>('observations')
|
.collection<EngineerContext>('observations')
|
||||||
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
|
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||||
@@ -91,6 +97,16 @@ export async function getMemberWorkHistory(
|
|||||||
podId,
|
podId,
|
||||||
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
|
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
|
||||||
}),
|
}),
|
||||||
|
db
|
||||||
|
.collection<Collision>('collisions')
|
||||||
|
.find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ detectedAt: -1 })
|
||||||
|
.limit(200)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<{ collisionId: string }>('interventions')
|
||||||
|
.find({ podId }, { projection: { collisionId: 1, _id: 0 } })
|
||||||
|
.toArray(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const files = new Map<string, FileAccumulator>();
|
const files = new Map<string, FileAccumulator>();
|
||||||
@@ -158,6 +174,9 @@ export async function getMemberWorkHistory(
|
|||||||
|
|
||||||
timeline.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
timeline.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
||||||
|
|
||||||
|
const interventionIds = new Set(interventions.map((i) => i.collisionId));
|
||||||
|
const roi = computeRoi(member, collisions, interventionIds, gitState?.changedFiles?.length ?? 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
podId,
|
podId,
|
||||||
member,
|
member,
|
||||||
@@ -170,5 +189,56 @@ export async function getMemberWorkHistory(
|
|||||||
},
|
},
|
||||||
files: fileRows,
|
files: fileRows,
|
||||||
timeline: timeline.slice(0, limit),
|
timeline: timeline.slice(0, limit),
|
||||||
|
roi,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRoi(
|
||||||
|
member: string,
|
||||||
|
collisions: Collision[],
|
||||||
|
interventionCollisionIds: Set<string>,
|
||||||
|
changedFileCount: number,
|
||||||
|
): MemberWorkHistoryRoi {
|
||||||
|
const involved = (c: Collision) =>
|
||||||
|
c.engineers?.some((e) => sameMember(e, member)) ||
|
||||||
|
sameMember(c.researcher, member) ||
|
||||||
|
sameMember(c.editor, member);
|
||||||
|
|
||||||
|
const eligible = collisions.filter(
|
||||||
|
(c) =>
|
||||||
|
involved(c) &&
|
||||||
|
interventionCollisionIds.has(c.id) &&
|
||||||
|
(c.gitOverlap === true || c.severity === 'critical'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const weightOf = (c: Collision): { label: string; minutes: number } => {
|
||||||
|
if (c.overlapKind === 'research') return { label: 'research overlap', minutes: 10 };
|
||||||
|
if (c.severity === 'critical') return { label: 'critical same-file', minutes: 45 };
|
||||||
|
if (c.severity === 'warn') return { label: 'warn same-file', minutes: 20 };
|
||||||
|
return { label: 'info same-file', minutes: 10 };
|
||||||
|
};
|
||||||
|
|
||||||
|
let savedMinutes = 0;
|
||||||
|
const groups = new Map<string, { count: number; minutesEach: number }>();
|
||||||
|
for (const c of eligible) {
|
||||||
|
const { label, minutes } = weightOf(c);
|
||||||
|
savedMinutes += minutes / Math.max(1, c.engineers?.length ?? 1);
|
||||||
|
const g = groups.get(label) ?? { count: 0, minutesEach: minutes };
|
||||||
|
g.count += 1;
|
||||||
|
groups.set(label, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesDeconflicted = new Set(eligible.map((c) => c.file)).size;
|
||||||
|
return {
|
||||||
|
savedMinutes: Math.round(savedMinutes),
|
||||||
|
clashesCaught: eligible.length,
|
||||||
|
filesDeconflicted,
|
||||||
|
conflictFreeFiles: Math.max(0, changedFileCount - filesDeconflicted),
|
||||||
|
totalFiles: changedFileCount,
|
||||||
|
breakdown: [...groups.entries()].map(([label, g]) => ({
|
||||||
|
label,
|
||||||
|
count: g.count,
|
||||||
|
minutesEach: g.minutesEach,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-28
@@ -2,28 +2,69 @@ import { RoomEvent, type Room } from '@livekit/rtc-node';
|
|||||||
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
|
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
|
||||||
import { analyzeFrame } from '../vision/gemini.js';
|
import { analyzeFrame } from '../vision/gemini.js';
|
||||||
import { detectCollisions } from '../collision/detector.js';
|
import { detectCollisions } from '../collision/detector.js';
|
||||||
|
import { detectResearchOverlaps } from '../collision/research.js';
|
||||||
import { getGithubState } from '../github/client.js';
|
import { getGithubState } from '../github/client.js';
|
||||||
import {
|
import {
|
||||||
recordObservation,
|
recordObservation,
|
||||||
recordCollision,
|
recordCollision,
|
||||||
recordIntervention,
|
recordIntervention,
|
||||||
|
recordSuppression,
|
||||||
updateInterventionStatus,
|
updateInterventionStatus,
|
||||||
} from '../memory/store.js';
|
} from '../memory/store.js';
|
||||||
import { getGitStates } from '../memory/db.js';
|
import { getGitStates, type GitState } from '../memory/db.js';
|
||||||
import { recallSimilar } from '../memory/vectors.js';
|
import { recallSimilar } from '../memory/vectors.js';
|
||||||
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
||||||
import { publishHermesIntervention } from '../action/hermes.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a still-present conflict stays muted after we voice it, before it
|
||||||
|
* re-alerts. Edge-trigger alone permanently muted files that stay dirty for
|
||||||
|
* the whole session (e.g. README everyone tests on). This bounds that: voice
|
||||||
|
* once, then re-alert at most every interval while it persists. */
|
||||||
|
const CONFLICT_REALERT_MS = Number(process.env.CONFLICT_REALERT_MS ?? '20000');
|
||||||
|
|
||||||
|
/** 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 {
|
export class PodMan {
|
||||||
private contexts = new Map<string, EngineerContext>();
|
private contexts = new Map<string, EngineerContext>();
|
||||||
/**
|
/**
|
||||||
* Conflicts we have already voiced and that are still unresolved, keyed by
|
* Conflicts we have already voiced, keyed by file + engineer pair (see
|
||||||
* file (see conflictKey). Edge-triggered alerting: speak once when a conflict
|
* conflictKey), mapped to the time we last voiced them. Edge-triggered:
|
||||||
* appears, stay quiet while it persists. A conflict is re-armed (deleted
|
* speak once when a conflict appears. Re-armed (deleted here) by
|
||||||
* here) by onScreenFrame as soon as a detection cycle no longer sees it, so a
|
* onScreenFrame as soon as a detection cycle no longer sees it, so a
|
||||||
* resolved-then-recurring conflict alerts again.
|
* resolved-then-recurring conflict alerts again. Additionally, a conflict
|
||||||
|
* that *persists* re-alerts every CONFLICT_REALERT_MS so a perpetually-dirty
|
||||||
|
* file (README) doesn't go silent forever after the first alert.
|
||||||
*/
|
*/
|
||||||
private activeConflicts = new Set<string>();
|
private activeConflicts = new Map<string, number>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private room: Room,
|
private room: Room,
|
||||||
@@ -66,15 +107,26 @@ export class PodMan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const github = await getGithubState(); // cached
|
const github = await getGithubState(); // cached
|
||||||
const collisions = detectCollisions([...this.contexts.values()], github, gitStates);
|
const contexts = [...this.contexts.values()];
|
||||||
|
const fileCollisions = detectCollisions(contexts, github, gitStates);
|
||||||
|
const researchCollisions = await detectResearchOverlaps(contexts, gitStates);
|
||||||
|
const collisions = [...fileCollisions, ...researchCollisions];
|
||||||
|
|
||||||
// Re-arm: any conflict we previously voiced that is no longer present has
|
// Re-arm: any conflict we previously voiced that is no longer present has
|
||||||
// resolved, so allow it to alert again if it recurs.
|
// resolved, so allow it to alert again if it recurs.
|
||||||
const current = new Set(collisions.map((c) => this.conflictKey(c)));
|
const current = new Set(collisions.map((c) => this.conflictKey(c)));
|
||||||
for (const key of this.activeConflicts) {
|
for (const key of this.activeConflicts.keys()) {
|
||||||
if (!current.has(key)) this.activeConflicts.delete(key);
|
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) {
|
||||||
|
if (collision.overlapKind !== 'research') {
|
||||||
|
collision.gitOverlap = engineersOverlapOnFile(collision, gitStates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const collision of collisions) await this.handle(collision);
|
for (const collision of collisions) await this.handle(collision);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,29 +137,84 @@ export class PodMan {
|
|||||||
* basename.
|
* basename.
|
||||||
*/
|
*/
|
||||||
private conflictKey(collision: Collision): string {
|
private conflictKey(collision: Collision): string {
|
||||||
return (
|
const who = [...collision.engineers].map(canonicalName).sort().join('+');
|
||||||
(collision.file ?? '')
|
return `${collision.overlapKind ?? 'file'}:${comparableBasename(collision.file)}:${who}`;
|
||||||
.trim()
|
|
||||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
|
||||||
.split(/[\\/]/)
|
|
||||||
.pop()
|
|
||||||
?.toLowerCase() ?? ''
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handle(collision: Collision): Promise<void> {
|
private async handle(collision: Collision): Promise<void> {
|
||||||
const key = this.conflictKey(collision);
|
const key = this.conflictKey(collision);
|
||||||
if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved
|
const lastAlerted = this.activeConflicts.get(key);
|
||||||
|
// Edge-trigger + time-based re-arm: stay quiet right after voicing, but
|
||||||
|
// re-alert a persistent conflict once CONFLICT_REALERT_MS has elapsed.
|
||||||
|
if (lastAlerted !== undefined && Date.now() - lastAlerted < CONFLICT_REALERT_MS) return;
|
||||||
|
|
||||||
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
|
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
|
||||||
if (prior) collision.severity = 'critical';
|
// Only escalate to critical (which triggers the spoken alert) when the
|
||||||
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
|
// 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)) {
|
||||||
|
// Feature A — make the negative-feedback loop VISIBLE. If we stayed quiet
|
||||||
|
// *specifically* because this signature was DISMISSED before, record a
|
||||||
|
// durable suppressed-repeat event (timestamped now, at the repeat) so the
|
||||||
|
// activity stream shows the learning instead of nothing.
|
||||||
|
if (prior?.priorOutcome && !prior.priorOutcome.accepted) {
|
||||||
|
// Mark handled first — like the alert path below — so we record ONE
|
||||||
|
// suppressed-repeat per recurrence, not once per frame; it re-arms via
|
||||||
|
// the resolution sweep in onScreenFrame. Awaited like recordCollision so
|
||||||
|
// the durable learning proof is reliably written.
|
||||||
|
this.activeConflicts.set(key, Date.now());
|
||||||
|
await recordSuppression(
|
||||||
|
collision,
|
||||||
|
prior.priorOutcome.interventionId,
|
||||||
|
prior.priorOutcome.recordedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return; // Loop B: policy gate
|
||||||
|
}
|
||||||
|
|
||||||
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
|
this.activeConflicts.set(key, Date.now()); // claim + timestamp; re-armed on resolution or after CONFLICT_REALERT_MS
|
||||||
await recordCollision(collision);
|
await recordCollision(collision);
|
||||||
const action = preferredAction(collision, prior);
|
const action = preferredAction(collision, prior);
|
||||||
const names = collision.engineers.join(' + ');
|
|
||||||
const shortFile = collision.file.split('/').pop() ?? collision.file;
|
const shortFile = collision.file.split('/').pop() ?? collision.file;
|
||||||
|
const isResearchOverlap = collision.overlapKind === 'research';
|
||||||
|
|
||||||
|
if (isResearchOverlap) {
|
||||||
|
const researcher = collision.researcher ?? collision.engineers[1] ?? 'A teammate';
|
||||||
|
const editor = collision.editor ?? collision.engineers[0] ?? 'a teammate';
|
||||||
|
const topic = collision.researchTopic ?? 'the same area';
|
||||||
|
const source = collision.researchSource ? ` (${collision.researchSource})` : '';
|
||||||
|
const message = `🤝 ${researcher} is researching ${topic}${source} while ${editor} edits ${shortFile} — sync up before duplicating effort.`;
|
||||||
|
const voiceLine = `${researcher} is researching ${topic} while ${editor} works on ${shortFile}. Worth a quick sync.`;
|
||||||
|
const intervention: Intervention = {
|
||||||
|
id: `int_${Date.now()}`,
|
||||||
|
collisionId: collision.id,
|
||||||
|
podId: this.podId,
|
||||||
|
kind: 'card',
|
||||||
|
message,
|
||||||
|
suggestedAction: {
|
||||||
|
kind: 'ping_teammate',
|
||||||
|
params: {
|
||||||
|
file: collision.file,
|
||||||
|
summary: message,
|
||||||
|
engineers: collision.engineers,
|
||||||
|
researchTopic: collision.researchTopic,
|
||||||
|
researchSource: collision.researchSource,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await recordIntervention(intervention);
|
||||||
|
await publishHermesIntervention(this.room, collision, intervention, voiceLine);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = collision.engineers.join(' + ');
|
||||||
|
|
||||||
// Terse, demo-centered alert — short and direct, not chatty AI prose.
|
// Terse, demo-centered alert — short and direct, not chatty AI prose.
|
||||||
const message =
|
const message =
|
||||||
@@ -137,11 +244,9 @@ export class PodMan {
|
|||||||
};
|
};
|
||||||
await recordIntervention(intervention);
|
await recordIntervention(intervention);
|
||||||
|
|
||||||
await publishHermesIntervention(
|
// Voice every intervention, not just critical escalations. Priority is set
|
||||||
this.room,
|
// by severity inside publishHermesIntervention: critical jumps the queue,
|
||||||
collision,
|
// the rest play sequentially so concurrent alerts don't garble each other.
|
||||||
intervention,
|
await publishHermesIntervention(this.room, collision, intervention, voiceLine);
|
||||||
collision.severity === 'critical' ? voiceLine : undefined,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { clerkMiddleware, getAuth } from '@clerk/express';
|
||||||
|
import type { Request, RequestHandler, Response } from 'express';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
export interface RequestUserContext {
|
||||||
|
clerkUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clerkAuthMiddleware: RequestHandler = env.CLERK_SECRET_KEY
|
||||||
|
? clerkMiddleware()
|
||||||
|
: (_req, _res, next) => next();
|
||||||
|
|
||||||
|
export function requestUser(req: Request): RequestUserContext | null {
|
||||||
|
if (!env.CLERK_SECRET_KEY) return null;
|
||||||
|
try {
|
||||||
|
const auth = getAuth(req);
|
||||||
|
return auth.isAuthenticated && auth.userId ? { clerkUserId: auth.userId } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRequestUser(req: Request, res: Response): RequestUserContext | null {
|
||||||
|
const user = requestUser(req);
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'sign in required' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -19,6 +19,15 @@ function fileKey(raw?: string): string | undefined {
|
|||||||
return base.toLowerCase();
|
return base.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize an engineer identity for case/whitespace-insensitive matching.
|
||||||
|
* Vision reports a display name ("Karti") while the git watcher reports a handle
|
||||||
|
* ("karti"); without this the same human collides with themselves.
|
||||||
|
*/
|
||||||
|
function canonicalName(raw: string): string {
|
||||||
|
return raw.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
interface Touch {
|
interface Touch {
|
||||||
engineerId: string;
|
engineerId: string;
|
||||||
unpushed: boolean;
|
unpushed: boolean;
|
||||||
@@ -64,8 +73,15 @@ export function detectCollisions(
|
|||||||
|
|
||||||
const out: Collision[] = [];
|
const out: Collision[] = [];
|
||||||
for (const [, touches] of byFile) {
|
for (const [, touches] of byFile) {
|
||||||
const engineers = [...new Set(touches.map((t) => t.engineerId))];
|
// Distinct PEOPLE, case/whitespace-insensitive — one display name per person.
|
||||||
if (engineers.length < 2) continue; // need two distinct people on one file
|
// Vision "Karti" and git "karti" are the same human, not a collision.
|
||||||
|
const byPerson = new Map<string, string>(); // canonical id -> display name
|
||||||
|
for (const t of touches) {
|
||||||
|
const id = canonicalName(t.engineerId);
|
||||||
|
if (id && !byPerson.has(id)) byPerson.set(id, t.engineerId);
|
||||||
|
}
|
||||||
|
if (byPerson.size < 2) continue; // need two distinct people on one file
|
||||||
|
const engineers = [...byPerson.values()];
|
||||||
|
|
||||||
const anyUnpushed = touches.some((t) => t.unpushed) || github.unpushed === true;
|
const anyUnpushed = touches.some((t) => t.unpushed) || github.unpushed === true;
|
||||||
if (!anyUnpushed) continue; // the crux GitHub alone cannot answer
|
if (!anyUnpushed) continue; // the crux GitHub alone cannot answer
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Collision, EngineerContext } from '@podman/shared';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import type { GitState } from '../memory/db.js';
|
||||||
|
import { semanticSimilarity } from '../memory/vectors.js';
|
||||||
|
|
||||||
|
export interface ResearchOpts {
|
||||||
|
similarity?: (a: string, b: string) => Promise<number | null>;
|
||||||
|
threshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorFile {
|
||||||
|
engineerId: string;
|
||||||
|
file: string;
|
||||||
|
symbol?: string;
|
||||||
|
activity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
collision: Collision;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripGitPrefix(raw: string): string {
|
||||||
|
return raw.trim().replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case/whitespace-insensitive identity so vision "Karti" and git "karti" are
|
||||||
|
* recognized as the same person and never flagged researching-vs-editing self. */
|
||||||
|
function canonicalName(raw: string): string {
|
||||||
|
return raw.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileStem(raw: string): string {
|
||||||
|
const base = stripGitPrefix(raw).split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
|
||||||
|
return base.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function words(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((word) => word.length >= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTokens(raw: string): Set<string> {
|
||||||
|
const tokens = new Set(words(raw));
|
||||||
|
for (const word of [...tokens]) {
|
||||||
|
if (word.endsWith('kit')) tokens.add(word.replace(/kit$/, ''));
|
||||||
|
if (word.endsWith('s')) tokens.add(word.slice(0, -1));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackMatches(researchText: string, fileText: string): boolean {
|
||||||
|
const research = uniqueTokens(researchText);
|
||||||
|
const file = uniqueTokens(fileText);
|
||||||
|
for (const token of file) {
|
||||||
|
if (research.has(token)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEditorFiles(
|
||||||
|
contexts: EngineerContext[],
|
||||||
|
gitStates: Map<string, GitState> | undefined,
|
||||||
|
): EditorFile[] {
|
||||||
|
const files = new Map<string, EditorFile>();
|
||||||
|
const add = (editor: EditorFile): void => {
|
||||||
|
const stem = fileStem(editor.file);
|
||||||
|
if (!stem) return;
|
||||||
|
files.set(`${editor.engineerId}:${stripGitPrefix(editor.file)}`, editor);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [engineerId, git] of gitStates ?? []) {
|
||||||
|
for (const changed of git.changedFiles) {
|
||||||
|
add({ engineerId, file: changed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const context of contexts) {
|
||||||
|
if (context.mode !== 'research' && context.currentFile) {
|
||||||
|
add({
|
||||||
|
engineerId: context.engineerId,
|
||||||
|
file: context.currentFile,
|
||||||
|
symbol: context.currentSymbol,
|
||||||
|
activity: context.activity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...files.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectResearchOverlaps(
|
||||||
|
contexts: EngineerContext[],
|
||||||
|
gitStates: Map<string, GitState> | undefined,
|
||||||
|
opts: ResearchOpts = {},
|
||||||
|
): Promise<Collision[]> {
|
||||||
|
const similarity = opts.similarity ?? semanticSimilarity;
|
||||||
|
const threshold = opts.threshold ?? env.RESEARCH_OVERLAP_THRESHOLD;
|
||||||
|
const researchers = contexts.filter((c) => c.mode === 'research' && c.researchTopic);
|
||||||
|
const editorFiles = collectEditorFiles(contexts, gitStates);
|
||||||
|
const bestByResearcher = new Map<string, Candidate>();
|
||||||
|
|
||||||
|
for (const researcher of researchers) {
|
||||||
|
const topic = researcher.researchTopic?.trim();
|
||||||
|
if (!topic) continue;
|
||||||
|
const source = researcher.researchSource?.trim();
|
||||||
|
const researchText = [topic, source].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
for (const editor of editorFiles) {
|
||||||
|
if (canonicalName(editor.engineerId) === canonicalName(researcher.engineerId)) continue;
|
||||||
|
|
||||||
|
const stem = fileStem(editor.file);
|
||||||
|
if (!stem) continue;
|
||||||
|
const fileText = [stem, editor.symbol, editor.activity].filter(Boolean).join(' ');
|
||||||
|
const score = await similarity(researchText, fileText);
|
||||||
|
const matched = score === null ? fallbackMatches(researchText, fileText) : score >= threshold;
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
const rank = score ?? 1;
|
||||||
|
const existing = bestByResearcher.get(researcher.engineerId);
|
||||||
|
if (existing && existing.score >= rank) continue;
|
||||||
|
|
||||||
|
bestByResearcher.set(researcher.engineerId, {
|
||||||
|
score: rank,
|
||||||
|
collision: {
|
||||||
|
id: `col_research_${stem}_${Date.now()}`,
|
||||||
|
podId: researcher.podId,
|
||||||
|
file: editor.file,
|
||||||
|
symbol: editor.symbol,
|
||||||
|
engineers: [editor.engineerId, researcher.engineerId],
|
||||||
|
severity: 'warn',
|
||||||
|
overlapKind: 'research',
|
||||||
|
researchTopic: topic,
|
||||||
|
...(source ? { researchSource: source } : {}),
|
||||||
|
researcher: researcher.engineerId,
|
||||||
|
editor: editor.engineerId,
|
||||||
|
detectedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...bestByResearcher.values()].map((candidate) => candidate.collision);
|
||||||
|
}
|
||||||
+9
-2
@@ -1,4 +1,6 @@
|
|||||||
import 'dotenv/config';
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
config({ path: ['.env.local', '../.env.local', '.env', '../.env'] });
|
||||||
|
|
||||||
function req(name: string): string {
|
function req(name: string): string {
|
||||||
const v = process.env[name];
|
const v = process.env[name];
|
||||||
@@ -22,7 +24,10 @@ export const env = {
|
|||||||
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
||||||
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
||||||
LIVEKIT_AGENT_NAME: opt('LIVEKIT_AGENT_NAME'),
|
LIVEKIT_AGENT_NAME: opt('LIVEKIT_AGENT_NAME'),
|
||||||
LIVEKIT_CONVERSATION_AGENT_NAME: opt('LIVEKIT_CONVERSATION_AGENT_NAME', 'podman-live-conversation'),
|
LIVEKIT_CONVERSATION_AGENT_NAME: opt(
|
||||||
|
'LIVEKIT_CONVERSATION_AGENT_NAME',
|
||||||
|
'podman-live-conversation',
|
||||||
|
),
|
||||||
// Gemini
|
// Gemini
|
||||||
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_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_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
|
||||||
@@ -39,7 +44,9 @@ export const env = {
|
|||||||
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
||||||
// Server
|
// Server
|
||||||
PORT: Number(opt('PORT', '8787')),
|
PORT: Number(opt('PORT', '8787')),
|
||||||
|
CLERK_SECRET_KEY: opt('CLERK_SECRET_KEY'),
|
||||||
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
||||||
|
RESEARCH_OVERLAP_THRESHOLD: Number(opt('RESEARCH_OVERLAP_THRESHOLD', '0.6')),
|
||||||
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
|
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -457,6 +457,38 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Suppressed repeats: PodMan stayed quiet on a recurring collision because
|
||||||
|
// the signature was dismissed before — the negative-feedback loop made visible
|
||||||
|
// (Feature A). Stamped at repeat time, so it sorts as recent activity.
|
||||||
|
const suppressionDocs = await c.suppressions
|
||||||
|
.find({ podId })
|
||||||
|
.sort({ suppressedAt: -1 })
|
||||||
|
.limit(50)
|
||||||
|
.toArray();
|
||||||
|
// Collapse to one beat per file (keep the most recent — docs are sorted desc)
|
||||||
|
// so pre-fix duplicate rows never render as spam. The stable per-file id also
|
||||||
|
// dedupes through pushActivity's `seen` set.
|
||||||
|
const seenSuppressedFiles = new Set<string>();
|
||||||
|
for (const s of suppressionDocs) {
|
||||||
|
const sFile = normalizeFile(s.file);
|
||||||
|
if (!isFilePath(sFile)) continue;
|
||||||
|
const fileKey = sFile.toLowerCase();
|
||||||
|
if (seenSuppressedFiles.has(fileKey)) continue;
|
||||||
|
seenSuppressedFiles.add(fileKey);
|
||||||
|
const sEngs = (s.engineers ?? []).join(' + ') || 'teammates';
|
||||||
|
pushActivity(
|
||||||
|
activity,
|
||||||
|
{
|
||||||
|
id: `suppressed:${fileKey}`,
|
||||||
|
at: s.suppressedAt,
|
||||||
|
kind: 'suppressed',
|
||||||
|
title: `Suppressed — ${shortLabel(sFile)} repeat silenced`,
|
||||||
|
detail: `${sEngs} on ${sFile} recurred, but it was dismissed before — PodMan stayed quiet.`,
|
||||||
|
},
|
||||||
|
activityIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prune test-artifact engineers, then anything left orphaned by that.
|
// Prune test-artifact engineers, then anything left orphaned by that.
|
||||||
const dropNode = (id: string) => {
|
const dropNode = (id: string) => {
|
||||||
b.nodes.delete(id);
|
b.nodes.delete(id);
|
||||||
@@ -495,7 +527,11 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
|
|
||||||
const nodes = [...b.nodes.values()];
|
const nodes = [...b.nodes.values()];
|
||||||
// No real activity beyond the bare roster -> let the caller fall back to demo.
|
// No real activity beyond the bare roster -> let the caller fall back to demo.
|
||||||
const hasActivity = nodes.some((n) => n.kind !== 'engineer');
|
// Suppression beats are real negative-feedback proof even when they add no
|
||||||
|
// nodes/edges, so they satisfy the gate too — a clean pod with preserved
|
||||||
|
// suppressions must not fall back to the demo graph and hide the proof.
|
||||||
|
const hasSuppressed = activity.some((a) => a.kind === 'suppressed');
|
||||||
|
const hasActivity = hasSuppressed || nodes.some((n) => n.kind !== 'engineer');
|
||||||
if (!hasActivity) return null;
|
if (!hasActivity) return null;
|
||||||
|
|
||||||
layout(nodes);
|
layout(nodes);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { getDb } from '../memory/db.js';
|
import { getDb } from '../memory/db.js';
|
||||||
import { getMemberWorkHistory } from '../activity/member-history.js';
|
import { getMemberWorkHistory } from '../activity/member-history.js';
|
||||||
|
import {
|
||||||
|
buildUserLearningProfile,
|
||||||
|
getUserLearningProfileByIdentity,
|
||||||
|
} from '../memory/user-learning.js';
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 8;
|
const DEFAULT_LIMIT = 8;
|
||||||
|
|
||||||
@@ -10,7 +14,8 @@ function sinceIso(hours: number): string {
|
|||||||
export async function getLiveConversationContext(podId: string, identity: string) {
|
export async function getLiveConversationContext(podId: string, identity: string) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const since = sinceIso(12);
|
const since = sinceIso(12);
|
||||||
const [pod, history, gitState, collisions, interventions, outcomes] = await Promise.all([
|
const [pod, history, gitState, collisions, interventions, outcomes, userLearningProfile] =
|
||||||
|
await Promise.all([
|
||||||
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
|
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
|
||||||
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
|
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
|
||||||
db.collection('engineer_states').findOne(
|
db.collection('engineer_states').findOne(
|
||||||
@@ -44,6 +49,7 @@ export async function getLiveConversationContext(podId: string, identity: string
|
|||||||
.sort({ recordedAt: -1 })
|
.sort({ recordedAt: -1 })
|
||||||
.limit(DEFAULT_LIMIT)
|
.limit(DEFAULT_LIMIT)
|
||||||
.toArray(),
|
.toArray(),
|
||||||
|
getUserLearningProfileByIdentity(identity).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -51,6 +57,7 @@ export async function getLiveConversationContext(podId: string, identity: string
|
|||||||
identity,
|
identity,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
currentGitState: gitState,
|
currentGitState: gitState,
|
||||||
|
userLearningProfile,
|
||||||
memberHistory: history,
|
memberHistory: history,
|
||||||
recentCollisions: collisions,
|
recentCollisions: collisions,
|
||||||
recentInterventions: interventions,
|
recentInterventions: interventions,
|
||||||
@@ -76,5 +83,13 @@ export async function recordLiveConversationNote(input: {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await (await getDb()).collection('conversation_notes').insertOne(doc);
|
await (await getDb()).collection('conversation_notes').insertOne(doc);
|
||||||
|
if (input.identity) {
|
||||||
|
const profile = await getUserLearningProfileByIdentity(input.identity);
|
||||||
|
if (profile) {
|
||||||
|
void buildUserLearningProfile(profile.clerkUserId).catch((err) =>
|
||||||
|
console.warn(`[memory] note user learning refresh failed: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return { ...doc, _id: undefined };
|
return { ...doc, _id: undefined };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,40 @@ export async function closeMemory(): Promise<void> {
|
|||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A durable record that PodMan stayed quiet on a recurring collision because
|
||||||
|
* its signature was previously dismissed — the negative-feedback loop made
|
||||||
|
* auditable. Timestamped at the repeat; materialized as `suppressed` activity
|
||||||
|
* in graph/live.ts. The suppressed collision is never written to `collisions`
|
||||||
|
* (the agent returns before recordCollision), so this is its own record. */
|
||||||
|
export interface SuppressionDoc {
|
||||||
|
id: string;
|
||||||
|
podId: string;
|
||||||
|
collisionId: string;
|
||||||
|
file: string;
|
||||||
|
engineers: string[];
|
||||||
|
priorInterventionId?: string;
|
||||||
|
priorDismissedAt?: string;
|
||||||
|
suppressedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PodCollections {
|
export interface PodCollections {
|
||||||
pods: Collection<Pod>;
|
pods: Collection<Pod>;
|
||||||
observations: Collection<EngineerContext>;
|
observations: Collection<EngineerContext>;
|
||||||
collisions: Collection<Collision>;
|
collisions: Collection<Collision>;
|
||||||
interventions: Collection<Intervention>;
|
interventions: Collection<Intervention>;
|
||||||
outcomes: Collection<InterventionOutcome>;
|
outcomes: Collection<InterventionOutcome>;
|
||||||
|
suppressions: Collection<SuppressionDoc>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPodContextDoc {
|
||||||
|
id: string;
|
||||||
|
clerkUserId: string;
|
||||||
|
podId: string;
|
||||||
|
memberName?: string;
|
||||||
|
action: string;
|
||||||
|
source: 'clerk';
|
||||||
|
observedAt: string;
|
||||||
|
metadata?: Record<string, string | number | boolean | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collections(): Promise<PodCollections> {
|
export async function collections(): Promise<PodCollections> {
|
||||||
@@ -47,6 +75,7 @@ export async function collections(): Promise<PodCollections> {
|
|||||||
collisions: db.collection<Collision>('collisions'),
|
collisions: db.collection<Collision>('collisions'),
|
||||||
interventions: db.collection<Intervention>('interventions'),
|
interventions: db.collection<Intervention>('interventions'),
|
||||||
outcomes: db.collection<InterventionOutcome>('outcomes'),
|
outcomes: db.collection<InterventionOutcome>('outcomes'),
|
||||||
|
suppressions: db.collection<SuppressionDoc>('suppressions'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +143,7 @@ export async function initMemory(): Promise<void> {
|
|||||||
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
||||||
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
||||||
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
||||||
|
['suppressions.podId', () => c.suppressions.createIndex({ podId: 1, suppressedAt: -1 })],
|
||||||
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
|
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
|
||||||
[
|
[
|
||||||
'hermes_jobs.session',
|
'hermes_jobs.session',
|
||||||
@@ -123,6 +153,26 @@ export async function initMemory(): Promise<void> {
|
|||||||
'hermes_job_events.job',
|
'hermes_job_events.job',
|
||||||
() => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
|
() => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'user_pod_context.user',
|
||||||
|
() => db.collection('user_pod_context').createIndex({ clerkUserId: 1, observedAt: -1 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_pod_context.pod',
|
||||||
|
() => db.collection('user_pod_context').createIndex({ podId: 1, observedAt: -1 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_learning_profiles.user',
|
||||||
|
() => db.collection('user_learning_profiles').createIndex({ clerkUserId: 1 }, { unique: true }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_learning_profiles.updated',
|
||||||
|
() => db.collection('user_learning_profiles').createIndex({ updatedAt: -1 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'conversation_notes.identity',
|
||||||
|
() => db.collection('conversation_notes').createIndex({ identity: 1, createdAt: -1 }),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [name, make] of indexes) {
|
for (const [name, make] of indexes) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
import type { Collision, SuggestedActionKind } from '@podman/shared';
|
import type { Collision, SuggestedActionKind } from '@podman/shared';
|
||||||
import type { RecalledCollision } from './vectors.js';
|
import type { RecalledCollision } from './vectors.js';
|
||||||
|
|
||||||
const lastNudgeByPod = new Map<string, number>();
|
/**
|
||||||
|
* Policy gate — suppression fully disabled for the demo.
|
||||||
function cooldownMs(): number {
|
*
|
||||||
return Number(process.env.NUDGE_COOLDOWN_MS ?? '180000');
|
* Every real collision surfaces an intervention. Accept/Dismiss are still
|
||||||
}
|
* recorded as outcomes but carry NO gating meaning: dismissing one collision
|
||||||
|
* never silences future ones, and there is no per-pod cooldown throttling
|
||||||
/** Policy gate: combines severity, exact recall outcomes, and per-pod cooldown. */
|
* consecutive conflicts. The only filter is severity: `info` collisions are
|
||||||
export function shouldIntervene(collision: Collision, prior: RecalledCollision | null): boolean {
|
* informational, not actionable, so they don't nudge. The same-collision
|
||||||
if (collision.severity === 'info') return false;
|
* single-shot dedupe lives in `PodmanAgent.handle` (activeConflicts), so
|
||||||
|
* removing the cooldown does not cause repeat spam of an unresolved collision.
|
||||||
const priorOutcome = prior?.priorOutcome;
|
*
|
||||||
if (priorOutcome && !priorOutcome.accepted && !priorOutcome.wasRealCollision) return false;
|
* (Prior dismissal-based suppression over-generalized: one README discard
|
||||||
|
* permanently muted all README clashes via loose file/vector recall.)
|
||||||
const cooldown = cooldownMs();
|
*/
|
||||||
const last = lastNudgeByPod.get(collision.podId) ?? 0;
|
export function shouldIntervene(collision: Collision, _prior: RecalledCollision | null): boolean {
|
||||||
if (cooldown > 0 && Date.now() - last < cooldown) {
|
return collision.severity !== 'info';
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastNudgeByPod.set(collision.podId, Date.now());
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preferred action selection based on collision severity and prior accepted actions. */
|
/** Preferred action selection based on collision severity and prior accepted actions. */
|
||||||
|
|||||||
+124
-12
@@ -5,16 +5,19 @@ import type {
|
|||||||
InterventionOutcome,
|
InterventionOutcome,
|
||||||
InterventionStatus,
|
InterventionStatus,
|
||||||
} from '@podman/shared';
|
} from '@podman/shared';
|
||||||
import { collections } from './db.js';
|
import { collections, getDb, getGitStates, type UserPodContextDoc } from './db.js';
|
||||||
import { enrichCollisionMemory } from './vectors.js';
|
import { enrichCollisionMemory } from './vectors.js';
|
||||||
|
import { buildUserLearningProfile } from './user-learning.js';
|
||||||
|
|
||||||
function comparableFile(raw?: string): string {
|
function comparableFile(raw?: string): string {
|
||||||
return (raw ?? '')
|
return (
|
||||||
.trim()
|
(raw ?? '')
|
||||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
.trim()
|
||||||
.split(/[\\/]/)
|
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||||
.pop()
|
.split(/[\\/]/)
|
||||||
?.toLowerCase() ?? '';
|
.pop()
|
||||||
|
?.toLowerCase() ?? ''
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +41,30 @@ export async function recordObservation(ctx: EngineerContext): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recordUserPodContext(input: {
|
||||||
|
clerkUserId: string;
|
||||||
|
podId: string;
|
||||||
|
memberName?: string;
|
||||||
|
action: string;
|
||||||
|
metadata?: UserPodContextDoc['metadata'];
|
||||||
|
}): Promise<void> {
|
||||||
|
await persist('user pod context', async () =>
|
||||||
|
(await getDb()).collection<UserPodContextDoc>('user_pod_context').insertOne({
|
||||||
|
id: `upc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
clerkUserId: input.clerkUserId,
|
||||||
|
podId: input.podId,
|
||||||
|
memberName: input.memberName,
|
||||||
|
action: input.action,
|
||||||
|
source: 'clerk',
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
metadata: input.metadata,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
void buildUserLearningProfile(input.clerkUserId).catch((err) =>
|
||||||
|
console.warn(`[memory] user learning refresh failed: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordCollision(collision: Collision): Promise<void> {
|
export async function recordCollision(collision: Collision): Promise<void> {
|
||||||
await persist('collision', async () =>
|
await persist('collision', async () =>
|
||||||
(await collections()).collisions.insertOne(await enrichCollisionMemory(collision)),
|
(await collections()).collisions.insertOne(await enrichCollisionMemory(collision)),
|
||||||
@@ -50,6 +77,31 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature A — record that PodMan stayed quiet on a recurring collision because
|
||||||
|
* its signature was previously dismissed. Timestamped at the repeat (now), so
|
||||||
|
* the negative-feedback beat surfaces as recent `suppressed` activity rather
|
||||||
|
* than being re-synthesized from the old dismissal row.
|
||||||
|
*/
|
||||||
|
export async function recordSuppression(
|
||||||
|
collision: Collision,
|
||||||
|
priorInterventionId?: string,
|
||||||
|
priorDismissedAt?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await persist('suppression', async () =>
|
||||||
|
(await collections()).suppressions.insertOne({
|
||||||
|
id: `supp_${Date.now()}`,
|
||||||
|
podId: collision.podId,
|
||||||
|
collisionId: collision.id,
|
||||||
|
file: collision.file,
|
||||||
|
engineers: collision.engineers,
|
||||||
|
priorInterventionId,
|
||||||
|
priorDismissedAt,
|
||||||
|
suppressedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function hasRecentInterventionForCollision(
|
export async function hasRecentInterventionForCollision(
|
||||||
collision: Collision,
|
collision: Collision,
|
||||||
windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'),
|
windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'),
|
||||||
@@ -84,13 +136,53 @@ export async function updateInterventionStatus(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||||
|
// 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 () => {
|
await persist('outcome', async () => {
|
||||||
const c = await collections();
|
const c = await collections();
|
||||||
await c.outcomes.insertOne({ ...outcome });
|
await c.outcomes.insertOne({ ...verified });
|
||||||
await c.interventions.updateOne(
|
await c.interventions.updateOne(
|
||||||
{ id: outcome.interventionId },
|
{ id: verified.interventionId },
|
||||||
{ $set: { status: outcome.accepted ? 'accepted' : 'dismissed' } },
|
{ $set: { status: verified.accepted ? 'accepted' : 'dismissed' } },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -98,11 +190,31 @@ export async function recordOutcome(outcome: InterventionOutcome): Promise<void>
|
|||||||
/** Document counts per collection — used by the /api/memory/stats endpoint. */
|
/** Document counts per collection — used by the /api/memory/stats endpoint. */
|
||||||
export async function memoryStats(): Promise<Record<string, number>> {
|
export async function memoryStats(): Promise<Record<string, number>> {
|
||||||
const c = await collections();
|
const c = await collections();
|
||||||
const [observations, collisions, interventions, outcomes] = await Promise.all([
|
const db = await getDb();
|
||||||
|
const [
|
||||||
|
observations,
|
||||||
|
collisions,
|
||||||
|
interventions,
|
||||||
|
outcomes,
|
||||||
|
suppressions,
|
||||||
|
userPodContext,
|
||||||
|
userLearningProfiles,
|
||||||
|
] = await Promise.all([
|
||||||
c.observations.estimatedDocumentCount(),
|
c.observations.estimatedDocumentCount(),
|
||||||
c.collisions.estimatedDocumentCount(),
|
c.collisions.estimatedDocumentCount(),
|
||||||
c.interventions.estimatedDocumentCount(),
|
c.interventions.estimatedDocumentCount(),
|
||||||
c.outcomes.estimatedDocumentCount(),
|
c.outcomes.estimatedDocumentCount(),
|
||||||
|
c.suppressions.estimatedDocumentCount(),
|
||||||
|
db.collection<UserPodContextDoc>('user_pod_context').estimatedDocumentCount(),
|
||||||
|
db.collection('user_learning_profiles').estimatedDocumentCount(),
|
||||||
]);
|
]);
|
||||||
return { observations, collisions, interventions, outcomes };
|
return {
|
||||||
|
observations,
|
||||||
|
collisions,
|
||||||
|
interventions,
|
||||||
|
outcomes,
|
||||||
|
suppressions,
|
||||||
|
userPodContext,
|
||||||
|
userLearningProfiles,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import type { Collision, EngineerContext, HermesJob, Pod, UserLearningProfile } from '@podman/shared';
|
||||||
|
import { getDb, type UserPodContextDoc } from './db.js';
|
||||||
|
|
||||||
|
interface EngineerStateDoc {
|
||||||
|
podId: string;
|
||||||
|
name: string;
|
||||||
|
changedFiles?: string[];
|
||||||
|
branch?: string | null;
|
||||||
|
recentCommit?: string | null;
|
||||||
|
gitUpdatedAt?: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationNoteDoc {
|
||||||
|
podId: string;
|
||||||
|
identity?: string;
|
||||||
|
kind?: string;
|
||||||
|
note?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(value: unknown): string {
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString();
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniq(values: Array<string | undefined>): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const item = value?.trim();
|
||||||
|
if (!item) continue;
|
||||||
|
const key = item.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function top(values: string[], limit: number): string[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const value of values) {
|
||||||
|
const item = value.trim();
|
||||||
|
if (!item) continue;
|
||||||
|
counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([value]) => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lowerRegex(values: string[]): RegExp[] {
|
||||||
|
return values.map((value) => new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteGoals(notes: ConversationNoteDoc[]): string[] {
|
||||||
|
const goalNotes = notes
|
||||||
|
.filter((note) => /goal|plan|todo|next|preference|prefers|likes|wants|needs/i.test(`${note.kind} ${note.note}`))
|
||||||
|
.map((note) => clean(note.note)?.slice(0, 180))
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
return uniq(goalNotes).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCollaborationStyle(input: {
|
||||||
|
contexts: UserPodContextDoc[];
|
||||||
|
observations: EngineerContext[];
|
||||||
|
collisions: Collision[];
|
||||||
|
outcomes: Array<{ accepted?: boolean; wasRealCollision?: boolean }>;
|
||||||
|
notes: ConversationNoteDoc[];
|
||||||
|
jobs: HermesJob[];
|
||||||
|
}): string[] {
|
||||||
|
const style: string[] = [];
|
||||||
|
const actions = input.contexts.map((ctx) => ctx.action);
|
||||||
|
const pods = new Set(input.contexts.map((ctx) => ctx.podId));
|
||||||
|
if (pods.size > 1) style.push(`Works across ${pods.size} pods and carries context between rooms.`);
|
||||||
|
if (actions.filter((action) => action === 'joined_pod').length >= 3)
|
||||||
|
style.push('Frequently joins live rooms and collaborates synchronously.');
|
||||||
|
if (input.collisions.length > 0)
|
||||||
|
style.push(`Has been involved in ${input.collisions.length} coordination risk signals.`);
|
||||||
|
const accepted = input.outcomes.filter((outcome) => outcome.accepted).length;
|
||||||
|
if (accepted > 0) style.push(`Accepts Hermes help when useful (${accepted} accepted outcomes).`);
|
||||||
|
if (input.jobs.length > 0) style.push(`Delegates complex work to Hermes (${input.jobs.length} jobs).`);
|
||||||
|
if (input.notes.some((note) => /concise|short|brief/i.test(note.note ?? '')))
|
||||||
|
style.push('Prefers concise coordination.');
|
||||||
|
return style.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferWorkingStyle(input: {
|
||||||
|
observations: EngineerContext[];
|
||||||
|
gitStates: EngineerStateDoc[];
|
||||||
|
}): string[] {
|
||||||
|
const style: string[] = [];
|
||||||
|
const modes = top(input.observations.map((obs) => obs.mode ?? '').filter(Boolean), 2);
|
||||||
|
const activities = top(input.observations.map((obs) => obs.activity ?? '').filter(Boolean), 5);
|
||||||
|
const files = top(
|
||||||
|
[
|
||||||
|
...input.observations.map((obs) => obs.currentFile ?? ''),
|
||||||
|
...input.gitStates.flatMap((state) => state.changedFiles ?? []),
|
||||||
|
].filter(Boolean),
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
if (modes.length) style.push(`Usually seen in ${modes.join(' and ')} mode.`);
|
||||||
|
if (activities.length) style.push(`Common work patterns: ${activities.join(', ')}.`);
|
||||||
|
if (files.length) style.push(`Frequently touches ${files.join(', ')}.`);
|
||||||
|
return style.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferKnowledge(input: {
|
||||||
|
observations: EngineerContext[];
|
||||||
|
gitStates: EngineerStateDoc[];
|
||||||
|
notes: ConversationNoteDoc[];
|
||||||
|
}): string[] {
|
||||||
|
const topics = top(
|
||||||
|
[
|
||||||
|
...input.observations.map((obs) => obs.researchTopic ?? ''),
|
||||||
|
...input.observations.map((obs) => obs.researchSource ?? ''),
|
||||||
|
...input.observations.map((obs) => obs.currentSymbol ?? ''),
|
||||||
|
...input.gitStates.flatMap((state) => state.changedFiles ?? []),
|
||||||
|
...input.notes
|
||||||
|
.filter((note) => /learn|knows|expert|worked on|decision/i.test(note.note ?? ''))
|
||||||
|
.map((note) => note.note?.slice(0, 120) ?? ''),
|
||||||
|
].filter(Boolean),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildUserLearningProfile(clerkUserId: string): Promise<UserLearningProfile | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const contexts = await db
|
||||||
|
.collection<UserPodContextDoc>('user_pod_context')
|
||||||
|
.find({ clerkUserId }, { projection: { _id: 0 } })
|
||||||
|
.sort({ observedAt: -1 })
|
||||||
|
.limit(500)
|
||||||
|
.toArray();
|
||||||
|
if (!contexts.length) return null;
|
||||||
|
|
||||||
|
const latest = contexts[0];
|
||||||
|
const identities = uniq([
|
||||||
|
...contexts.map((ctx) => ctx.memberName),
|
||||||
|
...contexts.map((ctx) => clean(ctx.metadata?.identity)),
|
||||||
|
...contexts.map((ctx) => clean(ctx.metadata?.email)),
|
||||||
|
]);
|
||||||
|
const email = clean(latest?.metadata?.email) ?? identities.find((item) => item.includes('@'));
|
||||||
|
const displayName = latest?.memberName ?? identities.find((item) => !item.includes('@')) ?? email;
|
||||||
|
const imageUrl = clean(latest?.metadata?.imageUrl);
|
||||||
|
const identityRegex = lowerRegex(identities);
|
||||||
|
|
||||||
|
const podIds = uniq(contexts.map((ctx) => ctx.podId));
|
||||||
|
const [pods, observations, gitStates, collisions, outcomes, notes, jobs] = await Promise.all([
|
||||||
|
db
|
||||||
|
.collection<Pod>('pods')
|
||||||
|
.find({ id: { $in: podIds } }, { projection: { _id: 0 } })
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<EngineerContext>('observations')
|
||||||
|
.find({ engineerId: { $in: identities } }, { projection: { _id: 0, screenshotDataUrl: 0 } })
|
||||||
|
.sort({ observedAt: -1 })
|
||||||
|
.limit(500)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<EngineerStateDoc>('engineer_states')
|
||||||
|
.find({ name: { $in: identityRegex } }, { projection: { _id: 0 } })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<Collision>('collisions')
|
||||||
|
.find({ engineers: { $in: identities } }, { projection: { _id: 0, embedding: 0 } })
|
||||||
|
.sort({ detectedAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<{ podId: string; accepted?: boolean; wasRealCollision?: boolean }>('outcomes')
|
||||||
|
.find({ podId: { $in: podIds } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ recordedAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<ConversationNoteDoc>('conversation_notes')
|
||||||
|
.find({ identity: { $in: identities } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<HermesJob>('hermes_jobs')
|
||||||
|
.find({ identity: { $in: identities } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const podById = new Map(pods.map((pod) => [pod.id, pod]));
|
||||||
|
const podsSummary = podIds.map((podId) => {
|
||||||
|
const rows = contexts.filter((ctx) => ctx.podId === podId);
|
||||||
|
const sorted = [...rows].sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt));
|
||||||
|
return {
|
||||||
|
podId,
|
||||||
|
podName: podById.get(podId)?.name,
|
||||||
|
visits: rows.filter((row) => row.action === 'joined_pod').length,
|
||||||
|
actions: top(rows.map((row) => row.action), 6),
|
||||||
|
firstSeenAt: toIso(sorted[0]?.observedAt),
|
||||||
|
lastSeenAt: toIso(sorted.at(-1)?.observedAt),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile: UserLearningProfile = {
|
||||||
|
clerkUserId,
|
||||||
|
displayName,
|
||||||
|
email,
|
||||||
|
imageUrl,
|
||||||
|
identities,
|
||||||
|
pods: podsSummary,
|
||||||
|
recentWork: observations.slice(0, 20).map((obs) => ({
|
||||||
|
podId: obs.podId,
|
||||||
|
file: obs.currentFile,
|
||||||
|
activity: obs.activity ?? obs.researchTopic,
|
||||||
|
at: obs.observedAt,
|
||||||
|
})),
|
||||||
|
collaborationStyle: inferCollaborationStyle({ contexts, observations, collisions, outcomes, notes, jobs }),
|
||||||
|
workingStyle: inferWorkingStyle({ observations, gitStates }),
|
||||||
|
goals: noteGoals(notes),
|
||||||
|
knowledge: inferKnowledge({ observations, gitStates, notes }),
|
||||||
|
counts: {
|
||||||
|
podActions: contexts.length,
|
||||||
|
observations: observations.length,
|
||||||
|
gitStates: gitStates.length,
|
||||||
|
collisionsInvolved: collisions.length,
|
||||||
|
outcomes: outcomes.length,
|
||||||
|
conversationNotes: notes.length,
|
||||||
|
hermesJobs: jobs.length,
|
||||||
|
},
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db
|
||||||
|
.collection<UserLearningProfile>('user_learning_profiles')
|
||||||
|
.updateOne({ clerkUserId }, { $set: profile }, { upsert: true });
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshUserLearningProfiles(): Promise<UserLearningProfile[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const ids = await db.collection<UserPodContextDoc>('user_pod_context').distinct('clerkUserId');
|
||||||
|
const profiles = await Promise.all(ids.map((id) => buildUserLearningProfile(String(id))));
|
||||||
|
return profiles.filter(Boolean) as UserLearningProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserLearningProfileByIdentity(identity: string): Promise<UserLearningProfile | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const direct = await db
|
||||||
|
.collection<UserLearningProfile>('user_learning_profiles')
|
||||||
|
.findOne({ identities: { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } }, { projection: { _id: 0 } });
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const context = await db
|
||||||
|
.collection<UserPodContextDoc>('user_pod_context')
|
||||||
|
.findOne(
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ memberName: { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } },
|
||||||
|
{ 'metadata.identity': { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ sort: { observedAt: -1 }, projection: { _id: 0 } },
|
||||||
|
);
|
||||||
|
return context ? buildUserLearningProfile(context.clerkUserId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserLearningProfiles(): Promise<UserLearningProfile[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db
|
||||||
|
.collection<UserLearningProfile>('user_learning_profiles')
|
||||||
|
.find({}, { projection: { _id: 0 } })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ function memoryText(collision: Collision): string {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cosine(a: number[], b: number[]): number {
|
export function cosine(a: number[], b: number[]): number {
|
||||||
const n = Math.min(a.length, b.length);
|
const n = Math.min(a.length, b.length);
|
||||||
let dot = 0;
|
let dot = 0;
|
||||||
let aNorm = 0;
|
let aNorm = 0;
|
||||||
@@ -69,6 +69,12 @@ async function embed(text: string, inputType: 'document' | 'query'): Promise<num
|
|||||||
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
|
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function semanticSimilarity(a: string, b: string): Promise<number | null> {
|
||||||
|
const [va, vb] = await Promise.all([embed(a, 'query'), embed(b, 'document')]);
|
||||||
|
if (!va || !vb) return null;
|
||||||
|
return cosine(va, vb);
|
||||||
|
}
|
||||||
|
|
||||||
async function embedWithVoyage(
|
async function embedWithVoyage(
|
||||||
text: string,
|
text: string,
|
||||||
inputType: 'document' | 'query',
|
inputType: 'document' | 'query',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Pod, PodInput } from '@podman/shared';
|
import type { Pod, PodInput } from '@podman/shared';
|
||||||
import { collections } from '../memory/db.js';
|
import { collections, getDb, type UserPodContextDoc } from '../memory/db.js';
|
||||||
|
|
||||||
const NO_ID = { projection: { _id: 0 } } as const;
|
const NO_ID = { projection: { _id: 0 } } as const;
|
||||||
|
|
||||||
@@ -59,14 +59,67 @@ function now(): string {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function regexExact(value: string): RegExp {
|
||||||
|
return new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileString(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesMember(doc: UserPodContextDoc, member: string): boolean {
|
||||||
|
const key = member.trim().toLowerCase();
|
||||||
|
return [doc.memberName, doc.metadata?.identity, doc.metadata?.email]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).trim().toLowerCase() === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateMemberProfiles(pod: Pod): Promise<Pod> {
|
||||||
|
if (!pod.members.length) return pod;
|
||||||
|
const db = await getDb();
|
||||||
|
const matchers = pod.members.map(regexExact);
|
||||||
|
const docs = await db
|
||||||
|
.collection<UserPodContextDoc>('user_pod_context')
|
||||||
|
.find(
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ memberName: { $in: matchers } },
|
||||||
|
{ 'metadata.identity': { $in: matchers } },
|
||||||
|
{ 'metadata.email': { $in: matchers } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ projection: { _id: 0 } },
|
||||||
|
)
|
||||||
|
.sort({ observedAt: -1 })
|
||||||
|
.limit(500)
|
||||||
|
.toArray();
|
||||||
|
const memberProfiles: NonNullable<Pod['memberProfiles']> = {};
|
||||||
|
for (const member of pod.members) {
|
||||||
|
const doc = docs.find((row) => matchesMember(row, member));
|
||||||
|
const imageUrl = profileString(doc?.metadata?.imageUrl);
|
||||||
|
if (!doc || !imageUrl) continue;
|
||||||
|
memberProfiles[member] = {
|
||||||
|
displayName: doc.memberName ?? member,
|
||||||
|
email: profileString(doc.metadata?.email),
|
||||||
|
imageUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Object.keys(memberProfiles).length ? { ...pod, memberProfiles } : pod;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydratePods(pods: Pod[]): Promise<Pod[]> {
|
||||||
|
return Promise.all(pods.map((pod) => hydrateMemberProfiles(pod)));
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPods(): Promise<Pod[]> {
|
export async function listPods(): Promise<Pod[]> {
|
||||||
const c = await collections();
|
const c = await collections();
|
||||||
return c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray();
|
return hydratePods(await c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPod(id: string): Promise<Pod | null> {
|
export async function getPod(id: string): Promise<Pod | null> {
|
||||||
const c = await collections();
|
const c = await collections();
|
||||||
return c.pods.findOne({ id }, NO_ID);
|
const pod = await c.pods.findOne({ id }, NO_ID);
|
||||||
|
return pod ? hydrateMemberProfiles(pod) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPod(input: PodInput): Promise<Pod> {
|
export async function createPod(input: PodInput): Promise<Pod> {
|
||||||
@@ -92,7 +145,7 @@ export async function createPod(input: PodInput): Promise<Pod> {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await c.pods.insertOne({ ...pod });
|
await c.pods.insertOne({ ...pod });
|
||||||
return pod;
|
return hydrateMemberProfiles(pod);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDuplicateKey(err)) continue;
|
if (isDuplicateKey(err)) continue;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -119,7 +172,7 @@ export async function updatePod(id: string, patch: PodInput): Promise<Pod | null
|
|||||||
{ $set: set },
|
{ $set: set },
|
||||||
{ returnDocument: 'after', projection: { _id: 0 } },
|
{ returnDocument: 'after', projection: { _id: 0 } },
|
||||||
);
|
);
|
||||||
return updated ?? null;
|
return updated ? hydrateMemberProfiles(updated) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePod(id: string): Promise<boolean> {
|
export async function deletePod(id: string): Promise<boolean> {
|
||||||
@@ -135,7 +188,7 @@ async function setMembers(id: string, members: string[]): Promise<Pod | null> {
|
|||||||
{ $set: { members, updatedAt: now() } },
|
{ $set: { members, updatedAt: now() } },
|
||||||
{ returnDocument: 'after', projection: { _id: 0 } },
|
{ returnDocument: 'after', projection: { _id: 0 } },
|
||||||
);
|
);
|
||||||
return updated ?? null;
|
return updated ? hydrateMemberProfiles(updated) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMember(id: string, rawName: unknown): Promise<Pod | null> {
|
export async function addMember(id: string, rawName: unknown): Promise<Pod | null> {
|
||||||
|
|||||||
+107
-7
@@ -12,7 +12,9 @@ import {
|
|||||||
recordOutcome,
|
recordOutcome,
|
||||||
hasRecentInterventionForCollision,
|
hasRecentInterventionForCollision,
|
||||||
memoryStats,
|
memoryStats,
|
||||||
|
recordUserPodContext,
|
||||||
} from './memory/store.js';
|
} from './memory/store.js';
|
||||||
|
import { clerkAuthMiddleware, requestUser } from './auth.js';
|
||||||
import { closeMemory, initMemory } from './memory/db.js';
|
import { closeMemory, initMemory } from './memory/db.js';
|
||||||
import {
|
import {
|
||||||
listPods,
|
listPods,
|
||||||
@@ -29,6 +31,7 @@ import { loadPodGraph, reachFrom } from './graph/store.js';
|
|||||||
import { listPodActivity } from './activity/store.js';
|
import { listPodActivity } from './activity/store.js';
|
||||||
import { getMemberWorkHistory } from './activity/member-history.js';
|
import { getMemberWorkHistory } from './activity/member-history.js';
|
||||||
import { speakInRoom } from './voice/live.js';
|
import { speakInRoom } from './voice/live.js';
|
||||||
|
import { getPodMusic } from './voice/music.js';
|
||||||
import { notifyHermesInterventionInRoom } from './action/hermes.js';
|
import { notifyHermesInterventionInRoom } from './action/hermes.js';
|
||||||
import {
|
import {
|
||||||
activeLiveConversation,
|
activeLiveConversation,
|
||||||
@@ -39,6 +42,10 @@ import {
|
|||||||
getLiveConversationContext,
|
getLiveConversationContext,
|
||||||
recordLiveConversationNote,
|
recordLiveConversationNote,
|
||||||
} from './live-conversation/context.js';
|
} from './live-conversation/context.js';
|
||||||
|
import {
|
||||||
|
listUserLearningProfiles,
|
||||||
|
refreshUserLearningProfiles,
|
||||||
|
} from './memory/user-learning.js';
|
||||||
import {
|
import {
|
||||||
abortHermesJob,
|
abortHermesJob,
|
||||||
appendHermesJobEvent,
|
appendHermesJobEvent,
|
||||||
@@ -59,6 +66,7 @@ import type {
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(clerkAuthMiddleware);
|
||||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
function stringArray(value: unknown): string[] {
|
function stringArray(value: unknown): string[] {
|
||||||
@@ -71,6 +79,10 @@ function suggestedAction(value: unknown): SuggestedActionKind {
|
|||||||
: 'ping_teammate';
|
: 'ping_teammate';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringMeta(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
||||||
return value === 'accepted' ||
|
return value === 'accepted' ||
|
||||||
value === 'heartbeat' ||
|
value === 'heartbeat' ||
|
||||||
@@ -89,11 +101,16 @@ function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
|||||||
app.post('/api/token', async (req, res) => {
|
app.post('/api/token', async (req, res) => {
|
||||||
const { room, identity, name, githubLogin } = req.body ?? {};
|
const { room, identity, name, githubLogin } = req.body ?? {};
|
||||||
if (!room || !identity) return res.status(400).json({ error: 'room+identity required' });
|
if (!room || !identity) return res.status(400).json({ error: 'room+identity required' });
|
||||||
|
const user = requestUser(req);
|
||||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||||
identity,
|
identity,
|
||||||
name,
|
name,
|
||||||
ttl: '4h',
|
ttl: '4h',
|
||||||
metadata: JSON.stringify({ githubLogin: githubLogin ?? name }),
|
metadata: JSON.stringify({
|
||||||
|
githubLogin: githubLogin ?? name,
|
||||||
|
email: stringMeta(req.body?.profile?.email),
|
||||||
|
imageUrl: stringMeta(req.body?.profile?.imageUrl),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||||
const agents = env.LIVEKIT_AGENT_NAME
|
const agents = env.LIVEKIT_AGENT_NAME
|
||||||
@@ -112,6 +129,19 @@ app.post('/api/token', async (req, res) => {
|
|||||||
departureTimeout: 20,
|
departureTimeout: 20,
|
||||||
agents,
|
agents,
|
||||||
});
|
});
|
||||||
|
if (user) {
|
||||||
|
await recordUserPodContext({
|
||||||
|
clerkUserId: user.clerkUserId,
|
||||||
|
podId: String(room),
|
||||||
|
memberName: typeof name === 'string' ? name : String(identity),
|
||||||
|
action: 'joined_pod',
|
||||||
|
metadata: {
|
||||||
|
identity: String(identity),
|
||||||
|
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||||
|
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,6 +179,22 @@ app.get('/api/memory/stats', async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/memory/users', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await listUserLearningProfiles());
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/memory/users/refresh', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ profiles: await refreshUserLearningProfiles() });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Live presence: who is currently connected in each pod's LiveKit room.
|
// Live presence: who is currently connected in each pod's LiveKit room.
|
||||||
app.get('/api/presence', async (_req, res) => {
|
app.get('/api/presence', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -169,7 +215,24 @@ app.get('/api/pods', async (_req, res) => {
|
|||||||
|
|
||||||
app.post('/api/pods', async (req, res) => {
|
app.post('/api/pods', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.status(201).json(await createPod(req.body ?? {}));
|
const pod = await createPod(req.body ?? {});
|
||||||
|
const user = requestUser(req);
|
||||||
|
if (user) {
|
||||||
|
await recordUserPodContext({
|
||||||
|
clerkUserId: user.clerkUserId,
|
||||||
|
podId: pod.id,
|
||||||
|
memberName: stringMeta(req.body?.profile?.displayName),
|
||||||
|
action: 'created_pod',
|
||||||
|
metadata: {
|
||||||
|
podName: pod.name,
|
||||||
|
repo: pod.repo,
|
||||||
|
identity: stringMeta(req.body?.profile?.displayName) ?? null,
|
||||||
|
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||||
|
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.status(201).json(await getPod(pod.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(400).json({ error: (e as Error).message });
|
res.status(400).json({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
@@ -185,6 +248,15 @@ app.patch('/api/pods/:id', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pod = await updatePod(req.params.id, req.body ?? {});
|
const pod = await updatePod(req.params.id, req.body ?? {});
|
||||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||||
|
const user = requestUser(req);
|
||||||
|
if (user) {
|
||||||
|
await recordUserPodContext({
|
||||||
|
clerkUserId: user.clerkUserId,
|
||||||
|
podId: pod.id,
|
||||||
|
action: 'updated_pod',
|
||||||
|
metadata: { podName: pod.name, repo: pod.repo },
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json(pod);
|
res.json(pod);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(400).json({ error: (e as Error).message });
|
res.status(400).json({ error: (e as Error).message });
|
||||||
@@ -202,7 +274,21 @@ app.post('/api/pods/:id/members', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pod = await addMember(req.params.id, req.body?.name ?? '');
|
const pod = await addMember(req.params.id, req.body?.name ?? '');
|
||||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||||
res.json(pod);
|
const user = requestUser(req);
|
||||||
|
if (user) {
|
||||||
|
await recordUserPodContext({
|
||||||
|
clerkUserId: user.clerkUserId,
|
||||||
|
podId: pod.id,
|
||||||
|
memberName: typeof req.body?.name === 'string' ? req.body.name.trim() : undefined,
|
||||||
|
action: 'added_member',
|
||||||
|
metadata: {
|
||||||
|
identity: typeof req.body?.name === 'string' ? req.body.name.trim() : null,
|
||||||
|
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||||
|
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json(await getPod(pod.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(400).json({ error: (e as Error).message });
|
res.status(400).json({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
@@ -305,15 +391,14 @@ app.post('/api/internal/pods/:id/live-conversation/:sessionId/note', async (req,
|
|||||||
const note = typeof req.body?.note === 'string' ? req.body.note : '';
|
const note = typeof req.body?.note === 'string' ? req.body.note : '';
|
||||||
const identity = typeof req.body?.identity === 'string' ? req.body.identity : undefined;
|
const identity = typeof req.body?.identity === 'string' ? req.body.identity : undefined;
|
||||||
const kind = typeof req.body?.kind === 'string' ? req.body.kind : undefined;
|
const kind = typeof req.body?.kind === 'string' ? req.body.kind : undefined;
|
||||||
res.status(201).json(
|
const saved = await recordLiveConversationNote({
|
||||||
await recordLiveConversationNote({
|
|
||||||
podId: req.params.id,
|
podId: req.params.id,
|
||||||
sessionId: req.params.sessionId,
|
sessionId: req.params.sessionId,
|
||||||
identity,
|
identity,
|
||||||
kind,
|
kind,
|
||||||
note,
|
note,
|
||||||
}),
|
});
|
||||||
);
|
res.status(201).json(saved);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(400).json({ error: (e as Error).message });
|
res.status(400).json({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
@@ -406,6 +491,21 @@ app.get('/api/internal/hermes/jobs/:jobId/events/stream', async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
app.post('/api/pods/:id/hermes/notify', async (req, res) => {
|
||||||
const podId = req.params.id;
|
const podId = req.params.id;
|
||||||
const pod = await getPod(podId);
|
const pod = await getPod(podId);
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
|||||||
const SCHEMA = {
|
const SCHEMA = {
|
||||||
type: Type.OBJECT,
|
type: Type.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
|
mode: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description:
|
||||||
|
'editing when an IDE/editor/terminal is primary; research for browser docs/SDK pages',
|
||||||
|
},
|
||||||
currentFile: {
|
currentFile: {
|
||||||
type: Type.STRING,
|
type: Type.STRING,
|
||||||
description: 'open file path if visible, e.g. src/auth/session.ts',
|
description: 'open file path if visible, e.g. src/auth/session.ts',
|
||||||
@@ -20,13 +25,24 @@ const SCHEMA = {
|
|||||||
type: Type.BOOLEAN,
|
type: Type.BOOLEAN,
|
||||||
description: 'dirty git gutter / modified markers visible',
|
description: 'dirty git gutter / modified markers visible',
|
||||||
},
|
},
|
||||||
|
researchTopic: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: 'topic being researched when mode is research, e.g. LiveKit agents setup',
|
||||||
|
},
|
||||||
|
researchSource: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: 'source domain when mode is research, e.g. docs.livekit.io',
|
||||||
|
},
|
||||||
confidence: { type: Type.NUMBER, description: '0..1 confidence in this read' },
|
confidence: { type: Type.NUMBER, description: '0..1 confidence in this read' },
|
||||||
},
|
},
|
||||||
propertyOrdering: [
|
propertyOrdering: [
|
||||||
|
'mode',
|
||||||
'currentFile',
|
'currentFile',
|
||||||
'currentSymbol',
|
'currentSymbol',
|
||||||
'activity',
|
'activity',
|
||||||
'hasUnpushedChanges',
|
'hasUnpushedChanges',
|
||||||
|
'researchTopic',
|
||||||
|
'researchSource',
|
||||||
'confidence',
|
'confidence',
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
@@ -43,7 +59,10 @@ export async function analyzeFrame(
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
text: "You are PodMan watching an engineer's screen. Identify what file/symbol they are working on and whether there are uncommitted edits. JSON only.",
|
text:
|
||||||
|
"You are PodMan watching an engineer's screen. Return JSON only. " +
|
||||||
|
"If the primary window is an IDE/editor/terminal, set mode='editing' and identify the file, symbol, activity, and whether uncommitted edits are visible. " +
|
||||||
|
"If the primary window is a browser/docs/SDK/reference page, set mode='research', leave currentFile empty unless a file path is clearly visible, and extract researchTopic plus researchSource as the source domain.",
|
||||||
},
|
},
|
||||||
{ inlineData: { mimeType: 'image/jpeg', data: jpeg.toString('base64') } },
|
{ inlineData: { mimeType: 'image/jpeg', data: jpeg.toString('base64') } },
|
||||||
],
|
],
|
||||||
@@ -63,6 +82,9 @@ export async function analyzeFrame(
|
|||||||
currentFile: parsed.currentFile,
|
currentFile: parsed.currentFile,
|
||||||
currentSymbol: parsed.currentSymbol,
|
currentSymbol: parsed.currentSymbol,
|
||||||
activity: parsed.activity,
|
activity: parsed.activity,
|
||||||
|
mode: parsed.mode,
|
||||||
|
researchTopic: parsed.researchTopic,
|
||||||
|
researchSource: parsed.researchSource,
|
||||||
hasUnpushedChanges: parsed.hasUnpushedChanges,
|
hasUnpushedChanges: parsed.hasUnpushedChanges,
|
||||||
confidence: parsed.confidence ?? 0.5,
|
confidence: parsed.confidence ?? 0.5,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
-745
@@ -1,745 +0,0 @@
|
|||||||
# PodMan - Canonical Master Plan
|
|
||||||
|
|
||||||
> Source of truth for PodMan product intent, current implementation truth, demo
|
|
||||||
> strategy, public interfaces, risks, sponsor story, and next build order.
|
|
||||||
>
|
|
||||||
> If this file conflicts with `README.md`, `docs/idea.md`, `docs/livekit.md`,
|
|
||||||
> `docs/gemini.md`, `docs/mongodb.md`, `docs/continual-learning/`,
|
|
||||||
> `docs/graph-discovery/`, `docs/agent-learning/`, `docs/digitalocean.md`,
|
|
||||||
> `docs/demo-setup.md`, or `docs/superpowers/specs/*`, follow this file and
|
|
||||||
> treat the older docs as reference material to reconcile later.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Product thesis
|
|
||||||
|
|
||||||
**PodMan sees active work before it becomes visible to GitHub, remembers how the
|
|
||||||
team works, researches better paths in the background, and coordinates teammates
|
|
||||||
without being intrusive.**
|
|
||||||
|
|
||||||
GitHub knows pushed branches, PRs, issues, and comments. It cannot see the most
|
|
||||||
expensive coordination failures while they are still forming on laptops: two
|
|
||||||
engineers editing the same unpushed file, someone blocked on an endpoint a
|
|
||||||
teammate is nearly done with, duplicated work starting silently, or a team
|
|
||||||
walking into a dead-end implementation path.
|
|
||||||
|
|
||||||
PodMan puts engineers in a consented LiveKit pod, watches live IDE/screen
|
|
||||||
context, fuses that with scheduled local git reports, GitHub state, MongoDB team
|
|
||||||
memory, and background research, then routes only useful interventions through
|
|
||||||
Hermes. The default is a small visual card. Hermes can message teammates when
|
|
||||||
the team needs coordination. Voice is reserved for urgent escalation.
|
|
||||||
|
|
||||||
**One-line product definition:** PodMan is a non-intrusive, continual-learning
|
|
||||||
team assistant for active coding.
|
|
||||||
|
|
||||||
**One-line demo promise:** PodMan notices live work, finds a better path,
|
|
||||||
remembers a previous intervention, and escalates only when the team actually
|
|
||||||
needs it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Product contract
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
- **Live IDE/screen context:** engineers join a LiveKit room and publish screen
|
|
||||||
share so the backend agent can sample real work in progress.
|
|
||||||
- **Scheduled local git state:** each laptop should report dirty files,
|
|
||||||
unpushed commits, branch, and latest commit about every minute. This is the
|
|
||||||
deterministic fallback for facts vision cannot reliably infer.
|
|
||||||
- **MongoDB team memory:** ownership, current tasks, blockers, repeated
|
|
||||||
mistakes, preferred tools, decisions, intervention history, and outcomes.
|
|
||||||
- **GitHub repo state:** public repo metadata, branches, PR artifacts, and
|
|
||||||
issue/PR state when it exists.
|
|
||||||
- **Background research signals:** tool, repo, skill, package, docs, and
|
|
||||||
dead-end evidence discovered while teammates are working.
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
- **Default:** small visual intervention card in the PodMan frontend.
|
|
||||||
- **Coordination:** Hermes message to the right teammate(s) or project channel.
|
|
||||||
- **Urgent escalation:** voice only when timing or risk justifies interruption.
|
|
||||||
- **Action path:** optional sync PR, research recommendation, summary, fix
|
|
||||||
suggestion, or teammate notification.
|
|
||||||
|
|
||||||
### Memory rules
|
|
||||||
|
|
||||||
- Remember team-level work patterns, not raw screen recordings.
|
|
||||||
- Store structured observations, collisions, interventions, outcomes, and pod
|
|
||||||
state.
|
|
||||||
- Add exact-signature recall before vector recall: normalized file, symbol,
|
|
||||||
engineer pair, event type, and accepted/dismissed outcome.
|
|
||||||
- Privacy must stay explicit: engineers consent by joining the pod and sharing
|
|
||||||
screen context; do not store raw screenshots, full recordings, or secrets.
|
|
||||||
|
|
||||||
### Non-goals
|
|
||||||
|
|
||||||
- Not a dashboard as the product center.
|
|
||||||
- Not a screenshot analyzer with no action loop.
|
|
||||||
- Not sponsor-padding; every sponsor technology must be load-bearing or clearly
|
|
||||||
marked as optional polish.
|
|
||||||
- Not a task manager, Slack clone, full auth system, or general surveillance
|
|
||||||
tool.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Track fit: Continual Learning
|
|
||||||
|
|
||||||
PodMan fits **Continual Learning** because the system gets more useful from team
|
|
||||||
history and intervention outcomes.
|
|
||||||
|
|
||||||
- **Team model:** observations build ownership, hotspot, blocker, tool, and
|
|
||||||
decision memory per pod.
|
|
||||||
- **Outcome loop:** accepted, dismissed, and confirmed interventions become
|
|
||||||
supervision for future thresholds and routing.
|
|
||||||
- **Session compounding:** a later similar situation should reference prior
|
|
||||||
memory, choose a better action sooner, or lower the noise level.
|
|
||||||
- **Visible demo proof:** the first intervention writes memory; the second
|
|
||||||
similar situation retrieves it and says, in effect, "I have seen this pattern
|
|
||||||
before."
|
|
||||||
|
|
||||||
The learning proof should not depend on Atlas Vector Search being finished.
|
|
||||||
Exact MongoDB recall is enough for the MVP learning beat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Current implementation truth
|
|
||||||
|
|
||||||
Verified on `2026-06-27` from local repo inspection, authenticated `gh`, and
|
|
||||||
the current remote plan commit.
|
|
||||||
|
|
||||||
### GitHub state
|
|
||||||
|
|
||||||
- Repo: <https://github.com/karti-ai/podman>
|
|
||||||
- Visibility: public
|
|
||||||
- Default branch: `main`
|
|
||||||
- Current local branch: `main`
|
|
||||||
- Local branch state during this rewrite: behind `origin/main` by two commits
|
|
||||||
- Issues: none
|
|
||||||
- PRs: none
|
|
||||||
- `origin/main` latest relevant commits:
|
|
||||||
- `8271188 feat(frontend): live room view, beat connectivity test, session resume`
|
|
||||||
- `65a0791 docs(plan): audit server state + mark tasks 1-5 done, reflect actual arch`
|
|
||||||
|
|
||||||
### Working / started
|
|
||||||
|
|
||||||
- Monorepo packages exist: `frontend`, `backend`, `shared`, `database`, and
|
|
||||||
`infra`.
|
|
||||||
- Backend is split into two processes:
|
|
||||||
- API service in `backend/src/server.ts`.
|
|
||||||
- LiveKit agent worker in `backend/src/agent.ts`.
|
|
||||||
- Backend API exposes:
|
|
||||||
- `GET /health`
|
|
||||||
- `POST /api/token`
|
|
||||||
- `POST /api/sync-pr`
|
|
||||||
- `POST /api/outcome`
|
|
||||||
- `GET /api/memory/stats`
|
|
||||||
- `GET /api/pods`
|
|
||||||
- `POST /api/pods`
|
|
||||||
- `GET /api/pods/:id`
|
|
||||||
- `PATCH /api/pods/:id`
|
|
||||||
- `DELETE /api/pods/:id`
|
|
||||||
- `POST /api/pods/:id/members`
|
|
||||||
- `DELETE /api/pods/:id/members/:name`
|
|
||||||
- Remote API health check returned `{"ok":true}` at
|
|
||||||
`http://165.22.129.249:8787/health` during verification.
|
|
||||||
- The LiveKit agent uses `@livekit/rtc-node` to join as `podman-agent`, subscribe
|
|
||||||
to `TrackSource.SOURCE_SCREENSHARE`, sample frames near 1 fps, convert frames
|
|
||||||
to RGBA, and encode downscaled JPEGs with `sharp`.
|
|
||||||
- Gemini vision is wired in `backend/src/vision/gemini.ts` with JSON structured
|
|
||||||
output, response schema, low media resolution, and model ID from env.
|
|
||||||
- Collision detection exists and groups engineer contexts by normalized file,
|
|
||||||
then fires when 2+ engineers touch the same file and at least one unpushed or
|
|
||||||
dirty signal exists.
|
|
||||||
- Shared LiveKit data topic and wire messages exist:
|
|
||||||
- topic: `podman.intervention`
|
|
||||||
- messages: `COLLISION`, `VOICE_CUE`, `ACK`, `GIT_REPORT`
|
|
||||||
- MongoDB persistence groundwork exists for observations, collisions,
|
|
||||||
interventions, outcomes, and pods.
|
|
||||||
- Frontend has pod selection, pod join, post-join pod view, LiveKit join helper,
|
|
||||||
and dev-mode fallback.
|
|
||||||
- `origin/main` adds live room participants, active-speaker state, session
|
|
||||||
resume, a "Play beat" audio connectivity test, and a deliberate "Share my
|
|
||||||
screen" button that publishes with `Track.Source.ScreenShare`. Merge that
|
|
||||||
remote commit before doing more frontend work on the local checkout.
|
|
||||||
- DigitalOcean infra scaffolding exists:
|
|
||||||
- `infra/.do/app.yaml` is the split App Platform direction.
|
|
||||||
- `infra/app.yaml` is an older single-service backend spec and should be
|
|
||||||
treated as legacy until reconciled.
|
|
||||||
|
|
||||||
### Server snapshot
|
|
||||||
|
|
||||||
From the remote plan snapshot and health check on `2026-06-27`:
|
|
||||||
|
|
||||||
- Backend API: running on `http://165.22.129.249:8787` and `/health` returned
|
|
||||||
`{"ok":true}`.
|
|
||||||
- Frontend: reported running on `:81`; port `80` was already taken.
|
|
||||||
- Agent worker: reported not running; it still needs LiveKit credentials and
|
|
||||||
`pnpm --filter @podman/backend dev:agent`.
|
|
||||||
- Treat this as operational evidence, not architecture truth. Reverify before
|
|
||||||
demo.
|
|
||||||
|
|
||||||
### Partial / completed since the original audit
|
|
||||||
|
|
||||||
- `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. 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 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
|
|
||||||
|
|
||||||
- Real browser -> LiveKit room -> backend agent screen-frame capture end to end.
|
|
||||||
- Real Gemini inference from a live shared IDE frame using the stage key/model.
|
|
||||||
- Real data-channel intervention card rendering in the active frontend.
|
|
||||||
- Hermes message routing to teammates.
|
|
||||||
- Voice escalation heard by participants through LiveKit, including
|
|
||||||
duration-based track holding so longer Gemini TTS announcements finish.
|
|
||||||
- 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
|
|
||||||
together.
|
|
||||||
- Background research recommendation that is both timely and evidence-backed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Architecture to build toward
|
|
||||||
|
|
||||||
```
|
|
||||||
Engineer browser PWA
|
|
||||||
- joins a pod room
|
|
||||||
- publishes screen share and optional mic
|
|
||||||
- receives intervention cards and voice
|
|
||||||
|
|
|
||||||
v
|
|
||||||
LiveKit room
|
|
||||||
- one room per pod
|
|
||||||
- screen-share tracks are the live work signal
|
|
||||||
- small reliable data packets carry interventions
|
|
||||||
|
|
|
||||||
v
|
|
||||||
PodMan backend agent worker
|
|
||||||
- @livekit/rtc-node room participant
|
|
||||||
- screen-track subscription
|
|
||||||
- frame throttle and JPEG encode
|
|
||||||
- Gemini structured vision
|
|
||||||
- scheduled GIT_REPORT fusion
|
|
||||||
- GitHub state fusion
|
|
||||||
- collision, blocker, duplicate-work, and dead-end detection
|
|
||||||
- MongoDB memory recall and policy
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Hermes action layer
|
|
||||||
- visual card routing
|
|
||||||
- teammate messages
|
|
||||||
- urgent voice escalation
|
|
||||||
- optional research/action/sync PR workflows
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Backend API + MongoDB + GitHub
|
|
||||||
- token minting, pod CRUD, outcomes, memory stats
|
|
||||||
- observations, collisions, interventions, outcomes, pod memory
|
|
||||||
- public repo state and PR artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend must remain split:
|
|
||||||
|
|
||||||
- **API service:** routable HTTP process with `/api/*` endpoints and health
|
|
||||||
checks.
|
|
||||||
- **Agent worker:** outbound LiveKit participant with no HTTP health-check port
|
|
||||||
requirement.
|
|
||||||
|
|
||||||
This split matters for DigitalOcean App Platform: the LiveKit agent should be a
|
|
||||||
worker, not a web service that App Platform expects to health-check over HTTP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Public interfaces to preserve
|
|
||||||
|
|
||||||
Do not rename or reshape these without updating frontend, backend, docs, and demo
|
|
||||||
scripts together.
|
|
||||||
|
|
||||||
### Backend HTTP
|
|
||||||
|
|
||||||
- `GET /health`
|
|
||||||
- `POST /api/token`
|
|
||||||
- `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`
|
|
||||||
- `PATCH /api/pods/:id`
|
|
||||||
- `DELETE /api/pods/:id`
|
|
||||||
- `POST /api/pods/:id/members`
|
|
||||||
- `DELETE /api/pods/:id/members/:name`
|
|
||||||
|
|
||||||
### LiveKit data channel
|
|
||||||
|
|
||||||
- Topic: `podman.intervention`
|
|
||||||
- Core messages:
|
|
||||||
- `COLLISION`: agent -> PWA; contains `collision` and `intervention`.
|
|
||||||
- `ACK`: PWA -> agent/API; intervention response.
|
|
||||||
- `GIT_REPORT`: local git sidecar -> agent; dirty/unpushed ground truth.
|
|
||||||
- `VOICE_CUE`: text cue/fallback for voice escalation.
|
|
||||||
|
|
||||||
### Required environment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LIVEKIT_URL=
|
|
||||||
LIVEKIT_API_KEY=
|
|
||||||
LIVEKIT_API_SECRET=
|
|
||||||
|
|
||||||
GEMINI_API_KEY=
|
|
||||||
GEMINI_VISION_MODEL=
|
|
||||||
GEMINI_LIVE_MODEL=
|
|
||||||
GEMINI_TTS_VOICE=
|
|
||||||
|
|
||||||
GITHUB_TOKEN=
|
|
||||||
GITHUB_REPO=karti-ai/podman
|
|
||||||
|
|
||||||
MONGODB_URI=
|
|
||||||
VOYAGE_API_KEY=
|
|
||||||
POD_ROOM=demo-pod
|
|
||||||
PORT=8787
|
|
||||||
|
|
||||||
VITE_BACKEND_URL=http://localhost:8787
|
|
||||||
VITE_LIVEKIT_URL=
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep all non-`VITE_` secrets server-side.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Critical implementation callouts
|
|
||||||
|
|
||||||
### LiveKit
|
|
||||||
|
|
||||||
- Screen share is a video track. The backend agent should consume raw screen
|
|
||||||
frames through `@livekit/rtc-node`.
|
|
||||||
- The agent must filter screen share, not webcam:
|
|
||||||
`pub.source === TrackSource.SOURCE_SCREENSHARE`.
|
|
||||||
- Frontend publishing must tag the track as screen share; otherwise the agent can
|
|
||||||
miss it.
|
|
||||||
- Throttle aggressively. Screens can arrive near video frame rate; Gemini should
|
|
||||||
receive sampled frames only.
|
|
||||||
- Keep reliable data packets small. Use them for intervention metadata, not
|
|
||||||
screenshots, large diffs, or research dumps. Treat reliable payloads as
|
|
||||||
roughly 15 KiB max.
|
|
||||||
- A historical closed `livekit/node-sdks` issue reported high memory use when
|
|
||||||
consuming video; run memory checks during agent frame tests and stop if the
|
|
||||||
loop leaks.
|
|
||||||
|
|
||||||
### Gemini
|
|
||||||
|
|
||||||
- Use structured output for vision: JSON mime type plus response schema.
|
|
||||||
- Use low media resolution for ambient screen watching; reserve higher
|
|
||||||
resolution for debugging or targeted inspection.
|
|
||||||
- Never expose `GEMINI_API_KEY` to the browser.
|
|
||||||
- Use card + Hermes message first. For urgent stage audio, default to Gemini TTS
|
|
||||||
published through LiveKit; keep browser TTS only as an explicit fallback flag.
|
|
||||||
- Keep model IDs in env so preview/availability changes do not require code
|
|
||||||
changes.
|
|
||||||
|
|
||||||
### MongoDB
|
|
||||||
|
|
||||||
- Local MongoDB is fine for dev CRUD and memory counts.
|
|
||||||
- Atlas or Atlas Local is needed for the sponsor-grade Vector Search story.
|
|
||||||
- Build exact-signature recall first:
|
|
||||||
normalized file + symbol + engineer pair + event type + outcome.
|
|
||||||
- Writes from the agent should be best-effort. Mongo hiccups should degrade
|
|
||||||
memory, not kill live detection.
|
|
||||||
- Do not store raw screenshots or recordings.
|
|
||||||
|
|
||||||
### GitHub
|
|
||||||
|
|
||||||
- The repo is public and currently has no issue/PR backlog, so do not make the
|
|
||||||
plan issue-driven yet.
|
|
||||||
- GitHub cannot see local dirty files or unpushed commits. That is still a core
|
|
||||||
product moat.
|
|
||||||
- Sync PRs should use deterministic GitHub REST/Octokit flows, not browser
|
|
||||||
automation.
|
|
||||||
- Verify token scopes and demo repo permissions before stage time.
|
|
||||||
|
|
||||||
### DigitalOcean
|
|
||||||
|
|
||||||
- Use App Platform as:
|
|
||||||
- static site for frontend,
|
|
||||||
- HTTP service for API,
|
|
||||||
- worker for the LiveKit agent.
|
|
||||||
- Do not model the agent worker as a health-checked HTTP service.
|
|
||||||
- Keep a local and recorded fallback even if deployment works; venue network is a
|
|
||||||
stage risk.
|
|
||||||
|
|
||||||
### Hermes
|
|
||||||
|
|
||||||
- Treat Hermes as the action and messaging layer, not as a replacement for the
|
|
||||||
current implemented backend agent until code changes make that real.
|
|
||||||
- Hermes should choose the least intrusive channel:
|
|
||||||
card -> message -> voice.
|
|
||||||
- Hermes can own research summaries, teammate notification, sync PR initiation,
|
|
||||||
and urgent escalation once those workflows exist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Build ladder
|
|
||||||
|
|
||||||
Do not mark a rung done until it is proven in logs, UI, or a visible external
|
|
||||||
artifact.
|
|
||||||
|
|
||||||
### P0 - make the live loop undeniable
|
|
||||||
|
|
||||||
1. **Preserve and reconcile the plan**
|
|
||||||
- Merge local `docs/PLAN.md` with `origin/main:docs/PLAN.md`.
|
|
||||||
- Keep both the broad product thesis and concrete server/current-state facts.
|
|
||||||
- After the docs are safe, merge or rebase the two newer `origin/main` commits
|
|
||||||
before implementing frontend work.
|
|
||||||
|
|
||||||
2. **Browser publish proof**
|
|
||||||
- Start backend API and frontend.
|
|
||||||
- Join a real LiveKit room from the browser.
|
|
||||||
- Confirm the browser publishes a screen-share track with the correct source.
|
|
||||||
|
|
||||||
3. **Agent frame proof**
|
|
||||||
- Start `pnpm --filter @podman/backend dev:agent`.
|
|
||||||
- Confirm room join, screen-track subscription, frame sampling, and JPEG
|
|
||||||
encode logs.
|
|
||||||
- Watch process memory while consuming frames.
|
|
||||||
|
|
||||||
4. **Gemini vision proof**
|
|
||||||
- Send one live sampled IDE frame to Gemini.
|
|
||||||
- Log parsed JSON with `currentFile`, `currentSymbol`, `activity`,
|
|
||||||
`hasUnpushedChanges`, and `confidence`.
|
|
||||||
- Add a confidence/logging gate if noisy frames cause bad reads.
|
|
||||||
|
|
||||||
5. **Scheduled git truth** ✅ partial
|
|
||||||
- `scripts/podman-agent.mjs` polls every 15 s: `git status --short`,
|
|
||||||
`git diff --stat HEAD`, `git log --oneline -1`, `git branch --show-current`.
|
|
||||||
- Upserts `changedFiles`, `diffStat`, `recentCommit`, `branch`, `gitUpdatedAt`
|
|
||||||
to `engineer_states` collection in MongoDB (upsert by `podId::name` key).
|
|
||||||
- **Still needed:** fuse `engineer_states` git fields into the collision
|
|
||||||
detector, and/or publish `GIT_REPORT` data channel messages so the agent
|
|
||||||
worker can incorporate git truth into vision-based decisions.
|
|
||||||
|
|
||||||
6. **Intervention card + Hermes notification**
|
|
||||||
- Publish a real intervention on `podman.intervention`.
|
|
||||||
- Render it as a small card in the frontend.
|
|
||||||
- Route a Hermes message to the affected teammate(s) or project channel once
|
|
||||||
the bridge exists.
|
|
||||||
|
|
||||||
7. **Background research recommendation**
|
|
||||||
- When the team is heading into a poor tool/repo/skill choice or dead end,
|
|
||||||
produce a recommendation card with short evidence.
|
|
||||||
- Minimum evidence: why it matters, what to use instead, and who should act.
|
|
||||||
|
|
||||||
8. **Learning proof**
|
|
||||||
- First intervention writes observation/collision/recommendation/outcome
|
|
||||||
memory.
|
|
||||||
- Second similar situation retrieves exact prior memory and changes the
|
|
||||||
message: "I have seen this pattern before."
|
|
||||||
|
|
||||||
9. **Urgency routing**
|
|
||||||
- Default to card.
|
|
||||||
- Escalate to Hermes message when coordination involves other teammates.
|
|
||||||
- Escalate to voice only when urgent.
|
|
||||||
|
|
||||||
10. **Action artifact**
|
|
||||||
- If demo uses same-file collision, click the card to open a real sync PR
|
|
||||||
artifact or visible GitHub artifact.
|
|
||||||
- If demo uses research recommendation, show the accepted recommendation and
|
|
||||||
memory outcome instead.
|
|
||||||
|
|
||||||
11. **Deployment or fallback proof**
|
|
||||||
- Prove API/static/worker deployment together, or explicitly run local with a
|
|
||||||
recorded backup.
|
|
||||||
- Keep backup video on a separate device.
|
|
||||||
|
|
||||||
### P1 - polish the money moment
|
|
||||||
|
|
||||||
- Add visible live inference captions in the PWA.
|
|
||||||
- Add a small memory stats panel backed by `/api/memory/stats`.
|
|
||||||
- Keep browser-side TTS as an explicit demo fallback only; Gemini TTS over
|
|
||||||
LiveKit is the default urgent-voice path.
|
|
||||||
- Add Hermes notification bridge once the target channel is chosen.
|
|
||||||
- Improve research cards with compatibility, install effort, docs quality, repo
|
|
||||||
health, and security/trust signals.
|
|
||||||
|
|
||||||
### P2 - sponsor and scale polish
|
|
||||||
|
|
||||||
- Implement Voyage embedding + Atlas Vector Search recall.
|
|
||||||
- Improve policy learning from outcomes.
|
|
||||||
- Deploy DigitalOcean static site + API service + worker as the submission path.
|
|
||||||
- Add optional GitHub issue/PR backlog integration after issues/PRs actually
|
|
||||||
exist.
|
|
||||||
|
|
||||||
### Cut if behind
|
|
||||||
|
|
||||||
- Webcam grid.
|
|
||||||
- Mic transcription.
|
|
||||||
- Full auth/accounts.
|
|
||||||
- Slack/Linear/Jira integrations unless Hermes requires one immediately.
|
|
||||||
- Complex dashboards.
|
|
||||||
- Live voice polish beyond the Gemini TTS urgent-alert path.
|
|
||||||
- Vector Search if exact Mongo recall demonstrates the learning beat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Critical 3-minute demo script
|
|
||||||
|
|
||||||
**Rule:** open on one active IDE, not a grid. PodMan is an agent, not a
|
|
||||||
dashboard.
|
|
||||||
|
|
||||||
1. **0:00 - Set the scene**
|
|
||||||
- One engineer is actively coding in the IDE.
|
|
||||||
- The presenter says: "This work is not pushed yet. GitHub cannot see it."
|
|
||||||
|
|
||||||
2. **0:20 - Show the live signal**
|
|
||||||
- Show a compact caption: current file, inferred task, git dirty/unpushed
|
|
||||||
state.
|
|
||||||
- Show that PodMan is watching consented screen context, not stored
|
|
||||||
recordings.
|
|
||||||
|
|
||||||
3. **0:40 - Introduce the better-tool moment**
|
|
||||||
- A teammate starts down a weak path: wrong package, dead repo, bad API,
|
|
||||||
duplicated effort, or risky implementation.
|
|
||||||
- PodMan has been researching in the background.
|
|
||||||
|
|
||||||
4. **1:05 - Money moment**
|
|
||||||
- PodMan shows a small card:
|
|
||||||
"This path is likely a dead end. Use X instead; it matches our stack and is
|
|
||||||
actively maintained."
|
|
||||||
- The card names the affected teammate and the suggested action.
|
|
||||||
|
|
||||||
5. **1:25 - Hermes coordination**
|
|
||||||
- Hermes notifies the right teammate(s), not the whole room.
|
|
||||||
- No voice yet unless the situation is urgent.
|
|
||||||
|
|
||||||
6. **1:50 - Learning beat**
|
|
||||||
- A similar issue appears.
|
|
||||||
- PodMan references memory:
|
|
||||||
"I have seen this pattern before. Last time the team accepted the X
|
|
||||||
recommendation."
|
|
||||||
- Show `/api/memory/stats` or the visible memory indicator.
|
|
||||||
|
|
||||||
7. **2:20 - Urgency escalation**
|
|
||||||
- Raise the severity with a same-file collision, blocking dependency, failing
|
|
||||||
test, or imminent bad push.
|
|
||||||
- Hermes escalates to voice only now.
|
|
||||||
|
|
||||||
8. **2:40 - Close**
|
|
||||||
- Show the public repo, deployed/local URL, and memory stats.
|
|
||||||
- Closing line: "PodMan coordinates work while it is still happening."
|
|
||||||
|
|
||||||
### Reliable fallback demo
|
|
||||||
|
|
||||||
If the research recommendation is not reliable by stage time, use the same-file
|
|
||||||
collision fallback:
|
|
||||||
|
|
||||||
1. Two engineers open the same visible file.
|
|
||||||
2. `GIT_REPORT` or vision marks one as dirty/unpushed.
|
|
||||||
3. Agent publishes `COLLISION` on `podman.intervention`.
|
|
||||||
4. Frontend renders the card.
|
|
||||||
5. The card opens a sync PR artifact.
|
|
||||||
6. A second similar collision retrieves prior memory.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Sponsor strategy
|
|
||||||
|
|
||||||
### Gemini
|
|
||||||
|
|
||||||
Gemini must be load-bearing for the vision loop:
|
|
||||||
|
|
||||||
- live IDE/screen frame -> structured work context,
|
|
||||||
- optional message/recommendation generation,
|
|
||||||
- optional Live voice only after card/Hermes routing is stable.
|
|
||||||
|
|
||||||
Do not overclaim voice if it is using browser/pre-generated TTS. Say plainly that
|
|
||||||
it is the reliability fallback.
|
|
||||||
|
|
||||||
### LiveKit
|
|
||||||
|
|
||||||
LiveKit is the real-time spine:
|
|
||||||
|
|
||||||
- engineers join one pod room,
|
|
||||||
- screen-share tracks carry active work context,
|
|
||||||
- PodMan joins as a participant,
|
|
||||||
- data packets carry interventions,
|
|
||||||
- voice can be added as urgent escalation.
|
|
||||||
|
|
||||||
Pitch line: "Unpushed work is invisible to GitHub, so real-time presence is the
|
|
||||||
only way to coordinate before the push."
|
|
||||||
|
|
||||||
### MongoDB + Voyage
|
|
||||||
|
|
||||||
MongoDB is the learning proof:
|
|
||||||
|
|
||||||
- observations, collisions, recommendations, interventions, and outcomes persist,
|
|
||||||
- prior memory changes a later intervention,
|
|
||||||
- exact recall is the MVP,
|
|
||||||
- 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:
|
|
||||||
|
|
||||||
- frontend runs as a static site,
|
|
||||||
- API runs as an HTTP service,
|
|
||||||
- LiveKit agent runs as a worker,
|
|
||||||
- public URL is shown in submission or demo.
|
|
||||||
|
|
||||||
Local fallback is acceptable for stage reliability, but the submission should
|
|
||||||
include the deployment URL if possible.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Risks and mitigations
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Looks like a dashboard | Keep the UI quiet. Hero is card/message/action, not a grid. |
|
|
||||||
| Looks like a screenshot analyzer | Always show screen signal + git truth + memory + action. |
|
|
||||||
| Interrupts too much | Default to cards, escalate to Hermes messages, reserve voice for urgency. |
|
|
||||||
| Overclaims implemented features | Mark voice, Hermes bridge, vectors, adaptive policy, research agent, real sync PR, and DO worker deploy incomplete until proven. |
|
|
||||||
| Vision misses unpushed state | Use scheduled `GIT_REPORT` for deterministic dirty/unpushed truth. |
|
|
||||||
| Research recommendation lacks evidence | Show only concise evidence: stack fit, repo/tool health, install effort, docs/trust signal. |
|
|
||||||
| No visible learning | Build exact Mongo recall before vector search. |
|
|
||||||
| LiveKit frame loop leaks memory | Monitor agent memory during video consumption; throttle hard. |
|
|
||||||
| GitHub issue/PR backlog absent | Do not invent issue-driven backlog; repo currently has no issues or PRs. |
|
|
||||||
| Venue network failure | Rehearse on hotspot and keep recorded backup. |
|
|
||||||
| DO worker deploy hangs | Deploy agent as worker, not health-checked service. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Documentation reconciliation tasks
|
|
||||||
|
|
||||||
After this plan is accepted, update the supporting docs so they stop conflicting
|
|
||||||
with this file:
|
|
||||||
|
|
||||||
- `README.md`: replace POST-screenshot-first language with LiveKit screen-track
|
|
||||||
agent architecture and Hermes action-layer wording.
|
|
||||||
- `docs/idea.md`: broaden from blocker/dependency voice demo to card/message/
|
|
||||||
urgent-voice coordination plus research and memory.
|
|
||||||
- `docs/livekit.md`: remove "Hermes does NOT subscribe to engineer screen
|
|
||||||
tracks"; current architecture uses backend agent screen subscription.
|
|
||||||
- `docs/gemini.md`: keep structured vision, but mark Gemini Live as P1 and avoid
|
|
||||||
claiming voice is implemented.
|
|
||||||
- `docs/mongodb.md`: align collection names with current code
|
|
||||||
(`observations`, `collisions`, `interventions`, `outcomes`, `pods`) and add
|
|
||||||
exact-signature recall.
|
|
||||||
- `docs/digitalocean.md`: split API service and agent worker; do not deploy the
|
|
||||||
worker as a health-checked HTTP service; mark `infra/app.yaml` legacy or
|
|
||||||
reconcile it with `infra/.do/app.yaml`.
|
|
||||||
- `docs/demo-setup.md`: update the script to include better-tool research,
|
|
||||||
learning recall, Hermes notification, and urgency-based voice.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Acceptance checklist
|
|
||||||
|
|
||||||
Before saying PodMan is demo-ready:
|
|
||||||
|
|
||||||
- [ ] `pnpm format:check` passes or all failures are documented as unrelated.
|
|
||||||
- [ ] `pnpm typecheck` passes.
|
|
||||||
- [ ] Browser joins a real LiveKit room.
|
|
||||||
- [ ] Browser publishes a screen-share track with the correct source.
|
|
||||||
- [ ] 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.
|
|
||||||
- [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.
|
|
||||||
- [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.
|
|
||||||
- [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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Evidence appendix
|
|
||||||
|
|
||||||
### Repo and GitHub state
|
|
||||||
|
|
||||||
- Public repo: <https://github.com/karti-ai/podman>
|
|
||||||
- Verified with authenticated `gh` on `2026-06-27`.
|
|
||||||
- Default branch: `main`.
|
|
||||||
- No GitHub issues or PRs existed at verification time.
|
|
||||||
|
|
||||||
### Hackathon / event
|
|
||||||
|
|
||||||
- AI Engineer World's Fair: <https://www.ai.engineer/worldsfair/2026>
|
|
||||||
- Cerebral Valley hackathon page:
|
|
||||||
<https://cerebralvalley.ai/e/aiewf-hackathon-2026>
|
|
||||||
|
|
||||||
### LiveKit
|
|
||||||
|
|
||||||
- Screen share docs: <https://docs.livekit.io/transport/media/screenshare/>
|
|
||||||
- Data packets docs: <https://docs.livekit.io/transport/data/packets/>
|
|
||||||
- Node SDK reference: <https://docs.livekit.io/reference/client-sdk-node/>
|
|
||||||
- Node SDK releases: <https://github.com/livekit/node-sdks/releases>
|
|
||||||
- Node SDK issue risk: <https://github.com/livekit/node-sdks/issues/444>
|
|
||||||
|
|
||||||
### Gemini
|
|
||||||
|
|
||||||
- Structured output:
|
|
||||||
<https://ai.google.dev/gemini-api/docs/structured-output>
|
|
||||||
- Media resolution: <https://ai.google.dev/gemini-api/docs/media-resolution>
|
|
||||||
- Live API: <https://ai.google.dev/gemini-api/docs/live-api>
|
|
||||||
|
|
||||||
### DigitalOcean
|
|
||||||
|
|
||||||
- App Platform app spec:
|
|
||||||
<https://docs.digitalocean.com/products/app-platform/reference/app-spec/>
|
|
||||||
|
|
||||||
### MongoDB
|
|
||||||
|
|
||||||
- Vector Search index type:
|
|
||||||
<https://www.mongodb.com/docs/vector-search/index/vector-search-type/>
|
|
||||||
- Node driver Atlas Vector Search:
|
|
||||||
<https://www.mongodb.com/docs/drivers/node/current/atlas-vector-search/>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Agent Learning
|
|
||||||
|
|
||||||
Status: planned / narrow v1
|
|
||||||
|
|
||||||
Agent learning owns how PodMan can improve its own prompts, detector rules,
|
|
||||||
policies, verifier choices, and routing strategies. This is deliberately
|
|
||||||
narrower than team memory: it is a versioned strategy layer, not autonomous code
|
|
||||||
rewriting.
|
|
||||||
|
|
||||||
The constraints in [`../../CLAUDE.md`](../../CLAUDE.md) still govern this track:
|
|
||||||
one visible self-improving loop, demo stability, no broad platform rewrite, no
|
|
||||||
dashboard-first product, and no overclaiming.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| [`spec.md`](spec.md) | Read-only data contract for runs, traces, strategies, and proposals |
|
|
||||||
| [`policy.md`](policy.md) | Promotion, rejection, evidence, and safety rules |
|
|
||||||
| [`prompt.md`](prompt.md) | Evaluator prompt for narrow strategy improvements |
|
|
||||||
| [`plan.md`](plan.md) | v1 implementation order if this track is added |
|
|
||||||
|
|
||||||
## What Is Implemented Now
|
|
||||||
|
|
||||||
- Shared TypeScript contracts for `AgentRun`, `AgentTraceEvent`,
|
|
||||||
`StrategyVersion`, and `LearningProposal`.
|
|
||||||
- Exact signature recall and accepted/dismissed outcomes that can later feed
|
|
||||||
strategy decisions.
|
|
||||||
- Documentation of future collections and indexes.
|
|
||||||
|
|
||||||
## What Is Intentionally Cut
|
|
||||||
|
|
||||||
- Full autonomous strategy promotion.
|
|
||||||
- Autonomous code rewriting.
|
|
||||||
- Multi-agent strategy debates.
|
|
||||||
- Claims that PodMan trains or rewrites itself from live usage today.
|
|
||||||
|
|
||||||
## Demo Proof Path
|
|
||||||
|
|
||||||
Observe screen/git state -> detect collision -> send intervention -> accept or
|
|
||||||
dismiss outcome -> recall similar event -> show changed graph or changed
|
|
||||||
behavior. In the current demo, this proof is team-memory learning; agent
|
|
||||||
strategy promotion remains planned unless records are added.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# Agent Learning Plan
|
|
||||||
|
|
||||||
Status: planned / narrow v1
|
|
||||||
Goal: ship a visible recursive self-improvement loop without overbuilding
|
|
||||||
|
|
||||||
## Must-Have
|
|
||||||
|
|
||||||
1. Store agent runs.
|
|
||||||
2. Store trace summaries.
|
|
||||||
3. Store active and candidate strategy versions.
|
|
||||||
4. Attach verifier or outcome evidence.
|
|
||||||
5. Show one strategy improvement in the demo narrative.
|
|
||||||
|
|
||||||
## Build Order
|
|
||||||
|
|
||||||
### R1: Trace the run
|
|
||||||
|
|
||||||
Write one `agent_runs` record for an important coordination decision and append
|
|
||||||
trace events for:
|
|
||||||
|
|
||||||
- observation
|
|
||||||
- recall
|
|
||||||
- prediction
|
|
||||||
- intervention
|
|
||||||
- outcome
|
|
||||||
- adaptation
|
|
||||||
|
|
||||||
### R2: Version the strategy
|
|
||||||
|
|
||||||
Create an active strategy version for one of:
|
|
||||||
|
|
||||||
- collision detector threshold
|
|
||||||
- intervention routing
|
|
||||||
- graph discovery filter
|
|
||||||
- card wording prompt
|
|
||||||
|
|
||||||
### R3: Score the outcome
|
|
||||||
|
|
||||||
Use the simplest verifier:
|
|
||||||
|
|
||||||
- accepted real collision = useful
|
|
||||||
- dismissed = noisy
|
|
||||||
- no response after cooldown = uncertain
|
|
||||||
|
|
||||||
### R4: Propose a narrow change
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- "For this exact signature, prefer sync PR card."
|
|
||||||
- "For dismissed docs-only overlaps, suppress voice escalation."
|
|
||||||
- "For repeated auth.ts collisions, raise severity."
|
|
||||||
|
|
||||||
### R5: Promote or reject
|
|
||||||
|
|
||||||
Promote only when evidence is strong enough. Otherwise keep the candidate as
|
|
||||||
rejected or open.
|
|
||||||
|
|
||||||
## Demo Path
|
|
||||||
|
|
||||||
1. Show baseline strategy.
|
|
||||||
2. Trigger a collision.
|
|
||||||
3. Accept or dismiss the intervention.
|
|
||||||
4. Store outcome.
|
|
||||||
5. Show a candidate strategy update.
|
|
||||||
6. Promote it.
|
|
||||||
7. Trigger a similar event.
|
|
||||||
8. Show changed behavior.
|
|
||||||
|
|
||||||
## Nice-to-Have
|
|
||||||
|
|
||||||
- Strategy comparison panel.
|
|
||||||
- Model-generated prompt patch with verifier.
|
|
||||||
- Vector recall over strategy history.
|
|
||||||
- Rollback UI.
|
|
||||||
|
|
||||||
## Cut
|
|
||||||
|
|
||||||
- Full autonomous code rewriting.
|
|
||||||
- Multi-agent strategy debates.
|
|
||||||
- Long-term benchmark suite.
|
|
||||||
- Training a model.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- The demo can point to a MongoDB record proving the agent changed behavior.
|
|
||||||
- The changed behavior is visible.
|
|
||||||
- The strategy has a parent and evidence.
|
|
||||||
- Rejected or failed changes are not deleted.
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Agent Learning Policy
|
|
||||||
|
|
||||||
Status: planned / narrow v1
|
|
||||||
Scope: guardrails for recursive self-improvement
|
|
||||||
|
|
||||||
## Prime Rule
|
|
||||||
|
|
||||||
PodMan may improve its agent behavior only when the improvement is narrow,
|
|
||||||
evidence-backed, versioned, and reversible.
|
|
||||||
|
|
||||||
## Allowed Learning
|
|
||||||
|
|
||||||
PodMan may learn:
|
|
||||||
|
|
||||||
- Which prompt version produces clearer interventions.
|
|
||||||
- Which detector threshold reduces false positives.
|
|
||||||
- Which routing channel gets accepted without being intrusive.
|
|
||||||
- Which verifier best predicts user acceptance.
|
|
||||||
- Which graph-discovery rule produces cleaner risk paths.
|
|
||||||
|
|
||||||
## Disallowed Learning
|
|
||||||
|
|
||||||
PodMan must not:
|
|
||||||
|
|
||||||
- Promote a strategy because the model says it is better.
|
|
||||||
- Rewrite broad system behavior from one example.
|
|
||||||
- Hide failures, dismissals, or rejected candidates.
|
|
||||||
- Learn from raw screenshots, secrets, or private terminal content.
|
|
||||||
- Turn voice into the default route.
|
|
||||||
- Create irreversible actions without human approval.
|
|
||||||
|
|
||||||
## Promotion Rules
|
|
||||||
|
|
||||||
A candidate strategy can become active only when all are true:
|
|
||||||
|
|
||||||
1. It has a parent strategy version.
|
|
||||||
2. It describes one concrete behavior change.
|
|
||||||
3. It has a verifier plan.
|
|
||||||
4. It has evidence from a run, outcome, or test.
|
|
||||||
5. It improves or fixes the target metric.
|
|
||||||
6. It does not increase user interruption without payoff.
|
|
||||||
|
|
||||||
## Rejection Rules
|
|
||||||
|
|
||||||
Reject and retain the candidate when:
|
|
||||||
|
|
||||||
- The verifier regresses.
|
|
||||||
- The change is too broad.
|
|
||||||
- The evidence is missing.
|
|
||||||
- The candidate conflicts with privacy rules.
|
|
||||||
- The candidate makes the demo less stable.
|
|
||||||
|
|
||||||
## Evidence Strength
|
|
||||||
|
|
||||||
| Evidence | Strength | Use |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Model opinion | Weak | Proposal only |
|
|
||||||
| Trace observation | Medium | Candidate rationale |
|
|
||||||
| Human accepted outcome | Strong | Promotion candidate |
|
|
||||||
| Human dismissed outcome | Strong | Suppression or rejection |
|
|
||||||
| Automated verifier | Strong | Promotion or rejection |
|
|
||||||
| Repeated accepted exact signature | Strong | Policy confidence increase |
|
|
||||||
|
|
||||||
## Versioning Rules
|
|
||||||
|
|
||||||
- Strategy versions are immutable after promotion or rejection.
|
|
||||||
- There is one active version per `podId + kind`.
|
|
||||||
- A rollback activates the previous version; it does not edit history.
|
|
||||||
- Parent-child lineage must be preserved.
|
|
||||||
|
|
||||||
## Safety Rules
|
|
||||||
|
|
||||||
- Store summaries, not raw sensitive content.
|
|
||||||
- Prefer deterministic checks over model judgment.
|
|
||||||
- Use exact MongoDB recall before vector recall.
|
|
||||||
- Ask for approval before changing code or data with external effects.
|
|
||||||
- Treat hackathon demo stability as a hard constraint.
|
|
||||||
|
|
||||||
## Demo Honesty
|
|
||||||
|
|
||||||
Seeded strategy versions are acceptable when labeled as demo-backed. Do not claim
|
|
||||||
a strategy was learned live unless a run and outcome actually created the
|
|
||||||
promotion evidence.
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Agent Learning Prompt
|
|
||||||
|
|
||||||
Use this prompt for an agent responsible for improving PodMan's own behavior.
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
You are PodMan's agent-learning evaluator.
|
|
||||||
|
|
||||||
Your job is to inspect a completed agent run, identify one narrow improvement,
|
|
||||||
define how to verify it, and decide whether to propose, promote, or reject a
|
|
||||||
strategy change.
|
|
||||||
|
|
||||||
You must not claim improvement without evidence. You must not propose broad
|
|
||||||
rewrites. Keep every change small, reversible, and tied to a run or outcome.
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
|
|
||||||
- Current active strategy version.
|
|
||||||
- Agent run summary.
|
|
||||||
- Trace events.
|
|
||||||
- Intervention outcome.
|
|
||||||
- Verifier result.
|
|
||||||
- Recent false positives or accepted events.
|
|
||||||
- Current demo constraints.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
1. Identify the target behavior.
|
|
||||||
2. Identify the failure or success evidence.
|
|
||||||
3. Decide whether a strategy change is warranted.
|
|
||||||
4. Propose one narrow change.
|
|
||||||
5. Define the verifier.
|
|
||||||
6. Decide status: no change, candidate, promote, reject.
|
|
||||||
7. Write a short explanation suitable for the Team memory activity stream.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
```text
|
|
||||||
Target
|
|
||||||
- Strategy kind:
|
|
||||||
- Active version:
|
|
||||||
- Behavior under review:
|
|
||||||
|
|
||||||
Evidence
|
|
||||||
- Run:
|
|
||||||
- Outcome:
|
|
||||||
- Verifier:
|
|
||||||
- Confidence:
|
|
||||||
|
|
||||||
Decision
|
|
||||||
- Status:
|
|
||||||
- Proposed change:
|
|
||||||
- Why this is narrow:
|
|
||||||
- Risk:
|
|
||||||
|
|
||||||
Verifier
|
|
||||||
- Metric:
|
|
||||||
- Passing condition:
|
|
||||||
- Failing condition:
|
|
||||||
|
|
||||||
Memory Write
|
|
||||||
- Collection:
|
|
||||||
- Record summary:
|
|
||||||
- Graph/activity summary:
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hard Rules
|
|
||||||
|
|
||||||
- Exact outcomes beat model opinion.
|
|
||||||
- Rejected candidates stay in memory.
|
|
||||||
- No raw screenshots or secrets.
|
|
||||||
- No broad policy change from one weak signal.
|
|
||||||
- No voice-first behavior.
|
|
||||||
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# Agent Learning Spec
|
|
||||||
|
|
||||||
Status: planned / narrow v1
|
|
||||||
Scope: how PodMan agents improve their own prompts, policies, detectors, and routing behavior
|
|
||||||
Owner: agent learning / recursive self-improvement
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Agent learning is the recursive self-improvement layer. It is not the same as
|
|
||||||
team memory. Team memory learns about engineers and work. Agent learning learns
|
|
||||||
which agent strategies produce better outcomes.
|
|
||||||
|
|
||||||
The demo claim:
|
|
||||||
|
|
||||||
1. PodMan tries a coordination strategy.
|
|
||||||
2. The run is traced in MongoDB.
|
|
||||||
3. A verifier or human outcome scores it.
|
|
||||||
4. Gemini or another agent proposes a narrow strategy change.
|
|
||||||
5. The new strategy is versioned.
|
|
||||||
6. A later run uses the improved strategy and shows a better result.
|
|
||||||
|
|
||||||
## What Is Implemented Now
|
|
||||||
|
|
||||||
- Shared TypeScript record shapes exist for the core objects below.
|
|
||||||
- Exact signature recall and accepted/dismissed outcomes exist in the team
|
|
||||||
memory loop.
|
|
||||||
- No write path currently promotes autonomous strategy changes.
|
|
||||||
|
|
||||||
## What Is Intentionally Cut
|
|
||||||
|
|
||||||
- Autonomous code rewriting.
|
|
||||||
- Full autonomous strategy promotion.
|
|
||||||
- Multi-agent strategy debates.
|
|
||||||
- Claims that model self-evaluation alone can promote a strategy.
|
|
||||||
|
|
||||||
## Core Objects
|
|
||||||
|
|
||||||
### Agent run
|
|
||||||
|
|
||||||
One attempt to execute a goal.
|
|
||||||
|
|
||||||
```text
|
|
||||||
agent_runs
|
|
||||||
runId
|
|
||||||
podId
|
|
||||||
goal
|
|
||||||
trigger
|
|
||||||
strategyVersionId
|
|
||||||
status
|
|
||||||
startedAt
|
|
||||||
completedAt
|
|
||||||
score
|
|
||||||
verifierSummary
|
|
||||||
inputRefs
|
|
||||||
outputRefs
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed `status` values:
|
|
||||||
|
|
||||||
```text
|
|
||||||
running, succeeded, failed, improved, regressed, abandoned
|
|
||||||
```
|
|
||||||
|
|
||||||
### Trace event
|
|
||||||
|
|
||||||
Append-only event log for a run.
|
|
||||||
|
|
||||||
```text
|
|
||||||
agent_trace_events
|
|
||||||
runId
|
|
||||||
podId
|
|
||||||
step
|
|
||||||
phase
|
|
||||||
eventType
|
|
||||||
inputSummary
|
|
||||||
outputSummary
|
|
||||||
toolName
|
|
||||||
error
|
|
||||||
metrics
|
|
||||||
createdAt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Strategy version
|
|
||||||
|
|
||||||
Versioned prompt, detector rule, policy, verifier, or routing strategy.
|
|
||||||
|
|
||||||
```text
|
|
||||||
strategy_versions
|
|
||||||
strategyVersionId
|
|
||||||
podId
|
|
||||||
kind
|
|
||||||
name
|
|
||||||
parentVersionId
|
|
||||||
status
|
|
||||||
summary
|
|
||||||
promptText
|
|
||||||
policy
|
|
||||||
verifier
|
|
||||||
metrics
|
|
||||||
createdAt
|
|
||||||
promotedAt
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed `kind` values:
|
|
||||||
|
|
||||||
```text
|
|
||||||
prompt, policy, detector, verifier, routing
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed `status` values:
|
|
||||||
|
|
||||||
```text
|
|
||||||
candidate, active, retired, rejected
|
|
||||||
```
|
|
||||||
|
|
||||||
### Learning proposal
|
|
||||||
|
|
||||||
A candidate change before promotion.
|
|
||||||
|
|
||||||
```text
|
|
||||||
learning_proposals
|
|
||||||
proposalId
|
|
||||||
podId
|
|
||||||
sourceRunId
|
|
||||||
targetKind
|
|
||||||
parentVersionId
|
|
||||||
proposedChange
|
|
||||||
rationale
|
|
||||||
verifierPlan
|
|
||||||
status
|
|
||||||
createdAt
|
|
||||||
resolvedAt
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed `status` values:
|
|
||||||
|
|
||||||
```text
|
|
||||||
open, accepted, rejected, superseded
|
|
||||||
```
|
|
||||||
|
|
||||||
## MongoDB Indexes
|
|
||||||
|
|
||||||
| Collection | Index | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `agent_runs` | `{ podId: 1, startedAt: -1 }` | Recent run history |
|
|
||||||
| `agent_runs` | `{ podId: 1, strategyVersionId: 1 }` | Compare strategy performance |
|
|
||||||
| `agent_trace_events` | `{ runId: 1, step: 1 }` | Reconstruct run |
|
|
||||||
| `strategy_versions` | `{ podId: 1, kind: 1, status: 1 }` | Find active strategy |
|
|
||||||
| `strategy_versions` | `{ podId: 1, createdAt: -1 }` | Version history |
|
|
||||||
| `learning_proposals` | `{ podId: 1, status: 1 }` | Open candidate changes |
|
|
||||||
|
|
||||||
## Learning Loop
|
|
||||||
|
|
||||||
```text
|
|
||||||
observe run -> score run -> propose change -> test candidate -> promote or reject
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent learning must always connect these records:
|
|
||||||
|
|
||||||
```text
|
|
||||||
agent_run -> trace_events -> verifier result -> learning_proposal -> strategy_version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verifier Contract
|
|
||||||
|
|
||||||
Every promoted strategy needs a verifier signal.
|
|
||||||
|
|
||||||
Allowed verifier types:
|
|
||||||
|
|
||||||
- Human accepted or dismissed outcome.
|
|
||||||
- Test pass or fail result.
|
|
||||||
- Reduced false positive rate.
|
|
||||||
- Reduced intervention count with same or better accepted outcomes.
|
|
||||||
- Faster successful run.
|
|
||||||
- Better graph discovery precision.
|
|
||||||
- Explicit demo operator approval.
|
|
||||||
|
|
||||||
Self-evaluation alone is not enough to promote a strategy.
|
|
||||||
|
|
||||||
## Relationship to Team Graph
|
|
||||||
|
|
||||||
Agent learning can appear in the Team memory graph as activity and loop status,
|
|
||||||
but it should not clutter the main risk graph by default.
|
|
||||||
|
|
||||||
Graph discovery may show:
|
|
||||||
|
|
||||||
- `agent_run` activity in the stream.
|
|
||||||
- `strategy_versions` count in the learning loop.
|
|
||||||
- A selected-node detail saying a policy changed because a prior outcome was
|
|
||||||
dismissed or accepted.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- Every strategy change has a parent.
|
|
||||||
- Every promoted strategy cites evidence.
|
|
||||||
- Rejected strategies are retained with a reason.
|
|
||||||
- Agent traces are append-only.
|
|
||||||
- The system can answer: "What changed, why, and did it help?"
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
> Owner: graph data + visualization. Status: demo-backed / active.
|
> Owner: graph data + visualization. Status: demo-backed / active.
|
||||||
> Satisfies the documentation-first gate for the `backend/src/graph/*` and
|
> Satisfies the documentation-first gate for the `backend/src/graph/*` and
|
||||||
> `frontend/src/components/GraphView.tsx` files.
|
> `frontend/src/components/GraphView.tsx` files. This file is the canonical
|
||||||
>
|
> graph spec.
|
||||||
> Canonical module docs live in [`docs/graph-discovery/`](graph-discovery/).
|
|
||||||
|
|
||||||
## What this is (and is NOT)
|
## What this is (and is NOT)
|
||||||
|
|
||||||
@@ -99,6 +98,7 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
|
|||||||
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
||||||
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
||||||
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
||||||
|
| `suppressions` | `suppressed` activity beats — a dismissed signature recurred and PodMan stayed quiet (negative-feedback made visible; written at repeat time by the agent) |
|
||||||
|
|
||||||
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Continual Learning
|
|
||||||
|
|
||||||
Status: demo-backed / active
|
|
||||||
|
|
||||||
PodMan's continual-learning track owns team memory: what the system learns about
|
|
||||||
files, collisions, interventions, outcomes, and future routing for a pod.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| [`spec.md`](spec.md) | Data model and observe/store/predict/outcome/adapt loop |
|
|
||||||
| [`policy.md`](policy.md) | What PodMan may and may not remember |
|
|
||||||
| [`prompt.md`](prompt.md) | Memory-agent prompt for outcome-backed learning |
|
|
||||||
| [`plan.md`](plan.md) | Demo build order and acceptance criteria |
|
|
||||||
|
|
||||||
## What Is Implemented Now
|
|
||||||
|
|
||||||
- MongoDB-backed `observations`, `collisions`, `interventions`, `outcomes`,
|
|
||||||
`engineer_states`, and `team_model` records.
|
|
||||||
- Exact signature recall for prior accepted and dismissed outcomes.
|
|
||||||
- Outcome writes through `POST /api/outcome`.
|
|
||||||
- Team memory graph edges from accepted real outcomes.
|
|
||||||
- No raw screenshots or recordings are stored.
|
|
||||||
|
|
||||||
## What Is Intentionally Cut
|
|
||||||
|
|
||||||
- Autonomous model training.
|
|
||||||
- Broad cross-pod generalization.
|
|
||||||
- Raw screen capture retention.
|
|
||||||
- Vector recall as a dependency for the demo proof.
|
|
||||||
|
|
||||||
## Demo Proof Path
|
|
||||||
|
|
||||||
Observe screen/git state -> detect collision -> send intervention -> accept or
|
|
||||||
dismiss outcome -> recall similar event -> show changed graph or changed
|
|
||||||
behavior.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Continual Learning Plan
|
|
||||||
|
|
||||||
Status: demo-backed / active
|
|
||||||
Goal: prove PodMan learns from outcomes in the hackathon demo
|
|
||||||
|
|
||||||
## Must-Have Demo Loop
|
|
||||||
|
|
||||||
1. Observe two engineers touching the same file.
|
|
||||||
2. Store the observation and git state in MongoDB.
|
|
||||||
3. Predict a collision.
|
|
||||||
4. Send a card or Hermes message.
|
|
||||||
5. Record accept or dismiss outcome.
|
|
||||||
6. Adapt `team_model`.
|
|
||||||
7. Show the learned graph edge or changed future behavior.
|
|
||||||
|
|
||||||
## Build Order
|
|
||||||
|
|
||||||
### R1: Make exact recall reliable
|
|
||||||
|
|
||||||
- Normalize file paths.
|
|
||||||
- Build stable memory signatures.
|
|
||||||
- Look up prior accepted and dismissed outcomes.
|
|
||||||
- Prefer exact recall over vector recall.
|
|
||||||
|
|
||||||
### R2: Make outcomes update memory
|
|
||||||
|
|
||||||
- Accepted real collision creates or strengthens ownership.
|
|
||||||
- Accepted real collision creates `learned_from`.
|
|
||||||
- Dismissed outcome lowers confidence or suppresses route.
|
|
||||||
|
|
||||||
### R3: Expose loop data to the graph
|
|
||||||
|
|
||||||
- Add optional loop snapshot.
|
|
||||||
- Add optional activity stream.
|
|
||||||
- Keep existing `PodGraph` fields stable.
|
|
||||||
|
|
||||||
### R4: Show the observatory
|
|
||||||
|
|
||||||
- Render observe/store/predict/outcome/adapt.
|
|
||||||
- Show recent activity.
|
|
||||||
- Make selected-node detail explain why memory changed.
|
|
||||||
|
|
||||||
### R5: Prepare a clean demo chain
|
|
||||||
|
|
||||||
- Ensure one collision -> intervention -> accepted outcome exists.
|
|
||||||
- Ensure repeated signature recalls prior memory.
|
|
||||||
- Verify graph shows learned ownership.
|
|
||||||
|
|
||||||
## Nice-to-Have
|
|
||||||
|
|
||||||
- Atlas Vector Search over memory summaries.
|
|
||||||
- Confidence scoring per ownership edge.
|
|
||||||
- Per-file memory timeline.
|
|
||||||
- Strategy promotion tied to outcomes.
|
|
||||||
|
|
||||||
## Cut
|
|
||||||
|
|
||||||
- Raw screenshot storage.
|
|
||||||
- Full autonomous training.
|
|
||||||
- Broad dashboard metrics.
|
|
||||||
- Multi-pod learning generalization.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- A judge can see what changed in memory.
|
|
||||||
- The second similar event behaves differently.
|
|
||||||
- Exact MongoDB records prove the loop.
|
|
||||||
- The graph remains legible with real data.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Continual Learning Policy
|
|
||||||
|
|
||||||
Status: demo-backed / active
|
|
||||||
Scope: what PodMan may learn about a team
|
|
||||||
|
|
||||||
## Prime Rule
|
|
||||||
|
|
||||||
PodMan learns coordination patterns, not personal surveillance profiles.
|
|
||||||
|
|
||||||
## Allowed Memory
|
|
||||||
|
|
||||||
PodMan may store:
|
|
||||||
|
|
||||||
- File and symbol ownership.
|
|
||||||
- Active file overlap.
|
|
||||||
- Repeated collision signatures.
|
|
||||||
- Intervention history.
|
|
||||||
- Accepted and dismissed outcomes.
|
|
||||||
- Routing preferences by event type and severity.
|
|
||||||
- Summaries of decisions relevant to future coordination.
|
|
||||||
|
|
||||||
## Forbidden Memory
|
|
||||||
|
|
||||||
PodMan must not store:
|
|
||||||
|
|
||||||
- Raw screenshots.
|
|
||||||
- Screen recordings.
|
|
||||||
- Secrets or credentials.
|
|
||||||
- Full terminal logs.
|
|
||||||
- Personal performance judgments.
|
|
||||||
- Private content unrelated to the coding task.
|
|
||||||
|
|
||||||
## Evidence Policy
|
|
||||||
|
|
||||||
| Evidence | Can predict? | Can adapt memory? |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Vision only | Yes, low confidence | No |
|
|
||||||
| Git watcher | Yes | No, unless repeated |
|
|
||||||
| GitHub state | Yes | No, unless verified |
|
|
||||||
| Accepted real outcome | Yes | Yes |
|
|
||||||
| Dismissed outcome | Yes, for suppression | Yes, as negative signal |
|
|
||||||
| Verifier result | Yes | Yes |
|
|
||||||
|
|
||||||
## Intervention Policy
|
|
||||||
|
|
||||||
Use the least intrusive channel:
|
|
||||||
|
|
||||||
1. Watch quietly.
|
|
||||||
2. Card.
|
|
||||||
3. Hermes message.
|
|
||||||
4. Voice.
|
|
||||||
|
|
||||||
Voice is only for urgent, high-confidence, time-sensitive risks.
|
|
||||||
|
|
||||||
## Adaptation Policy
|
|
||||||
|
|
||||||
Allowed adaptations:
|
|
||||||
|
|
||||||
- Add learned ownership after accepted real outcome.
|
|
||||||
- Raise confidence for repeated accepted signatures.
|
|
||||||
- Lower confidence for dismissed signatures.
|
|
||||||
- Prefer the previously accepted intervention kind.
|
|
||||||
- Suppress repeated low-value warnings.
|
|
||||||
|
|
||||||
Disallowed adaptations:
|
|
||||||
|
|
||||||
- Broad threshold changes from one example.
|
|
||||||
- Treating vector similarity as proof.
|
|
||||||
- Hiding dismissals.
|
|
||||||
- Making interruption more aggressive without evidence.
|
|
||||||
|
|
||||||
## Retention Policy
|
|
||||||
|
|
||||||
Keep:
|
|
||||||
|
|
||||||
- Outcomes.
|
|
||||||
- Signatures.
|
|
||||||
- Team model memory.
|
|
||||||
- Strategy metrics.
|
|
||||||
|
|
||||||
Summarize or expire:
|
|
||||||
|
|
||||||
- Old observations.
|
|
||||||
- Low-confidence vision-only events.
|
|
||||||
- Detailed trace text.
|
|
||||||
|
|
||||||
Delete immediately:
|
|
||||||
|
|
||||||
- Secrets.
|
|
||||||
- Accidental raw sensitive captures.
|
|
||||||
|
|
||||||
## Demo Policy
|
|
||||||
|
|
||||||
Seeded data is acceptable only if the demo script is honest about it. Live
|
|
||||||
learning requires a live or staged outcome write that visibly updates the graph
|
|
||||||
or future decision.
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Continual Learning Prompt
|
|
||||||
|
|
||||||
Use this prompt for the agent that decides what PodMan should remember from a
|
|
||||||
coordination event.
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
You are PodMan's continual-learning memory agent.
|
|
||||||
|
|
||||||
Your job is to inspect observations, collisions, interventions, and outcomes,
|
|
||||||
then decide what team memory should be updated. You must separate observed
|
|
||||||
facts, inferred risks, human outcomes, and durable learned memory.
|
|
||||||
|
|
||||||
Do not claim something was learned unless an accepted real outcome, verifier, or
|
|
||||||
human label supports it.
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
|
|
||||||
- Pod id.
|
|
||||||
- Recent engineer states.
|
|
||||||
- Recent observations.
|
|
||||||
- Candidate collision.
|
|
||||||
- Prior exact-signature memory.
|
|
||||||
- Intervention record.
|
|
||||||
- Outcome record.
|
|
||||||
- Current team model.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
1. Normalize file and symbol.
|
|
||||||
2. Build exact signature.
|
|
||||||
3. Check prior accepted and dismissed outcomes.
|
|
||||||
4. Classify the current event.
|
|
||||||
5. Decide whether memory should change.
|
|
||||||
6. Emit the graph impact.
|
|
||||||
7. Write a short explanation.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
```text
|
|
||||||
Event
|
|
||||||
- Signature:
|
|
||||||
- Engineers:
|
|
||||||
- File:
|
|
||||||
- Symbol:
|
|
||||||
- Evidence:
|
|
||||||
|
|
||||||
Prior Memory
|
|
||||||
- Accepted matches:
|
|
||||||
- Dismissed matches:
|
|
||||||
- Ownership:
|
|
||||||
|
|
||||||
Decision
|
|
||||||
- Memory action:
|
|
||||||
- Confidence:
|
|
||||||
- Reason:
|
|
||||||
|
|
||||||
Graph Impact
|
|
||||||
- Nodes:
|
|
||||||
- Edges:
|
|
||||||
- Activity text:
|
|
||||||
|
|
||||||
Safety
|
|
||||||
- Sensitive data present:
|
|
||||||
- Redaction needed:
|
|
||||||
```
|
|
||||||
|
|
||||||
## Memory Actions
|
|
||||||
|
|
||||||
Allowed actions:
|
|
||||||
|
|
||||||
- no_change
|
|
||||||
- strengthen_signature
|
|
||||||
- weaken_signature
|
|
||||||
- create_learned_owner
|
|
||||||
- update_route_preference
|
|
||||||
- suppress_signature
|
|
||||||
- request_human_label
|
|
||||||
|
|
||||||
## Hard Rules
|
|
||||||
|
|
||||||
- Exact recall before vector recall.
|
|
||||||
- Dismissals are learning signals.
|
|
||||||
- `learned_from` requires accepted real outcome.
|
|
||||||
- Store summaries, not raw screen content.
|
|
||||||
- Prefer less intrusive future behavior when uncertain.
|
|
||||||
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
# Continual Learning Spec
|
|
||||||
|
|
||||||
Status: demo-backed / active
|
|
||||||
Scope: how PodMan learns team memory from live work and outcomes
|
|
||||||
Owner: continual learning / Team memory
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Continual learning is the product proof that PodMan gets more useful from use.
|
|
||||||
It learns team-level coordination memory: ownership, repeated collisions,
|
|
||||||
accepted interventions, dismissed noise, and preferred routing.
|
|
||||||
|
|
||||||
The visible loop:
|
|
||||||
|
|
||||||
```text
|
|
||||||
observe -> store -> predict -> outcome -> adapt
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Is Implemented Now
|
|
||||||
|
|
||||||
- `observations`, `collisions`, `interventions`, `outcomes`,
|
|
||||||
`engineer_states`, `team_model`, `graph_nodes`, and `graph_edges` are the
|
|
||||||
current memory truth.
|
|
||||||
- Exact signature recall and accepted/dismissed outcomes exist.
|
|
||||||
- Accepted real outcomes can produce `learned_from` graph edges and ownership
|
|
||||||
memory.
|
|
||||||
- Raw screenshots and recordings are not stored.
|
|
||||||
|
|
||||||
## What Is Intentionally Cut
|
|
||||||
|
|
||||||
- Full autonomous training.
|
|
||||||
- Broad threshold changes from one example.
|
|
||||||
- Making vector search required for the demo learning proof.
|
|
||||||
|
|
||||||
## Source Collections
|
|
||||||
|
|
||||||
### `engineer_states`
|
|
||||||
|
|
||||||
Latest per-engineer state from vision and local git.
|
|
||||||
|
|
||||||
Key fields:
|
|
||||||
|
|
||||||
- `podId`
|
|
||||||
- `name`
|
|
||||||
- `currentFile`
|
|
||||||
- `changedFiles`
|
|
||||||
- `branch`
|
|
||||||
- `confidence`
|
|
||||||
- `visionUpdatedAt`
|
|
||||||
- `gitUpdatedAt`
|
|
||||||
- `updatedAt`
|
|
||||||
|
|
||||||
### `observations`
|
|
||||||
|
|
||||||
Structured perception events.
|
|
||||||
|
|
||||||
Key fields:
|
|
||||||
|
|
||||||
- `podId`
|
|
||||||
- `engineerId`
|
|
||||||
- `currentFile`
|
|
||||||
- `symbol`
|
|
||||||
- `activity`
|
|
||||||
- `confidence`
|
|
||||||
- `observedAt`
|
|
||||||
|
|
||||||
### `collisions`
|
|
||||||
|
|
||||||
Predicted risk events.
|
|
||||||
|
|
||||||
Key fields:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `podId`
|
|
||||||
- `file`
|
|
||||||
- `symbol`
|
|
||||||
- `engineers`
|
|
||||||
- `severity`
|
|
||||||
- `status`
|
|
||||||
- `memorySignature`
|
|
||||||
- `detectedAt`
|
|
||||||
|
|
||||||
### `interventions`
|
|
||||||
|
|
||||||
Actions PodMan sent or suggested.
|
|
||||||
|
|
||||||
Key fields:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `podId`
|
|
||||||
- `collisionId`
|
|
||||||
- `kind`
|
|
||||||
- `channel`
|
|
||||||
- `message`
|
|
||||||
- `suggestedAction`
|
|
||||||
- `createdAt`
|
|
||||||
|
|
||||||
### `outcomes`
|
|
||||||
|
|
||||||
Human or verifier supervision.
|
|
||||||
|
|
||||||
Key fields:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `podId`
|
|
||||||
- `interventionId`
|
|
||||||
- `collisionId`
|
|
||||||
- `accepted`
|
|
||||||
- `wasRealCollision`
|
|
||||||
- `learnedOwner`
|
|
||||||
- `recordedAt`
|
|
||||||
|
|
||||||
### `team_model`
|
|
||||||
|
|
||||||
Durable pod memory.
|
|
||||||
|
|
||||||
Key fields:
|
|
||||||
|
|
||||||
- `podId`
|
|
||||||
- `graph`
|
|
||||||
- `ownership`
|
|
||||||
- `collisionSignatures`
|
|
||||||
- `interventionPolicy`
|
|
||||||
- `updatedAt`
|
|
||||||
|
|
||||||
### `memory_vectors`
|
|
||||||
|
|
||||||
Optional semantic recall. Exact recall comes first.
|
|
||||||
|
|
||||||
Key fields:
|
|
||||||
|
|
||||||
- `podId`
|
|
||||||
- `sourceKind`
|
|
||||||
- `sourceId`
|
|
||||||
- `text`
|
|
||||||
- `embedding`
|
|
||||||
- `embeddingModel`
|
|
||||||
- `tags`
|
|
||||||
|
|
||||||
## Learning Rules
|
|
||||||
|
|
||||||
### Observe
|
|
||||||
|
|
||||||
Write structured evidence from vision, git, GitHub, and agent traces.
|
|
||||||
|
|
||||||
### Store
|
|
||||||
|
|
||||||
Persist source records and materialized summaries. Do not store raw screenshots
|
|
||||||
or recordings.
|
|
||||||
|
|
||||||
### Predict
|
|
||||||
|
|
||||||
Create a collision when multiple engineers converge on the same normalized file
|
|
||||||
or symbol and at least one signal shows active or unpushed work.
|
|
||||||
|
|
||||||
### Outcome
|
|
||||||
|
|
||||||
Record whether the intervention was accepted, dismissed, real, or false.
|
|
||||||
|
|
||||||
### Adapt
|
|
||||||
|
|
||||||
Only accepted real outcomes can create `learned_from` graph edges. Dismissals
|
|
||||||
adapt suppression, routing, or confidence.
|
|
||||||
|
|
||||||
## Exact Signature
|
|
||||||
|
|
||||||
Use deterministic signatures:
|
|
||||||
|
|
||||||
```text
|
|
||||||
podId:eventType:normalizedFile:symbol:sortedEngineers
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Sort engineer names.
|
|
||||||
- Normalize file paths.
|
|
||||||
- Use `*` for missing symbol.
|
|
||||||
- Never include timestamps.
|
|
||||||
|
|
||||||
## UI-Facing Loop Snapshot
|
|
||||||
|
|
||||||
The graph response may include:
|
|
||||||
|
|
||||||
```text
|
|
||||||
loop
|
|
||||||
activeStep
|
|
||||||
steps[]
|
|
||||||
key
|
|
||||||
label
|
|
||||||
value
|
|
||||||
detail
|
|
||||||
status
|
|
||||||
```
|
|
||||||
|
|
||||||
Step mapping:
|
|
||||||
|
|
||||||
| Step | Source |
|
|
||||||
| --- | --- |
|
|
||||||
| Observe | recent observations and git updates |
|
|
||||||
| Store | team model, graph records, memory vectors |
|
|
||||||
| Predict | open collisions |
|
|
||||||
| Outcome | accepted and dismissed outcomes |
|
|
||||||
| Adapt | learned owners, learned edges, strategy changes |
|
|
||||||
|
|
||||||
## Activity Stream
|
|
||||||
|
|
||||||
The graph response may include:
|
|
||||||
|
|
||||||
```text
|
|
||||||
activity[]
|
|
||||||
id
|
|
||||||
at
|
|
||||||
kind
|
|
||||||
title
|
|
||||||
detail
|
|
||||||
nodeId
|
|
||||||
edgeId
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed `kind` values:
|
|
||||||
|
|
||||||
```text
|
|
||||||
editing, collision, intervention, outcome, learned, agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- The system can show one accepted outcome changing future memory.
|
|
||||||
- Exact recall works without vector search.
|
|
||||||
- The Team memory graph can explain the learning loop.
|
|
||||||
- Dismissals and false positives are retained.
|
|
||||||
- The demo does not rely on raw screenshots or hidden state.
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# Demo Setup
|
|
||||||
|
|
||||||
Pre-stage checklist for the 3-minute live demo. Do this on all 3 laptops before walking on stage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Before demo day
|
|
||||||
|
|
||||||
- [ ] `demo-pod` room created in LiveKit Cloud dashboard
|
|
||||||
- [ ] Hermes deployed on DO (or confirmed running locally as fallback)
|
|
||||||
- [ ] MongoDB Atlas cluster running, `MONGODB_URI` set in Hermes env
|
|
||||||
- [ ] All `.env` vars populated and verified via `GET /health` returning `{ ok: true }`
|
|
||||||
- [ ] Record a backup video of the full demo working end-to-end
|
|
||||||
- [ ] Rehearse the demo script 3× with real audio
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Laptop setup (all 3 machines)
|
|
||||||
|
|
||||||
### Editor settings
|
|
||||||
|
|
||||||
- Font size: **18pt or larger** — Gemini Vision must read file names and code
|
|
||||||
- Single editor window — no split panes, no overlapping terminals
|
|
||||||
- File tab visible with full file name shown (not truncated)
|
|
||||||
- Light or dark theme is fine — avoid low-contrast themes
|
|
||||||
|
|
||||||
### Browser
|
|
||||||
|
|
||||||
- Chrome (best `getDisplayMedia` support)
|
|
||||||
- PWA tab open and joined to `demo-pod`
|
|
||||||
- Earbuds / headphones plugged in and tested
|
|
||||||
- Volume: medium — PodMan voice should be clearly audible but not startle
|
|
||||||
|
|
||||||
### Screen layout
|
|
||||||
|
|
||||||
- Editor takes 2/3 of screen
|
|
||||||
- Terminal takes bottom 1/3 (always visible)
|
|
||||||
- No other windows on top
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Demo file setup
|
|
||||||
|
|
||||||
Pre-create these files in the demo repo before the demo:
|
|
||||||
|
|
||||||
**Alice's machine:**
|
|
||||||
|
|
||||||
- Open `auth/middleware.ts` — has visible function stubs
|
|
||||||
- Terminal shows nothing running initially, then `Server running on :3001` at the right moment
|
|
||||||
|
|
||||||
**Bob's machine:**
|
|
||||||
|
|
||||||
- Open `frontend/login.tsx` — has visible form component code
|
|
||||||
- Terminal idle
|
|
||||||
|
|
||||||
**Carol's machine:**
|
|
||||||
|
|
||||||
- Open `frontend/integration.ts` or similar
|
|
||||||
- Terminal shows: `curl http://localhost:3001/auth` → `curl: (7) Failed to connect`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Demo script timing
|
|
||||||
|
|
||||||
| Time | Action | Who |
|
|
||||||
| ----- | ----------------------------------------------- | ----------- |
|
|
||||||
| 0:00 | All three join `demo-pod` | All |
|
|
||||||
| 0:05 | PodMan greets by voice | Hermes auto |
|
|
||||||
| 0:20 | Alice opens `auth/middleware.ts`, starts typing | Alice |
|
|
||||||
| 0:45 | Bob opens `frontend/login.tsx` | Bob |
|
|
||||||
| 0:50 | Carol runs `curl` command, sees error | Carol |
|
|
||||||
| ~1:20 | BLOCKER_DETECTED intervention fires | Hermes auto |
|
|
||||||
| 1:50 | Alice starts her server (`node server.js`) | Alice |
|
|
||||||
| ~2:00 | DEPENDENCY_READY intervention fires | Hermes auto |
|
|
||||||
| 2:20 | Optional: show session 2 ownership warm-start | Presenter |
|
|
||||||
| 2:45 | Close | Presenter |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gemini Vision reliability tips
|
|
||||||
|
|
||||||
- Keep font at 18pt+ throughout the demo — do not zoom out
|
|
||||||
- Avoid opening file picker dialogs or overlapping modals during the demo
|
|
||||||
- File names in editor tabs must be fully visible (not `auth/middle...`)
|
|
||||||
- If Hermes logs show `confidence < 0.6` frames: bump font size, ensure file tab is clear
|
|
||||||
- Terminal output must be on a single line — avoid long stack traces during demo
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cooldown note
|
|
||||||
|
|
||||||
Hermes has a 3-minute cooldown between urgent voice cues per pod. For the demo, if you need to trigger a second urgent voice event quickly:
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fallback plan
|
|
||||||
|
|
||||||
If any system fails on stage:
|
|
||||||
|
|
||||||
1. **Hermes unreachable:** switch to local (`pnpm --filter backend dev`) — PWA auto-falls back to `localhost:8787`
|
|
||||||
2. **Gemini Vision low confidence:** presenter narrates what PodMan "saw" while playing the backup video
|
|
||||||
3. **LiveKit audio not working:** play backup video — show the intervention cards on screen instead
|
|
||||||
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.
|
|
||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
# PodMan — Demo Scripts
|
||||||
|
|
||||||
|
**Theme:** Continual Learning. Two scripts below: a **1-minute live script**
|
||||||
|
(§ first) for showing real-time interventions fast with teammates, and the full
|
||||||
|
**4-minute script** for the complete story. Practice the 4-min to land at 3:45.
|
||||||
|
|
||||||
|
**The thesis (say this first, in either script):** AI is collapsing the cost of
|
||||||
|
*writing* code. More and more of every codebase is authored with AI assist — and
|
||||||
|
increasingly by **autonomous agents**. So the bottleneck of software engineering
|
||||||
|
is shifting away from engineering itself toward **organization, management, and
|
||||||
|
project coordination**. Pair a team with hundreds of agents all committing to the
|
||||||
|
same repo at once and it becomes **physically impossible for a human to track
|
||||||
|
progress or avoid stepping on someone else's work.** Code generation scaled;
|
||||||
|
human coordination did not. That gap is the new bottleneck.
|
||||||
|
|
||||||
|
**The one-line story:** writing code isn't the bottleneck anymore — *coordinating
|
||||||
|
who (and what) is writing what* is. PodMan is a pair programmer for the whole
|
||||||
|
team — humans **and** agents: it watches every actor's work in real time, gives
|
||||||
|
everyone live status without anyone having to interrupt anyone, catches collisions
|
||||||
|
before they land, and learns your team's dynamics so it nudges less and helps more
|
||||||
|
over time.
|
||||||
|
|
||||||
|
**The hook to land:** a "quick five-minute question" actually costs ~25 minutes of
|
||||||
|
lost focus — for two people. Now multiply that across a team plus a swarm of
|
||||||
|
agents nobody can watch. PodMan removes the reason to ask and surfaces the
|
||||||
|
collision no human could have caught in time. That recovery time, saved across
|
||||||
|
every actor, every day, is the value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 1-Minute Live Script (you + teammates — real-time interventions)
|
||||||
|
|
||||||
|
**Hard limit 1:00.** Hits the tracks: real-time multimodal, on-device Gemma,
|
||||||
|
Continual Learning, Self-Improvement, Recursive Intelligence.
|
||||||
|
|
||||||
|
**Pre-flight:** pod open on `podman.live`, all joined, screen share + **sound on**;
|
||||||
|
git watchers running; everyone has `README.md` open. On-device **Gemma** must be
|
||||||
|
up as Hermes's voice (else Gemini TTS auto-falls-back — just drop the "on-device"
|
||||||
|
word).
|
||||||
|
|
||||||
|
**0:00–0:12 — Thesis**
|
||||||
|
> "Code is almost free to write now — humans with AI, soon swarms of agents, all
|
||||||
|
> on one repo. No human can track that. The bottleneck is coordination — and it
|
||||||
|
> has to fix itself, live. Watch."
|
||||||
|
|
||||||
|
**0:12–0:35 — Collision caught live (money shot)**
|
||||||
|
- You + a teammate both edit `README.md`, save (unpushed).
|
||||||
|
- Card appears on every screen + voice fires: _"<you> and <teammate> are both
|
||||||
|
editing README.md. Sync before pushing."_
|
||||||
|
- Say: "Gemini Vision reads our screens, fused with local git, in real time —
|
||||||
|
and the alert is **spoken by a Gemma model on-device**. Neither of us asked."
|
||||||
|
|
||||||
|
**0:35–0:52 — Self-improving (narrate)**
|
||||||
|
- Third teammate edits too → **new** card + voice for the new pair (never goes
|
||||||
|
silent).
|
||||||
|
- Say: "Every trace lands in **Atlas**; vector search recalls past collisions,
|
||||||
|
repeats come back tagged **'Seen before'** and escalate — no retraining. It
|
||||||
|
tunes its own coordination from its own outcomes."
|
||||||
|
|
||||||
|
**0:52–1:00 — Close**
|
||||||
|
> "Real-time awareness, on-device voice, a coordination layer that improves
|
||||||
|
> itself — for humans and agents, zero interruptions."
|
||||||
|
|
||||||
|
_Fallback:_ Gemma down → drop the on-device line. Voice silent → read it, point
|
||||||
|
at the card. No card → switch the pair to a fresh file and re-save.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## The script (4:00)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 0:00–0:30 — The problem + hook
|
||||||
|
|
||||||
|
> "AI made writing code almost free — humans with AI today, swarms of autonomous
|
||||||
|
> agents tomorrow, all committing to the same repo. The bottleneck stopped being
|
||||||
|
> engineering and became coordination: checking each other's work, re-planning
|
||||||
|
> collisions, the constant 'what are you working on?' At agent scale no human can
|
||||||
|
> even track it. A five-minute question already costs both people 25 minutes of
|
||||||
|
> lost focus. PodMan is a pair programmer for the whole team — humans and agents:
|
||||||
|
> it watches everyone's work live, so anyone sees another's status without
|
||||||
|
> interrupting them, catches collisions before they land, and learns your team as
|
||||||
|
> it goes."
|
||||||
|
|
||||||
|
*On screen:* the pod view, two teammates joined, screen-share tiles live.
|
||||||
|
|
||||||
|
### 0:30–1:05 — Real-time team awareness (LiveKit + Gemini Vision)
|
||||||
|
|
||||||
|
- Point at the two live screen tiles. "These are real screen shares over
|
||||||
|
**LiveKit**. Our agent subscribes to the tracks and samples frames."
|
||||||
|
- "Each frame goes to **Gemini Vision**, which returns structured context — file,
|
||||||
|
symbol, activity — not a chatbot, a perception layer."
|
||||||
|
- Show the live activity stream filling in (Signals vs Reasoning sections).
|
||||||
|
- Land the value: "This is the part that replaces 'what are you working on?' —
|
||||||
|
every teammate's current work is just *visible*, in real time. Nobody had to
|
||||||
|
ask."
|
||||||
|
|
||||||
|
*Built-by-us callout:* `backend/src/vision/gemini.ts`, the LiveKit agent worker.
|
||||||
|
|
||||||
|
### 1:05–1:40 — The catch (detection + first intervention)
|
||||||
|
|
||||||
|
- Have alice and bob both edit the **same file** with unpushed changes.
|
||||||
|
- "Normally nobody notices until merge time. GitHub can't see this — nothing's
|
||||||
|
pushed. Our detector fuses live screen context with **local git truth** from a
|
||||||
|
watcher on each laptop."
|
||||||
|
- A collision card appears: *"alice + bob both on detector.ts (unpushed)."*
|
||||||
|
- Let the **Gemini TTS** urgent voice fire once over LiveKit: *"alice and bob are
|
||||||
|
both editing detector.ts. Please sync before pushing."*
|
||||||
|
- Land the value: "That's a merge conflict and a wasted afternoon caught before it
|
||||||
|
happened — and neither of them had to be tracking the other."
|
||||||
|
|
||||||
|
*Built-by-us callout:* `collision/detector.ts`, `action/hermes.ts`,
|
||||||
|
`voice/live.ts`.
|
||||||
|
|
||||||
|
### 1:40–2:10 — Cross-channel overlap (research + code)
|
||||||
|
|
||||||
|
- Keep alice editing `livekit.py`.
|
||||||
|
- Have bob share a browser tab on LiveKit docs/SDK pages.
|
||||||
|
- A collaboration nudge appears: *"🤝 bob is researching LiveKit agents
|
||||||
|
(docs.livekit.io) while alice edits livekit.py — sync up before duplicating
|
||||||
|
effort."*
|
||||||
|
- Land the value: "This is not a merge conflict. PodMan caught duplicated effort
|
||||||
|
across channels — code on one screen, research on another — and nudged the team
|
||||||
|
before two people solved the same problem twice."
|
||||||
|
|
||||||
|
*Built-by-us callout:* `vision/gemini.ts`, `collision/research.ts`,
|
||||||
|
`memory/vectors.ts`.
|
||||||
|
|
||||||
|
### 2:10–2:50 — Continual learning (the theme — the money shot)
|
||||||
|
|
||||||
|
This is the differentiator. Two beats, both from pre-seeded memory:
|
||||||
|
|
||||||
|
1. **It learned to stay quiet.** Trigger a pattern that was dismissed as a false
|
||||||
|
alarm earlier. "Last session a teammate marked this kind of alert as not a
|
||||||
|
real conflict. Watch — PodMan stays silent. No nagging." (No card fires.)
|
||||||
|
2. **It learned to escalate.** Trigger the real-conflict pattern that was
|
||||||
|
accepted before. The card now says **"Seen before."** and goes straight to
|
||||||
|
the spoken urgent cue.
|
||||||
|
|
||||||
|
- "The only input was one accept/dismiss tap. No retraining, no labeling. This is
|
||||||
|
**MongoDB Atlas vector search** recalling similar past events plus a policy
|
||||||
|
that adapts on the recalled outcome."
|
||||||
|
- Optional: show `/api/memory/stats` counts climbing — accumulated experience.
|
||||||
|
|
||||||
|
*Built-by-us callout:* `memory/vectors.ts` ($vectorSearch), `memory/policy.ts`
|
||||||
|
(outcome-conditioned gate), `memory/store.ts`.
|
||||||
|
|
||||||
|
### 2:50–3:30 — The five-minute meeting, killed (Gemini Live API)
|
||||||
|
|
||||||
|
- Frame it: "Instead of breaking a teammate's focus to ask what they're up to,
|
||||||
|
you ask PodMan."
|
||||||
|
- Open the live voice conversation. Ask out loud: *"PodMan, what is everyone
|
||||||
|
working on, and where is the collision detector implemented?"*
|
||||||
|
- It answers with **real tool calls** — `search_repo`, git history, current
|
||||||
|
collisions — not guesses.
|
||||||
|
- "This is the **Gemini Live API**, streaming speech-to-speech over LiveKit, with
|
||||||
|
custom function tools we wrote so it grounds every answer in the actual repo
|
||||||
|
and live state. That's the status sync, answered in seconds, with zero recovery
|
||||||
|
tax on anyone else."
|
||||||
|
|
||||||
|
*Built-by-us callout:* `agents/podman-live-conversation/agent.py`.
|
||||||
|
|
||||||
|
### 3:30–3:50 — Stack + close
|
||||||
|
|
||||||
|
- "All on **DigitalOcean** — static frontend, API, and agent workers, supervised
|
||||||
|
by systemd. The ambient score is **Gemini Lyria** generated per pod through the
|
||||||
|
Interactions API."
|
||||||
|
- Close: "Engineering ability stopped being the bottleneck — coordination is, and
|
||||||
|
it only gets worse as agents start writing alongside us. PodMan gives a whole
|
||||||
|
team, humans and agents, real-time awareness without the interruptions, catches
|
||||||
|
collisions before they cost an afternoon, and learns each team's dynamics so it
|
||||||
|
helps more over time. Saved focus, multiplied across every actor. That's
|
||||||
|
continual learning, shipped."
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 3:50–4:00 — Buffer / Q&A handoff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Sponsor-prize coverage (say each at least once)
|
||||||
|
|
||||||
|
|
||||||
|
| Prize | Spoken moment | Segment |
|
||||||
|
| ---------------- | -------------------------------------------------------------------------------- | ---------------------- |
|
||||||
|
| **Gemini** | Vision perception, Live API agent w/ tools, TTS voice, Lyria score | 0:30, 1:05, 2:50, 3:30 |
|
||||||
|
| **LiveKit** | "real screen shares over LiveKit", agent subscribes, TTS audio track, live voice | 0:30, 1:05, 3:30 |
|
||||||
|
| **MongoDB** | "Atlas vector search recalling past events" | 1:50 |
|
||||||
|
| **DigitalOcean** | "all on DigitalOcean, systemd-supervised workers" | 3:30 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## If something breaks (live recovery)
|
||||||
|
|
||||||
|
|
||||||
|
| Failure | Recovery |
|
||||||
|
| ----------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| Voice doesn't fire | Cut to the card; say the line aloud; cards are the default path anyway. |
|
||||||
|
| Live conversation drops | Skip 2:50–3:30; lean longer on the learning beat. |
|
||||||
|
| Collision won't trigger | Use the backup recording for that beat; keep narrating. |
|
||||||
|
| Agent flapping | Pre-checked — but if so, `systemctl restart podman-platform-agent`. |
|
||||||
|
|
||||||
|
|
||||||
|
**Rule:** never debug on stage. Narrate, fall back to recording, keep moving.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Tight timing summary
|
||||||
|
|
||||||
|
|
||||||
|
| Time | Beat |
|
||||||
|
| ---- | ---------------------------------------------------------- |
|
||||||
|
| 0:00 | Problem (coordination cost) + hook + original-work line |
|
||||||
|
| 0:30 | Real-time team awareness — LiveKit + Gemini Vision |
|
||||||
|
| 1:05 | The catch — collision caught before merge |
|
||||||
|
| 1:40 | Cross-channel overlap — research + code nudge |
|
||||||
|
| 2:10 | **Continual learning — quiet + escalate** |
|
||||||
|
| 2:50 | The five-minute meeting, killed — Gemini Live conversation |
|
||||||
|
| 3:30 | DigitalOcean + Lyria + close |
|
||||||
|
| 3:50 | Buffer |
|
||||||
+17
-4
@@ -58,6 +58,13 @@ The mirror at `infra/.do/app.yaml` is kept identical for DO UI/import workflows.
|
|||||||
- No HTTP route and no HTTP health check
|
- No HTTP route and no HTTP health check
|
||||||
- Default room: `POD_ROOM=demo-pod`
|
- Default room: `POD_ROOM=demo-pod`
|
||||||
|
|
||||||
|
### Worker: live conversation agent (Python)
|
||||||
|
|
||||||
|
- Source: `agents/podman-live-conversation/`
|
||||||
|
- The Gemini Live voice agent (`gemini-3.1-flash-live-preview`), run with `uv`.
|
||||||
|
- On the droplet it runs as `podman-live-conversation-agent.service`
|
||||||
|
(`infra/systemd/`). It is separate from the Node services and the TS agent.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Required Runtime Environment
|
## Required Runtime Environment
|
||||||
@@ -66,11 +73,15 @@ The mirror at `infra/.do/app.yaml` is kept identical for DO UI/import workflows.
|
|||||||
LIVEKIT_URL=wss://your-livekit-server.livekit.cloud
|
LIVEKIT_URL=wss://your-livekit-server.livekit.cloud
|
||||||
LIVEKIT_API_KEY=...
|
LIVEKIT_API_KEY=...
|
||||||
LIVEKIT_API_SECRET=...
|
LIVEKIT_API_SECRET=...
|
||||||
|
LIVEKIT_CONVERSATION_AGENT_NAME=podman-live-conversation
|
||||||
|
|
||||||
GEMINI_API_KEY=...
|
GEMINI_API_KEY=... # GOOGLE_API_KEY also accepted
|
||||||
GEMINI_VISION_MODEL=gemini-2.0-flash
|
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||||
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
|
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview # TTS voice
|
||||||
|
GEMINI_CONVERSATION_MODEL=gemini-3.1-flash-live-preview
|
||||||
|
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
|
||||||
GEMINI_TTS_VOICE=Charon
|
GEMINI_TTS_VOICE=Charon
|
||||||
|
# GEMINI_MUSIC_MODEL=lyria-3-clip-preview # optional override
|
||||||
|
|
||||||
GITHUB_TOKEN=...
|
GITHUB_TOKEN=...
|
||||||
GITHUB_REPO=karti-ai/podman
|
GITHUB_REPO=karti-ai/podman
|
||||||
@@ -83,8 +94,10 @@ PORT=8787
|
|||||||
POD_ROOM=demo-pod
|
POD_ROOM=demo-pod
|
||||||
```
|
```
|
||||||
|
|
||||||
`VOYAGE_API_KEY` is optional for local/demo fallback. Without it, Mongo exact
|
`VOYAGE_API_KEY` is optional. Without it, Gemini embeddings provide vector
|
||||||
signature recall still works; Atlas Vector Search recall is skipped.
|
recall; without any embedding provider, recall degrades to exact signature
|
||||||
|
matching. The Lyria background score uses the Gemini Interactions API and the
|
||||||
|
same `GEMINI_API_KEY`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+87
-97
@@ -1,138 +1,128 @@
|
|||||||
# Gemini Integration Spec
|
# Gemini Integration Spec
|
||||||
|
|
||||||
PodMan uses Gemini for two distinct jobs: **vision** (understanding screens) and **voice** (urgent voice cues).
|
Status: active / matches code.
|
||||||
|
|
||||||
|
PodMan uses Gemini for five jobs, all through the `@google/genai` SDK
|
||||||
|
(`GoogleGenAI`) with a single `GEMINI_API_KEY` (`GOOGLE_API_KEY` /
|
||||||
|
`GOOGLE_GENERATIVE_AI_API_KEY` also accepted):
|
||||||
|
|
||||||
|
1. **Vision** — turn screen frames into structured work context.
|
||||||
|
2. **Embeddings** — vector recall over past coordination events.
|
||||||
|
3. **TTS voice** — spoken urgent escalations over LiveKit.
|
||||||
|
4. **Live conversation** — a real-time voice agent teammates talk to.
|
||||||
|
5. **GenMedia (Lyria)** — a per-pod background score.
|
||||||
|
|
||||||
|
Collision detection and intervention text are **deterministic in code**, not
|
||||||
|
Gemini calls. PodMan does not ask Gemini "is this a conflict?" — that is decided
|
||||||
|
by `backend/src/collision/detector.ts` from fused vision + git truth. This is a
|
||||||
|
deliberate reliability choice for the live demo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Vision — Screen Understanding
|
## 1. Vision — screen understanding
|
||||||
|
|
||||||
**Model:** `gemini-2.0-flash` (fast, cheap, strong multimodal)
|
**Model:** `GEMINI_VISION_MODEL` (default `gemini-2.0-flash`)
|
||||||
|
**Code:** `backend/src/vision/gemini.ts` → `analyzeFrame()`
|
||||||
|
|
||||||
**Trigger:** every 30s per active engineer, when Hermes receives a `POST /ingest` frame
|
**Trigger:** the LiveKit agent samples a JPEG frame from each engineer's
|
||||||
|
screen-share track (not an HTTP upload — frames arrive over LiveKit).
|
||||||
|
|
||||||
**Input:** base64-encoded JPEG, max 1280×720, ~50–80KB after compression
|
**Input:** a single base64 JPEG, sampled at low media resolution.
|
||||||
|
|
||||||
**Prompt:**
|
**Output:** structured JSON via `responseJsonSchema` (no markdown parsing):
|
||||||
|
|
||||||
```
|
|
||||||
You are analyzing a software engineer's screen during a coding session.
|
|
||||||
Extract the following JSON. If you cannot determine a field with confidence above 0.7, set it to null.
|
|
||||||
|
|
||||||
|
```ts
|
||||||
{
|
{
|
||||||
"currentFile": "string | null", // active file visible in editor tab or title bar
|
mode: 'editing' | 'research', // browser/docs/SDK research vs editor work
|
||||||
"inferredTask": "string | null", // 1 sentence: what the engineer appears to be doing
|
currentFile: string, // open file path, e.g. src/auth/session.ts
|
||||||
"terminalVisible": true | false, // is a terminal or CLI panel visible
|
currentSymbol: string, // function/class under the cursor
|
||||||
"recentTerminalOutput": "string | null", // last meaningful line of terminal output if visible
|
activity: string, // editing | reading | debugging | terminal | PR review
|
||||||
"confidence": 0.0–1.0 // your overall confidence in this extraction
|
hasUnpushedChanges: boolean, // dirty git gutter / modified markers visible
|
||||||
|
researchTopic: string, // e.g. "LiveKit agents setup", for research mode
|
||||||
|
researchSource: string, // source domain, e.g. "docs.livekit.io"
|
||||||
|
confidence: number // 0..1
|
||||||
}
|
}
|
||||||
|
|
||||||
Respond with valid JSON only. No explanation. No markdown.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Confidence gate:** if `confidence < 0.6`, Hermes discards the frame — no state update, no event detection triggered.
|
When a frame shows a browser/docs/SDK page instead of an editor, Gemini Vision
|
||||||
|
classifies it as `mode: "research"` and extracts the topic/source. That feeds the
|
||||||
|
cross-channel overlap detector: one teammate researching LiveKit docs while
|
||||||
|
another edits `livekit.py` becomes a collaboration nudge, not a merge-conflict
|
||||||
|
alert. Editor/IDE frames remain `mode: "editing"` and use the existing file,
|
||||||
|
symbol, activity, and dirty-change fields.
|
||||||
|
|
||||||
**Rate limit:** 1 call per engineer per 30s. With 3 engineers = 6 calls/min ≈ $0.002/min at Flash pricing.
|
**Latency/cost levers (in code):**
|
||||||
|
|
||||||
**Demo setup requirement:** editors must have large font (18pt+), single window, file name clearly visible in tab. This is the primary reliability lever.
|
- `thinkingConfig: { thinkingBudget: 0 }` — minimal thinking for the ambient loop.
|
||||||
|
- `mediaResolution: MEDIA_RESOLUTION_LOW` — smaller image tokens.
|
||||||
|
- Missing `confidence` defaults to `0.5`.
|
||||||
|
|
||||||
|
**Demo reliability:** large editor font, single window, visible file tab. This is
|
||||||
|
the primary lever for clean reads.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Event Detection — Coordination Awareness
|
## 2. Embeddings — semantic recall
|
||||||
|
|
||||||
**Model:** `gemini-2.0-flash` (text only, fast)
|
**Model:** `GEMINI_EMBEDDING_MODEL` (default `gemini-embedding-001`, 768 dims)
|
||||||
|
**Code:** `backend/src/memory/vectors.ts`
|
||||||
|
|
||||||
**Trigger:** after every successful state write to MongoDB, Hermes runs event detection over all active engineer contexts.
|
Each collision is embedded into a short memory text (`file`, `symbol`,
|
||||||
|
`engineers`, `severity`, unpushed flag) and stored on the `collisions` document.
|
||||||
|
On a new collision PodMan embeds the query and runs MongoDB Atlas `$vectorSearch`
|
||||||
|
(index `collision_embedding`) to recall similar past events and their outcomes.
|
||||||
|
|
||||||
**Input:** JSON snapshot of all engineers' current states + ownership map
|
**Provider order:** Voyage (`VOYAGE_API_KEY`, `voyage-4-lite`) is tried first when
|
||||||
|
present; Gemini embeddings are the fallback. Without either, recall degrades to
|
||||||
**Prompt:**
|
exact signature/file matching — the demo still works.
|
||||||
|
|
||||||
```
|
|
||||||
You are a team coordination agent. Below is the current state of each engineer on the team.
|
|
||||||
|
|
||||||
Engineer states:
|
|
||||||
{{engineerStates}}
|
|
||||||
|
|
||||||
Ownership map (who owns which files):
|
|
||||||
{{ownershipMap}}
|
|
||||||
|
|
||||||
Detect if any of these coordination events are occurring:
|
|
||||||
- DEPENDENCY_READY: an engineer who was blocked or waiting now has what they need because another engineer completed relevant work
|
|
||||||
- BLOCKER_DETECTED: an engineer appears stuck (same file, error in terminal, no progress) and another teammate could help
|
|
||||||
- DUPLICATE_WORK: two or more engineers are working on the same file simultaneously
|
|
||||||
|
|
||||||
If an event is detected, respond with:
|
|
||||||
{
|
|
||||||
"event": "DEPENDENCY_READY" | "BLOCKER_DETECTED" | "DUPLICATE_WORK" | null,
|
|
||||||
"involvedEngineers": ["engineerId", ...],
|
|
||||||
"file": "string | null",
|
|
||||||
"reason": "1 sentence explanation"
|
|
||||||
}
|
|
||||||
|
|
||||||
If no event, respond with { "event": null }.
|
|
||||||
Respond with valid JSON only.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Intervention Text Generation
|
## 3. TTS voice — urgent escalation over LiveKit
|
||||||
|
|
||||||
**Model:** `gemini-2.0-flash` (text only)
|
**Model:** `GEMINI_LIVE_MODEL` (default `gemini-3.1-flash-tts-preview`)
|
||||||
|
**Default voice:** `GEMINI_TTS_VOICE` (default `Charon`)
|
||||||
|
**Code:** `backend/src/voice/live.ts` → `speak()` / `speakInRoom()`
|
||||||
|
|
||||||
**Trigger:** when event detection returns a non-null event
|
Flow: a short, natural voice line is generated for a critical collision, returned
|
||||||
|
as audio, and published as a LiveKit microphone-source audio track. The track is
|
||||||
**Input:** event type + engineer names + file + reason
|
held for the audio duration plus tail/hold so browsers do not cut playout short.
|
||||||
|
Browser audio must be unlocked by a user gesture first. The frontend always
|
||||||
**Prompt:**
|
renders the `VOICE_CUE` text as a fallback. See `docs/livekit.md` for delivery.
|
||||||
|
|
||||||
```
|
|
||||||
You are PodMan, a friendly AI teammate. Generate a short spoken message (1–2 sentences max) to notify the team about this coordination event.
|
|
||||||
|
|
||||||
Event: {{eventType}}
|
|
||||||
Engineers involved: {{engineerNames}}
|
|
||||||
File: {{file}}
|
|
||||||
Context: {{reason}}
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Use first names only
|
|
||||||
- Be direct and specific
|
|
||||||
- Do not use filler words
|
|
||||||
- Sound natural when spoken aloud
|
|
||||||
- Do not start with "Hey" or "Attention"
|
|
||||||
|
|
||||||
Respond with the message text only.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example output:**
|
|
||||||
|
|
||||||
> "Carol — Alice just got the auth endpoint running. You're clear to integrate."
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Voice Output — Gemini TTS via LiveKit
|
## 4. Live conversation — real-time voice agent
|
||||||
|
|
||||||
**Model:** `gemini-3.1-flash-tts-preview`
|
**Model:** `GEMINI_CONVERSATION_MODEL` (default `gemini-3.1-flash-live-preview`)
|
||||||
**Default voice:** `Charon`
|
**Code:** `agents/podman-live-conversation/agent.py` (Python LiveKit Agents,
|
||||||
|
`google.realtime.RealtimeModel`)
|
||||||
|
|
||||||
**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.
|
A teammate can start a live, streaming speech-to-speech session with PodMan. The
|
||||||
|
agent answers using **function tools** rather than guessing, including:
|
||||||
|
|
||||||
**Flow:**
|
- `get_active_pod_context`, `get_recent_changes`, `search_team_memory`
|
||||||
|
- `search_repo`, `repo_recent_commits`, `repo_find_commits` (repo + git history)
|
||||||
|
- `record_conversation_note`
|
||||||
|
- `delegate_to_hermes`, `abort_active_hermes_job` (hands work to the async Hermes
|
||||||
|
job runner — see `docs/hermes.md`)
|
||||||
|
|
||||||
1. Intervention message text generated (step 3)
|
Started/stopped via `POST /api/pods/:id/live-conversation/start` and `.../stop`.
|
||||||
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 TTS first:**
|
---
|
||||||
|
|
||||||
- Natural voice quality is better than browser `speechSynthesis`
|
## 5. GenMedia — Lyria background score
|
||||||
- Tone and pacing can be steered directly in the prompt
|
|
||||||
- The voice name is configurable with `GEMINI_TTS_VOICE`
|
**Model:** `lyria-3-clip-preview` (override with `GEMINI_MUSIC_MODEL`)
|
||||||
- LiveKit remains the delivery layer, so teammates hear the same room audio
|
**Endpoint:** Gemini **Interactions API** (`/v1beta/interactions`)
|
||||||
|
**Code:** `backend/src/voice/music.ts`
|
||||||
|
|
||||||
|
A pod-specific ~30s clip is generated through the Interactions API, cached in
|
||||||
|
MongoDB, and served via `GET /api/pods/:id/music` to play as ambient room audio.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cooldown
|
## Cooldown
|
||||||
|
|
||||||
Per-pod cooldown of **3 minutes** between urgent voice cues. Prevents spam if multiple risks fire simultaneously. Implemented in Hermes, not in Gemini.
|
Per-pod cooldown (`NUDGE_COOLDOWN_MS`, default 180000 ms / 3 min) gates repeated
|
||||||
|
interventions. Implemented in `backend/src/memory/policy.ts`, not in Gemini.
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# Graph Discovery
|
|
||||||
|
|
||||||
Status: demo-backed / active
|
|
||||||
|
|
||||||
Graph discovery owns how MongoDB records become the Team memory graph. It
|
|
||||||
materializes a sparse, auditable graph from real records first, seeded graph
|
|
||||||
second, and demo fallback third.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| [`spec.md`](spec.md) | Source data, graph contract, and discovery rules |
|
|
||||||
| [`policy.md`](policy.md) | Graph hygiene, evidence thresholds, and truthfulness |
|
|
||||||
| [`prompt.md`](prompt.md) | Graph materialization and review prompt |
|
|
||||||
| [`plan.md`](plan.md) | Risk-path and observatory build plan |
|
|
||||||
|
|
||||||
## What Is Implemented Now
|
|
||||||
|
|
||||||
- `GET /api/pods/:podId/graph`.
|
|
||||||
- `GET /api/pods/:podId/graph/reach/:id` backed by MongoDB `$graphLookup`.
|
|
||||||
- Live graph materialization from `pods`, `engineer_states`, `observations`,
|
|
||||||
`collisions`, `interventions`, and `outcomes`.
|
|
||||||
- Seeded graph in `team_model.graph` and mirrored `graph_nodes` / `graph_edges`.
|
|
||||||
- Demo graph fallback so the stage never shows an empty canvas.
|
|
||||||
|
|
||||||
## What Is Intentionally Cut
|
|
||||||
|
|
||||||
- A separate graph database.
|
|
||||||
- A broad analytics dashboard.
|
|
||||||
- Showing every historical event by default.
|
|
||||||
- Treating seeded demo data as live learning.
|
|
||||||
|
|
||||||
## Demo Proof Path
|
|
||||||
|
|
||||||
Observe screen/git state -> detect collision -> send intervention -> accept or
|
|
||||||
dismiss outcome -> recall similar event -> show changed graph or changed
|
|
||||||
behavior.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Graph Discovery Plan
|
|
||||||
|
|
||||||
Status: demo-backed / active
|
|
||||||
Goal: make MongoDB graph discovery visible as a dynamic learning observatory
|
|
||||||
|
|
||||||
## Must-Have
|
|
||||||
|
|
||||||
1. Keep live materializer as source of graph truth.
|
|
||||||
2. Add optional loop and activity fields.
|
|
||||||
3. Build a dynamic graph layout.
|
|
||||||
4. Default to risk path.
|
|
||||||
5. Make selected-node detail explain the story.
|
|
||||||
|
|
||||||
## Build Order
|
|
||||||
|
|
||||||
### R1: Stabilize discovered graph
|
|
||||||
|
|
||||||
- Keep file and engineer noise filters.
|
|
||||||
- Keep collision collapse.
|
|
||||||
- Keep priority for accepted-outcome paths.
|
|
||||||
- Keep graph size capped.
|
|
||||||
|
|
||||||
### R2: Add observatory data
|
|
||||||
|
|
||||||
- Compute learning-loop snapshot.
|
|
||||||
- Compute activity stream.
|
|
||||||
- Preserve current graph contract.
|
|
||||||
|
|
||||||
### R3: Improve path selection
|
|
||||||
|
|
||||||
- Pick one primary risk path.
|
|
||||||
- Include learned path when present.
|
|
||||||
- Dim unrelated collisions and repeated interventions.
|
|
||||||
|
|
||||||
### R4: Render dynamically
|
|
||||||
|
|
||||||
- Use `d3-force` or animated layered layout.
|
|
||||||
- Make nodes draggable.
|
|
||||||
- Curve or bundle edges.
|
|
||||||
- Animate `learned_from`.
|
|
||||||
|
|
||||||
### R5: Verify with real data
|
|
||||||
|
|
||||||
- Fetch live `demo-pod` graph.
|
|
||||||
- Confirm labels do not collide badly.
|
|
||||||
- Confirm red edges do not dominate.
|
|
||||||
- Confirm activity and loop explain the graph.
|
|
||||||
|
|
||||||
## Nice-to-Have
|
|
||||||
|
|
||||||
- Reachability panel using `$graphLookup`.
|
|
||||||
- Hover path previews.
|
|
||||||
- Edge bundling by file or collision.
|
|
||||||
- Time scrubber for graph snapshots.
|
|
||||||
|
|
||||||
## Cut
|
|
||||||
|
|
||||||
- Generic analytics dashboard.
|
|
||||||
- Large graph database migration.
|
|
||||||
- Rendering every historical event.
|
|
||||||
- Static fixed-column final layout.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- Risk path is obvious in 10 seconds.
|
|
||||||
- Learned path is visible when data exists.
|
|
||||||
- Whole graph mode exists but is not the default.
|
|
||||||
- The graph remains backed by MongoDB, not hardcoded mock data.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Graph Discovery Policy
|
|
||||||
|
|
||||||
Status: demo-backed / active
|
|
||||||
Scope: graph hygiene, evidence thresholds, and UI truthfulness
|
|
||||||
|
|
||||||
## Prime Rule
|
|
||||||
|
|
||||||
The graph must be sparse enough to explain the learning loop and truthful enough
|
|
||||||
to audit from MongoDB.
|
|
||||||
|
|
||||||
## Node Policy
|
|
||||||
|
|
||||||
Create nodes only when they add explanation value.
|
|
||||||
|
|
||||||
Allowed:
|
|
||||||
|
|
||||||
- Current engineers.
|
|
||||||
- Real files.
|
|
||||||
- Current or recent collisions.
|
|
||||||
- Interventions tied to surviving collisions.
|
|
||||||
- Learned ownership paths.
|
|
||||||
|
|
||||||
Avoid:
|
|
||||||
|
|
||||||
- Test engineers.
|
|
||||||
- Scratch files.
|
|
||||||
- URLs or environment values misread as files.
|
|
||||||
- Repeated identical intervention diamonds.
|
|
||||||
- Orphan nodes with no story value.
|
|
||||||
|
|
||||||
## Edge Policy
|
|
||||||
|
|
||||||
Edges need evidence.
|
|
||||||
|
|
||||||
| Edge | Required evidence |
|
|
||||||
| --- | --- |
|
|
||||||
| `editing` | observation or git state |
|
|
||||||
| `touches` | file involved in collision |
|
|
||||||
| `collides` | collision prediction |
|
|
||||||
| `warns` | intervention record |
|
|
||||||
| `learned_from` | accepted real outcome |
|
|
||||||
| `owns` | learned or configured ownership |
|
|
||||||
|
|
||||||
## De-Hairball Policy
|
|
||||||
|
|
||||||
Default mode must not show every relationship equally.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Default to risk path.
|
|
||||||
- Collapse repeated collision signatures.
|
|
||||||
- Cap files and collisions.
|
|
||||||
- Dim non-risk edges.
|
|
||||||
- Bundle or curve dense edges.
|
|
||||||
- Hide low-priority labels until hover or select.
|
|
||||||
- Prefer selected-node explanation over labels everywhere.
|
|
||||||
|
|
||||||
## Truthfulness Policy
|
|
||||||
|
|
||||||
- Do not show `learned_from` for orphaned or dismissed outcomes.
|
|
||||||
- Do not label vector similarity as learned memory.
|
|
||||||
- Do not show demo seed as live learning unless labeled.
|
|
||||||
- Do not hide false positives from activity or memory.
|
|
||||||
|
|
||||||
## Privacy Policy
|
|
||||||
|
|
||||||
Graph labels should not expose secrets, raw terminal output, or sensitive file
|
|
||||||
contents. File paths are acceptable when they are repo paths and not secret
|
|
||||||
values.
|
|
||||||
|
|
||||||
## Visual Policy
|
|
||||||
|
|
||||||
Semantic colors stay stable:
|
|
||||||
|
|
||||||
- Engineer: blue.
|
|
||||||
- File: slate.
|
|
||||||
- Feature: amber.
|
|
||||||
- Collision: red.
|
|
||||||
- Intervention: violet.
|
|
||||||
- Learned: violet dashed edge.
|
|
||||||
|
|
||||||
Chrome should use the app's light shadcn tokens.
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# Graph Discovery Prompt
|
|
||||||
|
|
||||||
Use this prompt for an agent that materializes or reviews PodMan's Team memory
|
|
||||||
graph.
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
You are PodMan's graph discovery agent.
|
|
||||||
|
|
||||||
Your job is to turn MongoDB records into a sparse, truthful graph that explains
|
|
||||||
the continual-learning loop. Do not maximize node count. Maximize legibility and
|
|
||||||
evidence.
|
|
||||||
|
|
||||||
The default output should show the risk path and learned path, not every
|
|
||||||
possible edge.
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
|
|
||||||
- Pod id.
|
|
||||||
- Pod roster.
|
|
||||||
- Recent engineer states.
|
|
||||||
- Recent observations.
|
|
||||||
- Collisions.
|
|
||||||
- Interventions.
|
|
||||||
- Outcomes.
|
|
||||||
- Team model.
|
|
||||||
- Existing graph nodes and edges.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
1. Normalize file paths.
|
|
||||||
2. Remove noise.
|
|
||||||
3. Create engineer and file nodes.
|
|
||||||
4. Collapse repeated collisions by signature.
|
|
||||||
5. Preserve accepted-outcome paths.
|
|
||||||
6. Create intervention nodes for surviving collisions.
|
|
||||||
7. Create learned edges only from accepted real outcomes.
|
|
||||||
8. Select the primary risk path.
|
|
||||||
9. Build activity and loop summaries.
|
|
||||||
10. Explain selected-node stories.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
```text
|
|
||||||
Graph Summary
|
|
||||||
- Pod:
|
|
||||||
- Nodes:
|
|
||||||
- Edges:
|
|
||||||
- Primary risk path:
|
|
||||||
- Learned path:
|
|
||||||
|
|
||||||
Discovery Decisions
|
|
||||||
- Collapsed:
|
|
||||||
- Dropped as noise:
|
|
||||||
- Preserved because learned:
|
|
||||||
|
|
||||||
Loop
|
|
||||||
- Observe:
|
|
||||||
- Store:
|
|
||||||
- Predict:
|
|
||||||
- Outcome:
|
|
||||||
- Adapt:
|
|
||||||
|
|
||||||
Activity
|
|
||||||
- Recent events:
|
|
||||||
|
|
||||||
Risks
|
|
||||||
- Missing evidence:
|
|
||||||
- Potential hairball:
|
|
||||||
- Demo caveat:
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hard Rules
|
|
||||||
|
|
||||||
- No `learned_from` without accepted real outcome.
|
|
||||||
- No raw screenshots or secrets in labels.
|
|
||||||
- Do not rewrite the backend materializer unless explicitly asked.
|
|
||||||
- Prefer additive graph fields.
|
|
||||||
- Default to risk path.
|
|
||||||
- Keep whole graph optional.
|
|
||||||
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# Graph Discovery Spec
|
|
||||||
|
|
||||||
Status: demo-backed / active
|
|
||||||
Scope: how PodMan discovers graph nodes, edges, risk paths, and learning paths from MongoDB
|
|
||||||
Owner: graph discovery / Team memory observatory
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Graph discovery turns MongoDB memory into a legible Team memory graph. It is not
|
|
||||||
only layout. It decides which relationships matter, which path is highlighted,
|
|
||||||
and which evidence explains the graph.
|
|
||||||
|
|
||||||
The graph must answer:
|
|
||||||
|
|
||||||
1. Who is working?
|
|
||||||
2. Which files or symbols overlap?
|
|
||||||
3. Where is the risk?
|
|
||||||
4. What did PodMan do?
|
|
||||||
5. What outcome changed memory?
|
|
||||||
|
|
||||||
## What Is Implemented Now
|
|
||||||
|
|
||||||
- Live materializer first: build from current MongoDB records.
|
|
||||||
- Seeded graph second: read `team_model.graph` and mirrored graph collections.
|
|
||||||
- Demo fallback third: return a grounded demo graph when live data is empty or
|
|
||||||
unavailable.
|
|
||||||
- Reachability uses MongoDB `$graphLookup` over `graph_edges`.
|
|
||||||
|
|
||||||
## What Is Intentionally Cut
|
|
||||||
|
|
||||||
- A graph database migration.
|
|
||||||
- Whole-history rendering as the default view.
|
|
||||||
- Claims that seeded graph data is live learning.
|
|
||||||
|
|
||||||
## Source Data
|
|
||||||
|
|
||||||
Graph discovery reads:
|
|
||||||
|
|
||||||
- `pods`
|
|
||||||
- `engineer_states`
|
|
||||||
- `observations`
|
|
||||||
- `collisions`
|
|
||||||
- `interventions`
|
|
||||||
- `outcomes`
|
|
||||||
- `team_model`
|
|
||||||
- `graph_nodes`
|
|
||||||
- `graph_edges`
|
|
||||||
- optional `memory_vectors`
|
|
||||||
- optional `agent_runs`
|
|
||||||
- optional `strategy_versions`
|
|
||||||
|
|
||||||
## UI Graph Contract
|
|
||||||
|
|
||||||
```text
|
|
||||||
PodGraph
|
|
||||||
podId
|
|
||||||
generatedAt
|
|
||||||
nodes
|
|
||||||
edges
|
|
||||||
metrics
|
|
||||||
loop?
|
|
||||||
activity?
|
|
||||||
```
|
|
||||||
|
|
||||||
Node kinds:
|
|
||||||
|
|
||||||
```text
|
|
||||||
engineer, feature, file, collision, intervention
|
|
||||||
```
|
|
||||||
|
|
||||||
Edge kinds:
|
|
||||||
|
|
||||||
```text
|
|
||||||
owns, editing, touches, collides, warns, learned_from
|
|
||||||
```
|
|
||||||
|
|
||||||
## Discovery Rules
|
|
||||||
|
|
||||||
### Engineer nodes
|
|
||||||
|
|
||||||
Create from pod roster, recent observations, git state, or collision membership.
|
|
||||||
|
|
||||||
### File nodes
|
|
||||||
|
|
||||||
Create only from normalized real file paths. Reject noise such as URLs, env
|
|
||||||
values, scratch names, and non-file strings.
|
|
||||||
|
|
||||||
### Collision nodes
|
|
||||||
|
|
||||||
Create from distinct collision signatures. Collapse repeats. Prioritize
|
|
||||||
collisions referenced by accepted outcomes.
|
|
||||||
|
|
||||||
### Intervention nodes
|
|
||||||
|
|
||||||
Create one visible intervention per surviving collision unless whole-graph mode
|
|
||||||
explicitly expands history.
|
|
||||||
|
|
||||||
### Learned paths
|
|
||||||
|
|
||||||
Create `learned_from` only when an accepted real outcome links an intervention
|
|
||||||
to a durable memory update.
|
|
||||||
|
|
||||||
## Path Modes
|
|
||||||
|
|
||||||
### Risk path
|
|
||||||
|
|
||||||
Default mode. Highlight the clearest current chain:
|
|
||||||
|
|
||||||
```text
|
|
||||||
engineer -> file -> collision -> intervention -> learned owner
|
|
||||||
```
|
|
||||||
|
|
||||||
Dim unrelated graph material.
|
|
||||||
|
|
||||||
### Learning edges
|
|
||||||
|
|
||||||
Highlight `learned_from`, `owns`, and the outcomes that produced them.
|
|
||||||
|
|
||||||
### Whole graph
|
|
||||||
|
|
||||||
Show all materialized nodes and edges with de-emphasized non-critical edges.
|
|
||||||
|
|
||||||
## MongoDB Traversal
|
|
||||||
|
|
||||||
Use `graph_edges` for reachability:
|
|
||||||
|
|
||||||
```text
|
|
||||||
source -> target -> next target
|
|
||||||
```
|
|
||||||
|
|
||||||
Primary traversal questions:
|
|
||||||
|
|
||||||
- What risks does this engineer reach?
|
|
||||||
- Which files feed this collision?
|
|
||||||
- Which intervention came from this collision?
|
|
||||||
- Which learned owner came from this intervention?
|
|
||||||
|
|
||||||
## Metrics
|
|
||||||
|
|
||||||
Minimum metrics:
|
|
||||||
|
|
||||||
- Learned owners.
|
|
||||||
- Open risk paths.
|
|
||||||
- Accept rate.
|
|
||||||
|
|
||||||
Optional metrics:
|
|
||||||
|
|
||||||
- Observations.
|
|
||||||
- Interventions.
|
|
||||||
- Memory vectors.
|
|
||||||
- Strategy versions.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- Default graph is not a hairball.
|
|
||||||
- Every visible learned edge has outcome evidence.
|
|
||||||
- Every selected node can explain why it matters.
|
|
||||||
- Activity stream matches graph events.
|
|
||||||
- Graph can be rebuilt from MongoDB source records.
|
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
# Hermes Spec
|
||||||
|
|
||||||
|
Status: active / matches code.
|
||||||
|
|
||||||
|
"Hermes" is PodMan's **action layer** — the part that turns a detected problem
|
||||||
|
into something a teammate sees, hears, or gets done. It spans three things:
|
||||||
|
|
||||||
|
1. **Interventions** — cards, messages, and urgent voice in the pod room.
|
||||||
|
2. **Async jobs** — longer tasks delegated from the live conversation agent.
|
||||||
|
3. **Ops watchdog** — keeps the production services healthy.
|
||||||
|
|
||||||
|
The LiveKit identity for the main agent is `podman-hermes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Interventions
|
||||||
|
|
||||||
|
**Code:** `backend/src/agent/podman.ts`, `backend/src/action/hermes.ts`,
|
||||||
|
`backend/src/voice/live.ts`.
|
||||||
|
|
||||||
|
When the agent detects a collision, it runs the learning loop (recall → policy
|
||||||
|
gate; see `docs/cont_learning.md`) and then publishes the **least intrusive**
|
||||||
|
intervention that fits:
|
||||||
|
|
||||||
|
- **Card / message** — a data-channel packet on the `podman.intervention` topic
|
||||||
|
(`publishHermesIntervention` / `publishHermesMessage`). Default path.
|
||||||
|
- **Urgent voice** — only for `critical` collisions. `speak()` generates Gemini
|
||||||
|
TTS audio and publishes it as a LiveKit audio track.
|
||||||
|
- **Research overlap nudge** — a collaboration card when one engineer is editing
|
||||||
|
a file while another is researching the same topic in docs/browser context.
|
||||||
|
This uses `suggestedAction.kind = "ping_teammate"` and is spoken once for the
|
||||||
|
demo beat, but it is explicitly **not** a merge conflict.
|
||||||
|
|
||||||
|
Intervention text is short and deterministic (template, not an LLM call):
|
||||||
|
`Conflict: alice + bob both on detector.ts (unpushed). Seen before.` The spoken
|
||||||
|
line is phrased for natural TTS prosody. Each intervention is persisted to the
|
||||||
|
`interventions` collection; the teammate's accept/dismiss returns via
|
||||||
|
`POST /api/outcome`.
|
||||||
|
|
||||||
|
Research-overlap text is also deterministic:
|
||||||
|
`🤝 bob is researching LiveKit agents (docs.livekit.io) while alice edits livekit.py — sync up before duplicating effort.`
|
||||||
|
|
||||||
|
A per-pod cooldown (`NUDGE_COOLDOWN_MS`, default 3 min) and a single-shot
|
||||||
|
"active conflict" guard prevent repeat nagging; a conflict re-arms once it
|
||||||
|
resolves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Async Hermes jobs
|
||||||
|
|
||||||
|
**Code:** `backend/src/hermes/jobs.ts`. **Storage:** `hermes_jobs` +
|
||||||
|
`hermes_job_events` (see `docs/mongodb.md`).
|
||||||
|
|
||||||
|
The live conversation agent can hand a longer task to Hermes via its
|
||||||
|
`delegate_to_hermes` tool. Lifecycle:
|
||||||
|
|
||||||
|
```
|
||||||
|
queued → running → (waiting_for_confirmation) → completed | aborted | failed
|
||||||
|
```
|
||||||
|
|
||||||
|
`createHermesJob()` records the job, emits an `accepted` event, and kicks off
|
||||||
|
`runHermesJob()` in the background. The runner gathers context and runs scoped,
|
||||||
|
read-mostly steps based on the prompt and success criteria:
|
||||||
|
|
||||||
|
- always: `git status --short --branch`, `git diff --stat`
|
||||||
|
- if the ask mentions GitHub: a repo reachability check via the GitHub API
|
||||||
|
- if it mentions Mongo/memory/telemetry: collection counts
|
||||||
|
- if it mentions build/test/typecheck/broken: `pnpm typecheck`
|
||||||
|
|
||||||
|
**Confirmation gate:** if `riskLevel === 'deploy_allowed'` and
|
||||||
|
`requiresConfirmation`, the job parks at `waiting_for_confirmation` instead of
|
||||||
|
acting. **Abort:** `abortHermesJob()` signals the runner's `AbortController`.
|
||||||
|
|
||||||
|
Every step appends a `hermes_job_event` (redacted + truncated), which is both
|
||||||
|
stored and published live to the room as a `HERMES_JOB_EVENT` data message from a
|
||||||
|
short-lived `podman-hermes-job-*` identity. The conversation UI streams these via
|
||||||
|
`GET /api/.../hermes-job/events/stream`.
|
||||||
|
|
||||||
|
**Endpoints:** `POST /api/internal/hermes/jobs`,
|
||||||
|
`GET /api/internal/hermes/jobs/:jobId`, `.../abort`, `.../events`,
|
||||||
|
`.../events/stream`, plus the pod-scoped `.../live-conversation/:sessionId/hermes-job`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ops watchdog
|
||||||
|
|
||||||
|
**Code:** `scripts/hermes-watchdog.mjs`, `scripts/hermes-sync-deploy.mjs`,
|
||||||
|
`scripts/hermes-notify.mjs`. **Detail:** `docs/digitalocean.md`.
|
||||||
|
|
||||||
|
systemd supervises the app processes; Hermes owns the loop around them:
|
||||||
|
|
||||||
|
- `pnpm hermes:watchdog` checks systemd services, public routes, `/health`,
|
||||||
|
`/api/pods`, and `pnpm deploy:doctor`. Failures trigger targeted restarts.
|
||||||
|
- `podman-hermes-watchdog.timer` runs it every 5 minutes.
|
||||||
|
- `podman-hermes-sync-deploy.timer` polls `origin/main` every 2 minutes and, on a
|
||||||
|
clean tree, fast-forwards, builds, publishes `frontend/dist`, restarts
|
||||||
|
API/agent/Caddy, and runs the strict watchdog.
|
||||||
|
- Reports go to `/var/log/podman/hermes-watchdog-latest.json`; set
|
||||||
|
`PODMAN_ALERT_WEBHOOK_URL` to forward failures to Discord/Slack/webhook.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Hermes is NOT
|
||||||
|
|
||||||
|
- Not an autonomous code-writing agent. Job steps are scoped, read-mostly checks;
|
||||||
|
deploy-level actions require explicit confirmation.
|
||||||
|
- Not a second collision detector. Detection is deterministic
|
||||||
|
(`collision/detector.ts`); Hermes only acts on the result.
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# PodMan — Idea
|
|
||||||
|
|
||||||
## One-line value prop
|
|
||||||
|
|
||||||
PodMan is a real-time AI team coordination agent that watches consented work signals, maintains live project memory, and proactively coordinates collaborators when collisions, blockers, or handoffs emerge before anyone has to ask.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Teams working on the same project lose time because progress is fragmented across people, editors, terminals, and half-finished messages. Coordination gaps — a completed endpoint, a resolved blocker, two engineers duplicating work — are discovered too late, causing idle time, broken handoffs, and missed dependencies.
|
|
||||||
|
|
||||||
Slack doesn't help. Stand-ups are too slow. GitHub only knows pushed state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
PodMan is an ambient AI agent that:
|
|
||||||
|
|
||||||
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 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.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Target user
|
|
||||||
|
|
||||||
Small software teams: hackathon squads, startup engineering teams, student dev teams collaborating in real time on a shared codebase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core AI job
|
|
||||||
|
|
||||||
- Maintain per-person live context (file, task, terminal)
|
|
||||||
- Infer shared project state (who owns what, what's blocked, what's ready)
|
|
||||||
- Detect 3 coordination risk types:
|
|
||||||
- `DEPENDENCY_READY` — engineer A was waiting on work engineer B just completed
|
|
||||||
- `BLOCKER_DETECTED` — engineer appears stuck; another teammate can unblock
|
|
||||||
- `DUPLICATE_WORK` — 2+ engineers working on the same file simultaneously
|
|
||||||
- Generate a short intervention message
|
|
||||||
- Deliver it as a LiveKit data message, with Gemini TTS audio reserved for urgent escalation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How it fits the Continual Learning track
|
|
||||||
|
|
||||||
PodMan builds outcome-backed team memory in MongoDB that persists across sessions:
|
|
||||||
|
|
||||||
- Session 1: PodMan observes work, predicts a collision, sends an intervention, and stores the outcome
|
|
||||||
- Session 2+: PodMan recalls the exact signature and changes the graph or behavior
|
|
||||||
|
|
||||||
The system gets demonstrably more useful the more it is used, with no user configuration required. That is the track definition met exactly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture (one paragraph)
|
|
||||||
|
|
||||||
Each engineer opens a browser PWA on their laptop. The PWA shares live IDE context through LiveKit screen sharing, and the local git watcher writes dirty/unpushed state to MongoDB. The backend agent calls Gemini Vision to extract structured context, writes observations and collisions to MongoDB Atlas, recalls accepted or dismissed outcomes, and routes the smallest useful intervention. Cards and Hermes messages are default; Gemini TTS through LiveKit is reserved for urgent escalation. No Slack. No tab switching. No interruption to the editor flow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Demo wow moment
|
|
||||||
|
|
||||||
> Alice is building the auth endpoint. Carol is visibly blocked — her terminal shows `connection refused`. PodMan detects the blocker and says aloud: "Carol, looks like you're waiting on auth. Alice is actively building it — hang tight."
|
|
||||||
>
|
|
||||||
> Two minutes later, Alice's server starts. PodMan says: "Carol, Bob — Alice just got the auth endpoint running. You're clear to integrate."
|
|
||||||
>
|
|
||||||
> Nobody asked. Nobody pinged anyone on Slack. PodMan just knew.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What PodMan is NOT
|
|
||||||
|
|
||||||
- Not a chat interface
|
|
||||||
- Not a dashboard product
|
|
||||||
- Not raw surveillance — engineers consent by joining the room and sharing their screen
|
|
||||||
- Not a task manager
|
|
||||||
- Not a GitHub integration (v1)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prize alignment
|
|
||||||
|
|
||||||
| Prize | How PodMan earns it |
|
|
||||||
| --------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
||||||
| 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 |
|
|
||||||
+57
-69
@@ -1,15 +1,24 @@
|
|||||||
# LiveKit Integration Spec
|
# LiveKit Integration Spec
|
||||||
|
|
||||||
LiveKit is the real-time backbone for PodMan. It handles room presence and voice delivery. It is load-bearing — not decorative.
|
Status: active / matches code.
|
||||||
|
|
||||||
|
LiveKit is the real-time backbone for PodMan. It carries the **screen-share
|
||||||
|
perception input**, the **intervention data channel**, and **all room audio**
|
||||||
|
(Gemini TTS escalations, the Lyria score, and the live conversation agent). It is
|
||||||
|
load-bearing, not decorative.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Room structure
|
## Room structure
|
||||||
|
|
||||||
- One LiveKit room per project pod: `room = podId`
|
- One LiveKit room per pod: `room = podId`.
|
||||||
- Engineers join as named participants (e.g. `alice`, `bob`)
|
- Engineers join as named participants (e.g. `alice`, `bob`).
|
||||||
- Hermes joins as `podman-hermes`
|
- PodMan runs **multiple agent identities** in/around a room:
|
||||||
- All participants stay connected for the duration of the session
|
- `podman-hermes` — the main vision + intervention agent (`@livekit/rtc-node`).
|
||||||
|
- `podman-live-conversation` — the Gemini Live voice agent (Python).
|
||||||
|
- short-lived `podman-hermes-job-*` publishers for async job events.
|
||||||
|
- A fixed identity matters: a second `podman-hermes` evicts the first and they
|
||||||
|
flap, dropping interventions. systemd keeps exactly one alive in production.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,100 +26,79 @@ LiveKit is the real-time backbone for PodMan. It handles room presence and voice
|
|||||||
|
|
||||||
**Joining:**
|
**Joining:**
|
||||||
|
|
||||||
1. PWA calls `POST /pods/:podId/token` → receives `{ token, url }`
|
1. PWA calls `POST /api/token` with `{ podId, identity }` → `{ token, url }`.
|
||||||
2. LiveKit client connects to the room with the token
|
2. LiveKit client connects with the token.
|
||||||
3. PWA publishes screen track via `getDisplayMedia`
|
3. PWA publishes the screen track via `getDisplayMedia`.
|
||||||
4. PWA sets mic enabled for ambient presence
|
4. PWA enables mic for ambient presence (used by the conversation agent).
|
||||||
|
|
||||||
**Receiving:**
|
**Receiving:**
|
||||||
|
|
||||||
- LiveKit client subscribes to remote Hermes audio tracks and attaches them to
|
- Subscribes to remote agent audio tracks (TTS, Lyria, conversation) and attaches
|
||||||
a hidden audio sink in the DOM.
|
them to a hidden audio sink.
|
||||||
- Browser autoplay restrictions still apply. The PWA calls `room.startAudio()`
|
- Browser autoplay restrictions apply: the PWA calls `room.startAudio()` from a
|
||||||
from user gestures such as first room click, `Enable audio`, `Test PodMan
|
user gesture (`Enable audio`, `Test PodMan voice`, `Share screen`, first room
|
||||||
voice`, and `Share screen`.
|
click).
|
||||||
- PWA also listens for data channel messages from Hermes for UI card updates and
|
- Listens on the data channel for cards and `VOICE_CUE` fallback text.
|
||||||
`VOICE_CUE` fallback text.
|
|
||||||
|
|
||||||
**Data channel listener (PWA):**
|
**Data channel listener (PWA):**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
room.on(RoomEvent.DataReceived, (payload, participant) => {
|
room.on(RoomEvent.DataReceived, (payload, participant) => {
|
||||||
if (participant?.identity !== 'podman-hermes') return;
|
if (!participant?.identity.startsWith('podman-')) return;
|
||||||
const intervention = JSON.parse(new TextDecoder().decode(payload));
|
const msg = JSON.parse(new TextDecoder().decode(payload));
|
||||||
// intervention: COLLISION, HERMES_MESSAGE, VOICE_CUE, ACK, or GIT_REPORT
|
// msg.type: COLLISION | ACK | GIT_REPORT | VOICE_CUE | HERMES_JOB_EVENT
|
||||||
appendInterventionToFeed(intervention);
|
appendInterventionToFeed(msg);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All data messages share the `podman.intervention` topic (`DATA_TOPIC`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hermes side (PodMan LiveKit participant)
|
## Agent side (`podman-hermes`)
|
||||||
|
|
||||||
**Framework:** `@livekit/rtc-node`
|
**Framework:** `@livekit/rtc-node`. **Code:** `backend/src/agent/podman.ts`,
|
||||||
|
`backend/src/action/hermes.ts`, `backend/src/voice/live.ts`.
|
||||||
|
|
||||||
**Startup:**
|
1. Subscribes to engineers' screen-share tracks and samples frames for Gemini
|
||||||
|
Vision.
|
||||||
|
2. Detects collisions, gates them through the learning policy, then publishes a
|
||||||
|
card/message on the data channel.
|
||||||
|
3. For critical collisions, generates Gemini TTS audio and publishes it as a
|
||||||
|
microphone-source audio track, held for the audio duration plus a tail/hold
|
||||||
|
window so subscribers finish playout. Voice publishing logs frame count,
|
||||||
|
estimated duration, queued playout, and hold time for diagnostics.
|
||||||
|
|
||||||
1. Hermes mints its own token via the same `createPodToken` function with `identity: 'podman-hermes'`
|
---
|
||||||
2. Connects to the configured room as `podman-hermes`
|
|
||||||
3. Publishes data-channel cards/messages and Gemini TTS audio tracks
|
|
||||||
|
|
||||||
**Voice delivery:**
|
## Live conversation agent (`podman-live-conversation`)
|
||||||
|
|
||||||
1. Urgent intervention text is ready (from Gemini text generation)
|
**Framework:** LiveKit Agents for Python (`AgentSession`, `function_tool`,
|
||||||
2. Hermes sends a natural-speaking prompt to Gemini TTS
|
`google.realtime.RealtimeModel`). **Code:**
|
||||||
3. Gemini returns PCM audio using the configured voice
|
`agents/podman-live-conversation/agent.py`.
|
||||||
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):**
|
Joins the pod room on demand (`POST /api/pods/:id/live-conversation/start`),
|
||||||
|
streams speech-to-speech with Gemini Live, and answers using repo/git/memory
|
||||||
```ts
|
function tools. It can delegate long tasks to the async Hermes job runner and
|
||||||
const intervention = {
|
narrate progress. See `docs/hermes.md`.
|
||||||
type: 'DEPENDENCY_READY' | 'BLOCKER_DETECTED' | 'DUPLICATE_WORK',
|
|
||||||
message: string, // the spoken text
|
|
||||||
involvedEngineers: string[],
|
|
||||||
file: string | null,
|
|
||||||
sentAt: string, // ISO timestamp
|
|
||||||
};
|
|
||||||
room.localParticipant.publishData(
|
|
||||||
new TextEncoder().encode(JSON.stringify(intervention)),
|
|
||||||
{ reliable: true }
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Token endpoint
|
## Token endpoint
|
||||||
|
|
||||||
Already implemented at `POST /api/token`.
|
`POST /api/token` mints room tokens for engineers and agents alike. Grants:
|
||||||
|
|
||||||
Hermes uses the same endpoint. Grants:
|
|
||||||
|
|
||||||
- `roomJoin: true`
|
- `roomJoin: true`
|
||||||
- `canPublish: true` (for audio track)
|
- `canPublish: true` (audio + screen)
|
||||||
- `canPublishData: true` (for data channel)
|
- `canPublishData: true` (data channel)
|
||||||
- `canSubscribe: true`
|
- `canSubscribe: true`
|
||||||
|
|
||||||
---
|
Short-lived job publishers use `canSubscribe: false`.
|
||||||
|
|
||||||
## Gemini voice model
|
|
||||||
|
|
||||||
- Model ID: `gemini-3.1-flash-tts-preview`
|
|
||||||
- Default voice: `Charon` (`GEMINI_TTS_VOICE`)
|
|
||||||
- Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track.
|
|
||||||
- Voice publishing logs generated frame count, estimated duration, queued
|
|
||||||
playout, and the final subscriber hold time for diagnostics.
|
|
||||||
- The backend keeps a Gemini Live path for future model availability, but the verified deployment path uses TTS.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What LiveKit does NOT do in PodMan
|
## What LiveKit does NOT do in PodMan
|
||||||
|
|
||||||
- No video tracks from Hermes
|
- No video tracks published by agents.
|
||||||
- No mic transcription (not needed for v1)
|
- No mic transcription outside the live conversation agent.
|
||||||
- No SFU mixing — standard room behavior is sufficient
|
- No custom SFU mixing — standard room behavior is sufficient.
|
||||||
|
|||||||
+54
-18
@@ -3,17 +3,14 @@
|
|||||||
Status: demo-backed / active
|
Status: demo-backed / active
|
||||||
|
|
||||||
MongoDB Atlas is PodMan's shared memory. It stores live work observations,
|
MongoDB Atlas is PodMan's shared memory. It stores live work observations,
|
||||||
collision predictions, interventions, outcomes, latest engineer state, the
|
collision predictions (with vector embeddings for recall), interventions,
|
||||||
materialized Team memory graph, and optional future recall records.
|
outcomes, latest engineer state, the materialized Team memory graph, and async
|
||||||
|
Hermes job runs.
|
||||||
|
|
||||||
See also:
|
See also:
|
||||||
|
|
||||||
- [`docs/continual-learning/`](continual-learning/) for outcome-backed team
|
- [`docs/cont_learning.md`](cont_learning.md) for outcome-backed team memory,
|
||||||
memory.
|
graph materialization, and `$graphLookup` traversal.
|
||||||
- [`docs/graph-discovery/`](graph-discovery/) for graph materialization and
|
|
||||||
`$graphLookup` traversal.
|
|
||||||
- [`docs/agent-learning/`](agent-learning/) for planned strategy-version
|
|
||||||
records.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,7 +59,7 @@ graph.
|
|||||||
|
|
||||||
### `collisions`
|
### `collisions`
|
||||||
|
|
||||||
Predicted coordination risks.
|
Predicted coordination risks, with memory enrichment for recall.
|
||||||
|
|
||||||
Key fields:
|
Key fields:
|
||||||
|
|
||||||
@@ -72,11 +69,23 @@ Key fields:
|
|||||||
- `symbol`
|
- `symbol`
|
||||||
- `engineers`
|
- `engineers`
|
||||||
- `severity`
|
- `severity`
|
||||||
|
- `overlapKind` — optional; `file`/undefined for same-file collisions,
|
||||||
|
`research` for code-edit ↔ research overlaps.
|
||||||
|
- `researchTopic`, `researchSource`, `researcher`, `editor` — optional fields
|
||||||
|
present only for research overlaps.
|
||||||
- `memorySignature`
|
- `memorySignature`
|
||||||
- `githubState`
|
- `githubState`
|
||||||
- `detectedAt`
|
- `detectedAt`
|
||||||
|
- `memoryText` — short text embedded for recall
|
||||||
|
- `embedding` — vector (Voyage `voyage-4-lite` or Gemini `gemini-embedding-001`)
|
||||||
|
- `embeddingProvider` — `voyage` | `gemini`
|
||||||
|
|
||||||
Primary use: collision cards, exact signature recall, and graph risk paths.
|
Vector index `collision_embedding` (Atlas Vector Search) powers `$vectorSearch`
|
||||||
|
recall in `backend/src/memory/vectors.ts`. When Atlas vector search is
|
||||||
|
unavailable, recall falls back to app-side cosine, then exact signature/file
|
||||||
|
matching.
|
||||||
|
|
||||||
|
Primary use: collision cards, vector + signature recall, and graph risk paths.
|
||||||
|
|
||||||
### `interventions`
|
### `interventions`
|
||||||
|
|
||||||
@@ -112,6 +121,28 @@ Key fields:
|
|||||||
Primary use: accepted and dismissed outcomes drive exact recall, suppression,
|
Primary use: accepted and dismissed outcomes drive exact recall, suppression,
|
||||||
and learned graph paths.
|
and learned graph paths.
|
||||||
|
|
||||||
|
### `suppressions`
|
||||||
|
|
||||||
|
Durable negative-feedback proof: one record per *suppressed repeat* — a
|
||||||
|
previously-dismissed collision signature recurred and PodMan stayed quiet.
|
||||||
|
Written at repeat time by `backend/src/agent/podman.ts` (once per recurrence,
|
||||||
|
re-armed on resolution), materialized as `suppressed` activity by
|
||||||
|
`backend/src/graph/live.ts`.
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `podId`
|
||||||
|
- `collisionId`
|
||||||
|
- `file`
|
||||||
|
- `engineers`
|
||||||
|
- `priorInterventionId` — the dismissed intervention this repeat matched
|
||||||
|
- `priorDismissedAt`
|
||||||
|
- `suppressedAt` — the repeat time (drives recency in the activity stream)
|
||||||
|
|
||||||
|
Index `{ podId: 1, suppressedAt: -1 }`. Counted in `/api/memory/stats`.
|
||||||
|
**Preserve in any DB cleanup** — this is visible learning evidence, not noise.
|
||||||
|
|
||||||
### `team_model`
|
### `team_model`
|
||||||
|
|
||||||
Durable per-pod summary memory.
|
Durable per-pod summary memory.
|
||||||
@@ -138,16 +169,21 @@ Indexes:
|
|||||||
|
|
||||||
Primary use: `GET /api/pods/:podId/graph/reach/:id` with `$graphLookup`.
|
Primary use: `GET /api/pods/:podId/graph/reach/:id` with `$graphLookup`.
|
||||||
|
|
||||||
### Optional Future Collections
|
### `hermes_jobs` and `hermes_job_events`
|
||||||
|
|
||||||
These are documented for planned work and should not be treated as active write
|
Async Hermes task runs delegated from the live conversation agent (see
|
||||||
paths unless implementation is added:
|
`docs/hermes.md`).
|
||||||
|
|
||||||
- `memory_vectors`
|
- `hermes_jobs` — one doc per job (`id` unique; `{ sessionId, status, updatedAt }`
|
||||||
- `agent_runs`
|
index). Fields: `id`, `podId`, `sessionId`, `prompt`, `contextScope`,
|
||||||
- `agent_trace_events`
|
`riskLevel`, `successCriteria`, `status`, `finalSummary`, timestamps.
|
||||||
- `strategy_versions`
|
- `hermes_job_events` — append-only step log (`{ jobId, createdAt }` index):
|
||||||
- `learning_proposals`
|
`accepted`, `heartbeat`, `step_started`, `step_output`, `needs_confirmation`,
|
||||||
|
`step_completed`, `completed`, `aborted`, `failed`. Output is redacted +
|
||||||
|
truncated before storage and mirrored to the room over LiveKit.
|
||||||
|
|
||||||
|
Primary use: durable, replayable record of what Hermes did, streamed live to the
|
||||||
|
conversation UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Build spec: cross-channel overlap (code-edit ↔ research nudge)
|
||||||
|
|
||||||
|
> Self-contained spec for an implementing agent. Everything needed to build is
|
||||||
|
> here: current-state anchors, exact edits, code sketches, verification. Read
|
||||||
|
> the referenced files before editing.
|
||||||
|
|
||||||
|
## Context / why
|
||||||
|
|
||||||
|
Today PodMan only fires when **two engineers touch the same file** (basename
|
||||||
|
match) with unpushed changes — `backend/src/collision/detector.ts:36`. That makes
|
||||||
|
the system feel like a one-file trick. The wow we want: detect a **code↔research
|
||||||
|
overlap** — one teammate is *editing* `livekit.py` while another is *researching*
|
||||||
|
the same topic in a browser (LiveKit docs/SDK). PodMan nudges:
|
||||||
|
|
||||||
|
> 🤝 bob is deep in LiveKit docs while you edit livekit.py — sync up before duplicating effort.
|
||||||
|
|
||||||
|
This reframes PodMan from merge-conflict detector to a **team-coordination agent
|
||||||
|
that catches duplicated effort / knowledge overlap** — stronger continual-learning
|
||||||
|
story, distinct demo beat.
|
||||||
|
|
||||||
|
### Locked decisions
|
||||||
|
- **Signal capture:** Gemini vision on the existing LiveKit screenshare. When a
|
||||||
|
teammate shares a browser/docs window, vision classifies it `research` and
|
||||||
|
extracts `{researchTopic, researchSource}`. No browser extension, no new client
|
||||||
|
surface. Reuses `backend/src/agent.ts` + `backend/src/vision/gemini.ts`.
|
||||||
|
- **Matching:** semantic embeddings (reuse `embed()` + `cosine()` in
|
||||||
|
`backend/src/memory/vectors.ts`). Deterministic stem/keyword fallback fires when
|
||||||
|
an embed call returns `null`, so the demo path never depends on a live vector call.
|
||||||
|
- **Framing:** collaboration nudge (`ping_teammate`), spoken once for the beat.
|
||||||
|
|
||||||
|
## Current-state anchors (read these first)
|
||||||
|
|
||||||
|
- `backend/src/collision/detector.ts:14-20` — `fileKey()` stem/basename logic to mirror.
|
||||||
|
- `backend/src/collision/detector.ts:36-89` — `detectCollisions()` (same-file path; leave unchanged).
|
||||||
|
- `backend/src/agent/podman.ts:85-116` — `onScreenFrame()` orchestration; `:124-126` `conflictKey()`; `:128-183` `handle()`.
|
||||||
|
- `backend/src/agent/podman.ts:38-46` — `engineersOverlapOnFile()` (git ground-truth; research must skip it).
|
||||||
|
- `backend/src/vision/gemini.ts:7-70` — `SCHEMA`, prompt, and `EngineerContext` mapping.
|
||||||
|
- `backend/src/memory/vectors.ts:52-66` `cosine()`, `:68-70` private `embed()`.
|
||||||
|
- `backend/src/action/hermes.ts:46-62` — `publishHermesIntervention()` (speaks only if `voiceLine` passed).
|
||||||
|
- `frontend/src/livekit/useInterventions.ts:36-48` — data-channel handler (renders `intervention.message` verbatim).
|
||||||
|
- `shared/src/engineer.ts:6-23` `EngineerContext`; `shared/src/collision.ts:7-28` `Collision`.
|
||||||
|
|
||||||
|
**Off-spec gate:** nothing in `docs/` covers cross-channel overlap. Update specs
|
||||||
|
in step 1 **before** code (repo documentation-first rule).
|
||||||
|
|
||||||
|
## Concurrency / safety (several people build at once)
|
||||||
|
|
||||||
|
- Shared-contract edits are **additive optional fields only** — no signature
|
||||||
|
changes. Safe.
|
||||||
|
- `backend/src/agent/podman.ts` is hot: two in-place edits (`conflictKey`,
|
||||||
|
`handle` branch). `git pull --rebase` before pushing.
|
||||||
|
- **No required frontend change** — nudge text rides in `intervention.message`,
|
||||||
|
already rendered by `frontend/src/components/PodView.tsx`.
|
||||||
|
|
||||||
|
## Implementation steps
|
||||||
|
|
||||||
|
### 1. Specs first
|
||||||
|
- `docs/gemini.md` — vision classifies `editing` vs `research`; extracts `researchTopic`/`researchSource`.
|
||||||
|
- `docs/hermes.md` — new intervention type **research overlap** (collaboration nudge, `ping_teammate`, spoken once); explicitly NOT a merge conflict.
|
||||||
|
- `docs/mongodb.md` — new optional `Collision` fields.
|
||||||
|
- `docs/demo.md` — insert ~30s beat after the same-file collision.
|
||||||
|
|
||||||
|
### 2. Shared contract (additive, optional)
|
||||||
|
`shared/src/engineer.ts` — add to `EngineerContext`:
|
||||||
|
```ts
|
||||||
|
mode?: 'editing' | 'research';
|
||||||
|
researchTopic?: string;
|
||||||
|
researchSource?: string; // domain, e.g. "docs.livekit.io"
|
||||||
|
```
|
||||||
|
`shared/src/collision.ts` — add to `Collision`:
|
||||||
|
```ts
|
||||||
|
overlapKind?: 'file' | 'research'; // undefined = file (preserves current behavior)
|
||||||
|
researchTopic?: string;
|
||||||
|
researchSource?: string;
|
||||||
|
researcher?: string; // engineer doing research
|
||||||
|
editor?: string; // engineer editing the file
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Vision: classify editing vs research
|
||||||
|
`backend/src/vision/gemini.ts` — extend `SCHEMA` with `mode`, `researchTopic`,
|
||||||
|
`researchSource` (+ propertyOrdering). Update the prompt so a browser/docs/SDK
|
||||||
|
frame returns `mode:'research'` + topic + source domain, else `mode:'editing'`
|
||||||
|
with existing IDE fields. Map new fields into the returned `EngineerContext`.
|
||||||
|
Keep `thinkingConfig.thinkingBudget:0` and `MEDIA_RESOLUTION_LOW`.
|
||||||
|
|
||||||
|
### 4. Semantic matcher helper (reuse embed/cosine)
|
||||||
|
`backend/src/memory/vectors.ts` — add and export:
|
||||||
|
```ts
|
||||||
|
export async function semanticSimilarity(a: string, b: string): Promise<number | null> {
|
||||||
|
const [va, vb] = await Promise.all([embed(a, 'query'), embed(b, 'document')]);
|
||||||
|
if (!va || !vb) return null;
|
||||||
|
return cosine(va, vb);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. New detector (additive file)
|
||||||
|
`backend/src/env.ts` — add `RESEARCH_OVERLAP_THRESHOLD` (default `0.6`).
|
||||||
|
|
||||||
|
`backend/src/collision/research.ts` (new):
|
||||||
|
```ts
|
||||||
|
export interface ResearchOpts { similarity?: (a: string, b: string) => Promise<number | null>; threshold?: number; }
|
||||||
|
export async function detectResearchOverlaps(
|
||||||
|
contexts: EngineerContext[],
|
||||||
|
gitStates: Map<string, GitState> | undefined,
|
||||||
|
opts: ResearchOpts = {},
|
||||||
|
): Promise<Collision[]>;
|
||||||
|
```
|
||||||
|
Logic:
|
||||||
|
- **researchers** = contexts with `mode==='research'` && `researchTopic`.
|
||||||
|
- **editor files** = ground truth from `gitStates[*].changedFiles` (reliable) plus
|
||||||
|
any `mode==='editing'` `currentFile`; each tagged with its engineer.
|
||||||
|
- For each distinct (researcher, editor) pair on a file, score
|
||||||
|
`similarity("${topic} ${source}", "<file stem words> <symbol> <activity>")`
|
||||||
|
(default `similarity = semanticSimilarity`). Fire when `score >= threshold`
|
||||||
|
(default `RESEARCH_OVERLAP_THRESHOLD`).
|
||||||
|
- **Fallback:** if `score === null`, deterministic stem/token overlap (mirror
|
||||||
|
`fileKey()` stemming) — guarantees `livekit` ↔ `livekit.py` fires offline.
|
||||||
|
- Dedupe to best-scoring file per researcher; require distinct engineers.
|
||||||
|
- Emit `Collision`: `id: col_research_<stem>_<Date.now()>`, `engineers:[editor, researcher]`,
|
||||||
|
`file:<editor file>`, `severity:'warn'`, `overlapKind:'research'`, plus
|
||||||
|
`researcher`, `editor`, `researchTopic`, `researchSource`.
|
||||||
|
|
||||||
|
### 6. Wire into orchestrator — `backend/src/agent/podman.ts`
|
||||||
|
- In `onScreenFrame`, after `detectCollisions(...)` (line ~100):
|
||||||
|
```ts
|
||||||
|
const research = await detectResearchOverlaps([...this.contexts.values()], gitStates);
|
||||||
|
const collisions = [...fileCollisions, ...research];
|
||||||
|
```
|
||||||
|
(concat **before** the re-arm + handle loops at `:104` / `:111` / `:115`).
|
||||||
|
- `conflictKey()` (`:124`) — namespace by overlap kind so research and file
|
||||||
|
overlaps on the same file don't share an edge-trigger key:
|
||||||
|
```ts
|
||||||
|
return `${collision.overlapKind ?? 'file'}:${comparableBasename(collision.file)}`;
|
||||||
|
```
|
||||||
|
- `gitOverlap` loop (`:111-113`) — guard: only call `engineersOverlapOnFile` when
|
||||||
|
`collision.overlapKind !== 'research'` (researcher won't have the file dirty;
|
||||||
|
leave `gitOverlap` undefined for research).
|
||||||
|
- `handle()` (`:128`) — branch on `overlapKind === 'research'`:
|
||||||
|
- `message`: `` `🤝 ${researcher} is researching ${researchTopic}` + (researchSource ? ` (${researchSource})` : '') + ` while ${editor} edits ${shortFile} — sync up before duplicating effort.` ``
|
||||||
|
- `voiceLine`: `` `${researcher} is researching ${researchTopic} while ${editor} works on ${shortFile}. Worth a quick sync.` ``
|
||||||
|
- `suggestedAction.kind = 'ping_teammate'`.
|
||||||
|
- Pass `voiceLine` to `publishHermesIntervention` **regardless of severity** so
|
||||||
|
the beat is spoken once. (For file collisions keep existing
|
||||||
|
`severity === 'critical' ? voiceLine : undefined`.)
|
||||||
|
- Existing `recallSimilar` / `shouldIntervene` gate stays unchanged.
|
||||||
|
|
||||||
|
### 7. Frontend (nice-to-have, cut if behind)
|
||||||
|
`frontend/src/livekit/useInterventions.ts` — capture `msg.collision.overlapKind`;
|
||||||
|
show a 🤝 badge on the card in `PodView.tsx`. Core path needs nothing.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Build:** `pnpm -r build` (shared builds first — order matters).
|
||||||
|
2. **Detector (offline, deterministic):** harness calling `detectResearchOverlaps`
|
||||||
|
with stubbed `opts.similarity`:
|
||||||
|
- researcher `{mode:'research', researchTopic:'LiveKit agent init', researchSource:'docs.livekit.io'}`
|
||||||
|
+ gitState with `livekit.py` dirty for another engineer → exactly one
|
||||||
|
`overlapKind:'research'` collision naming both.
|
||||||
|
- single engineer both sides → no overlap.
|
||||||
|
- `similarity` returns `null` → keyword fallback still fires on `livekit`.
|
||||||
|
3. **Live:** run the agent (systemd on the box, or local); one participant shares a
|
||||||
|
browser tab on LiveKit docs, another keeps `livekit.py` dirty (git sidecar
|
||||||
|
running) → 🤝 nudge card + one-time spoken cue. Fires once (edge-triggered),
|
||||||
|
re-arms after the browser closes.
|
||||||
|
4. **Regression:** same-file collision still fires, unaffected (separate
|
||||||
|
`conflictKey` namespace).
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# Build spec: Work History "Coordination ROI" band
|
||||||
|
|
||||||
|
> Self-contained spec for an implementing agent. Everything needed to build is
|
||||||
|
> here: current-state anchors, exact edits, code sketches, verification. Read
|
||||||
|
> the referenced files before editing. Changes are **additive** — one optional
|
||||||
|
> field on a shared type, two new backend queries, one new presentational
|
||||||
|
> component. No route signature changes, no DB writes, no new deps.
|
||||||
|
|
||||||
|
## Context / why
|
||||||
|
|
||||||
|
The Work History dialog (opens when you click a teammate's "History" button)
|
||||||
|
today shows three raw counters (files / screen logs / git changes), a Recent
|
||||||
|
files bar list, and a Timeline. It is pure **activity volume** — it summarizes
|
||||||
|
nothing and shows none of PodMan's actual value.
|
||||||
|
|
||||||
|
We add a **Coordination ROI band** at the top of the dialog that answers "what
|
||||||
|
did PodMan save this person?": estimated rework-hours saved by clashes Hermes
|
||||||
|
caught early, plus hard defensible counts. This is a *summary* layer — it must
|
||||||
|
NOT re-stream the pod activity feed ("Team Memory"). Existing Recent files +
|
||||||
|
Timeline sections stay exactly as they are, below the new band.
|
||||||
|
|
||||||
|
### Locked decisions
|
||||||
|
- **Primary story = saved time / ROI.** One number leads: `~Xh Ym rework saved`.
|
||||||
|
- **Transparent heuristic.** The hours are a labeled estimate (`~`, `(est.)`)
|
||||||
|
with an info tooltip showing the per-clash breakdown. Technical judges can see
|
||||||
|
the model; we never claim precision.
|
||||||
|
- **Backend extension required** (collisions/interventions are not in the
|
||||||
|
current `MemberWorkHistory` payload). Field is optional → old frontends and
|
||||||
|
pods with no collisions degrade gracefully (band hidden).
|
||||||
|
- **Cut:** editing/research focus donut. ROI is the single story; a donut
|
||||||
|
dilutes it.
|
||||||
|
- **Credit split** across involved engineers so a 2-person clash does not
|
||||||
|
double-count the pod total.
|
||||||
|
|
||||||
|
## Heuristic (this is the contract — implement exactly)
|
||||||
|
|
||||||
|
For each `collisions` doc in the window where the member is involved AND an
|
||||||
|
`interventions` doc exists for it (Hermes actually surfaced it):
|
||||||
|
|
||||||
|
1. **Eligibility (must be "real"):** count the collision only if
|
||||||
|
`gitOverlap === true` OR `severity === 'critical'`. Otherwise skip.
|
||||||
|
2. **Weight by kind/severity:**
|
||||||
|
| condition | minutesEach |
|
||||||
|
|---|---|
|
||||||
|
| `overlapKind === 'research'` | 10 |
|
||||||
|
| `severity === 'critical'` (same-file) | 45 |
|
||||||
|
| `severity === 'warn'` (same-file) | 20 |
|
||||||
|
| `severity === 'info'` (same-file) | 10 |
|
||||||
|
(Evaluate research first; a research overlap is always 10 regardless of severity.)
|
||||||
|
3. **Member share:** `minutesEach / max(1, engineers.length)`. Sum all shares →
|
||||||
|
`savedMinutes` (round to nearest integer).
|
||||||
|
4. **breakdown[]:** group eligible collisions by label
|
||||||
|
(`"critical same-file"`, `"warn same-file"`, `"research overlap"`, etc.),
|
||||||
|
each entry `{ label, count, minutesEach }` — for the tooltip.
|
||||||
|
|
||||||
|
Member "involved" = `engineers` array contains the member (case-insensitive),
|
||||||
|
OR `researcher`/`editor` equals the member (case-insensitive). Reuse the
|
||||||
|
existing `sameMember()` helper logic.
|
||||||
|
|
||||||
|
Hard counts (no estimation):
|
||||||
|
- `clashesCaught` = number of eligible collisions (the integer behind the hours).
|
||||||
|
- `filesDeconflicted` = distinct `collision.file` values across eligible collisions.
|
||||||
|
- `conflictFreeCommits` / `totalCommits`: see "commits" note below.
|
||||||
|
|
||||||
|
### Commits note (keep simple, defensible)
|
||||||
|
There is no per-commit log in the window. Use the git ground-truth we have:
|
||||||
|
`totalCommits` = count of distinct `changedFiles` for the member from
|
||||||
|
`engineer_states` (already loaded as `gitState.changedFiles.length`).
|
||||||
|
`conflictFreeCommits` = `totalCommits - filesDeconflicted` (clamped ≥ 0). This
|
||||||
|
reads as "files in flight that never hit a clash". Label it in the UI as
|
||||||
|
**"conflict-free files"**, not commits, so the wording matches the data. (The
|
||||||
|
band copy below already says "files".)
|
||||||
|
|
||||||
|
## Current-state anchors (read these first)
|
||||||
|
|
||||||
|
- `shared/src/member-history.ts:24-36` — `MemberWorkHistory` interface (add `roi?`).
|
||||||
|
- `backend/src/activity/member-history.ts:73-174` — `getMemberWorkHistory()`.
|
||||||
|
- `:83-94` — the `Promise.all` that loads `observations` + `engineer_states`. Add collisions/interventions here.
|
||||||
|
- `:45-47` — `sameMember()` helper to reuse for involvement check.
|
||||||
|
- `:124` — `gitState` already in scope; `:169` uses `gitState.changedFiles.length`.
|
||||||
|
- `:161-173` — the returned object (add `roi`).
|
||||||
|
- `backend/src/memory/db.ts:47-48` — canonical collection names `collisions`, `interventions` (typed `Collision`, `Intervention`).
|
||||||
|
- `backend/src/activity/store.ts:167-178` — reference query shape for both collections.
|
||||||
|
- `shared/src/collision.ts:7-36` — `Collision` (`engineers`, `severity`, `overlapKind`, `gitOverlap`, `researcher`, `editor`, `file`, `detectedAt`).
|
||||||
|
- `shared/src/intervention.ts:8-18` — `Intervention` (`collisionId`, `podId`).
|
||||||
|
- `frontend/src/components/PodView.tsx:33-35` — type imports from `@podman/shared`.
|
||||||
|
- `frontend/src/components/PodView.tsx:1035-1041` — the `history && !loading && !error` block; the 3-stat grid is the insert point (band goes ABOVE it).
|
||||||
|
- `frontend/src/components/PodView.tsx:1093-1100` — `HistoryStat` (style reference for new sub-stats).
|
||||||
|
- `frontend/src/components/PodView.tsx` — `timeLabel()` exists for relative time; reuse if needed.
|
||||||
|
- Route is unchanged: `backend/src/server.ts:502-506` already returns whatever `getMemberWorkHistory` produces.
|
||||||
|
|
||||||
|
## Edit 1 — shared type (`shared/src/member-history.ts`)
|
||||||
|
|
||||||
|
Add to the `MemberWorkHistory` interface (after `timeline`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Coordination ROI summary — clashes Hermes caught for this member. Optional
|
||||||
|
* so pods with no collisions / older payloads render without the band. */
|
||||||
|
roi?: MemberWorkHistoryRoi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkHistoryRoi {
|
||||||
|
/** Estimated rework minutes saved (heuristic, labeled "~/est." in UI). */
|
||||||
|
savedMinutes: number;
|
||||||
|
/** Eligible collisions caught early (hard count). */
|
||||||
|
clashesCaught: number;
|
||||||
|
/** Distinct files that hit an eligible clash. */
|
||||||
|
filesDeconflicted: number;
|
||||||
|
/** Member files in flight that never hit a clash. */
|
||||||
|
conflictFreeFiles: number;
|
||||||
|
/** Total member files in flight (git changedFiles). */
|
||||||
|
totalFiles: number;
|
||||||
|
/** Per-kind breakdown for the tooltip. */
|
||||||
|
breakdown: { label: string; count: number; minutesEach: number }[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`shared/src/index.ts:56-59` already re-exports the member-history types via a
|
||||||
|
`export type { ... }` block — add `MemberWorkHistoryRoi` to that list.
|
||||||
|
|
||||||
|
## Edit 2 — backend (`backend/src/activity/member-history.ts`)
|
||||||
|
|
||||||
|
1. Import the types: `import type { Collision } from '@podman/shared';` (Intervention only needs `collisionId`, can stay untyped or import too).
|
||||||
|
2. In the `Promise.all` at `:83`, add two queries (windowed, podId-scoped):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
db.collection<Collision>('collisions')
|
||||||
|
.find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ detectedAt: -1 })
|
||||||
|
.limit(200)
|
||||||
|
.toArray(),
|
||||||
|
db.collection<{ collisionId: string }>('interventions')
|
||||||
|
.find({ podId }, { projection: { collisionId: 1, _id: 0 } })
|
||||||
|
.toArray(),
|
||||||
|
```
|
||||||
|
|
||||||
|
3. After building `fileRows`, compute `roi` with a new local helper
|
||||||
|
`computeRoi(member, collisions, interventions, gitState)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function computeRoi(
|
||||||
|
member: string,
|
||||||
|
collisions: Collision[],
|
||||||
|
interventionCollisionIds: Set<string>,
|
||||||
|
changedFileCount: number,
|
||||||
|
): MemberWorkHistoryRoi {
|
||||||
|
const involved = (c: Collision) =>
|
||||||
|
c.engineers?.some((e) => sameMember(e, member)) ||
|
||||||
|
sameMember(c.researcher, member) ||
|
||||||
|
sameMember(c.editor, member);
|
||||||
|
|
||||||
|
const eligible = collisions.filter(
|
||||||
|
(c) =>
|
||||||
|
involved(c) &&
|
||||||
|
interventionCollisionIds.has(c.id) &&
|
||||||
|
(c.gitOverlap === true || c.severity === 'critical'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const weightOf = (c: Collision): { label: string; minutes: number } => {
|
||||||
|
if (c.overlapKind === 'research') return { label: 'research overlap', minutes: 10 };
|
||||||
|
if (c.severity === 'critical') return { label: 'critical same-file', minutes: 45 };
|
||||||
|
if (c.severity === 'warn') return { label: 'warn same-file', minutes: 20 };
|
||||||
|
return { label: 'info same-file', minutes: 10 };
|
||||||
|
};
|
||||||
|
|
||||||
|
let savedMinutes = 0;
|
||||||
|
const groups = new Map<string, { count: number; minutesEach: number }>();
|
||||||
|
for (const c of eligible) {
|
||||||
|
const { label, minutes } = weightOf(c);
|
||||||
|
savedMinutes += minutes / Math.max(1, c.engineers?.length ?? 1);
|
||||||
|
const g = groups.get(label) ?? { count: 0, minutesEach: minutes };
|
||||||
|
g.count += 1;
|
||||||
|
groups.set(label, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesDeconflicted = new Set(eligible.map((c) => c.file)).size;
|
||||||
|
const totalFiles = changedFileCount;
|
||||||
|
return {
|
||||||
|
savedMinutes: Math.round(savedMinutes),
|
||||||
|
clashesCaught: eligible.length,
|
||||||
|
filesDeconflicted,
|
||||||
|
conflictFreeFiles: Math.max(0, totalFiles - filesDeconflicted),
|
||||||
|
totalFiles,
|
||||||
|
breakdown: [...groups.entries()].map(([label, g]) => ({
|
||||||
|
label,
|
||||||
|
count: g.count,
|
||||||
|
minutesEach: g.minutesEach,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Build the intervention id set and attach to the return:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const interventionIds = new Set(interventions.map((i) => i.collisionId));
|
||||||
|
const roi = computeRoi(member, collisions, interventionIds, gitState?.changedFiles?.length ?? 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `roi` to the returned object at `:161-173`. Always return it (it self-zeroes
|
||||||
|
when there are no clashes); the frontend decides whether to show the band based
|
||||||
|
on `clashesCaught`/`savedMinutes`.
|
||||||
|
|
||||||
|
> Note `Collision.id` survives the `{ _id: 0 }` projection — `id` is a real field
|
||||||
|
> (`shared/src/collision.ts:8`), distinct from Mongo `_id`. Do not project it out.
|
||||||
|
|
||||||
|
## Edit 3 — frontend (`frontend/src/components/PodView.tsx`)
|
||||||
|
|
||||||
|
1. Add `MemberWorkHistoryRoi` to the `@podman/shared` type import (`:33-35`).
|
||||||
|
2. Insert `<RoiBand roi={history.roi} />` immediately inside the
|
||||||
|
`history && !loading && !error` block, BEFORE the `grid ... sm:grid-cols-3`
|
||||||
|
stat grid (`:1037`).
|
||||||
|
3. New presentational component (place near `HistoryStat`, `:1093`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function formatSaved(minutes: number): string {
|
||||||
|
if (minutes < 60) return `~${minutes}m`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m ? `~${h}h ${m}m` : `~${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoiBand({ roi }: { roi?: MemberWorkHistoryRoi }) {
|
||||||
|
if (!roi || roi.clashesCaught === 0) return null; // no clashes → no band
|
||||||
|
const conflictFree = roi.totalFiles
|
||||||
|
? Math.round((roi.conflictFreeFiles / roi.totalFiles) * 100)
|
||||||
|
: 100;
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-primary/5 p-4">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<p className="font-mono text-2xl font-semibold">{formatSaved(roi.savedMinutes)}</p>
|
||||||
|
<span className="text-sm text-muted-foreground">rework saved</span>
|
||||||
|
<RoiTooltip roi={roi} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
estimated · clashes caught pre-commit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-mono text-lg font-medium">{roi.clashesCaught}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">clashes caught early</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-primary" style={{ width: `${conflictFree}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[0.68rem] text-muted-foreground">
|
||||||
|
conflict-free: {roi.conflictFreeFiles} of {roi.totalFiles} files ·{' '}
|
||||||
|
{roi.filesDeconflicted} auto-deconflicted
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Tooltip — reuse the existing Tooltip primitives already imported in this file
|
||||||
|
(`Tooltip`, `TooltipTrigger`, `TooltipContent` — see the History button at
|
||||||
|
`:982-988`). `RoiTooltip` renders an `ⓘ`/`InfoIcon` trigger; content lists
|
||||||
|
`breakdown` rows as `{count} × {minutesEach}m {label}` plus a footer line
|
||||||
|
`"split across engineers · est. only"`. Keep it a few lines.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -r build # @podman/shared first, then backend + frontend typecheck
|
||||||
|
```
|
||||||
|
Manual / demo check (against a pod that has collisions, e.g. demo-pod):
|
||||||
|
1. Open the app, click a teammate who was in a clash → History.
|
||||||
|
2. Band shows at top: `~Xh Ym rework saved`, clashes-caught integer, conflict-free bar.
|
||||||
|
3. Hover `ⓘ` → breakdown rows match the heuristic table.
|
||||||
|
4. Click a teammate with **no** clashes → band absent, rest of dialog unchanged.
|
||||||
|
5. Existing Recent files + Timeline sections render unchanged below the band.
|
||||||
|
|
||||||
|
Quick data sanity (optional, on the box or via mongosh):
|
||||||
|
```js
|
||||||
|
db.collisions.find({ podId: "demo-pod" }).count() // > 0 for a real band
|
||||||
|
db.interventions.find({ podId: "demo-pod" }).count() // surfaced clashes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coordination / merge risk (team is concurrent)
|
||||||
|
|
||||||
|
- Touches two shared hot files: `shared/src/member-history.ts` and
|
||||||
|
`frontend/src/components/PodView.tsx`. Both edits are **additive** (one
|
||||||
|
optional field + one new component + one insert line). Low conflict risk, but
|
||||||
|
announce before pushing.
|
||||||
|
- `git pull --rebase origin main` immediately before push. Never force-push main.
|
||||||
|
- No changes to `/api/...` route signatures, no new deps, no Mongo writes.
|
||||||
|
|
||||||
|
## Deploy (after merge, on the box — see CLAUDE.md ops)
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
(Frontend-visible change + backend payload change → both the static build and
|
||||||
|
the API service must be refreshed.)
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# Stream Categorization — My Stream / Team Stream
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Judges must read the pod stream in 10 seconds. Right now both lanes (`My stream`,
|
|
||||||
`Team stream`) dump every event into one flat chronological list. The two things
|
|
||||||
that prove "self-improving agent" are mushed together:
|
|
||||||
|
|
||||||
- **Sources of decisions** — raw signals the agent observed (screen vision, git).
|
|
||||||
- **Reasoning decisions** — what Hermes concluded and did (conflict detected,
|
|
||||||
intervention spoken, outcome/verifier result).
|
|
||||||
|
|
||||||
`source` (vision/git/memory/hermes/policy) is currently buried as a plain outline
|
|
||||||
badge next to the filename. `kind` is only an icon. No sectioning. Result: bloated,
|
|
||||||
undifferentiated, doesn't tell the loop story (observe → reason → act → learn).
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Split each stream lane into clear sections and promote provenance, so a judge sees:
|
|
||||||
"agent ingests **signals**, then makes **reasoning decisions** from them."
|
|
||||||
|
|
||||||
No backend / shared-type changes. The data already carries `kind` + `source`.
|
|
||||||
Pure presentation change in `frontend/src/components/PodView.tsx` —
|
|
||||||
`ActivitySidebar` + `ActivityItem` + new helpers only. Additive, localized.
|
|
||||||
|
|
||||||
## Categorization (the contract)
|
|
||||||
|
|
||||||
Two sections, derived from existing `PodActivityKind`:
|
|
||||||
|
|
||||||
| Section | Heading | kinds | Meaning |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `signal` | **Signals** | `observation`, `git` | Raw inputs the agent saw. The *sources*. |
|
|
||||||
| `decision` | **Reasoning & decisions** | `collision`, `intervention`, `outcome` | What Hermes reasoned and did. |
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const CATEGORY_OF: Record<PodActivityKind, 'signal' | 'decision'> = {
|
|
||||||
observation: 'signal',
|
|
||||||
git: 'signal',
|
|
||||||
collision: 'decision',
|
|
||||||
intervention: 'decision',
|
|
||||||
outcome: 'decision',
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Section order: **Signals** first, **Reasoning & decisions** second (top-to-bottom =
|
|
||||||
the loop direction). A section with zero events renders nothing.
|
|
||||||
|
|
||||||
## Provenance chip (sources)
|
|
||||||
|
|
||||||
Promote `source` to a leading color-coded chip with an icon. This is the "source of
|
|
||||||
decision" tag judges look for.
|
|
||||||
|
|
||||||
| source | label | icon | tint |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `vision` | Vision | `EyeIcon` | chart-1 |
|
|
||||||
| `git` | Git | `GitBranchIcon` | chart-2 |
|
|
||||||
| `memory` | Memory | `BrainIcon` | chart-4 |
|
|
||||||
| `hermes` | Hermes | `SparklesIcon` | primary |
|
|
||||||
| `policy` | Policy | `ShieldIcon` | chart-3 |
|
|
||||||
|
|
||||||
Chip class pattern (use `variant="outline"` so the default primary fill is overridden):
|
|
||||||
`border-{tint}/40 bg-{tint}/10 text-{tint}`.
|
|
||||||
|
|
||||||
## Kind label
|
|
||||||
|
|
||||||
Render `kind` as readable text next to the provenance chip, not just an icon:
|
|
||||||
|
|
||||||
| kind | label |
|
|
||||||
|---|---|
|
|
||||||
| observation | Observed |
|
|
||||||
| git | Git |
|
|
||||||
| collision | Conflict |
|
|
||||||
| intervention | Intervention |
|
|
||||||
| outcome | Outcome |
|
|
||||||
|
|
||||||
Tag row order in each card: **source chip → kind label → actors → file**.
|
|
||||||
|
|
||||||
## Implementation steps (after teammate lands frontend work)
|
|
||||||
|
|
||||||
1. **Rebase / pull teammate's PodView.tsx first.** Do not start before it lands —
|
|
||||||
this file is being actively rewritten right now.
|
|
||||||
2. Add icon imports: `BrainIcon`, `EyeIcon`, `ShieldIcon`, `WorkflowIcon` (and
|
|
||||||
reuse `GitBranchIcon`, `SparklesIcon`, `RadioTowerIcon`). Import
|
|
||||||
`PodActivitySource` type from `@podman/shared`.
|
|
||||||
3. Add module-level consts: `CATEGORY_OF`, `CATEGORIES` (id/label/hint/icon),
|
|
||||||
`SOURCE_META` (label/icon/className), `KIND_LABEL`.
|
|
||||||
4. In `ActivitySidebar`'s expanded `SidebarContent`, replace the flat
|
|
||||||
`events.map(...)` with a `CATEGORIES.map(...)` that filters events per category,
|
|
||||||
skips empty sections, and renders a section header (icon + label + count + hint)
|
|
||||||
above each group.
|
|
||||||
5. In `ActivityItem`, replace the buried source `<Badge variant="outline">{source}</Badge>`
|
|
||||||
with the colored provenance chip + a kind-label badge; keep actors and file badges.
|
|
||||||
6. Collapsed icon-rail (the `group-data-[collapsible=icon]` mini list) stays flat —
|
|
||||||
no sectioning needed there.
|
|
||||||
7. Verify: `pnpm --filter @podman/frontend build` typechecks; empty-state and a
|
|
||||||
live pod with mixed events both render correctly.
|
|
||||||
|
|
||||||
## Out of scope (do not do)
|
|
||||||
|
|
||||||
- No changes to `shared/src/activity.ts`, the SSE hook, or backend event emission.
|
|
||||||
- No new event kinds/sources.
|
|
||||||
- No third section / per-kind lanes — two buckets is the whole point (signals vs
|
|
||||||
reasoning). Keeps a sparse demo stream from fragmenting.
|
|
||||||
|
|
||||||
## Merge-safety note
|
|
||||||
|
|
||||||
Single file, two functions. Hold until the teammate improving the frontend pushes,
|
|
||||||
then pull and apply on top to avoid clobbering their design pass.
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
---
|
|
||||||
name: podman-design
|
|
||||||
description: Full system design for PodMan — real-time AI team coordination agent using Gemini Vision, Gemini Live 2.5, LiveKit, and MongoDB Atlas
|
|
||||||
metadata:
|
|
||||||
type: project
|
|
||||||
---
|
|
||||||
|
|
||||||
# PodMan — System Design
|
|
||||||
|
|
||||||
Status: historical reference. Current implementation truth lives in
|
|
||||||
[`../../PLAN.md`](../../PLAN.md), [`../../mongodb.md`](../../mongodb.md),
|
|
||||||
[`../../continual-learning/`](../../continual-learning/), and
|
|
||||||
[`../../graph-discovery/`](../../graph-discovery/).
|
|
||||||
|
|
||||||
## Concept
|
|
||||||
|
|
||||||
PodMan is a real-time AI team coordination agent for software teams. Engineers join a consented LiveKit room and publish screen share when they want PodMan to observe active work. The backend agent samples the LiveKit screen track, uses Gemini Vision to extract structured context, detects coordination risks, and sends intervention cards, Hermes messages, or urgent voice cues through LiveKit. MongoDB Atlas stores observations, collisions, interventions, outcomes, latest engineer state, and the Team memory graph.
|
|
||||||
|
|
||||||
**Track:** Continual Learning — accepted and dismissed outcomes make later exact-signature recall and graph memory more useful.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────── Engineer laptop (Browser PWA) ──────────────────┐
|
|
||||||
│ getDisplayMedia → LiveKit screen-share track │
|
|
||||||
│ Local git watcher → MongoDB engineer_states │
|
|
||||||
│ LiveKit room joined → receives cards, messages, voice cues │
|
|
||||||
│ Earbuds: hears PodMan urgent voice cues │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
│ LiveKit media + data
|
|
||||||
▼
|
|
||||||
┌────────────────── HERMES (DigitalOcean) ─────────────────────────┐
|
|
||||||
│ 1. Subscribe to screen-share track → Gemini Vision │
|
|
||||||
│ 2. Write observations and per-user state to MongoDB │
|
|
||||||
│ 3. Fuse local git truth from engineer_states │
|
|
||||||
│ 4. Run collision detector over active contexts │
|
|
||||||
│ 5. If risk detected → card/message first, voice only if urgent │
|
|
||||||
│ 6. Push data and optional audio into LiveKit room │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
│ read/write
|
|
||||||
▼
|
|
||||||
MongoDB Atlas
|
|
||||||
(engineer_states, observations,
|
|
||||||
collisions, interventions, outcomes,
|
|
||||||
team_model, graph_nodes, graph_edges)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### PWA (local agent)
|
|
||||||
|
|
||||||
- Joins LiveKit room via existing `joinPod` flow
|
|
||||||
- Publishes screen share through LiveKit after explicit user action
|
|
||||||
- Receives Hermes audio track through LiveKit when voice is urgent
|
|
||||||
- Listens for data channel messages → renders intervention feed
|
|
||||||
- Two screens: join screen (built), active session screen (to build)
|
|
||||||
|
|
||||||
### Hermes (orchestrator)
|
|
||||||
|
|
||||||
- Express server + LiveKit Agent on DigitalOcean
|
|
||||||
- LiveKit agent worker receives sampled screen-share frames and queues them for vision
|
|
||||||
- Vision pipeline: Gemini 2.0 Flash → `EngineerContext`
|
|
||||||
- Confidence gate: discard frames with confidence < 0.6
|
|
||||||
- State writer: write `observations`, `collisions`, `interventions`, `outcomes`, and `engineer_states`
|
|
||||||
- Event detector: Gemini text prompt over all active states
|
|
||||||
- Message generator: Gemini text → short intervention message
|
|
||||||
- Voice publisher: Gemini TTS via LiveKit audio into room for urgent escalation
|
|
||||||
- Data channel: sends structured intervention payload
|
|
||||||
- Cooldown: 3 min between voice cues per pod
|
|
||||||
|
|
||||||
### Gemini usage
|
|
||||||
|
|
||||||
- **Vision:** `gemini-2.0-flash` — screen → `{ currentFile, inferredTask, terminalVisible, recentTerminalOutput, confidence }`
|
|
||||||
- **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }`
|
|
||||||
- **Message generation:** `gemini-2.0-flash` — risk → intervention text
|
|
||||||
- **Voice:** `gemini-3.1-flash-tts-preview` via LiveKit audio publication — text → audio
|
|
||||||
|
|
||||||
### MongoDB Atlas
|
|
||||||
|
|
||||||
- `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-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 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." |
|
|
||||||
| `DUPLICATE_WORK` | 2+ engineers on same file simultaneously | "Alice and Bob — you're both in login.tsx. Coordinate before pushing." |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Continual learning story
|
|
||||||
|
|
||||||
The `team_model` graph and accepted outcomes persist across sessions. On graph
|
|
||||||
load:
|
|
||||||
|
|
||||||
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:** The first collision writes an outcome. The second similar collision
|
|
||||||
recalls that memory and changes the graph or behavior. That is the learning
|
|
||||||
visible on stage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Demo flow (3 min)
|
|
||||||
|
|
||||||
1. **(0:00)** Three engineers join pod. PodMan greets by voice.
|
|
||||||
2. **(0:20)** Alice opens `auth/middleware.ts`. Hermes infers ownership.
|
|
||||||
3. **(0:45)** Bob opens `frontend/login.tsx`. Carol's terminal shows connection refused.
|
|
||||||
4. **(1:20) BLOCKER_DETECTED:** "Carol, looks like you're waiting on auth. Alice is actively building it — hang tight."
|
|
||||||
5. **(2:00) DEPENDENCY_READY:** "Carol, Bob — Alice just got the auth endpoint running. You're clear to integrate."
|
|
||||||
6. **(2:20)** Optional: session 2 warm-start comparison.
|
|
||||||
7. **(2:45)** Close: "PodMan — the teammate that sees what Slack can't."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key risks
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
| -------------------------------------------- | ------------------------------------------------- |
|
|
||||||
| Gemini Vision accuracy | Large font, single editor window, confidence gate |
|
|
||||||
| Gemini Live 2.5 + LiveKit Agents integration | Build together hour 5–7, have TTS fallback |
|
|
||||||
| Frame POST latency | JPEG compression, target < 500ms |
|
|
||||||
| Event false positives | 3-min cooldown, pre-staged demo |
|
|
||||||
| DO deploy failure | Hermes runs local, PWA defaults to localhost:8787 |
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Shared Test Audio — pod-wide connectivity check
|
|
||||||
|
|
||||||
> Spec for the `frontend/src/livekit/useBeat.ts` + `PodView.tsx` test-audio
|
|
||||||
> behavior and the additive `BEAT_STOP` data message. Satisfies the
|
|
||||||
> documentation-first gate for those files.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The **Test audio** button is PodMan's pre-flight check that the LiveKit audio
|
|
||||||
path works for the whole pod — the same path the urgent Gemini-TTS voice
|
|
||||||
escalation rides on. Today the beat is published correctly but its on/off state
|
|
||||||
is **local to the publisher**: teammates can't see it's playing and can't stop
|
|
||||||
it. This makes it a shared, pod-wide toggle so a judge sees the state flip on
|
|
||||||
every screen at once.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- Any participant clicks **Test audio** → they publish the `podman-beat` audio
|
|
||||||
track (Web Audio, `lib/beat.ts`). Everyone auto-subscribes and hears it.
|
|
||||||
- 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 from reality. The
|
|
||||||
publisher is the **owner**.
|
|
||||||
- Anyone can stop it:
|
|
||||||
- Owner clicks **Stop audio** → unpublishes its own track directly.
|
|
||||||
- Non-owner clicks **Stop (`<owner>`'s)** → sends `BEAT_STOP`; the owner
|
|
||||||
unpublishes. (LiveKit forbids unpublishing another participant's track, so a
|
|
||||||
request is the only way.)
|
|
||||||
- The Status card shows `publishing` / `<owner> playing` / `ready`, and the
|
|
||||||
waveform animates (`active`) for everyone while the test is live.
|
|
||||||
|
|
||||||
## State derivation (source of truth = the track)
|
|
||||||
|
|
||||||
`useBeat(room)` returns `{ 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` — one new message on the existing `podman.intervention`
|
|
||||||
data topic:
|
|
||||||
|
|
||||||
| { type: 'BEAT_STOP' } // any participant → owner: stop the shared beat
|
|
||||||
|
|
||||||
Additive to the `DataMessage` union; existing consumers ignore unknown types.
|
|
||||||
**No backend / API change.**
|
|
||||||
|
|
||||||
## 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 beat 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 the test also stop it.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `shared/src/messages.ts` — `BEAT_STOP` message (additive).
|
|
||||||
- `frontend/src/livekit/useBeat.ts` — `useBeat(room)` hook.
|
|
||||||
- `frontend/src/components/PodView.tsx` — button label, status line, waveform
|
|
||||||
`active` driven by the hook.
|
|
||||||
- `frontend/src/lib/beat.ts` — unchanged (existing Web-Audio beat source).
|
|
||||||
@@ -9,7 +9,6 @@ export default tseslint.config(
|
|||||||
'**/node_modules/**',
|
'**/node_modules/**',
|
||||||
'**/.venv/**',
|
'**/.venv/**',
|
||||||
'**/*.config.*',
|
'**/*.config.*',
|
||||||
'examples/livekit-gemini-hacker-starter/**',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
venv/
|
|
||||||
env/
|
|
||||||
ENV/
|
|
||||||
.venv
|
|
||||||
.env.local
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
.uv/
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules/
|
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
.env.local
|
|
||||||
.env*.local
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# LiveKit
|
|
||||||
.livekit/
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
# Gemini Hacker Starter
|
|
||||||
|
|
||||||
A minimal starting point for building with **Gemini 3.1**, **NanoBanana 2**, and **Lyria RealTime** on LiveKit. Get a working multimodal agent running in under 10 minutes, then make it your own.
|
|
||||||
|
|
||||||
Built for the **Google DeepMind × YC Hackathon**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's included
|
|
||||||
|
|
||||||
| Model | What it does in this starter |
|
|
||||||
|---|---|
|
|
||||||
| **Gemini 3.1 Flash Audio** | Real-time voice conversation with native audio and video understanding |
|
|
||||||
| **NanoBanana 2** (`gemini-3.1-flash-image-preview`) | Generates images from text prompts — agent calls it as a function tool and sends the result to your browser |
|
|
||||||
| **Lyria RealTime** (`models/lyria-realtime-exp`) | Streams generative music into the LiveKit room as a live audio track |
|
|
||||||
|
|
||||||
The agent can see your camera, hear you speak, generate images on demand, and play real-time music — all through a single LiveKit room.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Install the LiveKit MCP server
|
|
||||||
|
|
||||||
Install this before you start. It gives your AI coding assistant direct access to LiveKit documentation so you get accurate, current help as you build.
|
|
||||||
|
|
||||||
**Cursor** — click to install:
|
|
||||||
|
|
||||||
[](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.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
LIVEKIT_API_KEY=<your API Key>
|
|
||||||
LIVEKIT_API_SECRET=<your API Secret>
|
|
||||||
LIVEKIT_URL=<your LiveKit server URL>
|
|
||||||
GOOGLE_API_KEY=<your Google/Gemini API key>
|
|
||||||
GEMINI_REALTIME_MODEL=gemini-3.1-flash-live-preview
|
|
||||||
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image
|
|
||||||
GEMINI_LYRIA_MODEL=models/lyria-realtime-exp
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
3.11
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Agent Setup
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Using uv
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create a virtual environment and install all dependencies.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Copy the example environment file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
Then edit `.env.local` with your credentials:
|
|
||||||
|
|
||||||
- `LIVEKIT_API_KEY` - Your LiveKit API key
|
|
||||||
- `LIVEKIT_API_SECRET` - Your LiveKit API secret
|
|
||||||
- `LIVEKIT_URL` - Your LiveKit server URL (e.g., `wss://your-project.livekit.cloud`)
|
|
||||||
- `GOOGLE_API_KEY` - Your Google/Gemini API key
|
|
||||||
|
|
||||||
Or use the LiveKit CLI to auto-populate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lk app env -w
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Agent
|
|
||||||
|
|
||||||
### Using uv
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run python agent.py dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The agent will connect to LiveKit and wait for incoming sessions.
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from google import genai
|
|
||||||
from google.genai import types as genai_types
|
|
||||||
from livekit import agents, rtc
|
|
||||||
from livekit.agents import AgentServer, AgentSession, Agent, RunContext, function_tool, room_io
|
|
||||||
from livekit.plugins import google
|
|
||||||
|
|
||||||
load_dotenv(".env.local")
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# HACK HERE: swap model IDs to experiment
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
REALTIME_MODEL = os.getenv("GEMINI_REALTIME_MODEL", "gemini-3.1-flash-live-preview")
|
|
||||||
IMAGE_MODEL = os.getenv("GEMINI_IMAGE_MODEL", "gemini-3.1-flash-image") # Nano Banana 2
|
|
||||||
LYRIA_MODEL = os.getenv("GEMINI_LYRIA_MODEL", "models/lyria-realtime-exp")
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# HACK HERE: change the agent's persona
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
PERSONA_INSTRUCTIONS = """You are a creative multimodal AI assistant at a Google DeepMind x YC hackathon.
|
|
||||||
You can see through the user's camera, hear them speak, generate images, and play real-time music.
|
|
||||||
|
|
||||||
Your capabilities:
|
|
||||||
- generate_image: Create images with Nano Banana 2 (Gemini 3.1 Flash Image). Use this when asked to generate, create, render, or visualize anything.
|
|
||||||
- start_music: Play real-time generative music with Lyria RealTime. Use this for soundtracks, ambience, or any audio atmosphere.
|
|
||||||
- stop_music: Stop the current music.
|
|
||||||
|
|
||||||
IMPORTANT: When the user asks you to generate an image, ALWAYS say a brief acknowledgment first (like "On it!" or "Let me create that for you") before calling generate_image. The image takes a few seconds to generate, so the user needs to know you heard them.
|
|
||||||
|
|
||||||
Be concise and creative. Lean into the multimodal possibilities — when a user describes something, offer to generate it."""
|
|
||||||
|
|
||||||
|
|
||||||
class HackathonAgent(Agent):
|
|
||||||
BASE_VIDEO_AWARENESS = """You can only see video when the user enables their camera or screenshare.
|
|
||||||
When asked about visuals:
|
|
||||||
- Only describe what you can actually see in provided video frames.
|
|
||||||
- Never invent visual details that are not present.
|
|
||||||
- If no camera is active, tell the user to enable it."""
|
|
||||||
|
|
||||||
def __init__(self, room: rtc.Room) -> None:
|
|
||||||
full_instructions = f"{self.BASE_VIDEO_AWARENESS}\n\n{PERSONA_INSTRUCTIONS}"
|
|
||||||
super().__init__(instructions=full_instructions)
|
|
||||||
|
|
||||||
self._room = room
|
|
||||||
self._music_task: asyncio.Task | None = None
|
|
||||||
self._music_stop_event = asyncio.Event()
|
|
||||||
self._music_track_pub = None
|
|
||||||
|
|
||||||
# Standard client for image generation (Nano Banana 2)
|
|
||||||
self._image_client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
|
|
||||||
|
|
||||||
# v1alpha client required for Lyria RealTime
|
|
||||||
self._lyria_client = genai.Client(
|
|
||||||
api_key=os.environ["GOOGLE_API_KEY"],
|
|
||||||
http_options={"api_version": "v1alpha"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────
|
|
||||||
# HACK HERE: customize the image generation prompt or post-processing
|
|
||||||
# ─────────────────────────────────────────
|
|
||||||
@function_tool()
|
|
||||||
async def generate_image(
|
|
||||||
self,
|
|
||||||
context: RunContext,
|
|
||||||
prompt: str,
|
|
||||||
) -> str:
|
|
||||||
"""Generate an image using Nano Banana 2 and display it on the user's screen.
|
|
||||||
|
|
||||||
Call this whenever the user asks you to create, generate, render, or visualize something.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt: A detailed description of the image to generate. Be specific about style,
|
|
||||||
composition, lighting, and content.
|
|
||||||
"""
|
|
||||||
logger.info("Generating image: %s", prompt)
|
|
||||||
try:
|
|
||||||
response = await asyncio.to_thread(
|
|
||||||
self._image_client.models.generate_content,
|
|
||||||
model=IMAGE_MODEL,
|
|
||||||
contents=prompt,
|
|
||||||
config=genai_types.GenerateContentConfig(
|
|
||||||
response_modalities=["Text", "Image"]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
image_bytes = None
|
|
||||||
mime_type = "image/png"
|
|
||||||
for part in response.candidates[0].content.parts:
|
|
||||||
if part.inline_data is not None:
|
|
||||||
image_bytes = part.inline_data.data
|
|
||||||
mime_type = part.inline_data.mime_type or "image/png"
|
|
||||||
break
|
|
||||||
|
|
||||||
if image_bytes is None:
|
|
||||||
return "Image generation did not return any image data."
|
|
||||||
|
|
||||||
writer = await self._room.local_participant.stream_bytes(
|
|
||||||
name="generated-image",
|
|
||||||
mime_type=mime_type,
|
|
||||||
total_size=len(image_bytes),
|
|
||||||
topic="generated-image",
|
|
||||||
attributes={"prompt": prompt},
|
|
||||||
)
|
|
||||||
await writer.write(image_bytes)
|
|
||||||
await writer.aclose()
|
|
||||||
|
|
||||||
return f"Image generated and sent to the screen. Prompt used: {prompt}"
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Image generation failed: %s", exc)
|
|
||||||
return f"Image generation failed: {exc}"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────
|
|
||||||
# HACK HERE: customize Lyria prompts or add BPM/density controls
|
|
||||||
# ─────────────────────────────────────────
|
|
||||||
@function_tool()
|
|
||||||
async def start_music(
|
|
||||||
self,
|
|
||||||
context: RunContext,
|
|
||||||
prompt: str,
|
|
||||||
bpm: int = 120,
|
|
||||||
) -> str:
|
|
||||||
"""Start streaming real-time generative music using Lyria RealTime.
|
|
||||||
|
|
||||||
Music plays continuously until stop_music is called. Use this for soundtracks,
|
|
||||||
atmospheric audio, or any mood-setting music.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt: Description of the music to generate, e.g. "upbeat electronic", "calm ambient piano",
|
|
||||||
"epic orchestral score", "jazzy lounge". Can combine styles: "lo-fi hip-hop with strings".
|
|
||||||
bpm: Beats per minute (default: 120). Lower values (60-90) feel slower and more ambient;
|
|
||||||
higher values (120-160) feel energetic.
|
|
||||||
"""
|
|
||||||
await self._stop_music_internal()
|
|
||||||
logger.info("Starting Lyria music: %s @ %d BPM", prompt, bpm)
|
|
||||||
self._music_stop_event.clear()
|
|
||||||
self._music_task = asyncio.create_task(self._stream_lyria(prompt, bpm))
|
|
||||||
return f"Music started: {prompt} at {bpm} BPM. Call stop_music to stop it."
|
|
||||||
|
|
||||||
@function_tool()
|
|
||||||
async def stop_music(self, context: RunContext) -> str:
|
|
||||||
"""Stop the currently playing Lyria music."""
|
|
||||||
if self._music_task is None or self._music_task.done():
|
|
||||||
return "No music is currently playing."
|
|
||||||
await self._stop_music_internal()
|
|
||||||
return "Music stopped."
|
|
||||||
|
|
||||||
async def _stop_music_internal(self) -> None:
|
|
||||||
if self._music_task and not self._music_task.done():
|
|
||||||
self._music_stop_event.set()
|
|
||||||
self._music_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._music_task
|
|
||||||
except (asyncio.CancelledError, Exception):
|
|
||||||
pass
|
|
||||||
self._music_task = None
|
|
||||||
|
|
||||||
if self._music_track_pub is not None:
|
|
||||||
try:
|
|
||||||
await self._room.local_participant.unpublish_track(self._music_track_pub.sid)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._music_track_pub = None
|
|
||||||
|
|
||||||
async def _stream_lyria(self, prompt: str, bpm: int) -> None:
|
|
||||||
"""Stream Lyria audio into the LiveKit room as a published audio track."""
|
|
||||||
SAMPLE_RATE = 48000
|
|
||||||
NUM_CHANNELS = 2
|
|
||||||
|
|
||||||
audio_source = rtc.AudioSource(sample_rate=SAMPLE_RATE, num_channels=NUM_CHANNELS)
|
|
||||||
track = rtc.LocalAudioTrack.create_audio_track("lyria-music", audio_source)
|
|
||||||
options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_UNKNOWN)
|
|
||||||
|
|
||||||
pub = await self._room.local_participant.publish_track(track, options)
|
|
||||||
self._music_track_pub = pub
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with self._lyria_client.aio.live.music.connect(model=LYRIA_MODEL) as session:
|
|
||||||
await session.set_weighted_prompts(
|
|
||||||
prompts=[genai_types.WeightedPrompt(text=prompt, weight=1.0)]
|
|
||||||
)
|
|
||||||
await session.set_music_generation_config(
|
|
||||||
config=genai_types.LiveMusicGenerationConfig(bpm=bpm)
|
|
||||||
)
|
|
||||||
await session.play()
|
|
||||||
|
|
||||||
async for message in session.receive():
|
|
||||||
if self._music_stop_event.is_set():
|
|
||||||
break
|
|
||||||
|
|
||||||
chunks = message.server_content.audio_chunks
|
|
||||||
if chunks:
|
|
||||||
audio_bytes = chunks[0].data
|
|
||||||
if audio_bytes:
|
|
||||||
# 16-bit stereo = 4 bytes per sample pair
|
|
||||||
samples_per_channel = len(audio_bytes) // (NUM_CHANNELS * 2)
|
|
||||||
frame = rtc.AudioFrame(
|
|
||||||
data=audio_bytes,
|
|
||||||
sample_rate=SAMPLE_RATE,
|
|
||||||
num_channels=NUM_CHANNELS,
|
|
||||||
samples_per_channel=samples_per_channel,
|
|
||||||
)
|
|
||||||
await audio_source.capture_frame(frame)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Lyria streaming error: %s", exc)
|
|
||||||
finally:
|
|
||||||
if self._music_track_pub is not None:
|
|
||||||
try:
|
|
||||||
await self._room.local_participant.unpublish_track(
|
|
||||||
self._music_track_pub.sid
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._music_track_pub = None
|
|
||||||
|
|
||||||
|
|
||||||
server = AgentServer()
|
|
||||||
|
|
||||||
|
|
||||||
@server.rtc_session(agent_name="gemini-hackathon-agent")
|
|
||||||
async def entrypoint(ctx: agents.JobContext):
|
|
||||||
has_video = False
|
|
||||||
|
|
||||||
def on_track_subscribed(
|
|
||||||
track: rtc.Track,
|
|
||||||
publication: rtc.TrackPublication,
|
|
||||||
participant: rtc.RemoteParticipant,
|
|
||||||
):
|
|
||||||
nonlocal has_video
|
|
||||||
if track.kind == rtc.TrackKind.KIND_VIDEO:
|
|
||||||
has_video = True
|
|
||||||
logger.info("Video track subscribed from %s", participant.identity)
|
|
||||||
|
|
||||||
def on_track_unsubscribed(
|
|
||||||
track: rtc.Track,
|
|
||||||
publication: rtc.TrackPublication,
|
|
||||||
participant: rtc.RemoteParticipant,
|
|
||||||
):
|
|
||||||
nonlocal has_video
|
|
||||||
if track.kind == rtc.TrackKind.KIND_VIDEO:
|
|
||||||
has_video = any(
|
|
||||||
pub.track and pub.track.kind == rtc.TrackKind.KIND_VIDEO
|
|
||||||
for p in ctx.room.remote_participants.values()
|
|
||||||
for pub in p.track_publications.values()
|
|
||||||
if pub.subscribed
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx.room.on("track_subscribed", on_track_subscribed)
|
|
||||||
ctx.room.on("track_unsubscribed", on_track_unsubscribed)
|
|
||||||
|
|
||||||
for participant in ctx.room.remote_participants.values():
|
|
||||||
for publication in participant.track_publications.values():
|
|
||||||
if (
|
|
||||||
publication.subscribed
|
|
||||||
and publication.track
|
|
||||||
and publication.track.kind == rtc.TrackKind.KIND_VIDEO
|
|
||||||
):
|
|
||||||
has_video = True
|
|
||||||
break
|
|
||||||
|
|
||||||
session = AgentSession(
|
|
||||||
llm=google.realtime.RealtimeModel(
|
|
||||||
model=REALTIME_MODEL,
|
|
||||||
voice="Aoede",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.start(
|
|
||||||
room=ctx.room,
|
|
||||||
agent=HackathonAgent(room=ctx.room),
|
|
||||||
)
|
|
||||||
|
|
||||||
await ctx.connect()
|
|
||||||
|
|
||||||
if REALTIME_MODEL != "gemini-3.1-flash-live-preview":
|
|
||||||
try:
|
|
||||||
await session.generate_reply(
|
|
||||||
instructions="Greet the user. Let them know you can generate images with Nano Banana 2 and play real-time music with Lyria. Mention they can enable their camera for visual context."
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Initial greeting failed: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
agents.cli.run_app(server)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "gemini-hacker-starter"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Gemini hackathon starter — voice, vision, image generation, and real-time music with LiveKit"
|
|
||||||
requires-python = ">=3.10,<3.14"
|
|
||||||
dependencies = [
|
|
||||||
"livekit-agents[google,images]>=1.6.4,<1.7",
|
|
||||||
"google-genai>=2.10.0,<3",
|
|
||||||
"python-dotenv>=1.0.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["."]
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
livekit-agents[google,images]~=1.3
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
-2265
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
|||||||
# Enviroment variables needed to connect to the LiveKit server.
|
|
||||||
LIVEKIT_API_KEY=<your_api_key>
|
|
||||||
LIVEKIT_API_SECRET=<your_api_secret>
|
|
||||||
LIVEKIT_URL=wss://<project-subdomain>.livekit.cloud
|
|
||||||
|
|
||||||
# Agent dispatch (https://docs.livekit.io/agents/server/agent-dispatch)
|
|
||||||
# Leave AGENT_NAME blank to enable automatic dispatch
|
|
||||||
# Provide an agent name to enable explicit dispatch
|
|
||||||
AGENT_NAME=
|
|
||||||
|
|
||||||
# Internally used environment variables
|
|
||||||
NEXT_PUBLIC_APP_CONFIG_ENDPOINT=
|
|
||||||
SANDBOX_ID=
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript", "prettier"]
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
dist/
|
|
||||||
docs/
|
|
||||||
node_modules/
|
|
||||||
pnpm-lock.yaml
|
|
||||||
.next/
|
|
||||||
.env*
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"semi": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"printWidth": 100,
|
|
||||||
"importOrder": [
|
|
||||||
"^react",
|
|
||||||
"^next",
|
|
||||||
"^next/(.*)$",
|
|
||||||
"<THIRD_PARTY_MODULES>",
|
|
||||||
"^@[^/](.*)$",
|
|
||||||
"^@/(.*)$",
|
|
||||||
"^[./]"
|
|
||||||
],
|
|
||||||
"importOrderSeparation": false,
|
|
||||||
"importOrderSortSpecifiers": true,
|
|
||||||
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"]
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# Agent Starter for React
|
|
||||||
|
|
||||||
This is a starter template for [LiveKit Agents](https://docs.livekit.io/agents) that provides a simple voice interface using [Agents UI](https://livekit.io/ui) components and [LiveKit JavaScript SDK](https://github.com/livekit/client-sdk-js). It supports [voice](https://docs.livekit.io/agents/start/voice-ai), [transcriptions](https://docs.livekit.io/agents/build/text/), and [virtual avatars](https://docs.livekit.io/agents/integrations/avatar).
|
|
||||||
|
|
||||||
Also available for:
|
|
||||||
[Android](https://github.com/livekit-examples/agent-starter-android) • [Flutter](https://github.com/livekit-examples/agent-starter-flutter) • [Swift](https://github.com/livekit-examples/agent-starter-swift) • [React Native](https://github.com/livekit-examples/agent-starter-react-native)
|
|
||||||
|
|
||||||
<picture>
|
|
||||||
<source srcset="./.github/assets/readme-hero-dark.webp" media="(prefers-color-scheme: dark)">
|
|
||||||
<source srcset="./.github/assets/readme-hero-light.webp" media="(prefers-color-scheme: light)">
|
|
||||||
<img src="./.github/assets/readme-hero-light.webp" alt="App screenshot">
|
|
||||||
</picture>
|
|
||||||
|
|
||||||
### Features:
|
|
||||||
|
|
||||||
- Real-time voice interaction with LiveKit Agents
|
|
||||||
- Camera video streaming support
|
|
||||||
- Screen sharing capabilities
|
|
||||||
- Audio visualization and level monitoring
|
|
||||||
- Virtual avatar integration
|
|
||||||
- Light/dark theme switching with system preference detection
|
|
||||||
- Customizable branding, colors, and UI text via configuration
|
|
||||||
|
|
||||||
This template is built with Next.js and is free for you to use or modify as you see fit.
|
|
||||||
|
|
||||||
### Project structure
|
|
||||||
|
|
||||||
This starter uses the [Agents UI](https://livekit.io/ui) components for core UI elements like media controls, audio visualizers, chat transcripts, and providing session data. Shadcn installs components into `components/` folder so you can customize them like any other local component.
|
|
||||||
|
|
||||||
```
|
|
||||||
agent-starter-react/
|
|
||||||
├── app/
|
|
||||||
│ ├── api/
|
|
||||||
├── components/
|
|
||||||
│ ├── agents-ui/ - Agents UI components
|
|
||||||
│ ├── ai-elements/ - AI Elements components
|
|
||||||
│ ├── app/ - App-specific components
|
|
||||||
│ ├── ui/ - Primitive shadcn/ui components
|
|
||||||
├── fonts/
|
|
||||||
├── hooks/
|
|
||||||
├── lib/
|
|
||||||
├── public/
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Business logic lives within the `components/app` folder. It's here where the application's state and behavior is managed and the various Shadcn UI components are composed together.
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `session-view.tsx` | Initializes the application, and LiveKit session. Renders the view controller and session UI including chat transcript, media tiles, and control bar. |
|
|
||||||
| `view-controller.tsx` | Manages the transitions between the welcome and session views based on the LiveKit session state. |
|
|
||||||
| `welcome-view.tsx` | Renders the welcome UI when the LiveKit session is not connected. |
|
|
||||||
| `chat-transcript.tsx` | Manages the chat transcript transitions. |
|
|
||||||
| `tile-layout.tsx` | Manages the layout and transition of media tiles in various application states. |
|
|
||||||
|
|
||||||
### Component usage
|
|
||||||
|
|
||||||
Most Agents UI components require access to a LiveKit session object for access to values like agent state or audio tracks. A Session object can be created from a [TokenSource](/reference/client-sdk-js/variables/TokenSource.html), and provided by wrapping the component in an [AgentSessionProvider](/reference/components/shadcn/component/agent-session-provider).
|
|
||||||
|
|
||||||
See [`components/app/app.tsx`](./components/app/app.tsx) for an example of how this is done in this app.
|
|
||||||
|
|
||||||
### Customizing components
|
|
||||||
|
|
||||||
Agents UI components, like most Shadcn compopnents, take as many primitive attributes as possible. For example, the [AgentControlBar](/reference/components/shadcn/component/agent-control-bar/page.mdoc) component extends `HTMLAttributes<HTMLDivElement>`, so you can pass any props that a div supports. This makes it easy to extend the component with your own styles or functionality.
|
|
||||||
|
|
||||||
You can edit any Agents UI component's source code in the `components/agents-ui` directory. For style changes, we recommend passing in tailwind classes to override the default styles. Take a look at the source code to get a sense of how to override a component's default styles.
|
|
||||||
|
|
||||||
### Updating components
|
|
||||||
|
|
||||||
To update the Agents UI components to the latest publication, run the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm shadcn:install
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The CLI will ask before overwriting any modified files so you can avoid losing any customizations you might have made.
|
|
||||||
|
|
||||||
### Installing components
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dlx shadcn@latest add @agents-ui/{component-name-a} @agents-ui/{component-name-b}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> If you'd like to try this application without modification, you can deploy an instance in just a few clicks with [LiveKit Cloud Sandbox](https://cloud.livekit.io/projects/p_/sandbox/templates/agent-starter-react).
|
|
||||||
|
|
||||||
[](https://cloud.livekit.io/projects/p_/sandbox/templates/agent-starter-react)
|
|
||||||
|
|
||||||
Run the following command to automatically clone this template.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
lk app create --template agent-starter-react
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the app with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
And open http://localhost:3000 in your browser.
|
|
||||||
|
|
||||||
You'll also need an agent to speak with. Try our starter agent for [Python](https://github.com/livekit-examples/agent-starter-python), [Node.js](https://github.com/livekit-examples/agent-starter-node), or [create your own from scratch](https://docs.livekit.io/agents/start/voice-ai/).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
This starter is designed to be flexible so you can adapt it to your specific agent use case. You can easily configure it to work with different types of inputs and outputs:
|
|
||||||
|
|
||||||
#### Example: App configuration (`app-config.ts`)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const APP_CONFIG_DEFAULTS: AppConfig = {
|
|
||||||
companyName: 'LiveKit',
|
|
||||||
pageTitle: 'LiveKit Voice Agent',
|
|
||||||
pageDescription: 'A voice agent built with LiveKit',
|
|
||||||
|
|
||||||
supportsChatInput: true,
|
|
||||||
supportsVideoInput: true,
|
|
||||||
supportsScreenShare: true,
|
|
||||||
isPreConnectBufferEnabled: true,
|
|
||||||
|
|
||||||
logo: '/lk-logo.svg',
|
|
||||||
accent: '#002cf2',
|
|
||||||
logoDark: '/lk-logo-dark.svg',
|
|
||||||
accentDark: '#1fd5f9',
|
|
||||||
startButtonText: 'Start call',
|
|
||||||
|
|
||||||
// agent dispatch configuration
|
|
||||||
agentName: undefined,
|
|
||||||
|
|
||||||
// LiveKit Cloud Sandbox configuration
|
|
||||||
sandboxId: undefined,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
You can update these values in [`app-config.ts`](./app-config.ts) to customize branding, features, and UI text for your deployment.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The `sandboxId` is for the LiveKit Cloud Sandbox environment.
|
|
||||||
> It is not used for local development.
|
|
||||||
|
|
||||||
#### Environment Variables
|
|
||||||
|
|
||||||
You'll also need to configure your LiveKit credentials in `.env.local` (copy `.env.example` if you don't have one):
|
|
||||||
|
|
||||||
```env
|
|
||||||
LIVEKIT_API_KEY=your_livekit_api_key
|
|
||||||
LIVEKIT_API_SECRET=your_livekit_api_secret
|
|
||||||
LIVEKIT_URL=https://your-livekit-server-url
|
|
||||||
|
|
||||||
# Agent dispatch (https://docs.livekit.io/agents/server/agent-dispatch)
|
|
||||||
# Leave AGENT_NAME blank to enable automatic dispatch
|
|
||||||
# Provide an agent name to enable explicit dispatch
|
|
||||||
AGENT_NAME=
|
|
||||||
```
|
|
||||||
|
|
||||||
These are required for the voice agent functionality to work with your LiveKit project.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
This template is open source and we welcome contributions! Please open a PR or issue through GitHub, and don't forget to join us in the [LiveKit Community Slack](https://livekit.io/join-slack)!
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
export interface AppConfig {
|
|
||||||
pageTitle: string;
|
|
||||||
pageDescription: string;
|
|
||||||
companyName: string;
|
|
||||||
|
|
||||||
supportsChatInput: boolean;
|
|
||||||
supportsVideoInput: boolean;
|
|
||||||
supportsScreenShare: boolean;
|
|
||||||
isPreConnectBufferEnabled: boolean;
|
|
||||||
|
|
||||||
logo: string;
|
|
||||||
startButtonText: string;
|
|
||||||
accent?: string;
|
|
||||||
logoDark?: string;
|
|
||||||
accentDark?: string;
|
|
||||||
|
|
||||||
// agent dispatch configuration
|
|
||||||
agentName?: string;
|
|
||||||
|
|
||||||
// LiveKit Cloud Sandbox configuration
|
|
||||||
sandboxId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const APP_CONFIG_DEFAULTS: AppConfig = {
|
|
||||||
companyName: 'Gemini Hackathon',
|
|
||||||
pageTitle: 'Gemini Hacker Starter',
|
|
||||||
pageDescription:
|
|
||||||
'Voice, vision, image generation, and real-time music with Gemini 3.1 Live, Nano Banana 2, and Lyria',
|
|
||||||
|
|
||||||
supportsChatInput: true,
|
|
||||||
supportsVideoInput: true,
|
|
||||||
supportsScreenShare: true,
|
|
||||||
isPreConnectBufferEnabled: true,
|
|
||||||
|
|
||||||
logo: '/lk-logo.svg',
|
|
||||||
accent: '#4285f4',
|
|
||||||
logoDark: '/lk-logo-dark.svg',
|
|
||||||
accentDark: '#1fd5f9',
|
|
||||||
startButtonText: 'Start hacking',
|
|
||||||
|
|
||||||
// agent dispatch configuration
|
|
||||||
agentName: process.env.AGENT_NAME ?? undefined,
|
|
||||||
|
|
||||||
// LiveKit Cloud Sandbox configuration
|
|
||||||
sandboxId: undefined,
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk';
|
|
||||||
import { RoomConfiguration } from '@livekit/protocol';
|
|
||||||
|
|
||||||
type ConnectionDetails = {
|
|
||||||
serverUrl: string;
|
|
||||||
roomName: string;
|
|
||||||
participantName: string;
|
|
||||||
participantToken: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// NOTE: you are expected to define the following environment variables in `.env.local`:
|
|
||||||
const API_KEY = process.env.LIVEKIT_API_KEY;
|
|
||||||
const API_SECRET = process.env.LIVEKIT_API_SECRET;
|
|
||||||
const LIVEKIT_URL = process.env.LIVEKIT_URL;
|
|
||||||
|
|
||||||
// don't cache the results
|
|
||||||
export const revalidate = 0;
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
if (LIVEKIT_URL === undefined) {
|
|
||||||
throw new Error('LIVEKIT_URL is not defined');
|
|
||||||
}
|
|
||||||
if (API_KEY === undefined) {
|
|
||||||
throw new Error('LIVEKIT_API_KEY is not defined');
|
|
||||||
}
|
|
||||||
if (API_SECRET === undefined) {
|
|
||||||
throw new Error('LIVEKIT_API_SECRET is not defined');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse agent configuration from request body
|
|
||||||
const body = await req.json();
|
|
||||||
const agentName: string = body?.room_config?.agents?.[0]?.agent_name;
|
|
||||||
|
|
||||||
// Generate participant token
|
|
||||||
const participantName = 'user';
|
|
||||||
const participantIdentity = `voice_assistant_user_${Math.floor(Math.random() * 10_000)}`;
|
|
||||||
const roomName = `voice_assistant_room_${Math.floor(Math.random() * 10_000)}`;
|
|
||||||
|
|
||||||
const participantToken = await createParticipantToken(
|
|
||||||
{ identity: participantIdentity, name: participantName },
|
|
||||||
roomName,
|
|
||||||
agentName
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return connection details
|
|
||||||
const data: ConnectionDetails = {
|
|
||||||
serverUrl: LIVEKIT_URL,
|
|
||||||
roomName,
|
|
||||||
participantToken: participantToken,
|
|
||||||
participantName,
|
|
||||||
};
|
|
||||||
const headers = new Headers({
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
return NextResponse.json(data, { headers });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error(error);
|
|
||||||
return new NextResponse(error.message, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createParticipantToken(
|
|
||||||
userInfo: AccessTokenOptions,
|
|
||||||
roomName: string,
|
|
||||||
agentName?: string
|
|
||||||
): Promise<string> {
|
|
||||||
const at = new AccessToken(API_KEY, API_SECRET, {
|
|
||||||
...userInfo,
|
|
||||||
ttl: '15m',
|
|
||||||
});
|
|
||||||
const grant: VideoGrant = {
|
|
||||||
room: roomName,
|
|
||||||
roomJoin: true,
|
|
||||||
canPublish: true,
|
|
||||||
canPublishData: true,
|
|
||||||
canSubscribe: true,
|
|
||||||
};
|
|
||||||
at.addGrant(grant);
|
|
||||||
|
|
||||||
if (agentName) {
|
|
||||||
at.roomConfig = new RoomConfiguration({
|
|
||||||
agents: [{ agentName }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return at.toJwt();
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,111 +0,0 @@
|
|||||||
import { Public_Sans } from 'next/font/google';
|
|
||||||
import localFont from 'next/font/local';
|
|
||||||
import { headers } from 'next/headers';
|
|
||||||
import { ThemeProvider } from '@/components/app/theme-provider';
|
|
||||||
import { ThemeToggle } from '@/components/app/theme-toggle';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
import { getAppConfig, getStyles } from '@/lib/utils';
|
|
||||||
import '@/styles/globals.css';
|
|
||||||
|
|
||||||
const publicSans = Public_Sans({
|
|
||||||
variable: '--font-public-sans',
|
|
||||||
subsets: ['latin'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const commitMono = localFont({
|
|
||||||
display: 'swap',
|
|
||||||
variable: '--font-commit-mono',
|
|
||||||
src: [
|
|
||||||
{
|
|
||||||
path: '../fonts/CommitMono-400-Regular.otf',
|
|
||||||
weight: '400',
|
|
||||||
style: 'normal',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '../fonts/CommitMono-700-Regular.otf',
|
|
||||||
weight: '700',
|
|
||||||
style: 'normal',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '../fonts/CommitMono-400-Italic.otf',
|
|
||||||
weight: '400',
|
|
||||||
style: 'italic',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '../fonts/CommitMono-700-Italic.otf',
|
|
||||||
weight: '700',
|
|
||||||
style: 'italic',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
interface RootLayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RootLayout({ children }: RootLayoutProps) {
|
|
||||||
const hdrs = await headers();
|
|
||||||
const appConfig = await getAppConfig(hdrs);
|
|
||||||
const styles = getStyles(appConfig);
|
|
||||||
const { pageTitle, pageDescription, companyName, logo, logoDark } = appConfig;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html
|
|
||||||
lang="en"
|
|
||||||
suppressHydrationWarning
|
|
||||||
className={cn(
|
|
||||||
publicSans.variable,
|
|
||||||
commitMono.variable,
|
|
||||||
'scroll-smooth font-sans antialiased'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<head>
|
|
||||||
{styles && <style>{styles}</style>}
|
|
||||||
<title>{pageTitle}</title>
|
|
||||||
<meta name="description" content={pageDescription} />
|
|
||||||
</head>
|
|
||||||
<body className="overflow-x-hidden">
|
|
||||||
<ThemeProvider
|
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<header className="fixed top-0 left-0 z-50 hidden w-full flex-row justify-between p-6 md:flex">
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
href="https://livekit.io"
|
|
||||||
className="scale-100 transition-transform duration-300 hover:scale-110"
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={logo} alt={`${companyName} Logo`} className="block size-6 dark:hidden" />
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={logoDark ?? logo}
|
|
||||||
alt={`${companyName} Logo`}
|
|
||||||
className="hidden size-6 dark:block"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<span className="text-foreground font-mono text-xs font-bold tracking-wider uppercase">
|
|
||||||
Built with{' '}
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
href="https://docs.livekit.io/agents"
|
|
||||||
className="underline underline-offset-4"
|
|
||||||
>
|
|
||||||
LiveKit Agents
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
<div className="group fixed bottom-0 left-1/2 z-50 mb-2 -translate-x-1/2">
|
|
||||||
<ThemeToggle className="translate-y-20 transition-transform delay-150 duration-300 group-hover:translate-y-0" />
|
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import { headers } from 'next/headers';
|
|
||||||
import { ImageResponse } from 'next/og';
|
|
||||||
import getImageSize from 'buffer-image-size';
|
|
||||||
import mime from 'mime';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { APP_CONFIG_DEFAULTS } from '@/app-config';
|
|
||||||
import { getAppConfig } from '@/lib/utils';
|
|
||||||
|
|
||||||
type Dimensions = {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImageData = {
|
|
||||||
base64: string;
|
|
||||||
dimensions: Dimensions;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Image metadata
|
|
||||||
export const alt = 'About Acme';
|
|
||||||
export const size = {
|
|
||||||
width: 1200,
|
|
||||||
height: 628,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRemoteFile(uri: string) {
|
|
||||||
return uri.startsWith('http');
|
|
||||||
}
|
|
||||||
|
|
||||||
function doesLocalFileExist(uri: string) {
|
|
||||||
return existsSync(join(process.cwd(), uri));
|
|
||||||
}
|
|
||||||
|
|
||||||
// LOCAL FILES MUST BE IN PUBLIC FOLDER
|
|
||||||
async function loadFileData(filePath: string): Promise<ArrayBuffer> {
|
|
||||||
if (isRemoteFile(filePath)) {
|
|
||||||
const response = await fetch(filePath);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch ${filePath} - ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
return await response.arrayBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try file system first (works in local development)
|
|
||||||
if (doesLocalFileExist(filePath)) {
|
|
||||||
const buffer = await readFile(join(process.cwd(), filePath));
|
|
||||||
return buffer.buffer.slice(
|
|
||||||
buffer.byteOffset,
|
|
||||||
buffer.byteOffset + buffer.byteLength
|
|
||||||
) as ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to fetching from public URL (works in production)
|
|
||||||
const publicFilePath = filePath.replace('public/', '');
|
|
||||||
const fontUrl = `https://${process.env.VERCEL_URL}/${publicFilePath}`;
|
|
||||||
|
|
||||||
const response = await fetch(fontUrl);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch ${fontUrl} - ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.arrayBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getImageData(uri: string, fallbackUri?: string): Promise<ImageData> {
|
|
||||||
try {
|
|
||||||
const fileData = await loadFileData(uri);
|
|
||||||
const buffer = Buffer.from(fileData);
|
|
||||||
const mimeType = mime.getType(uri);
|
|
||||||
|
|
||||||
return {
|
|
||||||
base64: `data:${mimeType};base64,${buffer.toString('base64')}`,
|
|
||||||
dimensions: getImageSize(buffer),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (fallbackUri) {
|
|
||||||
return getImageData(fallbackUri, fallbackUri);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scaleImageSize(size: { width: number; height: number }, desiredHeight: number) {
|
|
||||||
const scale = desiredHeight / size.height;
|
|
||||||
return {
|
|
||||||
width: size.width * scale,
|
|
||||||
height: desiredHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanPageTitle(appName: string) {
|
|
||||||
if (appName === APP_CONFIG_DEFAULTS.pageTitle) {
|
|
||||||
return 'Voice agent';
|
|
||||||
}
|
|
||||||
|
|
||||||
return appName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const contentType = 'image/png';
|
|
||||||
|
|
||||||
// Image generation
|
|
||||||
export default async function Image() {
|
|
||||||
const hdrs = await headers();
|
|
||||||
const appConfig = await getAppConfig(hdrs);
|
|
||||||
|
|
||||||
const pageTitle = cleanPageTitle(appConfig.pageTitle);
|
|
||||||
const logoUri = appConfig.logoDark || appConfig.logo;
|
|
||||||
const isLogoUriLocal = logoUri.includes('lk-logo');
|
|
||||||
const wordmarkUri = logoUri === APP_CONFIG_DEFAULTS.logoDark ? 'public/lk-wordmark.svg' : logoUri;
|
|
||||||
|
|
||||||
// Load fonts - use file system in dev, fetch in production
|
|
||||||
let commitMonoData: ArrayBuffer | undefined;
|
|
||||||
let everettLightData: ArrayBuffer | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
commitMonoData = await loadFileData('public/commit-mono-400-regular.woff');
|
|
||||||
everettLightData = await loadFileData('public/everett-light.woff');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load fonts:', e);
|
|
||||||
// Continue without custom fonts - will fall back to system fonts
|
|
||||||
}
|
|
||||||
|
|
||||||
// bg
|
|
||||||
const { base64: bgSrcBase64 } = await getImageData('public/opengraph-image-bg.png');
|
|
||||||
|
|
||||||
// wordmark
|
|
||||||
const { base64: wordmarkSrcBase64, dimensions: wordmarkDimensions } = isLogoUriLocal
|
|
||||||
? await getImageData(wordmarkUri)
|
|
||||||
: await getImageData(logoUri);
|
|
||||||
const wordmarkSize = scaleImageSize(wordmarkDimensions, isLogoUriLocal ? 32 : 64);
|
|
||||||
|
|
||||||
// logo
|
|
||||||
const { base64: logoSrcBase64, dimensions: logoDimensions } = await getImageData(
|
|
||||||
logoUri,
|
|
||||||
'public/lk-logo-dark.svg'
|
|
||||||
);
|
|
||||||
const logoSize = scaleImageSize(logoDimensions, 24);
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
// ImageResponse JSX element
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: size.width,
|
|
||||||
height: size.height,
|
|
||||||
backgroundImage: `url(${bgSrcBase64})`,
|
|
||||||
backgroundSize: '100% 100%',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* wordmark */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 30,
|
|
||||||
left: 30,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
|
||||||
<img src={wordmarkSrcBase64} width={wordmarkSize.width} height={wordmarkSize.height} />
|
|
||||||
</div>
|
|
||||||
{/* logo */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 200,
|
|
||||||
left: 460,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
|
||||||
<img src={logoSrcBase64} width={logoSize.width} height={logoSize.height} />
|
|
||||||
</div>
|
|
||||||
{/* title */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 100,
|
|
||||||
left: 30,
|
|
||||||
width: '380px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#1F1F1F',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
width: 72,
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'CommitMono',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#999999',
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SANDBOX
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 300,
|
|
||||||
fontFamily: 'Everett',
|
|
||||||
color: 'white',
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pageTitle}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
// ImageResponse options
|
|
||||||
{
|
|
||||||
// For convenience, we can re-use the exported opengraph-image
|
|
||||||
// size config to also set the ImageResponse's width and height.
|
|
||||||
...size,
|
|
||||||
fonts: [
|
|
||||||
...(commitMonoData
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: 'CommitMono',
|
|
||||||
data: commitMonoData,
|
|
||||||
style: 'normal' as const,
|
|
||||||
weight: 400 as const,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(everettLightData
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: 'Everett',
|
|
||||||
data: everettLightData,
|
|
||||||
style: 'normal' as const,
|
|
||||||
weight: 300 as const,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { headers } from 'next/headers';
|
|
||||||
import { App } from '@/components/app/app';
|
|
||||||
import { getAppConfig } from '@/lib/utils';
|
|
||||||
|
|
||||||
export default async function Page() {
|
|
||||||
const hdrs = await headers();
|
|
||||||
const appConfig = await getAppConfig(hdrs);
|
|
||||||
|
|
||||||
return <App appConfig={appConfig} />;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/shadcn/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib/shadcn",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"registries": {
|
|
||||||
"@agents-ui": "https://livekit.io/ui/r/{name}.json",
|
|
||||||
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-196
@@ -1,196 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
|
||||||
type CSSProperties,
|
|
||||||
Children,
|
|
||||||
type ComponentProps,
|
|
||||||
type ReactNode,
|
|
||||||
cloneElement,
|
|
||||||
isValidElement,
|
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
|
||||||
import { type VariantProps, cva } from 'class-variance-authority';
|
|
||||||
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
|
|
||||||
import {
|
|
||||||
type AgentState,
|
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
useMultibandTrackVolume,
|
|
||||||
} from '@livekit/components-react';
|
|
||||||
import { useAgentAudioVisualizerBarAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-bar';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
function cloneSingleChild(
|
|
||||||
children: ReactNode | ReactNode[],
|
|
||||||
props?: Record<string, unknown>,
|
|
||||||
key?: unknown
|
|
||||||
) {
|
|
||||||
return Children.map(children, (child) => {
|
|
||||||
// Checking isValidElement is the safe way and avoids a typescript error too.
|
|
||||||
if (isValidElement(child) && Children.only(children)) {
|
|
||||||
const childProps = child.props as Record<string, unknown>;
|
|
||||||
if (childProps.className) {
|
|
||||||
// make sure we retain classnames of both passed props and child
|
|
||||||
props ??= {};
|
|
||||||
props.className = cn(childProps.className as string, props.className as string);
|
|
||||||
props.style = {
|
|
||||||
...(childProps.style as CSSProperties),
|
|
||||||
...(props.style as CSSProperties),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return cloneElement(child, { ...props, key: key ? String(key) : undefined });
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentAudioVisualizerBarVariants = cva(
|
|
||||||
[
|
|
||||||
'relative flex items-center justify-center',
|
|
||||||
'*:rounded-full *:transition-colors *:duration-250 *:ease-linear',
|
|
||||||
'*:bg-transparent *:data-[lk-highlighted=true]:bg-current',
|
|
||||||
],
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
icon: ['h-[24px] gap-[2px]', '*:w-[4px] *:min-h-[4px]'],
|
|
||||||
sm: ['h-[56px] gap-[4px]', '*:w-[8px] *:min-h-[8px]'],
|
|
||||||
md: ['h-[112px] gap-[8px]', '*:w-[16px] *:min-h-[16px]'],
|
|
||||||
lg: ['h-[224px] gap-[16px]', '*:w-[32px] *:min-h-[32px]'],
|
|
||||||
xl: ['h-[448px] gap-[32px]', '*:w-[64px] *:min-h-[64px]'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: 'md',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AgentAudioVisualizerBar component.
|
|
||||||
*/
|
|
||||||
export interface AgentAudioVisualizerBarProps {
|
|
||||||
/**
|
|
||||||
* The size of the visualizer.
|
|
||||||
* @defaultValue 'md'
|
|
||||||
*/
|
|
||||||
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
|
|
||||||
/**
|
|
||||||
* The current state of the agent. Determines the animation pattern.
|
|
||||||
* @defaultValue 'connecting'
|
|
||||||
*/
|
|
||||||
state?: AgentState;
|
|
||||||
/**
|
|
||||||
* The number of bars to display in the visualizer.
|
|
||||||
* If not provided, defaults based on size: 3 for 'icon'/'sm', 5 for others.
|
|
||||||
*/
|
|
||||||
barCount?: number;
|
|
||||||
/**
|
|
||||||
* The audio track to visualize. Can be a local/remote audio track or a track reference.
|
|
||||||
*/
|
|
||||||
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
|
|
||||||
/**
|
|
||||||
* Additional CSS class names to apply to the container.
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
/**
|
|
||||||
* Custom children to render as bars. Each child receives data-lk-index,
|
|
||||||
* data-lk-highlighted, and style props for height.
|
|
||||||
*/
|
|
||||||
children?: ReactNode | ReactNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bar-style audio visualizer that responds to agent state and audio levels.
|
|
||||||
* Displays animated bars that react to the current agent state (connecting, thinking, speaking, etc.)
|
|
||||||
* and audio volume when speaking.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'div'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <AgentAudioVisualizerBar
|
|
||||||
* size="md"
|
|
||||||
* state="speaking"
|
|
||||||
* audioTrack={agentAudioTrack}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentAudioVisualizerBar({
|
|
||||||
size = 'md',
|
|
||||||
state = 'connecting',
|
|
||||||
barCount,
|
|
||||||
audioTrack,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: AgentAudioVisualizerBarProps &
|
|
||||||
VariantProps<typeof AgentAudioVisualizerBarVariants> &
|
|
||||||
ComponentProps<'div'>) {
|
|
||||||
const _barCount = useMemo(() => {
|
|
||||||
if (barCount) {
|
|
||||||
return barCount;
|
|
||||||
}
|
|
||||||
switch (size) {
|
|
||||||
case 'icon':
|
|
||||||
case 'sm':
|
|
||||||
return 3;
|
|
||||||
default:
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
}, [barCount, size]);
|
|
||||||
|
|
||||||
const volumeBands = useMultibandTrackVolume(audioTrack, {
|
|
||||||
bands: _barCount,
|
|
||||||
loPass: 100,
|
|
||||||
hiPass: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sequencerInterval = useMemo(() => {
|
|
||||||
switch (state) {
|
|
||||||
case 'connecting':
|
|
||||||
return 2000 / _barCount;
|
|
||||||
case 'initializing':
|
|
||||||
return 2000;
|
|
||||||
case 'listening':
|
|
||||||
return 500;
|
|
||||||
case 'thinking':
|
|
||||||
return 150;
|
|
||||||
default:
|
|
||||||
return 1000;
|
|
||||||
}
|
|
||||||
}, [state, _barCount]);
|
|
||||||
|
|
||||||
const highlightedIndices = useAgentAudioVisualizerBarAnimator(
|
|
||||||
state,
|
|
||||||
_barCount,
|
|
||||||
sequencerInterval
|
|
||||||
);
|
|
||||||
|
|
||||||
const bands = useMemo(
|
|
||||||
() => (state === 'speaking' ? volumeBands : new Array(_barCount).fill(0)),
|
|
||||||
[state, volumeBands, _barCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(AgentAudioVisualizerBarVariants({ size }), className)} {...props}>
|
|
||||||
{bands.map((band: number, idx: number) =>
|
|
||||||
children ? (
|
|
||||||
<React.Fragment key={idx}>
|
|
||||||
{cloneSingleChild(children, {
|
|
||||||
'data-lk-index': idx,
|
|
||||||
'data-lk-highlighted': highlightedIndices.includes(idx),
|
|
||||||
style: { height: `${band * 100}%` },
|
|
||||||
})}
|
|
||||||
</React.Fragment>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
data-lk-index={idx}
|
|
||||||
data-lk-highlighted={highlightedIndices.includes(idx)}
|
|
||||||
style={{ height: `${band * 100}%` }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-290
@@ -1,290 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {
|
|
||||||
type CSSProperties,
|
|
||||||
Children,
|
|
||||||
type ComponentProps,
|
|
||||||
type ReactNode,
|
|
||||||
cloneElement,
|
|
||||||
isValidElement,
|
|
||||||
memo,
|
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
|
||||||
import { type VariantProps, cva } from 'class-variance-authority';
|
|
||||||
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
|
|
||||||
import {
|
|
||||||
type AgentState,
|
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
useMultibandTrackVolume,
|
|
||||||
} from '@livekit/components-react';
|
|
||||||
import {
|
|
||||||
type Coordinate,
|
|
||||||
useAgentAudioVisualizerGridAnimator,
|
|
||||||
} from '@/hooks/agents-ui/use-agent-audio-visualizer-grid';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
function cloneSingleChild(
|
|
||||||
children: ReactNode | ReactNode[],
|
|
||||||
props?: Record<string, unknown>,
|
|
||||||
key?: unknown
|
|
||||||
) {
|
|
||||||
return Children.map(children, (child) => {
|
|
||||||
// Checking isValidElement is the safe way and avoids a typescript error too.
|
|
||||||
if (isValidElement(child) && Children.only(children)) {
|
|
||||||
const childProps = child.props as Record<string, unknown>;
|
|
||||||
if (childProps.className) {
|
|
||||||
// make sure we retain classnames of both passed props and child
|
|
||||||
props ??= {};
|
|
||||||
props.className = cn(childProps.className as string, props.className as string);
|
|
||||||
props.style = {
|
|
||||||
...(childProps.style as CSSProperties),
|
|
||||||
...(props.style as CSSProperties),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return cloneElement(child, { ...props, key: key ? String(key) : undefined });
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentAudioVisualizerGridVariants = cva(
|
|
||||||
[
|
|
||||||
'grid',
|
|
||||||
'*:size-1 *:rounded-full',
|
|
||||||
'*:bg-foreground/10 [&_>_[data-lk-highlighted=true]]:bg-foreground [&_>_[data-lk-highlighted=true]]:scale-125 [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_10px_2px_rgba(255,255,255,0.4)]',
|
|
||||||
],
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
icon: ['gap-[2px] *:size-[4px]'],
|
|
||||||
sm: ['gap-[4px] *:size-[4px]'],
|
|
||||||
md: ['gap-[8px] *:size-[8px]'],
|
|
||||||
lg: ['gap-[8px] *:size-[8px]'],
|
|
||||||
xl: ['gap-[8px] *:size-[8px]'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: 'md',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for the grid visualizer.
|
|
||||||
*/
|
|
||||||
export interface GridOptions {
|
|
||||||
/**
|
|
||||||
* The radius for the animation spread effect.
|
|
||||||
*/
|
|
||||||
radius?: number;
|
|
||||||
/**
|
|
||||||
* The interval in milliseconds between animation frames.
|
|
||||||
* @defaultValue 100
|
|
||||||
*/
|
|
||||||
interval?: number;
|
|
||||||
/**
|
|
||||||
* The number of rows in the grid.
|
|
||||||
* @defaultValue 5
|
|
||||||
*/
|
|
||||||
rowCount?: number;
|
|
||||||
/**
|
|
||||||
* The number of columns in the grid.
|
|
||||||
* @defaultValue 5
|
|
||||||
*/
|
|
||||||
columnCount?: number;
|
|
||||||
/**
|
|
||||||
* A function to transform the style of each grid cell based on its position.
|
|
||||||
* Receives the cell index, row count, and column count as arguments.
|
|
||||||
*/
|
|
||||||
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
|
|
||||||
/**
|
|
||||||
* Additional CSS class names to apply to the container.
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
/**
|
|
||||||
* Custom children to render as grid cells.
|
|
||||||
*/
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sizeDefaults = {
|
|
||||||
icon: 3,
|
|
||||||
sm: 5,
|
|
||||||
md: 5,
|
|
||||||
lg: 5,
|
|
||||||
xl: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
function useGrid(
|
|
||||||
size: VariantProps<typeof AgentAudioVisualizerGridVariants>['size'] = 'md',
|
|
||||||
columnCount = sizeDefaults[size as keyof typeof sizeDefaults],
|
|
||||||
rowCount = sizeDefaults[size as keyof typeof sizeDefaults]
|
|
||||||
) {
|
|
||||||
return useMemo(() => {
|
|
||||||
const _columnCount = columnCount;
|
|
||||||
const _rowCount = rowCount ?? columnCount;
|
|
||||||
const items = new Array(_columnCount * _rowCount).fill(0).map((_, idx) => idx);
|
|
||||||
|
|
||||||
return { columnCount: _columnCount, rowCount: _rowCount, items };
|
|
||||||
}, [columnCount, rowCount]);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GridCellProps {
|
|
||||||
index: number;
|
|
||||||
state: AgentState;
|
|
||||||
interval: number;
|
|
||||||
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
|
|
||||||
rowCount: number;
|
|
||||||
columnCount: number;
|
|
||||||
volumeBands: number[];
|
|
||||||
highlightedCoordinate: Coordinate;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GridCell = memo(function GridCell({
|
|
||||||
index,
|
|
||||||
state,
|
|
||||||
interval,
|
|
||||||
transformer,
|
|
||||||
rowCount,
|
|
||||||
columnCount,
|
|
||||||
volumeBands,
|
|
||||||
highlightedCoordinate,
|
|
||||||
children,
|
|
||||||
}: GridCellProps) {
|
|
||||||
if (state === 'speaking') {
|
|
||||||
const y = Math.floor(index / columnCount);
|
|
||||||
const rowMidPoint = Math.floor(rowCount / 2);
|
|
||||||
const volumeChunks = 1 / (rowMidPoint + 1);
|
|
||||||
const distanceToMid = Math.abs(rowMidPoint - y);
|
|
||||||
const threshold = distanceToMid * volumeChunks;
|
|
||||||
const isHighlighted = (volumeBands[index % columnCount] ?? 0) >= threshold;
|
|
||||||
|
|
||||||
return cloneSingleChild(children, {
|
|
||||||
'data-lk-index': index,
|
|
||||||
'data-lk-highlighted': isHighlighted,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let transformerStyle: CSSProperties | undefined;
|
|
||||||
if (transformer) {
|
|
||||||
transformerStyle = transformer(index, rowCount, columnCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHighlighted =
|
|
||||||
highlightedCoordinate.x === index % columnCount &&
|
|
||||||
highlightedCoordinate.y === Math.floor(index / columnCount);
|
|
||||||
|
|
||||||
const transitionDurationInSeconds = interval / (isHighlighted ? 1000 : 100);
|
|
||||||
|
|
||||||
return cloneSingleChild(children, {
|
|
||||||
'data-lk-index': index,
|
|
||||||
'data-lk-highlighted': isHighlighted,
|
|
||||||
style: {
|
|
||||||
transitionProperty: 'all',
|
|
||||||
transitionDuration: `${transitionDurationInSeconds}s`,
|
|
||||||
transitionTimingFunction: 'ease-out',
|
|
||||||
...transformerStyle,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AgentAudioVisualizerGrid component.
|
|
||||||
*/
|
|
||||||
export type AgentAudioVisualizerGridProps = GridOptions & {
|
|
||||||
/**
|
|
||||||
* The size of the visualizer.
|
|
||||||
* @defaultValue 'md'
|
|
||||||
*/
|
|
||||||
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
|
|
||||||
/**
|
|
||||||
* The current state of the agent. Determines the animation pattern.
|
|
||||||
* @defaultValue 'connecting'
|
|
||||||
*/
|
|
||||||
state?: AgentState;
|
|
||||||
/**
|
|
||||||
* The audio track to visualize. Can be a local/remote audio track or a track reference.
|
|
||||||
*/
|
|
||||||
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
|
|
||||||
/**
|
|
||||||
* Additional CSS class names to apply to the container.
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
/**
|
|
||||||
* Custom children to render as grid cells. Each child receives data-lk-index
|
|
||||||
* and data-lk-highlighted props.
|
|
||||||
*/
|
|
||||||
children?: ReactNode;
|
|
||||||
} & VariantProps<typeof AgentAudioVisualizerGridVariants>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A grid-style audio visualizer that responds to agent state and audio levels.
|
|
||||||
* Displays an animated grid of cells that react to the current agent state
|
|
||||||
* and audio volume when speaking.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'div'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <AgentAudioVisualizerGrid
|
|
||||||
* size="md"
|
|
||||||
* state="speaking"
|
|
||||||
* rowCount={5}
|
|
||||||
* columnCount={5}
|
|
||||||
* audioTrack={agentAudioTrack}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentAudioVisualizerGrid({
|
|
||||||
size = 'md',
|
|
||||||
state = 'connecting',
|
|
||||||
radius,
|
|
||||||
rowCount: _rowCount = 5,
|
|
||||||
columnCount: _columnCount = 5,
|
|
||||||
transformer,
|
|
||||||
interval = 100,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
audioTrack,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
}: AgentAudioVisualizerGridProps & ComponentProps<'div'>) {
|
|
||||||
const { columnCount, rowCount, items } = useGrid(size, _columnCount, _rowCount);
|
|
||||||
const highlightedCoordinate = useAgentAudioVisualizerGridAnimator(
|
|
||||||
state,
|
|
||||||
rowCount,
|
|
||||||
columnCount,
|
|
||||||
interval,
|
|
||||||
radius
|
|
||||||
);
|
|
||||||
const volumeBands = useMultibandTrackVolume(audioTrack, {
|
|
||||||
bands: columnCount,
|
|
||||||
loPass: 100,
|
|
||||||
hiPass: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(AgentAudioVisualizerGridVariants({ size }), className)}
|
|
||||||
style={{ ...style, gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{items.map((idx) => (
|
|
||||||
<GridCell
|
|
||||||
key={idx}
|
|
||||||
index={idx}
|
|
||||||
state={state}
|
|
||||||
interval={interval}
|
|
||||||
transformer={transformer}
|
|
||||||
rowCount={rowCount}
|
|
||||||
columnCount={columnCount}
|
|
||||||
volumeBands={volumeBands}
|
|
||||||
highlightedCoordinate={highlightedCoordinate}
|
|
||||||
>
|
|
||||||
{children ?? <div />}
|
|
||||||
</GridCell>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-205
@@ -1,205 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { type ComponentProps, useMemo } from 'react';
|
|
||||||
import { type VariantProps, cva } from 'class-variance-authority';
|
|
||||||
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
|
|
||||||
import {
|
|
||||||
type AgentState,
|
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
useMultibandTrackVolume,
|
|
||||||
} from '@livekit/components-react';
|
|
||||||
import { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-radial';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
export const AgentAudioVisualizerRadialVariants = cva(
|
|
||||||
[
|
|
||||||
'relative flex items-center justify-center',
|
|
||||||
'[&_[data-lk-index]]:absolute [&_[data-lk-index]]:top-1/2 [&_[data-lk-index]]:left-1/2 [&_[data-lk-index]]:origin-bottom [&_[data-lk-index]]:-translate-x-1/2',
|
|
||||||
'[&_[data-lk-index]]:rounded-full [&_[data-lk-index]]:transition-colors [&_[data-lk-index]]:duration-150 [&_[data-lk-index]]:ease-linear [&_[data-lk-index]]:bg-transparent [&_[data-lk-index]]:data-[lk-highlighted=true]:bg-current',
|
|
||||||
'has-data-[lk-state=connecting]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=connecting]:[&_[data-lk-index]]:bg-current/10',
|
|
||||||
'has-data-[lk-state=initializing]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=initializing]:[&_[data-lk-index]]:bg-current/10',
|
|
||||||
'has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=listening]:[&_[data-lk-index]]:bg-current/10 has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300',
|
|
||||||
'has-data-[lk-state=thinking]:animate-spin has-data-[lk-state=thinking]:[animation-duration:5s] has-data-[lk-state=thinking]:[&_[data-lk-index]]:bg-current',
|
|
||||||
],
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
icon: ['h-[24px] gap-[2px]'],
|
|
||||||
sm: ['h-[56px] gap-[4px]'],
|
|
||||||
md: ['h-[112px] gap-[8px]'],
|
|
||||||
lg: ['h-[224px] gap-[16px]'],
|
|
||||||
xl: ['h-[448px] gap-[32px]'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: 'md',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AgentAudioVisualizerRadial component.
|
|
||||||
*/
|
|
||||||
export interface AgentAudioVisualizerRadialProps {
|
|
||||||
/**
|
|
||||||
* The size of the visualizer.
|
|
||||||
* @defaultValue 'md'
|
|
||||||
*/
|
|
||||||
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
|
|
||||||
/**
|
|
||||||
* The current state of the agent. Determines the animation pattern.
|
|
||||||
* @defaultValue 'connecting'
|
|
||||||
*/
|
|
||||||
state?: AgentState;
|
|
||||||
/**
|
|
||||||
* The radius (distance from center) for the radial bars.
|
|
||||||
* If not provided, defaults based on size.
|
|
||||||
*/
|
|
||||||
radius?: number;
|
|
||||||
/**
|
|
||||||
* The number of bars to display around the circle.
|
|
||||||
* Should be divisible by 4 for optimal visual results.
|
|
||||||
* If not provided, defaults to 12 for 'icon'/'sm', 24 for others.
|
|
||||||
*/
|
|
||||||
barCount?: number;
|
|
||||||
/**
|
|
||||||
* The audio track to visualize. Can be a local/remote audio track or a track reference.
|
|
||||||
*/
|
|
||||||
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
|
|
||||||
/**
|
|
||||||
* Additional CSS class names to apply to the container.
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A radial (circular) audio visualizer that responds to agent state and audio levels.
|
|
||||||
* Displays animated bars arranged in a circle that react to the current agent state
|
|
||||||
* and audio volume when speaking.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'div'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <AgentAudioVisualizerRadial
|
|
||||||
* size="lg"
|
|
||||||
* state="speaking"
|
|
||||||
* barCount={24}
|
|
||||||
* audioTrack={agentAudioTrack}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentAudioVisualizerRadial({
|
|
||||||
size = 'md',
|
|
||||||
state = 'connecting',
|
|
||||||
radius,
|
|
||||||
barCount,
|
|
||||||
audioTrack,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AgentAudioVisualizerRadialProps &
|
|
||||||
ComponentProps<'div'> &
|
|
||||||
VariantProps<typeof AgentAudioVisualizerRadialVariants>) {
|
|
||||||
const _barCount = useMemo(() => {
|
|
||||||
if (barCount) {
|
|
||||||
return barCount;
|
|
||||||
}
|
|
||||||
switch (size) {
|
|
||||||
case 'icon':
|
|
||||||
case 'sm':
|
|
||||||
return 12;
|
|
||||||
default:
|
|
||||||
return 24;
|
|
||||||
}
|
|
||||||
}, [barCount, size]);
|
|
||||||
|
|
||||||
const volumeBands = useMultibandTrackVolume(audioTrack, {
|
|
||||||
bands: _barCount,
|
|
||||||
loPass: 100,
|
|
||||||
hiPass: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sequencerInterval = useMemo(() => {
|
|
||||||
switch (state) {
|
|
||||||
case 'connecting':
|
|
||||||
case 'listening':
|
|
||||||
return 500;
|
|
||||||
case 'initializing':
|
|
||||||
return 250;
|
|
||||||
case 'thinking':
|
|
||||||
return Infinity;
|
|
||||||
default:
|
|
||||||
return 1000;
|
|
||||||
}
|
|
||||||
}, [state, _barCount]);
|
|
||||||
|
|
||||||
const distanceFromCenter = useMemo(() => {
|
|
||||||
if (radius) {
|
|
||||||
return radius;
|
|
||||||
}
|
|
||||||
switch (size) {
|
|
||||||
case 'icon':
|
|
||||||
return 6;
|
|
||||||
case 'xl':
|
|
||||||
return 128;
|
|
||||||
case 'lg':
|
|
||||||
return 64;
|
|
||||||
case 'sm':
|
|
||||||
return 16;
|
|
||||||
case 'md':
|
|
||||||
default:
|
|
||||||
return 32;
|
|
||||||
}
|
|
||||||
}, [size, radius]);
|
|
||||||
|
|
||||||
if (_barCount % 4 !== 0) {
|
|
||||||
console.warn('barCount should be divisible by 4 for optimal visual results');
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightedIndices = useAgentAudioVisualizerRadialAnimator(
|
|
||||||
state,
|
|
||||||
_barCount,
|
|
||||||
sequencerInterval
|
|
||||||
);
|
|
||||||
const bands = useMemo(
|
|
||||||
() => (audioTrack ? volumeBands : new Array(_barCount).fill(0)),
|
|
||||||
[audioTrack, volumeBands, _barCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dotSize = useMemo(() => {
|
|
||||||
return (distanceFromCenter * Math.PI) / _barCount;
|
|
||||||
}, [distanceFromCenter, _barCount]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(AgentAudioVisualizerRadialVariants({ size }), 'relative', className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{bands.map((band, idx) => {
|
|
||||||
const angle = (idx / _barCount) * Math.PI * 2;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${_barCount}-${idx}`}
|
|
||||||
data-lk-state={state}
|
|
||||||
className="absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2"
|
|
||||||
style={{
|
|
||||||
transformOrigin: 'center',
|
|
||||||
transform: `rotate(${angle}rad) translateY(${distanceFromCenter}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-lk-index={idx}
|
|
||||||
data-lk-highlighted={highlightedIndices.includes(idx)}
|
|
||||||
style={{
|
|
||||||
width: dotSize,
|
|
||||||
minHeight: dotSize,
|
|
||||||
height: state === 'speaking' ? `${dotSize * 10 * band}px` : 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-89
@@ -1,89 +0,0 @@
|
|||||||
import { type Ref } from 'react';
|
|
||||||
import { type VariantProps, cva } from 'class-variance-authority';
|
|
||||||
import { type MotionProps, motion } from 'motion/react';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
const motionAnimationProps = {
|
|
||||||
variants: {
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0.1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.1,
|
|
||||||
ease: 'linear' as const,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
opacity: [0.5, 1],
|
|
||||||
scale: [1, 1.2],
|
|
||||||
transition: {
|
|
||||||
type: 'spring' as const,
|
|
||||||
bounce: 0,
|
|
||||||
duration: 0.5,
|
|
||||||
repeat: Infinity,
|
|
||||||
repeatType: 'mirror' as const,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
initial: 'hidden',
|
|
||||||
animate: 'visible',
|
|
||||||
exit: 'hidden',
|
|
||||||
};
|
|
||||||
|
|
||||||
const agentChatIndicatorVariants = cva('bg-muted-foreground inline-block size-2.5 rounded-full', {
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
sm: 'size-2.5',
|
|
||||||
md: 'size-4',
|
|
||||||
lg: 'size-6',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: 'md',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AgentChatIndicator component.
|
|
||||||
*/
|
|
||||||
export interface AgentChatIndicatorProps extends MotionProps {
|
|
||||||
/**
|
|
||||||
* The size of the indicator dot.
|
|
||||||
* @defaultValue 'md'
|
|
||||||
*/
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
/**
|
|
||||||
* Additional CSS class names to apply to the indicator.
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
/**
|
|
||||||
* Allows getting a ref to the component instance.\nOnce the component unmounts, React will set `ref.current` to `null`\n(or call the ref with `null` if you passed a callback ref).\n@see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
|
|
||||||
*/
|
|
||||||
ref?: Ref<HTMLSpanElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An animated indicator that shows the agent is processing or thinking.
|
|
||||||
* Displays as a pulsing dot, typically used in chat interfaces.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'span'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* {agentState === 'thinking' && <AgentChatIndicator size="md" />}
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentChatIndicator({
|
|
||||||
size = 'md',
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AgentChatIndicatorProps & VariantProps<typeof agentChatIndicatorVariants>) {
|
|
||||||
return (
|
|
||||||
<motion.span
|
|
||||||
{...motionAnimationProps}
|
|
||||||
transition={{ duration: 0.1, ease: 'linear' as const }}
|
|
||||||
className={cn(agentChatIndicatorVariants({ size }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AnimatePresence } from 'motion/react';
|
|
||||||
import { type AgentState, type ReceivedMessage } from '@livekit/components-react';
|
|
||||||
import { AgentChatIndicator } from '@/components/agents-ui/agent-chat-indicator';
|
|
||||||
import {
|
|
||||||
Conversation,
|
|
||||||
ConversationContent,
|
|
||||||
ConversationScrollButton,
|
|
||||||
} from '@/components/ai-elements/conversation';
|
|
||||||
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AgentChatTranscript component.
|
|
||||||
*/
|
|
||||||
export interface AgentChatTranscriptProps {
|
|
||||||
/**
|
|
||||||
* The current state of the agent. When 'thinking', displays a loading indicator.
|
|
||||||
*/
|
|
||||||
agentState?: AgentState;
|
|
||||||
/**
|
|
||||||
* Array of messages to display in the transcript.
|
|
||||||
* @defaultValue []
|
|
||||||
*/
|
|
||||||
messages?: ReceivedMessage[];
|
|
||||||
/**
|
|
||||||
* Additional CSS class names to apply to the conversation container.
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A chat transcript component that displays a conversation between the user and agent.
|
|
||||||
* Shows messages with timestamps and origin indicators, plus a thinking indicator
|
|
||||||
* when the agent is processing.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'div'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <AgentChatTranscript
|
|
||||||
* agentState={agentState}
|
|
||||||
* messages={chatMessages}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentChatTranscript({
|
|
||||||
agentState,
|
|
||||||
messages = [],
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AgentChatTranscriptProps) {
|
|
||||||
return (
|
|
||||||
<Conversation className={className} {...props}>
|
|
||||||
<ConversationContent>
|
|
||||||
{messages.map((receivedMessage) => {
|
|
||||||
const { id, timestamp, from, message } = receivedMessage;
|
|
||||||
const locale = navigator?.language ?? 'en-US';
|
|
||||||
const messageOrigin = from?.isLocal ? 'user' : 'assistant';
|
|
||||||
const time = new Date(timestamp);
|
|
||||||
const title = time.toLocaleTimeString(locale, { timeStyle: 'full' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Message key={id} title={title} from={messageOrigin}>
|
|
||||||
<MessageContent>
|
|
||||||
<MessageResponse>{message}</MessageResponse>
|
|
||||||
</MessageContent>
|
|
||||||
</Message>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<AnimatePresence>
|
|
||||||
{agentState === 'thinking' && <AgentChatIndicator size="sm" />}
|
|
||||||
</AnimatePresence>
|
|
||||||
</ConversationContent>
|
|
||||||
<ConversationScrollButton />
|
|
||||||
</Conversation>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-392
@@ -1,392 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { type ComponentProps, useEffect, useRef, useState } from 'react';
|
|
||||||
import { Track } from 'livekit-client';
|
|
||||||
import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import { useChat } from '@livekit/components-react';
|
|
||||||
import { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button';
|
|
||||||
import { AgentTrackControl } from '@/components/agents-ui/agent-track-control';
|
|
||||||
import {
|
|
||||||
AgentTrackToggle,
|
|
||||||
agentTrackToggleVariants,
|
|
||||||
} from '@/components/agents-ui/agent-track-toggle';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Toggle } from '@/components/ui/toggle';
|
|
||||||
import {
|
|
||||||
type UseInputControlsProps,
|
|
||||||
useInputControls,
|
|
||||||
usePublishPermissions,
|
|
||||||
} from '@/hooks/agents-ui/use-agent-control-bar';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
const TOGGLE_VARIANT_1 = [
|
|
||||||
'[&_[data-state=off]]:bg-accent [&_[data-state=off]]:hover:bg-foreground/10',
|
|
||||||
'[&_[data-state=off]_~_button]:bg-accent [&_[data-state=off]_~_button]:hover:bg-foreground/10',
|
|
||||||
'[&_[data-state=off]]:border-border [&_[data-state=off]]:hover:border-foreground/12',
|
|
||||||
'[&_[data-state=off]_~_button]:border-border [&_[data-state=off]_~_button]:hover:border-foreground/12',
|
|
||||||
'[&_[data-state=off]]:text-destructive [&_[data-state=off]]:hover:text-destructive [&_[data-state=off]]:focus:text-destructive',
|
|
||||||
'[&_[data-state=off]]:focus-visible:ring-foreground/12 [&_[data-state=off]]:focus-visible:border-ring',
|
|
||||||
'dark:[&_[data-state=off]_~_button]:bg-accent dark:[&_[data-state=off]_~_button:hover]:bg-foreground/10',
|
|
||||||
];
|
|
||||||
|
|
||||||
const TOGGLE_VARIANT_2 = [
|
|
||||||
'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
|
|
||||||
'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
|
|
||||||
'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12',
|
|
||||||
'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground',
|
|
||||||
'data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30',
|
|
||||||
'data-[state=on]:border-blue-700/10 data-[state=on]:text-blue-700 data-[state=on]:ring-blue-700/30',
|
|
||||||
'data-[state=on]:focus-visible:border-blue-700/50',
|
|
||||||
'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',
|
|
||||||
];
|
|
||||||
|
|
||||||
const MOTION_PROPS = {
|
|
||||||
variants: {
|
|
||||||
hidden: {
|
|
||||||
height: 0,
|
|
||||||
opacity: 0,
|
|
||||||
marginBottom: 0,
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
height: 'auto',
|
|
||||||
opacity: 1,
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
initial: 'hidden',
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
ease: 'easeOut',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AgentChatInputProps {
|
|
||||||
chatOpen: boolean;
|
|
||||||
onSend?: (message: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentChatInputProps) {
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [isSending, setIsSending] = useState(false);
|
|
||||||
const [message, setMessage] = useState<string>('');
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsSending(true);
|
|
||||||
await onSend(message);
|
|
||||||
setMessage('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDisabled = isSending || message.trim().length === 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatOpen) return;
|
|
||||||
// when not disabled refocus on input
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, [chatOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className={cn('mb-3 flex grow items-end gap-2 rounded-md pl-1 text-sm', className)}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
autoFocus
|
|
||||||
ref={inputRef}
|
|
||||||
value={message}
|
|
||||||
disabled={!chatOpen}
|
|
||||||
placeholder="Type something..."
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
className="field-sizing-content max-h-16 min-h-8 flex-1 py-2 [scrollbar-width:thin] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
type="submit"
|
|
||||||
disabled={isDisabled}
|
|
||||||
variant={isDisabled ? 'secondary' : 'default'}
|
|
||||||
title={isSending ? 'Sending...' : 'Send'}
|
|
||||||
className="self-end disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isSending ? <Loader className="animate-spin" /> : <SendHorizontal />}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for which controls to display in the AgentControlBar.
|
|
||||||
*/
|
|
||||||
export interface AgentControlBarControls {
|
|
||||||
/**
|
|
||||||
* Whether to show the leave/disconnect button.
|
|
||||||
* @defaultValue true
|
|
||||||
*/
|
|
||||||
leave?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether to show the camera toggle control.
|
|
||||||
* @defaultValue true (if camera publish permission is granted)
|
|
||||||
*/
|
|
||||||
camera?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether to show the microphone toggle control.
|
|
||||||
* @defaultValue true (if microphone publish permission is granted)
|
|
||||||
*/
|
|
||||||
microphone?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether to show the screen share toggle control.
|
|
||||||
* @defaultValue true (if screen share publish permission is granted)
|
|
||||||
*/
|
|
||||||
screenShare?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether to show the chat toggle control.
|
|
||||||
* @defaultValue true (if data publish permission is granted)
|
|
||||||
*/
|
|
||||||
chat?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentControlBarProps extends UseInputControlsProps {
|
|
||||||
/**
|
|
||||||
* The visual style of the control bar.
|
|
||||||
* @default 'default'
|
|
||||||
*/
|
|
||||||
variant?: 'default' | 'outline' | 'livekit';
|
|
||||||
/**
|
|
||||||
* This takes an object with the following keys: `leave`, `microphone`, `screenShare`, `camera`, `chat`.
|
|
||||||
* Each key maps to a boolean value that determines whether the control is displayed.
|
|
||||||
*
|
|
||||||
* @default
|
|
||||||
* {
|
|
||||||
* leave: true,
|
|
||||||
* microphone: true,
|
|
||||||
* screenShare: true,
|
|
||||||
* camera: true,
|
|
||||||
* chat: true,
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
controls?: AgentControlBarControls;
|
|
||||||
/**
|
|
||||||
* Whether to save user choices.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
saveUserChoices?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the agent is connected to a session.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
isConnected?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the chat input interface is open.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
isChatOpen?: boolean;
|
|
||||||
/**
|
|
||||||
* The callback for when the user disconnects.
|
|
||||||
*/
|
|
||||||
onDisconnect?: () => void;
|
|
||||||
/**
|
|
||||||
* The callback for when the chat is opened or closed.
|
|
||||||
*/
|
|
||||||
onIsChatOpenChange?: (open: boolean) => void;
|
|
||||||
/**
|
|
||||||
* The callback for when a device error occurs.
|
|
||||||
*/
|
|
||||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A control bar specifically designed for voice assistant interfaces.
|
|
||||||
* Provides controls for microphone, camera, screen share, chat, and disconnect.
|
|
||||||
* Includes an expandable chat input for text-based interaction with the agent.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'div'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <AgentControlBar
|
|
||||||
* variant="livekit"
|
|
||||||
* isConnected={true}
|
|
||||||
* onDisconnect={() => handleDisconnect()}
|
|
||||||
* controls={{
|
|
||||||
* microphone: true,
|
|
||||||
* camera: true,
|
|
||||||
* screenShare: false,
|
|
||||||
* chat: true,
|
|
||||||
* leave: true,
|
|
||||||
* }}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentControlBar({
|
|
||||||
variant = 'default',
|
|
||||||
controls,
|
|
||||||
isChatOpen = false,
|
|
||||||
isConnected = false,
|
|
||||||
saveUserChoices = true,
|
|
||||||
onDisconnect,
|
|
||||||
onDeviceError,
|
|
||||||
onIsChatOpenChange,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AgentControlBarProps & ComponentProps<'div'>) {
|
|
||||||
const { send } = useChat();
|
|
||||||
const publishPermissions = usePublishPermissions();
|
|
||||||
const [isChatOpenUncontrolled, setIsChatOpenUncontrolled] = useState(isChatOpen);
|
|
||||||
const {
|
|
||||||
micTrackRef,
|
|
||||||
cameraToggle,
|
|
||||||
microphoneToggle,
|
|
||||||
screenShareToggle,
|
|
||||||
handleAudioDeviceChange,
|
|
||||||
handleVideoDeviceChange,
|
|
||||||
handleMicrophoneDeviceSelectError,
|
|
||||||
handleCameraDeviceSelectError,
|
|
||||||
} = useInputControls({ onDeviceError, saveUserChoices });
|
|
||||||
|
|
||||||
const handleSendMessage = async (message: string) => {
|
|
||||||
await send(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibleControls = {
|
|
||||||
leave: controls?.leave ?? true,
|
|
||||||
microphone: controls?.microphone ?? publishPermissions.microphone,
|
|
||||||
screenShare: controls?.screenShare ?? publishPermissions.screenShare,
|
|
||||||
camera: controls?.camera ?? publishPermissions.camera,
|
|
||||||
chat: controls?.chat ?? publishPermissions.data,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isEmpty = Object.values(visibleControls).every((value) => !value);
|
|
||||||
|
|
||||||
if (isEmpty) {
|
|
||||||
console.warn('AgentControlBar: `visibleControls` contains only false values.');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-label="Voice assistant controls"
|
|
||||||
className={cn(
|
|
||||||
'bg-background border-input/50 dark:border-muted flex flex-col border p-3 drop-shadow-md/3',
|
|
||||||
variant === 'livekit' ? 'rounded-[31px]' : 'rounded-lg',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
{...MOTION_PROPS}
|
|
||||||
inert={!(isChatOpen || isChatOpenUncontrolled)}
|
|
||||||
animate={isChatOpen || isChatOpenUncontrolled ? 'visible' : 'hidden'}
|
|
||||||
className="border-input/50 flex w-full items-start overflow-hidden border-b"
|
|
||||||
>
|
|
||||||
<AgentChatInput
|
|
||||||
chatOpen={isChatOpen || isChatOpenUncontrolled}
|
|
||||||
onSend={handleSendMessage}
|
|
||||||
className={cn(variant === 'livekit' && '[&_button]:rounded-full')}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<div className="flex grow gap-1">
|
|
||||||
{/* Toggle Microphone */}
|
|
||||||
{visibleControls.microphone && (
|
|
||||||
<AgentTrackControl
|
|
||||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
|
||||||
kind="audioinput"
|
|
||||||
aria-label="Toggle microphone"
|
|
||||||
source={Track.Source.Microphone}
|
|
||||||
pressed={microphoneToggle.enabled}
|
|
||||||
disabled={microphoneToggle.pending}
|
|
||||||
audioTrack={micTrackRef}
|
|
||||||
onPressedChange={microphoneToggle.toggle}
|
|
||||||
onActiveDeviceChange={handleAudioDeviceChange}
|
|
||||||
onMediaDeviceError={handleMicrophoneDeviceSelectError}
|
|
||||||
className={cn(
|
|
||||||
variant === 'livekit' && [
|
|
||||||
TOGGLE_VARIANT_1,
|
|
||||||
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
|
|
||||||
]
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toggle Camera */}
|
|
||||||
{visibleControls.camera && (
|
|
||||||
<AgentTrackControl
|
|
||||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
|
||||||
kind="videoinput"
|
|
||||||
aria-label="Toggle camera"
|
|
||||||
source={Track.Source.Camera}
|
|
||||||
pressed={cameraToggle.enabled}
|
|
||||||
pending={cameraToggle.pending}
|
|
||||||
disabled={cameraToggle.pending}
|
|
||||||
onPressedChange={cameraToggle.toggle}
|
|
||||||
onMediaDeviceError={handleCameraDeviceSelectError}
|
|
||||||
onActiveDeviceChange={handleVideoDeviceChange}
|
|
||||||
className={cn(
|
|
||||||
variant === 'livekit' && [
|
|
||||||
TOGGLE_VARIANT_1,
|
|
||||||
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
|
|
||||||
]
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toggle Screen Share */}
|
|
||||||
{visibleControls.screenShare && (
|
|
||||||
<AgentTrackToggle
|
|
||||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
|
||||||
aria-label="Toggle screen share"
|
|
||||||
source={Track.Source.ScreenShare}
|
|
||||||
pressed={screenShareToggle.enabled}
|
|
||||||
disabled={screenShareToggle.pending}
|
|
||||||
onPressedChange={screenShareToggle.toggle}
|
|
||||||
className={cn(variant === 'livekit' && [TOGGLE_VARIANT_2, 'rounded-full'])}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toggle Transcript */}
|
|
||||||
{visibleControls.chat && (
|
|
||||||
<Toggle
|
|
||||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
|
||||||
pressed={isChatOpen || isChatOpenUncontrolled}
|
|
||||||
aria-label="Toggle transcript"
|
|
||||||
onPressedChange={(state) => {
|
|
||||||
if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state);
|
|
||||||
else onIsChatOpenChange(state);
|
|
||||||
}}
|
|
||||||
className={agentTrackToggleVariants({
|
|
||||||
variant: variant === 'outline' ? 'outline' : 'default',
|
|
||||||
className: cn(variant === 'livekit' && [TOGGLE_VARIANT_2, 'rounded-full']),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<MessageSquareTextIcon />
|
|
||||||
</Toggle>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disconnect */}
|
|
||||||
{visibleControls.leave && (
|
|
||||||
<AgentDisconnectButton
|
|
||||||
onClick={onDisconnect}
|
|
||||||
disabled={!isConnected}
|
|
||||||
className={cn(
|
|
||||||
variant === 'livekit' &&
|
|
||||||
'bg-destructive/10 dark:bg-destructive/10 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/4 rounded-full font-mono text-xs font-bold tracking-wider'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="hidden md:inline">END CALL</span>
|
|
||||||
<span className="inline md:hidden">END</span>
|
|
||||||
</AgentDisconnectButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { type VariantProps } from 'class-variance-authority';
|
|
||||||
import { PhoneOffIcon } from 'lucide-react';
|
|
||||||
import { useSessionContext } from '@livekit/components-react';
|
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
export interface AgentDisconnectButtonProps
|
|
||||||
extends React.ComponentProps<'button'>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentDisconnectButton({
|
|
||||||
icon,
|
|
||||||
size = 'default',
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
...props
|
|
||||||
}: AgentDisconnectButtonProps) {
|
|
||||||
const { end } = useSessionContext();
|
|
||||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
onClick?.(event);
|
|
||||||
end();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant="destructive" size={size} onClick={handleClick} {...props}>
|
|
||||||
{icon ?? <PhoneOffIcon />}
|
|
||||||
{children ?? <span className={cn(size?.includes('icon') && 'sr-only')}>END CALL</span>}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
import { Room } from 'livekit-client';
|
|
||||||
import {
|
|
||||||
RoomAudioRenderer,
|
|
||||||
type RoomAudioRendererProps,
|
|
||||||
SessionProvider,
|
|
||||||
type SessionProviderProps,
|
|
||||||
type UseSessionReturn,
|
|
||||||
} from '@livekit/components-react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AgentSessionProvider component.
|
|
||||||
* Combines SessionProviderProps with RoomAudioRendererProps.
|
|
||||||
*/
|
|
||||||
export type AgentSessionProviderProps = SessionProviderProps &
|
|
||||||
RoomAudioRendererProps & {
|
|
||||||
/**
|
|
||||||
* The room to provide.
|
|
||||||
*/
|
|
||||||
room?: Room;
|
|
||||||
/**
|
|
||||||
* The volume to set for the audio renderer.
|
|
||||||
*/
|
|
||||||
volume?: number;
|
|
||||||
/**
|
|
||||||
* Whether to mute the audio renderer.
|
|
||||||
*/
|
|
||||||
muted?: boolean;
|
|
||||||
/**
|
|
||||||
* The session to provide.
|
|
||||||
*/
|
|
||||||
session: UseSessionReturn;
|
|
||||||
/**
|
|
||||||
* The children to render.
|
|
||||||
*/
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A provider component for agent sessions that wraps SessionProvider
|
|
||||||
* and includes RoomAudioRenderer for audio playback.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <AgentSessionProvider session={agentSession}>
|
|
||||||
* <AgentControlBar />
|
|
||||||
* <AgentChatTranscript />
|
|
||||||
* </AgentSessionProvider>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentSessionProvider({
|
|
||||||
session,
|
|
||||||
children,
|
|
||||||
...roomAudioRendererProps
|
|
||||||
}: AgentSessionProviderProps) {
|
|
||||||
return (
|
|
||||||
<SessionProvider session={session}>
|
|
||||||
{children}
|
|
||||||
<RoomAudioRenderer {...roomAudioRendererProps} />
|
|
||||||
</SessionProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-323
@@ -1,323 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { type VariantProps, cva } from 'class-variance-authority';
|
|
||||||
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';
|
|
||||||
import {
|
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
useMaybeRoomContext,
|
|
||||||
useMediaDeviceSelect,
|
|
||||||
} from '@livekit/components-react';
|
|
||||||
import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar';
|
|
||||||
import { AgentTrackToggle } from '@/components/agents-ui/agent-track-toggle';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { toggleVariants } from '@/components/ui/toggle';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
const selectVariants = cva(
|
|
||||||
[
|
|
||||||
'rounded-l-none shadow-none pl-2 ',
|
|
||||||
'text-foreground hover:text-muted-foreground',
|
|
||||||
'peer-data-[state=on]/track:bg-muted peer-data-[state=on]/track:hover:bg-foreground/10',
|
|
||||||
'peer-data-[state=off]/track:text-destructive',
|
|
||||||
'peer-data-[state=off]/track:focus-visible:border-destructive peer-data-[state=off]/track:focus-visible:ring-destructive/30',
|
|
||||||
'[&_svg]:opacity-100',
|
|
||||||
],
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: [
|
|
||||||
'border-none',
|
|
||||||
'peer-data-[state=off]/track:bg-destructive/10',
|
|
||||||
'peer-data-[state=off]/track:hover:bg-destructive/15',
|
|
||||||
'peer-data-[state=off]/track:[&_svg]:!text-destructive',
|
|
||||||
|
|
||||||
'dark:peer-data-[state=on]/track:bg-accent',
|
|
||||||
'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
|
|
||||||
'dark:peer-data-[state=off]/track:bg-destructive/10',
|
|
||||||
'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
|
|
||||||
],
|
|
||||||
outline: [
|
|
||||||
'border border-l-0',
|
|
||||||
'peer-data-[state=off]/track:border-destructive/20',
|
|
||||||
'peer-data-[state=off]/track:bg-destructive/10',
|
|
||||||
'peer-data-[state=off]/track:hover:bg-destructive/15',
|
|
||||||
'peer-data-[state=off]/track:[&_svg]:!text-destructive',
|
|
||||||
'peer-data-[state=on]/track:hover:border-foreground/12',
|
|
||||||
|
|
||||||
'dark:peer-data-[state=off]/track:bg-destructive/10',
|
|
||||||
'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
|
|
||||||
'dark:peer-data-[state=on]/track:bg-accent',
|
|
||||||
'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: 'w-[180px]',
|
|
||||||
sm: 'w-auto',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'default',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the TrackDeviceSelect component. */
|
|
||||||
type TrackDeviceSelectProps = React.ComponentProps<typeof SelectTrigger> &
|
|
||||||
VariantProps<typeof selectVariants> & {
|
|
||||||
/**
|
|
||||||
* The size of the select.
|
|
||||||
* @defaultValue 'default'
|
|
||||||
*/
|
|
||||||
size?: 'default' | 'sm';
|
|
||||||
/**
|
|
||||||
* The variant of the select.
|
|
||||||
* @defaultValue 'default'
|
|
||||||
*/
|
|
||||||
variant?: 'default' | 'outline' | null;
|
|
||||||
/**
|
|
||||||
* The type of media device (audioinput or videoinput).
|
|
||||||
*/
|
|
||||||
kind: MediaDeviceKind;
|
|
||||||
/**
|
|
||||||
* The track source to control (Microphone, Camera, or ScreenShare).
|
|
||||||
*/
|
|
||||||
track?: LocalAudioTrack | LocalVideoTrack | undefined;
|
|
||||||
/**
|
|
||||||
* Whether to request permissions for the media device.
|
|
||||||
*/
|
|
||||||
requestPermissions?: boolean;
|
|
||||||
/**
|
|
||||||
* Callback when a media device error occurs.
|
|
||||||
*/
|
|
||||||
onMediaDeviceError?: (error: Error) => void;
|
|
||||||
/**
|
|
||||||
* Callback when the device list changes.
|
|
||||||
*/
|
|
||||||
onDeviceListChange?: (devices: MediaDeviceInfo[]) => void;
|
|
||||||
/**
|
|
||||||
* Callback when the active device changes.
|
|
||||||
*/
|
|
||||||
onActiveDeviceChange?: (deviceId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A select component for selecting a media device.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'button'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <TrackDeviceSelect
|
|
||||||
* size="sm"
|
|
||||||
* variant="outline"
|
|
||||||
* kind="audioinput"
|
|
||||||
* track={micTrackRef}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
function TrackDeviceSelect({
|
|
||||||
kind,
|
|
||||||
track,
|
|
||||||
size = 'default',
|
|
||||||
variant = 'default',
|
|
||||||
className,
|
|
||||||
requestPermissions = false,
|
|
||||||
onMediaDeviceError,
|
|
||||||
onDeviceListChange,
|
|
||||||
onActiveDeviceChange,
|
|
||||||
...props
|
|
||||||
}: TrackDeviceSelectProps) {
|
|
||||||
const room = useMaybeRoomContext();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [requestPermissionsState, setRequestPermissionsState] = useState(requestPermissions);
|
|
||||||
const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({
|
|
||||||
room,
|
|
||||||
kind,
|
|
||||||
track,
|
|
||||||
requestPermissions: requestPermissionsState,
|
|
||||||
onError: onMediaDeviceError,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onDeviceListChange?.(devices);
|
|
||||||
}, [devices, onDeviceListChange]);
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
setOpen(open);
|
|
||||||
if (open) {
|
|
||||||
setRequestPermissionsState(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActiveDeviceChange = (deviceId: string) => {
|
|
||||||
setActiveMediaDevice(deviceId);
|
|
||||||
onActiveDeviceChange?.(deviceId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredDevices = useMemo(() => devices.filter((d) => d.deviceId !== ''), [devices]);
|
|
||||||
|
|
||||||
if (filteredDevices.length < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
open={open}
|
|
||||||
value={activeDeviceId}
|
|
||||||
onOpenChange={handleOpenChange}
|
|
||||||
onValueChange={handleActiveDeviceChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={cn(selectVariants({ size, variant }), className)} {...props}>
|
|
||||||
{size !== 'sm' && (
|
|
||||||
<SelectValue className="font-mono text-sm" placeholder={`Select a ${kind}`} />
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{filteredDevices.map((device) => (
|
|
||||||
<SelectItem key={device.deviceId} value={device.deviceId} className="font-mono text-xs">
|
|
||||||
{device.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AgentTrackControl component.
|
|
||||||
*/
|
|
||||||
export type AgentTrackControlProps = VariantProps<typeof toggleVariants> & {
|
|
||||||
/**
|
|
||||||
* The type of media device (audioinput or videoinput).
|
|
||||||
*/
|
|
||||||
kind: MediaDeviceKind;
|
|
||||||
/**
|
|
||||||
* The track source to control (Microphone, Camera, or ScreenShare).
|
|
||||||
*/
|
|
||||||
source: 'camera' | 'microphone' | 'screen_share';
|
|
||||||
/**
|
|
||||||
* Whether the track is currently enabled/published.
|
|
||||||
*/
|
|
||||||
pressed?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the control is in a pending/loading state.
|
|
||||||
*/
|
|
||||||
pending?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the control is disabled.
|
|
||||||
*/
|
|
||||||
disabled?: boolean;
|
|
||||||
/**
|
|
||||||
* Additional CSS class names to apply to the container.
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
/**
|
|
||||||
* The audio track reference for visualization (only for microphone).
|
|
||||||
*/
|
|
||||||
audioTrack?: TrackReferenceOrPlaceholder;
|
|
||||||
/**
|
|
||||||
* Callback when the pressed state changes.
|
|
||||||
*/
|
|
||||||
onPressedChange?: (pressed: boolean) => void;
|
|
||||||
/**
|
|
||||||
* Callback when a media device error occurs.
|
|
||||||
*/
|
|
||||||
onMediaDeviceError?: (error: Error) => void;
|
|
||||||
/**
|
|
||||||
* Callback when the active device changes.
|
|
||||||
*/
|
|
||||||
onActiveDeviceChange?: (deviceId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A combined track toggle and device selector control.
|
|
||||||
* Includes a toggle button and a dropdown to select the active device.
|
|
||||||
* For microphone tracks, displays an audio visualizer.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <AgentTrackControl
|
|
||||||
* kind="audioinput"
|
|
||||||
* source={Track.Source.Microphone}
|
|
||||||
* pressed={isMicEnabled}
|
|
||||||
* audioTrack={micTrackRef}
|
|
||||||
* onPressedChange={(pressed) => setMicEnabled(pressed)}
|
|
||||||
* onActiveDeviceChange={(deviceId) => setMicDevice(deviceId)}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentTrackControl({
|
|
||||||
kind,
|
|
||||||
variant = 'default',
|
|
||||||
source,
|
|
||||||
pressed,
|
|
||||||
pending,
|
|
||||||
disabled,
|
|
||||||
className,
|
|
||||||
audioTrack,
|
|
||||||
onPressedChange,
|
|
||||||
onMediaDeviceError,
|
|
||||||
onActiveDeviceChange,
|
|
||||||
}: AgentTrackControlProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-0 rounded-md',
|
|
||||||
variant === 'outline' && 'shadow-xs [&_button]:shadow-none',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AgentTrackToggle
|
|
||||||
variant={variant ?? 'default'}
|
|
||||||
source={source}
|
|
||||||
pressed={pressed}
|
|
||||||
pending={pending}
|
|
||||||
disabled={disabled}
|
|
||||||
onPressedChange={onPressedChange}
|
|
||||||
className="peer/track group/track focus:z-10 has-[.audiovisualizer]:w-auto has-[.audiovisualizer]:px-3 has-[~_button]:rounded-r-none has-[~_button]:border-r-0 has-[~_button]:pr-2 has-[~_button]:pl-3"
|
|
||||||
>
|
|
||||||
{audioTrack && (
|
|
||||||
<AgentAudioVisualizerBar
|
|
||||||
size="icon"
|
|
||||||
barCount={3}
|
|
||||||
state={pressed ? 'speaking' : 'disconnected'}
|
|
||||||
audioTrack={pressed ? audioTrack : undefined}
|
|
||||||
className="audiovisualizer flex h-6 w-auto items-center justify-center gap-0.5"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn([
|
|
||||||
'h-full w-0.5 origin-center',
|
|
||||||
'group-data-[state=on]/track:bg-foreground group-data-[state=off]/track:bg-destructive',
|
|
||||||
'data-lk-muted:bg-muted',
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
</AgentAudioVisualizerBar>
|
|
||||||
)}
|
|
||||||
</AgentTrackToggle>
|
|
||||||
{kind && (
|
|
||||||
<TrackDeviceSelect
|
|
||||||
size="sm"
|
|
||||||
kind={kind}
|
|
||||||
variant={variant}
|
|
||||||
requestPermissions={false}
|
|
||||||
onMediaDeviceError={onMediaDeviceError}
|
|
||||||
onActiveDeviceChange={onActiveDeviceChange}
|
|
||||||
className={cn([
|
|
||||||
'relative',
|
|
||||||
'before:bg-border before:absolute before:inset-y-0 before:left-0 before:my-2.5 before:w-px has-[~_button]:before:content-[""]',
|
|
||||||
!pressed && 'before:bg-destructive/20',
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
import { type ComponentProps, Fragment } from 'react';
|
|
||||||
import { type VariantProps, cva } from 'class-variance-authority';
|
|
||||||
import { Track } from 'livekit-client';
|
|
||||||
import {
|
|
||||||
LoaderIcon,
|
|
||||||
MicIcon,
|
|
||||||
MicOffIcon,
|
|
||||||
MonitorOffIcon,
|
|
||||||
MonitorUpIcon,
|
|
||||||
VideoIcon,
|
|
||||||
VideoOffIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Toggle, toggleVariants } from '@/components/ui/toggle';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
export const agentTrackToggleVariants = cva(['size-9'], {
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: [
|
|
||||||
'data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive',
|
|
||||||
'data-[state=off]:hover:bg-destructive/15',
|
|
||||||
'data-[state=off]:focus-visible:ring-destructive/30',
|
|
||||||
'data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
|
||||||
'data-[state=on]:hover:bg-foreground/10',
|
|
||||||
],
|
|
||||||
outline: [
|
|
||||||
'data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive data-[state=off]:border-destructive/20',
|
|
||||||
'data-[state=off]:hover:bg-destructive/15 data-[state=off]:hover:text-destructive',
|
|
||||||
'data-[state=off]:focus:text-destructive',
|
|
||||||
'data-[state=off]:focus-visible:border-destructive data-[state=off]:focus-visible:ring-destructive/30',
|
|
||||||
'data-[state=on]:hover:bg-foreground/10 data-[state=on]:hover:border-foreground/12',
|
|
||||||
'dark:data-[state=on]:hover:bg-foreground/10',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) {
|
|
||||||
if (pending) {
|
|
||||||
return LoaderIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (source) {
|
|
||||||
case Track.Source.Microphone:
|
|
||||||
return enabled ? MicIcon : MicOffIcon;
|
|
||||||
case Track.Source.Camera:
|
|
||||||
return enabled ? VideoIcon : VideoOffIcon;
|
|
||||||
case Track.Source.ScreenShare:
|
|
||||||
return enabled ? MonitorUpIcon : MonitorOffIcon;
|
|
||||||
default:
|
|
||||||
return Fragment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the AgentTrackToggle component.
|
|
||||||
*/
|
|
||||||
export type AgentTrackToggleProps = VariantProps<typeof toggleVariants> &
|
|
||||||
ComponentProps<'button'> & {
|
|
||||||
/**
|
|
||||||
* The variant of the toggle.
|
|
||||||
* @defaultValue 'default'
|
|
||||||
*/
|
|
||||||
variant?: 'default' | 'outline';
|
|
||||||
/**
|
|
||||||
* The track source to toggle (Microphone, Camera, or ScreenShare).
|
|
||||||
*/
|
|
||||||
source: 'camera' | 'microphone' | 'screen_share';
|
|
||||||
/**
|
|
||||||
* Whether the toggle is in a pending/loading state.
|
|
||||||
* When true, displays a loading spinner icon.
|
|
||||||
* @defaultValue false
|
|
||||||
*/
|
|
||||||
pending?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the toggle is currently pressed/enabled.
|
|
||||||
* @defaultValue false
|
|
||||||
*/
|
|
||||||
pressed?: boolean;
|
|
||||||
/**
|
|
||||||
* The default pressed state when uncontrolled.
|
|
||||||
* @defaultValue false
|
|
||||||
*/
|
|
||||||
defaultPressed?: boolean;
|
|
||||||
/**
|
|
||||||
* Callback fired when the pressed state changes.
|
|
||||||
*/
|
|
||||||
onPressedChange?: (pressed: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A toggle button for controlling track publishing state.
|
|
||||||
* Displays appropriate icons based on the track source and state.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'button'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <AgentTrackToggle
|
|
||||||
* source={Track.Source.Microphone}
|
|
||||||
* pressed={isMicEnabled}
|
|
||||||
* onPressedChange={(pressed) => setMicEnabled(pressed)}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function AgentTrackToggle({
|
|
||||||
size = 'default',
|
|
||||||
variant = 'default',
|
|
||||||
source,
|
|
||||||
pending = false,
|
|
||||||
pressed = false,
|
|
||||||
defaultPressed = false,
|
|
||||||
className,
|
|
||||||
onPressedChange,
|
|
||||||
...props
|
|
||||||
}: AgentTrackToggleProps) {
|
|
||||||
const IconComponent = getSourceIcon(source as Track.Source, pressed ?? false, pending);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Toggle
|
|
||||||
size={size}
|
|
||||||
variant={variant}
|
|
||||||
pressed={pressed}
|
|
||||||
defaultPressed={defaultPressed}
|
|
||||||
aria-label={`Toggle ${source}`}
|
|
||||||
onPressedChange={onPressedChange}
|
|
||||||
className={cn(
|
|
||||||
agentTrackToggleVariants({
|
|
||||||
variant: variant ?? 'default',
|
|
||||||
className,
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<IconComponent className={cn(pending && 'animate-spin')} />
|
|
||||||
{props.children}
|
|
||||||
</Toggle>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
import { type ComponentProps } from 'react';
|
|
||||||
import { Room } from 'livekit-client';
|
|
||||||
import { useEnsureRoom, useStartAudio } from '@livekit/components-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the StartAudioButton component.
|
|
||||||
*/
|
|
||||||
export interface StartAudioButtonProps extends ComponentProps<'button'> {
|
|
||||||
/**
|
|
||||||
* The size of the button.
|
|
||||||
* @defaultValue 'default'
|
|
||||||
*/
|
|
||||||
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
|
|
||||||
/**
|
|
||||||
* The variant of the button.
|
|
||||||
* @defaultValue 'default'
|
|
||||||
*/
|
|
||||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
|
||||||
/**
|
|
||||||
* The LiveKit room instance. If not provided, uses the room from context.
|
|
||||||
*/
|
|
||||||
room?: Room;
|
|
||||||
/**
|
|
||||||
* The label text to display on the button.
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A button that allows users to start audio playback.
|
|
||||||
* Required for browsers that block autoplay of audio.
|
|
||||||
* Only renders when audio playback is blocked.
|
|
||||||
*
|
|
||||||
* @extends ComponentProps<'button'>
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <StartAudioButton label="Click to allow audio playback" />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function StartAudioButton({
|
|
||||||
size = 'default',
|
|
||||||
variant = 'default',
|
|
||||||
label,
|
|
||||||
room,
|
|
||||||
...props
|
|
||||||
}: StartAudioButtonProps) {
|
|
||||||
const roomEnsured = useEnsureRoom(room);
|
|
||||||
const { mergedProps } = useStartAudio({ room: roomEnsured, props });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button size={size} variant={variant} {...mergedProps}>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { ArrowDownIcon } from 'lucide-react';
|
|
||||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
|
||||||
|
|
||||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
|
||||||
<StickToBottom
|
|
||||||
className={cn('relative flex-1 overflow-y-hidden', className)}
|
|
||||||
initial="smooth"
|
|
||||||
resize="smooth"
|
|
||||||
role="log"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
|
||||||
|
|
||||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
|
||||||
<StickToBottom.Content className={cn('flex flex-col gap-8 p-4', className)} {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ConversationEmptyStateProps = ComponentProps<'div'> & {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConversationEmptyState = ({
|
|
||||||
className,
|
|
||||||
title = 'No messages yet',
|
|
||||||
description = 'Start a conversation to see messages here',
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ConversationEmptyStateProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-sm font-medium">{title}</h3>
|
|
||||||
{description && <p className="text-muted-foreground text-sm">{description}</p>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
|
||||||
|
|
||||||
export const ConversationScrollButton = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ConversationScrollButtonProps) => {
|
|
||||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [scrollToBottom]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
!isAtBottom && (
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
'dark:bg-background dark:hover:bg-muted absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={handleScrollToBottom}
|
|
||||||
size="icon"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
|
|
||||||
import { createContext, memo, useContext, useEffect, useState } from 'react';
|
|
||||||
import type { FileUIPart, UIMessage } from 'ai';
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from 'lucide-react';
|
|
||||||
import { Streamdown } from 'streamdown';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ButtonGroup, ButtonGroupText } from '@/components/ui/button-group';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
from: UIMessage['role'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'group flex w-full max-w-[95%] flex-col gap-2',
|
|
||||||
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm',
|
|
||||||
'group-[.is-user]:bg-secondary group-[.is-user]:text-foreground group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:px-4 group-[.is-user]:py-3',
|
|
||||||
'group-[.is-assistant]:text-foreground',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type MessageActionsProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
|
|
||||||
<div className={cn('flex items-center gap-1', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
|
||||||
tooltip?: string;
|
|
||||||
label?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MessageAction = ({
|
|
||||||
tooltip,
|
|
||||||
children,
|
|
||||||
label,
|
|
||||||
variant = 'ghost',
|
|
||||||
size = 'icon-sm',
|
|
||||||
...props
|
|
||||||
}: MessageActionProps) => {
|
|
||||||
const button = (
|
|
||||||
<Button size={size} type="button" variant={variant} {...props}>
|
|
||||||
{children}
|
|
||||||
<span className="sr-only">{label || tooltip}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tooltip) {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{tooltip}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return button;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MessageBranchContextType = {
|
|
||||||
currentBranch: number;
|
|
||||||
totalBranches: number;
|
|
||||||
goToPrevious: () => void;
|
|
||||||
goToNext: () => void;
|
|
||||||
branches: ReactElement[];
|
|
||||||
setBranches: (branches: ReactElement[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(null);
|
|
||||||
|
|
||||||
const useMessageBranch = () => {
|
|
||||||
const context = useContext(MessageBranchContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('MessageBranch components must be used within MessageBranch');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
defaultBranch?: number;
|
|
||||||
onBranchChange?: (branchIndex: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MessageBranch = ({
|
|
||||||
defaultBranch = 0,
|
|
||||||
onBranchChange,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: MessageBranchProps) => {
|
|
||||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
|
||||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
|
||||||
|
|
||||||
const handleBranchChange = (newBranch: number) => {
|
|
||||||
setCurrentBranch(newBranch);
|
|
||||||
onBranchChange?.(newBranch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPrevious = () => {
|
|
||||||
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
|
||||||
handleBranchChange(newBranch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToNext = () => {
|
|
||||||
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
|
||||||
handleBranchChange(newBranch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue: MessageBranchContextType = {
|
|
||||||
currentBranch,
|
|
||||||
totalBranches: branches.length,
|
|
||||||
goToPrevious,
|
|
||||||
goToNext,
|
|
||||||
branches,
|
|
||||||
setBranches,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MessageBranchContext.Provider value={contextValue}>
|
|
||||||
<div className={cn('grid w-full gap-2 [&>div]:pb-0', className)} {...props} />
|
|
||||||
</MessageBranchContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
|
|
||||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
|
||||||
const childrenArray = Array.isArray(children) ? children : [children];
|
|
||||||
|
|
||||||
// Use useEffect to update branches when they change
|
|
||||||
useEffect(() => {
|
|
||||||
if (branches.length !== childrenArray.length) {
|
|
||||||
setBranches(childrenArray);
|
|
||||||
}
|
|
||||||
}, [childrenArray, branches, setBranches]);
|
|
||||||
|
|
||||||
return childrenArray.map((branch, index) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid gap-2 overflow-hidden [&>div]:pb-0',
|
|
||||||
index === currentBranch ? 'block' : 'hidden'
|
|
||||||
)}
|
|
||||||
key={branch.key}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{branch}
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
from: UIMessage['role'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MessageBranchSelector = ({
|
|
||||||
className,
|
|
||||||
from,
|
|
||||||
...props
|
|
||||||
}: MessageBranchSelectorProps) => {
|
|
||||||
const { totalBranches } = useMessageBranch();
|
|
||||||
|
|
||||||
// Don't render if there's only one branch
|
|
||||||
if (totalBranches <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonGroup
|
|
||||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
|
||||||
orientation="horizontal"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
|
||||||
|
|
||||||
export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
|
|
||||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
aria-label="Previous branch"
|
|
||||||
disabled={totalBranches <= 1}
|
|
||||||
onClick={goToPrevious}
|
|
||||||
size="icon-sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronLeftIcon size={14} />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
|
||||||
|
|
||||||
export const MessageBranchNext = ({ children, className, ...props }: MessageBranchNextProps) => {
|
|
||||||
const { goToNext, totalBranches } = useMessageBranch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
aria-label="Next branch"
|
|
||||||
disabled={totalBranches <= 1}
|
|
||||||
onClick={goToNext}
|
|
||||||
size="icon-sm"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronRightIcon size={14} />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
|
||||||
|
|
||||||
export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
|
|
||||||
const { currentBranch, totalBranches } = useMessageBranch();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonGroupText
|
|
||||||
className={cn('text-muted-foreground border-none bg-transparent shadow-none', className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{currentBranch + 1} of {totalBranches}
|
|
||||||
</ButtonGroupText>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
|
||||||
|
|
||||||
export const MessageResponse = memo(
|
|
||||||
({ className, ...props }: MessageResponseProps) => (
|
|
||||||
<Streamdown
|
|
||||||
className={cn('size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
|
||||||
);
|
|
||||||
|
|
||||||
MessageResponse.displayName = 'MessageResponse';
|
|
||||||
|
|
||||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
data: FileUIPart;
|
|
||||||
className?: string;
|
|
||||||
onRemove?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MessageAttachment({ data, className, onRemove, ...props }: MessageAttachmentProps) {
|
|
||||||
const filename = data.filename || '';
|
|
||||||
const mediaType = data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file';
|
|
||||||
const isImage = mediaType === 'image';
|
|
||||||
const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('group relative size-24 overflow-hidden rounded-lg', className)} {...props}>
|
|
||||||
{isImage ? (
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
alt={filename || 'attachment'}
|
|
||||||
className="size-full object-cover"
|
|
||||||
height={100}
|
|
||||||
src={data.url}
|
|
||||||
width={100}
|
|
||||||
/>
|
|
||||||
{onRemove && (
|
|
||||||
<Button
|
|
||||||
aria-label="Remove attachment"
|
|
||||||
className="bg-background/80 hover:bg-background absolute top-2 right-2 size-6 rounded-full p-0 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 [&>svg]:size-3"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Remove</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg">
|
|
||||||
<PaperclipIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{attachmentLabel}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{onRemove && (
|
|
||||||
<Button
|
|
||||||
aria-label="Remove attachment"
|
|
||||||
className="hover:bg-accent size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100 [&>svg]:size-3"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Remove</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MessageAttachmentsProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export function MessageAttachments({ children, className, ...props }: MessageAttachmentsProps) {
|
|
||||||
if (!children) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('ml-auto flex w-fit flex-wrap items-start gap-2', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MessageToolbarProps = ComponentProps<'div'>;
|
|
||||||
|
|
||||||
export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
|
|
||||||
<div className={cn('mt-4 flex w-full items-center justify-between gap-4', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { type CSSProperties, type ElementType, type JSX, memo, useMemo } from 'react';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
export type TextShimmerProps = {
|
|
||||||
children: string;
|
|
||||||
as?: ElementType;
|
|
||||||
className?: string;
|
|
||||||
duration?: number;
|
|
||||||
spread?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShimmerComponent = ({
|
|
||||||
children,
|
|
||||||
as: Component = 'p',
|
|
||||||
className,
|
|
||||||
duration = 2,
|
|
||||||
spread = 2,
|
|
||||||
}: TextShimmerProps) => {
|
|
||||||
const MotionComponent = motion.create(Component as keyof JSX.IntrinsicElements);
|
|
||||||
|
|
||||||
const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MotionComponent
|
|
||||||
animate={{ backgroundPosition: '0% center' }}
|
|
||||||
className={cn(
|
|
||||||
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent',
|
|
||||||
'[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
initial={{ backgroundPosition: '100% center' }}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
'--spread': `${dynamicSpread}px`,
|
|
||||||
backgroundImage:
|
|
||||||
'var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))',
|
|
||||||
} as CSSProperties
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
duration,
|
|
||||||
ease: 'linear',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</MotionComponent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Shimmer = memo(ShimmerComponent);
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { TokenSource } from 'livekit-client';
|
|
||||||
import { useSession } from '@livekit/components-react';
|
|
||||||
import { WarningIcon } from '@phosphor-icons/react/dist/ssr';
|
|
||||||
import type { AppConfig } from '@/app-config';
|
|
||||||
import { AgentSessionProvider } from '@/components/agents-ui/agent-session-provider';
|
|
||||||
import { StartAudioButton } from '@/components/agents-ui/start-audio-button';
|
|
||||||
import { ViewController } from '@/components/app/view-controller';
|
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
|
||||||
import { useAgentErrors } from '@/hooks/useAgentErrors';
|
|
||||||
import { useDebugMode } from '@/hooks/useDebug';
|
|
||||||
import { getSandboxTokenSource } from '@/lib/utils';
|
|
||||||
|
|
||||||
const IN_DEVELOPMENT = process.env.NODE_ENV !== 'production';
|
|
||||||
|
|
||||||
function AppSetup() {
|
|
||||||
useDebugMode({ enabled: IN_DEVELOPMENT });
|
|
||||||
useAgentErrors();
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppProps {
|
|
||||||
appConfig: AppConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App({ appConfig }: AppProps) {
|
|
||||||
const tokenSource = useMemo(() => {
|
|
||||||
return typeof process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT === 'string'
|
|
||||||
? getSandboxTokenSource(appConfig)
|
|
||||||
: TokenSource.endpoint('/api/connection-details');
|
|
||||||
}, [appConfig]);
|
|
||||||
|
|
||||||
const session = useSession(
|
|
||||||
tokenSource,
|
|
||||||
appConfig.agentName ? { agentName: appConfig.agentName } : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AgentSessionProvider session={session}>
|
|
||||||
<AppSetup />
|
|
||||||
<main className="grid h-svh grid-cols-1 place-content-center">
|
|
||||||
<ViewController appConfig={appConfig} />
|
|
||||||
</main>
|
|
||||||
<StartAudioButton label="Start Audio" />
|
|
||||||
<Toaster
|
|
||||||
icons={{
|
|
||||||
warning: <WarningIcon weight="bold" />,
|
|
||||||
}}
|
|
||||||
position="top-center"
|
|
||||||
className="toaster group"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
'--normal-bg': 'var(--popover)',
|
|
||||||
'--normal-text': 'var(--popover-foreground)',
|
|
||||||
'--normal-border': 'var(--border)',
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</AgentSessionProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react';
|
|
||||||
import { type ReceivedMessage, useAgent } from '@livekit/components-react';
|
|
||||||
import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
const MotionContainer = motion.create('div');
|
|
||||||
|
|
||||||
const CONTAINER_MOTION_PROPS = {
|
|
||||||
variants: {
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
transition: {
|
|
||||||
ease: 'easeOut',
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
delay: 0.2,
|
|
||||||
ease: 'easeOut',
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
initial: 'hidden',
|
|
||||||
animate: 'visible',
|
|
||||||
exit: 'hidden',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ChatTranscriptProps {
|
|
||||||
hidden?: boolean;
|
|
||||||
messages?: ReceivedMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatTranscript({
|
|
||||||
hidden = false,
|
|
||||||
messages = [],
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ChatTranscriptProps & Omit<HTMLMotionProps<'div'>, 'ref'>) {
|
|
||||||
const { state: agentState } = useAgent();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute top-0 bottom-[135px] flex w-full flex-col md:bottom-[170px]">
|
|
||||||
<AnimatePresence>
|
|
||||||
{!hidden && (
|
|
||||||
<MotionContainer
|
|
||||||
{...props}
|
|
||||||
{...CONTAINER_MOTION_PROPS}
|
|
||||||
className={cn('flex h-full w-full flex-col gap-4', className)}
|
|
||||||
>
|
|
||||||
<AgentChatTranscript
|
|
||||||
agentState={agentState}
|
|
||||||
messages={messages}
|
|
||||||
className="mx-auto w-full max-w-2xl [&_.is-user>div]:rounded-[22px] [&>div>div]:px-4 [&>div>div]:pt-40 md:[&>div>div]:px-6"
|
|
||||||
/>
|
|
||||||
</MotionContainer>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-96
@@ -1,96 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { XIcon } from 'lucide-react';
|
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
|
||||||
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
const MotionPanel = motion.create('div');
|
|
||||||
|
|
||||||
interface ImageCardProps {
|
|
||||||
image: GeneratedImage;
|
|
||||||
onDismiss: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageCard({ image, onDismiss }: ImageCardProps) {
|
|
||||||
const src = image.imageUrl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MotionPanel
|
|
||||||
key={image.id}
|
|
||||||
layout
|
|
||||||
initial={{ opacity: 0, scale: 0.92, y: 8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.92, y: 8 }}
|
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
||||||
className="bg-background border-input/50 relative overflow-hidden rounded-xl border shadow-xl"
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={src} alt={image.prompt} className="block max-h-[360px] w-full object-contain" />
|
|
||||||
{image.prompt && (
|
|
||||||
<div className="bg-background/80 px-3 py-1.5 backdrop-blur-sm">
|
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs">{image.prompt}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onDismiss}
|
|
||||||
className={cn(
|
|
||||||
'bg-background/70 hover:bg-background absolute top-2 right-2 rounded-full p-1 backdrop-blur-sm transition-colors'
|
|
||||||
)}
|
|
||||||
aria-label="Dismiss image"
|
|
||||||
>
|
|
||||||
<XIcon className="text-muted-foreground size-3.5" />
|
|
||||||
</button>
|
|
||||||
</MotionPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GeneratedImagePanelProps {
|
|
||||||
chatOpen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listens for images generated by the agent and shows the most recent one.
|
|
||||||
* When chat is open, repositions to the top-right corner to avoid covering the transcript.
|
|
||||||
*
|
|
||||||
* HACK HERE: swap for a gallery view, a fullscreen modal, download button, etc.
|
|
||||||
*/
|
|
||||||
export function GeneratedImagePanel({ chatOpen = false }: GeneratedImagePanelProps) {
|
|
||||||
const images = useGeneratedImages();
|
|
||||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
|
||||||
const prevLengthRef = useRef(0);
|
|
||||||
|
|
||||||
// Auto-scroll to latest image
|
|
||||||
useEffect(() => {
|
|
||||||
if (images.length > prevLengthRef.current) {
|
|
||||||
prevLengthRef.current = images.length;
|
|
||||||
}
|
|
||||||
}, [images]);
|
|
||||||
|
|
||||||
const visible = images.filter((img) => !dismissed.has(img.id));
|
|
||||||
const latest = visible.at(-1);
|
|
||||||
|
|
||||||
const dismiss = (id: string) => {
|
|
||||||
setDismissed((prev) => new Set([...prev, id]));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'pointer-events-none fixed z-40 flex justify-center px-4',
|
|
||||||
chatOpen
|
|
||||||
? 'top-16 right-4 bottom-auto left-auto justify-end'
|
|
||||||
: 'inset-x-0 bottom-36 md:bottom-44'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn('pointer-events-auto', chatOpen ? 'w-48' : 'w-full max-w-sm')}>
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{latest && (
|
|
||||||
<ImageCard key={latest.id} image={latest} onDismiss={() => dismiss(latest.id)} />
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ChevronLeftIcon, ImagesIcon, XIcon } from 'lucide-react';
|
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
|
||||||
import { type GeneratedImage, useGeneratedImages } from '@/hooks/useGeneratedImages';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
const MotionPanel = motion.create('div');
|
|
||||||
const MotionOverlay = motion.create('div');
|
|
||||||
|
|
||||||
interface ThumbnailProps {
|
|
||||||
image: GeneratedImage;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Thumbnail({ image, onClick }: ThumbnailProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className="group border-input/50 bg-muted hover:border-foreground/20 focus-visible:ring-ring relative aspect-square overflow-hidden rounded-lg border transition-all hover:shadow-md focus-visible:ring-2 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={image.imageUrl}
|
|
||||||
alt={image.prompt}
|
|
||||||
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FullImageViewProps {
|
|
||||||
image: GeneratedImage;
|
|
||||||
onBack: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FullImageView({ image, onBack }: FullImageViewProps) {
|
|
||||||
return (
|
|
||||||
<MotionPanel
|
|
||||||
key="full"
|
|
||||||
initial={{ opacity: 0, x: 24 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 24 }}
|
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
||||||
className="flex h-full flex-col"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
className="text-muted-foreground hover:text-foreground mb-3 flex items-center gap-1 text-sm transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="size-4" />
|
|
||||||
All images
|
|
||||||
</button>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src={image.imageUrl} alt={image.prompt} className="w-full rounded-xl object-contain" />
|
|
||||||
{image.prompt && <p className="text-muted-foreground mt-3 text-sm">{image.prompt}</p>}
|
|
||||||
</MotionPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Floating gallery button + slide-in panel for all agent-generated images.
|
|
||||||
* The button appears once at least one image has been generated.
|
|
||||||
*/
|
|
||||||
export function ImageGallery() {
|
|
||||||
const images = useGeneratedImages();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [selected, setSelected] = useState<GeneratedImage | null>(null);
|
|
||||||
|
|
||||||
if (images.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Floating trigger button */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{!open && (
|
|
||||||
<MotionPanel
|
|
||||||
key="trigger"
|
|
||||||
initial={{ opacity: 0, scale: 0.85 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.85 }}
|
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
||||||
className="fixed right-4 bottom-36 z-40 md:bottom-44"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
aria-label="View generated images"
|
|
||||||
className={cn(
|
|
||||||
'relative flex items-center justify-center rounded-full p-3',
|
|
||||||
'bg-background border-input/50 border shadow-lg',
|
|
||||||
'hover:bg-accent focus-visible:ring-ring transition-colors focus-visible:ring-2 focus-visible:outline-none'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ImagesIcon className="text-foreground size-5" />
|
|
||||||
<span className="bg-primary text-primary-foreground absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold">
|
|
||||||
{images.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</MotionPanel>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Gallery panel + backdrop */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
<MotionOverlay
|
|
||||||
key="overlay"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(null);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MotionPanel
|
|
||||||
key="panel"
|
|
||||||
initial={{ x: '100%' }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: '100%' }}
|
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
||||||
className="border-input/50 bg-background fixed top-0 right-0 bottom-0 z-[60] flex w-80 flex-col overflow-hidden border-l shadow-2xl md:top-16"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="border-input/50 flex items-center justify-between border-b px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold">Generated images</h2>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{images.length} {images.length === 1 ? 'image' : 'images'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(null);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
aria-label="Close gallery"
|
|
||||||
className="hover:bg-accent focus-visible:ring-ring rounded-full p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
<XIcon className="text-muted-foreground size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{selected ? (
|
|
||||||
<FullImageView
|
|
||||||
key={selected.id}
|
|
||||||
image={selected}
|
|
||||||
onBack={() => setSelected(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MotionPanel
|
|
||||||
key="grid"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="grid grid-cols-2 gap-2"
|
|
||||||
>
|
|
||||||
{[...images].reverse().map((img) => (
|
|
||||||
<Thumbnail key={img.id} image={img} onClick={() => setSelected(img)} />
|
|
||||||
))}
|
|
||||||
</MotionPanel>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</MotionPanel>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
|
||||||
import { useSessionContext, useSessionMessages } from '@livekit/components-react';
|
|
||||||
import type { AppConfig } from '@/app-config';
|
|
||||||
import {
|
|
||||||
AgentControlBar,
|
|
||||||
type AgentControlBarControls,
|
|
||||||
} from '@/components/agents-ui/agent-control-bar';
|
|
||||||
import { ChatTranscript } from '@/components/app/chat-transcript';
|
|
||||||
import { GeneratedImagePanel } from '@/components/app/generated-image-panel';
|
|
||||||
import { ImageGallery } from '@/components/app/image-gallery';
|
|
||||||
import { TileLayout } from '@/components/app/tile-layout';
|
|
||||||
import { GeneratedImagesProvider } from '@/hooks/useGeneratedImages';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
import { Shimmer } from '../ai-elements/shimmer';
|
|
||||||
|
|
||||||
const MotionBottom = motion.create('div');
|
|
||||||
|
|
||||||
const MotionMessage = motion.create(Shimmer);
|
|
||||||
|
|
||||||
const BOTTOM_VIEW_MOTION_PROPS = {
|
|
||||||
variants: {
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
translateY: '0%',
|
|
||||||
},
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
translateY: '100%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
initial: 'hidden',
|
|
||||||
animate: 'visible',
|
|
||||||
exit: 'hidden',
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
delay: 0.5,
|
|
||||||
ease: 'easeOut',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SHIMMER_MOTION_PROPS = {
|
|
||||||
variants: {
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
ease: 'easeIn',
|
|
||||||
duration: 0.5,
|
|
||||||
delay: 0.8,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
transition: {
|
|
||||||
ease: 'easeIn',
|
|
||||||
duration: 0.5,
|
|
||||||
delay: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
initial: 'hidden',
|
|
||||||
animate: 'visible',
|
|
||||||
exit: 'hidden',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FadeProps {
|
|
||||||
top?: boolean;
|
|
||||||
bottom?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Fade({ top = false, bottom = false, className }: FadeProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'from-background pointer-events-none h-4 bg-linear-to-b to-transparent',
|
|
||||||
top && 'bg-linear-to-b',
|
|
||||||
bottom && 'bg-linear-to-t',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionViewProps {
|
|
||||||
appConfig: AppConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionView = ({
|
|
||||||
appConfig,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<'section'> & SessionViewProps) => {
|
|
||||||
const session = useSessionContext();
|
|
||||||
const { messages } = useSessionMessages(session);
|
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const controls: AgentControlBarControls = {
|
|
||||||
leave: true,
|
|
||||||
microphone: true,
|
|
||||||
chat: appConfig.supportsChatInput,
|
|
||||||
camera: appConfig.supportsVideoInput,
|
|
||||||
screenShare: appConfig.supportsScreenShare,
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const lastMessage = messages.at(-1);
|
|
||||||
const lastMessageIsLocal = lastMessage?.from?.isLocal === true;
|
|
||||||
|
|
||||||
if (scrollAreaRef.current && lastMessageIsLocal) {
|
|
||||||
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-background relative z-10 h-svh w-svw overflow-hidden" {...props}>
|
|
||||||
<Fade top className="absolute inset-x-4 top-0 z-10 h-40" />
|
|
||||||
{/* transcript */}
|
|
||||||
<ChatTranscript
|
|
||||||
hidden={!chatOpen}
|
|
||||||
messages={messages}
|
|
||||||
className="space-y-3 transition-opacity duration-300 ease-out"
|
|
||||||
/>
|
|
||||||
{/* Tile layout */}
|
|
||||||
<TileLayout chatOpen={chatOpen} />
|
|
||||||
{/* Single provider registers the byte stream handler once for both image components */}
|
|
||||||
<GeneratedImagesProvider>
|
|
||||||
{/* Generated image panel — appears when the agent calls generate_image */}
|
|
||||||
<GeneratedImagePanel chatOpen={chatOpen} />
|
|
||||||
{/* Gallery — persistent access to all generated images */}
|
|
||||||
<ImageGallery />
|
|
||||||
</GeneratedImagesProvider>
|
|
||||||
{/* Bottom */}
|
|
||||||
<MotionBottom
|
|
||||||
{...BOTTOM_VIEW_MOTION_PROPS}
|
|
||||||
className="fixed inset-x-3 bottom-0 z-50 md:inset-x-12"
|
|
||||||
>
|
|
||||||
{/* Pre-connect message */}
|
|
||||||
{appConfig.isPreConnectBufferEnabled && (
|
|
||||||
<AnimatePresence>
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<MotionMessage
|
|
||||||
key="pre-connect-message"
|
|
||||||
duration={2}
|
|
||||||
aria-hidden={messages.length > 0}
|
|
||||||
{...SHIMMER_MOTION_PROPS}
|
|
||||||
className="pointer-events-none mx-auto block w-full max-w-2xl pb-4 text-center text-sm font-semibold"
|
|
||||||
>
|
|
||||||
Agent is listening, ask it a question
|
|
||||||
</MotionMessage>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
)}
|
|
||||||
<div className="bg-background relative mx-auto max-w-2xl pb-3 md:pb-12">
|
|
||||||
<Fade bottom className="absolute inset-x-0 top-0 h-4 -translate-y-full" />
|
|
||||||
<AgentControlBar
|
|
||||||
variant="livekit"
|
|
||||||
controls={controls}
|
|
||||||
isChatOpen={chatOpen}
|
|
||||||
isConnected={session.isConnected}
|
|
||||||
onDisconnect={session.end}
|
|
||||||
onIsChatOpenChange={setChatOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</MotionBottom>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
|
||||||
|
|
||||||
export function ThemeProvider({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { MonitorIcon, MoonIcon, SunIcon } from '@phosphor-icons/react';
|
|
||||||
import { cn } from '@/lib/shadcn/utils';
|
|
||||||
|
|
||||||
interface ThemeToggleProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeToggle({ className }: ThemeToggleProps) {
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'text-foreground bg-background flex w-full flex-row justify-end divide-x overflow-hidden rounded-full border',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Color scheme toggle</span>
|
|
||||||
<button type="button" onClick={() => setTheme('dark')} className="cursor-pointer p-1 pl-1.5">
|
|
||||||
<span className="sr-only">Enable dark color scheme</span>
|
|
||||||
<MoonIcon
|
|
||||||
suppressHydrationWarning
|
|
||||||
size={16}
|
|
||||||
weight="bold"
|
|
||||||
className={cn(theme !== 'dark' && 'opacity-25')}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme('light')}
|
|
||||||
className="cursor-pointer px-1.5 py-1"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Enable light color scheme</span>
|
|
||||||
<SunIcon
|
|
||||||
suppressHydrationWarning
|
|
||||||
size={16}
|
|
||||||
weight="bold"
|
|
||||||
className={cn(theme !== 'light' && 'opacity-25')}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme('system')}
|
|
||||||
className="cursor-pointer p-1 pr-1.5"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Enable system color scheme</span>
|
|
||||||
<MonitorIcon
|
|
||||||
suppressHydrationWarning
|
|
||||||
size={16}
|
|
||||||
weight="bold"
|
|
||||||
className={cn(theme !== 'system' && 'opacity-25')}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user