Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82e5378658 | |||
| 26c851e2bb | |||
| 28f3236d76 | |||
| 70b41104ad | |||
| a97142cf09 | |||
| bc50406587 | |||
| 88abd8efb7 | |||
| 454fdab13b | |||
| 63a411490c | |||
| faa9769470 | |||
| 79620bccaa | |||
| d8eaf8d2e2 | |||
| e6e7a42d90 | |||
| 9658d700fa | |||
| 06098f3c4e | |||
| 65764f0a1c | |||
| b6447bb12c | |||
| f7521fb5df | |||
| 91403abe9f | |||
| f4ad3bec63 | |||
| 9fb252ec6a | |||
| 8d17663734 | |||
| 3cb66e3f27 | |||
| dec6557cad | |||
| 50cc4a2900 | |||
| d1570a44d6 | |||
| 9459203ffc |
@@ -26,13 +26,16 @@ VOYAGE_EMBEDDING_MODEL=voyage-4-lite
|
|||||||
# --- Backend server ---
|
# --- Backend server ---
|
||||||
PORT=8787
|
PORT=8787
|
||||||
POD_ROOM=demo-pod
|
POD_ROOM=demo-pod
|
||||||
|
CLERK_SECRET_KEY=
|
||||||
|
|
||||||
# --- Nudge cooldown (ms) — set to 0 during demo if needed ---
|
# --- Nudge cooldown (ms) — set to 0 during demo if needed ---
|
||||||
NUDGE_COOLDOWN_MS=180000
|
NUDGE_COOLDOWN_MS=180000
|
||||||
|
RESEARCH_OVERLAP_THRESHOLD=0.6
|
||||||
|
|
||||||
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
|
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
|
||||||
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
|
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||||
VITE_BACKEND_URL=http://localhost:8787
|
VITE_BACKEND_URL=http://localhost:8787
|
||||||
|
VITE_CLERK_PUBLISHABLE_KEY=
|
||||||
# Keep off by default so users hear Gemini audio delivered through LiveKit.
|
# Keep off by default so users hear Gemini audio delivered through LiveKit.
|
||||||
VITE_ENABLE_BROWSER_TTS_FALLBACK=false
|
VITE_ENABLE_BROWSER_TTS_FALLBACK=false
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ __pycache__/
|
|||||||
# Ramis
|
# Ramis
|
||||||
.remember/
|
.remember/
|
||||||
.claude/
|
.claude/
|
||||||
|
.hermes/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -365,6 +365,20 @@ This repo is actively used by **4 engineers at the same time**. Claude sessions
|
|||||||
- **Prefer additive changes** — new files, new functions — over modifying existing ones. This minimizes merge conflicts in a concurrent team.
|
- **Prefer additive changes** — new files, new functions — over modifying existing ones. This minimizes merge conflicts in a concurrent team.
|
||||||
- **When proposing new files**, verify they match the file names and paths described in the relevant spec in `docs/`. Do not invent new paths.
|
- **When proposing new files**, verify they match the file names and paths described in the relevant spec in `docs/`. Do not invent new paths.
|
||||||
|
|
||||||
|
### Git workflow — push directly to `main`, stay in sync
|
||||||
|
|
||||||
|
This repo **does not use feature branches**. Commit straight to `main` and push.
|
||||||
|
There is no branch-first step. Because several people push concurrently:
|
||||||
|
|
||||||
|
- **Always sync before pushing:** `git pull --rebase origin main` immediately
|
||||||
|
before `git push`. Never force-push `main`.
|
||||||
|
- **Keep commits small and additive** so rebases stay clean — prefer new files
|
||||||
|
and new functions over editing shared hot files (`backend/src/agent/podman.ts`,
|
||||||
|
`backend/src/server.ts`, `frontend/src/App.tsx`).
|
||||||
|
- **If a rebase conflicts**, resolve it locally and re-run the pull-rebase before
|
||||||
|
pushing; do not overwrite a teammate's commit.
|
||||||
|
- Commit/push only when the user asks (overrides any default branch-first habit).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production deployment & ops — READ BEFORE TOUCHING THE SERVER
|
## Production deployment & ops — READ BEFORE TOUCHING THE SERVER
|
||||||
|
|||||||
@@ -1,172 +1,384 @@
|
|||||||
# PodMan — A Pair Programmer for Engineering Teams
|
# PodMan
|
||||||
|
|
||||||
[LiveKit](https://livekit.io/)
|
**An ambient pair programmer for engineering teams.**
|
||||||
[MongoDB](https://www.mongodb.com/)
|
|
||||||
[Gemini](https://ai.google.dev/)
|
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.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://podman.live/">
|
||||||
|
<img
|
||||||
|
alt="Try PodMan live in production"
|
||||||
|
src="https://img.shields.io/badge/TRY%20PODMAN%20LIVE-https%3A%2F%2Fpodman.live-1f6feb?style=for-the-badge"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 align="center">
|
||||||
|
<a href="https://podman.live/">Open the live production app: https://podman.live/</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
PodMan is already deployed. Click the link above and try the production app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 align="center">
|
||||||
|
<a href="https://youtu.be/bWJIsIWTgr0">Watch the demo video</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://youtu.be/bWJIsIWTgr0">
|
||||||
|
<img
|
||||||
|
src="https://img.youtube.com/vi/bWJIsIWTgr0/maxresdefault.jpg"
|
||||||
|
alt="PodMan demo video thumbnail"
|
||||||
|
width="760"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[LiveKit](https://livekit.io/) · [MongoDB](https://www.mongodb.com/) ·
|
||||||
|
[Gemini](https://ai.google.dev/) · [Hermes](https://hermes-agent.nousresearch.com/) ·
|
||||||
|
[Modular MAX](https://www.modular.com/max) ·
|
||||||
[DigitalOcean](https://www.digitalocean.com/)
|
[DigitalOcean](https://www.digitalocean.com/)
|
||||||
|
|
||||||
**2026 AI Engineer World's Fair Hackathon — Theme: Continual Learning**
|
<p align="center">
|
||||||
|
<img
|
||||||
|
src="docs/assets/interruption-flow-recovery.jpg"
|
||||||
|
alt="A chart showing that a five minute interruption can create a much longer recovery period before a developer returns to flow state."
|
||||||
|
width="760"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
## The bottleneck moved
|
> PodMan exists to prevent the five-minute interruption from becoming a
|
||||||
|
> half-hour recovery tax.
|
||||||
|
|
||||||
Models keep getting better, and more people can build software than ever before.
|
Source image: `~/pic.jpg`, committed as
|
||||||
Writing the code is no longer the hard part — engineering ability is not the
|
`docs/assets/interruption-flow-recovery.jpg` so it renders on GitHub.
|
||||||
ceiling anymore.
|
|
||||||
|
|
||||||
What slows teams down now is everything *around* the code: manually checking each
|
## Read This First
|
||||||
other's work, re-planning when two people drift into the same change, and
|
|
||||||
constantly asking "what are you working on?" just to stay in sync. People
|
|
||||||
naturally want to build together — so as more people start coding, there will be
|
|
||||||
thousands of teams bottlenecked not by skill, but by the **speed of human
|
|
||||||
coordination.**
|
|
||||||
|
|
||||||
**PodMan is a pair programmer for the whole team.** It watches what every member
|
| Need | Use this |
|
||||||
is doing in real time, learns your team's decisions and working dynamics, and
|
| ----------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
gives everyone a live picture of where the others are — without anyone having to
|
| Open the product | `https://podman.live` |
|
||||||
stop and ask.
|
| Check the app | `curl https://podman.live/health` |
|
||||||
|
| Check pods and memory | `curl https://podman.live/api/pods && curl https://podman.live/api/memory/stats` |
|
||||||
|
| Use the LLM externally | Base URL `https://llm.alhinai.dev/v1`, model `gemma-4-31B-it` |
|
||||||
|
| Test Hermes | `hermes -z 'Reply with exactly: working' --provider gemma4-31b-max --model gemma-4-31B-it` |
|
||||||
|
| Start local development | API, vision agent, and frontend commands are in [Local Development](#local-development) |
|
||||||
|
| Debug production | Public checks first, then systemd services in [Production Operations](#production-operations) |
|
||||||
|
|
||||||
### The five-minute meeting that isn't
|
## Hackathon Fit
|
||||||
|
|
||||||
> **What people assume:** "Quick question, five minutes."
|
PodMan is built for the **2026 AI Engineer World's Fair Hackathon** theme:
|
||||||
> **What actually happens:** the interrupted developer loses their place and needs
|
**Continual Learning**.
|
||||||
> 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
|
It is not a wrapper chatbot or static dashboard. It is a production-running
|
||||||
engineering skill — becomes the real ceiling on how fast a team ships.
|
agentic coordination system that gets better from real use: every observation,
|
||||||
|
collision, intervention, accept, dismiss, suppression, and conversation note
|
||||||
|
feeds MongoDB-backed memory so future interventions become sharper and less
|
||||||
|
annoying.
|
||||||
|
|
||||||
PodMan removes the reason to interrupt. Because it already knows who is touching
|
| Hackathon target | How PodMan addresses it |
|
||||||
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,
|
| Continual Learning | Learns from real team behavior and accepted/dismissed interventions. |
|
||||||
no recovery tax. And it gets better as it goes: every accept or dismiss teaches
|
| Self-Improvement Stack | Uses memory stats, Hermes jobs, production watchdogs, and deployment checks. |
|
||||||
it what your team actually cares about, so it nudges less and helps more over
|
| Recursive Intelligence | Gives agents operational memory and feedback loops for improving future coordination. |
|
||||||
time.
|
| Live Demo | Already deployed at [`https://podman.live/`](https://podman.live/). |
|
||||||
|
| Technicality | Combines realtime media, vision, vector recall, graph traversal, memory, and ops agents. |
|
||||||
|
| Creativity | Solves the coordination cost around AI-assisted engineering teams. |
|
||||||
|
|
||||||
> GitHub sees pushed work. PodMan sees work while it is still happening — and
|
### Sponsor Tech Used
|
||||||
> remembers what helped.
|
|
||||||
|
|
||||||
---
|
| Sponsor / tech | Usage in PodMan |
|
||||||
|
| -------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| DigitalOcean | Hosts the production app, API, workers, Caddy, and systemd-supervised services. |
|
||||||
|
| LiveKit | Realtime room layer for screen share, audio, data messages, and agent presence. |
|
||||||
|
| Gemini | Screen understanding, live room conversation, urgent TTS, embeddings, and music. |
|
||||||
|
| MongoDB Atlas | Durable memory, `$vectorSearch`, `$graphLookup`, user learning, and job logs. |
|
||||||
|
| Modular MAX | Serves `gemma-4-31B-it` as the long-context reasoning model. |
|
||||||
|
|
||||||
|
### What To Try
|
||||||
|
|
||||||
## How it learns
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```
|
|
||||||
observe → detect → RECALL prior outcomes → policy gate → act → record outcome
|
|
||||||
└──────────────────────────── feeds next recall ───────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph Laptop["Engineer laptop"]
|
A["Open podman.live"] --> B["Join a pod"]
|
||||||
PWA["React PWA"]
|
B --> C["Share screen via LiveKit"]
|
||||||
Screen["Screen share track"]
|
C --> D["Gemini extracts work context"]
|
||||||
Git["Git watcher<br/>scripts/podman-agent.mjs"]
|
D --> E["MongoDB recalls prior outcomes"]
|
||||||
end
|
E --> F["PodMan nudges only when useful"]
|
||||||
|
F --> G["Accept or dismiss"]
|
||||||
|
G --> H["Memory improves next run"]
|
||||||
|
|
||||||
subgraph Realtime["LiveKit room"]
|
classDef action fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
Room["Pod room"]
|
classDef ai fill:#f4edff,stroke:#805ad5,color:#2d1857;
|
||||||
Data["Data topic<br/>podman.intervention"]
|
classDef memory fill:#fff6df,stroke:#c47f00,color:#3d2b00;
|
||||||
Audio["Audio tracks<br/>Gemini TTS + Lyria"]
|
class A,B,C,F,G action;
|
||||||
end
|
class D ai;
|
||||||
|
class E,H memory;
|
||||||
subgraph Backend["PodMan backend"]
|
|
||||||
API["API service<br/>/api/token /api/pods /api/outcome"]
|
|
||||||
Agent["Vision agent worker<br/>@livekit/rtc-node"]
|
|
||||||
Convo["Live conversation agent<br/>Gemini Live API (Python)"]
|
|
||||||
Vision["Gemini Vision<br/>structured JSON"]
|
|
||||||
Detector["Coordination detector<br/>collisions, blockers"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Memory["Memory and actions"]
|
|
||||||
Mongo["MongoDB Atlas<br/>observations, outcomes, vectors"]
|
|
||||||
GitHub["GitHub<br/>repo state + sync PR artifact"]
|
|
||||||
Hermes["Hermes action layer<br/>cards, messages, urgent voice"]
|
|
||||||
end
|
|
||||||
|
|
||||||
PWA -->|"POST /api/token"| API
|
|
||||||
API -->|"LiveKit JWT"| PWA
|
|
||||||
PWA --> Screen
|
|
||||||
Screen --> Room
|
|
||||||
Room --> Agent
|
|
||||||
Agent --> Vision
|
|
||||||
Vision --> Detector
|
|
||||||
Git --> Mongo
|
|
||||||
Detector --> Mongo
|
|
||||||
Detector --> GitHub
|
|
||||||
Detector --> Hermes
|
|
||||||
Hermes --> Data
|
|
||||||
Hermes --> Audio
|
|
||||||
Convo --> Audio
|
|
||||||
Convo --> Mongo
|
|
||||||
Data --> PWA
|
|
||||||
Audio --> PWA
|
|
||||||
PWA -->|"POST /api/outcome"| API
|
|
||||||
API --> Mongo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
1. Open [`https://podman.live/`](https://podman.live/).
|
||||||
|
2. Create or join a pod.
|
||||||
|
3. Add teammates and share screens.
|
||||||
|
4. Watch PodMan build live context, detect overlap, and surface intervention
|
||||||
|
cards.
|
||||||
|
5. Accept or dismiss a card; that feedback becomes memory for the next similar
|
||||||
|
event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Live System
|
||||||
|
|
||||||
|
| Surface | Running now | Purpose |
|
||||||
|
| ------------- | ---------------------------- | -------------------------------------- |
|
||||||
|
| Product | `https://podman.live` | Team room, screen context, cards |
|
||||||
|
| API | `https://podman.live/api/*` | Pods, tokens, outcomes, memory |
|
||||||
|
| Health | `https://podman.live/health` | Backend readiness |
|
||||||
|
| Local API | `127.0.0.1:8787` | Express service behind Caddy |
|
||||||
|
| Reasoning LLM | `https://llm.alhinai.dev/v1` | OpenAI-compatible Modular/MAX endpoint |
|
||||||
|
| API key | `not-needed` | Placeholder key for OpenAI clients |
|
||||||
|
| Hermes model | `gemma-4-31B-it` | 262K-context tool-using agent |
|
||||||
|
| Hermes config | `gemma4-31b-max` | Custom provider used by Hermes locally |
|
||||||
|
|
||||||
### Runtime shape
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Browser["Engineer Browser<br/>React + Vite PWA"]
|
||||||
|
Room["LiveKit Room<br/>screen share + audio + data messages"]
|
||||||
|
|
||||||
|
subgraph Droplet["DigitalOcean PodMan Droplet"]
|
||||||
|
Caddy["Caddy<br/>static app + /api proxy"]
|
||||||
|
API["Express API<br/>127.0.0.1:8787"]
|
||||||
|
Vision["Vision Agent<br/>screen frame observer"]
|
||||||
|
Voice["Live Conversation Agent<br/>Python + Gemini Live"]
|
||||||
|
Ops["Hermes Ops Timers<br/>watchdog + sync deploy"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Memory["MongoDB Atlas"]
|
||||||
|
Observations["observations"]
|
||||||
|
State["engineer_states"]
|
||||||
|
Outcomes["interventions + outcomes"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Google["Google Gemini APIs"]
|
||||||
|
GeminiVision["Vision"]
|
||||||
|
GeminiVoice["Live voice + TTS"]
|
||||||
|
GeminiEmbed["Embeddings fallback"]
|
||||||
|
Lyria["Music"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Reasoning["External Reasoning Endpoint"]
|
||||||
|
Tunnel["Cloudflare Tunnel<br/>llm.alhinai.dev"]
|
||||||
|
MaxServe["MAX OpenAI Server<br/>gemma-4-31B-it<br/>262144 context"]
|
||||||
|
Hermes["Hermes Agent<br/>provider: gemma4-31b-max"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Browser --> Caddy --> API
|
||||||
|
Browser <-->|screen, audio, cards| Room
|
||||||
|
API --> Room
|
||||||
|
API --> Observations
|
||||||
|
API --> State
|
||||||
|
API --> Outcomes
|
||||||
|
Vision <-->|screen tracks| Room
|
||||||
|
Vision --> GeminiVision
|
||||||
|
Vision --> Observations
|
||||||
|
Vision --> Outcomes
|
||||||
|
Voice <-->|conversation| Room
|
||||||
|
Voice --> GeminiVoice
|
||||||
|
Vision --> GeminiEmbed
|
||||||
|
Voice --> GeminiEmbed
|
||||||
|
Ops --> Hermes --> Tunnel --> MaxServe
|
||||||
|
Tunnel -. "public route<br/>llm.alhinai.dev/v1" .-> MaxServe
|
||||||
|
API --> Hermes
|
||||||
|
Lyria --> Voice
|
||||||
|
|
||||||
|
classDef user fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
|
classDef app fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||||
|
classDef data fill:#fff6df,stroke:#c47f00,color:#3d2b00;
|
||||||
|
classDef ai fill:#f4edff,stroke:#805ad5,color:#2d1857;
|
||||||
|
class Browser,Room user;
|
||||||
|
class Caddy,API,Vision,Voice,Ops app;
|
||||||
|
class Observations,State,Outcomes data;
|
||||||
|
class GeminiVision,GeminiVoice,GeminiEmbed,Lyria,Tunnel,MaxServe,Hermes ai;
|
||||||
|
```
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Engineer shares screen"] --> B["Gemini extracts work context"]
|
||||||
|
B --> C["PodMan detects overlap<br/>files, symbols, research, unpushed work"]
|
||||||
|
C --> D["MongoDB recalls<br/>similar prior events"]
|
||||||
|
D --> E{"Policy gate"}
|
||||||
|
E -->|"seen false alarm"| F["stay quiet"]
|
||||||
|
E -->|"seen real collision"| G["raise urgency"]
|
||||||
|
E -->|"new useful signal"| H["show card or message"]
|
||||||
|
G --> I["Hermes / voice escalation"]
|
||||||
|
H --> J["teammate accepts or dismisses"]
|
||||||
|
I --> J
|
||||||
|
J --> K["outcome becomes future memory"]
|
||||||
|
K --> D
|
||||||
|
```
|
||||||
|
|
||||||
|
PodMan removes the reason to interrupt. It gives the team a live picture of work
|
||||||
|
in progress, then improves from every accepted or dismissed intervention.
|
||||||
|
|
||||||
|
> GitHub sees pushed work. PodMan sees work while it is still happening.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Model Stack
|
||||||
|
|
||||||
|
PodMan uses multiple AI surfaces. They are intentionally split by job.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Work["Screen + room activity"] --> VisionRoute["Perception route"]
|
||||||
|
VoiceInput["Engineer speech"] --> ConversationRoute["Conversation route"]
|
||||||
|
OpsNeed["Ops / autonomous task"] --> HermesRoute["Reasoning route"]
|
||||||
|
MemoryNeed["Similarity search"] --> EmbedRoute["Memory route"]
|
||||||
|
Ambient["Session atmosphere"] --> MusicRoute["Audio route"]
|
||||||
|
|
||||||
|
VisionRoute --> Gemini20["gemini-2.0-flash<br/>screen understanding"]
|
||||||
|
ConversationRoute --> GeminiLive["gemini-3.1-flash-live-preview<br/>live Q&A"]
|
||||||
|
ConversationRoute --> GeminiTTS["gemini-3.1-flash-tts-preview<br/>urgent speech"]
|
||||||
|
HermesRoute --> Gemma["gemma-4-31B-it<br/>served by Modular MAX"]
|
||||||
|
EmbedRoute --> Voyage["voyage-4-lite<br/>primary embeddings"]
|
||||||
|
EmbedRoute --> GeminiEmbed["gemini-embedding-001<br/>fallback embeddings"]
|
||||||
|
MusicRoute --> Lyria["lyria-3-clip-preview<br/>background music"]
|
||||||
|
|
||||||
|
classDef signal fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
|
classDef route fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||||
|
classDef model fill:#f4edff,stroke:#805ad5,color:#2d1857;
|
||||||
|
class Work,VoiceInput,OpsNeed,MemoryNeed,Ambient signal;
|
||||||
|
class VisionRoute,ConversationRoute,HermesRoute,EmbedRoute,MusicRoute route;
|
||||||
|
class Gemini20,GeminiLive,GeminiTTS,Gemma,Voyage,GeminiEmbed,Lyria model;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemma 4 31B via Modular MAX
|
||||||
|
|
||||||
|
Hermes uses the external OpenAI-compatible endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Base URL: https://llm.alhinai.dev/v1
|
||||||
|
API key: not-needed
|
||||||
|
Model: gemma-4-31B-it
|
||||||
|
Context: 262144 tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
The Modular/MAX server is configured for long-context Gemma 4 serving:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--max-length 262144
|
||||||
|
--device-memory-utilization 0.85
|
||||||
|
--kv-cache-format float8_e4m3fn
|
||||||
|
--enable-prefix-caching
|
||||||
|
--enable-chunked-prefill
|
||||||
|
--max-batch-size 1
|
||||||
|
--max-batch-input-tokens 16384
|
||||||
|
```
|
||||||
|
|
||||||
|
Hermes should point at that endpoint with this provider shape:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model:
|
||||||
|
default: gemma-4-31B-it
|
||||||
|
provider: gemma4-31b-max
|
||||||
|
|
||||||
|
providers:
|
||||||
|
gemma4-31b-max:
|
||||||
|
name: Gemma 4 31B Modular MAX (256K)
|
||||||
|
api: https://llm.alhinai.dev/v1
|
||||||
|
api_key: not-needed
|
||||||
|
transport: chat_completions
|
||||||
|
default_model: gemma-4-31B-it
|
||||||
|
discover_models: true
|
||||||
|
models:
|
||||||
|
gemma-4-31B-it:
|
||||||
|
context_length: 262144
|
||||||
|
|
||||||
|
agent:
|
||||||
|
tool_use_enforcement: auto
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this matters: Hermes sends OpenAI tool schemas and `tool_choice: "auto"`.
|
||||||
|
The model endpoint must support automatic tool choice so Hermes can initialize
|
||||||
|
the agent without a client-side workaround.
|
||||||
|
|
||||||
|
### Modular MAX
|
||||||
|
|
||||||
|
Gemma 4 is served through Modular MAX on the GB10 machine.
|
||||||
|
|
||||||
|
| MAX support | Value |
|
||||||
|
| ------------ | ---------------------------------------- |
|
||||||
|
| Architecture | `Gemma4ForConditionalGeneration` |
|
||||||
|
| Example | `google/gemma-4-31B-it` |
|
||||||
|
| Encodings | `float4_e2m1fnx2`, `float16`, `bfloat16` |
|
||||||
|
|
||||||
|
| Runtime | Role |
|
||||||
|
| ----------------- | ----------------------------------------- |
|
||||||
|
| Modular MAX | OpenAI-compatible Gemma 4 serving runtime |
|
||||||
|
| `gemma-4-31B-it` | Primary long-context reasoning model |
|
||||||
|
| `262144` tokens | Reported model context window |
|
||||||
|
| `llm.alhinai.dev` | Public Cloudflare-routed model endpoint |
|
||||||
|
|
||||||
|
### Gemini Surfaces
|
||||||
|
|
||||||
|
Gemini remains the realtime perception and voice layer inside PodMan.
|
||||||
|
|
||||||
|
| Use | Model | Code |
|
||||||
|
| -------------------------- | ------------------------------- | ------------------------------------------ |
|
||||||
|
| Screen understanding | `gemini-2.0-flash` | `backend/src/vision/gemini.ts` |
|
||||||
|
| Urgent spoken alerts | `gemini-3.1-flash-tts-preview` | `backend/src/voice/live.ts` |
|
||||||
|
| Live room conversation | `gemini-3.1-flash-live-preview` | `agents/podman-live-conversation/agent.py` |
|
||||||
|
| Memory embeddings fallback | `gemini-embedding-001` | `backend/src/memory/vectors.ts` |
|
||||||
|
| Ambient background music | `lyria-3-clip-preview` | `backend/src/voice/music.ts` |
|
||||||
|
|
||||||
|
Voyage embeddings can be used first when `VOYAGE_API_KEY` is set. Gemini
|
||||||
|
embeddings remain the fallback. If no embedding provider is available, PodMan
|
||||||
|
falls back to exact-signature matching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How The Learning Loop Works
|
||||||
|
|
||||||
|
The learning loop is the product. A teammate only has to accept or dismiss an
|
||||||
|
intervention; the rest is captured automatically.
|
||||||
|
|
||||||
|
```text
|
||||||
|
observe -> detect -> recall prior outcomes -> policy gate -> act -> record outcome
|
||||||
|
^ |
|
||||||
|
+-------------------------- next recall -----------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stage | What happens | Code |
|
||||||
|
| ------- | --------------------------------------------------------------------------- | ----------------------------------- |
|
||||||
|
| Observe | Gemini Vision turns sampled screen frames into structured work context. | `backend/src/vision/gemini.ts` |
|
||||||
|
| Detect | PodMan detects overlapping files, symbols, research, and unpushed work. | `backend/src/collision/detector.ts` |
|
||||||
|
| Recall | MongoDB Atlas recalls similar prior events and outcomes. | `backend/src/memory/vectors.ts` |
|
||||||
|
| Gate | Policy suppresses dismissed false alarms and escalates recurring real ones. | `backend/src/memory/policy.ts` |
|
||||||
|
| Act | PodMan publishes a card, Hermes message, or urgent voice cue. | `backend/src/action/hermes.ts` |
|
||||||
|
| Learn | Accept/dismiss feedback is written back to memory. | `backend/src/memory/store.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime Components
|
||||||
|
|
||||||
| Layer | Runtime | Responsibility |
|
| Layer | Runtime | Responsibility |
|
||||||
| ----------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------- |
|
| ------------------ | ----------------------------------- | -------------------------------------------------------------------- |
|
||||||
| Frontend PWA | React + Vite | Join pods, publish screen share, render interventions, play audio |
|
| Frontend | React + Vite | Pod rooms, screen share, cards, voice controls, member state |
|
||||||
| Backend API | Express | Mint LiveKit tokens, manage pods, record outcomes, expose memory stats, create sync PRs |
|
| Backend API | Express on `:8787` | LiveKit tokens, pod CRUD, outcomes, memory stats, sync PRs |
|
||||||
| Vision agent | `@livekit/rtc-node` | Subscribe to screen-share tracks, sample frames, publish intervention data |
|
| Vision agent | Node + `@livekit/rtc-node` | Subscribes to screen tracks, samples frames, publishes interventions |
|
||||||
| Live conversation agent | LiveKit Agents (Python) + Gemini Live API | Real-time voice Q&A with function tools over repo, git, and memory |
|
| Live voice agent | Python LiveKit Agents + Gemini Live | Real-time voice Q&A in a pod room |
|
||||||
| Perception | Gemini Vision (`gemini-2.0-flash`) | Sampled IDE frames → structured work context |
|
| Memory | MongoDB Atlas | Observations, engineer state, collisions, interventions, outcomes |
|
||||||
| Team memory | MongoDB Atlas | Observations, collisions, interventions, outcomes, vector embeddings |
|
| Reasoning agent | Hermes + Gemma Modular/MAX | Tool-using autonomous assistant and ops layer |
|
||||||
| Git watcher | Node script | Report each laptop's dirty/unpushed state as ground truth |
|
| Realtime transport | LiveKit Cloud | Screen tracks, audio tracks, data messages |
|
||||||
| Action layer | Hermes | Cards, teammate messages, Gemini TTS urgent voice, Lyria background score |
|
| Hosting | DigitalOcean + Caddy + systemd | Static frontend, API, workers, watchdog timers |
|
||||||
| Deployment | DigitalOcean | Static frontend, API service, agent workers |
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
### Data flow
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -174,161 +386,106 @@ sequenceDiagram
|
|||||||
participant Dev as Engineer PWA
|
participant Dev as Engineer PWA
|
||||||
participant API as Backend API
|
participant API as Backend API
|
||||||
participant LK as LiveKit Room
|
participant LK as LiveKit Room
|
||||||
participant Agent as PodMan Agent
|
participant Agent as Vision Agent
|
||||||
participant Gemini as Gemini Vision
|
participant Gemini as Gemini APIs
|
||||||
participant Mongo as MongoDB Memory
|
participant Mongo as MongoDB Atlas
|
||||||
participant Hermes as Hermes / Action Layer
|
participant Hermes as Hermes/Gemma
|
||||||
|
|
||||||
Dev->>API: POST /api/token
|
Dev->>API: POST /api/token
|
||||||
API-->>Dev: LiveKit URL + JWT
|
API-->>Dev: LiveKit URL + JWT
|
||||||
Dev->>LK: Join pod room + publish screen share
|
Dev->>LK: Join pod room + publish screen share
|
||||||
Agent->>LK: Subscribe to screen-share video
|
Agent->>LK: Subscribe to screen-share video
|
||||||
Agent->>Gemini: Sampled JPEG frame
|
Agent->>Gemini: Sampled frame
|
||||||
Gemini-->>Agent: Structured work context
|
Gemini-->>Agent: Structured work context
|
||||||
Agent->>Mongo: Record observation
|
Agent->>Mongo: Store observation
|
||||||
Agent->>Mongo: Recall prior patterns (vector search)
|
Agent->>Mongo: Recall similar prior events
|
||||||
Mongo-->>Agent: Prior intervention + outcome
|
Mongo-->>Agent: Prior outcome + policy hints
|
||||||
Agent->>Hermes: Create intervention (gated by policy)
|
Agent->>Hermes: Escalate when autonomous help is useful
|
||||||
Hermes->>LK: Publish card / message / Gemini TTS voice
|
Agent->>LK: Publish card / message / voice cue
|
||||||
LK-->>Dev: Render intervention
|
Dev->>API: POST /api/outcome
|
||||||
Dev->>API: POST /api/outcome (accept / dismiss)
|
API->>Mongo: Store accept/dismiss feedback
|
||||||
API->>Mongo: Store learning signal
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Public Interfaces
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
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 |
|
| Interface | Purpose |
|
||||||
| ----------------------------------------------------------- | ------------------------------------------------- |
|
| ----------------------------------------------------------- | ---------------------------------------------- |
|
||||||
| `GET /health` | API health check |
|
| `GET /health` | API health check |
|
||||||
| `POST /api/token` | Mint LiveKit room tokens |
|
| `POST /api/token` | Mint LiveKit room tokens |
|
||||||
| `POST /api/sync-pr` | Create a visible sync PR artifact |
|
| `GET /api/pods` | List pods |
|
||||||
| `POST /api/outcome` | Store accepted/dismissed intervention outcomes |
|
|
||||||
| `GET /api/memory/stats` | Memory collection counts (live learning evidence) |
|
|
||||||
| `GET/POST/PATCH/DELETE /api/pods` | Pod CRUD |
|
| `GET/POST/PATCH/DELETE /api/pods` | Pod CRUD |
|
||||||
| `POST/DELETE /api/pods/:id/members` | Pod membership |
|
| `POST/DELETE /api/pods/:id/members` | Pod membership |
|
||||||
|
| `GET /api/pods/:id/members/:name/history` | Recent member work history |
|
||||||
|
| `POST /api/outcome` | Store accepted/dismissed intervention outcomes |
|
||||||
|
| `GET /api/memory/stats` | Live memory collection counts |
|
||||||
|
| `POST /api/sync-pr` | Create a visible sync PR artifact |
|
||||||
| LiveKit topic `podman.intervention` | Intervention data channel |
|
| LiveKit topic `podman.intervention` | Intervention data channel |
|
||||||
| Wire messages `COLLISION`, `ACK`, `GIT_REPORT`, `VOICE_CUE` | Agent/PWA contract |
|
| Wire messages `COLLISION`, `ACK`, `GIT_REPORT`, `VOICE_CUE` | Agent/PWA contract |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Monorepo Layout
|
||||||
|
|
||||||
|
| Folder | Purpose |
|
||||||
## Monorepo layout
|
| ----------- | ----------------------------------------------------------------------- |
|
||||||
|
| `frontend/` | React + Vite PWA |
|
||||||
|
| `backend/` | Express API, vision agent, memory, collision detection, Hermes job APIs |
|
||||||
| Folder | What |
|
| `agents/` | Python LiveKit conversation agent |
|
||||||
| ----------- | -------------------------------------------------------------------------- |
|
| `shared/` | Shared TypeScript contracts |
|
||||||
| `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 |
|
| `database/` | MongoDB setup and seed utilities |
|
||||||
| `infra/` | DigitalOcean specs, Caddyfile, systemd units |
|
| `infra/` | Caddy, Docker, DigitalOcean, systemd units |
|
||||||
| `scripts/` | Local git watcher + deploy/verify tooling |
|
| `scripts/` | Git watcher, deploy doctor, watchdog, verification tooling |
|
||||||
| `docs/` | Integration specs and the demo script |
|
| `docs/` | Demo, deployment, learning, graph, and architecture notes |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Run the core app in three terminals:
|
||||||
|
|
||||||
## Quick start
|
```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
|
||||||
|
|
||||||
|
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
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# fill in LIVEKIT_*, GEMINI_*, GITHUB_*, MONGODB_URI
|
# Fill LIVEKIT_*, GEMINI_*, GITHUB_*, MONGODB_URI.
|
||||||
|
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm --filter @podman/backend dev # API on :8787
|
pnpm --filter @podman/backend dev # API on :8787
|
||||||
pnpm --filter @podman/backend dev:agent # PodMan LiveKit vision agent
|
pnpm --filter @podman/backend dev:agent # LiveKit vision agent
|
||||||
pnpm --filter @podman/frontend dev # PWA on :5173
|
pnpm --filter @podman/frontend dev # PWA on :5173
|
||||||
```
|
```
|
||||||
|
|
||||||
The live conversation agent (Gemini Live API) runs from `agents/podman-live-conversation/`.
|
Run the Python live conversation agent:
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Git watcher — run on every demo laptop
|
|
||||||
|
|
||||||
Each engineer runs this before the demo. It polls the local git working tree
|
|
||||||
every 15 seconds and writes git state to MongoDB so PodMan has deterministic
|
|
||||||
dirty/unpushed truth that vision alone cannot reliably infer.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# from the repo root
|
pnpm livekit:conversation:agent
|
||||||
node scripts/podman-agent.mjs --name <yourname> --pod <podId>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Demo setup:**
|
Run the local git watcher on each demo laptop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/podman-agent.mjs --name <engineer-name> --pod <pod-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Demo identities:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/podman-agent.mjs --name alice --pod demo-pod
|
node scripts/podman-agent.mjs --name alice --pod demo-pod
|
||||||
@@ -336,5 +493,184 @@ node scripts/podman-agent.mjs --name bob --pod demo-pod
|
|||||||
node scripts/podman-agent.mjs --name carol --pod demo-pod
|
node scripts/podman-agent.mjs --name carol --pod demo-pod
|
||||||
```
|
```
|
||||||
|
|
||||||
**Requirements:** `MONGODB_URI` exported (or in `backend/.env`), `pnpm install`
|
---
|
||||||
run first, launched from the repo root.
|
|
||||||
|
## Production Operations
|
||||||
|
|
||||||
|
The production droplet is systemd-supervised. Caddy serves the built frontend
|
||||||
|
and proxies `/api/*` to the backend on `127.0.0.1:8787`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Public["https://podman.live"] --> Caddy["caddy.service"]
|
||||||
|
Caddy --> Static["/var/www/podman"]
|
||||||
|
Caddy --> API["podman-platform-api.service<br/>:8787"]
|
||||||
|
API --> Agent["podman-platform-agent.service"]
|
||||||
|
API --> Voice["podman-live-conversation-agent.service"]
|
||||||
|
Watchdog["podman-hermes-watchdog.timer"] --> Public
|
||||||
|
Sync["podman-hermes-sync-deploy.timer"] --> API
|
||||||
|
|
||||||
|
classDef public fill:#e8f1ff,stroke:#3366cc,color:#0b1f44;
|
||||||
|
classDef service fill:#eef8ee,stroke:#2f8a3a,color:#123915;
|
||||||
|
classDef timer fill:#fff6df,stroke:#c47f00,color:#3d2b00;
|
||||||
|
class Public public;
|
||||||
|
class Caddy,Static,API,Agent,Voice service;
|
||||||
|
class Watchdog,Sync timer;
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service / timer | Purpose |
|
||||||
|
| ---------------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| `podman-platform-api.service` | Built backend API on port `8787` |
|
||||||
|
| `podman-platform-agent.service` | Node LiveKit vision agent |
|
||||||
|
| `podman-live-conversation-agent.service` | Python Gemini Live conversation agent |
|
||||||
|
| `podman-hermes-watchdog.timer` | Periodic public health and remediation |
|
||||||
|
| `podman-hermes-sync-deploy.timer` | Clean-tree fast-forward deploy loop |
|
||||||
|
| `caddy.service` | Serves `/var/www/podman`, proxies `/api/*` to `127.0.0.1:8787` |
|
||||||
|
|
||||||
|
Check the app from the outside first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://podman.live/
|
||||||
|
curl https://podman.live/health
|
||||||
|
curl https://podman.live/api/pods
|
||||||
|
curl https://podman.live/api/presence
|
||||||
|
curl https://podman.live/api/memory/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check the droplet services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl is-active podman-platform-api podman-platform-agent
|
||||||
|
systemctl is-active podman-live-conversation-agent
|
||||||
|
systemctl is-active podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Hermes operations scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm hermes:watchdog
|
||||||
|
pnpm hermes:watchdog:strict
|
||||||
|
pnpm hermes:sync-deploy
|
||||||
|
pnpm deploy:doctor:strict
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemma Modular/MAX endpoint checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://llm.alhinai.dev/v1/models \
|
||||||
|
-H "Authorization: Bearer not-needed"
|
||||||
|
|
||||||
|
curl https://llm.alhinai.dev/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer not-needed" \
|
||||||
|
-d '{
|
||||||
|
"model": "gemma-4-31B-it",
|
||||||
|
"messages": [{"role": "user", "content": "Reply with exactly: working"}],
|
||||||
|
"temperature": 0,
|
||||||
|
"max_tokens": 512
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LIVEKIT_URL=wss://your-livekit-server.livekit.cloud
|
||||||
|
LIVEKIT_API_KEY=...
|
||||||
|
LIVEKIT_API_SECRET=...
|
||||||
|
LIVEKIT_CONVERSATION_AGENT_NAME=podman-live-conversation
|
||||||
|
|
||||||
|
GEMINI_API_KEY=...
|
||||||
|
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||||
|
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
|
||||||
|
GEMINI_CONVERSATION_MODEL=gemini-3.1-flash-live-preview
|
||||||
|
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
|
||||||
|
GEMINI_TTS_VOICE=Charon
|
||||||
|
|
||||||
|
GITHUB_TOKEN=...
|
||||||
|
GITHUB_REPO=karti-ai/podman
|
||||||
|
|
||||||
|
MONGODB_URI=mongodb+srv://...
|
||||||
|
VOYAGE_API_KEY=...
|
||||||
|
VOYAGE_EMBEDDING_MODEL=voyage-4-lite
|
||||||
|
|
||||||
|
PORT=8787
|
||||||
|
POD_ROOM=demo-pod
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemma/Hermes provider values live in Hermes config, not PodMan `.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
provider: gemma4-31b-max
|
||||||
|
model: gemma-4-31B-it
|
||||||
|
base_url: https://llm.alhinai.dev/v1
|
||||||
|
api_key: not-needed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Before calling a deployment healthy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm verify
|
||||||
|
pnpm verify:infra
|
||||||
|
pnpm deploy:doctor:strict
|
||||||
|
pnpm hermes:watchdog:strict
|
||||||
|
```
|
||||||
|
|
||||||
|
For the public site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://podman.live/
|
||||||
|
curl https://podman.live/health
|
||||||
|
curl https://podman.live/api/pods
|
||||||
|
curl https://podman.live/api/presence
|
||||||
|
curl https://podman.live/api/memory/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
For Hermes/Gemma:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes -z 'Reply with exactly: working' \
|
||||||
|
--provider gemma4-31b-max \
|
||||||
|
--model gemma-4-31B-it
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
working
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Check |
|
||||||
|
| ----------------------------- | ------------------------------------------------ | ---------------------------------------------------- |
|
||||||
|
| Frontend loads but API fails | Backend or Caddy proxy issue | `systemctl status podman-platform-api caddy` |
|
||||||
|
| `/api/*` returns 502 | API not listening on `8787` | `ss -ltnp`, `curl http://127.0.0.1:8787/health` |
|
||||||
|
| No screen observations | Vision agent not in LiveKit room | `journalctl -u podman-platform-agent -n 80` |
|
||||||
|
| Live voice missing | Python conversation agent down | `journalctl -u podman-live-conversation-agent -n 80` |
|
||||||
|
| Memory empty | MongoDB unavailable or env missing | `curl /api/memory/stats`, backend logs |
|
||||||
|
| Hermes tool calls fail | Modular/MAX endpoint does not accept tool schema | verify Hermes provider and `/v1/chat/completions` |
|
||||||
|
| `llm.alhinai.dev` returns 502 | Gemma server still loading or tunnel target down | `curl /v1/models`, MAX logs on Gemma host |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why It Gets Better
|
||||||
|
|
||||||
|
PodMan is not a static alert system. It remembers what actually helped.
|
||||||
|
|
||||||
|
- A dismissed false alarm lowers future urgency.
|
||||||
|
- An accepted real collision raises future urgency for similar work.
|
||||||
|
- Exact-signature recall catches repeats even without vector search.
|
||||||
|
- MongoDB outcomes become the policy signal for the next session.
|
||||||
|
- Hermes and Gemma give the system a tool-using agent when the coordination
|
||||||
|
problem needs active investigation instead of a passive card.
|
||||||
|
|
||||||
|
The goal is simple: fewer interruptions, fewer duplicate branches, and a team
|
||||||
|
that can move fast without constantly asking what everyone else is doing.
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ You are in a private 1:1 voice conversation with one developer.
|
|||||||
|
|
||||||
Use PodMan tools before making claims about current work, git state, collisions, blockers,
|
Use PodMan tools before making claims about current work, git state, collisions, blockers,
|
||||||
team memory, or recent decisions. Keep spoken answers short. Prefer one useful next step.
|
team memory, or recent decisions. Keep spoken answers short. Prefer one useful next step.
|
||||||
|
For questions about a person's style, goals, past work, collaboration habits, personal context,
|
||||||
|
or what they know across pods/sessions, call get_user_learning_profile before answering.
|
||||||
If a critical collision event arrives, stop the current turn and state the alert immediately.
|
If a critical collision event arrives, stop the current turn and state the alert immediately.
|
||||||
To find code, files, symbols, or how something is implemented in the repository, call search_repo.
|
To find code, files, symbols, or how something is implemented in the repository, call search_repo.
|
||||||
For git commit history, authorship, recent changes, or which commit introduced something, call
|
For git commit history, authorship, recent changes, or which commit introduced something, call
|
||||||
@@ -130,6 +132,20 @@ class PodManLiveAgent(Agent):
|
|||||||
)
|
)
|
||||||
return json.dumps(data, ensure_ascii=True)[:12000]
|
return json.dumps(data, ensure_ascii=True)[:12000]
|
||||||
|
|
||||||
|
@function_tool()
|
||||||
|
async def get_user_learning_profile(self, context: RunContext) -> str:
|
||||||
|
"""Get persistent cross-pod, cross-session knowledge about this developer:
|
||||||
|
collaboration style, goals, known work, recent activity, and Hermes history.
|
||||||
|
"""
|
||||||
|
data = await asyncio.to_thread(
|
||||||
|
request_json,
|
||||||
|
f"/api/internal/pods/{self.pod_id}/live-context?identity={self.identity}",
|
||||||
|
)
|
||||||
|
profile = data.get("userLearningProfile")
|
||||||
|
if not profile:
|
||||||
|
return "No persistent user learning profile has been built for this developer yet."
|
||||||
|
return json.dumps(profile, ensure_ascii=True)[:10000]
|
||||||
|
|
||||||
@function_tool()
|
@function_tool()
|
||||||
async def record_conversation_note(self, context: RunContext, note: str, kind: str = "summary") -> str:
|
async def record_conversation_note(self, context: RunContext, note: str, kind: str = "summary") -> str:
|
||||||
"""Store a useful decision, outcome, or preference learned during this conversation."""
|
"""Store a useful decision, outcome, or preference learned during this conversation."""
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/express": "^2.1.32",
|
||||||
"@google/genai": "^2.10.0",
|
"@google/genai": "^2.10.0",
|
||||||
"@livekit/rtc-node": "^0.13.29",
|
"@livekit/rtc-node": "^0.13.29",
|
||||||
"@podman/shared": "workspace:*",
|
"@podman/shared": "workspace:*",
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ export async function publishHermesIntervention(
|
|||||||
void notifyCriticalLiveConversations(collision, intervention, voiceLine).catch((err) =>
|
void notifyCriticalLiveConversations(collision, intervention, voiceLine).catch((err) =>
|
||||||
console.warn(`[live-conversation] critical notify failed: ${(err as Error).message}`),
|
console.warn(`[live-conversation] critical notify failed: ${(err as Error).message}`),
|
||||||
);
|
);
|
||||||
if (voiceLine) await speak(room, voiceLine, { priority: 'critical' });
|
if (voiceLine)
|
||||||
|
await speak(room, voiceLine, {
|
||||||
|
priority: collision.severity === 'critical' ? 'critical' : 'normal',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hermesToken(roomName: string): Promise<string> {
|
async function hermesToken(roomName: string): Promise<string> {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { EngineerContext, MemberWorkHistory, MemberWorkHistoryFile } from '@podman/shared';
|
import type {
|
||||||
|
Collision,
|
||||||
|
EngineerContext,
|
||||||
|
MemberWorkHistory,
|
||||||
|
MemberWorkHistoryFile,
|
||||||
|
MemberWorkHistoryRoi,
|
||||||
|
} from '@podman/shared';
|
||||||
import { getDb } from '../memory/db.js';
|
import { getDb } from '../memory/db.js';
|
||||||
import { parseGitStatusPath } from '../graph/live.js';
|
import { parseGitStatusPath } from '../graph/live.js';
|
||||||
|
|
||||||
@@ -80,7 +86,7 @@ export async function getMemberWorkHistory(
|
|||||||
const limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
|
const limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
|
||||||
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
const [observations, gitState] = await Promise.all([
|
const [observations, gitState, collisions, interventions] = await Promise.all([
|
||||||
db
|
db
|
||||||
.collection<EngineerContext>('observations')
|
.collection<EngineerContext>('observations')
|
||||||
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
|
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||||
@@ -91,6 +97,16 @@ export async function getMemberWorkHistory(
|
|||||||
podId,
|
podId,
|
||||||
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
|
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
|
||||||
}),
|
}),
|
||||||
|
db
|
||||||
|
.collection<Collision>('collisions')
|
||||||
|
.find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ detectedAt: -1 })
|
||||||
|
.limit(200)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<{ collisionId: string }>('interventions')
|
||||||
|
.find({ podId }, { projection: { collisionId: 1, _id: 0 } })
|
||||||
|
.toArray(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const files = new Map<string, FileAccumulator>();
|
const files = new Map<string, FileAccumulator>();
|
||||||
@@ -158,6 +174,9 @@ export async function getMemberWorkHistory(
|
|||||||
|
|
||||||
timeline.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
timeline.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
||||||
|
|
||||||
|
const interventionIds = new Set(interventions.map((i) => i.collisionId));
|
||||||
|
const roi = computeRoi(member, collisions, interventionIds, gitState?.changedFiles?.length ?? 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
podId,
|
podId,
|
||||||
member,
|
member,
|
||||||
@@ -170,5 +189,56 @@ export async function getMemberWorkHistory(
|
|||||||
},
|
},
|
||||||
files: fileRows,
|
files: fileRows,
|
||||||
timeline: timeline.slice(0, limit),
|
timeline: timeline.slice(0, limit),
|
||||||
|
roi,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRoi(
|
||||||
|
member: string,
|
||||||
|
collisions: Collision[],
|
||||||
|
interventionCollisionIds: Set<string>,
|
||||||
|
changedFileCount: number,
|
||||||
|
): MemberWorkHistoryRoi {
|
||||||
|
const involved = (c: Collision) =>
|
||||||
|
c.engineers?.some((e) => sameMember(e, member)) ||
|
||||||
|
sameMember(c.researcher, member) ||
|
||||||
|
sameMember(c.editor, member);
|
||||||
|
|
||||||
|
const eligible = collisions.filter(
|
||||||
|
(c) =>
|
||||||
|
involved(c) &&
|
||||||
|
interventionCollisionIds.has(c.id) &&
|
||||||
|
(c.gitOverlap === true || c.severity === 'critical'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const weightOf = (c: Collision): { label: string; minutes: number } => {
|
||||||
|
if (c.overlapKind === 'research') return { label: 'research overlap', minutes: 10 };
|
||||||
|
if (c.severity === 'critical') return { label: 'critical same-file', minutes: 45 };
|
||||||
|
if (c.severity === 'warn') return { label: 'warn same-file', minutes: 20 };
|
||||||
|
return { label: 'info same-file', minutes: 10 };
|
||||||
|
};
|
||||||
|
|
||||||
|
let savedMinutes = 0;
|
||||||
|
const groups = new Map<string, { count: number; minutesEach: number }>();
|
||||||
|
for (const c of eligible) {
|
||||||
|
const { label, minutes } = weightOf(c);
|
||||||
|
savedMinutes += minutes / Math.max(1, c.engineers?.length ?? 1);
|
||||||
|
const g = groups.get(label) ?? { count: 0, minutesEach: minutes };
|
||||||
|
g.count += 1;
|
||||||
|
groups.set(label, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesDeconflicted = new Set(eligible.map((c) => c.file)).size;
|
||||||
|
return {
|
||||||
|
savedMinutes: Math.round(savedMinutes),
|
||||||
|
clashesCaught: eligible.length,
|
||||||
|
filesDeconflicted,
|
||||||
|
conflictFreeFiles: Math.max(0, changedFileCount - filesDeconflicted),
|
||||||
|
totalFiles: changedFileCount,
|
||||||
|
breakdown: [...groups.entries()].map(([label, g]) => ({
|
||||||
|
label,
|
||||||
|
count: g.count,
|
||||||
|
minutesEach: g.minutesEach,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-19
@@ -2,11 +2,13 @@ import { RoomEvent, type Room } from '@livekit/rtc-node';
|
|||||||
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
|
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
|
||||||
import { analyzeFrame } from '../vision/gemini.js';
|
import { analyzeFrame } from '../vision/gemini.js';
|
||||||
import { detectCollisions } from '../collision/detector.js';
|
import { detectCollisions } from '../collision/detector.js';
|
||||||
|
import { detectResearchOverlaps } from '../collision/research.js';
|
||||||
import { getGithubState } from '../github/client.js';
|
import { getGithubState } from '../github/client.js';
|
||||||
import {
|
import {
|
||||||
recordObservation,
|
recordObservation,
|
||||||
recordCollision,
|
recordCollision,
|
||||||
recordIntervention,
|
recordIntervention,
|
||||||
|
recordSuppression,
|
||||||
updateInterventionStatus,
|
updateInterventionStatus,
|
||||||
} from '../memory/store.js';
|
} from '../memory/store.js';
|
||||||
import { getGitStates, type GitState } from '../memory/db.js';
|
import { getGitStates, type GitState } from '../memory/db.js';
|
||||||
@@ -33,6 +35,12 @@ function canonicalName(raw?: string): string {
|
|||||||
return (raw ?? '').trim().toLowerCase();
|
return (raw ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long a still-present conflict stays muted after we voice it, before it
|
||||||
|
* re-alerts. Edge-trigger alone permanently muted files that stay dirty for
|
||||||
|
* the whole session (e.g. README everyone tests on). This bounds that: voice
|
||||||
|
* once, then re-alert at most every interval while it persists. */
|
||||||
|
const CONFLICT_REALERT_MS = Number(process.env.CONFLICT_REALERT_MS ?? '20000');
|
||||||
|
|
||||||
/** Git ground truth: do ALL involved engineers currently have the collided file
|
/** Git ground truth: do ALL involved engineers currently have the collided file
|
||||||
* in their changedFiles? Computed at detection time while git state is fresh. */
|
* in their changedFiles? Computed at detection time while git state is fresh. */
|
||||||
function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, GitState>): boolean {
|
function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, GitState>): boolean {
|
||||||
@@ -48,13 +56,15 @@ function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, Git
|
|||||||
export class PodMan {
|
export class PodMan {
|
||||||
private contexts = new Map<string, EngineerContext>();
|
private contexts = new Map<string, EngineerContext>();
|
||||||
/**
|
/**
|
||||||
* Conflicts we have already voiced and that are still unresolved, keyed by
|
* Conflicts we have already voiced, keyed by file + engineer pair (see
|
||||||
* file (see conflictKey). Edge-triggered alerting: speak once when a conflict
|
* conflictKey), mapped to the time we last voiced them. Edge-triggered:
|
||||||
* appears, stay quiet while it persists. A conflict is re-armed (deleted
|
* speak once when a conflict appears. Re-armed (deleted here) by
|
||||||
* here) by onScreenFrame as soon as a detection cycle no longer sees it, so a
|
* onScreenFrame as soon as a detection cycle no longer sees it, so a
|
||||||
* resolved-then-recurring conflict alerts again.
|
* resolved-then-recurring conflict alerts again. Additionally, a conflict
|
||||||
|
* that *persists* re-alerts every CONFLICT_REALERT_MS so a perpetually-dirty
|
||||||
|
* file (README) doesn't go silent forever after the first alert.
|
||||||
*/
|
*/
|
||||||
private activeConflicts = new Set<string>();
|
private activeConflicts = new Map<string, number>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private room: Room,
|
private room: Room,
|
||||||
@@ -97,20 +107,25 @@ export class PodMan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const github = await getGithubState(); // cached
|
const github = await getGithubState(); // cached
|
||||||
const collisions = detectCollisions([...this.contexts.values()], github, gitStates);
|
const contexts = [...this.contexts.values()];
|
||||||
|
const fileCollisions = detectCollisions(contexts, github, gitStates);
|
||||||
|
const researchCollisions = await detectResearchOverlaps(contexts, gitStates);
|
||||||
|
const collisions = [...fileCollisions, ...researchCollisions];
|
||||||
|
|
||||||
// Re-arm: any conflict we previously voiced that is no longer present has
|
// Re-arm: any conflict we previously voiced that is no longer present has
|
||||||
// resolved, so allow it to alert again if it recurs.
|
// resolved, so allow it to alert again if it recurs.
|
||||||
const current = new Set(collisions.map((c) => this.conflictKey(c)));
|
const current = new Set(collisions.map((c) => this.conflictKey(c)));
|
||||||
for (const key of this.activeConflicts) {
|
for (const key of this.activeConflicts.keys()) {
|
||||||
if (!current.has(key)) this.activeConflicts.delete(key);
|
if (!current.has(key)) this.activeConflicts.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture git ground-truth overlap now, while engineer_states are fresh, so
|
// Capture git ground-truth overlap now, while engineer_states are fresh, so
|
||||||
// the outcome-time verifier never depends on a stale sidecar or a late click.
|
// the outcome-time verifier never depends on a stale sidecar or a late click.
|
||||||
for (const collision of collisions) {
|
for (const collision of collisions) {
|
||||||
|
if (collision.overlapKind !== 'research') {
|
||||||
collision.gitOverlap = engineersOverlapOnFile(collision, gitStates);
|
collision.gitOverlap = engineersOverlapOnFile(collision, gitStates);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const collision of collisions) await this.handle(collision);
|
for (const collision of collisions) await this.handle(collision);
|
||||||
}
|
}
|
||||||
@@ -122,12 +137,16 @@ export class PodMan {
|
|||||||
* basename.
|
* basename.
|
||||||
*/
|
*/
|
||||||
private conflictKey(collision: Collision): string {
|
private conflictKey(collision: Collision): string {
|
||||||
return comparableBasename(collision.file);
|
const who = [...collision.engineers].map(canonicalName).sort().join('+');
|
||||||
|
return `${collision.overlapKind ?? 'file'}:${comparableBasename(collision.file)}:${who}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handle(collision: Collision): Promise<void> {
|
private async handle(collision: Collision): Promise<void> {
|
||||||
const key = this.conflictKey(collision);
|
const key = this.conflictKey(collision);
|
||||||
if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved
|
const lastAlerted = this.activeConflicts.get(key);
|
||||||
|
// Edge-trigger + time-based re-arm: stay quiet right after voicing, but
|
||||||
|
// re-alert a persistent conflict once CONFLICT_REALERT_MS has elapsed.
|
||||||
|
if (lastAlerted !== undefined && Date.now() - lastAlerted < CONFLICT_REALERT_MS) return;
|
||||||
|
|
||||||
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
|
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
|
||||||
// Only escalate to critical (which triggers the spoken alert) when the
|
// Only escalate to critical (which triggers the spoken alert) when the
|
||||||
@@ -138,13 +157,64 @@ export class PodMan {
|
|||||||
if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) {
|
if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) {
|
||||||
collision.severity = 'critical';
|
collision.severity = 'critical';
|
||||||
}
|
}
|
||||||
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
|
if (!shouldIntervene(collision, prior)) {
|
||||||
|
// Feature A — make the negative-feedback loop VISIBLE. If we stayed quiet
|
||||||
|
// *specifically* because this signature was DISMISSED before, record a
|
||||||
|
// durable suppressed-repeat event (timestamped now, at the repeat) so the
|
||||||
|
// activity stream shows the learning instead of nothing.
|
||||||
|
if (prior?.priorOutcome && !prior.priorOutcome.accepted) {
|
||||||
|
// Mark handled first — like the alert path below — so we record ONE
|
||||||
|
// suppressed-repeat per recurrence, not once per frame; it re-arms via
|
||||||
|
// the resolution sweep in onScreenFrame. Awaited like recordCollision so
|
||||||
|
// the durable learning proof is reliably written.
|
||||||
|
this.activeConflicts.set(key, Date.now());
|
||||||
|
await recordSuppression(
|
||||||
|
collision,
|
||||||
|
prior.priorOutcome.interventionId,
|
||||||
|
prior.priorOutcome.recordedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return; // Loop B: policy gate
|
||||||
|
}
|
||||||
|
|
||||||
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
|
this.activeConflicts.set(key, Date.now()); // claim + timestamp; re-armed on resolution or after CONFLICT_REALERT_MS
|
||||||
await recordCollision(collision);
|
await recordCollision(collision);
|
||||||
const action = preferredAction(collision, prior);
|
const action = preferredAction(collision, prior);
|
||||||
const names = collision.engineers.join(' + ');
|
|
||||||
const shortFile = collision.file.split('/').pop() ?? collision.file;
|
const shortFile = collision.file.split('/').pop() ?? collision.file;
|
||||||
|
const isResearchOverlap = collision.overlapKind === 'research';
|
||||||
|
|
||||||
|
if (isResearchOverlap) {
|
||||||
|
const researcher = collision.researcher ?? collision.engineers[1] ?? 'A teammate';
|
||||||
|
const editor = collision.editor ?? collision.engineers[0] ?? 'a teammate';
|
||||||
|
const topic = collision.researchTopic ?? 'the same area';
|
||||||
|
const source = collision.researchSource ? ` (${collision.researchSource})` : '';
|
||||||
|
const message = `🤝 ${researcher} is researching ${topic}${source} while ${editor} edits ${shortFile} — sync up before duplicating effort.`;
|
||||||
|
const voiceLine = `${researcher} is researching ${topic} while ${editor} works on ${shortFile}. Worth a quick sync.`;
|
||||||
|
const intervention: Intervention = {
|
||||||
|
id: `int_${Date.now()}`,
|
||||||
|
collisionId: collision.id,
|
||||||
|
podId: this.podId,
|
||||||
|
kind: 'card',
|
||||||
|
message,
|
||||||
|
suggestedAction: {
|
||||||
|
kind: 'ping_teammate',
|
||||||
|
params: {
|
||||||
|
file: collision.file,
|
||||||
|
summary: message,
|
||||||
|
engineers: collision.engineers,
|
||||||
|
researchTopic: collision.researchTopic,
|
||||||
|
researchSource: collision.researchSource,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await recordIntervention(intervention);
|
||||||
|
await publishHermesIntervention(this.room, collision, intervention, voiceLine);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = collision.engineers.join(' + ');
|
||||||
|
|
||||||
// Terse, demo-centered alert — short and direct, not chatty AI prose.
|
// Terse, demo-centered alert — short and direct, not chatty AI prose.
|
||||||
const message =
|
const message =
|
||||||
@@ -174,11 +244,9 @@ export class PodMan {
|
|||||||
};
|
};
|
||||||
await recordIntervention(intervention);
|
await recordIntervention(intervention);
|
||||||
|
|
||||||
await publishHermesIntervention(
|
// Voice every intervention, not just critical escalations. Priority is set
|
||||||
this.room,
|
// by severity inside publishHermesIntervention: critical jumps the queue,
|
||||||
collision,
|
// the rest play sequentially so concurrent alerts don't garble each other.
|
||||||
intervention,
|
await publishHermesIntervention(this.room, collision, intervention, voiceLine);
|
||||||
collision.severity === 'critical' ? voiceLine : undefined,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { clerkMiddleware, getAuth } from '@clerk/express';
|
||||||
|
import type { Request, RequestHandler, Response } from 'express';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
export interface RequestUserContext {
|
||||||
|
clerkUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clerkAuthMiddleware: RequestHandler = env.CLERK_SECRET_KEY
|
||||||
|
? clerkMiddleware()
|
||||||
|
: (_req, _res, next) => next();
|
||||||
|
|
||||||
|
export function requestUser(req: Request): RequestUserContext | null {
|
||||||
|
if (!env.CLERK_SECRET_KEY) return null;
|
||||||
|
try {
|
||||||
|
const auth = getAuth(req);
|
||||||
|
return auth.isAuthenticated && auth.userId ? { clerkUserId: auth.userId } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRequestUser(req: Request, res: Response): RequestUserContext | null {
|
||||||
|
const user = requestUser(req);
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ error: 'sign in required' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -19,6 +19,15 @@ function fileKey(raw?: string): string | undefined {
|
|||||||
return base.toLowerCase();
|
return base.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize an engineer identity for case/whitespace-insensitive matching.
|
||||||
|
* Vision reports a display name ("Karti") while the git watcher reports a handle
|
||||||
|
* ("karti"); without this the same human collides with themselves.
|
||||||
|
*/
|
||||||
|
function canonicalName(raw: string): string {
|
||||||
|
return raw.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
interface Touch {
|
interface Touch {
|
||||||
engineerId: string;
|
engineerId: string;
|
||||||
unpushed: boolean;
|
unpushed: boolean;
|
||||||
@@ -64,8 +73,15 @@ export function detectCollisions(
|
|||||||
|
|
||||||
const out: Collision[] = [];
|
const out: Collision[] = [];
|
||||||
for (const [, touches] of byFile) {
|
for (const [, touches] of byFile) {
|
||||||
const engineers = [...new Set(touches.map((t) => t.engineerId))];
|
// Distinct PEOPLE, case/whitespace-insensitive — one display name per person.
|
||||||
if (engineers.length < 2) continue; // need two distinct people on one file
|
// Vision "Karti" and git "karti" are the same human, not a collision.
|
||||||
|
const byPerson = new Map<string, string>(); // canonical id -> display name
|
||||||
|
for (const t of touches) {
|
||||||
|
const id = canonicalName(t.engineerId);
|
||||||
|
if (id && !byPerson.has(id)) byPerson.set(id, t.engineerId);
|
||||||
|
}
|
||||||
|
if (byPerson.size < 2) continue; // need two distinct people on one file
|
||||||
|
const engineers = [...byPerson.values()];
|
||||||
|
|
||||||
const anyUnpushed = touches.some((t) => t.unpushed) || github.unpushed === true;
|
const anyUnpushed = touches.some((t) => t.unpushed) || github.unpushed === true;
|
||||||
if (!anyUnpushed) continue; // the crux GitHub alone cannot answer
|
if (!anyUnpushed) continue; // the crux GitHub alone cannot answer
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Collision, EngineerContext } from '@podman/shared';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import type { GitState } from '../memory/db.js';
|
||||||
|
import { semanticSimilarity } from '../memory/vectors.js';
|
||||||
|
|
||||||
|
export interface ResearchOpts {
|
||||||
|
similarity?: (a: string, b: string) => Promise<number | null>;
|
||||||
|
threshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorFile {
|
||||||
|
engineerId: string;
|
||||||
|
file: string;
|
||||||
|
symbol?: string;
|
||||||
|
activity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
collision: Collision;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripGitPrefix(raw: string): string {
|
||||||
|
return raw.trim().replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case/whitespace-insensitive identity so vision "Karti" and git "karti" are
|
||||||
|
* recognized as the same person and never flagged researching-vs-editing self. */
|
||||||
|
function canonicalName(raw: string): string {
|
||||||
|
return raw.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileStem(raw: string): string {
|
||||||
|
const base = stripGitPrefix(raw).split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
|
||||||
|
return base.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function words(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((word) => word.length >= 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTokens(raw: string): Set<string> {
|
||||||
|
const tokens = new Set(words(raw));
|
||||||
|
for (const word of [...tokens]) {
|
||||||
|
if (word.endsWith('kit')) tokens.add(word.replace(/kit$/, ''));
|
||||||
|
if (word.endsWith('s')) tokens.add(word.slice(0, -1));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackMatches(researchText: string, fileText: string): boolean {
|
||||||
|
const research = uniqueTokens(researchText);
|
||||||
|
const file = uniqueTokens(fileText);
|
||||||
|
for (const token of file) {
|
||||||
|
if (research.has(token)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEditorFiles(
|
||||||
|
contexts: EngineerContext[],
|
||||||
|
gitStates: Map<string, GitState> | undefined,
|
||||||
|
): EditorFile[] {
|
||||||
|
const files = new Map<string, EditorFile>();
|
||||||
|
const add = (editor: EditorFile): void => {
|
||||||
|
const stem = fileStem(editor.file);
|
||||||
|
if (!stem) return;
|
||||||
|
files.set(`${editor.engineerId}:${stripGitPrefix(editor.file)}`, editor);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [engineerId, git] of gitStates ?? []) {
|
||||||
|
for (const changed of git.changedFiles) {
|
||||||
|
add({ engineerId, file: changed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const context of contexts) {
|
||||||
|
if (context.mode !== 'research' && context.currentFile) {
|
||||||
|
add({
|
||||||
|
engineerId: context.engineerId,
|
||||||
|
file: context.currentFile,
|
||||||
|
symbol: context.currentSymbol,
|
||||||
|
activity: context.activity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...files.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectResearchOverlaps(
|
||||||
|
contexts: EngineerContext[],
|
||||||
|
gitStates: Map<string, GitState> | undefined,
|
||||||
|
opts: ResearchOpts = {},
|
||||||
|
): Promise<Collision[]> {
|
||||||
|
const similarity = opts.similarity ?? semanticSimilarity;
|
||||||
|
const threshold = opts.threshold ?? env.RESEARCH_OVERLAP_THRESHOLD;
|
||||||
|
const researchers = contexts.filter((c) => c.mode === 'research' && c.researchTopic);
|
||||||
|
const editorFiles = collectEditorFiles(contexts, gitStates);
|
||||||
|
const bestByResearcher = new Map<string, Candidate>();
|
||||||
|
|
||||||
|
for (const researcher of researchers) {
|
||||||
|
const topic = researcher.researchTopic?.trim();
|
||||||
|
if (!topic) continue;
|
||||||
|
const source = researcher.researchSource?.trim();
|
||||||
|
const researchText = [topic, source].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
for (const editor of editorFiles) {
|
||||||
|
if (canonicalName(editor.engineerId) === canonicalName(researcher.engineerId)) continue;
|
||||||
|
|
||||||
|
const stem = fileStem(editor.file);
|
||||||
|
if (!stem) continue;
|
||||||
|
const fileText = [stem, editor.symbol, editor.activity].filter(Boolean).join(' ');
|
||||||
|
const score = await similarity(researchText, fileText);
|
||||||
|
const matched = score === null ? fallbackMatches(researchText, fileText) : score >= threshold;
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
const rank = score ?? 1;
|
||||||
|
const existing = bestByResearcher.get(researcher.engineerId);
|
||||||
|
if (existing && existing.score >= rank) continue;
|
||||||
|
|
||||||
|
bestByResearcher.set(researcher.engineerId, {
|
||||||
|
score: rank,
|
||||||
|
collision: {
|
||||||
|
id: `col_research_${stem}_${Date.now()}`,
|
||||||
|
podId: researcher.podId,
|
||||||
|
file: editor.file,
|
||||||
|
symbol: editor.symbol,
|
||||||
|
engineers: [editor.engineerId, researcher.engineerId],
|
||||||
|
severity: 'warn',
|
||||||
|
overlapKind: 'research',
|
||||||
|
researchTopic: topic,
|
||||||
|
...(source ? { researchSource: source } : {}),
|
||||||
|
researcher: researcher.engineerId,
|
||||||
|
editor: editor.engineerId,
|
||||||
|
detectedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...bestByResearcher.values()].map((candidate) => candidate.collision);
|
||||||
|
}
|
||||||
+9
-2
@@ -1,4 +1,6 @@
|
|||||||
import 'dotenv/config';
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
config({ path: ['.env.local', '../.env.local', '.env', '../.env'] });
|
||||||
|
|
||||||
function req(name: string): string {
|
function req(name: string): string {
|
||||||
const v = process.env[name];
|
const v = process.env[name];
|
||||||
@@ -22,7 +24,10 @@ export const env = {
|
|||||||
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
||||||
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
||||||
LIVEKIT_AGENT_NAME: opt('LIVEKIT_AGENT_NAME'),
|
LIVEKIT_AGENT_NAME: opt('LIVEKIT_AGENT_NAME'),
|
||||||
LIVEKIT_CONVERSATION_AGENT_NAME: opt('LIVEKIT_CONVERSATION_AGENT_NAME', 'podman-live-conversation'),
|
LIVEKIT_CONVERSATION_AGENT_NAME: opt(
|
||||||
|
'LIVEKIT_CONVERSATION_AGENT_NAME',
|
||||||
|
'podman-live-conversation',
|
||||||
|
),
|
||||||
// Gemini
|
// Gemini
|
||||||
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
|
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
|
||||||
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
|
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
|
||||||
@@ -39,7 +44,9 @@ export const env = {
|
|||||||
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
||||||
// Server
|
// Server
|
||||||
PORT: Number(opt('PORT', '8787')),
|
PORT: Number(opt('PORT', '8787')),
|
||||||
|
CLERK_SECRET_KEY: opt('CLERK_SECRET_KEY'),
|
||||||
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
||||||
|
RESEARCH_OVERLAP_THRESHOLD: Number(opt('RESEARCH_OVERLAP_THRESHOLD', '0.6')),
|
||||||
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
|
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -457,6 +457,38 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Suppressed repeats: PodMan stayed quiet on a recurring collision because
|
||||||
|
// the signature was dismissed before — the negative-feedback loop made visible
|
||||||
|
// (Feature A). Stamped at repeat time, so it sorts as recent activity.
|
||||||
|
const suppressionDocs = await c.suppressions
|
||||||
|
.find({ podId })
|
||||||
|
.sort({ suppressedAt: -1 })
|
||||||
|
.limit(50)
|
||||||
|
.toArray();
|
||||||
|
// Collapse to one beat per file (keep the most recent — docs are sorted desc)
|
||||||
|
// so pre-fix duplicate rows never render as spam. The stable per-file id also
|
||||||
|
// dedupes through pushActivity's `seen` set.
|
||||||
|
const seenSuppressedFiles = new Set<string>();
|
||||||
|
for (const s of suppressionDocs) {
|
||||||
|
const sFile = normalizeFile(s.file);
|
||||||
|
if (!isFilePath(sFile)) continue;
|
||||||
|
const fileKey = sFile.toLowerCase();
|
||||||
|
if (seenSuppressedFiles.has(fileKey)) continue;
|
||||||
|
seenSuppressedFiles.add(fileKey);
|
||||||
|
const sEngs = (s.engineers ?? []).join(' + ') || 'teammates';
|
||||||
|
pushActivity(
|
||||||
|
activity,
|
||||||
|
{
|
||||||
|
id: `suppressed:${fileKey}`,
|
||||||
|
at: s.suppressedAt,
|
||||||
|
kind: 'suppressed',
|
||||||
|
title: `Suppressed — ${shortLabel(sFile)} repeat silenced`,
|
||||||
|
detail: `${sEngs} on ${sFile} recurred, but it was dismissed before — PodMan stayed quiet.`,
|
||||||
|
},
|
||||||
|
activityIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prune test-artifact engineers, then anything left orphaned by that.
|
// Prune test-artifact engineers, then anything left orphaned by that.
|
||||||
const dropNode = (id: string) => {
|
const dropNode = (id: string) => {
|
||||||
b.nodes.delete(id);
|
b.nodes.delete(id);
|
||||||
@@ -495,7 +527,11 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
|
|
||||||
const nodes = [...b.nodes.values()];
|
const nodes = [...b.nodes.values()];
|
||||||
// No real activity beyond the bare roster -> let the caller fall back to demo.
|
// No real activity beyond the bare roster -> let the caller fall back to demo.
|
||||||
const hasActivity = nodes.some((n) => n.kind !== 'engineer');
|
// Suppression beats are real negative-feedback proof even when they add no
|
||||||
|
// nodes/edges, so they satisfy the gate too — a clean pod with preserved
|
||||||
|
// suppressions must not fall back to the demo graph and hide the proof.
|
||||||
|
const hasSuppressed = activity.some((a) => a.kind === 'suppressed');
|
||||||
|
const hasActivity = hasSuppressed || nodes.some((n) => n.kind !== 'engineer');
|
||||||
if (!hasActivity) return null;
|
if (!hasActivity) return null;
|
||||||
|
|
||||||
layout(nodes);
|
layout(nodes);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { getDb } from '../memory/db.js';
|
import { getDb } from '../memory/db.js';
|
||||||
import { getMemberWorkHistory } from '../activity/member-history.js';
|
import { getMemberWorkHistory } from '../activity/member-history.js';
|
||||||
|
import {
|
||||||
|
buildUserLearningProfile,
|
||||||
|
getUserLearningProfileByIdentity,
|
||||||
|
} from '../memory/user-learning.js';
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 8;
|
const DEFAULT_LIMIT = 8;
|
||||||
|
|
||||||
@@ -10,7 +14,8 @@ function sinceIso(hours: number): string {
|
|||||||
export async function getLiveConversationContext(podId: string, identity: string) {
|
export async function getLiveConversationContext(podId: string, identity: string) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const since = sinceIso(12);
|
const since = sinceIso(12);
|
||||||
const [pod, history, gitState, collisions, interventions, outcomes] = await Promise.all([
|
const [pod, history, gitState, collisions, interventions, outcomes, userLearningProfile] =
|
||||||
|
await Promise.all([
|
||||||
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
|
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
|
||||||
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
|
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
|
||||||
db.collection('engineer_states').findOne(
|
db.collection('engineer_states').findOne(
|
||||||
@@ -44,6 +49,7 @@ export async function getLiveConversationContext(podId: string, identity: string
|
|||||||
.sort({ recordedAt: -1 })
|
.sort({ recordedAt: -1 })
|
||||||
.limit(DEFAULT_LIMIT)
|
.limit(DEFAULT_LIMIT)
|
||||||
.toArray(),
|
.toArray(),
|
||||||
|
getUserLearningProfileByIdentity(identity).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -51,6 +57,7 @@ export async function getLiveConversationContext(podId: string, identity: string
|
|||||||
identity,
|
identity,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
currentGitState: gitState,
|
currentGitState: gitState,
|
||||||
|
userLearningProfile,
|
||||||
memberHistory: history,
|
memberHistory: history,
|
||||||
recentCollisions: collisions,
|
recentCollisions: collisions,
|
||||||
recentInterventions: interventions,
|
recentInterventions: interventions,
|
||||||
@@ -76,5 +83,13 @@ export async function recordLiveConversationNote(input: {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await (await getDb()).collection('conversation_notes').insertOne(doc);
|
await (await getDb()).collection('conversation_notes').insertOne(doc);
|
||||||
|
if (input.identity) {
|
||||||
|
const profile = await getUserLearningProfileByIdentity(input.identity);
|
||||||
|
if (profile) {
|
||||||
|
void buildUserLearningProfile(profile.clerkUserId).catch((err) =>
|
||||||
|
console.warn(`[memory] note user learning refresh failed: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return { ...doc, _id: undefined };
|
return { ...doc, _id: undefined };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,40 @@ export async function closeMemory(): Promise<void> {
|
|||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A durable record that PodMan stayed quiet on a recurring collision because
|
||||||
|
* its signature was previously dismissed — the negative-feedback loop made
|
||||||
|
* auditable. Timestamped at the repeat; materialized as `suppressed` activity
|
||||||
|
* in graph/live.ts. The suppressed collision is never written to `collisions`
|
||||||
|
* (the agent returns before recordCollision), so this is its own record. */
|
||||||
|
export interface SuppressionDoc {
|
||||||
|
id: string;
|
||||||
|
podId: string;
|
||||||
|
collisionId: string;
|
||||||
|
file: string;
|
||||||
|
engineers: string[];
|
||||||
|
priorInterventionId?: string;
|
||||||
|
priorDismissedAt?: string;
|
||||||
|
suppressedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PodCollections {
|
export interface PodCollections {
|
||||||
pods: Collection<Pod>;
|
pods: Collection<Pod>;
|
||||||
observations: Collection<EngineerContext>;
|
observations: Collection<EngineerContext>;
|
||||||
collisions: Collection<Collision>;
|
collisions: Collection<Collision>;
|
||||||
interventions: Collection<Intervention>;
|
interventions: Collection<Intervention>;
|
||||||
outcomes: Collection<InterventionOutcome>;
|
outcomes: Collection<InterventionOutcome>;
|
||||||
|
suppressions: Collection<SuppressionDoc>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPodContextDoc {
|
||||||
|
id: string;
|
||||||
|
clerkUserId: string;
|
||||||
|
podId: string;
|
||||||
|
memberName?: string;
|
||||||
|
action: string;
|
||||||
|
source: 'clerk';
|
||||||
|
observedAt: string;
|
||||||
|
metadata?: Record<string, string | number | boolean | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collections(): Promise<PodCollections> {
|
export async function collections(): Promise<PodCollections> {
|
||||||
@@ -47,6 +75,7 @@ export async function collections(): Promise<PodCollections> {
|
|||||||
collisions: db.collection<Collision>('collisions'),
|
collisions: db.collection<Collision>('collisions'),
|
||||||
interventions: db.collection<Intervention>('interventions'),
|
interventions: db.collection<Intervention>('interventions'),
|
||||||
outcomes: db.collection<InterventionOutcome>('outcomes'),
|
outcomes: db.collection<InterventionOutcome>('outcomes'),
|
||||||
|
suppressions: db.collection<SuppressionDoc>('suppressions'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +143,7 @@ export async function initMemory(): Promise<void> {
|
|||||||
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
||||||
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
||||||
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
||||||
|
['suppressions.podId', () => c.suppressions.createIndex({ podId: 1, suppressedAt: -1 })],
|
||||||
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
|
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
|
||||||
[
|
[
|
||||||
'hermes_jobs.session',
|
'hermes_jobs.session',
|
||||||
@@ -123,6 +153,26 @@ export async function initMemory(): Promise<void> {
|
|||||||
'hermes_job_events.job',
|
'hermes_job_events.job',
|
||||||
() => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
|
() => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'user_pod_context.user',
|
||||||
|
() => db.collection('user_pod_context').createIndex({ clerkUserId: 1, observedAt: -1 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_pod_context.pod',
|
||||||
|
() => db.collection('user_pod_context').createIndex({ podId: 1, observedAt: -1 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_learning_profiles.user',
|
||||||
|
() => db.collection('user_learning_profiles').createIndex({ clerkUserId: 1 }, { unique: true }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_learning_profiles.updated',
|
||||||
|
() => db.collection('user_learning_profiles').createIndex({ updatedAt: -1 }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'conversation_notes.identity',
|
||||||
|
() => db.collection('conversation_notes').createIndex({ identity: 1, createdAt: -1 }),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [name, make] of indexes) {
|
for (const [name, make] of indexes) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,32 +1,22 @@
|
|||||||
import type { Collision, SuggestedActionKind } from '@podman/shared';
|
import type { Collision, SuggestedActionKind } from '@podman/shared';
|
||||||
import type { RecalledCollision } from './vectors.js';
|
import type { RecalledCollision } from './vectors.js';
|
||||||
|
|
||||||
const lastNudgeByPod = new Map<string, number>();
|
/**
|
||||||
|
* Policy gate — suppression fully disabled for the demo.
|
||||||
function cooldownMs(): number {
|
*
|
||||||
return Number(process.env.NUDGE_COOLDOWN_MS ?? '180000');
|
* Every real collision surfaces an intervention. Accept/Dismiss are still
|
||||||
}
|
* recorded as outcomes but carry NO gating meaning: dismissing one collision
|
||||||
|
* never silences future ones, and there is no per-pod cooldown throttling
|
||||||
/** Policy gate: combines severity, exact recall outcomes, and per-pod cooldown. */
|
* consecutive conflicts. The only filter is severity: `info` collisions are
|
||||||
export function shouldIntervene(collision: Collision, prior: RecalledCollision | null): boolean {
|
* informational, not actionable, so they don't nudge. The same-collision
|
||||||
if (collision.severity === 'info') return false;
|
* single-shot dedupe lives in `PodmanAgent.handle` (activeConflicts), so
|
||||||
|
* removing the cooldown does not cause repeat spam of an unresolved collision.
|
||||||
const priorOutcome = prior?.priorOutcome;
|
*
|
||||||
// Suppress when the identical prior was dismissed (accepted === false). The
|
* (Prior dismissal-based suppression over-generalized: one README discard
|
||||||
// former `&& !priorOutcome.wasRealCollision` term was dead code: outcomes are
|
* permanently muted all README clashes via loose file/vector recall.)
|
||||||
// recorded with wasRealCollision hardcoded true, so the gate never fired and
|
*/
|
||||||
// the 85 real dismissals in Atlas were ignored. Dismissals are the negative
|
export function shouldIntervene(collision: Collision, _prior: RecalledCollision | null): boolean {
|
||||||
// signal per continual-learning/policy.md:41 + spec.md:163. (RSI Step 1)
|
return collision.severity !== 'info';
|
||||||
if (priorOutcome && !priorOutcome.accepted) return false;
|
|
||||||
|
|
||||||
const cooldown = cooldownMs();
|
|
||||||
const last = lastNudgeByPod.get(collision.podId) ?? 0;
|
|
||||||
if (cooldown > 0 && Date.now() - last < cooldown) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastNudgeByPod.set(collision.podId, Date.now());
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preferred action selection based on collision severity and prior accepted actions. */
|
/** Preferred action selection based on collision severity and prior accepted actions. */
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import type {
|
|||||||
InterventionOutcome,
|
InterventionOutcome,
|
||||||
InterventionStatus,
|
InterventionStatus,
|
||||||
} from '@podman/shared';
|
} from '@podman/shared';
|
||||||
import { collections, getGitStates } from './db.js';
|
import { collections, getDb, getGitStates, type UserPodContextDoc } from './db.js';
|
||||||
import { enrichCollisionMemory } from './vectors.js';
|
import { enrichCollisionMemory } from './vectors.js';
|
||||||
|
import { buildUserLearningProfile } from './user-learning.js';
|
||||||
|
|
||||||
function comparableFile(raw?: string): string {
|
function comparableFile(raw?: string): string {
|
||||||
return (raw ?? '')
|
return (
|
||||||
|
(raw ?? '')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||||
.split(/[\\/]/)
|
.split(/[\\/]/)
|
||||||
.pop()
|
.pop()
|
||||||
?.toLowerCase() ?? '';
|
?.toLowerCase() ?? ''
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +41,30 @@ export async function recordObservation(ctx: EngineerContext): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recordUserPodContext(input: {
|
||||||
|
clerkUserId: string;
|
||||||
|
podId: string;
|
||||||
|
memberName?: string;
|
||||||
|
action: string;
|
||||||
|
metadata?: UserPodContextDoc['metadata'];
|
||||||
|
}): Promise<void> {
|
||||||
|
await persist('user pod context', async () =>
|
||||||
|
(await getDb()).collection<UserPodContextDoc>('user_pod_context').insertOne({
|
||||||
|
id: `upc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
clerkUserId: input.clerkUserId,
|
||||||
|
podId: input.podId,
|
||||||
|
memberName: input.memberName,
|
||||||
|
action: input.action,
|
||||||
|
source: 'clerk',
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
metadata: input.metadata,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
void buildUserLearningProfile(input.clerkUserId).catch((err) =>
|
||||||
|
console.warn(`[memory] user learning refresh failed: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordCollision(collision: Collision): Promise<void> {
|
export async function recordCollision(collision: Collision): Promise<void> {
|
||||||
await persist('collision', async () =>
|
await persist('collision', async () =>
|
||||||
(await collections()).collisions.insertOne(await enrichCollisionMemory(collision)),
|
(await collections()).collisions.insertOne(await enrichCollisionMemory(collision)),
|
||||||
@@ -50,6 +77,31 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature A — record that PodMan stayed quiet on a recurring collision because
|
||||||
|
* its signature was previously dismissed. Timestamped at the repeat (now), so
|
||||||
|
* the negative-feedback beat surfaces as recent `suppressed` activity rather
|
||||||
|
* than being re-synthesized from the old dismissal row.
|
||||||
|
*/
|
||||||
|
export async function recordSuppression(
|
||||||
|
collision: Collision,
|
||||||
|
priorInterventionId?: string,
|
||||||
|
priorDismissedAt?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await persist('suppression', async () =>
|
||||||
|
(await collections()).suppressions.insertOne({
|
||||||
|
id: `supp_${Date.now()}`,
|
||||||
|
podId: collision.podId,
|
||||||
|
collisionId: collision.id,
|
||||||
|
file: collision.file,
|
||||||
|
engineers: collision.engineers,
|
||||||
|
priorInterventionId,
|
||||||
|
priorDismissedAt,
|
||||||
|
suppressedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function hasRecentInterventionForCollision(
|
export async function hasRecentInterventionForCollision(
|
||||||
collision: Collision,
|
collision: Collision,
|
||||||
windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'),
|
windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'),
|
||||||
@@ -138,11 +190,31 @@ export async function recordOutcome(outcome: InterventionOutcome): Promise<void>
|
|||||||
/** Document counts per collection — used by the /api/memory/stats endpoint. */
|
/** Document counts per collection — used by the /api/memory/stats endpoint. */
|
||||||
export async function memoryStats(): Promise<Record<string, number>> {
|
export async function memoryStats(): Promise<Record<string, number>> {
|
||||||
const c = await collections();
|
const c = await collections();
|
||||||
const [observations, collisions, interventions, outcomes] = await Promise.all([
|
const db = await getDb();
|
||||||
|
const [
|
||||||
|
observations,
|
||||||
|
collisions,
|
||||||
|
interventions,
|
||||||
|
outcomes,
|
||||||
|
suppressions,
|
||||||
|
userPodContext,
|
||||||
|
userLearningProfiles,
|
||||||
|
] = await Promise.all([
|
||||||
c.observations.estimatedDocumentCount(),
|
c.observations.estimatedDocumentCount(),
|
||||||
c.collisions.estimatedDocumentCount(),
|
c.collisions.estimatedDocumentCount(),
|
||||||
c.interventions.estimatedDocumentCount(),
|
c.interventions.estimatedDocumentCount(),
|
||||||
c.outcomes.estimatedDocumentCount(),
|
c.outcomes.estimatedDocumentCount(),
|
||||||
|
c.suppressions.estimatedDocumentCount(),
|
||||||
|
db.collection<UserPodContextDoc>('user_pod_context').estimatedDocumentCount(),
|
||||||
|
db.collection('user_learning_profiles').estimatedDocumentCount(),
|
||||||
]);
|
]);
|
||||||
return { observations, collisions, interventions, outcomes };
|
return {
|
||||||
|
observations,
|
||||||
|
collisions,
|
||||||
|
interventions,
|
||||||
|
outcomes,
|
||||||
|
suppressions,
|
||||||
|
userPodContext,
|
||||||
|
userLearningProfiles,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import type { Collision, EngineerContext, HermesJob, Pod, UserLearningProfile } from '@podman/shared';
|
||||||
|
import { getDb, type UserPodContextDoc } from './db.js';
|
||||||
|
|
||||||
|
interface EngineerStateDoc {
|
||||||
|
podId: string;
|
||||||
|
name: string;
|
||||||
|
changedFiles?: string[];
|
||||||
|
branch?: string | null;
|
||||||
|
recentCommit?: string | null;
|
||||||
|
gitUpdatedAt?: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationNoteDoc {
|
||||||
|
podId: string;
|
||||||
|
identity?: string;
|
||||||
|
kind?: string;
|
||||||
|
note?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(value: unknown): string {
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString();
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniq(values: Array<string | undefined>): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const item = value?.trim();
|
||||||
|
if (!item) continue;
|
||||||
|
const key = item.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function top(values: string[], limit: number): string[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const value of values) {
|
||||||
|
const item = value.trim();
|
||||||
|
if (!item) continue;
|
||||||
|
counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([value]) => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lowerRegex(values: string[]): RegExp[] {
|
||||||
|
return values.map((value) => new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteGoals(notes: ConversationNoteDoc[]): string[] {
|
||||||
|
const goalNotes = notes
|
||||||
|
.filter((note) => /goal|plan|todo|next|preference|prefers|likes|wants|needs/i.test(`${note.kind} ${note.note}`))
|
||||||
|
.map((note) => clean(note.note)?.slice(0, 180))
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
return uniq(goalNotes).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCollaborationStyle(input: {
|
||||||
|
contexts: UserPodContextDoc[];
|
||||||
|
observations: EngineerContext[];
|
||||||
|
collisions: Collision[];
|
||||||
|
outcomes: Array<{ accepted?: boolean; wasRealCollision?: boolean }>;
|
||||||
|
notes: ConversationNoteDoc[];
|
||||||
|
jobs: HermesJob[];
|
||||||
|
}): string[] {
|
||||||
|
const style: string[] = [];
|
||||||
|
const actions = input.contexts.map((ctx) => ctx.action);
|
||||||
|
const pods = new Set(input.contexts.map((ctx) => ctx.podId));
|
||||||
|
if (pods.size > 1) style.push(`Works across ${pods.size} pods and carries context between rooms.`);
|
||||||
|
if (actions.filter((action) => action === 'joined_pod').length >= 3)
|
||||||
|
style.push('Frequently joins live rooms and collaborates synchronously.');
|
||||||
|
if (input.collisions.length > 0)
|
||||||
|
style.push(`Has been involved in ${input.collisions.length} coordination risk signals.`);
|
||||||
|
const accepted = input.outcomes.filter((outcome) => outcome.accepted).length;
|
||||||
|
if (accepted > 0) style.push(`Accepts Hermes help when useful (${accepted} accepted outcomes).`);
|
||||||
|
if (input.jobs.length > 0) style.push(`Delegates complex work to Hermes (${input.jobs.length} jobs).`);
|
||||||
|
if (input.notes.some((note) => /concise|short|brief/i.test(note.note ?? '')))
|
||||||
|
style.push('Prefers concise coordination.');
|
||||||
|
return style.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferWorkingStyle(input: {
|
||||||
|
observations: EngineerContext[];
|
||||||
|
gitStates: EngineerStateDoc[];
|
||||||
|
}): string[] {
|
||||||
|
const style: string[] = [];
|
||||||
|
const modes = top(input.observations.map((obs) => obs.mode ?? '').filter(Boolean), 2);
|
||||||
|
const activities = top(input.observations.map((obs) => obs.activity ?? '').filter(Boolean), 5);
|
||||||
|
const files = top(
|
||||||
|
[
|
||||||
|
...input.observations.map((obs) => obs.currentFile ?? ''),
|
||||||
|
...input.gitStates.flatMap((state) => state.changedFiles ?? []),
|
||||||
|
].filter(Boolean),
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
if (modes.length) style.push(`Usually seen in ${modes.join(' and ')} mode.`);
|
||||||
|
if (activities.length) style.push(`Common work patterns: ${activities.join(', ')}.`);
|
||||||
|
if (files.length) style.push(`Frequently touches ${files.join(', ')}.`);
|
||||||
|
return style.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferKnowledge(input: {
|
||||||
|
observations: EngineerContext[];
|
||||||
|
gitStates: EngineerStateDoc[];
|
||||||
|
notes: ConversationNoteDoc[];
|
||||||
|
}): string[] {
|
||||||
|
const topics = top(
|
||||||
|
[
|
||||||
|
...input.observations.map((obs) => obs.researchTopic ?? ''),
|
||||||
|
...input.observations.map((obs) => obs.researchSource ?? ''),
|
||||||
|
...input.observations.map((obs) => obs.currentSymbol ?? ''),
|
||||||
|
...input.gitStates.flatMap((state) => state.changedFiles ?? []),
|
||||||
|
...input.notes
|
||||||
|
.filter((note) => /learn|knows|expert|worked on|decision/i.test(note.note ?? ''))
|
||||||
|
.map((note) => note.note?.slice(0, 120) ?? ''),
|
||||||
|
].filter(Boolean),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return topics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildUserLearningProfile(clerkUserId: string): Promise<UserLearningProfile | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const contexts = await db
|
||||||
|
.collection<UserPodContextDoc>('user_pod_context')
|
||||||
|
.find({ clerkUserId }, { projection: { _id: 0 } })
|
||||||
|
.sort({ observedAt: -1 })
|
||||||
|
.limit(500)
|
||||||
|
.toArray();
|
||||||
|
if (!contexts.length) return null;
|
||||||
|
|
||||||
|
const latest = contexts[0];
|
||||||
|
const identities = uniq([
|
||||||
|
...contexts.map((ctx) => ctx.memberName),
|
||||||
|
...contexts.map((ctx) => clean(ctx.metadata?.identity)),
|
||||||
|
...contexts.map((ctx) => clean(ctx.metadata?.email)),
|
||||||
|
]);
|
||||||
|
const email = clean(latest?.metadata?.email) ?? identities.find((item) => item.includes('@'));
|
||||||
|
const displayName = latest?.memberName ?? identities.find((item) => !item.includes('@')) ?? email;
|
||||||
|
const imageUrl = clean(latest?.metadata?.imageUrl);
|
||||||
|
const identityRegex = lowerRegex(identities);
|
||||||
|
|
||||||
|
const podIds = uniq(contexts.map((ctx) => ctx.podId));
|
||||||
|
const [pods, observations, gitStates, collisions, outcomes, notes, jobs] = await Promise.all([
|
||||||
|
db
|
||||||
|
.collection<Pod>('pods')
|
||||||
|
.find({ id: { $in: podIds } }, { projection: { _id: 0 } })
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<EngineerContext>('observations')
|
||||||
|
.find({ engineerId: { $in: identities } }, { projection: { _id: 0, screenshotDataUrl: 0 } })
|
||||||
|
.sort({ observedAt: -1 })
|
||||||
|
.limit(500)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<EngineerStateDoc>('engineer_states')
|
||||||
|
.find({ name: { $in: identityRegex } }, { projection: { _id: 0 } })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<Collision>('collisions')
|
||||||
|
.find({ engineers: { $in: identities } }, { projection: { _id: 0, embedding: 0 } })
|
||||||
|
.sort({ detectedAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<{ podId: string; accepted?: boolean; wasRealCollision?: boolean }>('outcomes')
|
||||||
|
.find({ podId: { $in: podIds } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ recordedAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<ConversationNoteDoc>('conversation_notes')
|
||||||
|
.find({ identity: { $in: identities } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
db
|
||||||
|
.collection<HermesJob>('hermes_jobs')
|
||||||
|
.find({ identity: { $in: identities } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.toArray(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const podById = new Map(pods.map((pod) => [pod.id, pod]));
|
||||||
|
const podsSummary = podIds.map((podId) => {
|
||||||
|
const rows = contexts.filter((ctx) => ctx.podId === podId);
|
||||||
|
const sorted = [...rows].sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt));
|
||||||
|
return {
|
||||||
|
podId,
|
||||||
|
podName: podById.get(podId)?.name,
|
||||||
|
visits: rows.filter((row) => row.action === 'joined_pod').length,
|
||||||
|
actions: top(rows.map((row) => row.action), 6),
|
||||||
|
firstSeenAt: toIso(sorted[0]?.observedAt),
|
||||||
|
lastSeenAt: toIso(sorted.at(-1)?.observedAt),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile: UserLearningProfile = {
|
||||||
|
clerkUserId,
|
||||||
|
displayName,
|
||||||
|
email,
|
||||||
|
imageUrl,
|
||||||
|
identities,
|
||||||
|
pods: podsSummary,
|
||||||
|
recentWork: observations.slice(0, 20).map((obs) => ({
|
||||||
|
podId: obs.podId,
|
||||||
|
file: obs.currentFile,
|
||||||
|
activity: obs.activity ?? obs.researchTopic,
|
||||||
|
at: obs.observedAt,
|
||||||
|
})),
|
||||||
|
collaborationStyle: inferCollaborationStyle({ contexts, observations, collisions, outcomes, notes, jobs }),
|
||||||
|
workingStyle: inferWorkingStyle({ observations, gitStates }),
|
||||||
|
goals: noteGoals(notes),
|
||||||
|
knowledge: inferKnowledge({ observations, gitStates, notes }),
|
||||||
|
counts: {
|
||||||
|
podActions: contexts.length,
|
||||||
|
observations: observations.length,
|
||||||
|
gitStates: gitStates.length,
|
||||||
|
collisionsInvolved: collisions.length,
|
||||||
|
outcomes: outcomes.length,
|
||||||
|
conversationNotes: notes.length,
|
||||||
|
hermesJobs: jobs.length,
|
||||||
|
},
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db
|
||||||
|
.collection<UserLearningProfile>('user_learning_profiles')
|
||||||
|
.updateOne({ clerkUserId }, { $set: profile }, { upsert: true });
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshUserLearningProfiles(): Promise<UserLearningProfile[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const ids = await db.collection<UserPodContextDoc>('user_pod_context').distinct('clerkUserId');
|
||||||
|
const profiles = await Promise.all(ids.map((id) => buildUserLearningProfile(String(id))));
|
||||||
|
return profiles.filter(Boolean) as UserLearningProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserLearningProfileByIdentity(identity: string): Promise<UserLearningProfile | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const direct = await db
|
||||||
|
.collection<UserLearningProfile>('user_learning_profiles')
|
||||||
|
.findOne({ identities: { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } }, { projection: { _id: 0 } });
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const context = await db
|
||||||
|
.collection<UserPodContextDoc>('user_pod_context')
|
||||||
|
.findOne(
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ memberName: { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } },
|
||||||
|
{ 'metadata.identity': { $regex: `^${identity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ sort: { observedAt: -1 }, projection: { _id: 0 } },
|
||||||
|
);
|
||||||
|
return context ? buildUserLearningProfile(context.clerkUserId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserLearningProfiles(): Promise<UserLearningProfile[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db
|
||||||
|
.collection<UserLearningProfile>('user_learning_profiles')
|
||||||
|
.find({}, { projection: { _id: 0 } })
|
||||||
|
.sort({ updatedAt: -1 })
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ function memoryText(collision: Collision): string {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function cosine(a: number[], b: number[]): number {
|
export function cosine(a: number[], b: number[]): number {
|
||||||
const n = Math.min(a.length, b.length);
|
const n = Math.min(a.length, b.length);
|
||||||
let dot = 0;
|
let dot = 0;
|
||||||
let aNorm = 0;
|
let aNorm = 0;
|
||||||
@@ -69,6 +69,12 @@ async function embed(text: string, inputType: 'document' | 'query'): Promise<num
|
|||||||
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
|
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function semanticSimilarity(a: string, b: string): Promise<number | null> {
|
||||||
|
const [va, vb] = await Promise.all([embed(a, 'query'), embed(b, 'document')]);
|
||||||
|
if (!va || !vb) return null;
|
||||||
|
return cosine(va, vb);
|
||||||
|
}
|
||||||
|
|
||||||
async function embedWithVoyage(
|
async function embedWithVoyage(
|
||||||
text: string,
|
text: string,
|
||||||
inputType: 'document' | 'query',
|
inputType: 'document' | 'query',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Pod, PodInput } from '@podman/shared';
|
import type { Pod, PodInput } from '@podman/shared';
|
||||||
import { collections } from '../memory/db.js';
|
import { collections, getDb, type UserPodContextDoc } from '../memory/db.js';
|
||||||
|
|
||||||
const NO_ID = { projection: { _id: 0 } } as const;
|
const NO_ID = { projection: { _id: 0 } } as const;
|
||||||
|
|
||||||
@@ -59,14 +59,67 @@ function now(): string {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function regexExact(value: string): RegExp {
|
||||||
|
return new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileString(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesMember(doc: UserPodContextDoc, member: string): boolean {
|
||||||
|
const key = member.trim().toLowerCase();
|
||||||
|
return [doc.memberName, doc.metadata?.identity, doc.metadata?.email]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).trim().toLowerCase() === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateMemberProfiles(pod: Pod): Promise<Pod> {
|
||||||
|
if (!pod.members.length) return pod;
|
||||||
|
const db = await getDb();
|
||||||
|
const matchers = pod.members.map(regexExact);
|
||||||
|
const docs = await db
|
||||||
|
.collection<UserPodContextDoc>('user_pod_context')
|
||||||
|
.find(
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ memberName: { $in: matchers } },
|
||||||
|
{ 'metadata.identity': { $in: matchers } },
|
||||||
|
{ 'metadata.email': { $in: matchers } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ projection: { _id: 0 } },
|
||||||
|
)
|
||||||
|
.sort({ observedAt: -1 })
|
||||||
|
.limit(500)
|
||||||
|
.toArray();
|
||||||
|
const memberProfiles: NonNullable<Pod['memberProfiles']> = {};
|
||||||
|
for (const member of pod.members) {
|
||||||
|
const doc = docs.find((row) => matchesMember(row, member));
|
||||||
|
const imageUrl = profileString(doc?.metadata?.imageUrl);
|
||||||
|
if (!doc || !imageUrl) continue;
|
||||||
|
memberProfiles[member] = {
|
||||||
|
displayName: doc.memberName ?? member,
|
||||||
|
email: profileString(doc.metadata?.email),
|
||||||
|
imageUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Object.keys(memberProfiles).length ? { ...pod, memberProfiles } : pod;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydratePods(pods: Pod[]): Promise<Pod[]> {
|
||||||
|
return Promise.all(pods.map((pod) => hydrateMemberProfiles(pod)));
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPods(): Promise<Pod[]> {
|
export async function listPods(): Promise<Pod[]> {
|
||||||
const c = await collections();
|
const c = await collections();
|
||||||
return c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray();
|
return hydratePods(await c.pods.find({}, NO_ID).sort({ createdAt: 1 }).toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPod(id: string): Promise<Pod | null> {
|
export async function getPod(id: string): Promise<Pod | null> {
|
||||||
const c = await collections();
|
const c = await collections();
|
||||||
return c.pods.findOne({ id }, NO_ID);
|
const pod = await c.pods.findOne({ id }, NO_ID);
|
||||||
|
return pod ? hydrateMemberProfiles(pod) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPod(input: PodInput): Promise<Pod> {
|
export async function createPod(input: PodInput): Promise<Pod> {
|
||||||
@@ -92,7 +145,7 @@ export async function createPod(input: PodInput): Promise<Pod> {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await c.pods.insertOne({ ...pod });
|
await c.pods.insertOne({ ...pod });
|
||||||
return pod;
|
return hydrateMemberProfiles(pod);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDuplicateKey(err)) continue;
|
if (isDuplicateKey(err)) continue;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -119,7 +172,7 @@ export async function updatePod(id: string, patch: PodInput): Promise<Pod | null
|
|||||||
{ $set: set },
|
{ $set: set },
|
||||||
{ returnDocument: 'after', projection: { _id: 0 } },
|
{ returnDocument: 'after', projection: { _id: 0 } },
|
||||||
);
|
);
|
||||||
return updated ?? null;
|
return updated ? hydrateMemberProfiles(updated) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePod(id: string): Promise<boolean> {
|
export async function deletePod(id: string): Promise<boolean> {
|
||||||
@@ -135,7 +188,7 @@ async function setMembers(id: string, members: string[]): Promise<Pod | null> {
|
|||||||
{ $set: { members, updatedAt: now() } },
|
{ $set: { members, updatedAt: now() } },
|
||||||
{ returnDocument: 'after', projection: { _id: 0 } },
|
{ returnDocument: 'after', projection: { _id: 0 } },
|
||||||
);
|
);
|
||||||
return updated ?? null;
|
return updated ? hydrateMemberProfiles(updated) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMember(id: string, rawName: unknown): Promise<Pod | null> {
|
export async function addMember(id: string, rawName: unknown): Promise<Pod | null> {
|
||||||
|
|||||||
+91
-7
@@ -12,7 +12,9 @@ import {
|
|||||||
recordOutcome,
|
recordOutcome,
|
||||||
hasRecentInterventionForCollision,
|
hasRecentInterventionForCollision,
|
||||||
memoryStats,
|
memoryStats,
|
||||||
|
recordUserPodContext,
|
||||||
} from './memory/store.js';
|
} from './memory/store.js';
|
||||||
|
import { clerkAuthMiddleware, requestUser } from './auth.js';
|
||||||
import { closeMemory, initMemory } from './memory/db.js';
|
import { closeMemory, initMemory } from './memory/db.js';
|
||||||
import {
|
import {
|
||||||
listPods,
|
listPods,
|
||||||
@@ -40,6 +42,10 @@ import {
|
|||||||
getLiveConversationContext,
|
getLiveConversationContext,
|
||||||
recordLiveConversationNote,
|
recordLiveConversationNote,
|
||||||
} from './live-conversation/context.js';
|
} from './live-conversation/context.js';
|
||||||
|
import {
|
||||||
|
listUserLearningProfiles,
|
||||||
|
refreshUserLearningProfiles,
|
||||||
|
} from './memory/user-learning.js';
|
||||||
import {
|
import {
|
||||||
abortHermesJob,
|
abortHermesJob,
|
||||||
appendHermesJobEvent,
|
appendHermesJobEvent,
|
||||||
@@ -60,6 +66,7 @@ import type {
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(clerkAuthMiddleware);
|
||||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
function stringArray(value: unknown): string[] {
|
function stringArray(value: unknown): string[] {
|
||||||
@@ -72,6 +79,10 @@ function suggestedAction(value: unknown): SuggestedActionKind {
|
|||||||
: 'ping_teammate';
|
: 'ping_teammate';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringMeta(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
||||||
return value === 'accepted' ||
|
return value === 'accepted' ||
|
||||||
value === 'heartbeat' ||
|
value === 'heartbeat' ||
|
||||||
@@ -90,11 +101,16 @@ function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
|||||||
app.post('/api/token', async (req, res) => {
|
app.post('/api/token', async (req, res) => {
|
||||||
const { room, identity, name, githubLogin } = req.body ?? {};
|
const { room, identity, name, githubLogin } = req.body ?? {};
|
||||||
if (!room || !identity) return res.status(400).json({ error: 'room+identity required' });
|
if (!room || !identity) return res.status(400).json({ error: 'room+identity required' });
|
||||||
|
const user = requestUser(req);
|
||||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||||
identity,
|
identity,
|
||||||
name,
|
name,
|
||||||
ttl: '4h',
|
ttl: '4h',
|
||||||
metadata: JSON.stringify({ githubLogin: githubLogin ?? name }),
|
metadata: JSON.stringify({
|
||||||
|
githubLogin: githubLogin ?? name,
|
||||||
|
email: stringMeta(req.body?.profile?.email),
|
||||||
|
imageUrl: stringMeta(req.body?.profile?.imageUrl),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||||
const agents = env.LIVEKIT_AGENT_NAME
|
const agents = env.LIVEKIT_AGENT_NAME
|
||||||
@@ -113,6 +129,19 @@ app.post('/api/token', async (req, res) => {
|
|||||||
departureTimeout: 20,
|
departureTimeout: 20,
|
||||||
agents,
|
agents,
|
||||||
});
|
});
|
||||||
|
if (user) {
|
||||||
|
await recordUserPodContext({
|
||||||
|
clerkUserId: user.clerkUserId,
|
||||||
|
podId: String(room),
|
||||||
|
memberName: typeof name === 'string' ? name : String(identity),
|
||||||
|
action: 'joined_pod',
|
||||||
|
metadata: {
|
||||||
|
identity: String(identity),
|
||||||
|
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||||
|
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +179,22 @@ app.get('/api/memory/stats', async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/memory/users', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await listUserLearningProfiles());
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/memory/users/refresh', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ profiles: await refreshUserLearningProfiles() });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Live presence: who is currently connected in each pod's LiveKit room.
|
// Live presence: who is currently connected in each pod's LiveKit room.
|
||||||
app.get('/api/presence', async (_req, res) => {
|
app.get('/api/presence', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -170,7 +215,24 @@ app.get('/api/pods', async (_req, res) => {
|
|||||||
|
|
||||||
app.post('/api/pods', async (req, res) => {
|
app.post('/api/pods', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.status(201).json(await createPod(req.body ?? {}));
|
const pod = await createPod(req.body ?? {});
|
||||||
|
const user = requestUser(req);
|
||||||
|
if (user) {
|
||||||
|
await recordUserPodContext({
|
||||||
|
clerkUserId: user.clerkUserId,
|
||||||
|
podId: pod.id,
|
||||||
|
memberName: stringMeta(req.body?.profile?.displayName),
|
||||||
|
action: 'created_pod',
|
||||||
|
metadata: {
|
||||||
|
podName: pod.name,
|
||||||
|
repo: pod.repo,
|
||||||
|
identity: stringMeta(req.body?.profile?.displayName) ?? null,
|
||||||
|
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||||
|
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.status(201).json(await getPod(pod.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(400).json({ error: (e as Error).message });
|
res.status(400).json({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
@@ -186,6 +248,15 @@ app.patch('/api/pods/:id', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pod = await updatePod(req.params.id, req.body ?? {});
|
const pod = await updatePod(req.params.id, req.body ?? {});
|
||||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||||
|
const user = requestUser(req);
|
||||||
|
if (user) {
|
||||||
|
await recordUserPodContext({
|
||||||
|
clerkUserId: user.clerkUserId,
|
||||||
|
podId: pod.id,
|
||||||
|
action: 'updated_pod',
|
||||||
|
metadata: { podName: pod.name, repo: pod.repo },
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json(pod);
|
res.json(pod);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(400).json({ error: (e as Error).message });
|
res.status(400).json({ error: (e as Error).message });
|
||||||
@@ -203,7 +274,21 @@ app.post('/api/pods/:id/members', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pod = await addMember(req.params.id, req.body?.name ?? '');
|
const pod = await addMember(req.params.id, req.body?.name ?? '');
|
||||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||||
res.json(pod);
|
const user = requestUser(req);
|
||||||
|
if (user) {
|
||||||
|
await recordUserPodContext({
|
||||||
|
clerkUserId: user.clerkUserId,
|
||||||
|
podId: pod.id,
|
||||||
|
memberName: typeof req.body?.name === 'string' ? req.body.name.trim() : undefined,
|
||||||
|
action: 'added_member',
|
||||||
|
metadata: {
|
||||||
|
identity: typeof req.body?.name === 'string' ? req.body.name.trim() : null,
|
||||||
|
email: stringMeta(req.body?.profile?.email) ?? null,
|
||||||
|
imageUrl: stringMeta(req.body?.profile?.imageUrl) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json(await getPod(pod.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(400).json({ error: (e as Error).message });
|
res.status(400).json({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
@@ -306,15 +391,14 @@ app.post('/api/internal/pods/:id/live-conversation/:sessionId/note', async (req,
|
|||||||
const note = typeof req.body?.note === 'string' ? req.body.note : '';
|
const note = typeof req.body?.note === 'string' ? req.body.note : '';
|
||||||
const identity = typeof req.body?.identity === 'string' ? req.body.identity : undefined;
|
const identity = typeof req.body?.identity === 'string' ? req.body.identity : undefined;
|
||||||
const kind = typeof req.body?.kind === 'string' ? req.body.kind : undefined;
|
const kind = typeof req.body?.kind === 'string' ? req.body.kind : undefined;
|
||||||
res.status(201).json(
|
const saved = await recordLiveConversationNote({
|
||||||
await recordLiveConversationNote({
|
|
||||||
podId: req.params.id,
|
podId: req.params.id,
|
||||||
sessionId: req.params.sessionId,
|
sessionId: req.params.sessionId,
|
||||||
identity,
|
identity,
|
||||||
kind,
|
kind,
|
||||||
note,
|
note,
|
||||||
}),
|
});
|
||||||
);
|
res.status(201).json(saved);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(400).json({ error: (e as Error).message });
|
res.status(400).json({ error: (e as Error).message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
|||||||
const SCHEMA = {
|
const SCHEMA = {
|
||||||
type: Type.OBJECT,
|
type: Type.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
|
mode: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description:
|
||||||
|
'editing when an IDE/editor/terminal is primary; research for browser docs/SDK pages',
|
||||||
|
},
|
||||||
currentFile: {
|
currentFile: {
|
||||||
type: Type.STRING,
|
type: Type.STRING,
|
||||||
description: 'open file path if visible, e.g. src/auth/session.ts',
|
description: 'open file path if visible, e.g. src/auth/session.ts',
|
||||||
@@ -20,13 +25,24 @@ const SCHEMA = {
|
|||||||
type: Type.BOOLEAN,
|
type: Type.BOOLEAN,
|
||||||
description: 'dirty git gutter / modified markers visible',
|
description: 'dirty git gutter / modified markers visible',
|
||||||
},
|
},
|
||||||
|
researchTopic: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: 'topic being researched when mode is research, e.g. LiveKit agents setup',
|
||||||
|
},
|
||||||
|
researchSource: {
|
||||||
|
type: Type.STRING,
|
||||||
|
description: 'source domain when mode is research, e.g. docs.livekit.io',
|
||||||
|
},
|
||||||
confidence: { type: Type.NUMBER, description: '0..1 confidence in this read' },
|
confidence: { type: Type.NUMBER, description: '0..1 confidence in this read' },
|
||||||
},
|
},
|
||||||
propertyOrdering: [
|
propertyOrdering: [
|
||||||
|
'mode',
|
||||||
'currentFile',
|
'currentFile',
|
||||||
'currentSymbol',
|
'currentSymbol',
|
||||||
'activity',
|
'activity',
|
||||||
'hasUnpushedChanges',
|
'hasUnpushedChanges',
|
||||||
|
'researchTopic',
|
||||||
|
'researchSource',
|
||||||
'confidence',
|
'confidence',
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
@@ -43,7 +59,10 @@ export async function analyzeFrame(
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
text: "You are PodMan watching an engineer's screen. Identify what file/symbol they are working on and whether there are uncommitted edits. JSON only.",
|
text:
|
||||||
|
"You are PodMan watching an engineer's screen. Return JSON only. " +
|
||||||
|
"If the primary window is an IDE/editor/terminal, set mode='editing' and identify the file, symbol, activity, and whether uncommitted edits are visible. " +
|
||||||
|
"If the primary window is a browser/docs/SDK/reference page, set mode='research', leave currentFile empty unless a file path is clearly visible, and extract researchTopic plus researchSource as the source domain.",
|
||||||
},
|
},
|
||||||
{ inlineData: { mimeType: 'image/jpeg', data: jpeg.toString('base64') } },
|
{ inlineData: { mimeType: 'image/jpeg', data: jpeg.toString('base64') } },
|
||||||
],
|
],
|
||||||
@@ -63,6 +82,9 @@ export async function analyzeFrame(
|
|||||||
currentFile: parsed.currentFile,
|
currentFile: parsed.currentFile,
|
||||||
currentSymbol: parsed.currentSymbol,
|
currentSymbol: parsed.currentSymbol,
|
||||||
activity: parsed.activity,
|
activity: parsed.activity,
|
||||||
|
mode: parsed.mode,
|
||||||
|
researchTopic: parsed.researchTopic,
|
||||||
|
researchSource: parsed.researchSource,
|
||||||
hasUnpushedChanges: parsed.hasUnpushedChanges,
|
hasUnpushedChanges: parsed.hasUnpushedChanges,
|
||||||
confidence: parsed.confidence ?? 0.5,
|
confidence: parsed.confidence ?? 0.5,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt: new Date().toISOString(),
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -98,6 +98,7 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
|
|||||||
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
||||||
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
||||||
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
||||||
|
| `suppressions` | `suppressed` activity beats — a dismissed signature recurred and PodMan stayed quiet (negative-feedback made visible; written at repeat time by the agent) |
|
||||||
|
|
||||||
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
||||||
|
|
||||||
|
|||||||
+133
-43
@@ -1,61 +1,136 @@
|
|||||||
# PodMan — 4-Minute Demo Script
|
# PodMan — Demo Scripts
|
||||||
|
|
||||||
**Theme:** Continual Learning. **Hard limit:** 4:00. Practice to land at 3:45.
|
**Theme:** Continual Learning. Two scripts below: a **1-minute live script**
|
||||||
|
(§ first) for showing real-time interventions fast with teammates, and the full
|
||||||
|
**4-minute script** for the complete story. Practice the 4-min to land at 3:45.
|
||||||
|
|
||||||
|
**The thesis (say this first, in either script):** AI is collapsing the cost of
|
||||||
|
*writing* code. More and more of every codebase is authored with AI assist — and
|
||||||
|
increasingly by **autonomous agents**. So the bottleneck of software engineering
|
||||||
|
is shifting away from engineering itself toward **organization, management, and
|
||||||
|
project coordination**. Pair a team with hundreds of agents all committing to the
|
||||||
|
same repo at once and it becomes **physically impossible for a human to track
|
||||||
|
progress or avoid stepping on someone else's work.** Code generation scaled;
|
||||||
|
human coordination did not. That gap is the new bottleneck.
|
||||||
|
|
||||||
**The one-line story:** writing code isn't the bottleneck anymore — *coordinating
|
**The one-line story:** writing code isn't the bottleneck anymore — *coordinating
|
||||||
who's writing what* is. PodMan is a pair programmer for the whole team: it watches
|
who (and what) is writing what* is. PodMan is a pair programmer for the whole
|
||||||
every member's work in real time, gives everyone live status without anyone having
|
team — humans **and** agents: it watches every actor's work in real time, gives
|
||||||
to interrupt anyone, and learns your team's dynamics so it nudges less and helps
|
everyone live status without anyone having to interrupt anyone, catches collisions
|
||||||
more over time.
|
before they land, and learns your team's dynamics so it nudges less and helps more
|
||||||
|
over time.
|
||||||
|
|
||||||
**The hook to land:** a "quick five-minute question" actually costs ~25 minutes of
|
**The hook to land:** a "quick five-minute question" actually costs ~25 minutes of
|
||||||
lost focus — for two people. PodMan removes the reason to ask. Multiply the saved
|
lost focus — for two people. Now multiply that across a team plus a swarm of
|
||||||
recovery time across every teammate, every day, and that is the value.
|
agents nobody can watch. PodMan removes the reason to ask and surfaces the
|
||||||
|
collision no human could have caught in time. That recovery time, saved across
|
||||||
|
every actor, every day, is the value.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 1-Minute Live Script (you + teammates — real-time interventions)
|
||||||
|
|
||||||
|
**Hard limit 1:00.** Hits the tracks: real-time multimodal, on-device Gemma,
|
||||||
|
Continual Learning, Self-Improvement, Recursive Intelligence.
|
||||||
|
|
||||||
|
**Pre-flight:** pod open on `podman.live`, all joined, screen share + **sound on**;
|
||||||
|
git watchers running; everyone has `README.md` open. On-device **Gemma** must be
|
||||||
|
up as Hermes's voice (else Gemini TTS auto-falls-back — just drop the "on-device"
|
||||||
|
word).
|
||||||
|
|
||||||
|
**0:00–0:12 — Thesis**
|
||||||
|
> "Code is almost free to write now — humans with AI, soon swarms of agents, all
|
||||||
|
> on one repo. No human can track that. The bottleneck is coordination — and it
|
||||||
|
> has to fix itself, live. Watch."
|
||||||
|
|
||||||
|
**0:12–0:35 — Collision caught live (money shot)**
|
||||||
|
- You + a teammate both edit `README.md`, save (unpushed).
|
||||||
|
- Card appears on every screen + voice fires: _"<you> and <teammate> are both
|
||||||
|
editing README.md. Sync before pushing."_
|
||||||
|
- Say: "Gemini Vision reads our screens, fused with local git, in real time —
|
||||||
|
and the alert is **spoken by a Gemma model on-device**. Neither of us asked."
|
||||||
|
|
||||||
|
**0:35–0:52 — Self-improving (narrate)**
|
||||||
|
- Third teammate edits too → **new** card + voice for the new pair (never goes
|
||||||
|
silent).
|
||||||
|
- Say: "Every trace lands in **Atlas**; vector search recalls past collisions,
|
||||||
|
repeats come back tagged **'Seen before'** and escalate — no retraining. It
|
||||||
|
tunes its own coordination from its own outcomes."
|
||||||
|
|
||||||
|
**0:52–1:00 — Close**
|
||||||
|
> "Real-time awareness, on-device voice, a coordination layer that improves
|
||||||
|
> itself — for humans and agents, zero interruptions."
|
||||||
|
|
||||||
|
_Fallback:_ Gemma down → drop the on-device line. Voice silent → read it, point
|
||||||
|
at the card. No card → switch the pair to a fresh file and re-save.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## The script (4:00)
|
## The script (4:00)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 0:00–0:30 — The problem + hook
|
### 0:00–0:30 — The problem + hook
|
||||||
|
|
||||||
> "AI made writing code easy. The thing still slowing teams down is coordination
|
> "AI made writing code almost free — humans with AI today, swarms of autonomous
|
||||||
> — checking each other's work, re-planning collisions, and the constant 'what
|
> agents tomorrow, all committing to the same repo. The bottleneck stopped being
|
||||||
> are you working on?' A five-minute question really costs both people 25 minutes
|
> engineering and became coordination: checking each other's work, re-planning
|
||||||
> of lost focus. PodMan is a pair programmer for the whole team: it watches
|
> collisions, the constant 'what are you working on?' At agent scale no human can
|
||||||
> everyone's work live, so anyone can see another's status without interrupting
|
> even track it. A five-minute question already costs both people 25 minutes of
|
||||||
> them — and it learns your team as it goes."
|
> lost focus. PodMan is a pair programmer for the whole team — humans and agents:
|
||||||
|
> it watches everyone's work live, so anyone sees another's status without
|
||||||
|
> interrupting them, catches collisions before they land, and learns your team as
|
||||||
|
> it goes."
|
||||||
|
|
||||||
*On screen:* the pod view, two teammates joined, screen-share tiles live.
|
*On screen:* the pod view, two teammates joined, screen-share tiles live.
|
||||||
|
|
||||||
### 0:30–1:05 — Real-time team awareness (LiveKit + Gemini Vision)
|
### 0:30–1:05 — Real-time team awareness (LiveKit + Gemini Vision)
|
||||||
|
|
||||||
- Point at the two live screen tiles. "These are real screen shares over
|
- Point at the two live screen tiles. "These are real screen shares over
|
||||||
**LiveKit**. Our agent subscribes to the tracks and samples frames."
|
**LiveKit**. Our agent subscribes to the tracks and samples frames."
|
||||||
- "Each frame goes to **Gemini Vision**, which returns structured context — file,
|
- "Each frame goes to **Gemini Vision**, which returns structured context — file,
|
||||||
symbol, activity — not a chatbot, a perception layer."
|
symbol, activity — not a chatbot, a perception layer."
|
||||||
- Show the live activity stream filling in (Signals vs Reasoning sections).
|
- Show the live activity stream filling in (Signals vs Reasoning sections).
|
||||||
- Land the value: "This is the part that replaces 'what are you working on?' —
|
- Land the value: "This is the part that replaces 'what are you working on?' —
|
||||||
every teammate's current work is just *visible*, in real time. Nobody had to
|
every teammate's current work is just *visible*, in real time. Nobody had to
|
||||||
ask."
|
ask."
|
||||||
|
|
||||||
*Built-by-us callout:* `backend/src/vision/gemini.ts`, the LiveKit agent worker.
|
*Built-by-us callout:* `backend/src/vision/gemini.ts`, the LiveKit agent worker.
|
||||||
|
|
||||||
### 1:05–1:50 — The catch (detection + first intervention)
|
### 1:05–1:40 — The catch (detection + first intervention)
|
||||||
|
|
||||||
- Have alice and bob both edit the **same file** with unpushed changes.
|
- Have alice and bob both edit the **same file** with unpushed changes.
|
||||||
- "Normally nobody notices until merge time. GitHub can't see this — nothing's
|
- "Normally nobody notices until merge time. GitHub can't see this — nothing's
|
||||||
pushed. Our detector fuses live screen context with **local git truth** from a
|
pushed. Our detector fuses live screen context with **local git truth** from a
|
||||||
watcher on each laptop."
|
watcher on each laptop."
|
||||||
- A collision card appears: *"alice + bob both on detector.ts (unpushed)."*
|
- A collision card appears: *"alice + bob both on detector.ts (unpushed)."*
|
||||||
- Let the **Gemini TTS** urgent voice fire once over LiveKit: *"alice and bob are
|
- Let the **Gemini TTS** urgent voice fire once over LiveKit: *"alice and bob are
|
||||||
both editing detector.ts. Please sync before pushing."*
|
both editing detector.ts. Please sync before pushing."*
|
||||||
- Land the value: "That's a merge conflict and a wasted afternoon caught before it
|
- Land the value: "That's a merge conflict and a wasted afternoon caught before it
|
||||||
happened — and neither of them had to be tracking the other."
|
happened — and neither of them had to be tracking the other."
|
||||||
|
|
||||||
*Built-by-us callout:* `collision/detector.ts`, `action/hermes.ts`,
|
*Built-by-us callout:* `collision/detector.ts`, `action/hermes.ts`,
|
||||||
`voice/live.ts`.
|
`voice/live.ts`.
|
||||||
|
|
||||||
### 1:50–2:50 — Continual learning (the theme — the money shot)
|
### 1:40–2:10 — Cross-channel overlap (research + code)
|
||||||
|
|
||||||
|
- Keep alice editing `livekit.py`.
|
||||||
|
- Have bob share a browser tab on LiveKit docs/SDK pages.
|
||||||
|
- A collaboration nudge appears: *"🤝 bob is researching LiveKit agents
|
||||||
|
(docs.livekit.io) while alice edits livekit.py — sync up before duplicating
|
||||||
|
effort."*
|
||||||
|
- Land the value: "This is not a merge conflict. PodMan caught duplicated effort
|
||||||
|
across channels — code on one screen, research on another — and nudged the team
|
||||||
|
before two people solved the same problem twice."
|
||||||
|
|
||||||
|
*Built-by-us callout:* `vision/gemini.ts`, `collision/research.ts`,
|
||||||
|
`memory/vectors.ts`.
|
||||||
|
|
||||||
|
### 2:10–2:50 — Continual learning (the theme — the money shot)
|
||||||
|
|
||||||
This is the differentiator. Two beats, both from pre-seeded memory:
|
This is the differentiator. Two beats, both from pre-seeded memory:
|
||||||
|
|
||||||
@@ -67,8 +142,8 @@ This is the differentiator. Two beats, both from pre-seeded memory:
|
|||||||
the spoken urgent cue.
|
the spoken urgent cue.
|
||||||
|
|
||||||
- "The only input was one accept/dismiss tap. No retraining, no labeling. This is
|
- "The only input was one accept/dismiss tap. No retraining, no labeling. This is
|
||||||
**MongoDB Atlas vector search** recalling similar past events plus a policy
|
**MongoDB Atlas vector search** recalling similar past events plus a policy
|
||||||
that adapts on the recalled outcome."
|
that adapts on the recalled outcome."
|
||||||
- Optional: show `/api/memory/stats` counts climbing — accumulated experience.
|
- Optional: show `/api/memory/stats` counts climbing — accumulated experience.
|
||||||
|
|
||||||
*Built-by-us callout:* `memory/vectors.ts` ($vectorSearch), `memory/policy.ts`
|
*Built-by-us callout:* `memory/vectors.ts` ($vectorSearch), `memory/policy.ts`
|
||||||
@@ -77,65 +152,80 @@ This is the differentiator. Two beats, both from pre-seeded memory:
|
|||||||
### 2:50–3:30 — The five-minute meeting, killed (Gemini Live API)
|
### 2:50–3:30 — The five-minute meeting, killed (Gemini Live API)
|
||||||
|
|
||||||
- Frame it: "Instead of breaking a teammate's focus to ask what they're up to,
|
- Frame it: "Instead of breaking a teammate's focus to ask what they're up to,
|
||||||
you ask PodMan."
|
you ask PodMan."
|
||||||
- Open the live voice conversation. Ask out loud: *"PodMan, what is everyone
|
- Open the live voice conversation. Ask out loud: *"PodMan, what is everyone
|
||||||
working on, and where is the collision detector implemented?"*
|
working on, and where is the collision detector implemented?"*
|
||||||
- It answers with **real tool calls** — `search_repo`, git history, current
|
- It answers with **real tool calls** — `search_repo`, git history, current
|
||||||
collisions — not guesses.
|
collisions — not guesses.
|
||||||
- "This is the **Gemini Live API**, streaming speech-to-speech over LiveKit, with
|
- "This is the **Gemini Live API**, streaming speech-to-speech over LiveKit, with
|
||||||
custom function tools we wrote so it grounds every answer in the actual repo
|
custom function tools we wrote so it grounds every answer in the actual repo
|
||||||
and live state. That's the status sync, answered in seconds, with zero recovery
|
and live state. That's the status sync, answered in seconds, with zero recovery
|
||||||
tax on anyone else."
|
tax on anyone else."
|
||||||
|
|
||||||
*Built-by-us callout:* `agents/podman-live-conversation/agent.py`.
|
*Built-by-us callout:* `agents/podman-live-conversation/agent.py`.
|
||||||
|
|
||||||
### 3:30–3:50 — Stack + close
|
### 3:30–3:50 — Stack + close
|
||||||
|
|
||||||
- "All on **DigitalOcean** — static frontend, API, and agent workers, supervised
|
- "All on **DigitalOcean** — static frontend, API, and agent workers, supervised
|
||||||
by systemd. The ambient score is **Gemini Lyria** generated per pod through the
|
by systemd. The ambient score is **Gemini Lyria** generated per pod through the
|
||||||
Interactions API."
|
Interactions API."
|
||||||
- Close: "Engineering ability stopped being the bottleneck — coordination is.
|
- Close: "Engineering ability stopped being the bottleneck — coordination is, and
|
||||||
PodMan gives a whole team real-time awareness without the interruptions, catches
|
it only gets worse as agents start writing alongside us. PodMan gives a whole
|
||||||
collisions before they cost an afternoon, and learns each team's dynamics so it
|
team, humans and agents, real-time awareness without the interruptions, catches
|
||||||
helps more over time. Saved focus, multiplied across every teammate. That's
|
collisions before they cost an afternoon, and learns each team's dynamics so it
|
||||||
continual learning, shipped."
|
helps more over time. Saved focus, multiplied across every actor. That's
|
||||||
|
continual learning, shipped."
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 3:50–4:00 — Buffer / Q&A handoff
|
### 3:50–4:00 — Buffer / Q&A handoff
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Sponsor-prize coverage (say each at least once)
|
## Sponsor-prize coverage (say each at least once)
|
||||||
|
|
||||||
|
|
||||||
| Prize | Spoken moment | Segment |
|
| Prize | Spoken moment | Segment |
|
||||||
| --- | --- | --- |
|
| ---------------- | -------------------------------------------------------------------------------- | ---------------------- |
|
||||||
| **Gemini** | Vision perception, Live API agent w/ tools, TTS voice, Lyria score | 0:30, 1:05, 2:50, 3:30 |
|
| **Gemini** | Vision perception, Live API agent w/ tools, TTS voice, Lyria score | 0:30, 1:05, 2:50, 3:30 |
|
||||||
| **LiveKit** | "real screen shares over LiveKit", agent subscribes, TTS audio track, live voice | 0:30, 1:05, 3:30 |
|
| **LiveKit** | "real screen shares over LiveKit", agent subscribes, TTS audio track, live voice | 0:30, 1:05, 3:30 |
|
||||||
| **MongoDB** | "Atlas vector search recalling past events" | 1:50 |
|
| **MongoDB** | "Atlas vector search recalling past events" | 1:50 |
|
||||||
| **DigitalOcean** | "all on DigitalOcean, systemd-supervised workers" | 3:30 |
|
| **DigitalOcean** | "all on DigitalOcean, systemd-supervised workers" | 3:30 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## If something breaks (live recovery)
|
## If something breaks (live recovery)
|
||||||
|
|
||||||
|
|
||||||
| Failure | Recovery |
|
| Failure | Recovery |
|
||||||
| --- | --- |
|
| ----------------------- | ----------------------------------------------------------------------- |
|
||||||
| Voice doesn't fire | Cut to the card; say the line aloud; cards are the default path anyway. |
|
| Voice doesn't fire | Cut to the card; say the line aloud; cards are the default path anyway. |
|
||||||
| Live conversation drops | Skip 2:50–3:30; lean longer on the learning beat. |
|
| Live conversation drops | Skip 2:50–3:30; lean longer on the learning beat. |
|
||||||
| Collision won't trigger | Use the backup recording for that beat; keep narrating. |
|
| Collision won't trigger | Use the backup recording for that beat; keep narrating. |
|
||||||
| Agent flapping | Pre-checked — but if so, `systemctl restart podman-platform-agent`. |
|
| Agent flapping | Pre-checked — but if so, `systemctl restart podman-platform-agent`. |
|
||||||
|
|
||||||
|
|
||||||
**Rule:** never debug on stage. Narrate, fall back to recording, keep moving.
|
**Rule:** never debug on stage. Narrate, fall back to recording, keep moving.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Tight timing summary
|
## Tight timing summary
|
||||||
|
|
||||||
|
|
||||||
| Time | Beat |
|
| Time | Beat |
|
||||||
| --- | --- |
|
| ---- | ---------------------------------------------------------- |
|
||||||
| 0:00 | Problem (coordination cost) + hook + original-work line |
|
| 0:00 | Problem (coordination cost) + hook + original-work line |
|
||||||
| 0:30 | Real-time team awareness — LiveKit + Gemini Vision |
|
| 0:30 | Real-time team awareness — LiveKit + Gemini Vision |
|
||||||
| 1:05 | The catch — collision caught before merge |
|
| 1:05 | The catch — collision caught before merge |
|
||||||
| 1:50 | **Continual learning — quiet + escalate** |
|
| 1:40 | Cross-channel overlap — research + code nudge |
|
||||||
|
| 2:10 | **Continual learning — quiet + escalate** |
|
||||||
| 2:50 | The five-minute meeting, killed — Gemini Live conversation |
|
| 2:50 | The five-minute meeting, killed — Gemini Live conversation |
|
||||||
| 3:30 | DigitalOcean + Lyria + close |
|
| 3:30 | DigitalOcean + Lyria + close |
|
||||||
| 3:50 | Buffer |
|
| 3:50 | Buffer |
|
||||||
|
|||||||
@@ -33,14 +33,24 @@ screen-share track (not an HTTP upload — frames arrive over LiveKit).
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
|
mode: 'editing' | 'research', // browser/docs/SDK research vs editor work
|
||||||
currentFile: string, // open file path, e.g. src/auth/session.ts
|
currentFile: string, // open file path, e.g. src/auth/session.ts
|
||||||
currentSymbol: string, // function/class under the cursor
|
currentSymbol: string, // function/class under the cursor
|
||||||
activity: string, // editing | reading | debugging | terminal | PR review
|
activity: string, // editing | reading | debugging | terminal | PR review
|
||||||
hasUnpushedChanges: boolean, // dirty git gutter / modified markers visible
|
hasUnpushedChanges: boolean, // dirty git gutter / modified markers visible
|
||||||
|
researchTopic: string, // e.g. "LiveKit agents setup", for research mode
|
||||||
|
researchSource: string, // source domain, e.g. "docs.livekit.io"
|
||||||
confidence: number // 0..1
|
confidence: number // 0..1
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When a frame shows a browser/docs/SDK page instead of an editor, Gemini Vision
|
||||||
|
classifies it as `mode: "research"` and extracts the topic/source. That feeds the
|
||||||
|
cross-channel overlap detector: one teammate researching LiveKit docs while
|
||||||
|
another edits `livekit.py` becomes a collaboration nudge, not a merge-conflict
|
||||||
|
alert. Editor/IDE frames remain `mode: "editing"` and use the existing file,
|
||||||
|
symbol, activity, and dirty-change fields.
|
||||||
|
|
||||||
**Latency/cost levers (in code):**
|
**Latency/cost levers (in code):**
|
||||||
|
|
||||||
- `thinkingConfig: { thinkingBudget: 0 }` — minimal thinking for the ambient loop.
|
- `thinkingConfig: { thinkingBudget: 0 }` — minimal thinking for the ambient loop.
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ intervention that fits:
|
|||||||
(`publishHermesIntervention` / `publishHermesMessage`). Default path.
|
(`publishHermesIntervention` / `publishHermesMessage`). Default path.
|
||||||
- **Urgent voice** — only for `critical` collisions. `speak()` generates Gemini
|
- **Urgent voice** — only for `critical` collisions. `speak()` generates Gemini
|
||||||
TTS audio and publishes it as a LiveKit audio track.
|
TTS audio and publishes it as a LiveKit audio track.
|
||||||
|
- **Research overlap nudge** — a collaboration card when one engineer is editing
|
||||||
|
a file while another is researching the same topic in docs/browser context.
|
||||||
|
This uses `suggestedAction.kind = "ping_teammate"` and is spoken once for the
|
||||||
|
demo beat, but it is explicitly **not** a merge conflict.
|
||||||
|
|
||||||
Intervention text is short and deterministic (template, not an LLM call):
|
Intervention text is short and deterministic (template, not an LLM call):
|
||||||
`Conflict: alice + bob both on detector.ts (unpushed). Seen before.` The spoken
|
`Conflict: alice + bob both on detector.ts (unpushed). Seen before.` The spoken
|
||||||
@@ -33,6 +37,9 @@ line is phrased for natural TTS prosody. Each intervention is persisted to the
|
|||||||
`interventions` collection; the teammate's accept/dismiss returns via
|
`interventions` collection; the teammate's accept/dismiss returns via
|
||||||
`POST /api/outcome`.
|
`POST /api/outcome`.
|
||||||
|
|
||||||
|
Research-overlap text is also deterministic:
|
||||||
|
`🤝 bob is researching LiveKit agents (docs.livekit.io) while alice edits livekit.py — sync up before duplicating effort.`
|
||||||
|
|
||||||
A per-pod cooldown (`NUDGE_COOLDOWN_MS`, default 3 min) and a single-shot
|
A per-pod cooldown (`NUDGE_COOLDOWN_MS`, default 3 min) and a single-shot
|
||||||
"active conflict" guard prevent repeat nagging; a conflict re-arms once it
|
"active conflict" guard prevent repeat nagging; a conflict re-arms once it
|
||||||
resolves.
|
resolves.
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ Key fields:
|
|||||||
- `symbol`
|
- `symbol`
|
||||||
- `engineers`
|
- `engineers`
|
||||||
- `severity`
|
- `severity`
|
||||||
|
- `overlapKind` — optional; `file`/undefined for same-file collisions,
|
||||||
|
`research` for code-edit ↔ research overlaps.
|
||||||
|
- `researchTopic`, `researchSource`, `researcher`, `editor` — optional fields
|
||||||
|
present only for research overlaps.
|
||||||
- `memorySignature`
|
- `memorySignature`
|
||||||
- `githubState`
|
- `githubState`
|
||||||
- `detectedAt`
|
- `detectedAt`
|
||||||
@@ -117,6 +121,28 @@ Key fields:
|
|||||||
Primary use: accepted and dismissed outcomes drive exact recall, suppression,
|
Primary use: accepted and dismissed outcomes drive exact recall, suppression,
|
||||||
and learned graph paths.
|
and learned graph paths.
|
||||||
|
|
||||||
|
### `suppressions`
|
||||||
|
|
||||||
|
Durable negative-feedback proof: one record per *suppressed repeat* — a
|
||||||
|
previously-dismissed collision signature recurred and PodMan stayed quiet.
|
||||||
|
Written at repeat time by `backend/src/agent/podman.ts` (once per recurrence,
|
||||||
|
re-armed on resolution), materialized as `suppressed` activity by
|
||||||
|
`backend/src/graph/live.ts`.
|
||||||
|
|
||||||
|
Key fields:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `podId`
|
||||||
|
- `collisionId`
|
||||||
|
- `file`
|
||||||
|
- `engineers`
|
||||||
|
- `priorInterventionId` — the dismissed intervention this repeat matched
|
||||||
|
- `priorDismissedAt`
|
||||||
|
- `suppressedAt` — the repeat time (drives recency in the activity stream)
|
||||||
|
|
||||||
|
Index `{ podId: 1, suppressedAt: -1 }`. Counted in `/api/memory/stats`.
|
||||||
|
**Preserve in any DB cleanup** — this is visible learning evidence, not noise.
|
||||||
|
|
||||||
### `team_model`
|
### `team_model`
|
||||||
|
|
||||||
Durable per-pod summary memory.
|
Durable per-pod summary memory.
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Build spec: cross-channel overlap (code-edit ↔ research nudge)
|
||||||
|
|
||||||
|
> Self-contained spec for an implementing agent. Everything needed to build is
|
||||||
|
> here: current-state anchors, exact edits, code sketches, verification. Read
|
||||||
|
> the referenced files before editing.
|
||||||
|
|
||||||
|
## Context / why
|
||||||
|
|
||||||
|
Today PodMan only fires when **two engineers touch the same file** (basename
|
||||||
|
match) with unpushed changes — `backend/src/collision/detector.ts:36`. That makes
|
||||||
|
the system feel like a one-file trick. The wow we want: detect a **code↔research
|
||||||
|
overlap** — one teammate is *editing* `livekit.py` while another is *researching*
|
||||||
|
the same topic in a browser (LiveKit docs/SDK). PodMan nudges:
|
||||||
|
|
||||||
|
> 🤝 bob is deep in LiveKit docs while you edit livekit.py — sync up before duplicating effort.
|
||||||
|
|
||||||
|
This reframes PodMan from merge-conflict detector to a **team-coordination agent
|
||||||
|
that catches duplicated effort / knowledge overlap** — stronger continual-learning
|
||||||
|
story, distinct demo beat.
|
||||||
|
|
||||||
|
### Locked decisions
|
||||||
|
- **Signal capture:** Gemini vision on the existing LiveKit screenshare. When a
|
||||||
|
teammate shares a browser/docs window, vision classifies it `research` and
|
||||||
|
extracts `{researchTopic, researchSource}`. No browser extension, no new client
|
||||||
|
surface. Reuses `backend/src/agent.ts` + `backend/src/vision/gemini.ts`.
|
||||||
|
- **Matching:** semantic embeddings (reuse `embed()` + `cosine()` in
|
||||||
|
`backend/src/memory/vectors.ts`). Deterministic stem/keyword fallback fires when
|
||||||
|
an embed call returns `null`, so the demo path never depends on a live vector call.
|
||||||
|
- **Framing:** collaboration nudge (`ping_teammate`), spoken once for the beat.
|
||||||
|
|
||||||
|
## Current-state anchors (read these first)
|
||||||
|
|
||||||
|
- `backend/src/collision/detector.ts:14-20` — `fileKey()` stem/basename logic to mirror.
|
||||||
|
- `backend/src/collision/detector.ts:36-89` — `detectCollisions()` (same-file path; leave unchanged).
|
||||||
|
- `backend/src/agent/podman.ts:85-116` — `onScreenFrame()` orchestration; `:124-126` `conflictKey()`; `:128-183` `handle()`.
|
||||||
|
- `backend/src/agent/podman.ts:38-46` — `engineersOverlapOnFile()` (git ground-truth; research must skip it).
|
||||||
|
- `backend/src/vision/gemini.ts:7-70` — `SCHEMA`, prompt, and `EngineerContext` mapping.
|
||||||
|
- `backend/src/memory/vectors.ts:52-66` `cosine()`, `:68-70` private `embed()`.
|
||||||
|
- `backend/src/action/hermes.ts:46-62` — `publishHermesIntervention()` (speaks only if `voiceLine` passed).
|
||||||
|
- `frontend/src/livekit/useInterventions.ts:36-48` — data-channel handler (renders `intervention.message` verbatim).
|
||||||
|
- `shared/src/engineer.ts:6-23` `EngineerContext`; `shared/src/collision.ts:7-28` `Collision`.
|
||||||
|
|
||||||
|
**Off-spec gate:** nothing in `docs/` covers cross-channel overlap. Update specs
|
||||||
|
in step 1 **before** code (repo documentation-first rule).
|
||||||
|
|
||||||
|
## Concurrency / safety (several people build at once)
|
||||||
|
|
||||||
|
- Shared-contract edits are **additive optional fields only** — no signature
|
||||||
|
changes. Safe.
|
||||||
|
- `backend/src/agent/podman.ts` is hot: two in-place edits (`conflictKey`,
|
||||||
|
`handle` branch). `git pull --rebase` before pushing.
|
||||||
|
- **No required frontend change** — nudge text rides in `intervention.message`,
|
||||||
|
already rendered by `frontend/src/components/PodView.tsx`.
|
||||||
|
|
||||||
|
## Implementation steps
|
||||||
|
|
||||||
|
### 1. Specs first
|
||||||
|
- `docs/gemini.md` — vision classifies `editing` vs `research`; extracts `researchTopic`/`researchSource`.
|
||||||
|
- `docs/hermes.md` — new intervention type **research overlap** (collaboration nudge, `ping_teammate`, spoken once); explicitly NOT a merge conflict.
|
||||||
|
- `docs/mongodb.md` — new optional `Collision` fields.
|
||||||
|
- `docs/demo.md` — insert ~30s beat after the same-file collision.
|
||||||
|
|
||||||
|
### 2. Shared contract (additive, optional)
|
||||||
|
`shared/src/engineer.ts` — add to `EngineerContext`:
|
||||||
|
```ts
|
||||||
|
mode?: 'editing' | 'research';
|
||||||
|
researchTopic?: string;
|
||||||
|
researchSource?: string; // domain, e.g. "docs.livekit.io"
|
||||||
|
```
|
||||||
|
`shared/src/collision.ts` — add to `Collision`:
|
||||||
|
```ts
|
||||||
|
overlapKind?: 'file' | 'research'; // undefined = file (preserves current behavior)
|
||||||
|
researchTopic?: string;
|
||||||
|
researchSource?: string;
|
||||||
|
researcher?: string; // engineer doing research
|
||||||
|
editor?: string; // engineer editing the file
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Vision: classify editing vs research
|
||||||
|
`backend/src/vision/gemini.ts` — extend `SCHEMA` with `mode`, `researchTopic`,
|
||||||
|
`researchSource` (+ propertyOrdering). Update the prompt so a browser/docs/SDK
|
||||||
|
frame returns `mode:'research'` + topic + source domain, else `mode:'editing'`
|
||||||
|
with existing IDE fields. Map new fields into the returned `EngineerContext`.
|
||||||
|
Keep `thinkingConfig.thinkingBudget:0` and `MEDIA_RESOLUTION_LOW`.
|
||||||
|
|
||||||
|
### 4. Semantic matcher helper (reuse embed/cosine)
|
||||||
|
`backend/src/memory/vectors.ts` — add and export:
|
||||||
|
```ts
|
||||||
|
export async function semanticSimilarity(a: string, b: string): Promise<number | null> {
|
||||||
|
const [va, vb] = await Promise.all([embed(a, 'query'), embed(b, 'document')]);
|
||||||
|
if (!va || !vb) return null;
|
||||||
|
return cosine(va, vb);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. New detector (additive file)
|
||||||
|
`backend/src/env.ts` — add `RESEARCH_OVERLAP_THRESHOLD` (default `0.6`).
|
||||||
|
|
||||||
|
`backend/src/collision/research.ts` (new):
|
||||||
|
```ts
|
||||||
|
export interface ResearchOpts { similarity?: (a: string, b: string) => Promise<number | null>; threshold?: number; }
|
||||||
|
export async function detectResearchOverlaps(
|
||||||
|
contexts: EngineerContext[],
|
||||||
|
gitStates: Map<string, GitState> | undefined,
|
||||||
|
opts: ResearchOpts = {},
|
||||||
|
): Promise<Collision[]>;
|
||||||
|
```
|
||||||
|
Logic:
|
||||||
|
- **researchers** = contexts with `mode==='research'` && `researchTopic`.
|
||||||
|
- **editor files** = ground truth from `gitStates[*].changedFiles` (reliable) plus
|
||||||
|
any `mode==='editing'` `currentFile`; each tagged with its engineer.
|
||||||
|
- For each distinct (researcher, editor) pair on a file, score
|
||||||
|
`similarity("${topic} ${source}", "<file stem words> <symbol> <activity>")`
|
||||||
|
(default `similarity = semanticSimilarity`). Fire when `score >= threshold`
|
||||||
|
(default `RESEARCH_OVERLAP_THRESHOLD`).
|
||||||
|
- **Fallback:** if `score === null`, deterministic stem/token overlap (mirror
|
||||||
|
`fileKey()` stemming) — guarantees `livekit` ↔ `livekit.py` fires offline.
|
||||||
|
- Dedupe to best-scoring file per researcher; require distinct engineers.
|
||||||
|
- Emit `Collision`: `id: col_research_<stem>_<Date.now()>`, `engineers:[editor, researcher]`,
|
||||||
|
`file:<editor file>`, `severity:'warn'`, `overlapKind:'research'`, plus
|
||||||
|
`researcher`, `editor`, `researchTopic`, `researchSource`.
|
||||||
|
|
||||||
|
### 6. Wire into orchestrator — `backend/src/agent/podman.ts`
|
||||||
|
- In `onScreenFrame`, after `detectCollisions(...)` (line ~100):
|
||||||
|
```ts
|
||||||
|
const research = await detectResearchOverlaps([...this.contexts.values()], gitStates);
|
||||||
|
const collisions = [...fileCollisions, ...research];
|
||||||
|
```
|
||||||
|
(concat **before** the re-arm + handle loops at `:104` / `:111` / `:115`).
|
||||||
|
- `conflictKey()` (`:124`) — namespace by overlap kind so research and file
|
||||||
|
overlaps on the same file don't share an edge-trigger key:
|
||||||
|
```ts
|
||||||
|
return `${collision.overlapKind ?? 'file'}:${comparableBasename(collision.file)}`;
|
||||||
|
```
|
||||||
|
- `gitOverlap` loop (`:111-113`) — guard: only call `engineersOverlapOnFile` when
|
||||||
|
`collision.overlapKind !== 'research'` (researcher won't have the file dirty;
|
||||||
|
leave `gitOverlap` undefined for research).
|
||||||
|
- `handle()` (`:128`) — branch on `overlapKind === 'research'`:
|
||||||
|
- `message`: `` `🤝 ${researcher} is researching ${researchTopic}` + (researchSource ? ` (${researchSource})` : '') + ` while ${editor} edits ${shortFile} — sync up before duplicating effort.` ``
|
||||||
|
- `voiceLine`: `` `${researcher} is researching ${researchTopic} while ${editor} works on ${shortFile}. Worth a quick sync.` ``
|
||||||
|
- `suggestedAction.kind = 'ping_teammate'`.
|
||||||
|
- Pass `voiceLine` to `publishHermesIntervention` **regardless of severity** so
|
||||||
|
the beat is spoken once. (For file collisions keep existing
|
||||||
|
`severity === 'critical' ? voiceLine : undefined`.)
|
||||||
|
- Existing `recallSimilar` / `shouldIntervene` gate stays unchanged.
|
||||||
|
|
||||||
|
### 7. Frontend (nice-to-have, cut if behind)
|
||||||
|
`frontend/src/livekit/useInterventions.ts` — capture `msg.collision.overlapKind`;
|
||||||
|
show a 🤝 badge on the card in `PodView.tsx`. Core path needs nothing.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Build:** `pnpm -r build` (shared builds first — order matters).
|
||||||
|
2. **Detector (offline, deterministic):** harness calling `detectResearchOverlaps`
|
||||||
|
with stubbed `opts.similarity`:
|
||||||
|
- researcher `{mode:'research', researchTopic:'LiveKit agent init', researchSource:'docs.livekit.io'}`
|
||||||
|
+ gitState with `livekit.py` dirty for another engineer → exactly one
|
||||||
|
`overlapKind:'research'` collision naming both.
|
||||||
|
- single engineer both sides → no overlap.
|
||||||
|
- `similarity` returns `null` → keyword fallback still fires on `livekit`.
|
||||||
|
3. **Live:** run the agent (systemd on the box, or local); one participant shares a
|
||||||
|
browser tab on LiveKit docs, another keeps `livekit.py` dirty (git sidecar
|
||||||
|
running) → 🤝 nudge card + one-time spoken cue. Fires once (edge-triggered),
|
||||||
|
re-arms after the browser closes.
|
||||||
|
4. **Regression:** same-file collision still fires, unaffected (separate
|
||||||
|
`conflictKey` namespace).
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# Build spec: Work History "Coordination ROI" band
|
||||||
|
|
||||||
|
> Self-contained spec for an implementing agent. Everything needed to build is
|
||||||
|
> here: current-state anchors, exact edits, code sketches, verification. Read
|
||||||
|
> the referenced files before editing. Changes are **additive** — one optional
|
||||||
|
> field on a shared type, two new backend queries, one new presentational
|
||||||
|
> component. No route signature changes, no DB writes, no new deps.
|
||||||
|
|
||||||
|
## Context / why
|
||||||
|
|
||||||
|
The Work History dialog (opens when you click a teammate's "History" button)
|
||||||
|
today shows three raw counters (files / screen logs / git changes), a Recent
|
||||||
|
files bar list, and a Timeline. It is pure **activity volume** — it summarizes
|
||||||
|
nothing and shows none of PodMan's actual value.
|
||||||
|
|
||||||
|
We add a **Coordination ROI band** at the top of the dialog that answers "what
|
||||||
|
did PodMan save this person?": estimated rework-hours saved by clashes Hermes
|
||||||
|
caught early, plus hard defensible counts. This is a *summary* layer — it must
|
||||||
|
NOT re-stream the pod activity feed ("Team Memory"). Existing Recent files +
|
||||||
|
Timeline sections stay exactly as they are, below the new band.
|
||||||
|
|
||||||
|
### Locked decisions
|
||||||
|
- **Primary story = saved time / ROI.** One number leads: `~Xh Ym rework saved`.
|
||||||
|
- **Transparent heuristic.** The hours are a labeled estimate (`~`, `(est.)`)
|
||||||
|
with an info tooltip showing the per-clash breakdown. Technical judges can see
|
||||||
|
the model; we never claim precision.
|
||||||
|
- **Backend extension required** (collisions/interventions are not in the
|
||||||
|
current `MemberWorkHistory` payload). Field is optional → old frontends and
|
||||||
|
pods with no collisions degrade gracefully (band hidden).
|
||||||
|
- **Cut:** editing/research focus donut. ROI is the single story; a donut
|
||||||
|
dilutes it.
|
||||||
|
- **Credit split** across involved engineers so a 2-person clash does not
|
||||||
|
double-count the pod total.
|
||||||
|
|
||||||
|
## Heuristic (this is the contract — implement exactly)
|
||||||
|
|
||||||
|
For each `collisions` doc in the window where the member is involved AND an
|
||||||
|
`interventions` doc exists for it (Hermes actually surfaced it):
|
||||||
|
|
||||||
|
1. **Eligibility (must be "real"):** count the collision only if
|
||||||
|
`gitOverlap === true` OR `severity === 'critical'`. Otherwise skip.
|
||||||
|
2. **Weight by kind/severity:**
|
||||||
|
| condition | minutesEach |
|
||||||
|
|---|---|
|
||||||
|
| `overlapKind === 'research'` | 10 |
|
||||||
|
| `severity === 'critical'` (same-file) | 45 |
|
||||||
|
| `severity === 'warn'` (same-file) | 20 |
|
||||||
|
| `severity === 'info'` (same-file) | 10 |
|
||||||
|
(Evaluate research first; a research overlap is always 10 regardless of severity.)
|
||||||
|
3. **Member share:** `minutesEach / max(1, engineers.length)`. Sum all shares →
|
||||||
|
`savedMinutes` (round to nearest integer).
|
||||||
|
4. **breakdown[]:** group eligible collisions by label
|
||||||
|
(`"critical same-file"`, `"warn same-file"`, `"research overlap"`, etc.),
|
||||||
|
each entry `{ label, count, minutesEach }` — for the tooltip.
|
||||||
|
|
||||||
|
Member "involved" = `engineers` array contains the member (case-insensitive),
|
||||||
|
OR `researcher`/`editor` equals the member (case-insensitive). Reuse the
|
||||||
|
existing `sameMember()` helper logic.
|
||||||
|
|
||||||
|
Hard counts (no estimation):
|
||||||
|
- `clashesCaught` = number of eligible collisions (the integer behind the hours).
|
||||||
|
- `filesDeconflicted` = distinct `collision.file` values across eligible collisions.
|
||||||
|
- `conflictFreeCommits` / `totalCommits`: see "commits" note below.
|
||||||
|
|
||||||
|
### Commits note (keep simple, defensible)
|
||||||
|
There is no per-commit log in the window. Use the git ground-truth we have:
|
||||||
|
`totalCommits` = count of distinct `changedFiles` for the member from
|
||||||
|
`engineer_states` (already loaded as `gitState.changedFiles.length`).
|
||||||
|
`conflictFreeCommits` = `totalCommits - filesDeconflicted` (clamped ≥ 0). This
|
||||||
|
reads as "files in flight that never hit a clash". Label it in the UI as
|
||||||
|
**"conflict-free files"**, not commits, so the wording matches the data. (The
|
||||||
|
band copy below already says "files".)
|
||||||
|
|
||||||
|
## Current-state anchors (read these first)
|
||||||
|
|
||||||
|
- `shared/src/member-history.ts:24-36` — `MemberWorkHistory` interface (add `roi?`).
|
||||||
|
- `backend/src/activity/member-history.ts:73-174` — `getMemberWorkHistory()`.
|
||||||
|
- `:83-94` — the `Promise.all` that loads `observations` + `engineer_states`. Add collisions/interventions here.
|
||||||
|
- `:45-47` — `sameMember()` helper to reuse for involvement check.
|
||||||
|
- `:124` — `gitState` already in scope; `:169` uses `gitState.changedFiles.length`.
|
||||||
|
- `:161-173` — the returned object (add `roi`).
|
||||||
|
- `backend/src/memory/db.ts:47-48` — canonical collection names `collisions`, `interventions` (typed `Collision`, `Intervention`).
|
||||||
|
- `backend/src/activity/store.ts:167-178` — reference query shape for both collections.
|
||||||
|
- `shared/src/collision.ts:7-36` — `Collision` (`engineers`, `severity`, `overlapKind`, `gitOverlap`, `researcher`, `editor`, `file`, `detectedAt`).
|
||||||
|
- `shared/src/intervention.ts:8-18` — `Intervention` (`collisionId`, `podId`).
|
||||||
|
- `frontend/src/components/PodView.tsx:33-35` — type imports from `@podman/shared`.
|
||||||
|
- `frontend/src/components/PodView.tsx:1035-1041` — the `history && !loading && !error` block; the 3-stat grid is the insert point (band goes ABOVE it).
|
||||||
|
- `frontend/src/components/PodView.tsx:1093-1100` — `HistoryStat` (style reference for new sub-stats).
|
||||||
|
- `frontend/src/components/PodView.tsx` — `timeLabel()` exists for relative time; reuse if needed.
|
||||||
|
- Route is unchanged: `backend/src/server.ts:502-506` already returns whatever `getMemberWorkHistory` produces.
|
||||||
|
|
||||||
|
## Edit 1 — shared type (`shared/src/member-history.ts`)
|
||||||
|
|
||||||
|
Add to the `MemberWorkHistory` interface (after `timeline`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Coordination ROI summary — clashes Hermes caught for this member. Optional
|
||||||
|
* so pods with no collisions / older payloads render without the band. */
|
||||||
|
roi?: MemberWorkHistoryRoi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkHistoryRoi {
|
||||||
|
/** Estimated rework minutes saved (heuristic, labeled "~/est." in UI). */
|
||||||
|
savedMinutes: number;
|
||||||
|
/** Eligible collisions caught early (hard count). */
|
||||||
|
clashesCaught: number;
|
||||||
|
/** Distinct files that hit an eligible clash. */
|
||||||
|
filesDeconflicted: number;
|
||||||
|
/** Member files in flight that never hit a clash. */
|
||||||
|
conflictFreeFiles: number;
|
||||||
|
/** Total member files in flight (git changedFiles). */
|
||||||
|
totalFiles: number;
|
||||||
|
/** Per-kind breakdown for the tooltip. */
|
||||||
|
breakdown: { label: string; count: number; minutesEach: number }[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`shared/src/index.ts:56-59` already re-exports the member-history types via a
|
||||||
|
`export type { ... }` block — add `MemberWorkHistoryRoi` to that list.
|
||||||
|
|
||||||
|
## Edit 2 — backend (`backend/src/activity/member-history.ts`)
|
||||||
|
|
||||||
|
1. Import the types: `import type { Collision } from '@podman/shared';` (Intervention only needs `collisionId`, can stay untyped or import too).
|
||||||
|
2. In the `Promise.all` at `:83`, add two queries (windowed, podId-scoped):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
db.collection<Collision>('collisions')
|
||||||
|
.find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||||
|
.sort({ detectedAt: -1 })
|
||||||
|
.limit(200)
|
||||||
|
.toArray(),
|
||||||
|
db.collection<{ collisionId: string }>('interventions')
|
||||||
|
.find({ podId }, { projection: { collisionId: 1, _id: 0 } })
|
||||||
|
.toArray(),
|
||||||
|
```
|
||||||
|
|
||||||
|
3. After building `fileRows`, compute `roi` with a new local helper
|
||||||
|
`computeRoi(member, collisions, interventions, gitState)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function computeRoi(
|
||||||
|
member: string,
|
||||||
|
collisions: Collision[],
|
||||||
|
interventionCollisionIds: Set<string>,
|
||||||
|
changedFileCount: number,
|
||||||
|
): MemberWorkHistoryRoi {
|
||||||
|
const involved = (c: Collision) =>
|
||||||
|
c.engineers?.some((e) => sameMember(e, member)) ||
|
||||||
|
sameMember(c.researcher, member) ||
|
||||||
|
sameMember(c.editor, member);
|
||||||
|
|
||||||
|
const eligible = collisions.filter(
|
||||||
|
(c) =>
|
||||||
|
involved(c) &&
|
||||||
|
interventionCollisionIds.has(c.id) &&
|
||||||
|
(c.gitOverlap === true || c.severity === 'critical'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const weightOf = (c: Collision): { label: string; minutes: number } => {
|
||||||
|
if (c.overlapKind === 'research') return { label: 'research overlap', minutes: 10 };
|
||||||
|
if (c.severity === 'critical') return { label: 'critical same-file', minutes: 45 };
|
||||||
|
if (c.severity === 'warn') return { label: 'warn same-file', minutes: 20 };
|
||||||
|
return { label: 'info same-file', minutes: 10 };
|
||||||
|
};
|
||||||
|
|
||||||
|
let savedMinutes = 0;
|
||||||
|
const groups = new Map<string, { count: number; minutesEach: number }>();
|
||||||
|
for (const c of eligible) {
|
||||||
|
const { label, minutes } = weightOf(c);
|
||||||
|
savedMinutes += minutes / Math.max(1, c.engineers?.length ?? 1);
|
||||||
|
const g = groups.get(label) ?? { count: 0, minutesEach: minutes };
|
||||||
|
g.count += 1;
|
||||||
|
groups.set(label, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesDeconflicted = new Set(eligible.map((c) => c.file)).size;
|
||||||
|
const totalFiles = changedFileCount;
|
||||||
|
return {
|
||||||
|
savedMinutes: Math.round(savedMinutes),
|
||||||
|
clashesCaught: eligible.length,
|
||||||
|
filesDeconflicted,
|
||||||
|
conflictFreeFiles: Math.max(0, totalFiles - filesDeconflicted),
|
||||||
|
totalFiles,
|
||||||
|
breakdown: [...groups.entries()].map(([label, g]) => ({
|
||||||
|
label,
|
||||||
|
count: g.count,
|
||||||
|
minutesEach: g.minutesEach,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Build the intervention id set and attach to the return:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const interventionIds = new Set(interventions.map((i) => i.collisionId));
|
||||||
|
const roi = computeRoi(member, collisions, interventionIds, gitState?.changedFiles?.length ?? 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `roi` to the returned object at `:161-173`. Always return it (it self-zeroes
|
||||||
|
when there are no clashes); the frontend decides whether to show the band based
|
||||||
|
on `clashesCaught`/`savedMinutes`.
|
||||||
|
|
||||||
|
> Note `Collision.id` survives the `{ _id: 0 }` projection — `id` is a real field
|
||||||
|
> (`shared/src/collision.ts:8`), distinct from Mongo `_id`. Do not project it out.
|
||||||
|
|
||||||
|
## Edit 3 — frontend (`frontend/src/components/PodView.tsx`)
|
||||||
|
|
||||||
|
1. Add `MemberWorkHistoryRoi` to the `@podman/shared` type import (`:33-35`).
|
||||||
|
2. Insert `<RoiBand roi={history.roi} />` immediately inside the
|
||||||
|
`history && !loading && !error` block, BEFORE the `grid ... sm:grid-cols-3`
|
||||||
|
stat grid (`:1037`).
|
||||||
|
3. New presentational component (place near `HistoryStat`, `:1093`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function formatSaved(minutes: number): string {
|
||||||
|
if (minutes < 60) return `~${minutes}m`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m ? `~${h}h ${m}m` : `~${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoiBand({ roi }: { roi?: MemberWorkHistoryRoi }) {
|
||||||
|
if (!roi || roi.clashesCaught === 0) return null; // no clashes → no band
|
||||||
|
const conflictFree = roi.totalFiles
|
||||||
|
? Math.round((roi.conflictFreeFiles / roi.totalFiles) * 100)
|
||||||
|
: 100;
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-primary/5 p-4">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<p className="font-mono text-2xl font-semibold">{formatSaved(roi.savedMinutes)}</p>
|
||||||
|
<span className="text-sm text-muted-foreground">rework saved</span>
|
||||||
|
<RoiTooltip roi={roi} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
estimated · clashes caught pre-commit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-mono text-lg font-medium">{roi.clashesCaught}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">clashes caught early</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-primary" style={{ width: `${conflictFree}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[0.68rem] text-muted-foreground">
|
||||||
|
conflict-free: {roi.conflictFreeFiles} of {roi.totalFiles} files ·{' '}
|
||||||
|
{roi.filesDeconflicted} auto-deconflicted
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Tooltip — reuse the existing Tooltip primitives already imported in this file
|
||||||
|
(`Tooltip`, `TooltipTrigger`, `TooltipContent` — see the History button at
|
||||||
|
`:982-988`). `RoiTooltip` renders an `ⓘ`/`InfoIcon` trigger; content lists
|
||||||
|
`breakdown` rows as `{count} × {minutesEach}m {label}` plus a footer line
|
||||||
|
`"split across engineers · est. only"`. Keep it a few lines.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -r build # @podman/shared first, then backend + frontend typecheck
|
||||||
|
```
|
||||||
|
Manual / demo check (against a pod that has collisions, e.g. demo-pod):
|
||||||
|
1. Open the app, click a teammate who was in a clash → History.
|
||||||
|
2. Band shows at top: `~Xh Ym rework saved`, clashes-caught integer, conflict-free bar.
|
||||||
|
3. Hover `ⓘ` → breakdown rows match the heuristic table.
|
||||||
|
4. Click a teammate with **no** clashes → band absent, rest of dialog unchanged.
|
||||||
|
5. Existing Recent files + Timeline sections render unchanged below the band.
|
||||||
|
|
||||||
|
Quick data sanity (optional, on the box or via mongosh):
|
||||||
|
```js
|
||||||
|
db.collisions.find({ podId: "demo-pod" }).count() // > 0 for a real band
|
||||||
|
db.interventions.find({ podId: "demo-pod" }).count() // surfaced clashes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coordination / merge risk (team is concurrent)
|
||||||
|
|
||||||
|
- Touches two shared hot files: `shared/src/member-history.ts` and
|
||||||
|
`frontend/src/components/PodView.tsx`. Both edits are **additive** (one
|
||||||
|
optional field + one new component + one insert line). Low conflict risk, but
|
||||||
|
announce before pushing.
|
||||||
|
- `git pull --rebase origin main` immediately before push. Never force-push main.
|
||||||
|
- No changes to `/api/...` route signatures, no new deps, no Mongo writes.
|
||||||
|
|
||||||
|
## Deploy (after merge, on the box — see CLAUDE.md ops)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/podman && git pull && pnpm -r build
|
||||||
|
rm -rf /var/www/podman/* && cp -r frontend/dist/* /var/www/podman/
|
||||||
|
systemctl restart podman-platform-api podman-platform-agent
|
||||||
|
```
|
||||||
|
(Frontend-visible change + backend payload change → both the static build and
|
||||||
|
the API service must be refreshed.)
|
||||||
+39
-1
@@ -8,8 +8,10 @@
|
|||||||
<title>PodMan — the teammate that sees what git can’t</title>
|
<title>PodMan — the teammate that sees what git can’t</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="PodMan is an ambient AI teammate that watches your pod's screens in realtime and catches merge collisions before anyone pushes."
|
content="PodMan is an ambient AI teammate that watches your pod's screens in realtime and catches merge collisions before anyone pushes. Top 3 at the AI Engineer World's Fair 2026 Hackathon — built by Karti Tripathi, Ramis Hasanli, Yahya Alhinai, and Shakthi Bachala."
|
||||||
/>
|
/>
|
||||||
|
<meta name="author" content="Karti Tripathi, Ramis Hasanli, Yahya Alhinai, Shakthi Bachala" />
|
||||||
|
<link rel="canonical" href="https://podman.live/" />
|
||||||
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:site_name" content="PodMan" />
|
<meta property="og:site_name" content="PodMan" />
|
||||||
@@ -31,6 +33,42 @@
|
|||||||
/>
|
/>
|
||||||
<meta name="twitter:image" content="https://www.podman.live/og.png" />
|
<meta name="twitter:image" content="https://www.podman.live/og.png" />
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "PodMan",
|
||||||
|
"url": "https://podman.live",
|
||||||
|
"applicationCategory": "DeveloperApplication",
|
||||||
|
"operatingSystem": "Web",
|
||||||
|
"description": "An ambient AI teammate that watches your pod's screens in realtime and catches merge collisions, duplicated work, and missed handoffs before anyone pushes.",
|
||||||
|
"award": "Top 3 — AI Engineer World's Fair 2026 Hackathon (2nd overall of 95+ teams, LiveKit track winner, DigitalOcean Best Use)",
|
||||||
|
"author": [
|
||||||
|
{
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Karti Tripathi",
|
||||||
|
"url": "https://karti.ai",
|
||||||
|
"sameAs": "https://www.linkedin.com/in/kartitripathi/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Ramis Hasanli",
|
||||||
|
"sameAs": "https://www.linkedin.com/in/hasanliramis/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Yahya Alhinai",
|
||||||
|
"sameAs": "https://www.linkedin.com/in/yhinai/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Shakthi Bachala",
|
||||||
|
"sameAs": "https://www.linkedin.com/in/shakthi-bachala-09401510a/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Tear down any service worker we shipped earlier (vite-plugin-pwa).
|
// Tear down any service worker we shipped earlier (vite-plugin-pwa).
|
||||||
// PodMan runs no PWA in production: we deploy continuously and SW-driven
|
// PodMan runs no PWA in production: we deploy continuously and SW-driven
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"@clerk/react": "^6.11.1",
|
||||||
|
"@clerk/ui": "^1.23.0",
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
"@podman/shared": "workspace:*",
|
"@podman/shared": "workspace:*",
|
||||||
"@shadcn/react": "^0.1.0",
|
"@shadcn/react": "^0.1.0",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-resizable-panels": "^4.11.2",
|
"react-resizable-panels": "^4.11.2",
|
||||||
|
"react-router-dom": "^7.18.1",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
"shadcn": "^4.12.0",
|
"shadcn": "^4.12.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -1,7 +1,64 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
|
||||||
<rect width="32" height="32" rx="7" fill="#0b0f17" />
|
<title id="title">PodMan Logo Mark</title>
|
||||||
<circle cx="25" cy="6.6" r="2.4" fill="#34d399" />
|
<desc id="desc">A minimal rounded robot head with glowing eyes and a blue-violet antenna orb, optimized for white backgrounds.</desc>
|
||||||
<text x="14.5" y="24.5" text-anchor="middle"
|
<defs>
|
||||||
font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
<filter id="softShadow" x="44" y="90" width="424" height="360" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
font-weight="700" font-size="24" fill="#34d399">P</text>
|
<feDropShadow dx="0" dy="14" stdDeviation="20" flood-color="#94A3B8" flood-opacity="0.22"/>
|
||||||
|
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#C7D2FE" flood-opacity="0.20"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="eyeGlow" x="138" y="234" width="236" height="96" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||||
|
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.27 0 0 0 0 0.42 0 0 0 0 1 0 0 0 0.78 0" result="glow"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="glow"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="shell" x1="122" y1="144" x2="390" y2="420" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.56" stop-color="#F7FAFF"/>
|
||||||
|
<stop offset="1" stop-color="#EDF3FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="face" x1="154" y1="220" x2="358" y2="334" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#050A1F"/>
|
||||||
|
<stop offset="0.48" stop-color="#081038"/>
|
||||||
|
<stop offset="1" stop-color="#10237A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="orb" x1="218" y1="38" x2="292" y2="113" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#65D5FF"/>
|
||||||
|
<stop offset="0.46" stop-color="#6A65FF"/>
|
||||||
|
<stop offset="1" stop-color="#9A5CFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="eye" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256 280) rotate(90) scale(18)">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.55" stop-color="#F6FBFF"/>
|
||||||
|
<stop offset="1" stop-color="#C8DDFF"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="antenna" x1="256" y1="86" x2="256" y2="158" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#4F63FF"/>
|
||||||
|
<stop offset="1" stop-color="#1E3A8A"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- transparent background; tuned to read clearly on white surfaces -->
|
||||||
|
|
||||||
|
<!-- Antenna -->
|
||||||
|
<rect x="250" y="87" width="12" height="82" rx="6" fill="url(#antenna)"/>
|
||||||
|
<circle cx="256" cy="70" r="31" fill="url(#orb)"/>
|
||||||
|
<circle cx="244" cy="56" r="9" fill="white" opacity="0.38"/>
|
||||||
|
|
||||||
|
<!-- Head shell -->
|
||||||
|
<g filter="url(#softShadow)">
|
||||||
|
<rect x="96" y="146" width="320" height="260" rx="92" fill="url(#shell)" stroke="#DDE6F3" stroke-width="2"/>
|
||||||
|
<rect x="104" y="154" width="304" height="244" rx="84" stroke="white" stroke-opacity="0.84" stroke-width="12"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Face visor -->
|
||||||
|
<g filter="url(#eyeGlow)">
|
||||||
|
<rect x="146" y="226" width="220" height="104" rx="46" fill="url(#face)"/>
|
||||||
|
<circle cx="202" cy="278" r="18" fill="url(#eye)"/>
|
||||||
|
<circle cx="310" cy="278" r="18" fill="url(#eye)"/>
|
||||||
|
<circle cx="202" cy="278" r="8" fill="white" opacity="0.96"/>
|
||||||
|
<circle cx="310" cy="278" r="8" fill="white" opacity="0.96"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,64 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">PodMan Logo Mark</title>
|
||||||
|
<desc id="desc">A minimal rounded robot head with glowing eyes and a blue-violet antenna orb, optimized for white backgrounds.</desc>
|
||||||
|
<defs>
|
||||||
|
<filter id="softShadow" x="44" y="90" width="424" height="360" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feDropShadow dx="0" dy="14" stdDeviation="20" flood-color="#94A3B8" flood-opacity="0.22"/>
|
||||||
|
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#C7D2FE" flood-opacity="0.20"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="eyeGlow" x="138" y="234" width="236" height="96" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||||
|
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.27 0 0 0 0 0.42 0 0 0 0 1 0 0 0 0.78 0" result="glow"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="glow"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="shell" x1="122" y1="144" x2="390" y2="420" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.56" stop-color="#F7FAFF"/>
|
||||||
|
<stop offset="1" stop-color="#EDF3FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="face" x1="154" y1="220" x2="358" y2="334" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#050A1F"/>
|
||||||
|
<stop offset="0.48" stop-color="#081038"/>
|
||||||
|
<stop offset="1" stop-color="#10237A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="orb" x1="218" y1="38" x2="292" y2="113" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#65D5FF"/>
|
||||||
|
<stop offset="0.46" stop-color="#6A65FF"/>
|
||||||
|
<stop offset="1" stop-color="#9A5CFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="eye" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256 280) rotate(90) scale(18)">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.55" stop-color="#F6FBFF"/>
|
||||||
|
<stop offset="1" stop-color="#C8DDFF"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="antenna" x1="256" y1="86" x2="256" y2="158" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#4F63FF"/>
|
||||||
|
<stop offset="1" stop-color="#1E3A8A"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- transparent background; tuned to read clearly on white surfaces -->
|
||||||
|
|
||||||
|
<!-- Antenna -->
|
||||||
|
<rect x="250" y="87" width="12" height="82" rx="6" fill="url(#antenna)"/>
|
||||||
|
<circle cx="256" cy="70" r="31" fill="url(#orb)"/>
|
||||||
|
<circle cx="244" cy="56" r="9" fill="white" opacity="0.38"/>
|
||||||
|
|
||||||
|
<!-- Head shell -->
|
||||||
|
<g filter="url(#softShadow)">
|
||||||
|
<rect x="96" y="146" width="320" height="260" rx="92" fill="url(#shell)" stroke="#DDE6F3" stroke-width="2"/>
|
||||||
|
<rect x="104" y="154" width="304" height="244" rx="84" stroke="white" stroke-opacity="0.84" stroke-width="12"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Face visor -->
|
||||||
|
<g filter="url(#eyeGlow)">
|
||||||
|
<rect x="146" y="226" width="220" height="104" rx="46" fill="url(#face)"/>
|
||||||
|
<circle cx="202" cy="278" r="18" fill="url(#eye)"/>
|
||||||
|
<circle cx="310" cy="278" r="18" fill="url(#eye)"/>
|
||||||
|
<circle cx="202" cy="278" r="8" fill="white" opacity="0.96"/>
|
||||||
|
<circle cx="310" cy="278" r="8" fill="white" opacity="0.96"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
+143
-116
@@ -1,16 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { Room } from 'livekit-client';
|
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
Show,
|
||||||
BrainCircuitIcon,
|
SignUp,
|
||||||
CircleDotIcon,
|
SignInButton,
|
||||||
RadioTowerIcon,
|
SignUpButton,
|
||||||
RefreshCwIcon,
|
UserButton,
|
||||||
SparklesIcon,
|
useAuth,
|
||||||
UsersIcon,
|
useUser,
|
||||||
WifiIcon,
|
} from '@clerk/react';
|
||||||
ShieldCheckIcon,
|
import type { Room } from 'livekit-client';
|
||||||
} from 'lucide-react';
|
import { AlertCircleIcon, RefreshCwIcon, SparklesIcon } from 'lucide-react';
|
||||||
import type { Pod, PodInput } from '@podman/shared';
|
import type { Pod, PodInput } from '@podman/shared';
|
||||||
import { joinPod } from './lib/pod.js';
|
import { joinPod } from './lib/pod.js';
|
||||||
import * as api from './lib/api.js';
|
import * as api from './lib/api.js';
|
||||||
@@ -19,7 +18,6 @@ import { CreatePodForm } from './components/CreatePodForm.js';
|
|||||||
import { PodView } from './components/PodView.js';
|
import { PodView } from './components/PodView.js';
|
||||||
import { GraphView } from './components/GraphView.js';
|
import { GraphView } from './components/GraphView.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -40,7 +38,6 @@ import {
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
const SESSION_KEY = 'podman.session';
|
const SESSION_KEY = 'podman.session';
|
||||||
const fmt = new Intl.NumberFormat('en', { notation: 'compact' });
|
|
||||||
|
|
||||||
function pathPodId(): string | null {
|
function pathPodId(): string | null {
|
||||||
const [segment] = window.location.pathname.split('/').filter(Boolean);
|
const [segment] = window.location.pathname.split('/').filter(Boolean);
|
||||||
@@ -62,13 +59,18 @@ function replacePath(path: string): void {
|
|||||||
window.history.replaceState({}, '', path || '/');
|
window.history.replaceState({}, '', path || '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstNameFrom(value: string | null | undefined): string {
|
||||||
|
return value?.trim().split(/\s+/).filter(Boolean)[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { getToken, isLoaded, isSignedIn } = useAuth();
|
||||||
|
const { user } = useUser();
|
||||||
const [pods, setPods] = useState<Pod[]>([]);
|
const [pods, setPods] = useState<Pod[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [pending, setPending] = useState<Set<string>>(new Set());
|
const [pending, setPending] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [presence, setPresence] = useState<Record<string, string[]>>({});
|
const [presence, setPresence] = useState<Record<string, string[]>>({});
|
||||||
const [memory, setMemory] = useState<api.MemoryStats | null>(null);
|
|
||||||
const [joinedPodId, setJoinedPodId] = useState<string | null>(null);
|
const [joinedPodId, setJoinedPodId] = useState<string | null>(null);
|
||||||
const [member, setMember] = useState('');
|
const [member, setMember] = useState('');
|
||||||
const [devMode, setDevMode] = useState(false);
|
const [devMode, setDevMode] = useState(false);
|
||||||
@@ -77,18 +79,31 @@ export default function App() {
|
|||||||
const [graphPodId, setGraphPodId] = useState<string | null>(null);
|
const [graphPodId, setGraphPodId] = useState<string | null>(null);
|
||||||
|
|
||||||
const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null;
|
const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null;
|
||||||
|
const userEmail = user?.primaryEmailAddress?.emailAddress;
|
||||||
|
const defaultMemberName =
|
||||||
|
user?.firstName?.trim() ||
|
||||||
|
firstNameFrom(user?.fullName) ||
|
||||||
|
firstNameFrom(userEmail?.split('@')[0]);
|
||||||
|
const currentUserProfile = {
|
||||||
|
displayName: defaultMemberName,
|
||||||
|
email: userEmail,
|
||||||
|
imageUrl: user?.imageUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.setAuthTokenGetter(isSignedIn ? getToken : null);
|
||||||
|
return () => api.setAuthTokenGetter(null);
|
||||||
|
}, [getToken, isSignedIn]);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextPods, nextPresence, nextMemory] = await Promise.all([
|
const [nextPods, nextPresence] = await Promise.all([
|
||||||
api.listPods(),
|
api.listPods(),
|
||||||
api.getPresence().catch(() => presence),
|
api.getPresence().catch(() => presence),
|
||||||
api.getMemoryStats().catch(() => memory),
|
|
||||||
]);
|
]);
|
||||||
setPods(nextPods);
|
setPods(nextPods);
|
||||||
setPresence(nextPresence);
|
setPresence(nextPresence);
|
||||||
setMemory(nextMemory);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
@@ -109,7 +124,13 @@ export default function App() {
|
|||||||
const previousPath = window.location.pathname;
|
const previousPath = window.location.pathname;
|
||||||
setPodPath(podId, replaceRoute);
|
setPodPath(podId, replaceRoute);
|
||||||
try {
|
try {
|
||||||
const result = await joinPod(podId, who, who);
|
const result = await joinPod(
|
||||||
|
podId,
|
||||||
|
who,
|
||||||
|
who,
|
||||||
|
isSignedIn ? getToken : undefined,
|
||||||
|
currentUserProfile,
|
||||||
|
);
|
||||||
setRoom(result.room);
|
setRoom(result.room);
|
||||||
setDevMode(result.mode === 'dev');
|
setDevMode(result.mode === 'dev');
|
||||||
setMember(who);
|
setMember(who);
|
||||||
@@ -122,21 +143,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isSignedIn) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, [isSignedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (joinedPodId) return;
|
if (!isSignedIn || joinedPodId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
try {
|
try {
|
||||||
const [p, m] = await Promise.all([
|
const p = await api.getPresence();
|
||||||
api.getPresence(),
|
|
||||||
api.getMemoryStats().catch(() => memory),
|
|
||||||
]);
|
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setPresence(p);
|
setPresence(p);
|
||||||
setMemory(m);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* presence is best-effort */
|
/* presence is best-effort */
|
||||||
@@ -148,15 +169,13 @@ export default function App() {
|
|||||||
alive = false;
|
alive = false;
|
||||||
window.clearInterval(id);
|
window.clearInterval(id);
|
||||||
};
|
};
|
||||||
}, [joinedPodId]);
|
}, [isSignedIn, joinedPodId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isSignedIn) return;
|
||||||
const routedPodId = pathPodId();
|
const routedPodId = pathPodId();
|
||||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
if (routedPodId) {
|
|
||||||
setError(`Enter your name to join ${routedPodId}.`);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let saved: { podId: string; member: string };
|
let saved: { podId: string; member: string };
|
||||||
@@ -177,10 +196,11 @@ export default function App() {
|
|||||||
setRestoring(false);
|
setRestoring(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, [isSignedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPopState = () => {
|
const onPopState = () => {
|
||||||
|
if (!isSignedIn) return;
|
||||||
const routedPodId = pathPodId();
|
const routedPodId = pathPodId();
|
||||||
if (!routedPodId) {
|
if (!routedPodId) {
|
||||||
room?.disconnect();
|
room?.disconnect();
|
||||||
@@ -201,7 +221,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
window.addEventListener('popstate', onPopState);
|
window.addEventListener('popstate', onPopState);
|
||||||
return () => window.removeEventListener('popstate', onPopState);
|
return () => window.removeEventListener('popstate', onPopState);
|
||||||
}, [joinedPodId, room]);
|
}, [isSignedIn, joinedPodId, room]);
|
||||||
|
|
||||||
async function run(key: string, fn: () => Promise<void>) {
|
async function run(key: string, fn: () => Promise<void>) {
|
||||||
startPending(key);
|
startPending(key);
|
||||||
@@ -221,7 +241,7 @@ export default function App() {
|
|||||||
startPending('new');
|
startPending('new');
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const created = await api.createPod(input);
|
const created = await api.createPod(input, currentUserProfile);
|
||||||
setPods((cur) => [...cur, created]);
|
setPods((cur) => [...cur, created]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
@@ -240,7 +260,7 @@ export default function App() {
|
|||||||
if (pathPodId() === id) setHomePath();
|
if (pathPodId() === id) setHomePath();
|
||||||
});
|
});
|
||||||
const handleAddMember = (id: string, name: string) =>
|
const handleAddMember = (id: string, name: string) =>
|
||||||
run(id, async () => upsert(await api.addMember(id, name)));
|
run(id, async () => upsert(await api.addMember(id, name, currentUserProfile)));
|
||||||
const handleRemoveMember = (id: string, name: string) =>
|
const handleRemoveMember = (id: string, name: string) =>
|
||||||
run(id, async () => upsert(await api.removeMember(id, name)));
|
run(id, async () => upsert(await api.removeMember(id, name)));
|
||||||
|
|
||||||
@@ -256,12 +276,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddAndJoin(pod: Pod, name: string) {
|
async function handleAddAndJoin(pod: Pod) {
|
||||||
startPending(pod.id);
|
startPending(pod.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
upsert(await api.addMember(pod.id, name));
|
if (!defaultMemberName) {
|
||||||
await connectToPod(pod.id, name);
|
throw new Error('Sign in with Clerk before joining a pod.');
|
||||||
|
}
|
||||||
|
upsert(await api.addMember(pod.id, defaultMemberName, currentUserProfile));
|
||||||
|
await connectToPod(pod.id, defaultMemberName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -279,18 +302,29 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null);
|
const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null);
|
||||||
const liveNames = Array.from(new Set(Object.values(presence).flat()));
|
|
||||||
const liveTotal = liveNames.length;
|
if (!isLoaded) {
|
||||||
const totalMembers = pods.reduce((sum, p) => sum + p.members.length, 0);
|
return (
|
||||||
const activeRooms = Object.values(presence).filter((names) => names.length > 0).length;
|
<div className="grid min-h-screen place-items-center bg-background text-foreground">
|
||||||
const podManOnline = liveNames.some((name) => name.toLowerCase() === 'podman');
|
<Skeleton className="h-12 w-64" />
|
||||||
const latestActivity = memory
|
</div>
|
||||||
? memory.observations + memory.collisions + memory.interventions + memory.outcomes
|
);
|
||||||
: 0;
|
}
|
||||||
|
|
||||||
|
if (!isSignedIn) {
|
||||||
|
return <AuthGate />;
|
||||||
|
}
|
||||||
|
|
||||||
if (joinedPod) {
|
if (joinedPod) {
|
||||||
return (
|
return (
|
||||||
<PodView team={joinedPod} me={member} room={room} devMode={devMode} onLeave={handleLeave} />
|
<PodView
|
||||||
|
team={joinedPod}
|
||||||
|
me={member}
|
||||||
|
room={room}
|
||||||
|
devMode={devMode}
|
||||||
|
currentUserProfile={currentUserProfile}
|
||||||
|
onLeave={handleLeave}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,46 +335,38 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-6 px-4 py-4 sm:px-6 lg:px-8">
|
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-6 px-4 py-4 sm:px-6 lg:px-8">
|
||||||
<header className="sticky top-0 z-10 -mx-4 flex flex-col gap-5 border-b bg-background/86 px-4 pb-5 pt-2 backdrop-blur-xl sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
<header className="sticky top-0 z-10 -mx-4 border-b bg-background/86 px-4 pb-4 pt-2 backdrop-blur-xl sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<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">
|
<img
|
||||||
PM
|
src="/podman-logo.svg"
|
||||||
</div>
|
alt=""
|
||||||
|
className="size-11 shrink-0 rounded-lg"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<h1 className="text-[1.95rem] font-semibold leading-none tracking-tight">PodMan</h1>
|
||||||
<h1 className="text-[1.95rem] font-semibold leading-none tracking-tight">
|
|
||||||
PodMan
|
|
||||||
</h1>
|
|
||||||
<Badge variant={podManOnline ? 'default' : 'secondary'}>
|
|
||||||
<CircleDotIcon data-icon="inline-start" />
|
|
||||||
{podManOnline ? 'online' : 'standby'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Live engineering rooms, team memory, and intervention routing.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="h-8 rounded-lg px-3">
|
<Show when="signed-out">
|
||||||
<ShieldCheckIcon data-icon="inline-start" />
|
<SignInButton mode="modal">
|
||||||
Privacy-limited
|
<Button variant="outline">Sign in</Button>
|
||||||
</Badge>
|
</SignInButton>
|
||||||
|
<SignUpButton mode="modal">
|
||||||
|
<Button>Sign up</Button>
|
||||||
|
</SignUpButton>
|
||||||
|
</Show>
|
||||||
|
<Show when="signed-in">
|
||||||
|
<UserButton />
|
||||||
|
</Show>
|
||||||
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<StatPill icon={WifiIcon} label="Live" value={fmt.format(liveTotal)} />
|
|
||||||
<StatPill icon={RadioTowerIcon} label="Rooms" value={fmt.format(activeRooms)} />
|
|
||||||
<StatPill icon={UsersIcon} label="Roster" value={fmt.format(totalMembers)} />
|
|
||||||
<StatPill icon={BrainCircuitIcon} label="Memory" value={fmt.format(latestActivity)} />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -374,9 +400,6 @@ export default function App() {
|
|||||||
<p className="text-xs font-medium uppercase text-muted-foreground">Workspaces</p>
|
<p className="text-xs font-medium uppercase text-muted-foreground">Workspaces</p>
|
||||||
<h2 className="text-xl font-semibold tracking-tight">Active pods</h2>
|
<h2 className="text-xl font-semibold tracking-tight">Active pods</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-xl text-sm leading-6 text-muted-foreground">
|
|
||||||
Join the room that matches your current workstream.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -392,6 +415,7 @@ export default function App() {
|
|||||||
pod={pod}
|
pod={pod}
|
||||||
busy={pending.has(pod.id)}
|
busy={pending.has(pod.id)}
|
||||||
presence={presence[pod.id] ?? []}
|
presence={presence[pod.id] ?? []}
|
||||||
|
currentUserProfile={currentUserProfile}
|
||||||
onJoin={handleJoin}
|
onJoin={handleJoin}
|
||||||
onAddAndJoin={handleAddAndJoin}
|
onAddAndJoin={handleAddAndJoin}
|
||||||
onAddMember={handleAddMember}
|
onAddMember={handleAddMember}
|
||||||
@@ -412,29 +436,23 @@ export default function App() {
|
|||||||
<EmptyDescription>Create the first room for this team.</EmptyDescription>
|
<EmptyDescription>Create the first room for this team.</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
<EmptyContent>
|
<EmptyContent>
|
||||||
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} compact />
|
<CreatePodForm
|
||||||
|
busy={pending.has('new')}
|
||||||
|
defaultMemberName={defaultMemberName}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
</EmptyContent>
|
</EmptyContent>
|
||||||
</Empty>
|
</Empty>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="flex flex-col gap-4">
|
<aside className="flex flex-col gap-4">
|
||||||
<CreatePodForm busy={pending.has('new')} onCreate={handleCreate} />
|
<CreatePodForm
|
||||||
<Card>
|
busy={pending.has('new')}
|
||||||
<CardHeader>
|
defaultMemberName={defaultMemberName}
|
||||||
<CardTitle>Operating brief</CardTitle>
|
onCreate={handleCreate}
|
||||||
<CardDescription>Current coordination signals.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<BriefLine label="Notification default" value="Card" />
|
|
||||||
<BriefLine label="Escalation" value="Hermes voice only when urgent" />
|
|
||||||
<BriefLine label="Memory events" value={fmt.format(latestActivity)} />
|
|
||||||
<BriefLine
|
|
||||||
label="Live people"
|
|
||||||
value={liveNames.length ? liveNames.join(', ') : 'None'}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
)}
|
)}
|
||||||
@@ -443,33 +461,42 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatPill({
|
function AuthGate() {
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-16 items-center gap-3 rounded-lg border bg-card/90 px-3 py-2 shadow-sm">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<div className="grid size-8 place-items-center rounded-md bg-muted">
|
<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">
|
||||||
<Icon className="size-4 text-muted-foreground" />
|
<header className="flex items-center justify-between border-b pb-4 pt-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
src="/podman-logo.svg"
|
||||||
|
alt=""
|
||||||
|
className="size-11 shrink-0 rounded-lg"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h1 className="text-[1.95rem] font-semibold leading-none tracking-tight">PodMan</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<SignInButton mode="modal">
|
||||||
<p className="text-xs font-medium uppercase text-muted-foreground">{label}</p>
|
<Button variant="outline">Sign in</Button>
|
||||||
<p className="text-base font-medium">{value}</p>
|
</SignInButton>
|
||||||
</div>
|
</header>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BriefLine({ label, value }: { label: string; value: string }) {
|
<main className="grid flex-1 items-center gap-8 py-8 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
return (
|
<section className="max-w-xl">
|
||||||
<div className="flex items-start justify-between gap-4 rounded-md bg-muted/45 px-3 py-2">
|
<p className="text-xs font-medium uppercase text-muted-foreground">Team memory</p>
|
||||||
<span className="text-sm text-muted-foreground">{label}</span>
|
<h2 className="mt-2 text-3xl font-semibold tracking-tight">
|
||||||
<span className="max-w-44 text-right text-sm font-medium">{value}</span>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
TrophyIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
EyeIcon,
|
||||||
|
GitMergeIcon,
|
||||||
|
UsersIcon,
|
||||||
|
BellIcon,
|
||||||
|
RadioIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
PlayIcon,
|
||||||
|
MicIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public marketing landing page for podman.live.
|
||||||
|
* Rendered at "/" without ClerkProvider — needs no secrets.
|
||||||
|
* The app (private beta) lives at "/app".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEAM = [
|
||||||
|
{ name: 'Karti Tripathi', url: 'https://www.linkedin.com/in/kartitripathi/' },
|
||||||
|
{ name: 'Ramis Hasanli', url: 'https://www.linkedin.com/in/hasanliramis/' },
|
||||||
|
{ name: 'Yahya Alhinai', url: 'https://www.linkedin.com/in/yhinai/' },
|
||||||
|
{ name: 'Shakthi Bachala', url: 'https://www.linkedin.com/in/shakthi-bachala-09401510a/' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Landing() {
|
||||||
|
const scrollToStory = useCallback(() => {
|
||||||
|
document.getElementById('story')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen scroll-smooth bg-[#0b0f17] text-white/90 antialiased">
|
||||||
|
{/* ambient glows */}
|
||||||
|
<div className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -top-40 left-1/2 h-[38rem] w-[38rem] -translate-x-1/2 rounded-full bg-indigo-600/20 blur-[120px]" />
|
||||||
|
<div className="absolute top-1/3 -right-32 h-[26rem] w-[26rem] rounded-full bg-cyan-500/10 blur-[110px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* top nav */}
|
||||||
|
<header className="relative z-20 mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
|
||||||
|
<a href="#top" className="flex items-center gap-2.5">
|
||||||
|
<img src="/podman-logo.svg" alt="PodMan" className="h-7 w-7" />
|
||||||
|
<span className="text-[15px] font-semibold tracking-tight text-white">PodMan</span>
|
||||||
|
</a>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-1.5 text-sm font-medium text-white/70">
|
||||||
|
<LiveDot />
|
||||||
|
Beta soon
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* hero */}
|
||||||
|
<section id="top" className="relative z-10 mx-auto flex max-w-3xl flex-col items-center px-6 pt-16 pb-24 text-center sm:pt-24">
|
||||||
|
<button
|
||||||
|
onClick={scrollToStory}
|
||||||
|
className="group mb-8 inline-flex items-center gap-2 rounded-full border border-amber-300/30 bg-gradient-to-r from-amber-400/15 to-amber-200/5 px-4 py-1.5 text-sm font-medium text-amber-200 transition-colors hover:border-amber-300/50"
|
||||||
|
>
|
||||||
|
<TrophyIcon className="size-4" />
|
||||||
|
Top 3 — AI Engineer World's Fair Hackathon 2026
|
||||||
|
<ChevronDownIcon className="size-4 transition-transform group-hover:translate-y-0.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-5xl font-semibold leading-[1.05] tracking-tight text-white sm:text-7xl">
|
||||||
|
PodMan
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 bg-gradient-to-r from-indigo-300 via-sky-300 to-cyan-200 bg-clip-text text-2xl font-medium text-transparent sm:text-3xl">
|
||||||
|
The teammate that sees what git can't.
|
||||||
|
</p>
|
||||||
|
<p className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-white/60 sm:text-lg">
|
||||||
|
An ambient AI teammate that watches your pod's screens in realtime — catching merge
|
||||||
|
collisions, duplicated work, and missed handoffs before anyone pushes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* award chips */}
|
||||||
|
<div className="mt-9 flex flex-wrap items-center justify-center gap-2.5">
|
||||||
|
{[
|
||||||
|
{ label: '2nd Overall · 95+ teams', accent: 'text-amber-200 border-amber-300/25 bg-amber-400/10' },
|
||||||
|
{ label: 'LiveKit Track — Winner', accent: 'text-sky-200 border-sky-300/25 bg-sky-400/10' },
|
||||||
|
{ label: 'DigitalOcean — Best Use', accent: 'text-cyan-200 border-cyan-300/25 bg-cyan-400/10' },
|
||||||
|
].map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.label}
|
||||||
|
className={`rounded-full border px-3.5 py-1.5 text-[13px] font-medium ${c.accent}`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTAs */}
|
||||||
|
<div className="mt-10 flex flex-col items-center gap-3 sm:flex-row">
|
||||||
|
<button
|
||||||
|
onClick={scrollToStory}
|
||||||
|
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-gradient-to-r from-indigo-500 to-cyan-400 px-7 text-sm font-semibold text-[#0b0f17] shadow-lg shadow-indigo-500/20 transition-transform hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
Read the story <ChevronDownIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
<BetaPill />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={scrollToStory}
|
||||||
|
aria-label="Scroll to read"
|
||||||
|
className="mt-20 animate-bounce text-white/40 transition-colors hover:text-white/70"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-7" />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* story */}
|
||||||
|
<Section id="story" eyebrow="How it happened" title="Built on stage, in about 24 hours.">
|
||||||
|
<div className="space-y-5 text-[15px] leading-relaxed text-white/70">
|
||||||
|
<p>
|
||||||
|
PodMan was built at the{' '}
|
||||||
|
<span className="text-white">AI Engineer World's Fair 2026 Hackathon</span> (June
|
||||||
|
27–28) against 95+ teams. It took{' '}
|
||||||
|
<span className="text-amber-200">2nd place overall</span>, and won both the{' '}
|
||||||
|
<span className="text-sky-200">LiveKit</span> track and{' '}
|
||||||
|
<span className="text-cyan-200">DigitalOcean's Best Use</span> award.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We shipped the live demo in a single sprint — a LiveKit room, Gemini watching the
|
||||||
|
screen-share, and a voice that speaks up the moment two engineers drift toward the same
|
||||||
|
code. There wasn't time on the clock to give it a proper home.
|
||||||
|
</p>
|
||||||
|
<p className="text-white/90">
|
||||||
|
So this is that home — and we're actively building PodMan out from the hackathon
|
||||||
|
prototype into something a team can actually run every day.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* what it does */}
|
||||||
|
<Section id="what" eyebrow="What it does" title="A teammate in the room, not another dashboard.">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feature
|
||||||
|
icon={<EyeIcon className="size-5" />}
|
||||||
|
title="Ambient awareness"
|
||||||
|
body="Joins your pod's LiveKit room and samples every screen-share in realtime — no bots to invoke, no commands to remember."
|
||||||
|
/>
|
||||||
|
<Feature
|
||||||
|
icon={<GitMergeIcon className="size-5" />}
|
||||||
|
title="Merge-collision radar"
|
||||||
|
body="Recognizes when two people are converging on the same files or feature and warns them before the conflict ever reaches a push."
|
||||||
|
/>
|
||||||
|
<Feature
|
||||||
|
icon={<UsersIcon className="size-5" />}
|
||||||
|
title="Duplicated-work detection"
|
||||||
|
body="Notices when effort overlaps across the team and surfaces it early, so two engineers don't quietly build the same thing."
|
||||||
|
/>
|
||||||
|
<Feature
|
||||||
|
icon={<BellIcon className="size-5" />}
|
||||||
|
title="Handoff nudges"
|
||||||
|
body="Speaks up about missed handoffs and stalled threads — and learns which nudges actually helped, so it stays useful, not noisy."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* how it works */}
|
||||||
|
<Section id="how" eyebrow="Under the hood" title="Realtime vision, memory, and a voice.">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Stack icon={<RadioIcon className="size-5" />} name="LiveKit" note="Realtime transport for the shared room and voice delivery." />
|
||||||
|
<Stack icon={<SparklesIcon className="size-5" />} name="Gemini" note="Vision + event detection + the live voice that nudges the team." />
|
||||||
|
<Stack icon={<DatabaseIcon className="size-5" />} name="MongoDB" note="Engineer state, ownership map, events, and which nudges landed." />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-wrap gap-2 text-[13px] text-white/50">
|
||||||
|
{['LiveKit', 'Gemini Live', 'MongoDB Atlas', 'Voyage embeddings', 'Modular MAX', 'Hermes ops'].map((t) => (
|
||||||
|
<span key={t} className="rounded-md border border-white/10 bg-white/[0.03] px-2.5 py-1">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* demo */}
|
||||||
|
<Section id="demo" eyebrow="See it" title="The 60-second demo.">
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/40 shadow-2xl">
|
||||||
|
<div className="relative w-full" style={{ paddingBottom: '56.25%' }}>
|
||||||
|
<iframe
|
||||||
|
className="absolute inset-0 h-full w-full"
|
||||||
|
src="https://www.youtube-nocookie.com/embed/bWJIsIWTgr0"
|
||||||
|
title="PodMan demo"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-sm text-white/40">
|
||||||
|
<PlayIcon className="size-4" /> Watch the pod get its warning before the collision.
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* built by / team */}
|
||||||
|
<Section id="team" eyebrow="Built by" title="The team behind PodMan.">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{TEAM.map((p) => (
|
||||||
|
<a
|
||||||
|
key={p.name}
|
||||||
|
href={p.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-[15px] font-medium text-white/80 transition-colors hover:border-cyan-300/40 hover:text-white"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* final CTA — beta coming soon */}
|
||||||
|
<section className="relative z-10 mx-auto max-w-3xl px-6 py-24 text-center">
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-gradient-to-b from-white/[0.06] to-transparent px-8 py-14">
|
||||||
|
<MicIcon className="mx-auto size-8 text-cyan-300" />
|
||||||
|
<h2 className="mt-5 text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||||
|
The private beta is coming soon.
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-4 max-w-md text-[15px] leading-relaxed text-white/60">
|
||||||
|
We're building PodMan out from the hackathon prototype into something a team runs
|
||||||
|
every day. The private beta opens soon.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex justify-center">
|
||||||
|
<BetaPill className="px-6 py-3 text-[15px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* footer */}
|
||||||
|
<footer className="relative z-10 border-t border-white/10">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 px-6 py-8 text-sm text-white/40 sm:flex-row">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img src="/podman-logo.svg" alt="" className="h-5 w-5 opacity-70" />
|
||||||
|
<span>PodMan · podman.live</span>
|
||||||
|
</div>
|
||||||
|
<span>Built at AI Engineer World's Fair 2026 · 2nd Overall</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveDot() {
|
||||||
|
return (
|
||||||
|
<span className="relative flex size-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cyan-400/70" />
|
||||||
|
<span className="relative inline-flex size-1.5 rounded-full bg-cyan-400" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BetaPill({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-5 py-2.5 text-sm font-medium text-white/70 ${className}`}
|
||||||
|
>
|
||||||
|
<LiveDot />
|
||||||
|
Private beta — coming soon
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
id,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="relative z-10 border-t border-white/[0.06] py-20">
|
||||||
|
<div className="mx-auto max-w-3xl px-6">
|
||||||
|
<p className="text-sm font-medium tracking-wide text-cyan-300/80 uppercase">{eyebrow}</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">{title}</h2>
|
||||||
|
<div className="mt-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Feature({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-6 transition-colors hover:border-white/20">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500/20 to-cyan-400/20 text-cyan-200">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-lg font-semibold text-white">{title}</h3>
|
||||||
|
<p className="mt-2 text-[14px] leading-relaxed text-white/60">{body}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stack({ icon, name, note }: { icon: React.ReactNode; name: string; note: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-6">
|
||||||
|
<div className="flex items-center gap-2.5 text-white">
|
||||||
|
<span className="text-cyan-200">{icon}</span>
|
||||||
|
<span className="text-base font-semibold">{name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[14px] leading-relaxed text-white/60">{note}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,17 +17,19 @@ import { cn } from '@/lib/utils';
|
|||||||
export function CreatePodForm({
|
export function CreatePodForm({
|
||||||
busy,
|
busy,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
defaultMemberName = '',
|
||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onCreate: (input: PodInput) => Promise<void>;
|
onCreate: (input: PodInput) => Promise<void>;
|
||||||
|
defaultMemberName?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(compact);
|
const [open, setOpen] = useState(compact);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [repo, setRepo] = useState('karti-ai/podman');
|
const [repo, setRepo] = useState('karti-ai/podman');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [firstMember, setFirstMember] = useState('');
|
const [firstMember, setFirstMember] = useState(defaultMemberName);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
@@ -40,7 +42,7 @@ export function CreatePodForm({
|
|||||||
});
|
});
|
||||||
setName('');
|
setName('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setFirstMember('');
|
setFirstMember(defaultMemberName);
|
||||||
if (!compact) setOpen(false);
|
if (!compact) setOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
/* parent owns the visible error */
|
/* parent owns the visible error */
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
VideoIcon,
|
VideoIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Pod, PodInput } from '@podman/shared';
|
import type { Pod, PodInput } from '@podman/shared';
|
||||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarImage,
|
||||||
|
} from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +47,7 @@ export function PodCard({
|
|||||||
pod,
|
pod,
|
||||||
busy,
|
busy,
|
||||||
presence,
|
presence,
|
||||||
|
currentUserProfile,
|
||||||
onJoin: _onJoin,
|
onJoin: _onJoin,
|
||||||
onAddAndJoin,
|
onAddAndJoin,
|
||||||
onAddMember: _onAddMember,
|
onAddMember: _onAddMember,
|
||||||
@@ -52,15 +59,19 @@ export function PodCard({
|
|||||||
pod: Pod;
|
pod: Pod;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
presence: string[];
|
presence: string[];
|
||||||
|
currentUserProfile?: {
|
||||||
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
};
|
||||||
onJoin: (pod: Pod, member: string) => void;
|
onJoin: (pod: Pod, member: string) => void;
|
||||||
onAddAndJoin: (pod: Pod, name: string) => void;
|
onAddAndJoin: (pod: Pod) => void;
|
||||||
onAddMember: (id: string, name: string) => void;
|
onAddMember: (id: string, name: string) => void;
|
||||||
onRemoveMember: (id: string, name: string) => void;
|
onRemoveMember: (id: string, name: string) => void;
|
||||||
onUpdate: (id: string, patch: PodInput) => void;
|
onUpdate: (id: string, patch: PodInput) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onOpenGraph: (id: string) => void;
|
onOpenGraph: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [newMember, setNewMember] = useState('');
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<PodInput>({
|
const [draft, setDraft] = useState<PodInput>({
|
||||||
name: pod.name,
|
name: pod.name,
|
||||||
@@ -69,6 +80,13 @@ export function PodCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
|
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
|
||||||
|
const profileForMember = (name: string) =>
|
||||||
|
pod.memberProfiles?.[name] ??
|
||||||
|
([currentUserProfile?.displayName, currentUserProfile?.email]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => value?.toLowerCase() === name.toLowerCase())
|
||||||
|
? currentUserProfile
|
||||||
|
: undefined);
|
||||||
const active = presence.length > 0;
|
const active = presence.length > 0;
|
||||||
|
|
||||||
function saveEdit() {
|
function saveEdit() {
|
||||||
@@ -80,13 +98,8 @@ export function PodCard({
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One action: enter your name, hit Join → added to the roster (deduped
|
|
||||||
// server-side) and connected to the room in a single step.
|
|
||||||
function join() {
|
function join() {
|
||||||
const name = newMember.trim();
|
onAddAndJoin(pod);
|
||||||
if (!name) return;
|
|
||||||
onAddAndJoin(pod, name);
|
|
||||||
setNewMember('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,12 +156,16 @@ export function PodCard({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<AvatarGroup>
|
<AvatarGroup>
|
||||||
{pod.members.slice(0, 4).map((member) => (
|
{pod.members.slice(0, 4).map((member) => {
|
||||||
<Avatar key={member} title={member}>
|
const profile = profileForMember(member);
|
||||||
|
return (
|
||||||
|
<Avatar key={member} title={profile?.email ?? member}>
|
||||||
|
{profile?.imageUrl && <AvatarImage src={profile.imageUrl} alt={member} />}
|
||||||
<AvatarFallback>{initials(member)}</AvatarFallback>
|
<AvatarFallback>{initials(member)}</AvatarFallback>
|
||||||
{inRoom(member) && <AvatarBadge />}
|
{inRoom(member) && <AvatarBadge />}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{pod.members.length > 4 && <span className="text-sm text-muted-foreground">+</span>}
|
{pod.members.length > 4 && <span className="text-sm text-muted-foreground">+</span>}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
@@ -157,16 +174,8 @@ export function PodCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex justify-end">
|
||||||
<Input
|
<Button className="min-w-24" onClick={join} disabled={busy}>
|
||||||
placeholder="Your name"
|
|
||||||
value={newMember}
|
|
||||||
onChange={(e) => setNewMember(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') join();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button className="min-w-24" onClick={join} disabled={busy || !newMember.trim()}>
|
|
||||||
<VideoIcon data-icon="inline-start" />
|
<VideoIcon data-icon="inline-start" />
|
||||||
Join
|
Join
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
EyeIcon,
|
EyeIcon,
|
||||||
GitBranchIcon,
|
GitBranchIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
|
InfoIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
MicOffIcon,
|
MicOffIcon,
|
||||||
MonitorUpIcon,
|
MonitorUpIcon,
|
||||||
|
Music2Icon,
|
||||||
PhoneCallIcon,
|
PhoneCallIcon,
|
||||||
PhoneOffIcon,
|
PhoneOffIcon,
|
||||||
PanelLeftIcon,
|
PanelLeftIcon,
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
VolumeXIcon,
|
VolumeXIcon,
|
||||||
WorkflowIcon,
|
WorkflowIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Room, RemoteTrack, RemoteTrackPublication } from 'livekit-client';
|
import type { Room, RemoteTrack, RemoteTrackPublication } from 'livekit-client';
|
||||||
import type {
|
import type {
|
||||||
@@ -33,6 +36,7 @@ import type {
|
|||||||
MemberWorkHistory,
|
MemberWorkHistory,
|
||||||
MemberWorkHistoryEvent,
|
MemberWorkHistoryEvent,
|
||||||
MemberWorkHistoryFile,
|
MemberWorkHistoryFile,
|
||||||
|
MemberWorkHistoryRoi,
|
||||||
Pod,
|
Pod,
|
||||||
PodActivityEvent,
|
PodActivityEvent,
|
||||||
PodActivityKind,
|
PodActivityKind,
|
||||||
@@ -51,9 +55,8 @@ import {
|
|||||||
} from '../lib/api.js';
|
} from '../lib/api.js';
|
||||||
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
|
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
|
||||||
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
||||||
import LiveWaveform from '@/components/ruixen/live-waveform';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Avatar, AvatarBadge, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -97,24 +100,46 @@ const STREAM_RAIL_WIDTH = '4rem';
|
|||||||
interface PInfo {
|
interface PInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
imageUrl?: string;
|
||||||
isLocal: boolean;
|
isLocal: boolean;
|
||||||
speaking: boolean;
|
speaking: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileFromMetadata(metadata?: string): { email?: string; imageUrl?: string } {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadata || '{}') as { email?: unknown; imageUrl?: unknown };
|
||||||
|
return {
|
||||||
|
email: typeof parsed.email === 'string' ? parsed.email : undefined,
|
||||||
|
imageUrl: typeof parsed.imageUrl === 'string' ? parsed.imageUrl : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function snapshot(room: Room, fallbackName: string): PInfo[] {
|
function snapshot(room: Room, fallbackName: string): PInfo[] {
|
||||||
const lp = room.localParticipant;
|
const lp = room.localParticipant;
|
||||||
|
const localProfile = profileFromMetadata(lp.metadata);
|
||||||
const local: PInfo = {
|
const local: PInfo = {
|
||||||
id: lp.identity,
|
id: lp.identity,
|
||||||
name: lp.name || fallbackName,
|
name: lp.name || fallbackName,
|
||||||
|
email: localProfile.email,
|
||||||
|
imageUrl: localProfile.imageUrl,
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
speaking: lp.isSpeaking,
|
speaking: lp.isSpeaking,
|
||||||
};
|
};
|
||||||
const remotes = Array.from(room.remoteParticipants.values()).map((p) => ({
|
const remotes = Array.from(room.remoteParticipants.values()).map((p) => {
|
||||||
|
const profile = profileFromMetadata(p.metadata);
|
||||||
|
return {
|
||||||
id: p.identity,
|
id: p.identity,
|
||||||
name: p.name || p.identity,
|
name: p.name || p.identity,
|
||||||
|
email: profile.email,
|
||||||
|
imageUrl: profile.imageUrl,
|
||||||
isLocal: false,
|
isLocal: false,
|
||||||
speaking: p.isSpeaking,
|
speaking: p.isSpeaking,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
return [local, ...remotes];
|
return [local, ...remotes];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,11 +158,17 @@ export function PodView({
|
|||||||
room,
|
room,
|
||||||
devMode,
|
devMode,
|
||||||
onLeave,
|
onLeave,
|
||||||
|
currentUserProfile,
|
||||||
}: {
|
}: {
|
||||||
team: Pod;
|
team: Pod;
|
||||||
me: string;
|
me: string;
|
||||||
room: Room | null;
|
room: Room | null;
|
||||||
devMode: boolean;
|
devMode: boolean;
|
||||||
|
currentUserProfile?: {
|
||||||
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
};
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [participants, setParticipants] = useState<PInfo[]>([]);
|
const [participants, setParticipants] = useState<PInfo[]>([]);
|
||||||
@@ -145,7 +176,7 @@ export function PodView({
|
|||||||
const [testingVoice, setTestingVoice] = useState(false);
|
const [testingVoice, setTestingVoice] = useState(false);
|
||||||
const [note, setNote] = useState<string | null>(null);
|
const [note, setNote] = useState<string | null>(null);
|
||||||
const [audioBlocked, setAudioBlocked] = useState(false);
|
const [audioBlocked, setAudioBlocked] = useState(false);
|
||||||
const [remoteAudioTracks, setRemoteAudioTracks] = useState(0);
|
const [, setRemoteAudioTracks] = useState(0);
|
||||||
const [micOn, setMicOn] = useState(false);
|
const [micOn, setMicOn] = useState(false);
|
||||||
const [historyMember, setHistoryMember] = useState<string | null>(null);
|
const [historyMember, setHistoryMember] = useState<string | null>(null);
|
||||||
const [history, setHistory] = useState<MemberWorkHistory | null>(null);
|
const [history, setHistory] = useState<MemberWorkHistory | null>(null);
|
||||||
@@ -238,6 +269,7 @@ export function PodView({
|
|||||||
room
|
room
|
||||||
.on(RoomEvent.ParticipantConnected, refresh)
|
.on(RoomEvent.ParticipantConnected, refresh)
|
||||||
.on(RoomEvent.ParticipantDisconnected, refresh)
|
.on(RoomEvent.ParticipantDisconnected, refresh)
|
||||||
|
.on(RoomEvent.ParticipantMetadataChanged, refresh)
|
||||||
.on(RoomEvent.ActiveSpeakersChanged, refresh)
|
.on(RoomEvent.ActiveSpeakersChanged, refresh)
|
||||||
.on(RoomEvent.TrackSubscribed, onAudio)
|
.on(RoomEvent.TrackSubscribed, onAudio)
|
||||||
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||||
@@ -249,6 +281,7 @@ export function PodView({
|
|||||||
room
|
room
|
||||||
.off(RoomEvent.ParticipantConnected, refresh)
|
.off(RoomEvent.ParticipantConnected, refresh)
|
||||||
.off(RoomEvent.ParticipantDisconnected, refresh)
|
.off(RoomEvent.ParticipantDisconnected, refresh)
|
||||||
|
.off(RoomEvent.ParticipantMetadataChanged, refresh)
|
||||||
.off(RoomEvent.ActiveSpeakersChanged, refresh)
|
.off(RoomEvent.ActiveSpeakersChanged, refresh)
|
||||||
.off(RoomEvent.TrackSubscribed, onAudio)
|
.off(RoomEvent.TrackSubscribed, onAudio)
|
||||||
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||||
@@ -567,8 +600,6 @@ export function PodView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
open={leftStreamOpen}
|
open={leftStreamOpen}
|
||||||
@@ -586,7 +617,7 @@ export function PodView({
|
|||||||
title="My stream"
|
title="My stream"
|
||||||
collapsedLabel="Mine"
|
collapsedLabel="Mine"
|
||||||
testId="my-stream-sidebar"
|
testId="my-stream-sidebar"
|
||||||
description={`${me}'s live screen, git, intervention, and conflict log.`}
|
description={`${me}'s activity feed.`}
|
||||||
events={activity.mine}
|
events={activity.mine}
|
||||||
connected={activity.connected}
|
connected={activity.connected}
|
||||||
open={leftStreamOpen}
|
open={leftStreamOpen}
|
||||||
@@ -638,55 +669,63 @@ export function PodView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<div className="flex items-center gap-0.5 rounded-xl border bg-card/80 p-1 shadow-sm backdrop-blur-sm">
|
||||||
variant={audioBlocked ? 'default' : 'outline'}
|
<PodControlButton
|
||||||
|
label={audioBlocked ? 'Enable audio' : 'Audio on'}
|
||||||
|
icon={audioBlocked ? VolumeXIcon : Volume2Icon}
|
||||||
onClick={() => void enableSound()}
|
onClick={() => void enableSound()}
|
||||||
disabled={!room}
|
disabled={!room}
|
||||||
>
|
active={!audioBlocked}
|
||||||
{audioBlocked ? (
|
attention={audioBlocked}
|
||||||
<VolumeXIcon data-icon="inline-start" />
|
/>
|
||||||
) : (
|
<PodControlButton
|
||||||
<Volume2Icon data-icon="inline-start" />
|
label={micOn ? 'Mic on' : 'Enable mic'}
|
||||||
)}
|
icon={micOn ? MicIcon : MicOffIcon}
|
||||||
{audioBlocked ? 'Enable audio' : 'Audio on'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={micOn ? 'default' : 'outline'}
|
|
||||||
onClick={() => void toggleMic()}
|
onClick={() => void toggleMic()}
|
||||||
disabled={!room}
|
disabled={!room}
|
||||||
>
|
active={micOn}
|
||||||
{micOn ? (
|
/>
|
||||||
<MicIcon data-icon="inline-start" />
|
<PodControlButton
|
||||||
) : (
|
label={
|
||||||
<MicOffIcon data-icon="inline-start" />
|
beat.on
|
||||||
)}
|
? beat.mine
|
||||||
{micOn ? 'Mic on' : 'Enable mic'}
|
? 'Stop background music'
|
||||||
</Button>
|
: `Stop music (${beat.by})`
|
||||||
<Button variant="outline" onClick={onToggleBeat} disabled={!room}>
|
: 'Background music'
|
||||||
<Volume2Icon data-icon="inline-start" />
|
}
|
||||||
{beat.on ? (beat.mine ? 'Stop music' : `Stop (${beat.by})`) : 'Background Music'}
|
icon={Music2Icon}
|
||||||
</Button>
|
onClick={onToggleBeat}
|
||||||
<Button
|
disabled={!room}
|
||||||
variant="outline"
|
active={beat.on}
|
||||||
|
/>
|
||||||
|
<PodControlButton
|
||||||
|
label={testingVoice ? 'Sending voice…' : 'Test PodMan voice'}
|
||||||
|
icon={RadioTowerIcon}
|
||||||
onClick={() => void playPodManVoiceTest()}
|
onClick={() => void playPodManVoiceTest()}
|
||||||
disabled={!room || testingVoice}
|
disabled={!room || testingVoice}
|
||||||
|
active={testingVoice}
|
||||||
|
busy={testingVoice}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={toggleScreen}
|
||||||
|
disabled={!room}
|
||||||
|
variant={sharing ? 'destructive' : 'default'}
|
||||||
>
|
>
|
||||||
<RadioTowerIcon data-icon="inline-start" />
|
|
||||||
{testingVoice ? 'Sending voice' : 'Test PodMan voice'}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={toggleScreen} disabled={!room}>
|
|
||||||
<MonitorUpIcon data-icon="inline-start" />
|
<MonitorUpIcon data-icon="inline-start" />
|
||||||
{sharing ? 'Stop sharing' : 'Share screen'}
|
{sharing ? 'Stop sharing' : 'Share screen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{sharing ? 'Stop sharing your screen' : 'Share your screen with PodMan'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<Metric label="Participants" value={participants.length || 1} />
|
|
||||||
<Metric label="Screen" value={sharing ? 'sharing' : 'idle'} />
|
|
||||||
<Metric label="PodMan" value={podmanPresent ? 'online' : 'waiting'} />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{devMode && (
|
{devMode && (
|
||||||
@@ -729,6 +768,8 @@ export function PodView({
|
|||||||
<Participant
|
<Participant
|
||||||
key={p.id}
|
key={p.id}
|
||||||
participant={p}
|
participant={p}
|
||||||
|
rosterProfile={team.memberProfiles?.[p.name]}
|
||||||
|
currentUserProfile={currentUserProfile}
|
||||||
onOpenHistory={setHistoryMember}
|
onOpenHistory={setHistoryMember}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -922,41 +963,6 @@ export function PodView({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Status</CardTitle>
|
|
||||||
<CardDescription>Connection, media, and agent presence.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<LiveWaveform
|
|
||||||
processing={!!room || beat.on}
|
|
||||||
active={false}
|
|
||||||
height={32}
|
|
||||||
barWidth={2}
|
|
||||||
barGap={3}
|
|
||||||
className="mb-4 px-3 py-2 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<StatusLine label="LiveKit" value={room ? 'connected' : 'offline'} />
|
|
||||||
<StatusLine label="Screen" value={sharing ? 'published' : 'not shared'} />
|
|
||||||
<StatusLine
|
|
||||||
label="Audio"
|
|
||||||
value={
|
|
||||||
audioBlocked
|
|
||||||
? 'blocked'
|
|
||||||
: remoteAudioTracks > 0
|
|
||||||
? `${remoteAudioTracks} remote track${remoteAudioTracks === 1 ? '' : 's'}`
|
|
||||||
: beat.on
|
|
||||||
? beat.mine
|
|
||||||
? 'publishing'
|
|
||||||
: `${beat.by} playing`
|
|
||||||
: 'ready'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusLine label="Agent" value={podmanPresent ? 'watching' : 'waiting'} />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
<div
|
<div
|
||||||
@@ -980,7 +986,7 @@ export function PodView({
|
|||||||
title="Team stream"
|
title="Team stream"
|
||||||
collapsedLabel="Team"
|
collapsedLabel="Team"
|
||||||
testId="team-stream-sidebar"
|
testId="team-stream-sidebar"
|
||||||
description="Everyone else in this pod, merged into one realtime feed."
|
description="Team activity feed."
|
||||||
events={activity.team}
|
events={activity.team}
|
||||||
connected={activity.connected}
|
connected={activity.connected}
|
||||||
open={rightStreamOpen}
|
open={rightStreamOpen}
|
||||||
@@ -993,24 +999,30 @@ export function PodView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: number | string }) {
|
|
||||||
return (
|
|
||||||
<Card size="sm">
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
<p className="mt-1 text-lg font-medium">{value}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Participant({
|
function Participant({
|
||||||
participant,
|
participant,
|
||||||
|
rosterProfile,
|
||||||
|
currentUserProfile,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
}: {
|
}: {
|
||||||
participant: PInfo;
|
participant: PInfo;
|
||||||
|
rosterProfile?: {
|
||||||
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
};
|
||||||
|
currentUserProfile?: {
|
||||||
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
};
|
||||||
onOpenHistory: (member: string) => void;
|
onOpenHistory: (member: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const localProfile = participant.isLocal ? currentUserProfile : undefined;
|
||||||
|
const profile = {
|
||||||
|
email: participant.email ?? localProfile?.email ?? rosterProfile?.email,
|
||||||
|
imageUrl: participant.imageUrl ?? localProfile?.imageUrl ?? rosterProfile?.imageUrl,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1020,12 +1032,15 @@ function Participant({
|
|||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
|
{profile.imageUrl && <AvatarImage src={profile.imageUrl} alt={participant.name} />}
|
||||||
<AvatarFallback>{initials(participant.name)}</AvatarFallback>
|
<AvatarFallback>{initials(participant.name)}</AvatarFallback>
|
||||||
{participant.speaking && <AvatarBadge />}
|
{participant.speaking && <AvatarBadge />}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium">{participant.name}</p>
|
<p className="truncate text-sm font-medium">{participant.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{participant.isLocal ? 'you' : 'remote'}</p>
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{profile.email ?? (participant.isLocal ? 'you' : 'remote')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
@@ -1089,6 +1104,7 @@ function WorkHistoryDialog({
|
|||||||
|
|
||||||
{history && !loading && !error && (
|
{history && !loading && !error && (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
<RoiBand roi={history.roi} />
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<HistoryStat label="Files" value={history.totals.files} />
|
<HistoryStat label="Files" value={history.totals.files} />
|
||||||
<HistoryStat label="Screen logs" value={history.totals.observations} />
|
<HistoryStat label="Screen logs" value={history.totals.observations} />
|
||||||
@@ -1145,6 +1161,88 @@ function WorkHistoryDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSaved(minutes: number): string {
|
||||||
|
if (minutes < 60) return `~${minutes}m`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m ? `~${h}h ${m}m` : `~${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoiTooltip({ roi }: { roi: MemberWorkHistoryRoi }) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="How rework saved is estimated"
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<InfoIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-64">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium">Estimated rework saved</p>
|
||||||
|
{roi.breakdown.length ? (
|
||||||
|
roi.breakdown.map((row) => (
|
||||||
|
<p key={row.label} className="font-mono text-[0.7rem]">
|
||||||
|
{row.count} × {row.minutesEach}m · {row.label}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-[0.7rem]">No eligible clashes.</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[0.68rem] text-muted-foreground">
|
||||||
|
credit split across engineers · est. only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoiBand({ roi }: { roi?: MemberWorkHistoryRoi }) {
|
||||||
|
if (!roi || roi.clashesCaught === 0) return null;
|
||||||
|
const conflictFree = roi.totalFiles
|
||||||
|
? Math.round((roi.conflictFreeFiles / roi.totalFiles) * 100)
|
||||||
|
: 100;
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-primary/5 p-4">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<p className="font-mono text-2xl font-semibold">{formatSaved(roi.savedMinutes)}</p>
|
||||||
|
<span className="text-sm text-muted-foreground">rework saved</span>
|
||||||
|
<RoiTooltip roi={roi} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
estimated · clashes caught pre-commit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-mono text-lg font-medium">{roi.clashesCaught}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">clashes caught early</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{roi.totalFiles > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{ width: `${conflictFree}%` }}
|
||||||
|
aria-label={`${roi.conflictFreeFiles} of ${roi.totalFiles} files conflict-free`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[0.68rem] text-muted-foreground">
|
||||||
|
conflict-free: {roi.conflictFreeFiles} of {roi.totalFiles} files ·{' '}
|
||||||
|
{roi.filesDeconflicted} auto-deconflicted
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HistoryStat({ label, value }: { label: string; value: number }) {
|
function HistoryStat({ label, value }: { label: string; value: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-background px-3 py-2">
|
<div className="rounded-lg border bg-background px-3 py-2">
|
||||||
@@ -1548,7 +1646,7 @@ const ACTIVITY_CATEGORIES: {
|
|||||||
{
|
{
|
||||||
id: 'signal',
|
id: 'signal',
|
||||||
label: 'Signals',
|
label: 'Signals',
|
||||||
hint: 'Screen logs and local git activity routed to this stream.',
|
hint: 'Recent screen and git activity.',
|
||||||
icon: RadioTowerIcon,
|
icon: RadioTowerIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1663,3 +1761,46 @@ function initials(name: string): string {
|
|||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact, icon-only control for the pod toolbar. The label is hidden by
|
||||||
|
* default and surfaced on hover/focus via tooltip, so the row stays tight and
|
||||||
|
* responsive. `active` fills the button (engaged state), `attention` promotes
|
||||||
|
* it to the primary colour (needs a user action, e.g. audio is blocked).
|
||||||
|
*/
|
||||||
|
function PodControlButton({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
active = false,
|
||||||
|
attention = false,
|
||||||
|
busy = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
attention?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={attention ? 'default' : active ? 'secondary' : 'ghost'}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Icon className={cn(busy && 'animate-pulse')} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const ACTIVITY_TAG: Record<PodGraphActivityKind, { color: string; label:
|
|||||||
outcome: { color: GREEN, label: 'OUTCOME' },
|
outcome: { color: GREEN, label: 'OUTCOME' },
|
||||||
learned: { color: VIOLET, label: 'LEARNED' },
|
learned: { color: VIOLET, label: 'LEARNED' },
|
||||||
agent: { color: BLUE, label: 'AGENT' },
|
agent: { color: BLUE, label: 'AGENT' },
|
||||||
|
suppressed: { color: VIOLET, label: 'SUPPRESSED' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
export const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||||
|
|||||||
+66
-30
@@ -14,11 +14,34 @@ const BACKEND_URL =
|
|||||||
? 'http://localhost:8787'
|
? 'http://localhost:8787'
|
||||||
: '');
|
: '');
|
||||||
|
|
||||||
|
type AuthTokenGetter = () => Promise<string | null>;
|
||||||
|
|
||||||
|
let authTokenGetter: AuthTokenGetter | null = null;
|
||||||
|
|
||||||
|
export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
|
||||||
|
authTokenGetter = getter;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestHeaders(init?: HeadersInit): Promise<Headers> {
|
||||||
|
const next = new Headers(init);
|
||||||
|
const token = await authTokenGetter?.();
|
||||||
|
if (token) next.set('authorization', `Bearer ${token}`);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
return fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: await requestHeaders(init.headers),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemoryStats {
|
export interface MemoryStats {
|
||||||
observations: number;
|
observations: number;
|
||||||
collisions: number;
|
collisions: number;
|
||||||
interventions: number;
|
interventions: number;
|
||||||
outcomes: number;
|
outcomes: number;
|
||||||
|
userPodContext?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveConversationSession {
|
export interface LiveConversationSession {
|
||||||
@@ -34,6 +57,12 @@ export interface LiveConversationSession {
|
|||||||
endedAt?: string;
|
endedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfilePayload {
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
async function json<T>(res: Response): Promise<T> {
|
async function json<T>(res: Response): Promise<T> {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
@@ -42,16 +71,19 @@ async function json<T>(res: Response): Promise<T> {
|
|||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JSON_HEADERS = { 'content-type': 'application/json' } as const;
|
||||||
|
|
||||||
/** Mint a LiveKit token from the backend. */
|
/** Mint a LiveKit token from the backend. */
|
||||||
export async function fetchToken(params: {
|
export async function fetchToken(params: {
|
||||||
room: string;
|
room: string;
|
||||||
identity: string;
|
identity: string;
|
||||||
name: string;
|
name: string;
|
||||||
githubLogin?: string;
|
githubLogin?: string;
|
||||||
|
profile?: UserProfilePayload;
|
||||||
}): Promise<{ token: string; url: string }> {
|
}): Promise<{ token: string; url: string }> {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/token`, {
|
const res = await apiFetch(`${BACKEND_URL}/api/token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify(params),
|
body: JSON.stringify(params),
|
||||||
});
|
});
|
||||||
return json(res);
|
return json(res);
|
||||||
@@ -59,9 +91,9 @@ export async function fetchToken(params: {
|
|||||||
|
|
||||||
/** Record an intervention outcome for the policy learning loop. */
|
/** Record an intervention outcome for the policy learning loop. */
|
||||||
export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
|
export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/outcome`, {
|
const res = await apiFetch(`${BACKEND_URL}/api/outcome`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify(outcome),
|
body: JSON.stringify(outcome),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
|
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
|
||||||
@@ -73,9 +105,9 @@ export async function createSyncPr(input: {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
}): Promise<{ url: string; number: number }> {
|
}): Promise<{ url: string; number: number }> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BACKEND_URL}/api/sync-pr`, {
|
await apiFetch(`${BACKEND_URL}/api/sync-pr`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -84,21 +116,21 @@ export async function createSyncPr(input: {
|
|||||||
// --- Pods CRUD ---
|
// --- Pods CRUD ---
|
||||||
|
|
||||||
export async function listPods(): Promise<Pod[]> {
|
export async function listPods(): Promise<Pod[]> {
|
||||||
return json(await fetch(`${BACKEND_URL}/api/pods`));
|
return json(await apiFetch(`${BACKEND_URL}/api/pods`));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Display names currently connected per pod id (= LiveKit room name). */
|
/** Display names currently connected per pod id (= LiveKit room name). */
|
||||||
export async function getPresence(): Promise<Record<string, string[]>> {
|
export async function getPresence(): Promise<Record<string, string[]>> {
|
||||||
return json(await fetch(`${BACKEND_URL}/api/presence`));
|
return json(await apiFetch(`${BACKEND_URL}/api/presence`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMemoryStats(): Promise<MemoryStats> {
|
export async function getMemoryStats(): Promise<MemoryStats> {
|
||||||
return json(await fetch(`${BACKEND_URL}/api/memory/stats`));
|
return json(await apiFetch(`${BACKEND_URL}/api/memory/stats`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPodActivity(id: string, limit = 80): Promise<PodActivityEvent[]> {
|
export async function getPodActivity(id: string, limit = 80): Promise<PodActivityEvent[]> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity?limit=${limit}`),
|
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity?limit=${limit}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +148,7 @@ export async function getMemberWorkHistory(
|
|||||||
member: string,
|
member: string,
|
||||||
): Promise<MemberWorkHistory> {
|
): Promise<MemberWorkHistory> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(
|
await apiFetch(
|
||||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/members/${encodeURIComponent(
|
`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/members/${encodeURIComponent(
|
||||||
member,
|
member,
|
||||||
)}/history?hours=24&limit=80`,
|
)}/history?hours=24&limit=80`,
|
||||||
@@ -124,47 +156,51 @@ export async function getMemberWorkHistory(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPod(input: PodInput): Promise<Pod> {
|
export async function createPod(input: PodInput, profile?: UserProfilePayload): Promise<Pod> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BACKEND_URL}/api/pods`, {
|
await apiFetch(`${BACKEND_URL}/api/pods`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify({ ...input, profile }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePod(id: string, patch: PodInput): Promise<Pod> {
|
export async function updatePod(id: string, patch: PodInput): Promise<Pod> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePod(id: string): Promise<void> {
|
export async function deletePod(id: string): Promise<void> {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
const res = await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`delete pod failed: ${res.status}`);
|
if (!res.ok) throw new Error(`delete pod failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMember(id: string, name: string): Promise<Pod> {
|
export async function addMember(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
profile?: UserProfilePayload,
|
||||||
|
): Promise<Pod> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, {
|
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name, profile }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testPodVoice(id: string): Promise<void> {
|
export async function testPodVoice(id: string): Promise<void> {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/voice-test`, {
|
const res = await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/voice-test`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: 'PodMan voice test. Gemini TTS is playing through LiveKit.',
|
message: 'PodMan voice test. Gemini TTS is playing through LiveKit.',
|
||||||
}),
|
}),
|
||||||
@@ -177,16 +213,16 @@ export async function startLiveConversation(
|
|||||||
input: { identity: string; displayName?: string },
|
input: { identity: string; displayName?: string },
|
||||||
): Promise<LiveConversationSession> {
|
): Promise<LiveConversationSession> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/live-conversation/start`, {
|
await apiFetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/live-conversation/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: JSON_HEADERS,
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopLiveConversation(podId: string, sessionId: string): Promise<void> {
|
export async function stopLiveConversation(podId: string, sessionId: string): Promise<void> {
|
||||||
const res = await fetch(
|
const res = await apiFetch(
|
||||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||||
podId,
|
podId,
|
||||||
)}/live-conversation/${encodeURIComponent(sessionId)}/stop`,
|
)}/live-conversation/${encodeURIComponent(sessionId)}/stop`,
|
||||||
@@ -200,7 +236,7 @@ export async function getLiveConversationHermesJob(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<{ job: HermesJob | null; events: HermesJobEvent[] }> {
|
): Promise<{ job: HermesJob | null; events: HermesJobEvent[] }> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(
|
await apiFetch(
|
||||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||||
podId,
|
podId,
|
||||||
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job`,
|
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job`,
|
||||||
@@ -213,7 +249,7 @@ export async function abortLiveConversationHermesJob(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<{ job: HermesJob | null }> {
|
): Promise<{ job: HermesJob | null }> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(
|
await apiFetch(
|
||||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||||
podId,
|
podId,
|
||||||
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job/abort`,
|
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job/abort`,
|
||||||
@@ -224,7 +260,7 @@ export async function abortLiveConversationHermesJob(
|
|||||||
|
|
||||||
export async function removeMember(id: string, name: string): Promise<Pod> {
|
export async function removeMember(id: string, name: string): Promise<Pod> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(
|
await apiFetch(
|
||||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members/${encodeURIComponent(name)}`,
|
`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/members/${encodeURIComponent(name)}`,
|
||||||
{ method: 'DELETE' },
|
{ method: 'DELETE' },
|
||||||
),
|
),
|
||||||
|
|||||||
+16
-4
@@ -11,11 +11,17 @@ export async function fetchPodToken(
|
|||||||
podId: string,
|
podId: string,
|
||||||
identity: string,
|
identity: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
getToken?: () => Promise<string | null>,
|
||||||
|
profile?: { displayName?: string; email?: string; imageUrl?: string },
|
||||||
): Promise<{ token: string; url: string }> {
|
): Promise<{ token: string; url: string }> {
|
||||||
|
const clerkToken = await getToken?.();
|
||||||
const res = await fetch(`${BACKEND_URL}/api/token`, {
|
const res = await fetch(`${BACKEND_URL}/api/token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: {
|
||||||
body: JSON.stringify({ room: podId, identity, name }),
|
'content-type': 'application/json',
|
||||||
|
...(clerkToken ? { authorization: `Bearer ${clerkToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ room: podId, identity, name, profile }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
|
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -33,8 +39,14 @@ export type JoinResult = { mode: 'live'; room: Room } | { mode: 'dev'; room: nul
|
|||||||
* connected — screen sharing is a separate, deliberate action (see PodView) so
|
* connected — screen sharing is a separate, deliberate action (see PodView) so
|
||||||
* a denied/slow screen prompt never blocks or fails the join.
|
* a denied/slow screen prompt never blocks or fails the join.
|
||||||
*/
|
*/
|
||||||
export async function joinPod(podId: string, identity: string, name: string): Promise<JoinResult> {
|
export async function joinPod(
|
||||||
const { token, url } = await fetchPodToken(podId, identity, name);
|
podId: string,
|
||||||
|
identity: string,
|
||||||
|
name: string,
|
||||||
|
getToken?: () => Promise<string | null>,
|
||||||
|
profile?: { displayName?: string; email?: string; imageUrl?: string },
|
||||||
|
): Promise<JoinResult> {
|
||||||
|
const { token, url } = await fetchPodToken(podId, identity, name, getToken, profile);
|
||||||
|
|
||||||
if (!isLiveKitConfigured(url)) {
|
if (!isLiveKitConfigured(url)) {
|
||||||
console.warn('[podman] LiveKit not configured — dev mock join');
|
console.warn('[podman] LiveKit not configured — dev mock join');
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export function useInterventions(room: Room | null) {
|
|||||||
if (msg.type === 'COLLISION') {
|
if (msg.type === 'COLLISION') {
|
||||||
setActive(msg.intervention);
|
setActive(msg.intervention);
|
||||||
setActionUrl(null);
|
setActionUrl(null);
|
||||||
|
// Clear the prior intervention's cue/message so a new card never shows a
|
||||||
|
// stale voice line. The fresh ones arrive right after on the same
|
||||||
|
// reliable channel (ordered: COLLISION -> HERMES_MESSAGE -> VOICE_CUE).
|
||||||
|
setVoiceCue(null);
|
||||||
|
setHermes(null);
|
||||||
}
|
}
|
||||||
if (msg.type === 'HERMES_MESSAGE') setHermes(msg.message);
|
if (msg.type === 'HERMES_MESSAGE') setHermes(msg.message);
|
||||||
if (msg.type === 'VOICE_CUE') {
|
if (msg.type === 'VOICE_CUE') {
|
||||||
@@ -79,6 +84,8 @@ export function useInterventions(room: Room | null) {
|
|||||||
{ reliable: true, topic: DATA_TOPIC },
|
{ reliable: true, topic: DATA_TOPIC },
|
||||||
);
|
);
|
||||||
setActive(null);
|
setActive(null);
|
||||||
|
setVoiceCue(null);
|
||||||
|
setHermes(null);
|
||||||
return status;
|
return status;
|
||||||
},
|
},
|
||||||
[active, room],
|
[active, room],
|
||||||
|
|||||||
+42
-2
@@ -1,13 +1,53 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { ClerkProvider } from '@clerk/react';
|
||||||
|
import { shadcn } from '@clerk/ui/themes';
|
||||||
import App from './App.js';
|
import App from './App.js';
|
||||||
|
import { Landing } from './Landing.js';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import '@clerk/ui/themes/shadcn.css';
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
|
||||||
<StrictMode>
|
const hasClerk = typeof clerkPublishableKey === 'string' && clerkPublishableKey.startsWith('pk_');
|
||||||
|
|
||||||
|
function AppRoute() {
|
||||||
|
if (!hasClerk) {
|
||||||
|
// Secrets not provisioned yet — show a graceful placeholder instead of crashing.
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-[#0b0f17] px-6 text-center text-white/80">
|
||||||
|
<img src="/podman-logo.svg" alt="PodMan" className="h-10 w-10" />
|
||||||
|
<h1 className="text-xl font-semibold text-white">PodMan is deploying</h1>
|
||||||
|
<p className="max-w-sm text-sm text-white/50">
|
||||||
|
The live app is being provisioned. Check back shortly.
|
||||||
|
</p>
|
||||||
|
<a href="/" className="mt-2 text-sm text-cyan-300 hover:underline">
|
||||||
|
← Back to home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ClerkProvider
|
||||||
|
publishableKey={clerkPublishableKey}
|
||||||
|
appearance={{ theme: shadcn }}
|
||||||
|
afterSignOutUrl="/"
|
||||||
|
>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<App />
|
<App />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</ClerkProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Landing />} />
|
||||||
|
<Route path="/app/*" element={<AppRoute />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+3290
-20
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,14 @@ export interface Collision {
|
|||||||
/** Engineer ids involved in the overlap. */
|
/** Engineer ids involved in the overlap. */
|
||||||
engineers: string[];
|
engineers: string[];
|
||||||
severity: CollisionSeverity;
|
severity: CollisionSeverity;
|
||||||
|
/** Overlap type; undefined preserves historical same-file behavior. */
|
||||||
|
overlapKind?: 'file' | 'research';
|
||||||
|
/** Research topic/source for cross-channel code-edit ↔ research overlaps. */
|
||||||
|
researchTopic?: string;
|
||||||
|
researchSource?: string;
|
||||||
|
/** Engineer doing research and engineer editing the matched file. */
|
||||||
|
researcher?: string;
|
||||||
|
editor?: string;
|
||||||
/** Snapshot of relevant GitHub state at detection time. */
|
/** Snapshot of relevant GitHub state at detection time. */
|
||||||
githubState?: GithubStateSnapshot;
|
githubState?: GithubStateSnapshot;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export interface EngineerContext {
|
|||||||
currentSymbol?: string;
|
currentSymbol?: string;
|
||||||
/** Higher-level feature/action inferred from the screen. */
|
/** Higher-level feature/action inferred from the screen. */
|
||||||
activity?: string;
|
activity?: string;
|
||||||
|
/** Broad screen mode: editor work or browser/docs research. */
|
||||||
|
mode?: 'editing' | 'research';
|
||||||
|
/** Topic being researched when mode is "research", e.g. "LiveKit agents". */
|
||||||
|
researchTopic?: string;
|
||||||
|
/** Research source domain, e.g. "docs.livekit.io". */
|
||||||
|
researchSource?: string;
|
||||||
/** Whether the screen shows uncommitted/unpushed changes (gutter/diff). */
|
/** Whether the screen shows uncommitted/unpushed changes (gutter/diff). */
|
||||||
hasUnpushedChanges?: boolean;
|
hasUnpushedChanges?: boolean;
|
||||||
/** 0–1 confidence in this read of the screen. */
|
/** 0–1 confidence in this read of the screen. */
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type PodGraphActivityKind =
|
|||||||
| 'intervention'
|
| 'intervention'
|
||||||
| 'outcome'
|
| 'outcome'
|
||||||
| 'learned'
|
| 'learned'
|
||||||
|
| 'suppressed'
|
||||||
| 'agent';
|
| 'agent';
|
||||||
|
|
||||||
export interface PodGraphActivity {
|
export interface PodGraphActivity {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type { Pod, PodInput, Engineer } from './pod.js';
|
export type { Pod, PodInput, Engineer } from './pod.js';
|
||||||
export type { EngineerContext } from './engineer.js';
|
export type { EngineerContext } from './engineer.js';
|
||||||
|
export type { UserLearningPodSummary, UserLearningProfile } from './user-learning.js';
|
||||||
export type {
|
export type {
|
||||||
PodActivityEvent,
|
PodActivityEvent,
|
||||||
PodActivityKind,
|
PodActivityKind,
|
||||||
@@ -56,5 +57,6 @@ export type {
|
|||||||
MemberWorkHistory,
|
MemberWorkHistory,
|
||||||
MemberWorkHistoryEvent,
|
MemberWorkHistoryEvent,
|
||||||
MemberWorkHistoryFile,
|
MemberWorkHistoryFile,
|
||||||
|
MemberWorkHistoryRoi,
|
||||||
MemberWorkHistorySource,
|
MemberWorkHistorySource,
|
||||||
} from './member-history.js';
|
} from './member-history.js';
|
||||||
|
|||||||
@@ -33,4 +33,24 @@ export interface MemberWorkHistory {
|
|||||||
};
|
};
|
||||||
files: MemberWorkHistoryFile[];
|
files: MemberWorkHistoryFile[];
|
||||||
timeline: MemberWorkHistoryEvent[];
|
timeline: MemberWorkHistoryEvent[];
|
||||||
|
/**
|
||||||
|
* Coordination ROI summary — clashes Hermes caught for this member. Optional
|
||||||
|
* so pods with no collisions / older payloads render without the band.
|
||||||
|
*/
|
||||||
|
roi?: MemberWorkHistoryRoi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWorkHistoryRoi {
|
||||||
|
/** Estimated rework minutes saved (heuristic, labeled "~/est." in UI). */
|
||||||
|
savedMinutes: number;
|
||||||
|
/** Eligible collisions caught early (hard count). */
|
||||||
|
clashesCaught: number;
|
||||||
|
/** Distinct files that hit an eligible clash. */
|
||||||
|
filesDeconflicted: number;
|
||||||
|
/** Member files in flight that never hit a clash. */
|
||||||
|
conflictFreeFiles: number;
|
||||||
|
/** Total member files in flight (git changedFiles). */
|
||||||
|
totalFiles: number;
|
||||||
|
/** Per-kind breakdown for the tooltip. */
|
||||||
|
breakdown: { label: string; count: number; minutesEach: number }[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export interface Pod {
|
|||||||
description?: string;
|
description?: string;
|
||||||
/** Engineer display names PodMan uses when it speaks. */
|
/** Engineer display names PodMan uses when it speaks. */
|
||||||
members: string[];
|
members: string[];
|
||||||
|
/** Latest known Clerk/Gmail profile data per member display name. */
|
||||||
|
memberProfiles?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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