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:
@@ -26,6 +26,7 @@ VOYAGE_EMBEDDING_MODEL=voyage-4-lite
|
||||
# --- Backend server ---
|
||||
PORT=8787
|
||||
POD_ROOM=demo-pod
|
||||
CLERK_SECRET_KEY=
|
||||
|
||||
# --- Nudge cooldown (ms) — set to 0 during demo if needed ---
|
||||
NUDGE_COOLDOWN_MS=180000
|
||||
@@ -34,6 +35,7 @@ RESEARCH_OVERLAP_THRESHOLD=0.6
|
||||
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
|
||||
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
VITE_BACKEND_URL=http://localhost:8787
|
||||
VITE_CLERK_PUBLISHABLE_KEY=
|
||||
# Keep off by default so users hear Gemini audio delivered through LiveKit.
|
||||
VITE_ENABLE_BROWSER_TTS_FALLBACK=false
|
||||
|
||||
|
||||
@@ -1,195 +1,263 @@
|
||||
# PodMan — A Pair Programmer for Engineering Teams
|
||||
# PodMan
|
||||
|
||||
[LiveKit](https://livekit.io/)
|
||||
[MongoDB](https://www.mongodb.com/)
|
||||
[Gemini](https://ai.google.dev/)
|
||||
[DigitalOcean](https://www.digitalocean.com/)
|
||||
**An ambient pair programmer for engineering teams.**
|
||||
|
||||
## 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.
|
||||
Writing the code is no longer the hard part — engineering ability is not the
|
||||
ceiling anymore.
|
||||
[LiveKit](https://livekit.io/) · [MongoDB](https://www.mongodb.com/) ·
|
||||
[Gemini](https://ai.google.dev/) · [Hermes](https://hermes-agent.nousresearch.com/) ·
|
||||
[vLLM](https://vllm.ai/) · [DigitalOcean](https://www.digitalocean.com/)
|
||||
|
||||
What slows teams down now is everything *around* the code: manually checking each
|
||||
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.**
|
||||
## Read This First
|
||||
|
||||
**PodMan is a pair programmer for the whole team.** It watches what every member
|
||||
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
|
||||
stop and ask.
|
||||
|
||||
### The five-minute meeting that isn't
|
||||
|
||||
<img width="800" height="705" alt="image" src="https://github.com/user-attachments/assets/4d26bd7a-177b-4650-8c99-bc2ef551d6bb" />
|
||||
|
||||
|
||||
> **What people assume:** "Quick question, five minutes."
|
||||
> **What actually happens:** the interrupted developer loses their place and needs
|
||||
> 15–25 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.
|
||||
| Need | Use this |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Open the product | `https://podman.live` |
|
||||
| Check the app | `curl https://podman.live/health` |
|
||||
| Check pods and memory | `curl https://podman.live/api/pods && curl https://podman.live/api/memory/stats` |
|
||||
| Use the LLM externally | Base URL `https://llm.alhinai.dev/v1`, model `gemma-4-31B-it` |
|
||||
| Test Hermes | `hermes -z 'Reply with exactly: working' --provider gemma4-31b-vllm --model gemma-4-31B-it` |
|
||||
| Start local development | API, vision agent, and frontend commands are in [Local Development](#local-development) |
|
||||
| Debug production | Public checks first, then systemd services in [Production Operations](#production-operations) |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
extra work from anyone — the only human signal is a single accept/dismiss tap on
|
||||
a card.
|
||||
subgraph Droplet["DigitalOcean PodMan Droplet"]
|
||||
Caddy["Caddy<br/>static app + /api proxy"]
|
||||
API["Express API<br/>127.0.0.1:8787"]
|
||||
Vision["Vision Agent<br/>screen frame observer"]
|
||||
Voice["Live Conversation Agent<br/>Python + Gemini Live"]
|
||||
Ops["Hermes Ops Timers<br/>watchdog + sync deploy"]
|
||||
end
|
||||
|
||||
```
|
||||
observe → detect → RECALL prior outcomes → policy gate → act → record outcome
|
||||
└──────────────────────────── feeds next recall ───────────────────────────┘
|
||||
subgraph Memory["MongoDB Atlas"]
|
||||
Observations["observations"]
|
||||
State["engineer_states"]
|
||||
Outcomes["interventions + outcomes"]
|
||||
end
|
||||
|
||||
subgraph Google["Google Gemini APIs"]
|
||||
GeminiVision["Vision"]
|
||||
GeminiVoice["Live voice + TTS"]
|
||||
GeminiEmbed["Embeddings fallback"]
|
||||
Lyria["Music"]
|
||||
end
|
||||
|
||||
subgraph Reasoning["External Reasoning Endpoint"]
|
||||
Tunnel["Cloudflare Tunnel<br/>llm.alhinai.dev"]
|
||||
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 |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| **Observe** | Gemini Vision turns each screen frame into structured work context (file, symbol, activity, unpushed hints) | `backend/src/vision/gemini.ts` |
|
||||
| **Detect** | Same file touched by 2+ engineers with unpushed work → a coordination event | `backend/src/collision/detector.ts` |
|
||||
| **Recall (memory)** | Embed the event, query MongoDB Atlas `$vectorSearch` for similar past events, attach their prior intervention + outcome | `backend/src/memory/vectors.ts` |
|
||||
| **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` |
|
||||
| **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` |
|
||||
| **Record (feedback)** | Accept/dismiss + "was it real?" is written back to memory, closing the loop for next time | `backend/src/memory/store.ts` |
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Engineer shares screen"] --> B["Gemini extracts work context"]
|
||||
B --> C["PodMan detects overlap<br/>files, symbols, research, unpushed work"]
|
||||
C --> D["MongoDB recalls<br/>similar prior events"]
|
||||
D --> E{"Policy gate"}
|
||||
E -->|"seen false alarm"| F["stay quiet"]
|
||||
E -->|"seen real collision"| G["raise urgency"]
|
||||
E -->|"new useful signal"| H["show card or message"]
|
||||
G --> I["Hermes / voice escalation"]
|
||||
H --> J["teammate accepts or dismisses"]
|
||||
I --> J
|
||||
J --> K["outcome becomes future memory"]
|
||||
K --> D
|
||||
```
|
||||
|
||||
PodMan removes the reason to interrupt. It gives the team a live picture of work
|
||||
in progress, then improves from every accepted or dismissed intervention.
|
||||
|
||||
A few things make this real learning rather than a static prompt:
|
||||
|
||||
- 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.
|
||||
> GitHub sees pushed work. PodMan sees work while it is still happening.
|
||||
|
||||
---
|
||||
|
||||
## The Model Stack
|
||||
|
||||
|
||||
## 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.
|
||||
PodMan uses multiple AI surfaces. They are intentionally split by job.
|
||||
|
||||
```mermaid
|
||||
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"]
|
||||
WebApi["Caddy + API service<br/>static app · /api · LiveKit tokens"]
|
||||
Agent["Vision agent worker<br/>Gemini reads each screen frame"]
|
||||
Coord["Hermes coordinator<br/>detects clashes · intervenes"]
|
||||
PyAgent["Live conversation agent<br/>(Python)"]
|
||||
end
|
||||
VisionRoute --> Gemini20["gemini-2.0-flash<br/>screen understanding"]
|
||||
ConversationRoute --> GeminiLive["gemini-3.1-flash-live-preview<br/>live Q&A"]
|
||||
ConversationRoute --> GeminiTTS["gemini-3.1-flash-tts-preview<br/>urgent speech"]
|
||||
HermesRoute --> Gemma["gemma-4-31B-it<br/>Hermes + vLLM + tools"]
|
||||
EmbedRoute --> Voyage["voyage-4-lite<br/>primary embeddings"]
|
||||
EmbedRoute --> GeminiEmbed["gemini-embedding-001<br/>fallback embeddings"]
|
||||
MusicRoute --> Lyria["lyria-3-clip-preview<br/>background music"]
|
||||
|
||||
LK["LiveKit room<br/>managed cloud"]
|
||||
Mongo[("MongoDB Atlas<br/>team memory")]
|
||||
|
||||
Dev -->|"open app · get token"| WebApi
|
||||
Dev <-->|"screens & voice out · cards & alerts in"| LK
|
||||
LK -->|"pre-commit work signal"| Agent --> Coord
|
||||
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
|
||||
classDef signal fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||
classDef route fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||
classDef model fill:#f4edff,stroke:#805ad5,color:#2d1857;
|
||||
class Work,VoiceInput,OpsNeed,MemoryNeed,Ambient signal;
|
||||
class VisionRoute,ConversationRoute,HermesRoute,EmbedRoute,MusicRoute route;
|
||||
class Gemini20,GeminiLive,GeminiTTS,Gemma,Voyage,GeminiEmbed,Lyria model;
|
||||
```
|
||||
|
||||
> Mongo connectivity is **required at boot** — the API and both agents ping Atlas
|
||||
> on startup and exit loudly rather than degrade silently.
|
||||
### Gemma 4 31B via vLLM
|
||||
|
||||
### 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
|
||||
mapped to the file that calls it.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
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
|
||||
```text
|
||||
Base URL: https://llm.alhinai.dev/v1
|
||||
API key: not-needed
|
||||
Model: gemma-4-31B-it
|
||||
Context: 262144 tokens
|
||||
```
|
||||
|
||||
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 |
|
||||
| ----------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Frontend PWA | React + Vite | Join pods, publish screen share, render interventions, play audio |
|
||||
| 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 |
|
||||
Why this matters: Hermes sends OpenAI tool schemas and `tool_choice: "auto"`.
|
||||
Without `--enable-auto-tool-choice` and `--tool-call-parser gemma4`, vLLM returns
|
||||
HTTP 400 before Hermes can initialize an agent.
|
||||
|
||||
### 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
|
||||
sequenceDiagram
|
||||
@@ -197,161 +265,106 @@ sequenceDiagram
|
||||
participant Dev as Engineer PWA
|
||||
participant API as Backend API
|
||||
participant LK as LiveKit Room
|
||||
participant Agent as PodMan Agent
|
||||
participant Gemini as Gemini Vision
|
||||
participant Mongo as MongoDB Memory
|
||||
participant Hermes as Hermes / Action Layer
|
||||
participant Agent as Vision Agent
|
||||
participant Gemini as Gemini APIs
|
||||
participant Mongo as MongoDB Atlas
|
||||
participant Hermes as Hermes/Gemma
|
||||
|
||||
Dev->>API: POST /api/token
|
||||
API-->>Dev: LiveKit URL + JWT
|
||||
Dev->>LK: Join pod room + publish screen share
|
||||
Agent->>LK: Subscribe to screen-share video
|
||||
Agent->>Gemini: Sampled JPEG frame
|
||||
Agent->>Gemini: Sampled frame
|
||||
Gemini-->>Agent: Structured work context
|
||||
Agent->>Mongo: Record observation
|
||||
Agent->>Mongo: Recall prior patterns (vector search)
|
||||
Mongo-->>Agent: Prior intervention + outcome
|
||||
Agent->>Hermes: Create intervention (gated by policy)
|
||||
Hermes->>LK: Publish card / message / Gemini TTS voice
|
||||
LK-->>Dev: Render intervention
|
||||
Dev->>API: POST /api/outcome (accept / dismiss)
|
||||
API->>Mongo: Store learning signal
|
||||
Agent->>Mongo: Store observation
|
||||
Agent->>Mongo: Recall similar prior events
|
||||
Mongo-->>Agent: Prior outcome + policy hints
|
||||
Agent->>Hermes: Escalate when autonomous help is useful
|
||||
Agent->>LK: Publish card / message / voice cue
|
||||
Dev->>API: POST /api/outcome
|
||||
API->>Mongo: Store accept/dismiss feedback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
## Built with
|
||||
|
||||
Everything below maps to code in this repo.
|
||||
|
||||
**Gemini** does the perception, the voice, and the memory:
|
||||
|
||||
|
||||
| Use | Model | Where |
|
||||
| --------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------ |
|
||||
| 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.
|
||||
| Folder | Purpose |
|
||||
| ----------- | ----------------------------------------------------------------------- |
|
||||
| `frontend/` | React + Vite PWA |
|
||||
| `backend/` | Express API, vision agent, memory, collision detection, Hermes job APIs |
|
||||
| `agents/` | Python LiveKit conversation agent |
|
||||
| `shared/` | Shared TypeScript contracts |
|
||||
| `database/` | MongoDB setup and seed utilities |
|
||||
| `infra/` | Caddy, Docker, DigitalOcean, systemd units |
|
||||
| `scripts/` | Git watcher, deploy doctor, watchdog, verification tooling |
|
||||
| `docs/` | Demo, deployment, learning, graph, and architecture notes |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
2. The API mints a LiveKit token via `POST /api/token`.
|
||||
3. The PWA publishes screen share into the room on "Share my screen".
|
||||
4. The PodMan vision agent subscribes to the screen-share tracks.
|
||||
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
|
||||
classDef step fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||
classDef run fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||
class Env,Install step;
|
||||
class API,Agent,UI,Voice,Browser run;
|
||||
```
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# fill in LIVEKIT_*, GEMINI_*, GITHUB_*, MONGODB_URI
|
||||
# Fill LIVEKIT_*, GEMINI_*, GITHUB_*, MONGODB_URI.
|
||||
|
||||
pnpm install
|
||||
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
|
||||
```
|
||||
|
||||
The live conversation agent (Gemini Live API) runs from `agents/podman-live-conversation/`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 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.
|
||||
Run the Python live conversation agent:
|
||||
|
||||
```bash
|
||||
# from the repo root
|
||||
node scripts/podman-agent.mjs --name <yourname> --pod <podId>
|
||||
pnpm livekit:conversation:agent
|
||||
```
|
||||
|
||||
**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
|
||||
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
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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,
|
||||
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.
|
||||
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
|
||||
@@ -130,6 +132,20 @@ class PodManLiveAgent(Agent):
|
||||
)
|
||||
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()
|
||||
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."""
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/express": "^2.1.32",
|
||||
"@google/genai": "^2.10.0",
|
||||
"@livekit/rtc-node": "^0.13.29",
|
||||
"@podman/shared": "workspace:*",
|
||||
|
||||
@@ -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
@@ -1,4 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config({ path: ['.env.local', '../.env.local', '.env', '../.env'] });
|
||||
|
||||
function req(name: string): string {
|
||||
const v = process.env[name];
|
||||
@@ -42,6 +44,7 @@ export const env = {
|
||||
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
||||
// Server
|
||||
PORT: Number(opt('PORT', '8787')),
|
||||
CLERK_SECRET_KEY: opt('CLERK_SECRET_KEY'),
|
||||
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
||||
RESEARCH_OVERLAP_THRESHOLD: Number(opt('RESEARCH_OVERLAP_THRESHOLD', '0.6')),
|
||||
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { getMemberWorkHistory } from '../activity/member-history.js';
|
||||
import {
|
||||
buildUserLearningProfile,
|
||||
getUserLearningProfileByIdentity,
|
||||
} from '../memory/user-learning.js';
|
||||
|
||||
const DEFAULT_LIMIT = 8;
|
||||
|
||||
@@ -10,7 +14,8 @@ function sinceIso(hours: number): string {
|
||||
export async function getLiveConversationContext(podId: string, identity: string) {
|
||||
const db = await getDb();
|
||||
const since = sinceIso(12);
|
||||
const [pod, history, gitState, collisions, interventions, outcomes] = await Promise.all([
|
||||
const [pod, history, gitState, collisions, interventions, outcomes, userLearningProfile] =
|
||||
await Promise.all([
|
||||
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
|
||||
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
|
||||
db.collection('engineer_states').findOne(
|
||||
@@ -44,6 +49,7 @@ export async function getLiveConversationContext(podId: string, identity: string
|
||||
.sort({ recordedAt: -1 })
|
||||
.limit(DEFAULT_LIMIT)
|
||||
.toArray(),
|
||||
getUserLearningProfileByIdentity(identity).catch(() => null),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -51,6 +57,7 @@ export async function getLiveConversationContext(podId: string, identity: string
|
||||
identity,
|
||||
generatedAt: new Date().toISOString(),
|
||||
currentGitState: gitState,
|
||||
userLearningProfile,
|
||||
memberHistory: history,
|
||||
recentCollisions: collisions,
|
||||
recentInterventions: interventions,
|
||||
@@ -76,5 +83,13 @@ export async function recordLiveConversationNote(input: {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -56,6 +56,17 @@ export interface PodCollections {
|
||||
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> {
|
||||
const db = await getDb();
|
||||
return {
|
||||
@@ -142,6 +153,26 @@ export async function initMemory(): Promise<void> {
|
||||
'hermes_job_events.job',
|
||||
() => 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) {
|
||||
try {
|
||||
|
||||
@@ -5,16 +5,19 @@ import type {
|
||||
InterventionOutcome,
|
||||
InterventionStatus,
|
||||
} from '@podman/shared';
|
||||
import { collections, getGitStates } from './db.js';
|
||||
import { collections, getDb, getGitStates, type UserPodContextDoc } from './db.js';
|
||||
import { enrichCollisionMemory } from './vectors.js';
|
||||
import { buildUserLearningProfile } from './user-learning.js';
|
||||
|
||||
function comparableFile(raw?: string): string {
|
||||
return (raw ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase() ?? '';
|
||||
return (
|
||||
(raw ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.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> {
|
||||
await persist('collision', async () =>
|
||||
(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. */
|
||||
export async function memoryStats(): Promise<Record<string, number>> {
|
||||
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.collisions.estimatedDocumentCount(),
|
||||
c.interventions.estimatedDocumentCount(),
|
||||
c.outcomes.estimatedDocumentCount(),
|
||||
c.suppressions.estimatedDocumentCount(),
|
||||
db.collection<UserPodContextDoc>('user_pod_context').estimatedDocumentCount(),
|
||||
db.collection('user_learning_profiles').estimatedDocumentCount(),
|
||||
]);
|
||||
return { observations, collisions, interventions, outcomes, suppressions };
|
||||
return {
|
||||
observations,
|
||||
collisions,
|
||||
interventions,
|
||||
outcomes,
|
||||
suppressions,
|
||||
userPodContext,
|
||||
userLearningProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { Collision, EngineerContext, HermesJob, Pod, UserLearningProfile } from '@podman/shared';
|
||||
import { getDb, type UserPodContextDoc } from './db.js';
|
||||
|
||||
interface EngineerStateDoc {
|
||||
podId: string;
|
||||
name: string;
|
||||
changedFiles?: string[];
|
||||
branch?: string | null;
|
||||
recentCommit?: string | null;
|
||||
gitUpdatedAt?: Date | string;
|
||||
}
|
||||
|
||||
interface ConversationNoteDoc {
|
||||
podId: string;
|
||||
identity?: string;
|
||||
kind?: string;
|
||||
note?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
function clean(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function toIso(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString();
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function uniq(values: Array<string | undefined>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const value of values) {
|
||||
const item = value?.trim();
|
||||
if (!item) continue;
|
||||
const key = item.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function top(values: string[], limit: number): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
const item = value.trim();
|
||||
if (!item) continue;
|
||||
counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, limit)
|
||||
.map(([value]) => value);
|
||||
}
|
||||
|
||||
function lowerRegex(values: string[]): RegExp[] {
|
||||
return values.map((value) => new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'));
|
||||
}
|
||||
|
||||
function noteGoals(notes: ConversationNoteDoc[]): string[] {
|
||||
const goalNotes = notes
|
||||
.filter((note) => /goal|plan|todo|next|preference|prefers|likes|wants|needs/i.test(`${note.kind} ${note.note}`))
|
||||
.map((note) => clean(note.note)?.slice(0, 180))
|
||||
.filter(Boolean) as string[];
|
||||
return uniq(goalNotes).slice(0, 8);
|
||||
}
|
||||
|
||||
function inferCollaborationStyle(input: {
|
||||
contexts: UserPodContextDoc[];
|
||||
observations: EngineerContext[];
|
||||
collisions: Collision[];
|
||||
outcomes: Array<{ accepted?: boolean; wasRealCollision?: boolean }>;
|
||||
notes: ConversationNoteDoc[];
|
||||
jobs: HermesJob[];
|
||||
}): string[] {
|
||||
const style: string[] = [];
|
||||
const actions = input.contexts.map((ctx) => ctx.action);
|
||||
const pods = new Set(input.contexts.map((ctx) => ctx.podId));
|
||||
if (pods.size > 1) style.push(`Works across ${pods.size} pods and carries context between rooms.`);
|
||||
if (actions.filter((action) => action === 'joined_pod').length >= 3)
|
||||
style.push('Frequently joins live rooms and collaborates synchronously.');
|
||||
if (input.collisions.length > 0)
|
||||
style.push(`Has been involved in ${input.collisions.length} coordination risk signals.`);
|
||||
const accepted = input.outcomes.filter((outcome) => outcome.accepted).length;
|
||||
if (accepted > 0) style.push(`Accepts Hermes help when useful (${accepted} accepted outcomes).`);
|
||||
if (input.jobs.length > 0) style.push(`Delegates complex work to Hermes (${input.jobs.length} jobs).`);
|
||||
if (input.notes.some((note) => /concise|short|brief/i.test(note.note ?? '')))
|
||||
style.push('Prefers concise coordination.');
|
||||
return style.slice(0, 8);
|
||||
}
|
||||
|
||||
function inferWorkingStyle(input: {
|
||||
observations: EngineerContext[];
|
||||
gitStates: EngineerStateDoc[];
|
||||
}): string[] {
|
||||
const style: string[] = [];
|
||||
const modes = top(input.observations.map((obs) => obs.mode ?? '').filter(Boolean), 2);
|
||||
const activities = top(input.observations.map((obs) => obs.activity ?? '').filter(Boolean), 5);
|
||||
const files = top(
|
||||
[
|
||||
...input.observations.map((obs) => obs.currentFile ?? ''),
|
||||
...input.gitStates.flatMap((state) => state.changedFiles ?? []),
|
||||
].filter(Boolean),
|
||||
6,
|
||||
);
|
||||
if (modes.length) style.push(`Usually seen in ${modes.join(' and ')} mode.`);
|
||||
if (activities.length) style.push(`Common work patterns: ${activities.join(', ')}.`);
|
||||
if (files.length) style.push(`Frequently touches ${files.join(', ')}.`);
|
||||
return style.slice(0, 8);
|
||||
}
|
||||
|
||||
function inferKnowledge(input: {
|
||||
observations: EngineerContext[];
|
||||
gitStates: EngineerStateDoc[];
|
||||
notes: ConversationNoteDoc[];
|
||||
}): string[] {
|
||||
const topics = top(
|
||||
[
|
||||
...input.observations.map((obs) => obs.researchTopic ?? ''),
|
||||
...input.observations.map((obs) => obs.researchSource ?? ''),
|
||||
...input.observations.map((obs) => obs.currentSymbol ?? ''),
|
||||
...input.gitStates.flatMap((state) => state.changedFiles ?? []),
|
||||
...input.notes
|
||||
.filter((note) => /learn|knows|expert|worked on|decision/i.test(note.note ?? ''))
|
||||
.map((note) => note.note?.slice(0, 120) ?? ''),
|
||||
].filter(Boolean),
|
||||
10,
|
||||
);
|
||||
return topics;
|
||||
}
|
||||
|
||||
export async function buildUserLearningProfile(clerkUserId: string): Promise<UserLearningProfile | null> {
|
||||
const db = await getDb();
|
||||
const contexts = await db
|
||||
.collection<UserPodContextDoc>('user_pod_context')
|
||||
.find({ clerkUserId }, { projection: { _id: 0 } })
|
||||
.sort({ observedAt: -1 })
|
||||
.limit(500)
|
||||
.toArray();
|
||||
if (!contexts.length) return null;
|
||||
|
||||
const latest = contexts[0];
|
||||
const identities = uniq([
|
||||
...contexts.map((ctx) => ctx.memberName),
|
||||
...contexts.map((ctx) => clean(ctx.metadata?.identity)),
|
||||
...contexts.map((ctx) => clean(ctx.metadata?.email)),
|
||||
]);
|
||||
const email = clean(latest?.metadata?.email) ?? identities.find((item) => item.includes('@'));
|
||||
const displayName = latest?.memberName ?? identities.find((item) => !item.includes('@')) ?? email;
|
||||
const imageUrl = clean(latest?.metadata?.imageUrl);
|
||||
const identityRegex = lowerRegex(identities);
|
||||
|
||||
const podIds = uniq(contexts.map((ctx) => ctx.podId));
|
||||
const [pods, observations, gitStates, collisions, outcomes, notes, jobs] = await Promise.all([
|
||||
db
|
||||
.collection<Pod>('pods')
|
||||
.find({ id: { $in: podIds } }, { projection: { _id: 0 } })
|
||||
.toArray(),
|
||||
db
|
||||
.collection<EngineerContext>('observations')
|
||||
.find({ engineerId: { $in: identities } }, { projection: { _id: 0, screenshotDataUrl: 0 } })
|
||||
.sort({ observedAt: -1 })
|
||||
.limit(500)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<EngineerStateDoc>('engineer_states')
|
||||
.find({ name: { $in: identityRegex } }, { projection: { _id: 0 } })
|
||||
.limit(100)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<Collision>('collisions')
|
||||
.find({ engineers: { $in: identities } }, { projection: { _id: 0, embedding: 0 } })
|
||||
.sort({ detectedAt: -1 })
|
||||
.limit(100)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<{ podId: string; accepted?: boolean; wasRealCollision?: boolean }>('outcomes')
|
||||
.find({ podId: { $in: podIds } }, { projection: { _id: 0 } })
|
||||
.sort({ recordedAt: -1 })
|
||||
.limit(100)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<ConversationNoteDoc>('conversation_notes')
|
||||
.find({ identity: { $in: identities } }, { projection: { _id: 0 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(100)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<HermesJob>('hermes_jobs')
|
||||
.find({ identity: { $in: identities } }, { projection: { _id: 0 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(100)
|
||||
.toArray(),
|
||||
]);
|
||||
|
||||
const podById = new Map(pods.map((pod) => [pod.id, pod]));
|
||||
const podsSummary = podIds.map((podId) => {
|
||||
const rows = contexts.filter((ctx) => ctx.podId === podId);
|
||||
const sorted = [...rows].sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt));
|
||||
return {
|
||||
podId,
|
||||
podName: podById.get(podId)?.name,
|
||||
visits: rows.filter((row) => row.action === 'joined_pod').length,
|
||||
actions: top(rows.map((row) => row.action), 6),
|
||||
firstSeenAt: toIso(sorted[0]?.observedAt),
|
||||
lastSeenAt: toIso(sorted.at(-1)?.observedAt),
|
||||
};
|
||||
});
|
||||
|
||||
const profile: UserLearningProfile = {
|
||||
clerkUserId,
|
||||
displayName,
|
||||
email,
|
||||
imageUrl,
|
||||
identities,
|
||||
pods: podsSummary,
|
||||
recentWork: observations.slice(0, 20).map((obs) => ({
|
||||
podId: obs.podId,
|
||||
file: obs.currentFile,
|
||||
activity: obs.activity ?? obs.researchTopic,
|
||||
at: obs.observedAt,
|
||||
})),
|
||||
collaborationStyle: inferCollaborationStyle({ contexts, observations, collisions, outcomes, notes, jobs }),
|
||||
workingStyle: inferWorkingStyle({ observations, gitStates }),
|
||||
goals: noteGoals(notes),
|
||||
knowledge: inferKnowledge({ observations, gitStates, notes }),
|
||||
counts: {
|
||||
podActions: contexts.length,
|
||||
observations: observations.length,
|
||||
gitStates: gitStates.length,
|
||||
collisionsInvolved: collisions.length,
|
||||
outcomes: outcomes.length,
|
||||
conversationNotes: notes.length,
|
||||
hermesJobs: jobs.length,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await db
|
||||
.collection<UserLearningProfile>('user_learning_profiles')
|
||||
.updateOne({ clerkUserId }, { $set: profile }, { upsert: true });
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function refreshUserLearningProfiles(): Promise<UserLearningProfile[]> {
|
||||
const db = await getDb();
|
||||
const ids = await db.collection<UserPodContextDoc>('user_pod_context').distinct('clerkUserId');
|
||||
const profiles = await Promise.all(ids.map((id) => buildUserLearningProfile(String(id))));
|
||||
return profiles.filter(Boolean) as UserLearningProfile[];
|
||||
}
|
||||
|
||||
export async function getUserLearningProfileByIdentity(identity: string): Promise<UserLearningProfile | null> {
|
||||
const db = await getDb();
|
||||
const direct = await db
|
||||
.collection<UserLearningProfile>('user_learning_profiles')
|
||||
.findOne({ identities: { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } }, { projection: { _id: 0 } });
|
||||
if (direct) return direct;
|
||||
|
||||
const context = await db
|
||||
.collection<UserPodContextDoc>('user_pod_context')
|
||||
.findOne(
|
||||
{
|
||||
$or: [
|
||||
{ memberName: { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } },
|
||||
{ 'metadata.identity': { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } },
|
||||
],
|
||||
},
|
||||
{ sort: { observedAt: -1 }, projection: { _id: 0 } },
|
||||
);
|
||||
return context ? buildUserLearningProfile(context.clerkUserId) : null;
|
||||
}
|
||||
|
||||
export async function listUserLearningProfiles(): Promise<UserLearningProfile[]> {
|
||||
const db = await getDb();
|
||||
return db
|
||||
.collection<UserLearningProfile>('user_learning_profiles')
|
||||
.find({}, { projection: { _id: 0 } })
|
||||
.sort({ updatedAt: -1 })
|
||||
.toArray();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
|
||||
@@ -59,14 +59,67 @@ function now(): string {
|
||||
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[]> {
|
||||
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> {
|
||||
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> {
|
||||
@@ -92,7 +145,7 @@ export async function createPod(input: PodInput): Promise<Pod> {
|
||||
};
|
||||
try {
|
||||
await c.pods.insertOne({ ...pod });
|
||||
return pod;
|
||||
return hydrateMemberProfiles(pod);
|
||||
} catch (err) {
|
||||
if (isDuplicateKey(err)) continue;
|
||||
throw err;
|
||||
@@ -119,7 +172,7 @@ export async function updatePod(id: string, patch: PodInput): Promise<Pod | null
|
||||
{ $set: set },
|
||||
{ returnDocument: 'after', projection: { _id: 0 } },
|
||||
);
|
||||
return updated ?? null;
|
||||
return updated ? hydrateMemberProfiles(updated) : null;
|
||||
}
|
||||
|
||||
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() } },
|
||||
{ returnDocument: 'after', projection: { _id: 0 } },
|
||||
);
|
||||
return updated ?? null;
|
||||
return updated ? hydrateMemberProfiles(updated) : null;
|
||||
}
|
||||
|
||||
export async function addMember(id: string, rawName: unknown): Promise<Pod | null> {
|
||||
|
||||
+91
-7
@@ -12,7 +12,9 @@ import {
|
||||
recordOutcome,
|
||||
hasRecentInterventionForCollision,
|
||||
memoryStats,
|
||||
recordUserPodContext,
|
||||
} from './memory/store.js';
|
||||
import { clerkAuthMiddleware, requestUser } from './auth.js';
|
||||
import { closeMemory, initMemory } from './memory/db.js';
|
||||
import {
|
||||
listPods,
|
||||
@@ -40,6 +42,10 @@ import {
|
||||
getLiveConversationContext,
|
||||
recordLiveConversationNote,
|
||||
} from './live-conversation/context.js';
|
||||
import {
|
||||
listUserLearningProfiles,
|
||||
refreshUserLearningProfiles,
|
||||
} from './memory/user-learning.js';
|
||||
import {
|
||||
abortHermesJob,
|
||||
appendHermesJobEvent,
|
||||
@@ -60,6 +66,7 @@ import type {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(clerkAuthMiddleware);
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
@@ -72,6 +79,10 @@ function suggestedAction(value: unknown): SuggestedActionKind {
|
||||
: 'ping_teammate';
|
||||
}
|
||||
|
||||
function stringMeta(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
||||
return value === 'accepted' ||
|
||||
value === 'heartbeat' ||
|
||||
@@ -90,11 +101,16 @@ function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
||||
app.post('/api/token', async (req, res) => {
|
||||
const { room, identity, name, githubLogin } = req.body ?? {};
|
||||
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, {
|
||||
identity,
|
||||
name,
|
||||
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 });
|
||||
const agents = env.LIVEKIT_AGENT_NAME
|
||||
@@ -113,6 +129,19 @@ app.post('/api/token', async (req, res) => {
|
||||
departureTimeout: 20,
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
app.get('/api/presence', async (_req, res) => {
|
||||
try {
|
||||
@@ -170,7 +215,24 @@ app.get('/api/pods', async (_req, res) => {
|
||||
|
||||
app.post('/api/pods', async (req, res) => {
|
||||
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) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
@@ -186,6 +248,15 @@ app.patch('/api/pods/:id', async (req, res) => {
|
||||
try {
|
||||
const pod = await updatePod(req.params.id, req.body ?? {});
|
||||
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);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
@@ -203,7 +274,21 @@ app.post('/api/pods/:id/members', async (req, res) => {
|
||||
try {
|
||||
const pod = await addMember(req.params.id, req.body?.name ?? '');
|
||||
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) {
|
||||
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 identity = typeof req.body?.identity === 'string' ? req.body.identity : undefined;
|
||||
const kind = typeof req.body?.kind === 'string' ? req.body.kind : undefined;
|
||||
res.status(201).json(
|
||||
await recordLiveConversationNote({
|
||||
const saved = await recordLiveConversationNote({
|
||||
podId: req.params.id,
|
||||
sessionId: req.params.sessionId,
|
||||
identity,
|
||||
kind,
|
||||
note,
|
||||
}),
|
||||
);
|
||||
});
|
||||
res.status(201).json(saved);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@clerk/react": "^6.11.1",
|
||||
"@clerk/ui": "^1.23.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@podman/shared": "workspace:*",
|
||||
"@shadcn/react": "^0.1.0",
|
||||
|
||||
+142
-21
@@ -1,4 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Show,
|
||||
SignUp,
|
||||
SignInButton,
|
||||
SignUpButton,
|
||||
UserButton,
|
||||
useAuth,
|
||||
useUser,
|
||||
} from '@clerk/react';
|
||||
import type { Room } from 'livekit-client';
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -54,7 +63,13 @@ function replacePath(path: string): void {
|
||||
window.history.replaceState({}, '', path || '/');
|
||||
}
|
||||
|
||||
function firstNameFrom(value: string | null | undefined): string {
|
||||
return value?.trim().split(/\s+/).filter(Boolean)[0] ?? '';
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { getToken, isLoaded, isSignedIn } = useAuth();
|
||||
const { user } = useUser();
|
||||
const [pods, setPods] = useState<Pod[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState<Set<string>>(new Set());
|
||||
@@ -68,6 +83,21 @@ export default function App() {
|
||||
const [graphPodId, setGraphPodId] = useState<string | 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() {
|
||||
setLoading(true);
|
||||
@@ -98,7 +128,13 @@ export default function App() {
|
||||
const previousPath = window.location.pathname;
|
||||
setPodPath(podId, replaceRoute);
|
||||
try {
|
||||
const result = await joinPod(podId, who, who);
|
||||
const result = await joinPod(
|
||||
podId,
|
||||
who,
|
||||
who,
|
||||
isSignedIn ? getToken : undefined,
|
||||
currentUserProfile,
|
||||
);
|
||||
setRoom(result.room);
|
||||
setDevMode(result.mode === 'dev');
|
||||
setMember(who);
|
||||
@@ -111,11 +147,15 @@ export default function App() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, []);
|
||||
}, [isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (joinedPodId) return;
|
||||
if (!isSignedIn || joinedPodId) return;
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
@@ -133,15 +173,13 @@ export default function App() {
|
||||
alive = false;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [joinedPodId]);
|
||||
}, [isSignedIn, joinedPodId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn) return;
|
||||
const routedPodId = pathPodId();
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) {
|
||||
if (routedPodId) {
|
||||
setError(`Enter your name to join ${routedPodId}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let saved: { podId: string; member: string };
|
||||
@@ -162,10 +200,11 @@ export default function App() {
|
||||
setRestoring(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}, [isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
if (!isSignedIn) return;
|
||||
const routedPodId = pathPodId();
|
||||
if (!routedPodId) {
|
||||
room?.disconnect();
|
||||
@@ -186,7 +225,7 @@ export default function App() {
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, [joinedPodId, room]);
|
||||
}, [isSignedIn, joinedPodId, room]);
|
||||
|
||||
async function run(key: string, fn: () => Promise<void>) {
|
||||
startPending(key);
|
||||
@@ -206,7 +245,7 @@ export default function App() {
|
||||
startPending('new');
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.createPod(input);
|
||||
const created = await api.createPod(input, currentUserProfile);
|
||||
setPods((cur) => [...cur, created]);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -225,7 +264,7 @@ export default function App() {
|
||||
if (pathPodId() === id) setHomePath();
|
||||
});
|
||||
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) =>
|
||||
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);
|
||||
setError(null);
|
||||
try {
|
||||
upsert(await api.addMember(pod.id, name));
|
||||
await connectToPod(pod.id, name);
|
||||
if (!defaultMemberName) {
|
||||
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) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
@@ -265,9 +307,28 @@ export default function App() {
|
||||
|
||||
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) {
|
||||
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>
|
||||
|
||||
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Show when="signed-out">
|
||||
<SignInButton mode="modal">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -342,6 +416,7 @@ export default function App() {
|
||||
pod={pod}
|
||||
busy={pending.has(pod.id)}
|
||||
presence={presence[pod.id] ?? []}
|
||||
currentUserProfile={currentUserProfile}
|
||||
onJoin={handleJoin}
|
||||
onAddAndJoin={handleAddAndJoin}
|
||||
onAddMember={handleAddMember}
|
||||
@@ -362,14 +437,23 @@ export default function App() {
|
||||
<EmptyDescription>Create the first room for this team.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} compact />
|
||||
<CreatePodForm
|
||||
busy={pending.has('new')}
|
||||
defaultMemberName={defaultMemberName}
|
||||
onCreate={handleCreate}
|
||||
compact
|
||||
/>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="flex flex-col gap-4">
|
||||
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} />
|
||||
<CreatePodForm
|
||||
busy={pending.has('new')}
|
||||
defaultMemberName={defaultMemberName}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</aside>
|
||||
</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() {
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -17,17 +17,19 @@ import { cn } from '@/lib/utils';
|
||||
export function CreatePodForm({
|
||||
busy,
|
||||
onCreate,
|
||||
defaultMemberName = '',
|
||||
compact = false,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onCreate: (input: PodInput) => Promise<void>;
|
||||
defaultMemberName?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(compact);
|
||||
const [name, setName] = useState('');
|
||||
const [repo, setRepo] = useState('karti-ai/podman');
|
||||
const [description, setDescription] = useState('');
|
||||
const [firstMember, setFirstMember] = useState('');
|
||||
const [firstMember, setFirstMember] = useState(defaultMemberName);
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return;
|
||||
@@ -40,7 +42,7 @@ export function CreatePodForm({
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setFirstMember('');
|
||||
setFirstMember(defaultMemberName);
|
||||
if (!compact) setOpen(false);
|
||||
} catch {
|
||||
/* parent owns the visible error */
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
VideoIcon,
|
||||
} from 'lucide-react';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -41,6 +47,7 @@ export function PodCard({
|
||||
pod,
|
||||
busy,
|
||||
presence,
|
||||
currentUserProfile,
|
||||
onJoin: _onJoin,
|
||||
onAddAndJoin,
|
||||
onAddMember: _onAddMember,
|
||||
@@ -52,15 +59,19 @@ export function PodCard({
|
||||
pod: Pod;
|
||||
busy: boolean;
|
||||
presence: string[];
|
||||
currentUserProfile?: {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
onJoin: (pod: Pod, member: string) => void;
|
||||
onAddAndJoin: (pod: Pod, name: string) => void;
|
||||
onAddAndJoin: (pod: Pod) => void;
|
||||
onAddMember: (id: string, name: string) => void;
|
||||
onRemoveMember: (id: string, name: string) => void;
|
||||
onUpdate: (id: string, patch: PodInput) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onOpenGraph: (id: string) => void;
|
||||
}) {
|
||||
const [newMember, setNewMember] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<PodInput>({
|
||||
name: pod.name,
|
||||
@@ -69,6 +80,13 @@ export function PodCard({
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
function saveEdit() {
|
||||
@@ -80,13 +98,8 @@ export function PodCard({
|
||||
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() {
|
||||
const name = newMember.trim();
|
||||
if (!name) return;
|
||||
onAddAndJoin(pod, name);
|
||||
setNewMember('');
|
||||
onAddAndJoin(pod);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -143,12 +156,16 @@ export function PodCard({
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<AvatarGroup>
|
||||
{pod.members.slice(0, 4).map((member) => (
|
||||
<Avatar key={member} title={member}>
|
||||
<AvatarFallback>{initials(member)}</AvatarFallback>
|
||||
{inRoom(member) && <AvatarBadge />}
|
||||
</Avatar>
|
||||
))}
|
||||
{pod.members.slice(0, 4).map((member) => {
|
||||
const profile = profileForMember(member);
|
||||
return (
|
||||
<Avatar key={member} title={profile?.email ?? member}>
|
||||
{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>}
|
||||
</AvatarGroup>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
@@ -157,16 +174,8 @@ export function PodCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
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()}>
|
||||
<div className="flex justify-end">
|
||||
<Button className="min-w-24" onClick={join} disabled={busy}>
|
||||
<VideoIcon data-icon="inline-start" />
|
||||
Join
|
||||
</Button>
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
|
||||
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -100,24 +100,46 @@ const STREAM_RAIL_WIDTH = '4rem';
|
||||
interface PInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
isLocal: 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[] {
|
||||
const lp = room.localParticipant;
|
||||
const localProfile = profileFromMetadata(lp.metadata);
|
||||
const local: PInfo = {
|
||||
id: lp.identity,
|
||||
name: lp.name || fallbackName,
|
||||
email: localProfile.email,
|
||||
imageUrl: localProfile.imageUrl,
|
||||
isLocal: true,
|
||||
speaking: lp.isSpeaking,
|
||||
};
|
||||
const remotes = Array.from(room.remoteParticipants.values()).map((p) => ({
|
||||
id: p.identity,
|
||||
name: p.name || p.identity,
|
||||
isLocal: false,
|
||||
speaking: p.isSpeaking,
|
||||
}));
|
||||
const remotes = Array.from(room.remoteParticipants.values()).map((p) => {
|
||||
const profile = profileFromMetadata(p.metadata);
|
||||
return {
|
||||
id: p.identity,
|
||||
name: p.name || p.identity,
|
||||
email: profile.email,
|
||||
imageUrl: profile.imageUrl,
|
||||
isLocal: false,
|
||||
speaking: p.isSpeaking,
|
||||
};
|
||||
});
|
||||
return [local, ...remotes];
|
||||
}
|
||||
|
||||
@@ -136,11 +158,17 @@ export function PodView({
|
||||
room,
|
||||
devMode,
|
||||
onLeave,
|
||||
currentUserProfile,
|
||||
}: {
|
||||
team: Pod;
|
||||
me: string;
|
||||
room: Room | null;
|
||||
devMode: boolean;
|
||||
currentUserProfile?: {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const [participants, setParticipants] = useState<PInfo[]>([]);
|
||||
@@ -241,6 +269,7 @@ export function PodView({
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, refresh)
|
||||
.on(RoomEvent.ParticipantDisconnected, refresh)
|
||||
.on(RoomEvent.ParticipantMetadataChanged, refresh)
|
||||
.on(RoomEvent.ActiveSpeakersChanged, refresh)
|
||||
.on(RoomEvent.TrackSubscribed, onAudio)
|
||||
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||
@@ -252,6 +281,7 @@ export function PodView({
|
||||
room
|
||||
.off(RoomEvent.ParticipantConnected, refresh)
|
||||
.off(RoomEvent.ParticipantDisconnected, refresh)
|
||||
.off(RoomEvent.ParticipantMetadataChanged, refresh)
|
||||
.off(RoomEvent.ActiveSpeakersChanged, refresh)
|
||||
.off(RoomEvent.TrackSubscribed, onAudio)
|
||||
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||
@@ -738,6 +768,8 @@ export function PodView({
|
||||
<Participant
|
||||
key={p.id}
|
||||
participant={p}
|
||||
rosterProfile={team.memberProfiles?.[p.name]}
|
||||
currentUserProfile={currentUserProfile}
|
||||
onOpenHistory={setHistoryMember}
|
||||
/>
|
||||
))}
|
||||
@@ -969,11 +1001,28 @@ export function PodView({
|
||||
|
||||
function Participant({
|
||||
participant,
|
||||
rosterProfile,
|
||||
currentUserProfile,
|
||||
onOpenHistory,
|
||||
}: {
|
||||
participant: PInfo;
|
||||
rosterProfile?: {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
currentUserProfile?: {
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -983,12 +1032,15 @@ function Participant({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar>
|
||||
{profile.imageUrl && <AvatarImage src={profile.imageUrl} alt={participant.name} />}
|
||||
<AvatarFallback>{initials(participant.name)}</AvatarFallback>
|
||||
{participant.speaking && <AvatarBadge />}
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<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 className="flex shrink-0 items-center gap-2">
|
||||
|
||||
+66
-30
@@ -14,11 +14,34 @@ const BACKEND_URL =
|
||||
? '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 {
|
||||
observations: number;
|
||||
collisions: number;
|
||||
interventions: number;
|
||||
outcomes: number;
|
||||
userPodContext?: number;
|
||||
}
|
||||
|
||||
export interface LiveConversationSession {
|
||||
@@ -34,6 +57,12 @@ export interface LiveConversationSession {
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
export interface UserProfilePayload {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
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>;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'content-type': 'application/json' } as const;
|
||||
|
||||
/** Mint a LiveKit token from the backend. */
|
||||
export async function fetchToken(params: {
|
||||
room: string;
|
||||
identity: string;
|
||||
name: string;
|
||||
githubLogin?: string;
|
||||
profile?: UserProfilePayload;
|
||||
}): Promise<{ token: string; url: string }> {
|
||||
const res = await fetch(`${BACKEND_URL}/api/token`, {
|
||||
const res = await apiFetch(`${BACKEND_URL}/api/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return json(res);
|
||||
@@ -59,9 +91,9 @@ export async function fetchToken(params: {
|
||||
|
||||
/** Record an intervention outcome for the policy learning loop. */
|
||||
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',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(outcome),
|
||||
});
|
||||
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
|
||||
@@ -73,9 +105,9 @@ export async function createSyncPr(input: {
|
||||
summary?: string;
|
||||
}): Promise<{ url: string; number: number }> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/sync-pr`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/sync-pr`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
@@ -84,21 +116,21 @@ export async function createSyncPr(input: {
|
||||
// --- Pods CRUD ---
|
||||
|
||||
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). */
|
||||
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> {
|
||||
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[]> {
|
||||
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,
|
||||
): Promise<MemberWorkHistory> {
|
||||
return json(
|
||||
await fetch(
|
||||
await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/members/${encodeURIComponent(
|
||||
member,
|
||||
)}/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(
|
||||
await fetch(`${BACKEND_URL}/api/pods`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/pods`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ ...input, profile }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePod(id: string, patch: PodInput): Promise<Pod> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
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(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, {
|
||||
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ name, profile }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({
|
||||
message: 'PodMan voice test. Gemini TTS is playing through LiveKit.',
|
||||
}),
|
||||
@@ -177,16 +213,16 @@ export async function startLiveConversation(
|
||||
input: { identity: string; displayName?: string },
|
||||
): Promise<LiveConversationSession> {
|
||||
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',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopLiveConversation(podId: string, sessionId: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
const res = await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/live-conversation/${encodeURIComponent(sessionId)}/stop`,
|
||||
@@ -200,7 +236,7 @@ export async function getLiveConversationHermesJob(
|
||||
sessionId: string,
|
||||
): Promise<{ job: HermesJob | null; events: HermesJobEvent[] }> {
|
||||
return json(
|
||||
await fetch(
|
||||
await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job`,
|
||||
@@ -213,7 +249,7 @@ export async function abortLiveConversationHermesJob(
|
||||
sessionId: string,
|
||||
): Promise<{ job: HermesJob | null }> {
|
||||
return json(
|
||||
await fetch(
|
||||
await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/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> {
|
||||
return json(
|
||||
await fetch(
|
||||
await apiFetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members/${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
|
||||
+16
-4
@@ -11,11 +11,17 @@ export async function fetchPodToken(
|
||||
podId: string,
|
||||
identity: string,
|
||||
name: string,
|
||||
getToken?: () => Promise<string | null>,
|
||||
profile?: { displayName?: string; email?: string; imageUrl?: string },
|
||||
): Promise<{ token: string; url: string }> {
|
||||
const clerkToken = await getToken?.();
|
||||
const res = await fetch(`${BACKEND_URL}/api/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ room: podId, identity, name }),
|
||||
headers: {
|
||||
'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}`);
|
||||
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
|
||||
* a denied/slow screen prompt never blocks or fails the join.
|
||||
*/
|
||||
export async function joinPod(podId: string, identity: string, name: string): Promise<JoinResult> {
|
||||
const { token, url } = await fetchPodToken(podId, identity, name);
|
||||
export async function joinPod(
|
||||
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)) {
|
||||
console.warn('[podman] LiveKit not configured — dev mock join');
|
||||
|
||||
+14
-3
@@ -1,13 +1,24 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ClerkProvider } from '@clerk/react';
|
||||
import { shadcn } from '@clerk/ui/themes';
|
||||
import App from './App.js';
|
||||
import './index.css';
|
||||
import '@clerk/ui/themes/shadcn.css';
|
||||
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(
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
<ClerkProvider publishableKey={clerkPublishableKey} appearance={{ theme: shadcn }}>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</ClerkProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
Generated
+3245
-20
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
export type { Pod, PodInput, Engineer } from './pod.js';
|
||||
export type { EngineerContext } from './engineer.js';
|
||||
export type { UserLearningPodSummary, UserLearningProfile } from './user-learning.js';
|
||||
export type {
|
||||
PodActivityEvent,
|
||||
PodActivityKind,
|
||||
|
||||
@@ -11,6 +11,15 @@ export interface Pod {
|
||||
description?: string;
|
||||
/** Engineer display names PodMan uses when it speaks. */
|
||||
members: string[];
|
||||
/** Latest known Clerk/Gmail profile data per member display name. */
|
||||
memberProfiles?: Record<
|
||||
string,
|
||||
{
|
||||
displayName: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user