chore(box): snapshot in-progress auth + user-learning WIP before deploy

Captures uncommitted work present on the live droplet so origin/main can be integrated and the new control toolbar deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yahya Alhinai
2026-06-28 17:49:45 +00:00
parent 63a411490c
commit 454fdab13b
24 changed files with 4684 additions and 412 deletions
+2
View File
@@ -26,6 +26,7 @@ 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
@@ -34,6 +35,7 @@ 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
+467 -275
View File
@@ -1,195 +1,263 @@
# PodMan — A Pair Programmer for Engineering Teams # PodMan
[LiveKit](https://livekit.io/) **An ambient pair programmer for engineering teams.**
[MongoDB](https://www.mongodb.com/)
[Gemini](https://ai.google.dev/)
[DigitalOcean](https://www.digitalocean.com/)
## The bottleneck moved 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.
Models keep getting better, and more people can build software than ever before. [LiveKit](https://livekit.io/) · [MongoDB](https://www.mongodb.com/) ·
Writing the code is no longer the hard part — engineering ability is not the [Gemini](https://ai.google.dev/) · [Hermes](https://hermes-agent.nousresearch.com/) ·
ceiling anymore. [vLLM](https://vllm.ai/) · [DigitalOcean](https://www.digitalocean.com/)
What slows teams down now is everything *around* the code: manually checking each ## Read This First
other's work, re-planning when two people drift into the same change, and
constantly asking "what are you working on?" just to stay in sync. People
naturally want to build together — so as more people start coding, there will be
thousands of teams bottlenecked not by skill, but by the **speed of human
coordination.**
**PodMan is a pair programmer for the whole team.** It watches what every member | Need | Use this |
is doing in real time, learns your team's decisions and working dynamics, and | ----------------------- | --------------------------------------------------------------------------------------------- |
gives everyone a live picture of where the others are — without anyone having to | Open the product | `https://podman.live` |
stop and ask. | 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` |
### The five-minute meeting that isn't | 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-vllm --model gemma-4-31B-it` |
<img width="800" height="705" alt="image" src="https://github.com/user-attachments/assets/4d26bd7a-177b-4650-8c99-bc2ef551d6bb" /> | 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) |
> **What people assume:** "Quick question, five minutes."
> **What actually happens:** the interrupted developer loses their place and needs
> 1525 minutes to climb back into deep focus. That five-minute ask quietly costs
> half an hour — for *two* people.
Multiply that by every teammate, every day, and coordination overhead — not
engineering skill — becomes the real ceiling on how fast a team ships.
PodMan removes the reason to interrupt. Because it already knows who is touching
which file, what's still unpushed, and what each person is in the middle of, any
teammate can see another's status instantly — no tap on the shoulder, no standup,
no recovery tax. And it gets better as it goes: every accept or dismiss teaches
it what your team actually cares about, so it nudges less and helps more over
time.
> GitHub sees pushed work. PodMan sees work while it is still happening — and
> remembers what helped.
--- ---
## 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 Gemma/vLLM 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-vllm` | Custom provider used by Hermes locally |
## How it learns ```mermaid
flowchart TB
Browser["Engineer Browser<br/>React + Vite PWA"]
Room["LiveKit Room<br/>screen share + audio + data messages"]
The learning loop is the product, not a side feature. It runs with almost no subgraph Droplet["DigitalOcean PodMan Droplet"]
extra work from anyone — the only human signal is a single accept/dismiss tap on Caddy["Caddy<br/>static app + /api proxy"]
a card. 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"]
observe → detect → RECALL prior outcomes → policy gate → act → record outcome Observations["observations"]
└──────────────────────────── feeds next recall ───────────────────────────┘ 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"]
VLLM["vLLM OpenAI Server<br/>gemma-4-31B-it<br/>262144 context"]
Hermes["Hermes Agent<br/>provider: gemma4-31b-vllm"]
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 --> VLLM
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,VLLM,Hermes ai;
``` ```
## What It Does
| Stage | What happens | Code | ```mermaid
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | flowchart LR
| **Observe** | Gemini Vision turns each screen frame into structured work context (file, symbol, activity, unpushed hints) | `backend/src/vision/gemini.ts` | A["Engineer shares screen"] --> B["Gemini extracts work context"]
| **Detect** | Same file touched by 2+ engineers with unpushed work → a coordination event | `backend/src/collision/detector.ts` | B --> C["PodMan detects overlap<br/>files, symbols, research, unpushed work"]
| **Recall (memory)** | Embed the event, query MongoDB Atlas `$vectorSearch` for similar past events, attach their prior intervention + outcome | `backend/src/memory/vectors.ts` | C --> D["MongoDB recalls<br/>similar prior events"]
| **Policy gate (adapt)** | A dismissed false alarm stays silent; a confirmed real catch escalates to critical; a per-pod cooldown prevents nagging | `backend/src/memory/policy.ts` | D --> E{"Policy gate"}
| **Act (least intrusive)** | Reuse the action kind that was accepted before; default to a card, escalate to a Hermes message, voice only when urgent | `backend/src/action/hermes.ts` | E -->|"seen false alarm"| F["stay quiet"]
| **Record (feedback)** | Accept/dismiss + "was it real?" is written back to memory, closing the loop for next time | `backend/src/memory/store.ts` | 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.
A few things make this real learning rather than a static prompt: > GitHub sees pushed work. PodMan sees work while it is still happening.
- It adapts from real teammate behavior during a real session, not an offline
dataset.
- It gets more useful as the `outcomes` collection grows — better recall, fewer
false alarms.
- It needs one tap. No labeling, no config, no retraining.
- The mechanism is memory: Atlas vector recall plus an outcome-conditioned
policy, with an exact-signature fallback when vector search isn't available.
In practice: a false alarm gets dismissed once, and the same pattern stays quiet
next time. A real conflict gets accepted once, and when it recurs PodMan recalls
it and escalates straight to a spoken "seen before" cue.
--- ---
## The Model Stack
PodMan uses multiple AI surfaces. They are intentionally split by job.
## Architecture
A browser PWA, an HTTP API service, LiveKit agent workers, and a
memory/action layer. The screen signal flows through LiveKit, never a manual
screenshot upload.
### Infrastructure: LiveKit · Hermes · MongoDB · DigitalOcean
How the real-time, action, memory, and hosting layers wire together. Everything
under the droplet boundary is one systemd-supervised DigitalOcean box; LiveKit
and Mongo Atlas are managed clouds it talks to.
```mermaid ```mermaid
flowchart LR flowchart LR
Dev["Engineer browser<br/>React PWA + screen share"] 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"]
subgraph DO["DigitalOcean droplet — systemd-supervised"] VisionRoute --> Gemini20["gemini-2.0-flash<br/>screen understanding"]
WebApi["Caddy + API service<br/>static app · /api · LiveKit tokens"] ConversationRoute --> GeminiLive["gemini-3.1-flash-live-preview<br/>live Q&A"]
Agent["Vision agent worker<br/>Gemini reads each screen frame"] ConversationRoute --> GeminiTTS["gemini-3.1-flash-tts-preview<br/>urgent speech"]
Coord["Hermes coordinator<br/>detects clashes · intervenes"] HermesRoute --> Gemma["gemma-4-31B-it<br/>Hermes + vLLM + tools"]
PyAgent["Live conversation agent<br/>(Python)"] EmbedRoute --> Voyage["voyage-4-lite<br/>primary embeddings"]
end EmbedRoute --> GeminiEmbed["gemini-embedding-001<br/>fallback embeddings"]
MusicRoute --> Lyria["lyria-3-clip-preview<br/>background music"]
LK["LiveKit room<br/>managed cloud"] classDef signal fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
Mongo[("MongoDB Atlas<br/>team memory")] classDef route fill:#eef8ee,stroke:#2f8a3a,color:#123915;
classDef model fill:#f4edff,stroke:#805ad5,color:#2d1857;
Dev -->|"open app · get token"| WebApi class Work,VoiceInput,OpsNeed,MemoryNeed,Ambient signal;
Dev <-->|"screens & voice out · cards & alerts in"| LK class VisionRoute,ConversationRoute,HermesRoute,EmbedRoute,MusicRoute route;
LK -->|"pre-commit work signal"| Agent --> Coord class Gemini20,GeminiLive,GeminiTTS,Gemma,Voyage,GeminiEmbed,Lyria model;
Coord -->|"nudge before duplicate work / clash"| LK
PyAgent <-->|"answer questions by voice"| LK
Coord -->|"learn from every outcome"| Mongo
PyAgent -->|"recall team context"| Mongo
WebApi <-->|"pods + outcomes"| Mongo
``` ```
> Mongo connectivity is **required at boot** — the API and both agents ping Atlas ### Gemma 4 31B via vLLM
> on startup and exit loudly rather than degrade silently.
### Gemini: roles, models, and triggers Hermes uses the external OpenAI-compatible endpoint:
Five distinct Gemini surfaces, each fired by a specific event in the pod and ```text
mapped to the file that calls it. Base URL: https://llm.alhinai.dev/v1
API key: not-needed
```mermaid Model: gemma-4-31B-it
flowchart LR Context: 262144 tokens
subgraph Triggers["Trigger (event in the pod)"]
Frame["Sampled IDE frame<br/>from LiveKit screen track"]
NewEvent["New observation<br/>or collision"]
Talk["Engineer speaks<br/>in the room"]
Urgent["Hermes raises an<br/>urgent intervention"]
Session["Pod session<br/>active"]
end
subgraph G["Gemini surface · model · code"]
Vision["Vision<br/>gemini-2.0-flash<br/>backend/src/vision/gemini.ts"]
Embed["Embeddings<br/>gemini-embedding-001<br/>backend/src/memory/vectors.ts"]
Live["Live voice agent<br/>gemini-3.1-flash-live-preview<br/>agents/podman-live-conversation/agent.py"]
TTS["Urgent TTS<br/>gemini-3.1-flash-tts-preview<br/>backend/src/voice/live.ts"]
Lyria["Background music<br/>lyria-3-clip-preview<br/>backend/src/voice/music.ts"]
end
subgraph Out["Output"]
Ctx["Structured work context<br/>{file, symbol, activity, mode, research}"]
Recall["$vectorSearch recall<br/>prior intervention + outcome"]
Answer["Spoken answer over<br/>repo / git / memory tools"]
Voice["Spoken alert<br/>in the room"]
Score["Per-pod ambient<br/>score"]
end
Frame --> Vision --> Ctx
NewEvent --> Embed --> Recall
Talk --> Live --> Answer
Urgent --> TTS --> Voice
Session --> Lyria --> Score
``` ```
The vLLM server is configured for Hermes-style tool use:
```text
--max-model-len 262144
--max-num-seqs 1
--max-num-batched-tokens 16384
--gpu-memory-utilization 0.85
--kv-cache-dtype fp8
--enable-auto-tool-choice
--tool-call-parser gemma4
--enable-chunked-prefill
```
Hermes should point at that endpoint with this provider shape:
```yaml
model:
default: gemma-4-31B-it
provider: gemma4-31b-vllm
### Runtime shape providers:
gemma4-31b-vllm:
name: Gemma 4 31B vLLM (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
```
| Layer | Runtime | Responsibility | Why this matters: Hermes sends OpenAI tool schemas and `tool_choice: "auto"`.
| ----------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------- | Without `--enable-auto-tool-choice` and `--tool-call-parser gemma4`, vLLM returns
| Frontend PWA | React + Vite | Join pods, publish screen share, render interventions, play audio | HTTP 400 before Hermes can initialize an agent.
| Backend API | Express | Mint LiveKit tokens, manage pods, record outcomes, expose memory stats, create sync PRs |
| Vision agent | `@livekit/rtc-node` | Subscribe to screen-share tracks, sample frames, publish intervention data |
| Live conversation agent | LiveKit Agents (Python) + Gemini Live API | Real-time voice Q&A with function tools over repo, git, and memory |
| Perception | Gemini Vision (`gemini-2.0-flash`) | Sampled IDE frames → structured work context |
| Team memory | MongoDB Atlas | Observations, collisions, interventions, outcomes, vector embeddings |
| Git watcher | Node script | Report each laptop's dirty/unpushed state as ground truth |
| Action layer | Hermes | Cards, teammate messages, Gemini TTS urgent voice, Lyria background score |
| Deployment | DigitalOcean | Static frontend, API service, agent workers |
### 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` |
### Data flow 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 vLLM | 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
@@ -197,161 +265,106 @@ 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
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 + publish screen share Dev->>LK: Join pod room + publish screen share
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->>Mongo: Recall prior patterns (vector search) Agent->>Mongo: Recall similar prior events
Mongo-->>Agent: Prior intervention + outcome Mongo-->>Agent: Prior outcome + policy hints
Agent->>Hermes: Create intervention (gated by policy) Agent->>Hermes: Escalate when autonomous help is useful
Hermes->>LK: Publish card / message / Gemini TTS voice Agent->>LK: Publish card / message / voice cue
LK-->>Dev: Render intervention Dev->>API: POST /api/outcome
Dev->>API: POST /api/outcome (accept / dismiss) API->>Mongo: Store accept/dismiss feedback
API->>Mongo: Store learning signal
``` ```
---
## Public Interfaces
| Interface | Purpose |
| ----------------------------------------------------------- | ---------------------------------------------- |
| `GET /health` | API health check |
| `POST /api/token` | Mint LiveKit room tokens |
| `GET /api/pods` | List pods |
| `GET/POST/PATCH/DELETE /api/pods` | Pod CRUD |
| `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 |
| Wire messages `COLLISION`, `ACK`, `GIT_REPORT`, `VOICE_CUE` | Agent/PWA contract |
--- ---
## Monorepo Layout
| Folder | Purpose |
## Built with | ----------- | ----------------------------------------------------------------------- |
| `frontend/` | React + Vite PWA |
Everything below maps to code in this repo. | `backend/` | Express API, vision agent, memory, collision detection, Hermes job APIs |
| `agents/` | Python LiveKit conversation agent |
**Gemini** does the perception, the voice, and the memory: | `shared/` | Shared TypeScript contracts |
| `database/` | MongoDB setup and seed utilities |
| `infra/` | Caddy, Docker, DigitalOcean, systemd units |
| Use | Model | Where | | `scripts/` | Git watcher, deploy doctor, watchdog, verification tooling |
| --------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------ | | `docs/` | Demo, deployment, learning, graph, and architecture notes |
| Real-time voice agent (talk to PodMan, answered with repo/git/memory tools) | `gemini-3.1-flash-live-preview` | `agents/podman-live-conversation/agent.py` |
| Spoken urgent alerts over LiveKit | `gemini-3.1-flash-tts-preview` | `backend/src/voice/live.ts` |
| Screen understanding → structured work context | `gemini-2.0-flash` | `backend/src/vision/gemini.ts` |
| Per-pod background music (Interactions API) | `lyria-3-clip-preview` | `backend/src/voice/music.ts` |
| Embeddings for memory recall | `gemini-embedding-001` | `backend/src/memory/vectors.ts` |
**LiveKit** is the real-time layer: screen-share tracks are the input, a typed
data channel (`podman.intervention`) carries cards and messages, audio tracks
carry the spoken alerts and music, and a Python LiveKit Agents worker runs the
live conversation agent in the room.
**MongoDB Atlas** is the memory: `$vectorSearch` recalls similar past events
(exact-signature fallback when needed), and the `outcomes` collection drives the
policy that decides whether and how to act.
**DigitalOcean** hosts it: a static frontend, the API service, and the agent
workers, supervised by systemd. Mongo connectivity is required at boot — services
fail loudly rather than degrade silently.
--- ---
## Local Development
Run the core app in three terminals:
## How it works ```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
1. Engineers open the PWA and join a pod room. classDef step fill:#eef8ee,stroke:#2f8a3a,color:#123915;
2. The API mints a LiveKit token via `POST /api/token`. classDef run fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
3. The PWA publishes screen share into the room on "Share my screen". class Env,Install step;
4. The PodMan vision agent subscribes to the screen-share tracks. class API,Agent,UI,Voice,Browser run;
5. The agent samples frames, sends them to Gemini Vision, records structured ```
observations in MongoDB.
6. Each engineer runs the git watcher so PodMan has deterministic
dirty/unpushed truth.
7. The detector fuses live screen context, git truth, GitHub state, and recalled
memory.
8. The policy gate decides whether and how to act — card, Hermes message, or
urgent voice — reusing what worked before.
9. Urgent escalations are spoken via Gemini TTS over a LiveKit audio track.
10. The teammate's accept/dismiss is saved as an outcome, closing the
continual-learning loop.
Separately, any teammate can start a **live voice conversation** with PodMan
(Gemini Live API) to ask about current work, git state, or where something lives
in the repo — answered with real tool calls, not guesses.
---
## Public interfaces
| Interface | Purpose |
| ----------------------------------------------------------- | ------------------------------------------------- |
| `GET /health` | API health check |
| `POST /api/token` | Mint LiveKit room tokens |
| `POST /api/sync-pr` | Create a visible sync PR artifact |
| `POST /api/outcome` | Store accepted/dismissed intervention outcomes |
| `GET /api/memory/stats` | Memory collection counts (live learning evidence) |
| `GET/POST/PATCH/DELETE /api/pods` | Pod CRUD |
| `POST/DELETE /api/pods/:id/members` | Pod membership |
| LiveKit topic `podman.intervention` | Intervention data channel |
| Wire messages `COLLISION`, `ACK`, `GIT_REPORT`, `VOICE_CUE` | Agent/PWA contract |
---
## Monorepo layout
| Folder | What |
| ----------- | -------------------------------------------------------------------------- |
| `frontend/` | React + Vite PWA — pods, LiveKit room UI, screen share, intervention cards |
| `backend/` | Express API plus the LiveKit vision agent worker |
| `agents/` | Python LiveKit Agents worker for the Gemini Live conversation agent |
| `shared/` | Shared TypeScript types and LiveKit data message contracts |
| `database/` | MongoDB setup and seed utilities |
| `infra/` | DigitalOcean specs, Caddyfile, systemd units |
| `scripts/` | Local git watcher + deploy/verify tooling |
| `docs/` | Integration specs and the demo script |
---
## Quick start
```bash ```bash
cp .env.example .env cp .env.example .env
# fill in LIVEKIT_*, GEMINI_*, GITHUB_*, 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 vision 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
``` ```
The live conversation agent (Gemini Live API) runs from `agents/podman-live-conversation/`. Run the Python live conversation agent:
---
## Git watcher — run on every demo laptop
Each engineer runs this before the demo. It polls the local git working tree
every 15 seconds and writes git state to MongoDB so PodMan has deterministic
dirty/unpushed truth that vision alone cannot reliably infer.
```bash ```bash
# from the repo root pnpm livekit:conversation:agent
node scripts/podman-agent.mjs --name <yourname> --pod <podId>
``` ```
**Demo setup:** Run the local git watcher on each demo laptop:
```bash
node scripts/podman-agent.mjs --name <engineer-name> --pod <pod-id>
```
Demo identities:
```bash ```bash
node scripts/podman-agent.mjs --name alice --pod demo-pod node scripts/podman-agent.mjs --name alice --pod demo-pod
@@ -359,5 +372,184 @@ node scripts/podman-agent.mjs --name bob --pod demo-pod
node scripts/podman-agent.mjs --name carol --pod demo-pod node scripts/podman-agent.mjs --name carol --pod demo-pod
``` ```
**Requirements:** `MONGODB_URI` exported (or in `backend/.env`), `pnpm install` ---
run first, launched from the repo root.
## Production Operations
The production droplet is systemd-supervised. Caddy serves the built frontend
and proxies `/api/*` to the backend on `127.0.0.1:8787`.
```mermaid
flowchart LR
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;
```
| 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
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
```
Then check the droplet services:
```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
```
Hermes operations scripts:
```bash
pnpm hermes:watchdog
pnpm hermes:watchdog:strict
pnpm hermes:sync-deploy
pnpm deploy:doctor:strict
```
Gemma/vLLM 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-vllm
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-vllm \
--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 says tool auto-choice is disabled | vLLM missing tool flags | verify `--enable-auto-tool-choice --tool-call-parser gemma4` |
| `llm.alhinai.dev` returns 502 | Gemma vLLM still loading or tunnel target down | `curl /v1/models`, vLLM 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.
+16
View File
@@ -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."""
+1
View File
@@ -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:*",
+30
View File
@@ -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;
}
+4 -1
View File
@@ -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];
@@ -42,6 +44,7 @@ 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')), RESEARCH_OVERLAP_THRESHOLD: Number(opt('RESEARCH_OVERLAP_THRESHOLD', '0.6')),
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'), INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
+16 -1
View File
@@ -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
View File
@@ -56,6 +56,17 @@ export interface PodCollections {
suppressions: Collection<SuppressionDoc>; 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> {
const db = await getDb(); const db = await getDb();
return { return {
@@ -142,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 {
+55 -9
View File
@@ -5,16 +5,19 @@ import type {
InterventionOutcome, InterventionOutcome,
InterventionStatus, InterventionStatus,
} from '@podman/shared'; } from '@podman/shared';
import { collections, getGitStates } 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)),
@@ -163,12 +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, suppressions] = 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(), c.suppressions.estimatedDocumentCount(),
db.collection<UserPodContextDoc>('user_pod_context').estimatedDocumentCount(),
db.collection('user_learning_profiles').estimatedDocumentCount(),
]); ]);
return { observations, collisions, interventions, outcomes, suppressions }; return {
observations,
collisions,
interventions,
outcomes,
suppressions,
userPodContext,
userLearningProfiles,
};
} }
+282
View File
@@ -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();
}
+59 -6
View File
@@ -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> {
+91 -7
View File
@@ -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,
@@ -40,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,
@@ -60,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[] {
@@ -72,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' ||
@@ -90,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
@@ -113,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 });
}); });
@@ -150,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 {
@@ -170,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 });
} }
@@ -186,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 });
@@ -203,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 });
} }
@@ -306,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 });
} }
+2
View File
@@ -11,6 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@clerk/react": "^6.11.1",
"@clerk/ui": "^1.23.0",
"@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist": "^5.2.9",
"@podman/shared": "workspace:*", "@podman/shared": "workspace:*",
"@shadcn/react": "^0.1.0", "@shadcn/react": "^0.1.0",
+142 -21
View File
@@ -1,4 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import {
Show,
SignUp,
SignInButton,
SignUpButton,
UserButton,
useAuth,
useUser,
} from '@clerk/react';
import type { Room } from 'livekit-client'; import type { Room } from 'livekit-client';
import { import {
AlertCircleIcon, AlertCircleIcon,
@@ -54,7 +63,13 @@ function replacePath(path: string): void {
window.history.replaceState({}, '', path || '/'); window.history.replaceState({}, '', path || '/');
} }
function firstNameFrom(value: string | null | undefined): string {
return value?.trim().split(/\s+/).filter(Boolean)[0] ?? '';
}
export default function App() { export default function App() {
const { getToken, isLoaded, isSignedIn } = useAuth();
const { user } = useUser();
const [pods, setPods] = useState<Pod[]>([]); const [pods, setPods] = useState<Pod[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<Set<string>>(new Set()); const [pending, setPending] = useState<Set<string>>(new Set());
@@ -68,6 +83,21 @@ export default function App() {
const [graphPodId, setGraphPodId] = useState<string | null>(null); const [graphPodId, setGraphPodId] = useState<string | null>(null);
const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null; const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null;
const userEmail = user?.primaryEmailAddress?.emailAddress;
const defaultMemberName =
user?.firstName?.trim() ||
firstNameFrom(user?.fullName) ||
firstNameFrom(userEmail?.split('@')[0]);
const currentUserProfile = {
displayName: defaultMemberName,
email: userEmail,
imageUrl: user?.imageUrl,
};
useEffect(() => {
api.setAuthTokenGetter(isSignedIn ? getToken : null);
return () => api.setAuthTokenGetter(null);
}, [getToken, isSignedIn]);
async function refresh() { async function refresh() {
setLoading(true); setLoading(true);
@@ -98,7 +128,13 @@ export default function App() {
const previousPath = window.location.pathname; const previousPath = window.location.pathname;
setPodPath(podId, replaceRoute); setPodPath(podId, replaceRoute);
try { try {
const result = await joinPod(podId, who, who); const result = await joinPod(
podId,
who,
who,
isSignedIn ? getToken : undefined,
currentUserProfile,
);
setRoom(result.room); setRoom(result.room);
setDevMode(result.mode === 'dev'); setDevMode(result.mode === 'dev');
setMember(who); setMember(who);
@@ -111,11 +147,15 @@ export default function App() {
} }
useEffect(() => { useEffect(() => {
if (!isSignedIn) {
setLoading(false);
return;
}
void refresh(); void refresh();
}, []); }, [isSignedIn]);
useEffect(() => { useEffect(() => {
if (joinedPodId) return; if (!isSignedIn || joinedPodId) return;
let alive = true; let alive = true;
const tick = async () => { const tick = async () => {
try { try {
@@ -133,15 +173,13 @@ export default function App() {
alive = false; alive = false;
window.clearInterval(id); window.clearInterval(id);
}; };
}, [joinedPodId]); }, [isSignedIn, joinedPodId]);
useEffect(() => { useEffect(() => {
if (!isSignedIn) return;
const routedPodId = pathPodId(); const routedPodId = pathPodId();
const raw = sessionStorage.getItem(SESSION_KEY); const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) { if (!raw) {
if (routedPodId) {
setError(`Enter your name to join ${routedPodId}.`);
}
return; return;
} }
let saved: { podId: string; member: string }; let saved: { podId: string; member: string };
@@ -162,10 +200,11 @@ export default function App() {
setRestoring(false); setRestoring(false);
} }
})(); })();
}, []); }, [isSignedIn]);
useEffect(() => { useEffect(() => {
const onPopState = () => { const onPopState = () => {
if (!isSignedIn) return;
const routedPodId = pathPodId(); const routedPodId = pathPodId();
if (!routedPodId) { if (!routedPodId) {
room?.disconnect(); room?.disconnect();
@@ -186,7 +225,7 @@ export default function App() {
}; };
window.addEventListener('popstate', onPopState); window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState); return () => window.removeEventListener('popstate', onPopState);
}, [joinedPodId, room]); }, [isSignedIn, joinedPodId, room]);
async function run(key: string, fn: () => Promise<void>) { async function run(key: string, fn: () => Promise<void>) {
startPending(key); startPending(key);
@@ -206,7 +245,7 @@ export default function App() {
startPending('new'); startPending('new');
setError(null); setError(null);
try { try {
const created = await api.createPod(input); const created = await api.createPod(input, currentUserProfile);
setPods((cur) => [...cur, created]); setPods((cur) => [...cur, created]);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@@ -225,7 +264,7 @@ export default function App() {
if (pathPodId() === id) setHomePath(); if (pathPodId() === id) setHomePath();
}); });
const handleAddMember = (id: string, name: string) => const handleAddMember = (id: string, name: string) =>
run(id, async () => upsert(await api.addMember(id, name))); run(id, async () => upsert(await api.addMember(id, name, currentUserProfile)));
const handleRemoveMember = (id: string, name: string) => const handleRemoveMember = (id: string, name: string) =>
run(id, async () => upsert(await api.removeMember(id, name))); run(id, async () => upsert(await api.removeMember(id, name)));
@@ -241,12 +280,15 @@ export default function App() {
} }
} }
async function handleAddAndJoin(pod: Pod, name: string) { async function handleAddAndJoin(pod: Pod) {
startPending(pod.id); startPending(pod.id);
setError(null); setError(null);
try { try {
upsert(await api.addMember(pod.id, name)); if (!defaultMemberName) {
await connectToPod(pod.id, name); throw new Error('Sign in with Clerk before joining a pod.');
}
upsert(await api.addMember(pod.id, defaultMemberName, currentUserProfile));
await connectToPod(pod.id, defaultMemberName);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} finally { } finally {
@@ -265,9 +307,28 @@ export default function App() {
const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null); const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null);
if (!isLoaded) {
return (
<div className="grid min-h-screen place-items-center bg-background text-foreground">
<Skeleton className="h-12 w-64" />
</div>
);
}
if (!isSignedIn) {
return <AuthGate />;
}
if (joinedPod) { if (joinedPod) {
return ( return (
<PodView team={joinedPod} me={member} room={room} devMode={devMode} onLeave={handleLeave} /> <PodView
team={joinedPod}
me={member}
room={room}
devMode={devMode}
currentUserProfile={currentUserProfile}
onLeave={handleLeave}
/>
); );
} }
@@ -289,10 +350,23 @@ export default function App() {
</div> </div>
</div> </div>
<Button variant="outline" onClick={() => void refresh()} disabled={loading}> <div className="flex items-center gap-2">
<RefreshCwIcon data-icon="inline-start" /> <Show when="signed-out">
Refresh <SignInButton mode="modal">
</Button> <Button variant="outline">Sign in</Button>
</SignInButton>
<SignUpButton mode="modal">
<Button>Sign up</Button>
</SignUpButton>
</Show>
<Show when="signed-in">
<UserButton />
</Show>
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
<RefreshCwIcon data-icon="inline-start" />
Refresh
</Button>
</div>
</div> </div>
</header> </header>
@@ -342,6 +416,7 @@ export default function App() {
pod={pod} pod={pod}
busy={pending.has(pod.id)} busy={pending.has(pod.id)}
presence={presence[pod.id] ?? []} presence={presence[pod.id] ?? []}
currentUserProfile={currentUserProfile}
onJoin={handleJoin} onJoin={handleJoin}
onAddAndJoin={handleAddAndJoin} onAddAndJoin={handleAddAndJoin}
onAddMember={handleAddMember} onAddMember={handleAddMember}
@@ -362,14 +437,23 @@ export default function App() {
<EmptyDescription>Create the first room for this team.</EmptyDescription> <EmptyDescription>Create the first room for this team.</EmptyDescription>
</EmptyHeader> </EmptyHeader>
<EmptyContent> <EmptyContent>
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} compact /> <CreatePodForm
busy={pending.has('new')}
defaultMemberName={defaultMemberName}
onCreate={handleCreate}
compact
/>
</EmptyContent> </EmptyContent>
</Empty> </Empty>
)} )}
</section> </section>
<aside className="flex flex-col gap-4"> <aside className="flex flex-col gap-4">
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} /> <CreatePodForm
busy={pending.has('new')}
defaultMemberName={defaultMemberName}
onCreate={handleCreate}
/>
</aside> </aside>
</main> </main>
)} )}
@@ -378,6 +462,43 @@ export default function App() {
); );
} }
function AuthGate() {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 py-4 sm:px-6 lg:px-8">
<header className="flex items-center justify-between border-b pb-4 pt-2">
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-10 place-items-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground shadow-sm">
PM
</div>
<h1 className="text-[1.95rem] font-semibold leading-none tracking-tight">PodMan</h1>
</div>
<SignInButton mode="modal">
<Button variant="outline">Sign in</Button>
</SignInButton>
</header>
<main className="grid flex-1 items-center gap-8 py-8 lg:grid-cols-[minmax(0,1fr)_420px]">
<section className="max-w-xl">
<p className="text-xs font-medium uppercase text-muted-foreground">Team memory</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight">
Create your account to enter PodMan
</h2>
<p className="mt-3 text-base text-muted-foreground">
PodMan saves your context across pods so agents can learn from your work in every
room you join.
</p>
</section>
<div className="flex justify-center lg:justify-end">
<SignUp routing="hash" />
</div>
</main>
</div>
</div>
);
}
function PodSkeleton() { function PodSkeleton() {
return ( return (
<Card> <Card>
+4 -2
View File
@@ -17,17 +17,19 @@ import { cn } from '@/lib/utils';
export function CreatePodForm({ export function CreatePodForm({
busy, busy,
onCreate, onCreate,
defaultMemberName = '',
compact = false, compact = false,
}: { }: {
busy: boolean; busy: boolean;
onCreate: (input: PodInput) => Promise<void>; onCreate: (input: PodInput) => Promise<void>;
defaultMemberName?: string;
compact?: boolean; compact?: boolean;
}) { }) {
const [open, setOpen] = useState(compact); const [open, setOpen] = useState(compact);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [repo, setRepo] = useState('karti-ai/podman'); const [repo, setRepo] = useState('karti-ai/podman');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [firstMember, setFirstMember] = useState(''); const [firstMember, setFirstMember] = useState(defaultMemberName);
async function submit() { async function submit() {
if (!name.trim()) return; if (!name.trim()) return;
@@ -40,7 +42,7 @@ export function CreatePodForm({
}); });
setName(''); setName('');
setDescription(''); setDescription('');
setFirstMember(''); setFirstMember(defaultMemberName);
if (!compact) setOpen(false); if (!compact) setOpen(false);
} catch { } catch {
/* parent owns the visible error */ /* parent owns the visible error */
+34 -25
View File
@@ -7,7 +7,13 @@ import {
VideoIcon, VideoIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { Pod, PodInput } from '@podman/shared'; import type { Pod, PodInput } from '@podman/shared';
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar'; import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -41,6 +47,7 @@ export function PodCard({
pod, pod,
busy, busy,
presence, presence,
currentUserProfile,
onJoin: _onJoin, onJoin: _onJoin,
onAddAndJoin, onAddAndJoin,
onAddMember: _onAddMember, onAddMember: _onAddMember,
@@ -52,15 +59,19 @@ export function PodCard({
pod: Pod; pod: Pod;
busy: boolean; busy: boolean;
presence: string[]; presence: string[];
currentUserProfile?: {
displayName: string;
email?: string;
imageUrl?: string;
};
onJoin: (pod: Pod, member: string) => void; onJoin: (pod: Pod, member: string) => void;
onAddAndJoin: (pod: Pod, name: string) => void; onAddAndJoin: (pod: Pod) => void;
onAddMember: (id: string, name: string) => void; onAddMember: (id: string, name: string) => void;
onRemoveMember: (id: string, name: string) => void; onRemoveMember: (id: string, name: string) => void;
onUpdate: (id: string, patch: PodInput) => void; onUpdate: (id: string, patch: PodInput) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onOpenGraph: (id: string) => void; onOpenGraph: (id: string) => void;
}) { }) {
const [newMember, setNewMember] = useState('');
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PodInput>({ const [draft, setDraft] = useState<PodInput>({
name: pod.name, name: pod.name,
@@ -69,6 +80,13 @@ export function PodCard({
}); });
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase()); const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
const profileForMember = (name: string) =>
pod.memberProfiles?.[name] ??
([currentUserProfile?.displayName, currentUserProfile?.email]
.filter(Boolean)
.some((value) => value?.toLowerCase() === name.toLowerCase())
? currentUserProfile
: undefined);
const active = presence.length > 0; const active = presence.length > 0;
function saveEdit() { function saveEdit() {
@@ -80,13 +98,8 @@ export function PodCard({
setEditing(false); setEditing(false);
} }
// One action: enter your name, hit Join → added to the roster (deduped
// server-side) and connected to the room in a single step.
function join() { function join() {
const name = newMember.trim(); onAddAndJoin(pod);
if (!name) return;
onAddAndJoin(pod, name);
setNewMember('');
} }
return ( return (
@@ -143,12 +156,16 @@ export function PodCard({
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<AvatarGroup> <AvatarGroup>
{pod.members.slice(0, 4).map((member) => ( {pod.members.slice(0, 4).map((member) => {
<Avatar key={member} title={member}> const profile = profileForMember(member);
<AvatarFallback>{initials(member)}</AvatarFallback> return (
{inRoom(member) && <AvatarBadge />} <Avatar key={member} title={profile?.email ?? member}>
</Avatar> {profile?.imageUrl && <AvatarImage src={profile.imageUrl} alt={member} />}
))} <AvatarFallback>{initials(member)}</AvatarFallback>
{inRoom(member) && <AvatarBadge />}
</Avatar>
);
})}
{pod.members.length > 4 && <span className="text-sm text-muted-foreground">+</span>} {pod.members.length > 4 && <span className="text-sm text-muted-foreground">+</span>}
</AvatarGroup> </AvatarGroup>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
@@ -157,16 +174,8 @@ export function PodCard({
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex justify-end">
<Input <Button className="min-w-24" onClick={join} disabled={busy}>
placeholder="Your name"
value={newMember}
onChange={(e) => setNewMember(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') join();
}}
/>
<Button className="min-w-24" onClick={join} disabled={busy || !newMember.trim()}>
<VideoIcon data-icon="inline-start" /> <VideoIcon data-icon="inline-start" />
Join Join
</Button> </Button>
+60 -8
View File
@@ -56,7 +56,7 @@ import {
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js'; import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
import { usePodActivity } from '../hooks/use-pod-activity.js'; import { usePodActivity } from '../hooks/use-pod-activity.js';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -100,24 +100,46 @@ const STREAM_RAIL_WIDTH = '4rem';
interface PInfo { interface PInfo {
id: string; id: string;
name: string; name: string;
email?: string;
imageUrl?: string;
isLocal: boolean; isLocal: boolean;
speaking: boolean; speaking: boolean;
} }
function profileFromMetadata(metadata?: string): { email?: string; imageUrl?: string } {
try {
const parsed = JSON.parse(metadata || '{}') as { email?: unknown; imageUrl?: unknown };
return {
email: typeof parsed.email === 'string' ? parsed.email : undefined,
imageUrl: typeof parsed.imageUrl === 'string' ? parsed.imageUrl : undefined,
};
} catch {
return {};
}
}
function snapshot(room: Room, fallbackName: string): PInfo[] { function snapshot(room: Room, fallbackName: string): PInfo[] {
const lp = room.localParticipant; const lp = room.localParticipant;
const localProfile = profileFromMetadata(lp.metadata);
const local: PInfo = { const local: PInfo = {
id: lp.identity, id: lp.identity,
name: lp.name || fallbackName, name: lp.name || fallbackName,
email: localProfile.email,
imageUrl: localProfile.imageUrl,
isLocal: true, isLocal: true,
speaking: lp.isSpeaking, speaking: lp.isSpeaking,
}; };
const remotes = Array.from(room.remoteParticipants.values()).map((p) => ({ const remotes = Array.from(room.remoteParticipants.values()).map((p) => {
id: p.identity, const profile = profileFromMetadata(p.metadata);
name: p.name || p.identity, return {
isLocal: false, id: p.identity,
speaking: p.isSpeaking, name: p.name || p.identity,
})); email: profile.email,
imageUrl: profile.imageUrl,
isLocal: false,
speaking: p.isSpeaking,
};
});
return [local, ...remotes]; return [local, ...remotes];
} }
@@ -136,11 +158,17 @@ export function PodView({
room, room,
devMode, devMode,
onLeave, onLeave,
currentUserProfile,
}: { }: {
team: Pod; team: Pod;
me: string; me: string;
room: Room | null; room: Room | null;
devMode: boolean; devMode: boolean;
currentUserProfile?: {
displayName: string;
email?: string;
imageUrl?: string;
};
onLeave: () => void; onLeave: () => void;
}) { }) {
const [participants, setParticipants] = useState<PInfo[]>([]); const [participants, setParticipants] = useState<PInfo[]>([]);
@@ -241,6 +269,7 @@ export function PodView({
room room
.on(RoomEvent.ParticipantConnected, refresh) .on(RoomEvent.ParticipantConnected, refresh)
.on(RoomEvent.ParticipantDisconnected, refresh) .on(RoomEvent.ParticipantDisconnected, refresh)
.on(RoomEvent.ParticipantMetadataChanged, refresh)
.on(RoomEvent.ActiveSpeakersChanged, refresh) .on(RoomEvent.ActiveSpeakersChanged, refresh)
.on(RoomEvent.TrackSubscribed, onAudio) .on(RoomEvent.TrackSubscribed, onAudio)
.on(RoomEvent.TrackUnsubscribed, onAudioGone) .on(RoomEvent.TrackUnsubscribed, onAudioGone)
@@ -252,6 +281,7 @@ export function PodView({
room room
.off(RoomEvent.ParticipantConnected, refresh) .off(RoomEvent.ParticipantConnected, refresh)
.off(RoomEvent.ParticipantDisconnected, refresh) .off(RoomEvent.ParticipantDisconnected, refresh)
.off(RoomEvent.ParticipantMetadataChanged, refresh)
.off(RoomEvent.ActiveSpeakersChanged, refresh) .off(RoomEvent.ActiveSpeakersChanged, refresh)
.off(RoomEvent.TrackSubscribed, onAudio) .off(RoomEvent.TrackSubscribed, onAudio)
.off(RoomEvent.TrackUnsubscribed, onAudioGone) .off(RoomEvent.TrackUnsubscribed, onAudioGone)
@@ -738,6 +768,8 @@ export function PodView({
<Participant <Participant
key={p.id} key={p.id}
participant={p} participant={p}
rosterProfile={team.memberProfiles?.[p.name]}
currentUserProfile={currentUserProfile}
onOpenHistory={setHistoryMember} onOpenHistory={setHistoryMember}
/> />
))} ))}
@@ -969,11 +1001,28 @@ export function PodView({
function Participant({ function Participant({
participant, participant,
rosterProfile,
currentUserProfile,
onOpenHistory, onOpenHistory,
}: { }: {
participant: PInfo; participant: PInfo;
rosterProfile?: {
displayName: string;
email?: string;
imageUrl?: string;
};
currentUserProfile?: {
displayName: string;
email?: string;
imageUrl?: string;
};
onOpenHistory: (member: string) => void; onOpenHistory: (member: string) => void;
}) { }) {
const localProfile = participant.isLocal ? currentUserProfile : undefined;
const profile = {
email: participant.email ?? localProfile?.email ?? rosterProfile?.email,
imageUrl: participant.imageUrl ?? localProfile?.imageUrl ?? rosterProfile?.imageUrl,
};
return ( return (
<div <div
className={cn( className={cn(
@@ -983,12 +1032,15 @@ function Participant({
> >
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<Avatar> <Avatar>
{profile.imageUrl && <AvatarImage src={profile.imageUrl} alt={participant.name} />}
<AvatarFallback>{initials(participant.name)}</AvatarFallback> <AvatarFallback>{initials(participant.name)}</AvatarFallback>
{participant.speaking && <AvatarBadge />} {participant.speaking && <AvatarBadge />}
</Avatar> </Avatar>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium">{participant.name}</p> <p className="truncate text-sm font-medium">{participant.name}</p>
<p className="text-xs text-muted-foreground">{participant.isLocal ? 'you' : 'remote'}</p> <p className="truncate text-xs text-muted-foreground">
{profile.email ?? (participant.isLocal ? 'you' : 'remote')}
</p>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
+66 -30
View File
@@ -14,11 +14,34 @@ const BACKEND_URL =
? 'http://localhost:8787' ? 'http://localhost:8787'
: ''); : '');
type AuthTokenGetter = () => Promise<string | null>;
let authTokenGetter: AuthTokenGetter | null = null;
export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
authTokenGetter = getter;
}
async function requestHeaders(init?: HeadersInit): Promise<Headers> {
const next = new Headers(init);
const token = await authTokenGetter?.();
if (token) next.set('authorization', `Bearer ${token}`);
return next;
}
async function apiFetch(input: string, init: RequestInit = {}): Promise<Response> {
return fetch(input, {
...init,
headers: await requestHeaders(init.headers),
});
}
export interface MemoryStats { export interface MemoryStats {
observations: number; observations: number;
collisions: number; collisions: number;
interventions: number; interventions: number;
outcomes: number; outcomes: number;
userPodContext?: number;
} }
export interface LiveConversationSession { export interface LiveConversationSession {
@@ -34,6 +57,12 @@ export interface LiveConversationSession {
endedAt?: string; endedAt?: string;
} }
export interface UserProfilePayload {
displayName?: string;
email?: string;
imageUrl?: string;
}
async function json<T>(res: Response): Promise<T> { async function json<T>(res: Response): Promise<T> {
if (!res.ok) { if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }; const body = (await res.json().catch(() => ({}))) as { error?: string };
@@ -42,16 +71,19 @@ async function json<T>(res: Response): Promise<T> {
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
const JSON_HEADERS = { 'content-type': 'application/json' } as const;
/** Mint a LiveKit token from the backend. */ /** Mint a LiveKit token from the backend. */
export async function fetchToken(params: { export async function fetchToken(params: {
room: string; room: string;
identity: string; identity: string;
name: string; name: string;
githubLogin?: string; githubLogin?: string;
profile?: UserProfilePayload;
}): Promise<{ token: string; url: string }> { }): Promise<{ token: string; url: string }> {
const res = await fetch(`${BACKEND_URL}/api/token`, { const res = await apiFetch(`${BACKEND_URL}/api/token`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: JSON_HEADERS,
body: JSON.stringify(params), body: JSON.stringify(params),
}); });
return json(res); return json(res);
@@ -59,9 +91,9 @@ export async function fetchToken(params: {
/** Record an intervention outcome for the policy learning loop. */ /** Record an intervention outcome for the policy learning loop. */
export async function postOutcome(outcome: InterventionOutcome): Promise<void> { export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
const res = await fetch(`${BACKEND_URL}/api/outcome`, { const res = await apiFetch(`${BACKEND_URL}/api/outcome`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: JSON_HEADERS,
body: JSON.stringify(outcome), body: JSON.stringify(outcome),
}); });
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`); if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
@@ -73,9 +105,9 @@ export async function createSyncPr(input: {
summary?: string; summary?: string;
}): Promise<{ url: string; number: number }> { }): Promise<{ url: string; number: number }> {
return json( return json(
await fetch(`${BACKEND_URL}/api/sync-pr`, { await apiFetch(`${BACKEND_URL}/api/sync-pr`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: JSON_HEADERS,
body: JSON.stringify(input), body: JSON.stringify(input),
}), }),
); );
@@ -84,21 +116,21 @@ export async function createSyncPr(input: {
// --- Pods CRUD --- // --- Pods CRUD ---
export async function listPods(): Promise<Pod[]> { export async function listPods(): Promise<Pod[]> {
return json(await fetch(`${BACKEND_URL}/api/pods`)); return json(await apiFetch(`${BACKEND_URL}/api/pods`));
} }
/** Display names currently connected per pod id (= LiveKit room name). */ /** Display names currently connected per pod id (= LiveKit room name). */
export async function getPresence(): Promise<Record<string, string[]>> { export async function getPresence(): Promise<Record<string, string[]>> {
return json(await fetch(`${BACKEND_URL}/api/presence`)); return json(await apiFetch(`${BACKEND_URL}/api/presence`));
} }
export async function getMemoryStats(): Promise<MemoryStats> { export async function getMemoryStats(): Promise<MemoryStats> {
return json(await fetch(`${BACKEND_URL}/api/memory/stats`)); return json(await apiFetch(`${BACKEND_URL}/api/memory/stats`));
} }
export async function getPodActivity(id: string, limit = 80): Promise<PodActivityEvent[]> { export async function getPodActivity(id: string, limit = 80): Promise<PodActivityEvent[]> {
return json( return json(
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity?limit=${limit}`), await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity?limit=${limit}`),
); );
} }
@@ -116,7 +148,7 @@ export async function getMemberWorkHistory(
member: string, member: string,
): Promise<MemberWorkHistory> { ): Promise<MemberWorkHistory> {
return json( return json(
await fetch( await apiFetch(
`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/members/${encodeURIComponent( `${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/members/${encodeURIComponent(
member, member,
)}/history?hours=24&limit=80`, )}/history?hours=24&limit=80`,
@@ -124,47 +156,51 @@ export async function getMemberWorkHistory(
); );
} }
export async function createPod(input: PodInput): Promise<Pod> { export async function createPod(input: PodInput, profile?: UserProfilePayload): Promise<Pod> {
return json( return json(
await fetch(`${BACKEND_URL}/api/pods`, { await apiFetch(`${BACKEND_URL}/api/pods`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: JSON_HEADERS,
body: JSON.stringify(input), body: JSON.stringify({ ...input, profile }),
}), }),
); );
} }
export async function updatePod(id: string, patch: PodInput): Promise<Pod> { export async function updatePod(id: string, patch: PodInput): Promise<Pod> {
return json( return json(
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, { await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'content-type': 'application/json' }, headers: JSON_HEADERS,
body: JSON.stringify(patch), body: JSON.stringify(patch),
}), }),
); );
} }
export async function deletePod(id: string): Promise<void> { export async function deletePod(id: string): Promise<void> {
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, { const res = await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!res.ok) throw new Error(`delete pod failed: ${res.status}`); if (!res.ok) throw new Error(`delete pod failed: ${res.status}`);
} }
export async function addMember(id: string, name: string): Promise<Pod> { export async function addMember(
id: string,
name: string,
profile?: UserProfilePayload,
): Promise<Pod> {
return json( return json(
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, { await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: JSON_HEADERS,
body: JSON.stringify({ name }), body: JSON.stringify({ name, profile }),
}), }),
); );
} }
export async function testPodVoice(id: string): Promise<void> { export async function testPodVoice(id: string): Promise<void> {
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/voice-test`, { const res = await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/voice-test`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: JSON_HEADERS,
body: JSON.stringify({ body: JSON.stringify({
message: 'PodMan voice test. Gemini TTS is playing through LiveKit.', message: 'PodMan voice test. Gemini TTS is playing through LiveKit.',
}), }),
@@ -177,16 +213,16 @@ export async function startLiveConversation(
input: { identity: string; displayName?: string }, input: { identity: string; displayName?: string },
): Promise<LiveConversationSession> { ): Promise<LiveConversationSession> {
return json( return json(
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/live-conversation/start`, { await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/live-conversation/start`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: JSON_HEADERS,
body: JSON.stringify(input), body: JSON.stringify(input),
}), }),
); );
} }
export async function stopLiveConversation(podId: string, sessionId: string): Promise<void> { export async function stopLiveConversation(podId: string, sessionId: string): Promise<void> {
const res = await fetch( const res = await apiFetch(
`${BACKEND_URL}/api/pods/${encodeURIComponent( `${BACKEND_URL}/api/pods/${encodeURIComponent(
podId, podId,
)}/live-conversation/${encodeURIComponent(sessionId)}/stop`, )}/live-conversation/${encodeURIComponent(sessionId)}/stop`,
@@ -200,7 +236,7 @@ export async function getLiveConversationHermesJob(
sessionId: string, sessionId: string,
): Promise<{ job: HermesJob | null; events: HermesJobEvent[] }> { ): Promise<{ job: HermesJob | null; events: HermesJobEvent[] }> {
return json( return json(
await fetch( await apiFetch(
`${BACKEND_URL}/api/pods/${encodeURIComponent( `${BACKEND_URL}/api/pods/${encodeURIComponent(
podId, podId,
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job`, )}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job`,
@@ -213,7 +249,7 @@ export async function abortLiveConversationHermesJob(
sessionId: string, sessionId: string,
): Promise<{ job: HermesJob | null }> { ): Promise<{ job: HermesJob | null }> {
return json( return json(
await fetch( await apiFetch(
`${BACKEND_URL}/api/pods/${encodeURIComponent( `${BACKEND_URL}/api/pods/${encodeURIComponent(
podId, podId,
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job/abort`, )}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job/abort`,
@@ -224,7 +260,7 @@ export async function abortLiveConversationHermesJob(
export async function removeMember(id: string, name: string): Promise<Pod> { export async function removeMember(id: string, name: string): Promise<Pod> {
return json( return json(
await fetch( await apiFetch(
`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members/${encodeURIComponent(name)}`, `${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members/${encodeURIComponent(name)}`,
{ method: 'DELETE' }, { method: 'DELETE' },
), ),
+16 -4
View File
@@ -11,11 +11,17 @@ export async function fetchPodToken(
podId: string, podId: string,
identity: string, identity: string,
name: string, name: string,
getToken?: () => Promise<string | null>,
profile?: { displayName?: string; email?: string; imageUrl?: string },
): Promise<{ token: string; url: string }> { ): Promise<{ token: string; url: string }> {
const clerkToken = await getToken?.();
const res = await fetch(`${BACKEND_URL}/api/token`, { const res = await fetch(`${BACKEND_URL}/api/token`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: {
body: JSON.stringify({ room: podId, identity, name }), 'content-type': 'application/json',
...(clerkToken ? { authorization: `Bearer ${clerkToken}` } : {}),
},
body: JSON.stringify({ room: podId, identity, name, profile }),
}); });
if (!res.ok) throw new Error(`token request failed: ${res.status}`); if (!res.ok) throw new Error(`token request failed: ${res.status}`);
return res.json(); return res.json();
@@ -33,8 +39,14 @@ export type JoinResult = { mode: 'live'; room: Room } | { mode: 'dev'; room: nul
* connected — screen sharing is a separate, deliberate action (see PodView) so * connected — screen sharing is a separate, deliberate action (see PodView) so
* a denied/slow screen prompt never blocks or fails the join. * a denied/slow screen prompt never blocks or fails the join.
*/ */
export async function joinPod(podId: string, identity: string, name: string): Promise<JoinResult> { export async function joinPod(
const { token, url } = await fetchPodToken(podId, identity, name); podId: string,
identity: string,
name: string,
getToken?: () => Promise<string | null>,
profile?: { displayName?: string; email?: string; imageUrl?: string },
): Promise<JoinResult> {
const { token, url } = await fetchPodToken(podId, identity, name, getToken, profile);
if (!isLiveKitConfigured(url)) { if (!isLiveKitConfigured(url)) {
console.warn('[podman] LiveKit not configured — dev mock join'); console.warn('[podman] LiveKit not configured — dev mock join');
+14 -3
View File
@@ -1,13 +1,24 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { ClerkProvider } from '@clerk/react';
import { shadcn } from '@clerk/ui/themes';
import App from './App.js'; import App from './App.js';
import './index.css'; import './index.css';
import '@clerk/ui/themes/shadcn.css';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
if (!clerkPublishableKey) {
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY');
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<TooltipProvider> <ClerkProvider publishableKey={clerkPublishableKey} appearance={{ theme: shadcn }}>
<App /> <TooltipProvider>
</TooltipProvider> <App />
</TooltipProvider>
</ClerkProvider>
</StrictMode>, </StrictMode>,
); );
+3245 -20
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,5 +1,6 @@
export type { Pod, PodInput, Engineer } from './pod.js'; export type { Pod, PodInput, Engineer } from './pod.js';
export type { EngineerContext } from './engineer.js'; export type { EngineerContext } from './engineer.js';
export type { UserLearningPodSummary, UserLearningProfile } from './user-learning.js';
export type { export type {
PodActivityEvent, PodActivityEvent,
PodActivityKind, PodActivityKind,
+9
View File
@@ -11,6 +11,15 @@ export interface Pod {
description?: string; description?: string;
/** Engineer display names PodMan uses when it speaks. */ /** Engineer display names PodMan uses when it speaks. */
members: string[]; members: string[];
/** Latest known Clerk/Gmail profile data per member display name. */
memberProfiles?: Record<
string,
{
displayName: string;
email?: string;
imageUrl?: string;
}
>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
+37
View File
@@ -0,0 +1,37 @@
export interface UserLearningPodSummary {
podId: string;
podName?: string;
visits: number;
actions: string[];
firstSeenAt: string;
lastSeenAt: string;
}
export interface UserLearningProfile {
clerkUserId: string;
displayName?: string;
email?: string;
imageUrl?: string;
identities: string[];
pods: UserLearningPodSummary[];
recentWork: Array<{
podId: string;
file?: string;
activity?: string;
at: string;
}>;
collaborationStyle: string[];
workingStyle: string[];
goals: string[];
knowledge: string[];
counts: {
podActions: number;
observations: number;
gitStates: number;
collisionsInvolved: number;
outcomes: number;
conversationNotes: number;
hermesJobs: number;
};
updatedAt: string;
}