Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d4029a046 | |||
| 9dd8f3ce60 | |||
| 1ea3e3c9e6 | |||
| 5a96e2b1ae |
+1
-22
@@ -7,12 +7,8 @@ LIVEKIT_API_SECRET=
|
||||
|
||||
# --- Gemini (vision + event detection + voice) ---
|
||||
GEMINI_API_KEY=
|
||||
# GOOGLE_API_KEY= also works as a local alias, but GEMINI_API_KEY is the
|
||||
# canonical deployment secret name used by DigitalOcean and docs.
|
||||
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
|
||||
GEMINI_TTS_VOICE=Charon
|
||||
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
|
||||
GEMINI_LIVE_MODEL=gemini-live-2.5-flash
|
||||
|
||||
# --- GitHub (repo state + sync PR artifacts) ---
|
||||
GITHUB_TOKEN=
|
||||
@@ -33,24 +29,7 @@ NUDGE_COOLDOWN_MS=180000
|
||||
# --- Frontend (Vite — must be VITE_ prefixed to reach the client) ---
|
||||
VITE_LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
VITE_BACKEND_URL=http://localhost:8787
|
||||
# Keep off by default so users hear Gemini audio delivered through LiveKit.
|
||||
VITE_ENABLE_BROWSER_TTS_FALLBACK=false
|
||||
|
||||
# --- Deployment verification ---
|
||||
# Optional override when the deployed SPA and API use different origins.
|
||||
FRONTEND_URL=http://localhost:4173
|
||||
|
||||
# --- Hermes operations watchdog ---
|
||||
PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
|
||||
PODMAN_PUBLIC_API_URL=https://165-22-129-249.sslip.io/api/pods
|
||||
PODMAN_PUBLIC_HEALTH_URL=https://165-22-129-249.sslip.io/health
|
||||
PODMAN_HERMES_REMEDIATE=1
|
||||
PODMAN_HERMES_STRICT=0
|
||||
PODMAN_HERMES_STATE_DIR=/var/log/podman
|
||||
# Optional Discord/Slack/generic webhook for failed watchdog runs.
|
||||
PODMAN_ALERT_WEBHOOK_URL=
|
||||
|
||||
# --- Hermes git-sync deploy loop ---
|
||||
PODMAN_DEPLOY_REMOTE=origin
|
||||
PODMAN_DEPLOY_BRANCH=main
|
||||
PODMAN_DEPLOY_RESTART_SERVICES=podman-platform-api.service,podman-platform-agent.service,caddy.service
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Hermes verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.32.1
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm -r typecheck
|
||||
- run: pnpm -r build
|
||||
@@ -35,8 +35,6 @@ Thumbs.db
|
||||
coverage/
|
||||
.cache/
|
||||
.turbo/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Ramis
|
||||
.remember/
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
link-workspace-packages=true
|
||||
prefer-workspace-packages=true
|
||||
auto-install-peers=true
|
||||
prefix=/home/ramis/.npm-global
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
node_modules
|
||||
.venv
|
||||
**/.venv
|
||||
.pytest_cache
|
||||
**/.pytest_cache
|
||||
dist
|
||||
build
|
||||
pnpm-lock.yaml
|
||||
.omc
|
||||
*.log
|
||||
.agents
|
||||
examples/livekit-gemini-hacker-starter
|
||||
|
||||
@@ -318,31 +318,30 @@ Everything should serve that outcome.
|
||||
|
||||
## Documentation-first enforcement — HARD RULE
|
||||
|
||||
**Every line of code must trace back to a spec in `docs/`.**
|
||||
**Every line of code must trace back to a task in `docs/PLAN.md` or a spec in `docs/`.**
|
||||
|
||||
This is not a guideline. This is a gate. The canonical specs are
|
||||
`docs/gemini.md`, `docs/livekit.md`, `docs/mongodb.md`, `docs/cont_learning.md`,
|
||||
`docs/hermes.md`, and `docs/digitalocean.md`. `docs/demo.md` is the demo script.
|
||||
This is not a guideline. This is a gate.
|
||||
|
||||
### Before writing any code, verify:
|
||||
|
||||
1. **Is the approach consistent with the relevant spec?** Check `docs/gemini.md`, `docs/livekit.md`, `docs/mongodb.md`, `docs/cont_learning.md`, `docs/hermes.md`, `docs/digitalocean.md` as applicable.
|
||||
2. **Do the file names and API shapes match what's documented?** If a spec says `backend/src/memory/store.ts`, do not create `backend/src/database/engineStates.ts` without updating the spec first.
|
||||
1. **Is this task in `docs/PLAN.md`?** Find the exact task number. If it's not there, stop.
|
||||
2. **Is the approach consistent with the relevant spec?** Check `docs/gemini.md`, `docs/livekit.md`, `docs/mongodb.md`, `docs/digitalocean.md` as applicable.
|
||||
3. **Do the file names and API shapes match what's documented?** If the plan says `backend/src/db/states.ts`, do not create `backend/src/database/engineStates.ts` without updating the spec first.
|
||||
|
||||
### If a developer asks for something not in the specs:
|
||||
### If a developer asks for something not in the plan:
|
||||
|
||||
**Do not write the code.** Instead:
|
||||
|
||||
1. Say explicitly: _"This isn't in the current specs. Let me understand what you're trying to do."_
|
||||
1. Say explicitly: _"This isn't in the current plan. Let me understand what you're trying to do."_
|
||||
2. Ask what problem they're solving and whether it's required for the demo path.
|
||||
3. Evaluate whether it fits within scope or replaces something documented.
|
||||
4. If it's valid: **update the relevant spec first**, then proceed to code.
|
||||
5. If it's scope creep: say so directly and recommend the nearest in-spec alternative.
|
||||
3. Evaluate whether it fits within scope or replaces something planned.
|
||||
4. If it's valid: **update `docs/PLAN.md` and the relevant spec first**, then proceed to code.
|
||||
5. If it's scope creep: say so directly and recommend the nearest in-plan alternative.
|
||||
|
||||
### Signs a request is off-spec (stop and consult):
|
||||
### Signs a request is off-plan (stop and consult):
|
||||
|
||||
- Introducing a new file or API route not described in any spec
|
||||
- Changing a documented API signature (`/health`, `POST /api/token`, `POST /api/outcome`, `GET /api/pods/:id/...`)
|
||||
- Introducing a new file not mentioned in any task's **Files** list
|
||||
- Changing an API signature documented in a spec (`/ingest`, `/health`, `/pods/:podId/token`, `/pods/:podId/state`)
|
||||
- Adding a dependency not in the existing `package.json` files without a clear spec reason
|
||||
- Building a feature in the **Cut immediately** list
|
||||
- Touching another engineer's ownership area without explicit cross-team coordination
|
||||
@@ -360,54 +359,7 @@ This repo is actively used by **4 engineers at the same time**. Claude sessions
|
||||
### What this means for how you help
|
||||
|
||||
- **Assume other files are actively being edited.** Never refactor code outside the immediate task scope without explicit coordination from the user.
|
||||
- **Treat integration points as contracts.** The shared types in `shared/src/` and the API shapes of `POST /api/token`, `POST /api/outcome`, and the `GET /api/pods/:id/*` routes are the interfaces between all teammates — do not change their signatures unilaterally.
|
||||
- **Treat integration points as contracts.** The shared types in `shared/src/` and the API shapes of `POST /ingest`, `GET /pods/:podId/token`, and `GET /pods/:podId/state` are the interfaces between all teammates — do not change their signatures unilaterally.
|
||||
- **Flag merge risk explicitly** before editing a shared file (e.g., `backend/src/index.ts`, `frontend/src/App.tsx`). Say so, then proceed only if the user confirms.
|
||||
- **Prefer additive changes** — new files, new functions — over modifying existing ones. This minimizes merge conflicts in a concurrent team.
|
||||
- **When proposing new files**, verify they match the file names and paths described in the relevant spec in `docs/`. Do not invent new paths.
|
||||
|
||||
---
|
||||
|
||||
## Production deployment & ops — READ BEFORE TOUCHING THE SERVER
|
||||
|
||||
The live system runs on a DigitalOcean droplet at `165.22.129.249`
|
||||
(public: `https://165-22-129-249.sslip.io/` and `podman.live`). Repo on box:
|
||||
`/root/podman`. SSH is `root@165.22.129.249` (password auth; ask the team for
|
||||
the password — it is **not** stored in the repo).
|
||||
|
||||
### HARD RULE: manage processes with systemd, never manual `node`
|
||||
|
||||
The backend API and the LiveKit agent run as **systemd services** with
|
||||
`Restart=always`:
|
||||
|
||||
- `podman-platform-api.service` → `node backend/dist/server.js` (cwd `/root/podman`)
|
||||
- `podman-platform-agent.service` → `node dist/agent.js` (cwd `/root/podman/backend`,
|
||||
`Environment=POD_ROOM=demo-pod`, `EnvironmentFile=/root/podman/backend/.env`)
|
||||
|
||||
**Do not start the agent or server by hand** (`node ...`, `nohup`, `setsid`,
|
||||
`tsx`). The agent joins LiveKit with a fixed identity (`podman-hermes`); a
|
||||
second instance with the same identity **evicts the first from the room**, and
|
||||
they flap forever — silently dropping every intervention/voice update. systemd
|
||||
keeps exactly one of each alive. If you launched a manual process, kill it and
|
||||
let systemd own the singleton.
|
||||
|
||||
### Frontend is static, served by Caddy
|
||||
|
||||
The frontend is a Vite build served by **Caddy** from `/var/www/podman`
|
||||
(`/etc/caddy/Caddyfile`). Caddy reverse-proxies `/api/*` and `/health` to
|
||||
`127.0.0.1:8787`. The LiveKit URL reaches the browser via the backend
|
||||
`/api/token` response, **not** `VITE_LIVEKIT_URL` (intentionally empty in
|
||||
`frontend/.env`).
|
||||
|
||||
### Deploy procedure (run on the box)
|
||||
|
||||
```bash
|
||||
cd /root/podman && git pull && pnpm -r build
|
||||
rm -rf /var/www/podman/* && cp -r frontend/dist/* /var/www/podman/
|
||||
systemctl restart podman-platform-api podman-platform-agent
|
||||
systemctl status podman-platform-agent --no-pager # verify it came up
|
||||
```
|
||||
|
||||
`pnpm -r build` order matters: `@podman/shared` builds first, or backend/frontend
|
||||
typecheck fails with "Cannot find module '@podman/shared'". MongoDB is
|
||||
**mandatory** — both services ping Mongo at boot and exit loudly if it is
|
||||
unreachable (intentional; fix the `.env` creds, do not re-add silent fallbacks).
|
||||
- **When proposing new files**, verify they match the file names listed in the relevant task in `docs/PLAN.md`. Do not invent new paths.
|
||||
|
||||
@@ -1,99 +1,32 @@
|
||||
# PodMan — A Pair Programmer for Engineering Teams
|
||||
# PodMan - Real-time AI Team Coordination Agent
|
||||
|
||||
[LiveKit](https://livekit.io/)
|
||||
[MongoDB](https://www.mongodb.com/)
|
||||
[Gemini](https://ai.google.dev/)
|
||||
[DigitalOcean](https://www.digitalocean.com/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://livekit.io/)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://ai.google.dev/)
|
||||
[](https://www.digitalocean.com/)
|
||||
|
||||
**2026 AI Engineer World's Fair Hackathon — Theme: Continual Learning**
|
||||
**2026 AI Engineer World's Fair Hackathon** - Track: **Continual Learning**
|
||||
|
||||
## The bottleneck moved
|
||||
PodMan is a non-intrusive AI teammate for active coding. It watches consented
|
||||
LiveKit screen-share context, combines it with local git truth and shared team
|
||||
memory, and coordinates teammates before a problem becomes a GitHub problem.
|
||||
|
||||
Models keep getting better, and more people can build software than ever before.
|
||||
Writing the code is no longer the hard part — engineering ability is not the
|
||||
ceiling anymore.
|
||||
> GitHub sees pushed work. PodMan sees work while it is still happening.
|
||||
|
||||
What slows teams down now is everything *around* the code: manually checking each
|
||||
other's work, re-planning when two people drift into the same change, and
|
||||
constantly asking "what are you working on?" just to stay in sync. People
|
||||
naturally want to build together — so as more people start coding, there will be
|
||||
thousands of teams bottlenecked not by skill, but by the **speed of human
|
||||
coordination.**
|
||||
|
||||
**PodMan is a pair programmer for the whole team.** It watches what every member
|
||||
is doing in real time, learns your team's decisions and working dynamics, and
|
||||
gives everyone a live picture of where the others are — without anyone having to
|
||||
stop and ask.
|
||||
|
||||
### The five-minute meeting that isn't
|
||||
|
||||
> **What people assume:** "Quick question, five minutes."
|
||||
> **What actually happens:** the interrupted developer loses their place and needs
|
||||
> 15–25 minutes to climb back into deep focus. That five-minute ask quietly costs
|
||||
> half an hour — for *two* people.
|
||||
|
||||
Multiply that by every teammate, every day, and coordination overhead — not
|
||||
engineering skill — becomes the real ceiling on how fast a team ships.
|
||||
|
||||
PodMan removes the reason to interrupt. Because it already knows who is touching
|
||||
which file, what's still unpushed, and what each person is in the middle of, any
|
||||
teammate can see another's status instantly — no tap on the shoulder, no standup,
|
||||
no recovery tax. And it gets better as it goes: every accept or dismiss teaches
|
||||
it what your team actually cares about, so it nudges less and helps more over
|
||||
time.
|
||||
|
||||
> GitHub sees pushed work. PodMan sees work while it is still happening — and
|
||||
> remembers what helped.
|
||||
PodMan is not a dashboard and not a raw screenshot analyzer. Its job is to
|
||||
notice useful coordination moments, remember what helped before, and route the
|
||||
least intrusive intervention: a small card first, a Hermes message when teammates
|
||||
need coordination, and voice only for urgent escalation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 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.
|
||||
PodMan is split into a browser PWA, an HTTP API service, a LiveKit agent worker,
|
||||
and a persistence/action layer. The screen signal flows through LiveKit, not a
|
||||
manual screenshot upload endpoint.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -106,19 +39,17 @@ flowchart LR
|
||||
subgraph Realtime["LiveKit room"]
|
||||
Room["Pod room"]
|
||||
Data["Data topic<br/>podman.intervention"]
|
||||
Audio["Audio tracks<br/>Gemini TTS + Lyria"]
|
||||
end
|
||||
|
||||
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)"]
|
||||
Agent["Agent worker<br/>@livekit/rtc-node"]
|
||||
Vision["Gemini Vision<br/>structured JSON"]
|
||||
Detector["Coordination detector<br/>collisions, blockers"]
|
||||
Detector["Coordination detector<br/>collisions, blockers, dead ends"]
|
||||
end
|
||||
|
||||
subgraph Memory["Memory and actions"]
|
||||
Mongo["MongoDB Atlas<br/>observations, outcomes, vectors"]
|
||||
Mongo["MongoDB<br/>observations, outcomes, pods"]
|
||||
GitHub["GitHub<br/>repo state + sync PR artifact"]
|
||||
Hermes["Hermes action layer<br/>cards, messages, urgent voice"]
|
||||
end
|
||||
@@ -135,36 +66,23 @@ flowchart LR
|
||||
Detector --> GitHub
|
||||
Detector --> Hermes
|
||||
Hermes --> Data
|
||||
Hermes --> Audio
|
||||
Convo --> Audio
|
||||
Convo --> Mongo
|
||||
Data --> PWA
|
||||
Audio --> PWA
|
||||
PWA -->|"POST /api/outcome"| API
|
||||
API --> Mongo
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Runtime shape
|
||||
|
||||
|
||||
| Layer | Runtime | Responsibility |
|
||||
| ----------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Frontend PWA | React + Vite | Join pods, publish screen share, render interventions, play audio |
|
||||
| Backend API | Express | Mint LiveKit tokens, manage pods, record outcomes, expose memory stats, create sync PRs |
|
||||
| Vision agent | `@livekit/rtc-node` | Subscribe to screen-share tracks, sample frames, publish intervention data |
|
||||
| Live conversation agent | LiveKit Agents (Python) + Gemini Live API | Real-time voice Q&A with function tools over repo, git, and memory |
|
||||
| Perception | Gemini Vision (`gemini-2.0-flash`) | Sampled IDE frames → structured work context |
|
||||
| Team memory | MongoDB Atlas | Observations, collisions, interventions, outcomes, vector embeddings |
|
||||
| Git watcher | Node script | Report each laptop's dirty/unpushed state as ground truth |
|
||||
| Action layer | Hermes | Cards, teammate messages, Gemini TTS urgent voice, Lyria background score |
|
||||
| Deployment | DigitalOcean | Static frontend, API service, agent workers |
|
||||
|
||||
|
||||
|
||||
| ------------ | ------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Frontend PWA | React + Vite | Join pods, publish screen share, show live room state, render interventions |
|
||||
| Backend API | Express | Mint LiveKit tokens, manage pods, record outcomes, expose memory stats, create sync PR artifacts |
|
||||
| Agent worker | `@livekit/rtc-node` | Join the room as PodMan, subscribe to screen-share tracks, sample frames, publish intervention data |
|
||||
| Vision loop | Gemini | Convert sampled IDE frames into structured work context |
|
||||
| Team memory | MongoDB | Store observations, collisions, interventions, outcomes, pods, and git watcher state |
|
||||
| Git watcher | Node script | Poll each laptop's local git state so dirty/unpushed work is not guessed from vision alone |
|
||||
| Action layer | Hermes concept | Route cards, teammate messages, optional research summaries, and urgent voice escalation |
|
||||
| Deployment | DigitalOcean | Static site for frontend, HTTP service for API, worker for the LiveKit agent |
|
||||
|
||||
### Data flow
|
||||
|
||||
@@ -178,150 +96,139 @@ sequenceDiagram
|
||||
participant Gemini as Gemini Vision
|
||||
participant Mongo as MongoDB Memory
|
||||
participant Hermes as Hermes / Action Layer
|
||||
participant GH as GitHub
|
||||
|
||||
Dev->>API: POST /api/token
|
||||
API-->>Dev: LiveKit URL + JWT
|
||||
Dev->>LK: Join pod room + publish screen share
|
||||
Dev->>LK: Join pod room
|
||||
Dev->>LK: Publish screen-share track
|
||||
Agent->>LK: Subscribe to screen-share video
|
||||
Agent->>Gemini: Sampled JPEG frame
|
||||
Gemini-->>Agent: Structured work context
|
||||
Agent->>Mongo: Record observation
|
||||
Agent->>Mongo: Recall prior patterns (vector search)
|
||||
Mongo-->>Agent: Prior intervention + outcome
|
||||
Agent->>Hermes: Create intervention (gated by policy)
|
||||
Hermes->>LK: Publish card / message / Gemini TTS voice
|
||||
LK-->>Dev: Render intervention
|
||||
Dev->>API: POST /api/outcome (accept / dismiss)
|
||||
Agent->>GH: Read public repo state
|
||||
Agent->>Mongo: Recall prior patterns
|
||||
Agent->>Hermes: Create intervention
|
||||
Hermes->>LK: Publish small data packet
|
||||
LK-->>Dev: Render card / message / urgent voice cue
|
||||
Dev->>API: POST /api/outcome
|
||||
API->>Mongo: Store learning signal
|
||||
```
|
||||
|
||||
### Why this architecture matters
|
||||
|
||||
- **LiveKit is the realtime spine.** Screens and intervention data move through a
|
||||
shared room, so PodMan can react before code is pushed.
|
||||
- **Gemini is the perception layer.** The agent samples frames and asks Gemini
|
||||
for structured JSON such as current file, symbol, activity, unpushed hints,
|
||||
and confidence.
|
||||
- **MongoDB is the learning loop.** Outcomes and repeated patterns make later
|
||||
interventions quieter and more useful.
|
||||
- **Local git is the truth source.** The watcher reports dirty files and branch
|
||||
state directly from each laptop, which avoids relying on vision for facts
|
||||
GitHub cannot see.
|
||||
- **Hermes keeps it non-intrusive.** Most events are cards. Team messages and
|
||||
voice are escalation paths, not the default.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 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
|
||||
2. The backend API mints a LiveKit token via `POST /api/token`.
|
||||
3. The PWA publishes screen share into the pod room when the engineer chooses
|
||||
"Share my screen".
|
||||
4. The PodMan agent worker joins the same room and subscribes to screen-share
|
||||
tracks.
|
||||
5. The agent samples frames, sends them to Gemini Vision, and records structured
|
||||
observations in MongoDB.
|
||||
6. Each engineer runs the git watcher so PodMan has deterministic
|
||||
dirty/unpushed truth.
|
||||
7. The detector fuses live screen context, git truth, GitHub state, and recalled
|
||||
6. Each engineer runs the git watcher so PodMan has deterministic dirty/unpushed
|
||||
state.
|
||||
7. The detector combines live screen context, git truth, GitHub state, and team
|
||||
memory.
|
||||
8. 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.
|
||||
8. PodMan sends the smallest useful intervention: card first, Hermes message for
|
||||
coordination, voice only when urgent.
|
||||
9. The user's response is saved as an outcome, closing the continual-learning
|
||||
loop.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Public interfaces
|
||||
|
||||
|
||||
| Interface | Purpose |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------- |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `GET /health` | API health check |
|
||||
| `POST /api/token` | Mint LiveKit room tokens |
|
||||
| `POST /api/sync-pr` | Create a visible sync PR artifact |
|
||||
| `POST /api/outcome` | Store accepted/dismissed intervention outcomes |
|
||||
| `GET /api/memory/stats` | Memory collection counts (live learning evidence) |
|
||||
| `GET /api/memory/stats` | Show memory collection counts |
|
||||
| `GET/POST/PATCH/DELETE /api/pods` | Pod CRUD |
|
||||
| `POST/DELETE /api/pods/:id/members` | Pod membership |
|
||||
| LiveKit topic `podman.intervention` | Intervention data channel |
|
||||
| Wire messages `COLLISION`, `ACK`, `GIT_REPORT`, `VOICE_CUE` | Agent/PWA contract |
|
||||
|
||||
| Wire messages `COLLISION`, `ACK`, `GIT_REPORT`, `VOICE_CUE` | Shared agent/PWA message contract |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Monorepo layout
|
||||
|
||||
|
||||
| Folder | What |
|
||||
| ----------- | -------------------------------------------------------------------------- |
|
||||
| `frontend/` | React + Vite PWA — pods, LiveKit room UI, screen share, intervention cards |
|
||||
| `backend/` | Express API plus the LiveKit vision agent worker |
|
||||
| `agents/` | Python LiveKit Agents worker for the Gemini Live conversation agent |
|
||||
| ----------- | -------------------------------------------------------------------------------- |
|
||||
| `frontend/` | React + Vite PWA for pods, LiveKit room UI, screen share, and intervention cards |
|
||||
| `backend/` | Express API plus separate LiveKit agent worker |
|
||||
| `shared/` | Shared TypeScript types and LiveKit data message contracts |
|
||||
| `database/` | MongoDB setup and seed utilities |
|
||||
| `infra/` | DigitalOcean specs, Caddyfile, systemd units |
|
||||
| `scripts/` | Local git watcher + deploy/verify tooling |
|
||||
| `docs/` | Integration specs and the demo script |
|
||||
|
||||
| `infra/` | DigitalOcean App Platform specs and Dockerfile |
|
||||
| `scripts/` | Local git watcher for demo laptops |
|
||||
| `docs/` | Canonical plan and deeper sponsor/integration notes |
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
| File | What |
|
||||
| ---------------------------------------------- | ----------------------------------------- |
|
||||
| [`docs/PLAN.md`](docs/PLAN.md) | Canonical master plan and source of truth |
|
||||
| [`docs/idea.md`](docs/idea.md) | Product concept and demo framing |
|
||||
| [`docs/livekit.md`](docs/livekit.md) | LiveKit notes and room model |
|
||||
| [`docs/gemini.md`](docs/gemini.md) | Gemini vision and voice notes |
|
||||
| [`docs/mongodb.md`](docs/mongodb.md) | MongoDB memory design |
|
||||
| [`docs/digitalocean.md`](docs/digitalocean.md) | Deployment notes |
|
||||
| [`docs/demo-setup.md`](docs/demo-setup.md) | Demo laptop and stage checklist |
|
||||
|
||||
---
|
||||
|
||||
## Prizes targeted
|
||||
|
||||
- **Best Gemini:** structured vision over live IDE context, with voice as an
|
||||
optional escalation path.
|
||||
- **Best LiveKit:** realtime screen-share tracks, presence, data packets, and
|
||||
eventual voice in one pod room.
|
||||
- **Best DigitalOcean:** frontend static site, API service, and LiveKit agent
|
||||
worker deployment.
|
||||
- **MongoDB + Voyage story:** persistent memory first, vector recall once exact
|
||||
signature recall is proven.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# fill in LIVEKIT_*, GEMINI_*, GITHUB_*, MONGODB_URI
|
||||
# fill in LIVEKIT_*, GEMINI_*, GITHUB_*, and MONGODB_URI
|
||||
|
||||
pnpm install
|
||||
pnpm --filter @podman/backend dev # API on :8787
|
||||
pnpm --filter @podman/backend dev:agent # PodMan LiveKit vision agent
|
||||
pnpm --filter @podman/backend dev:agent # PodMan LiveKit agent
|
||||
pnpm --filter @podman/frontend dev # PWA on :5173
|
||||
```
|
||||
|
||||
The live conversation agent (Gemini Live API) runs from `agents/podman-live-conversation/`.
|
||||
|
||||
---
|
||||
|
||||
## Git watcher - run this on every demo laptop
|
||||
|
||||
|
||||
## 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.
|
||||
Each engineer runs this in a terminal 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
|
||||
# from the repo root
|
||||
@@ -336,5 +243,12 @@ node scripts/podman-agent.mjs --name bob --pod demo-pod
|
||||
node scripts/podman-agent.mjs --name carol --pod demo-pod
|
||||
```
|
||||
|
||||
**Requirements:** `MONGODB_URI` exported (or in `backend/.env`), `pnpm install`
|
||||
run first, launched from the repo root.
|
||||
The script logs one line per cycle: branch, changed file count, and latest
|
||||
commit. Leave it running in a background terminal tab throughout the session.
|
||||
Stop with `Ctrl+C`.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- `MONGODB_URI` must be exported in the shell or present in `backend/.env`.
|
||||
- Run `pnpm install` first so workspace dependencies are available.
|
||||
- Run from the repo root.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
LIVEKIT_URL=wss://stackauthnov28-kt4gd6fq.livekit.cloud
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
GOOGLE_API_KEY=
|
||||
GEMINI_CONVERSATION_MODEL=gemini-3.1-flash-live-preview
|
||||
GEMINI_CONVERSATION_VOICE=Aoede
|
||||
PODMAN_BACKEND_URL=http://127.0.0.1:8787
|
||||
INTERNAL_AGENT_TOKEN=
|
||||
@@ -1,22 +0,0 @@
|
||||
# PodMan Live Conversation Agent
|
||||
|
||||
Private 1:1 LiveKit Agent worker for PodMan Live Conversation.
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
cd agents/podman-live-conversation
|
||||
uv sync --extra test
|
||||
cp .env.example .env.local
|
||||
uv run agent.py dev
|
||||
```
|
||||
|
||||
Required env:
|
||||
|
||||
- `LIVEKIT_URL`
|
||||
- `LIVEKIT_API_KEY`
|
||||
- `LIVEKIT_API_SECRET`
|
||||
- `GOOGLE_API_KEY` or `GEMINI_API_KEY`
|
||||
- `PODMAN_BACKEND_URL`
|
||||
- `INTERNAL_AGENT_TOKEN`
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from livekit import agents
|
||||
from livekit.agents import Agent, AgentServer, AgentSession, RunContext, function_tool
|
||||
from livekit.plugins import google
|
||||
|
||||
load_dotenv(".env.local")
|
||||
if not os.getenv("GOOGLE_API_KEY") and os.getenv("GEMINI_API_KEY"):
|
||||
os.environ["GOOGLE_API_KEY"] = os.environ["GEMINI_API_KEY"]
|
||||
|
||||
logger = logging.getLogger("podman-live-conversation")
|
||||
|
||||
AGENT_NAME = "podman-live-conversation"
|
||||
MODEL = os.getenv("GEMINI_CONVERSATION_MODEL", "gemini-3.1-flash-live-preview")
|
||||
VOICE = os.getenv("GEMINI_CONVERSATION_VOICE", "Aoede")
|
||||
BACKEND_URL = os.getenv("PODMAN_BACKEND_URL", "http://127.0.0.1:8787").rstrip("/")
|
||||
INTERNAL_AGENT_TOKEN = os.getenv("INTERNAL_AGENT_TOKEN", "")
|
||||
REPO_SLUG = os.getenv("PODMAN_REPO_SLUG", "karti-ai/podman")
|
||||
|
||||
|
||||
def _resolve_repo_root() -> str:
|
||||
override = os.getenv("PODMAN_REPO_ROOT")
|
||||
if override:
|
||||
return override
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", here, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if out.returncode == 0 and out.stdout.strip():
|
||||
return out.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.abspath(os.path.join(here, "..", ".."))
|
||||
|
||||
|
||||
REPO_ROOT = _resolve_repo_root()
|
||||
|
||||
INSTRUCTIONS = """You are PodMan, a concise real-time engineering teammate.
|
||||
You are in a private 1:1 voice conversation with one developer.
|
||||
|
||||
Use PodMan tools before making claims about current work, git state, collisions, blockers,
|
||||
team memory, or recent decisions. Keep spoken answers short. Prefer one useful next step.
|
||||
If a critical collision event arrives, stop the current turn and state the alert immediately.
|
||||
To find code, files, symbols, or how something is implemented in the repository, call search_repo.
|
||||
For git commit history, authorship, recent changes, or which commit introduced something, call
|
||||
repo_recent_commits or repo_find_commits.
|
||||
For complex repository, terminal, GitHub, MongoDB, build, install, deploy, or multi-step tasks,
|
||||
call delegate_to_hermes. Do not run those actions directly. If the user says stop, wait, cancel,
|
||||
or change of plans while Hermes is running, call abort_active_hermes_job immediately.
|
||||
Do not reveal raw secrets, API keys, private tokens, or another teammate's private notes."""
|
||||
|
||||
|
||||
def parse_metadata(raw: str | None) -> dict[str, str]:
|
||||
try:
|
||||
data = json.loads(raw or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {str(k): str(v) for k, v in data.items() if v is not None}
|
||||
|
||||
|
||||
def request_json(path: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> Any:
|
||||
if not INTERNAL_AGENT_TOKEN:
|
||||
raise RuntimeError("INTERNAL_AGENT_TOKEN is not configured")
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
req = request.Request(
|
||||
f"{BACKEND_URL}{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"authorization": f"Bearer {INTERNAL_AGENT_TOKEN}",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=5) as res:
|
||||
payload = res.read().decode("utf-8")
|
||||
return json.loads(payload) if payload else {}
|
||||
except error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")
|
||||
raise RuntimeError(f"PodMan backend returned {exc.code}: {detail}") from exc
|
||||
|
||||
|
||||
async def _run_git(args: list[str], timeout: float = 15.0) -> tuple[int, str, str]:
|
||||
"""Run a read-only git command inside the repo checkout and capture its output."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"-C",
|
||||
REPO_ROOT,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return 124, "", "git command timed out"
|
||||
except FileNotFoundError:
|
||||
return 127, "", "git is not available on this host"
|
||||
return proc.returncode, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")
|
||||
|
||||
|
||||
class PodManLiveAgent(Agent):
|
||||
def __init__(self, pod_id: str, identity: str, session_id: str, conversation_room: str) -> None:
|
||||
super().__init__(instructions=INSTRUCTIONS)
|
||||
self.pod_id = pod_id
|
||||
self.identity = identity
|
||||
self.session_id = session_id
|
||||
self.conversation_room = conversation_room
|
||||
self.active_hermes_job_id: str | None = None
|
||||
self.last_spoken_progress_at = 0.0
|
||||
|
||||
@function_tool()
|
||||
async def get_active_pod_context(self, context: RunContext) -> str:
|
||||
"""Get the current PodMan context for this developer and pod."""
|
||||
data = await asyncio.to_thread(
|
||||
request_json,
|
||||
f"/api/internal/pods/{self.pod_id}/live-context?identity={self.identity}",
|
||||
)
|
||||
return json.dumps(data, ensure_ascii=True)[:12000]
|
||||
|
||||
@function_tool()
|
||||
async def record_conversation_note(self, context: RunContext, note: str, kind: str = "summary") -> str:
|
||||
"""Store a useful decision, outcome, or preference learned during this conversation."""
|
||||
await asyncio.to_thread(
|
||||
request_json,
|
||||
f"/api/internal/pods/{self.pod_id}/live-conversation/{self.session_id}/note",
|
||||
method="POST",
|
||||
body={"identity": self.identity, "kind": kind, "note": note},
|
||||
)
|
||||
return "Saved to PodMan memory."
|
||||
|
||||
@function_tool()
|
||||
async def get_recent_changes(self, context: RunContext) -> str:
|
||||
"""Get recent local git and activity signals for this developer."""
|
||||
data = await asyncio.to_thread(
|
||||
request_json,
|
||||
f"/api/internal/pods/{self.pod_id}/live-context?identity={self.identity}",
|
||||
)
|
||||
focused = {
|
||||
"identity": data.get("identity"),
|
||||
"currentGitState": data.get("currentGitState"),
|
||||
"memberHistory": data.get("memberHistory"),
|
||||
"recentCollisions": data.get("recentCollisions"),
|
||||
}
|
||||
return json.dumps(focused, ensure_ascii=True)[:8000]
|
||||
|
||||
@function_tool()
|
||||
async def search_team_memory(self, context: RunContext, query: str) -> str:
|
||||
"""Search current compact team memory for information relevant to a query."""
|
||||
data = await asyncio.to_thread(
|
||||
request_json,
|
||||
f"/api/internal/pods/{self.pod_id}/live-context?identity={self.identity}",
|
||||
)
|
||||
haystack = json.dumps(data, ensure_ascii=True)
|
||||
query_terms = [term.lower() for term in query.split() if len(term) > 2]
|
||||
if not query_terms:
|
||||
return haystack[:6000]
|
||||
snippets = []
|
||||
lower = haystack.lower()
|
||||
for term in query_terms[:8]:
|
||||
idx = lower.find(term)
|
||||
if idx >= 0:
|
||||
snippets.append(haystack[max(0, idx - 400) : idx + 1200])
|
||||
return "\n---\n".join(snippets)[:8000] or haystack[:6000]
|
||||
|
||||
@function_tool()
|
||||
async def search_repo(self, context: RunContext, query: str, max_results: int = 12) -> str:
|
||||
"""Search the team's code repository (github.com/karti-ai/podman) for code, symbols,
|
||||
filenames, config, or any text. Use this to find where something is implemented or which
|
||||
files mention a term before answering questions about the codebase. Searches the live
|
||||
local checkout of the main branch, so results are always current.
|
||||
"""
|
||||
cleaned = " ".join(query.split()).strip()
|
||||
if not cleaned:
|
||||
return "Provide a non-empty search query."
|
||||
limit = max(1, min(int(max_results or 12), 40))
|
||||
cmd = [
|
||||
"rg",
|
||||
"--line-number",
|
||||
"--no-heading",
|
||||
"--color",
|
||||
"never",
|
||||
"--smart-case",
|
||||
"--max-count",
|
||||
"3",
|
||||
"--max-columns",
|
||||
"240",
|
||||
"-g",
|
||||
"!*.lock",
|
||||
"-g",
|
||||
"!pnpm-lock.yaml",
|
||||
"-g",
|
||||
"!uv.lock",
|
||||
"-g",
|
||||
"!*.min.*",
|
||||
"--",
|
||||
cleaned,
|
||||
REPO_ROOT,
|
||||
]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
|
||||
except asyncio.TimeoutError:
|
||||
return "Repo search timed out. Try a more specific query."
|
||||
except FileNotFoundError:
|
||||
return "Repo search is unavailable on this host (ripgrep is not installed)."
|
||||
if proc.returncode not in (0, 1): # rg: 0=match, 1=no match, 2=error
|
||||
return f"Repo search failed: {stderr.decode('utf-8', 'replace')[:300]}"
|
||||
prefix = REPO_ROOT + os.sep
|
||||
lines: list[str] = []
|
||||
for line in stdout.decode("utf-8", "replace").splitlines():
|
||||
lines.append(line[len(prefix) :] if line.startswith(prefix) else line)
|
||||
if len(lines) >= limit:
|
||||
break
|
||||
if not lines:
|
||||
return f'No matches for "{cleaned}" in {REPO_SLUG}.'
|
||||
body = "\n".join(lines)
|
||||
return f'Matches for "{cleaned}" in {REPO_SLUG} (path:line):\n{body}'[:7000]
|
||||
|
||||
@function_tool()
|
||||
async def repo_recent_commits(
|
||||
self, context: RunContext, path: str = "", author: str = "", limit: int = 15
|
||||
) -> str:
|
||||
"""Show recent git commit history for github.com/karti-ai/podman: who committed what and when.
|
||||
Optionally scope to a file or folder (path) or filter by author name/email (author).
|
||||
Use this for questions about recent changes, authorship, or a specific file's history.
|
||||
"""
|
||||
n = max(1, min(int(limit or 15), 50))
|
||||
args = [
|
||||
"log",
|
||||
f"--max-count={n}",
|
||||
"--no-color",
|
||||
"--date=short",
|
||||
"--pretty=format:%h | %an | %ad | %s",
|
||||
]
|
||||
if author.strip():
|
||||
args.append(f"--author={author.strip()}")
|
||||
if path.strip():
|
||||
args += ["--", path.strip()]
|
||||
code, out, err = await _run_git(args)
|
||||
if code != 0:
|
||||
return f"Git history lookup failed: {err.strip()[:300] or 'unknown error'}"
|
||||
out = out.strip()
|
||||
if not out:
|
||||
scope = f" for {path.strip()}" if path.strip() else ""
|
||||
who = f" by {author.strip()}" if author.strip() else ""
|
||||
return f"No commits found{scope}{who}."
|
||||
return f"Recent commits in {REPO_SLUG} (hash | author | date | subject):\n{out}"[:7000]
|
||||
|
||||
@function_tool()
|
||||
async def repo_find_commits(
|
||||
self, context: RunContext, query: str, by: str = "message", limit: int = 15
|
||||
) -> str:
|
||||
"""Find commits in github.com/karti-ai/podman. by='message' searches commit messages;
|
||||
by='code' finds commits that added or removed the query text in the code (pickaxe).
|
||||
Use by='code' for "which commit introduced X"; use by='message' for "commits about X".
|
||||
"""
|
||||
cleaned = " ".join(query.split()).strip()
|
||||
if not cleaned:
|
||||
return "Provide a non-empty query."
|
||||
n = max(1, min(int(limit or 15), 50))
|
||||
args = [
|
||||
"log",
|
||||
f"--max-count={n}",
|
||||
"--no-color",
|
||||
"--date=short",
|
||||
"--pretty=format:%h | %an | %ad | %s",
|
||||
]
|
||||
mode = by.strip().lower()
|
||||
if mode == "code":
|
||||
args.append(f"-S{cleaned}")
|
||||
else:
|
||||
mode = "message"
|
||||
args += ["-i", f"--grep={cleaned}"]
|
||||
code, out, err = await _run_git(args)
|
||||
if code != 0:
|
||||
return f"Commit search failed: {err.strip()[:300] or 'unknown error'}"
|
||||
out = out.strip()
|
||||
if not out:
|
||||
return f'No commits found matching "{cleaned}" (by {mode}).'
|
||||
return (
|
||||
f'Commits in {REPO_SLUG} matching "{cleaned}" (by {mode}) — hash | author | date | subject:\n{out}'[
|
||||
:7000
|
||||
]
|
||||
)
|
||||
|
||||
@function_tool()
|
||||
async def delegate_to_hermes(
|
||||
self,
|
||||
context: RunContext,
|
||||
prompt: str,
|
||||
context_scope: str = "current_repo",
|
||||
target_repository: str = "",
|
||||
risk_level: str = "read_only",
|
||||
requires_confirmation: bool = False,
|
||||
success_criteria: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Hand off a complex engineering task to Hermes, PodMan's autonomous backend execution engine.
|
||||
|
||||
Use this for filesystem, terminal, GitHub, MongoDB, build, install, deploy, test,
|
||||
or multi-step repository tasks. Do not use this for simple conversational answers.
|
||||
"""
|
||||
body = {
|
||||
"prompt": prompt,
|
||||
"contextScope": context_scope,
|
||||
"targetRepository": target_repository or "karti-ai/podman",
|
||||
"riskLevel": risk_level,
|
||||
"requiresConfirmation": requires_confirmation,
|
||||
"successCriteria": success_criteria or ["Hermes completes the requested inspection."],
|
||||
"podId": self.pod_id,
|
||||
"identity": self.identity,
|
||||
"sessionId": self.session_id,
|
||||
"conversationRoom": self.conversation_room,
|
||||
}
|
||||
job = await asyncio.to_thread(request_json, "/api/internal/hermes/jobs", method="POST", body=body)
|
||||
self.active_hermes_job_id = str(job["id"])
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "accepted",
|
||||
"job_id": self.active_hermes_job_id,
|
||||
"spoken_ack": "Hermes is starting that now. I will keep you posted.",
|
||||
},
|
||||
ensure_ascii=True,
|
||||
)
|
||||
|
||||
@function_tool()
|
||||
async def abort_active_hermes_job(self, context: RunContext, reason: str = "User changed plans") -> str:
|
||||
"""Abort the currently running Hermes job immediately."""
|
||||
if not self.active_hermes_job_id:
|
||||
return "No active Hermes job is running."
|
||||
job = await asyncio.to_thread(
|
||||
request_json,
|
||||
f"/api/internal/hermes/jobs/{self.active_hermes_job_id}/abort",
|
||||
method="POST",
|
||||
body={"reason": reason},
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": job.get("status", "aborting"),
|
||||
"job_id": self.active_hermes_job_id,
|
||||
"spoken_ack": "Stopped. Hermes is aborting the job before making further changes.",
|
||||
},
|
||||
ensure_ascii=True,
|
||||
)
|
||||
|
||||
def should_speak_progress(self, event: dict[str, Any]) -> bool:
|
||||
event_type = event.get("type")
|
||||
if event_type in {"completed", "failed", "aborted", "needs_confirmation"}:
|
||||
return True
|
||||
if event_type not in {"heartbeat", "step_started", "step_completed"}:
|
||||
return False
|
||||
monotonic = time.monotonic()
|
||||
if monotonic - self.last_spoken_progress_at < 8:
|
||||
return False
|
||||
self.last_spoken_progress_at = monotonic
|
||||
return True
|
||||
|
||||
|
||||
server = AgentServer()
|
||||
|
||||
|
||||
@server.rtc_session(agent_name=AGENT_NAME)
|
||||
async def entrypoint(ctx: agents.JobContext):
|
||||
metadata = parse_metadata(getattr(ctx.job, "metadata", None))
|
||||
pod_id = metadata.get("podId", "demo-pod")
|
||||
identity = metadata.get("identity", "developer")
|
||||
session_id = metadata.get("sessionId", "unknown")
|
||||
|
||||
session = AgentSession(
|
||||
llm=google.realtime.RealtimeModel(
|
||||
model=MODEL,
|
||||
voice=VOICE,
|
||||
),
|
||||
)
|
||||
agent = PodManLiveAgent(
|
||||
pod_id=pod_id,
|
||||
identity=identity,
|
||||
session_id=session_id,
|
||||
conversation_room=ctx.room.name,
|
||||
)
|
||||
|
||||
def on_data_received(*args: Any):
|
||||
payload = args[0] if args else b""
|
||||
if isinstance(payload, str):
|
||||
raw = payload
|
||||
else:
|
||||
raw = bytes(payload).decode("utf-8", "replace")
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
msg_type = msg.get("type")
|
||||
if msg_type == "HERMES_JOB_EVENT":
|
||||
event = msg.get("event") or {}
|
||||
summary = str(event.get("message") or "").strip()
|
||||
if str(event.get("type")) in {"completed", "failed", "aborted"}:
|
||||
agent.active_hermes_job_id = None
|
||||
elif msg_type == "LIVE_CONVERSATION_EVENT":
|
||||
event = msg.get("event") or {}
|
||||
summary = str(event.get("summary") or "").strip()
|
||||
else:
|
||||
return
|
||||
if not summary:
|
||||
return
|
||||
|
||||
async def interrupt_and_say() -> None:
|
||||
try:
|
||||
if msg_type == "LIVE_CONVERSATION_EVENT":
|
||||
await session.interrupt(force=True)
|
||||
except Exception as exc:
|
||||
logger.warning("interrupt failed: %s", exc)
|
||||
if msg_type == "LIVE_CONVERSATION_EVENT" or agent.should_speak_progress(event):
|
||||
await session.say(summary, allow_interruptions=True, add_to_chat_ctx=True)
|
||||
|
||||
asyncio.create_task(interrupt_and_say())
|
||||
|
||||
ctx.room.on("data_received", on_data_received)
|
||||
await session.start(room=ctx.room, agent=agent)
|
||||
await ctx.connect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
agents.cli.run_app(server)
|
||||
@@ -1,19 +0,0 @@
|
||||
[project]
|
||||
name = "podman-live-conversation"
|
||||
version = "0.1.0"
|
||||
description = "PodMan private LiveKit/Gemini live conversation agent"
|
||||
requires-python = ">=3.10,<3.14"
|
||||
dependencies = [
|
||||
"livekit-agents[google]>=1.6.4,<1.7",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=8.0.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
@@ -1,26 +0,0 @@
|
||||
from agent import PodManLiveAgent, parse_metadata
|
||||
|
||||
|
||||
def test_parse_metadata_accepts_valid_json():
|
||||
assert parse_metadata('{"podId":"demo-pod","identity":"yahya","sessionId":"s1"}') == {
|
||||
"podId": "demo-pod",
|
||||
"identity": "yahya",
|
||||
"sessionId": "s1",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_metadata_handles_bad_json():
|
||||
assert parse_metadata("not json") == {}
|
||||
|
||||
|
||||
def test_hermes_terminal_events_always_speak():
|
||||
agent = PodManLiveAgent("demo-pod", "yahya", "s1", "room")
|
||||
assert agent.should_speak_progress({"type": "completed"}) is True
|
||||
assert agent.should_speak_progress({"type": "failed"}) is True
|
||||
assert agent.should_speak_progress({"type": "aborted"}) is True
|
||||
|
||||
|
||||
def test_hermes_progress_is_throttled():
|
||||
agent = PodManLiveAgent("demo-pod", "yahya", "s1", "room")
|
||||
assert agent.should_speak_progress({"type": "heartbeat"}) is True
|
||||
assert agent.should_speak_progress({"type": "step_started"}) is False
|
||||
Generated
-2275
File diff suppressed because it is too large
Load Diff
@@ -1,96 +0,0 @@
|
||||
import type { Room } from '@livekit/rtc-node';
|
||||
import { Room as LiveKitRoom } from '@livekit/rtc-node';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import type { Collision, DataMessage, HermesMessage, Intervention } from '@podman/shared';
|
||||
import { DATA_TOPIC } from '@podman/shared';
|
||||
import { env } from '../env.js';
|
||||
import { speak } from '../voice/live.js';
|
||||
import { notifyCriticalLiveConversations } from '../live-conversation/sessions.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function teammateText(collision: Collision, intervention: Intervention): string {
|
||||
return `${collision.engineers.join(', ')}: ${intervention.message}`;
|
||||
}
|
||||
|
||||
export function createHermesMessage(
|
||||
collision: Collision,
|
||||
intervention: Intervention,
|
||||
): HermesMessage {
|
||||
return {
|
||||
id: `hermes_${Date.now()}`,
|
||||
podId: collision.podId,
|
||||
interventionId: intervention.id,
|
||||
recipients: collision.engineers,
|
||||
text: teammateText(collision, intervention),
|
||||
urgency: collision.severity === 'critical' ? 'urgent' : 'normal',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishHermesMessage(
|
||||
room: Room,
|
||||
collision: Collision,
|
||||
intervention: Intervention,
|
||||
): Promise<void> {
|
||||
const data: DataMessage = {
|
||||
type: 'HERMES_MESSAGE',
|
||||
message: createHermesMessage(collision, intervention),
|
||||
};
|
||||
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishHermesIntervention(
|
||||
room: Room,
|
||||
collision: Collision,
|
||||
intervention: Intervention,
|
||||
voiceLine?: string,
|
||||
): Promise<void> {
|
||||
const data: DataMessage = { type: 'COLLISION', collision, intervention };
|
||||
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
await publishHermesMessage(room, collision, intervention);
|
||||
void notifyCriticalLiveConversations(collision, intervention, voiceLine).catch((err) =>
|
||||
console.warn(`[live-conversation] critical notify failed: ${(err as Error).message}`),
|
||||
);
|
||||
if (voiceLine) await speak(room, voiceLine, { priority: 'critical' });
|
||||
}
|
||||
|
||||
async function hermesToken(roomName: string): Promise<string> {
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
identity: `podman-hermes-${Date.now()}`,
|
||||
name: 'PodMan Hermes',
|
||||
ttl: '10m',
|
||||
});
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: roomName,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
return at.toJwt();
|
||||
}
|
||||
|
||||
export async function notifyHermesInterventionInRoom(
|
||||
roomName: string,
|
||||
collision: Collision,
|
||||
intervention: Intervention,
|
||||
voiceLine?: string,
|
||||
): Promise<void> {
|
||||
const room = new LiveKitRoom();
|
||||
try {
|
||||
await room.connect(env.LIVEKIT_URL, await hermesToken(roomName), {
|
||||
autoSubscribe: false,
|
||||
dynacast: false,
|
||||
});
|
||||
await publishHermesIntervention(room, collision, intervention, voiceLine);
|
||||
} finally {
|
||||
await room.disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import type { EngineerContext, MemberWorkHistory, MemberWorkHistoryFile } from '@podman/shared';
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { parseGitStatusPath } from '../graph/live.js';
|
||||
|
||||
interface EngineerStateDoc {
|
||||
_id: string;
|
||||
podId: string;
|
||||
name: string;
|
||||
changedFiles?: string[];
|
||||
branch?: string | null;
|
||||
recentCommit?: string | null;
|
||||
gitUpdatedAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
|
||||
interface FileAccumulator {
|
||||
file: string;
|
||||
observations: number;
|
||||
gitChanges: number;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
confidenceSum: number;
|
||||
confidenceCount: number;
|
||||
activities: Set<string>;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
function toIso(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function dateMs(value: string | Date | undefined): number {
|
||||
if (value instanceof Date) return value.getTime();
|
||||
if (value) {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function clean(value: string | undefined): string {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function sameMember(a: string | undefined, b: string): boolean {
|
||||
return clean(a).toLowerCase() === b.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function addFile(files: Map<string, FileAccumulator>, file: string, at: number): FileAccumulator {
|
||||
const existing = files.get(file);
|
||||
if (existing) {
|
||||
if (at > 0) {
|
||||
existing.firstSeenAt = Math.min(existing.firstSeenAt || at, at);
|
||||
existing.lastSeenAt = Math.max(existing.lastSeenAt, at);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const acc: FileAccumulator = {
|
||||
file,
|
||||
observations: 0,
|
||||
gitChanges: 0,
|
||||
firstSeenAt: at,
|
||||
lastSeenAt: at,
|
||||
confidenceSum: 0,
|
||||
confidenceCount: 0,
|
||||
activities: new Set<string>(),
|
||||
current: false,
|
||||
};
|
||||
files.set(file, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
export async function getMemberWorkHistory(
|
||||
podId: string,
|
||||
member: string,
|
||||
options: { hours?: number; limit?: number } = {},
|
||||
): Promise<MemberWorkHistory> {
|
||||
const db = await getDb();
|
||||
const windowHours = Math.min(Math.max(options.hours ?? 24, 1), 168);
|
||||
const limit = Math.min(Math.max(options.limit ?? 80, 10), 200);
|
||||
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const [observations, gitState] = await Promise.all([
|
||||
db
|
||||
.collection<EngineerContext>('observations')
|
||||
.find({ podId, observedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||
.sort({ observedAt: -1 })
|
||||
.limit(500)
|
||||
.toArray(),
|
||||
db.collection<EngineerStateDoc>('engineer_states').findOne({
|
||||
podId,
|
||||
name: { $regex: `^${member.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, $options: 'i' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const files = new Map<string, FileAccumulator>();
|
||||
const timeline: MemberWorkHistory['timeline'] = [];
|
||||
const memberObservations = observations.filter((doc) => sameMember(doc.engineerId, member));
|
||||
|
||||
for (const doc of memberObservations) {
|
||||
const file = clean(doc.currentFile);
|
||||
if (!file) continue;
|
||||
const at = dateMs(doc.observedAt);
|
||||
const acc = addFile(files, file, at);
|
||||
acc.observations += 1;
|
||||
acc.current ||= timeline.length === 0;
|
||||
if (typeof doc.confidence === 'number') {
|
||||
acc.confidenceSum += doc.confidence;
|
||||
acc.confidenceCount += 1;
|
||||
}
|
||||
const activity = clean(doc.activity);
|
||||
if (activity) acc.activities.add(activity);
|
||||
timeline.push({
|
||||
id: `vision:${doc.engineerId}:${doc.observedAt}:${file}`,
|
||||
at: doc.observedAt,
|
||||
source: 'vision',
|
||||
file,
|
||||
title: activity || `Worked in ${file}`,
|
||||
detail: clean(doc.currentSymbol) ? `symbol ${doc.currentSymbol}` : undefined,
|
||||
confidence: doc.confidence,
|
||||
});
|
||||
}
|
||||
|
||||
const gitAt = dateMs(gitState?.gitUpdatedAt ?? gitState?.updatedAt);
|
||||
for (const raw of gitState?.changedFiles ?? []) {
|
||||
const file = parseGitStatusPath(raw);
|
||||
if (!file) continue;
|
||||
const acc = addFile(files, file, gitAt || Date.now());
|
||||
acc.gitChanges += 1;
|
||||
acc.current = true;
|
||||
timeline.push({
|
||||
id: `git:${gitState?._id}:${gitAt}:${file}`,
|
||||
at: toIso(gitAt || Date.now()),
|
||||
source: 'git',
|
||||
file,
|
||||
title: `Local change in ${file}`,
|
||||
detail: [gitState?.branch ? `branch ${gitState.branch}` : undefined, gitState?.recentCommit]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
const fileRows: MemberWorkHistoryFile[] = [...files.values()]
|
||||
.sort((a, b) => b.lastSeenAt - a.lastSeenAt || b.observations - a.observations)
|
||||
.slice(0, 12)
|
||||
.map((file) => ({
|
||||
file: file.file,
|
||||
observations: file.observations,
|
||||
gitChanges: file.gitChanges,
|
||||
firstSeenAt: toIso(file.firstSeenAt || file.lastSeenAt || Date.now()),
|
||||
lastSeenAt: toIso(file.lastSeenAt || file.firstSeenAt || Date.now()),
|
||||
confidenceAvg: file.confidenceCount
|
||||
? Math.round((file.confidenceSum / file.confidenceCount) * 100) / 100
|
||||
: null,
|
||||
activities: [...file.activities].slice(0, 3),
|
||||
current: file.current,
|
||||
}));
|
||||
|
||||
timeline.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
||||
|
||||
return {
|
||||
podId,
|
||||
member,
|
||||
generatedAt: new Date().toISOString(),
|
||||
windowHours,
|
||||
totals: {
|
||||
files: fileRows.length,
|
||||
observations: memberObservations.length,
|
||||
gitChanges: gitState?.changedFiles?.length ?? 0,
|
||||
},
|
||||
files: fileRows,
|
||||
timeline: timeline.slice(0, limit),
|
||||
};
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import type {
|
||||
Collision,
|
||||
EngineerContext,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
PodActivityEvent,
|
||||
} from '@podman/shared';
|
||||
import { getDb } from '../memory/db.js';
|
||||
|
||||
interface EngineerStateDoc {
|
||||
_id: string;
|
||||
podId: string;
|
||||
name: string;
|
||||
changedFiles?: string[];
|
||||
diffStat?: string | null;
|
||||
recentCommit?: string | null;
|
||||
branch?: string | null;
|
||||
gitUpdatedAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
|
||||
function toIso(value: Date | string | undefined): string {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (value) return new Date(value).toISOString();
|
||||
return new Date(0).toISOString();
|
||||
}
|
||||
|
||||
function clean(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function shortFiles(files: string[] | undefined): string {
|
||||
if (!files?.length) return 'clean working tree';
|
||||
const sample = files.slice(0, 3).join(', ');
|
||||
return files.length > 3 ? `${sample}, +${files.length - 3} more` : sample;
|
||||
}
|
||||
|
||||
function observationEvent(doc: EngineerContext): PodActivityEvent {
|
||||
const file = clean(doc.currentFile);
|
||||
const symbol = clean(doc.currentSymbol);
|
||||
return {
|
||||
id: `observation:${doc.engineerId}:${doc.observedAt}`,
|
||||
podId: doc.podId,
|
||||
kind: 'observation',
|
||||
source: 'vision',
|
||||
actor: doc.engineerId,
|
||||
actors: [doc.engineerId],
|
||||
file,
|
||||
imageUrl: doc.screenshotDataUrl,
|
||||
title: file ? `Working in ${file}` : 'Screen context updated',
|
||||
detail: [
|
||||
symbol ? `symbol ${symbol}` : undefined,
|
||||
clean(doc.activity),
|
||||
doc.hasUnpushedChanges ? 'unpushed changes visible' : undefined,
|
||||
`confidence ${Math.round(doc.confidence * 100)}%`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
severity: doc.hasUnpushedChanges ? 'warn' : 'info',
|
||||
at: doc.observedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function gitEvent(doc: EngineerStateDoc): PodActivityEvent {
|
||||
const changedFiles = doc.changedFiles ?? [];
|
||||
return {
|
||||
id: `git:${doc._id}:${toIso(doc.gitUpdatedAt ?? doc.updatedAt)}`,
|
||||
podId: doc.podId,
|
||||
kind: 'git',
|
||||
source: 'git',
|
||||
actor: doc.name,
|
||||
actors: [doc.name],
|
||||
title: changedFiles.length ? `${changedFiles.length} local file changes` : 'Git state is clean',
|
||||
detail: [
|
||||
doc.branch ? `branch ${doc.branch}` : undefined,
|
||||
shortFiles(changedFiles),
|
||||
doc.recentCommit ? `head ${doc.recentCommit}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
severity: changedFiles.length ? 'warn' : 'info',
|
||||
at: toIso(doc.gitUpdatedAt ?? doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function collisionEvent(doc: Collision): PodActivityEvent {
|
||||
return {
|
||||
id: `collision:${doc.id}`,
|
||||
podId: doc.podId,
|
||||
kind: 'collision',
|
||||
source: 'memory',
|
||||
actor: doc.engineers[0],
|
||||
actors: doc.engineers,
|
||||
file: doc.file,
|
||||
title: `${doc.engineers.join(' + ')} conflict on ${doc.file}`,
|
||||
detail: [
|
||||
doc.symbol ? `symbol ${doc.symbol}` : undefined,
|
||||
doc.githubState?.unpushed ? 'unpushed local changes involved' : undefined,
|
||||
doc.githubState?.openPrs?.length
|
||||
? `open PRs ${doc.githubState.openPrs.join(', ')}`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
severity: doc.severity,
|
||||
at: doc.detectedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function interventionEvent(doc: Intervention): PodActivityEvent {
|
||||
return {
|
||||
id: `intervention:${doc.id}`,
|
||||
podId: doc.podId,
|
||||
kind: 'intervention',
|
||||
source: 'hermes',
|
||||
title: `Hermes ${doc.status} ${doc.suggestedAction.kind.replaceAll('_', ' ')}`,
|
||||
detail: doc.message,
|
||||
severity: doc.status === 'accepted' ? 'success' : doc.status === 'dismissed' ? 'info' : 'warn',
|
||||
at: doc.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function outcomeEvent(doc: InterventionOutcome): PodActivityEvent {
|
||||
return {
|
||||
id: `outcome:${doc.interventionId}:${doc.recordedAt}`,
|
||||
podId: doc.podId,
|
||||
kind: 'outcome',
|
||||
source: 'policy',
|
||||
title: doc.accepted ? 'Intervention accepted' : 'Intervention dismissed',
|
||||
detail: doc.wasRealCollision ? 'confirmed real collision' : 'marked as false positive',
|
||||
severity: doc.accepted ? 'success' : 'info',
|
||||
at: doc.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPodActivity(podId: string, limit = 80): Promise<PodActivityEvent[]> {
|
||||
const db = await getDb();
|
||||
const [observations, gitStates, collisions, interventions, outcomes] = await Promise.all([
|
||||
db
|
||||
.collection<EngineerContext>('observations')
|
||||
.find({ podId }, { projection: { _id: 0 } })
|
||||
.sort({ observedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<EngineerStateDoc>('engineer_states')
|
||||
.find(
|
||||
{ podId },
|
||||
{
|
||||
projection: {
|
||||
_id: 1,
|
||||
podId: 1,
|
||||
name: 1,
|
||||
changedFiles: 1,
|
||||
diffStat: 1,
|
||||
recentCommit: 1,
|
||||
branch: 1,
|
||||
gitUpdatedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
.sort({ gitUpdatedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<Collision>('collisions')
|
||||
.find({ podId }, { projection: { _id: 0 } })
|
||||
.sort({ detectedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<Intervention>('interventions')
|
||||
.find({ podId }, { projection: { _id: 0 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
db
|
||||
.collection<InterventionOutcome>('outcomes')
|
||||
.find({ podId }, { projection: { _id: 0 } })
|
||||
.sort({ recordedAt: -1 })
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
]);
|
||||
|
||||
return [
|
||||
...observations.map(observationEvent),
|
||||
...gitStates.map(gitEvent),
|
||||
...collisions.map(collisionEvent),
|
||||
...interventions.map(interventionEvent),
|
||||
...outcomes.map(outcomeEvent),
|
||||
]
|
||||
.filter((event) => event.at !== new Date(0).toISOString())
|
||||
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at))
|
||||
.slice(0, limit);
|
||||
}
|
||||
+14
-122
@@ -6,7 +6,6 @@ import {
|
||||
VideoStream,
|
||||
VideoBufferType,
|
||||
dispose,
|
||||
type VideoFrameEvent,
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
type RemoteParticipant,
|
||||
@@ -15,13 +14,10 @@ import sharp from 'sharp';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { env } from './env.js';
|
||||
import { PodMan } from './agent/podman.js';
|
||||
import { initMemory } from './memory/db.js';
|
||||
|
||||
const POD_ROOM = process.env.POD_ROOM ?? 'demo-pod';
|
||||
const HERMES_IDENTITY = 'podman-hermes';
|
||||
const SAMPLE_INTERVAL_MS = 1000; // ~1 fps to the vision model
|
||||
const SCREEN_THUMBNAIL_WIDTH = 360;
|
||||
const SHUTDOWN_GRACE_MS = 5000;
|
||||
|
||||
async function agentToken(room: string): Promise<string> {
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
@@ -33,25 +29,7 @@ async function agentToken(room: string): Promise<string> {
|
||||
return at.toJwt();
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// MongoDB is mandatory. Verify the connection before joining the room so bad
|
||||
// creds / unreachable Atlas fail loudly at boot, not silently mid-demo.
|
||||
await initMemory();
|
||||
|
||||
const room = new Room();
|
||||
const podman = new PodMan(room, POD_ROOM);
|
||||
await room.connect(env.LIVEKIT_URL, await agentToken(POD_ROOM), {
|
||||
@@ -62,57 +40,6 @@ async function main() {
|
||||
console.log(`[agent] ${HERMES_IDENTITY} joined room ${POD_ROOM}`);
|
||||
|
||||
const lastSent = new Map<string, number>();
|
||||
const inFlight = new Set<string>();
|
||||
const activeStreams = new Map<string, ReadableStreamDefaultReader<VideoFrameEvent>>();
|
||||
|
||||
const streamKey = (
|
||||
track: RemoteTrack,
|
||||
pub: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) => `${participant.identity}:${pub.sid ?? track.sid ?? 'screen'}`;
|
||||
|
||||
const stopStream = async (key: string) => {
|
||||
const reader = activeStreams.get(key);
|
||||
if (!reader) return;
|
||||
activeStreams.delete(key);
|
||||
await reader.cancel().catch(() => {});
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
};
|
||||
|
||||
const processFrame = async (engineerId: string, event: VideoFrameEvent) => {
|
||||
const now = Date.now();
|
||||
if (now - (lastSent.get(engineerId) ?? 0) < SAMPLE_INTERVAL_MS) return;
|
||||
if (inFlight.has(engineerId)) return;
|
||||
lastSent.set(engineerId, now);
|
||||
inFlight.add(engineerId);
|
||||
|
||||
try {
|
||||
const rgba = event.frame.convert(VideoBufferType.RGBA);
|
||||
const pixels = Buffer.from(rgba.data);
|
||||
const raw = { width: rgba.width, height: rgba.height, channels: 4 } as const;
|
||||
const [jpeg, thumbnail] = await Promise.all([
|
||||
sharp(pixels, { raw })
|
||||
.resize({ width: 1280, withoutEnlargement: true })
|
||||
.jpeg({ quality: 70 })
|
||||
.toBuffer(),
|
||||
sharp(pixels, { raw })
|
||||
.resize({ width: SCREEN_THUMBNAIL_WIDTH, withoutEnlargement: true })
|
||||
.jpeg({ quality: 42 })
|
||||
.toBuffer(),
|
||||
]);
|
||||
await podman.onScreenFrame(
|
||||
engineerId,
|
||||
jpeg,
|
||||
`data:image/jpeg;base64,${thumbnail.toString('base64')}`,
|
||||
);
|
||||
} finally {
|
||||
inFlight.delete(engineerId);
|
||||
}
|
||||
};
|
||||
|
||||
room.on(
|
||||
RoomEvent.TrackSubscribed,
|
||||
@@ -120,65 +47,30 @@ async function main() {
|
||||
if (track.kind !== TrackKind.KIND_VIDEO || pub.source !== TrackSource.SOURCE_SCREENSHARE)
|
||||
return;
|
||||
const id = participant.identity;
|
||||
const key = streamKey(track, pub, participant);
|
||||
const stream = new VideoStream(track);
|
||||
void stopStream(key);
|
||||
const reader = stream.getReader();
|
||||
activeStreams.set(key, reader);
|
||||
void (async () => {
|
||||
try {
|
||||
while (activeStreams.get(key) === reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
void processFrame(id, value).catch((err) =>
|
||||
console.error(`[agent] frame sample failed for ${id}: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[agent] screen stream failed for ${id}: ${(err as Error).message}`);
|
||||
} finally {
|
||||
if (activeStreams.get(key) === reader) activeStreams.delete(key);
|
||||
await reader.cancel().catch(() => {});
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
for await (const event of stream) {
|
||||
const now = Date.now();
|
||||
if (now - (lastSent.get(id) ?? 0) < SAMPLE_INTERVAL_MS) continue; // THROTTLE
|
||||
lastSent.set(id, now);
|
||||
const rgba = event.frame.convert(VideoBufferType.RGBA);
|
||||
const jpeg = await sharp(Buffer.from(rgba.data), {
|
||||
raw: { width: rgba.width, height: rgba.height, channels: 4 },
|
||||
})
|
||||
.resize({ width: 1280, withoutEnlargement: true })
|
||||
.jpeg({ quality: 70 })
|
||||
.toBuffer();
|
||||
await podman.onScreenFrame(id, jpeg);
|
||||
}
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
room.on(
|
||||
RoomEvent.TrackUnsubscribed,
|
||||
(track: RemoteTrack, pub: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
void stopStream(streamKey(track, pub, participant));
|
||||
},
|
||||
);
|
||||
|
||||
room.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
|
||||
for (const key of [...activeStreams.keys()]) {
|
||||
if (key.startsWith(`${participant.identity}:`)) void stopStream(key);
|
||||
}
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
await withTimeout(
|
||||
Promise.all([...activeStreams.keys()].map(stopStream)).then(() => room.disconnect()),
|
||||
SHUTDOWN_GRACE_MS,
|
||||
);
|
||||
await withTimeout(dispose(), SHUTDOWN_GRACE_MS);
|
||||
await room.disconnect();
|
||||
await dispose();
|
||||
process.exit(0);
|
||||
};
|
||||
room.on(RoomEvent.Disconnected, () => {
|
||||
if (!shuttingDown) {
|
||||
console.error('[agent] LiveKit disconnected; exiting so systemd restarts the worker');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
+20
-116
@@ -1,60 +1,18 @@
|
||||
import { RoomEvent, type Room } from '@livekit/rtc-node';
|
||||
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
|
||||
import { DATA_TOPIC } from '@podman/shared';
|
||||
import { analyzeFrame } from '../vision/gemini.js';
|
||||
import { detectCollisions } from '../collision/detector.js';
|
||||
import { getGithubState } from '../github/client.js';
|
||||
import {
|
||||
recordObservation,
|
||||
recordCollision,
|
||||
recordIntervention,
|
||||
updateInterventionStatus,
|
||||
} from '../memory/store.js';
|
||||
import { getGitStates, type GitState } from '../memory/db.js';
|
||||
import { recordObservation, recordCollision, recordIntervention } from '../memory/store.js';
|
||||
import { getGitStates } from '../memory/db.js';
|
||||
import { recallSimilar } from '../memory/vectors.js';
|
||||
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
||||
import { publishHermesIntervention } from '../action/hermes.js';
|
||||
|
||||
/** Strip a git-status prefix ("M ", "?? ") and reduce a path to its lowercased
|
||||
* basename — matches comparableFile() in memory/store.ts so keys line up. */
|
||||
function comparableBasename(raw?: string): string {
|
||||
return (
|
||||
(raw ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase() ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/** Canonicalize an engineer name for case/whitespace-insensitive matching, so
|
||||
* "Karti" and "karti" resolve to the same engineer's git state. */
|
||||
function canonicalName(raw?: string): string {
|
||||
return (raw ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Git ground truth: do ALL involved engineers currently have the collided file
|
||||
* in their changedFiles? Computed at detection time while git state is fresh. */
|
||||
function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, GitState>): boolean {
|
||||
const target = comparableBasename(collision.file);
|
||||
if (!target || collision.engineers.length < 2) return false;
|
||||
const byCanon = new Map<string, string[]>();
|
||||
for (const [name, st] of gitStates) byCanon.set(canonicalName(name), st.changedFiles);
|
||||
return collision.engineers.every((e) =>
|
||||
(byCanon.get(canonicalName(e)) ?? []).some((f) => comparableBasename(f) === target),
|
||||
);
|
||||
}
|
||||
import { speak } from '../voice/live.js';
|
||||
|
||||
export class PodMan {
|
||||
private contexts = new Map<string, EngineerContext>();
|
||||
/**
|
||||
* Conflicts we have already voiced and that are still unresolved, keyed by
|
||||
* file (see conflictKey). Edge-triggered alerting: speak once when a conflict
|
||||
* appears, stay quiet while it persists. A conflict is re-armed (deleted
|
||||
* here) by onScreenFrame as soon as a detection cycle no longer sees it, so a
|
||||
* resolved-then-recurring conflict alerts again.
|
||||
*/
|
||||
private activeConflicts = new Set<string>();
|
||||
private encoder = new TextEncoder();
|
||||
|
||||
constructor(
|
||||
private room: Room,
|
||||
@@ -71,20 +29,14 @@ export class PodMan {
|
||||
if (c)
|
||||
c.hasUnpushedChanges = msg.report.unpushedCount > 0 || msg.report.dirtyFiles.length > 0;
|
||||
}
|
||||
if (msg.type === 'ACK') {
|
||||
void updateInterventionStatus(msg.interventionId, msg.status).catch((err) =>
|
||||
console.error(`[memory] intervention ack failed: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onScreenFrame(engineerId: string, jpeg: Buffer, screenshotDataUrl?: string): Promise<void> {
|
||||
async onScreenFrame(engineerId: string, jpeg: Buffer): Promise<void> {
|
||||
const ctx = await analyzeFrame(engineerId, this.podId, jpeg);
|
||||
if (screenshotDataUrl) ctx.screenshotDataUrl = screenshotDataUrl;
|
||||
this.contexts.set(engineerId, ctx);
|
||||
await recordObservation(ctx);
|
||||
|
||||
@@ -97,63 +49,22 @@ export class PodMan {
|
||||
}
|
||||
|
||||
const github = await getGithubState(); // cached
|
||||
const collisions = detectCollisions([...this.contexts.values()], github, gitStates);
|
||||
|
||||
// Re-arm: any conflict we previously voiced that is no longer present has
|
||||
// resolved, so allow it to alert again if it recurs.
|
||||
const current = new Set(collisions.map((c) => this.conflictKey(c)));
|
||||
for (const key of this.activeConflicts) {
|
||||
if (!current.has(key)) this.activeConflicts.delete(key);
|
||||
}
|
||||
|
||||
// Capture git ground-truth overlap now, while engineer_states are fresh, so
|
||||
// the outcome-time verifier never depends on a stale sidecar or a late click.
|
||||
for (const collision of collisions) {
|
||||
collision.gitOverlap = engineersOverlapOnFile(collision, gitStates);
|
||||
}
|
||||
|
||||
const collisions = detectCollisions([...this.contexts.values()], github);
|
||||
for (const collision of collisions) await this.handle(collision);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a conflict, independent of the Date.now() baked into
|
||||
* collision.id. Mirrors comparableFile() in memory/store.ts so keys line up:
|
||||
* strip any git-status prefix ("M ", "?? ") and reduce to a lowercased
|
||||
* basename.
|
||||
*/
|
||||
private conflictKey(collision: Collision): string {
|
||||
return comparableBasename(collision.file);
|
||||
}
|
||||
|
||||
private async handle(collision: Collision): Promise<void> {
|
||||
const key = this.conflictKey(collision);
|
||||
if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved
|
||||
|
||||
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
|
||||
// Only escalate to critical (which triggers the spoken alert) when the
|
||||
// recalled prior was an *accepted real* collision. Blanket-escalating every
|
||||
// recall — including dismissed/false-positive priors — masked the learned
|
||||
// routing in preferredAction and made recalled noise scream "CRITICAL".
|
||||
// (RSI Step 2 — continual-learning/policy.md:62-63, plan.md:66)
|
||||
if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) {
|
||||
collision.severity = 'critical';
|
||||
}
|
||||
const prior = await recallSimilar(collision); // Loop A: vector recall raises confidence
|
||||
if (prior) collision.severity = 'critical';
|
||||
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
|
||||
|
||||
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
|
||||
await recordCollision(collision);
|
||||
const action = preferredAction(collision, prior);
|
||||
const names = collision.engineers.join(' + ');
|
||||
const shortFile = collision.file.split('/').pop() ?? collision.file;
|
||||
|
||||
// Terse, demo-centered alert — short and direct, not chatty AI prose.
|
||||
const names = collision.engineers.join(' and ');
|
||||
const message =
|
||||
`Conflict: ${names} both on ${shortFile}` +
|
||||
(collision.githubState?.unpushed ? ' (unpushed).' : '.') +
|
||||
(prior ? ' Seen before.' : '');
|
||||
|
||||
// Spoken line stays short, but uses natural phrasing for Gemini TTS prosody.
|
||||
const voiceLine = `${names} are both editing ${shortFile}. Please sync before pushing.`;
|
||||
`${names} are both editing ${collision.file}` +
|
||||
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
|
||||
(prior ? ` I've seen this conflict pattern before.` : '');
|
||||
|
||||
const intervention: Intervention = {
|
||||
id: `int_${Date.now()}`,
|
||||
@@ -161,24 +72,17 @@ export class PodMan {
|
||||
podId: this.podId,
|
||||
kind: 'card',
|
||||
message,
|
||||
suggestedAction: {
|
||||
kind: action,
|
||||
params: {
|
||||
file: collision.file,
|
||||
summary: message,
|
||||
engineers: collision.engineers,
|
||||
},
|
||||
},
|
||||
suggestedAction: { kind: action },
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await recordIntervention(intervention);
|
||||
|
||||
await publishHermesIntervention(
|
||||
this.room,
|
||||
collision,
|
||||
intervention,
|
||||
collision.severity === 'critical' ? voiceLine : undefined,
|
||||
);
|
||||
const data: DataMessage = { type: 'COLLISION', collision, intervention };
|
||||
await this.room.localParticipant?.publishData(this.encoder.encode(JSON.stringify(data)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
await speak(this.room, message); // gemini-3.1-flash-live voice into the room
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,34 @@
|
||||
import type { EngineerContext, Collision, GithubStateSnapshot } from '@podman/shared';
|
||||
import type { GitState } from '../memory/db.js';
|
||||
|
||||
/**
|
||||
* Collapse any path-ish string to a comparable file key.
|
||||
*
|
||||
* Vision reads paths at inconsistent depths ("agent.ts" vs
|
||||
* "backend/src/agent.ts"), and git status lines carry a status prefix
|
||||
* ("M README.md", "?? test.txt"). Reduce both to a lowercased basename so the
|
||||
* same file matches regardless of how it was observed. Basename matching can
|
||||
* over-group two same-named files in different dirs, but for live coordination
|
||||
* that bias toward firing is the right trade.
|
||||
*/
|
||||
function fileKey(raw?: string): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const stripped = raw.trim().replace(/^(\?\?|[MADRCU!]{1,2})\s+/, ''); // drop git status prefix
|
||||
const base = stripped.split(/[\\/]/).pop()?.trim();
|
||||
if (!base) return undefined;
|
||||
return base.toLowerCase();
|
||||
function normalize(path?: string): string | undefined {
|
||||
if (!path) return undefined;
|
||||
return path.replace(/^\.?\/?(src\/)?/, 'src/').toLowerCase();
|
||||
}
|
||||
|
||||
interface Touch {
|
||||
engineerId: string;
|
||||
unpushed: boolean;
|
||||
display: string; // original path/name to show in the card
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect same-file collisions from two fused signals:
|
||||
* 1. Vision — what each engineer currently has on screen.
|
||||
* 2. Git ground truth — each engineer's dirty/unpushed `changedFiles`.
|
||||
*
|
||||
* Git overlap is deterministic and does not require both engineers to have the
|
||||
* file on screen at the same instant, so it is the reliable demo path.
|
||||
*/
|
||||
export function detectCollisions(
|
||||
contexts: EngineerContext[],
|
||||
github: GithubStateSnapshot,
|
||||
gitStates?: Map<string, GitState>,
|
||||
): Collision[] {
|
||||
const byFile = new Map<string, Touch[]>();
|
||||
const add = (key: string | undefined, touch: Touch): void => {
|
||||
if (!key) return;
|
||||
(byFile.get(key) ?? byFile.set(key, []).get(key)!).push(touch);
|
||||
};
|
||||
|
||||
// Signal 1: live vision context.
|
||||
const byFile = new Map<string, EngineerContext[]>();
|
||||
for (const c of contexts) {
|
||||
add(fileKey(c.currentFile), {
|
||||
engineerId: c.engineerId,
|
||||
unpushed: c.hasUnpushedChanges === true,
|
||||
display: c.currentFile ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// Signal 2: git ground truth (a dirty changed file is unpushed by definition).
|
||||
if (gitStates) {
|
||||
for (const [engineerId, git] of gitStates) {
|
||||
for (const changed of git.changedFiles) {
|
||||
add(fileKey(changed), { engineerId, unpushed: true, display: changed });
|
||||
}
|
||||
}
|
||||
const f = normalize(c.currentFile);
|
||||
if (!f) continue;
|
||||
(byFile.get(f) ?? byFile.set(f, []).get(f)!).push(c);
|
||||
}
|
||||
|
||||
const out: Collision[] = [];
|
||||
for (const [, touches] of byFile) {
|
||||
const engineers = [...new Set(touches.map((t) => t.engineerId))];
|
||||
if (engineers.length < 2) continue; // need two distinct people on one file
|
||||
for (const [file, group] of byFile) {
|
||||
const engineers = [...new Set(group.map((g) => g.engineerId))];
|
||||
if (engineers.length < 2) continue;
|
||||
|
||||
const anyUnpushed = touches.some((t) => t.unpushed) || github.unpushed === true;
|
||||
const anyUnpushed = group.some((g) => g.hasUnpushedChanges) || github.unpushed === true;
|
||||
if (!anyUnpushed) continue; // the crux GitHub alone cannot answer
|
||||
|
||||
// Show the most specific path we saw for this file.
|
||||
const display =
|
||||
touches.map((t) => t.display).sort((a, b) => b.length - a.length)[0] ?? touches[0]!.display;
|
||||
|
||||
out.push({
|
||||
id: `col_${fileKey(display)}_${Date.now()}`,
|
||||
podId: contexts[0]?.podId ?? 'demo-pod',
|
||||
file: display,
|
||||
symbol: contexts.find((c) => c.currentSymbol)?.currentSymbol,
|
||||
id: `col_${file}_${Date.now()}`,
|
||||
podId: group[0]!.podId,
|
||||
file,
|
||||
symbol: group.find((g) => g.currentSymbol)?.currentSymbol,
|
||||
engineers,
|
||||
severity: 'warn',
|
||||
githubState: { ...github, unpushed: anyUnpushed },
|
||||
|
||||
+2
-16
@@ -5,13 +5,6 @@ function req(name: string): string {
|
||||
if (!v) throw new Error(`Missing required env var: ${name}`);
|
||||
return v;
|
||||
}
|
||||
function reqAny(primary: string, aliases: string[] = []): string {
|
||||
for (const name of [primary, ...aliases]) {
|
||||
const v = process.env[name];
|
||||
if (v) return v;
|
||||
}
|
||||
throw new Error(`Missing required env var: ${primary}`);
|
||||
}
|
||||
function opt(name: string, fallback = ''): string {
|
||||
return process.env[name] ?? fallback;
|
||||
}
|
||||
@@ -21,15 +14,10 @@ export const env = {
|
||||
LIVEKIT_URL: req('LIVEKIT_URL'),
|
||||
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
||||
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
||||
LIVEKIT_AGENT_NAME: opt('LIVEKIT_AGENT_NAME'),
|
||||
LIVEKIT_CONVERSATION_AGENT_NAME: opt('LIVEKIT_CONVERSATION_AGENT_NAME', 'podman-live-conversation'),
|
||||
// Gemini
|
||||
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
|
||||
GEMINI_API_KEY: req('GEMINI_API_KEY'),
|
||||
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
|
||||
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-tts-preview'),
|
||||
GEMINI_CONVERSATION_MODEL: opt('GEMINI_CONVERSATION_MODEL', 'gemini-3.1-flash-live-preview'),
|
||||
GEMINI_TTS_VOICE: opt('GEMINI_TTS_VOICE', 'Charon'),
|
||||
GEMINI_EMBEDDING_MODEL: opt('GEMINI_EMBEDDING_MODEL', 'gemini-embedding-001'),
|
||||
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-live-2.5-flash'),
|
||||
// GitHub
|
||||
GITHUB_TOKEN: req('GITHUB_TOKEN'),
|
||||
GITHUB_REPO: req('GITHUB_REPO'), // owner/name
|
||||
@@ -39,8 +27,6 @@ export const env = {
|
||||
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
||||
// Server
|
||||
PORT: Number(opt('PORT', '8787')),
|
||||
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
||||
INTERNAL_AGENT_TOKEN: opt('INTERNAL_AGENT_TOKEN'),
|
||||
} as const;
|
||||
|
||||
export function repoParts(): { owner: string; repo: string } {
|
||||
|
||||
@@ -39,45 +39,13 @@ export async function createSyncPr(input: { headBranch: string; file: string; su
|
||||
ref: `refs/heads/${branch}`,
|
||||
sha: mainRef.object.sha,
|
||||
});
|
||||
|
||||
const artifactPath = `podman-sync-artifacts/${branch}.md`;
|
||||
const body = [
|
||||
'# PodMan Sync Artifact',
|
||||
'',
|
||||
`- File: \`${input.file || 'unknown'}\``,
|
||||
`- Source branch hint: \`${input.headBranch || 'not provided'}\``,
|
||||
`- Created: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'## Coordination Summary',
|
||||
'',
|
||||
input.summary || 'PodMan detected a coordination risk before the relevant work was pushed.',
|
||||
'',
|
||||
'## Suggested Next Step',
|
||||
'',
|
||||
'Coordinate ownership before pushing or merging overlapping local work.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await gh.rest.repos.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
path: artifactPath,
|
||||
message: `PodMan sync artifact for ${input.file || 'active work'}`,
|
||||
content: Buffer.from(body).toString('base64'),
|
||||
});
|
||||
|
||||
const { data: pr } = await gh.rest.pulls.create({
|
||||
owner,
|
||||
repo,
|
||||
title: `PodMan: sync ${input.file} before collision`,
|
||||
head: branch,
|
||||
base: 'main',
|
||||
body: [
|
||||
input.summary,
|
||||
'',
|
||||
`PodMan created a visible sync artifact at \`${artifactPath}\` so the team can coordinate before pushing overlapping work.`,
|
||||
].join('\n'),
|
||||
body: input.summary,
|
||||
});
|
||||
return pr;
|
||||
}
|
||||
|
||||
@@ -11,90 +11,21 @@ export function createDemoPodGraph(podId: string): PodGraph {
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
// Kept consistent with the graph below (3 owner engineers, 1 collision file,
|
||||
// 1 of 1 interventions accepted) so the numbers never contradict the picture.
|
||||
metrics: [
|
||||
{
|
||||
label: 'Learned owners',
|
||||
value: '3',
|
||||
detail: 'Distinct owners retained from accepted interventions.',
|
||||
value: '5',
|
||||
detail: 'Ownership edges retained from accepted interventions.',
|
||||
},
|
||||
{
|
||||
label: 'Open risk paths',
|
||||
value: '1',
|
||||
detail: 'File with two or more converging editors.',
|
||||
value: '2',
|
||||
detail: 'auth.ts and the memory API have converging editors.',
|
||||
},
|
||||
{
|
||||
label: 'Accept rate',
|
||||
value: '100%',
|
||||
detail: 'Interventions accepted vs total this session.',
|
||||
},
|
||||
],
|
||||
loop: {
|
||||
activeStep: 'adapt',
|
||||
steps: [
|
||||
{
|
||||
key: 'observe',
|
||||
label: 'Observe',
|
||||
value: '2',
|
||||
detail: 'Screen context and local git state show two active editors.',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
key: 'store',
|
||||
label: 'Store',
|
||||
value: '7',
|
||||
detail: 'Observations, collisions, interventions, and outcomes are in MongoDB.',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
key: 'predict',
|
||||
label: 'Predict',
|
||||
value: '2',
|
||||
detail: 'Same-file risk paths are detected before push.',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
key: 'outcome',
|
||||
label: 'Outcome',
|
||||
value: '6',
|
||||
detail: 'Accepted and dismissed outcomes supervise future routing.',
|
||||
status: 'complete',
|
||||
},
|
||||
{
|
||||
key: 'adapt',
|
||||
label: 'Adapt',
|
||||
value: '1',
|
||||
detail: 'Accepted real collision created a learned_from edge.',
|
||||
status: 'complete',
|
||||
},
|
||||
],
|
||||
},
|
||||
activity: [
|
||||
{
|
||||
id: 'demo-learned-auth',
|
||||
at: new Date().toISOString(),
|
||||
kind: 'learned',
|
||||
title: 'Learned Karti owns auth.ts',
|
||||
detail: 'Accepted sync PR outcome created a durable learned_from path.',
|
||||
nodeId: 'engineer:karti',
|
||||
edgeId: 'e7',
|
||||
},
|
||||
{
|
||||
id: 'demo-intervention-sync-pr',
|
||||
at: new Date().toISOString(),
|
||||
kind: 'intervention',
|
||||
title: 'Intervention: sync PR',
|
||||
detail: 'PodMan offered a small coordination card before voice.',
|
||||
nodeId: 'intervention:sync-pr',
|
||||
},
|
||||
{
|
||||
id: 'demo-collision-auth',
|
||||
at: new Date().toISOString(),
|
||||
kind: 'collision',
|
||||
title: 'Collision risk on auth.ts',
|
||||
detail: 'Karti and Yahya converged on unpushed work.',
|
||||
nodeId: 'collision:auth',
|
||||
value: '86%',
|
||||
detail: 'Interventions accepted this session (+14%).',
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
@@ -255,7 +186,7 @@ export function createDemoPodGraph(podId: string): PodGraph {
|
||||
source: 'collision:auth',
|
||||
target: 'intervention:sync-pr',
|
||||
kind: 'warns',
|
||||
label: 'routes',
|
||||
label: 'nudges',
|
||||
strength: 0.9,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
import type {
|
||||
PodGraph,
|
||||
PodGraphNode,
|
||||
PodGraphEdge,
|
||||
PodGraphMetric,
|
||||
PodLearningLoop,
|
||||
PodGraphActivity,
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
} from '@podman/shared';
|
||||
import { collections, getGitStates, getDb } from '../memory/db.js';
|
||||
|
||||
/**
|
||||
* Live materializer: build a pod's continual-learning graph from the real
|
||||
* collections the agent writes (pods, engineer_states, observations, collisions,
|
||||
* interventions, outcomes) — NOT the hardcoded demo. See docs/live-ui-spec.md §1.
|
||||
*
|
||||
* Pure-read and best-effort. Returns `null` when there is no real activity yet
|
||||
* (only bare roster), so `loadPodGraph` can fall back to the demo graph.
|
||||
*/
|
||||
|
||||
const ACTIVE_WINDOW_MS = 90_000;
|
||||
const MAX_OBSERVATIONS = 250;
|
||||
|
||||
/** Strip a `git status --short` XY code (and rename `old -> new`) to a clean path. */
|
||||
export function parseGitStatusPath(line: string): string {
|
||||
let s = line.trim();
|
||||
const arrow = s.indexOf(' -> ');
|
||||
if (arrow !== -1) s = s.slice(arrow + 4);
|
||||
else s = s.replace(/^[ACDMRTU?!]{1,2}\s+/, '');
|
||||
return normalizeFile(s);
|
||||
}
|
||||
|
||||
/** Normalize a file path so vision (`collisions.file`) and git paths match. */
|
||||
export function normalizeFile(f: string): string {
|
||||
return f
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/^[ACDMRTU?!]{1,2}\s+/, '')
|
||||
.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
const MAX_COLLISIONS = 8;
|
||||
|
||||
/** Reject "file" values that aren't real source paths — vision/git noise such as
|
||||
* URLs, env vars, browser/app names, and scratch/test artifacts. */
|
||||
const FILE_NOISE =
|
||||
/(:\/\/|^[#~]|\s|\.env\b|\btett\b|test-change|demo-scratch|podman-test|scratch|sslip)/i;
|
||||
export function isFilePath(f: string): boolean {
|
||||
if (!f || FILE_NOISE.test(f)) return false;
|
||||
return /\.[a-z0-9]{1,6}$/i.test(f); // must end in a real file extension
|
||||
}
|
||||
|
||||
/** Engineer names that are test/verification artifacts, not real teammates. */
|
||||
const ENGINEER_NOISE = /(^verify\b|^.$|testrepo|-?check\b|\d{4,})/i;
|
||||
|
||||
const MAX_FILES = 9;
|
||||
|
||||
/** Short, readable node label — last two path segments (full path goes in summary). */
|
||||
function shortLabel(file: string): string {
|
||||
const parts = file.split('/').filter(Boolean);
|
||||
return parts.slice(-2).join('/') || file;
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<PodGraphNodeStatus, number> = {
|
||||
stable: 0,
|
||||
active: 1,
|
||||
learned: 2,
|
||||
risk: 3,
|
||||
};
|
||||
|
||||
interface Builder {
|
||||
nodes: Map<string, PodGraphNode>;
|
||||
edges: Map<string, PodGraphEdge>;
|
||||
}
|
||||
|
||||
function nodeKey(kind: PodGraphNodeKind, key: string): string {
|
||||
return `${kind}:${key}`;
|
||||
}
|
||||
|
||||
function upsertNode(
|
||||
b: Builder,
|
||||
kind: PodGraphNodeKind,
|
||||
key: string,
|
||||
patch: Partial<Omit<PodGraphNode, 'id' | 'kind' | 'x' | 'y'>>,
|
||||
): string {
|
||||
const id = nodeKey(kind, kind === 'engineer' ? key.toLowerCase() : key);
|
||||
const cur = b.nodes.get(id);
|
||||
if (!cur) {
|
||||
b.nodes.set(id, {
|
||||
id,
|
||||
kind,
|
||||
label: patch.label ?? key,
|
||||
summary: patch.summary ?? '',
|
||||
weight: patch.weight ?? 0.6,
|
||||
status: patch.status ?? 'stable',
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
if (patch.label) cur.label = patch.label;
|
||||
if (patch.summary) cur.summary = patch.summary;
|
||||
if (patch.weight && patch.weight > cur.weight) cur.weight = patch.weight;
|
||||
if (patch.status && STATUS_RANK[patch.status] > STATUS_RANK[cur.status])
|
||||
cur.status = patch.status;
|
||||
return id;
|
||||
}
|
||||
|
||||
function upsertEdge(
|
||||
b: Builder,
|
||||
source: string,
|
||||
target: string,
|
||||
kind: PodGraphEdgeKind,
|
||||
label: string,
|
||||
strength: number,
|
||||
): void {
|
||||
const id = `${kind}:${source}->${target}`;
|
||||
const cur = b.edges.get(id);
|
||||
if (!cur) b.edges.set(id, { id, source, target, kind, label, strength });
|
||||
else if (strength > cur.strength) cur.strength = strength;
|
||||
}
|
||||
|
||||
const COLUMN_X: Record<PodGraphNodeKind, number> = {
|
||||
engineer: 78,
|
||||
file: 300,
|
||||
feature: 360,
|
||||
collision: 470,
|
||||
intervention: 622,
|
||||
};
|
||||
|
||||
/** Deterministic column layout so the SVG renders stably across refreshes. */
|
||||
function layout(nodes: PodGraphNode[]): void {
|
||||
const byKind = new Map<PodGraphNodeKind, PodGraphNode[]>();
|
||||
for (const n of nodes) {
|
||||
const list = byKind.get(n.kind) ?? [];
|
||||
list.push(n);
|
||||
byKind.set(n.kind, list);
|
||||
}
|
||||
for (const [kind, list] of byKind) {
|
||||
list.sort((a, b) => a.id.localeCompare(b.id));
|
||||
const n = list.length;
|
||||
list.forEach((node, i) => {
|
||||
node.x = COLUMN_X[kind];
|
||||
node.y = Math.round(((i + 1) / (n + 1)) * 452) + 10;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 };
|
||||
|
||||
function buildLoop(input: {
|
||||
observations: number;
|
||||
gitStates: number;
|
||||
collisions: number;
|
||||
interventions: number;
|
||||
outcomes: number;
|
||||
acceptedReal: number;
|
||||
learnedEdges: number;
|
||||
}): PodLearningLoop {
|
||||
const stored = input.observations + input.gitStates + input.interventions + input.outcomes;
|
||||
return {
|
||||
activeStep:
|
||||
input.acceptedReal > 0
|
||||
? 'adapt'
|
||||
: input.outcomes > 0
|
||||
? 'outcome'
|
||||
: input.collisions > 0
|
||||
? 'predict'
|
||||
: input.observations + input.gitStates > 0
|
||||
? 'store'
|
||||
: 'observe',
|
||||
steps: [
|
||||
{
|
||||
key: 'observe',
|
||||
label: 'Observe',
|
||||
value: String(input.observations + input.gitStates),
|
||||
detail: 'Recent vision observations plus local git-state reports.',
|
||||
status: input.observations + input.gitStates > 0 ? 'complete' : 'quiet',
|
||||
},
|
||||
{
|
||||
key: 'store',
|
||||
label: 'Store',
|
||||
value: String(stored),
|
||||
detail: 'MongoDB records available to recall for this pod.',
|
||||
status: stored > 0 ? 'complete' : 'quiet',
|
||||
},
|
||||
{
|
||||
key: 'predict',
|
||||
label: 'Predict',
|
||||
value: String(input.collisions),
|
||||
detail: 'Distinct collision signatures detected from live work.',
|
||||
status: input.collisions > 0 ? 'complete' : 'quiet',
|
||||
},
|
||||
{
|
||||
key: 'outcome',
|
||||
label: 'Outcome',
|
||||
value: String(input.outcomes),
|
||||
detail: 'Accepted and dismissed intervention outcomes.',
|
||||
status: input.outcomes > 0 ? 'complete' : 'quiet',
|
||||
},
|
||||
{
|
||||
key: 'adapt',
|
||||
label: 'Adapt',
|
||||
value: String(input.learnedEdges),
|
||||
detail: 'Learned graph edges created from accepted real outcomes.',
|
||||
status: input.acceptedReal > 0 ? 'complete' : 'planned',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function pushActivity(
|
||||
activity: PodGraphActivity[],
|
||||
item: PodGraphActivity,
|
||||
seen: Set<string>,
|
||||
): void {
|
||||
if (seen.has(item.id)) return;
|
||||
seen.add(item.id);
|
||||
activity.push(item);
|
||||
}
|
||||
|
||||
export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
|
||||
const c = await collections();
|
||||
const db = await getDb();
|
||||
|
||||
const [pod, observations, collisionDocs, interventionDocs, outcomeDocs, gitStates] =
|
||||
await Promise.all([
|
||||
c.pods.findOne({ id: podId }),
|
||||
c.observations.find({ podId }).sort({ observedAt: -1 }).limit(MAX_OBSERVATIONS).toArray(),
|
||||
c.collisions.find({ podId }).sort({ detectedAt: -1 }).limit(100).toArray(),
|
||||
c.interventions.find({ podId }).toArray(),
|
||||
c.outcomes.find({ podId }).toArray(),
|
||||
getGitStates(podId),
|
||||
]);
|
||||
|
||||
// Optional supervised ownership map (team_model.ownership: file -> engineer).
|
||||
let ownership: Record<string, string> = {};
|
||||
try {
|
||||
const tm = await db
|
||||
.collection<{ podId: string; ownership?: Record<string, string> }>('team_model')
|
||||
.findOne({ podId });
|
||||
ownership = tm?.ownership ?? {};
|
||||
} catch {
|
||||
/* ownership is optional */
|
||||
}
|
||||
|
||||
const b: Builder = { nodes: new Map(), edges: new Map() };
|
||||
const activity: PodGraphActivity[] = [];
|
||||
const activityIds = new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
// 1. Baseline engineer nodes from the roster.
|
||||
for (const name of pod?.members ?? []) {
|
||||
upsertNode(b, 'engineer', name, { label: name });
|
||||
}
|
||||
|
||||
// 2. Vision (observations): who is active and on which file, with confidence.
|
||||
for (const o of observations) {
|
||||
if (!o.engineerId) continue;
|
||||
const recent = o.observedAt && now - new Date(o.observedAt).getTime() < ACTIVE_WINDOW_MS;
|
||||
const eng = upsertNode(b, 'engineer', o.engineerId, {
|
||||
label: o.engineerId,
|
||||
status: recent ? 'active' : undefined,
|
||||
});
|
||||
const file = o.currentFile ? normalizeFile(o.currentFile) : '';
|
||||
if (isFilePath(file)) {
|
||||
const f = upsertNode(b, 'file', file, { label: shortLabel(file), summary: file });
|
||||
upsertEdge(b, eng, f, 'editing', o.activity ?? 'edits', Math.max(0.4, o.confidence ?? 0.5));
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `editing:${o.engineerId}:${file}:${String(o.observedAt ?? '')}`,
|
||||
at: String(o.observedAt ?? new Date().toISOString()),
|
||||
kind: 'editing',
|
||||
title: `${o.engineerId} editing ${shortLabel(file)}`,
|
||||
detail: o.activity ?? 'Vision observed active work.',
|
||||
nodeId: f,
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Collisions referenced by accepted outcomes are the "learned" money path — they
|
||||
// always survive the cap so the learned_from beat is never dropped.
|
||||
const priorityCol = new Set<string>();
|
||||
for (const out of outcomeDocs) {
|
||||
if (!out.accepted || !out.wasRealCollision) continue;
|
||||
if (out.collisionId) priorityCol.add(out.collisionId);
|
||||
const iv = interventionDocs.find((i) => i.id === out.interventionId);
|
||||
if (iv?.collisionId) priorityCol.add(iv.collisionId);
|
||||
}
|
||||
|
||||
// 3. Collisions: collapse repeats by signature, keep the most recent, cap to
|
||||
// MAX_COLLISIONS, skip junk-file collisions. `collisionById` keeps every doc
|
||||
// (for the outcome join); `colNodeFor` maps each collisionId to its surviving
|
||||
// collision node (or null when collapsed / capped / filtered out).
|
||||
const collisionById = new Map<string, (typeof collisionDocs)[number]>();
|
||||
const colNodeFor = new Map<string, string | null>();
|
||||
const sigToNode = new Map<string, string>();
|
||||
let distinctCollisions = 0;
|
||||
for (const col of collisionDocs) {
|
||||
collisionById.set(col.id, col);
|
||||
const file = normalizeFile(col.file);
|
||||
const sig =
|
||||
(col as { memorySignature?: string }).memorySignature ?? `${file}#${col.symbol ?? ''}`;
|
||||
const existing = sigToNode.get(sig);
|
||||
if (existing) {
|
||||
colNodeFor.set(col.id, existing);
|
||||
continue;
|
||||
}
|
||||
if (!isFilePath(file)) {
|
||||
colNodeFor.set(col.id, null);
|
||||
continue;
|
||||
}
|
||||
const isPriority = priorityCol.has(col.id);
|
||||
if (!isPriority && distinctCollisions >= MAX_COLLISIONS) {
|
||||
colNodeFor.set(col.id, null);
|
||||
continue;
|
||||
}
|
||||
const cNode = upsertNode(b, 'collision', col.id, {
|
||||
label: shortLabel(file),
|
||||
status: 'risk',
|
||||
weight: SEVERITY_WEIGHT[col.severity] ?? 0.7,
|
||||
summary: `${col.engineers.join(' + ')} on ${file}${
|
||||
(col as { memorySignature?: string }).memorySignature ? ' · seen before' : ''
|
||||
}`,
|
||||
});
|
||||
const fNode = upsertNode(b, 'file', file, {
|
||||
label: shortLabel(file),
|
||||
summary: file,
|
||||
status: 'risk',
|
||||
});
|
||||
upsertEdge(b, fNode, cNode, 'touches', 'hot', 0.6);
|
||||
for (const name of col.engineers) {
|
||||
const eng = upsertNode(b, 'engineer', name, { label: name });
|
||||
upsertEdge(b, eng, cNode, 'collides', 'in', SEVERITY_WEIGHT[col.severity] ?? 0.7);
|
||||
}
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `collision:${col.id}`,
|
||||
at: col.detectedAt,
|
||||
kind: 'collision',
|
||||
title: `Collision risk on ${shortLabel(file)}`,
|
||||
detail: `${col.engineers.join(' + ')} converged on ${file}.`,
|
||||
nodeId: cNode,
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
sigToNode.set(sig, cNode);
|
||||
colNodeFor.set(col.id, cNode);
|
||||
if (!isPriority) distinctCollisions++;
|
||||
}
|
||||
|
||||
// 4. Git truth (engineer_states): mark unpushed work and confirm editing on
|
||||
// files vision/collisions already surfaced — not the whole repo diff.
|
||||
for (const [name, git] of gitStates) {
|
||||
const files = git.changedFiles.map(parseGitStatusPath).filter(Boolean);
|
||||
const eng = upsertNode(b, 'engineer', name, {
|
||||
label: name,
|
||||
status: files.length > 0 ? 'risk' : 'active',
|
||||
summary: files.length
|
||||
? `${files.length} changed file(s) on ${git.branch ?? 'detached'}`
|
||||
: `on ${git.branch ?? 'detached'}`,
|
||||
weight: 0.7,
|
||||
});
|
||||
for (const file of files) {
|
||||
const fid = nodeKey('file', file);
|
||||
if (b.nodes.has(fid)) upsertEdge(b, eng, fid, 'editing', 'edits', 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Interventions: collapse to one (most recent) per surviving collision.
|
||||
const interventionById = new Map<string, (typeof interventionDocs)[number]>();
|
||||
const ivNodeForCol = new Map<string, string>();
|
||||
const sortedIvs = [...interventionDocs].sort((a, b) =>
|
||||
String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')),
|
||||
);
|
||||
for (const iv of sortedIvs) {
|
||||
interventionById.set(iv.id, iv);
|
||||
const colNode = colNodeFor.get(iv.collisionId);
|
||||
if (!colNode || ivNodeForCol.has(colNode)) continue;
|
||||
const ivNode = upsertNode(b, 'intervention', iv.id, {
|
||||
label:
|
||||
iv.suggestedAction?.kind === 'open_sync_pr'
|
||||
? 'sync PR'
|
||||
: iv.suggestedAction?.kind === 'ping_teammate'
|
||||
? 'ping'
|
||||
: 'watch',
|
||||
summary: iv.message,
|
||||
});
|
||||
upsertEdge(b, colNode, ivNode, 'warns', 'routes', 0.85);
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `intervention:${iv.id}`,
|
||||
at: iv.createdAt,
|
||||
kind: 'intervention',
|
||||
title: `Intervention: ${b.nodes.get(ivNode)?.label ?? iv.kind}`,
|
||||
detail: iv.message,
|
||||
nodeId: ivNode,
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
ivNodeForCol.set(colNode, ivNode);
|
||||
}
|
||||
|
||||
// 6. Outcomes: the supervised learning signal -> learned_from edges + owns.
|
||||
for (const out of outcomeDocs) {
|
||||
if (!out.accepted || !out.wasRealCollision) continue;
|
||||
const iv = interventionById.get(out.interventionId);
|
||||
const col = iv ? collisionById.get(iv.collisionId) : collisionById.get(out.collisionId);
|
||||
if (!col) continue;
|
||||
const file = normalizeFile(col.file);
|
||||
if (!isFilePath(file)) continue;
|
||||
const owner =
|
||||
(out as { learnedOwner?: string }).learnedOwner ?? ownership[file] ?? col.engineers[0];
|
||||
if (!owner) continue;
|
||||
const engNode = upsertNode(b, 'engineer', owner, { label: owner, status: 'learned' });
|
||||
const fNode = upsertNode(b, 'file', file, { label: file });
|
||||
upsertEdge(b, engNode, fNode, 'owns', 'owns', 0.85);
|
||||
const cNode = colNodeFor.get(col.id);
|
||||
const ivNode = cNode ? ivNodeForCol.get(cNode) : undefined;
|
||||
if (ivNode) {
|
||||
const ivObj = b.nodes.get(ivNode);
|
||||
if (ivObj) ivObj.status = 'learned';
|
||||
const before = b.edges.size;
|
||||
upsertEdge(b, ivNode, engNode, 'learned_from', `learned: owns ${file}`, 0.6);
|
||||
const edgeId = `${'learned_from'}:${ivNode}->${engNode}`;
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `learned:${out.interventionId}:${owner}:${file}`,
|
||||
at: out.recordedAt,
|
||||
kind: 'learned',
|
||||
title: `Learned ${owner} owns ${shortLabel(file)}`,
|
||||
detail: 'Accepted real outcome created a durable learned_from path.',
|
||||
nodeId: engNode,
|
||||
edgeId: before === b.edges.size ? undefined : edgeId,
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
}
|
||||
pushActivity(
|
||||
activity,
|
||||
{
|
||||
id: `outcome:${out.interventionId}:${out.recordedAt}`,
|
||||
at: out.recordedAt,
|
||||
kind: 'outcome',
|
||||
title: out.accepted ? 'Outcome accepted' : 'Outcome dismissed',
|
||||
detail: out.wasRealCollision ? 'Marked as a real collision.' : 'Marked as noise.',
|
||||
},
|
||||
activityIds,
|
||||
);
|
||||
}
|
||||
|
||||
// Prune test-artifact engineers, then anything left orphaned by that.
|
||||
const dropNode = (id: string) => {
|
||||
b.nodes.delete(id);
|
||||
for (const [eid, e] of [...b.edges])
|
||||
if (e.source === id || e.target === id) b.edges.delete(eid);
|
||||
};
|
||||
for (const [id, n] of [...b.nodes]) {
|
||||
if (n.kind === 'engineer' && ENGINEER_NOISE.test(n.label)) dropNode(id);
|
||||
}
|
||||
// Collisions with no remaining engineer = test/orphan -> drop.
|
||||
for (const [id, n] of [...b.nodes]) {
|
||||
if (n.kind !== 'collision') continue;
|
||||
if (![...b.edges.values()].some((e) => e.kind === 'collides' && e.target === id)) dropNode(id);
|
||||
}
|
||||
// Cap file nodes to the most-connected (collision files first).
|
||||
const fileNodes = [...b.nodes.values()].filter((n) => n.kind === 'file');
|
||||
if (fileNodes.length > MAX_FILES) {
|
||||
const inCollision = (id: string) =>
|
||||
[...b.edges.values()].some((e) => e.kind === 'touches' && e.source === id);
|
||||
const degree = (id: string) =>
|
||||
[...b.edges.values()].filter((e) => e.source === id || e.target === id).length;
|
||||
fileNodes.sort(
|
||||
(a, z) =>
|
||||
Number(inCollision(z.id)) - Number(inCollision(a.id)) || degree(z.id) - degree(a.id),
|
||||
);
|
||||
for (const n of fileNodes.slice(MAX_FILES)) dropNode(n.id);
|
||||
}
|
||||
|
||||
// Files / interventions left with no edges -> drop.
|
||||
for (const [id, n] of [...b.nodes]) {
|
||||
if (n.kind === 'file' || n.kind === 'intervention') {
|
||||
if (![...b.edges.values()].some((e) => e.source === id || e.target === id))
|
||||
b.nodes.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = [...b.nodes.values()];
|
||||
// No real activity beyond the bare roster -> let the caller fall back to demo.
|
||||
const hasActivity = nodes.some((n) => n.kind !== 'engineer');
|
||||
if (!hasActivity) return null;
|
||||
|
||||
layout(nodes);
|
||||
|
||||
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
||||
const totalOutcomes = outcomeDocs.length;
|
||||
// Raw distinct collision signatures — kept for the learning-loop throughput view.
|
||||
const riskPaths = new Set(
|
||||
collisionDocs.map(
|
||||
(col) =>
|
||||
(col as { memorySignature?: string }).memorySignature ??
|
||||
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
|
||||
),
|
||||
).size;
|
||||
|
||||
// Headline metric cards are derived from the FINAL de-noised graph so they match
|
||||
// what's drawn. Counting raw collision signatures / accepted-outcome rows inflates
|
||||
// them with test churn (e.g. 50 "risk paths" for 4 files), which reads as fake.
|
||||
const finalEdges = [...b.edges.values()];
|
||||
const riskFiles = new Set<string>();
|
||||
for (const e of finalEdges) {
|
||||
if (e.kind === 'touches' && b.nodes.get(e.source)?.kind === 'file') riskFiles.add(e.source);
|
||||
}
|
||||
const openRiskPaths = riskFiles.size || nodes.filter((n) => n.kind === 'collision').length;
|
||||
const ownerSet = new Set<string>();
|
||||
for (const e of finalEdges) {
|
||||
if (e.kind === 'learned_from') ownerSet.add(e.target);
|
||||
if (e.kind === 'owns') ownerSet.add(e.source);
|
||||
}
|
||||
const learnedOwners = [...ownerSet].filter((id) => b.nodes.get(id)?.kind === 'engineer').length;
|
||||
|
||||
const metrics: PodGraphMetric[] = [
|
||||
{
|
||||
label: 'Learned owners',
|
||||
value: String(learnedOwners),
|
||||
detail: 'Distinct owners retained from accepted interventions.',
|
||||
},
|
||||
{
|
||||
label: 'Open risk paths',
|
||||
value: String(openRiskPaths),
|
||||
detail: `${openRiskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`,
|
||||
},
|
||||
{
|
||||
label: 'Accept rate',
|
||||
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
|
||||
detail: 'Interventions accepted vs total this session.',
|
||||
},
|
||||
];
|
||||
const learnedEdges = [...b.edges.values()].filter((e) => e.kind === 'learned_from').length;
|
||||
activity.sort((a, z) => String(z.at).localeCompare(String(a.at)));
|
||||
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
nodes,
|
||||
edges: [...b.edges.values()],
|
||||
metrics,
|
||||
loop: buildLoop({
|
||||
observations: observations.length,
|
||||
gitStates: gitStates.size,
|
||||
collisions: riskPaths,
|
||||
interventions: interventionDocs.length,
|
||||
outcomes: totalOutcomes,
|
||||
acceptedReal,
|
||||
learnedEdges,
|
||||
}),
|
||||
activity: activity.slice(0, 12),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PodGraph, GraphNodeDoc, GraphEdgeDoc } from '@podman/shared';
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { createDemoPodGraph } from './demo.js';
|
||||
import { materializePodGraph } from './live.js';
|
||||
|
||||
interface TeamModelDoc {
|
||||
podId: string;
|
||||
@@ -15,14 +14,6 @@ interface TeamModelDoc {
|
||||
* unreachable — so the demo path never depends on a populated DB.
|
||||
*/
|
||||
export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
||||
// 1. Live: materialize from the real collections (observations/collisions/…).
|
||||
try {
|
||||
const live = await materializePodGraph(podId);
|
||||
if (live) return live;
|
||||
} catch (err) {
|
||||
console.warn(`[graph] live materialize failed, falling back: ${(err as Error).message}`);
|
||||
}
|
||||
// 2. Seeded snapshot embedded in team_model.
|
||||
try {
|
||||
const db = await getDb();
|
||||
const doc = await db.collection<TeamModelDoc>('team_model').findOne({ podId });
|
||||
@@ -30,7 +21,6 @@ export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
||||
} catch (err) {
|
||||
console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`);
|
||||
}
|
||||
// 3. Demo (stage safety — never an empty canvas).
|
||||
return createDemoPodGraph(podId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { Room as LiveKitRoom } from '@livekit/rtc-node';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import {
|
||||
DATA_TOPIC,
|
||||
type DataMessage,
|
||||
type HermesJob,
|
||||
type HermesJobEvent,
|
||||
type HermesJobEventType,
|
||||
type HermesJobInput,
|
||||
type HermesJobStatus,
|
||||
type HermesRiskLevel,
|
||||
} from '@podman/shared';
|
||||
import { env, repoParts } from '../env.js';
|
||||
import { getDb } from '../memory/db.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const encoder = new TextEncoder();
|
||||
const MAX_OUTPUT = 3_000;
|
||||
const COMMAND_TIMEOUT_MS = 45_000;
|
||||
const runners = new Map<string, AbortController>();
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function truncate(value: string): string {
|
||||
return value.length > MAX_OUTPUT ? `${value.slice(0, MAX_OUTPUT)}\n...[truncated]` : value;
|
||||
}
|
||||
|
||||
function redact(value: string): string {
|
||||
return value
|
||||
.replace(/AIza[0-9A-Za-z_-]{20,}/g, '[redacted-google-key]')
|
||||
.replace(/API[_-]?SECRET=[^\s]+/gi, 'API_SECRET=[redacted]')
|
||||
.replace(/TOKEN=[^\s]+/gi, 'TOKEN=[redacted]')
|
||||
.replace(/mongodb(\+srv)?:\/\/[^@\s]+@/gi, 'mongodb$1://[redacted]@');
|
||||
}
|
||||
|
||||
function normalizeRisk(value: unknown): HermesRiskLevel {
|
||||
return value === 'safe_write' ||
|
||||
value === 'commit_allowed' ||
|
||||
value === 'deploy_allowed' ||
|
||||
value === 'read_only'
|
||||
? value
|
||||
: 'read_only';
|
||||
}
|
||||
|
||||
async function hermesJobs() {
|
||||
return (await getDb()).collection<HermesJob>('hermes_jobs');
|
||||
}
|
||||
|
||||
async function hermesJobEvents() {
|
||||
return (await getDb()).collection<HermesJobEvent>('hermes_job_events');
|
||||
}
|
||||
|
||||
export async function ensureHermesJobIndexes(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await Promise.allSettled([
|
||||
db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true }),
|
||||
db.collection('hermes_jobs').createIndex({ sessionId: 1, status: 1, updatedAt: -1 }),
|
||||
db.collection('hermes_jobs').createIndex({ podId: 1, updatedAt: -1 }),
|
||||
db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
|
||||
db.collection('hermes_job_events').createIndex({ sessionId: 1, createdAt: -1 }),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function createHermesJob(input: Partial<HermesJobInput>): Promise<HermesJob> {
|
||||
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
|
||||
if (!prompt) throw new Error('prompt is required');
|
||||
const createdAt = now();
|
||||
const job: HermesJob = {
|
||||
id: `hermes_job_${randomUUID()}`,
|
||||
podId: input.podId || 'demo-pod',
|
||||
identity: input.identity || 'developer',
|
||||
sessionId: input.sessionId || 'unknown',
|
||||
conversationRoom: input.conversationRoom,
|
||||
prompt,
|
||||
contextScope: input.contextScope || 'current_repo',
|
||||
targetRepository: input.targetRepository || env.GITHUB_REPO,
|
||||
riskLevel: normalizeRisk(input.riskLevel),
|
||||
requiresConfirmation: input.requiresConfirmation === true,
|
||||
successCriteria: Array.isArray(input.successCriteria)
|
||||
? input.successCriteria.map(String).filter(Boolean).slice(0, 8)
|
||||
: ['Hermes reports what it inspected and what changed.'],
|
||||
parentJobId: input.parentJobId,
|
||||
status: 'queued',
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
await (await hermesJobs()).insertOne(job);
|
||||
await appendHermesJobEvent(job.id, 'accepted', 'Hermes accepted the task.', {
|
||||
riskLevel: job.riskLevel,
|
||||
contextScope: job.contextScope,
|
||||
});
|
||||
void runHermesJob(job.id);
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function getHermesJob(jobId: string): Promise<HermesJob | null> {
|
||||
return (await hermesJobs()).findOne({ id: jobId }, { projection: { _id: 0 } });
|
||||
}
|
||||
|
||||
export async function getActiveHermesJobForSession(sessionId: string): Promise<HermesJob | null> {
|
||||
return (await hermesJobs()).findOne(
|
||||
{ sessionId, status: { $in: ['queued', 'running', 'waiting_for_confirmation', 'aborting'] } },
|
||||
{ projection: { _id: 0 }, sort: { updatedAt: -1 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLatestHermesJobForSession(sessionId: string): Promise<HermesJob | null> {
|
||||
return (await hermesJobs()).findOne(
|
||||
{ sessionId },
|
||||
{ projection: { _id: 0 }, sort: { updatedAt: -1 } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listHermesJobEvents(jobId: string, limit = 40): Promise<HermesJobEvent[]> {
|
||||
return (await hermesJobEvents())
|
||||
.find({ jobId }, { projection: { _id: 0 } })
|
||||
.sort({ createdAt: 1 })
|
||||
.limit(Math.min(limit, 200))
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function appendHermesJobEvent(
|
||||
jobId: string,
|
||||
type: HermesJobEventType,
|
||||
message: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<HermesJobEvent> {
|
||||
const job = await getHermesJob(jobId);
|
||||
if (!job) throw new Error('job not found');
|
||||
const event: HermesJobEvent = {
|
||||
id: `hermes_evt_${randomUUID()}`,
|
||||
jobId,
|
||||
podId: job.podId,
|
||||
sessionId: job.sessionId,
|
||||
type,
|
||||
message: redact(truncate(message)),
|
||||
data,
|
||||
createdAt: now(),
|
||||
};
|
||||
await (await hermesJobEvents()).insertOne(event);
|
||||
await (
|
||||
await hermesJobs()
|
||||
).updateOne(
|
||||
{ id: jobId },
|
||||
{ $set: { updatedAt: event.createdAt, lastHeartbeatAt: event.createdAt } },
|
||||
);
|
||||
if (job.conversationRoom) {
|
||||
void publishHermesJobEvent(job.conversationRoom, event).catch((err) =>
|
||||
console.warn(`[hermes-job] data publish failed: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function abortHermesJob(jobId: string): Promise<HermesJob | null> {
|
||||
const job = await getHermesJob(jobId);
|
||||
if (!job) return null;
|
||||
const abortAt = now();
|
||||
await (
|
||||
await hermesJobs()
|
||||
).updateOne(
|
||||
{ id: jobId },
|
||||
{ $set: { status: 'aborting', abortRequestedAt: abortAt, updatedAt: abortAt } },
|
||||
);
|
||||
runners.get(jobId)?.abort();
|
||||
await appendHermesJobEvent(jobId, 'heartbeat', 'Hermes is aborting the current job.');
|
||||
return getHermesJob(jobId);
|
||||
}
|
||||
|
||||
async function setStatus(jobId: string, status: HermesJobStatus, patch: Partial<HermesJob> = {}) {
|
||||
await (
|
||||
await hermesJobs()
|
||||
).updateOne({ id: jobId }, { $set: { status, updatedAt: now(), ...patch } });
|
||||
}
|
||||
|
||||
async function publishHermesJobEvent(roomName: string, event: HermesJobEvent): Promise<void> {
|
||||
const room = new LiveKitRoom();
|
||||
try {
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
identity: `podman-hermes-job-${Date.now()}`,
|
||||
name: 'PodMan Hermes jobs',
|
||||
ttl: '5m',
|
||||
});
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: roomName,
|
||||
canPublish: true,
|
||||
canSubscribe: false,
|
||||
canPublishData: true,
|
||||
});
|
||||
await room.connect(env.LIVEKIT_URL, await at.toJwt(), {
|
||||
autoSubscribe: false,
|
||||
dynacast: false,
|
||||
});
|
||||
const data: DataMessage = { type: 'HERMES_JOB_EVENT', event };
|
||||
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
} finally {
|
||||
await room.disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
jobId: string,
|
||||
label: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
await appendHermesJobEvent(jobId, 'step_started', `${label} started.`);
|
||||
const started = Date.now();
|
||||
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
signal,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const output = redact(truncate([stdout, stderr].filter(Boolean).join('\n').trim()));
|
||||
await appendHermesJobEvent(jobId, 'step_output', output || `${label} produced no output.`, {
|
||||
label,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
await appendHermesJobEvent(jobId, 'step_completed', `${label} completed.`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function wantsBuild(prompt: string, criteria: string[]): boolean {
|
||||
const haystack = `${prompt} ${criteria.join(' ')}`.toLowerCase();
|
||||
return /build|typecheck|test|lint|broken|failing|verify/.test(haystack);
|
||||
}
|
||||
|
||||
function wantsMongo(prompt: string, scope: string): boolean {
|
||||
return scope === 'mongodb' || /mongo|database|telemetry|logs?|memory/.test(prompt.toLowerCase());
|
||||
}
|
||||
|
||||
function wantsGithub(prompt: string, scope: string): boolean {
|
||||
return (
|
||||
scope === 'github' || /github|branch|pr|pull request|commit|diff/.test(prompt.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectMongo(jobId: string) {
|
||||
await appendHermesJobEvent(jobId, 'step_started', 'MongoDB inspection started.');
|
||||
const db = await getDb();
|
||||
const [observations, collisions, interventions, outcomes, jobs] = await Promise.all([
|
||||
db.collection('observations').estimatedDocumentCount(),
|
||||
db.collection('collisions').estimatedDocumentCount(),
|
||||
db.collection('interventions').estimatedDocumentCount(),
|
||||
db.collection('outcomes').estimatedDocumentCount(),
|
||||
db.collection('hermes_jobs').estimatedDocumentCount(),
|
||||
]);
|
||||
await appendHermesJobEvent(
|
||||
jobId,
|
||||
'step_output',
|
||||
`MongoDB is reachable. Counts: observations=${observations}, collisions=${collisions}, interventions=${interventions}, outcomes=${outcomes}, hermes_jobs=${jobs}.`,
|
||||
);
|
||||
await appendHermesJobEvent(jobId, 'step_completed', 'MongoDB inspection completed.');
|
||||
}
|
||||
|
||||
async function inspectGithub(jobId: string) {
|
||||
await appendHermesJobEvent(jobId, 'step_started', 'GitHub repository inspection started.');
|
||||
const { owner, repo } = repoParts();
|
||||
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||
'x-github-api-version': '2022-11-28',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub repo check returned ${res.status}`);
|
||||
const body = (await res.json()) as {
|
||||
full_name?: string;
|
||||
default_branch?: string;
|
||||
open_issues_count?: number;
|
||||
};
|
||||
await appendHermesJobEvent(
|
||||
jobId,
|
||||
'step_output',
|
||||
`GitHub ${body.full_name ?? `${owner}/${repo}`} is reachable. Default branch=${body.default_branch ?? 'unknown'}, open issue count=${body.open_issues_count ?? 0}.`,
|
||||
);
|
||||
await appendHermesJobEvent(jobId, 'step_completed', 'GitHub repository inspection completed.');
|
||||
}
|
||||
|
||||
async function runHermesJob(jobId: string): Promise<void> {
|
||||
const job = await getHermesJob(jobId);
|
||||
if (!job) return;
|
||||
const controller = new AbortController();
|
||||
runners.set(jobId, controller);
|
||||
try {
|
||||
await setStatus(jobId, 'running', { startedAt: now() });
|
||||
await appendHermesJobEvent(jobId, 'heartbeat', 'Hermes is gathering repository context.');
|
||||
const outputs: string[] = [];
|
||||
outputs.push(
|
||||
await runCommand(
|
||||
jobId,
|
||||
'Git status',
|
||||
'git',
|
||||
['status', '--short', '--branch'],
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
outputs.push(
|
||||
await runCommand(jobId, 'Git diff summary', 'git', ['diff', '--stat'], controller.signal),
|
||||
);
|
||||
|
||||
if (wantsGithub(job.prompt, job.contextScope)) await inspectGithub(jobId);
|
||||
if (wantsMongo(job.prompt, job.contextScope)) await inspectMongo(jobId);
|
||||
|
||||
if (wantsBuild(job.prompt, job.successCriteria)) {
|
||||
outputs.push(
|
||||
await runCommand(jobId, 'TypeScript typecheck', 'pnpm', ['typecheck'], controller.signal),
|
||||
);
|
||||
}
|
||||
|
||||
if (job.riskLevel === 'deploy_allowed' && job.requiresConfirmation) {
|
||||
await setStatus(jobId, 'waiting_for_confirmation');
|
||||
await appendHermesJobEvent(
|
||||
jobId,
|
||||
'needs_confirmation',
|
||||
'Hermes needs confirmation before deploy-level actions.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalSummary = `Hermes completed the task. It inspected repository state${wantsMongo(job.prompt, job.contextScope) ? ', MongoDB' : ''}${wantsGithub(job.prompt, job.contextScope) ? ', and GitHub' : ''}. ${outputs.some((o) => /error|failed/i.test(o)) ? 'Review the recorded output for warnings.' : 'No blocking error was reported by the completed checks.'}`;
|
||||
await setStatus(jobId, 'completed', { completedAt: now(), finalSummary });
|
||||
await appendHermesJobEvent(jobId, 'completed', finalSummary);
|
||||
} catch (err) {
|
||||
const aborted = controller.signal.aborted;
|
||||
const message = aborted
|
||||
? 'Hermes aborted the job before making further changes.'
|
||||
: (err as Error).message;
|
||||
await setStatus(jobId, aborted ? 'aborted' : 'failed', {
|
||||
completedAt: now(),
|
||||
finalSummary: message,
|
||||
error: aborted ? undefined : message,
|
||||
});
|
||||
await appendHermesJobEvent(jobId, aborted ? 'aborted' : 'failed', message);
|
||||
} finally {
|
||||
runners.delete(jobId);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { getMemberWorkHistory } from '../activity/member-history.js';
|
||||
|
||||
const DEFAULT_LIMIT = 8;
|
||||
|
||||
function sinceIso(hours: number): string {
|
||||
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
export async function getLiveConversationContext(podId: string, identity: string) {
|
||||
const db = await getDb();
|
||||
const since = sinceIso(12);
|
||||
const [pod, history, gitState, collisions, interventions, outcomes] = await Promise.all([
|
||||
db.collection('pods').findOne({ id: podId }, { projection: { _id: 0 } }),
|
||||
getMemberWorkHistory(podId, identity, { hours: 24, limit: 30 }).catch(() => null),
|
||||
db.collection('engineer_states').findOne(
|
||||
{ podId, name: identity },
|
||||
{
|
||||
projection: {
|
||||
_id: 0,
|
||||
name: 1,
|
||||
branch: 1,
|
||||
changedFiles: 1,
|
||||
recentCommit: 1,
|
||||
gitUpdatedAt: 1,
|
||||
},
|
||||
},
|
||||
),
|
||||
db
|
||||
.collection('collisions')
|
||||
.find({ podId, detectedAt: { $gte: since } }, { projection: { _id: 0, embedding: 0 } })
|
||||
.sort({ detectedAt: -1 })
|
||||
.limit(DEFAULT_LIMIT)
|
||||
.toArray(),
|
||||
db
|
||||
.collection('interventions')
|
||||
.find({ podId, createdAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(DEFAULT_LIMIT)
|
||||
.toArray(),
|
||||
db
|
||||
.collection('outcomes')
|
||||
.find({ podId, recordedAt: { $gte: since } }, { projection: { _id: 0 } })
|
||||
.sort({ recordedAt: -1 })
|
||||
.limit(DEFAULT_LIMIT)
|
||||
.toArray(),
|
||||
]);
|
||||
|
||||
return {
|
||||
pod,
|
||||
identity,
|
||||
generatedAt: new Date().toISOString(),
|
||||
currentGitState: gitState,
|
||||
memberHistory: history,
|
||||
recentCollisions: collisions,
|
||||
recentInterventions: interventions,
|
||||
recentOutcomes: outcomes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordLiveConversationNote(input: {
|
||||
podId: string;
|
||||
sessionId: string;
|
||||
identity?: string;
|
||||
note: string;
|
||||
kind?: string;
|
||||
}) {
|
||||
const note = input.note.trim();
|
||||
if (!note) throw new Error('note is required');
|
||||
const doc = {
|
||||
podId: input.podId,
|
||||
sessionId: input.sessionId,
|
||||
identity: input.identity,
|
||||
kind: input.kind || 'summary',
|
||||
note: note.slice(0, 4000),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await (await getDb()).collection('conversation_notes').insertOne(doc);
|
||||
return { ...doc, _id: undefined };
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AccessToken, RoomAgentDispatch, RoomConfiguration } from 'livekit-server-sdk';
|
||||
import { Room as LiveKitRoom } from '@livekit/rtc-node';
|
||||
import type { Collision, DataMessage, Intervention, LiveConversationEvent } from '@podman/shared';
|
||||
import { DATA_TOPIC } from '@podman/shared';
|
||||
import { env } from '../env.js';
|
||||
import { closeRoom } from '../livekit/rooms.js';
|
||||
import { speakInRoom } from '../voice/live.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const DEFAULT_AGENT = 'podman-live-conversation';
|
||||
|
||||
export interface LiveConversationSession {
|
||||
sessionId: string;
|
||||
podId: string;
|
||||
identity: string;
|
||||
displayName: string;
|
||||
room: string;
|
||||
url: string;
|
||||
startedAt: string;
|
||||
lastEventAt?: string;
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, LiveConversationSession>();
|
||||
|
||||
function sessionKey(podId: string, identity: string): string {
|
||||
return `${podId}:${identity.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function cleanPart(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function agentName(): string {
|
||||
return env.LIVEKIT_CONVERSATION_AGENT_NAME || DEFAULT_AGENT;
|
||||
}
|
||||
|
||||
function tokenFor(room: string, identity: string, name: string, metadata: object): Promise<string> {
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
identity,
|
||||
name,
|
||||
ttl: '4h',
|
||||
metadata: JSON.stringify(metadata),
|
||||
});
|
||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||
return at.toJwt();
|
||||
}
|
||||
|
||||
export async function startLiveConversation(input: {
|
||||
podId: string;
|
||||
identity: string;
|
||||
displayName?: string;
|
||||
}): Promise<LiveConversationSession & { token: string }> {
|
||||
const identity = input.identity.trim();
|
||||
if (!identity) throw new Error('identity is required');
|
||||
|
||||
const existing = activeLiveConversation(input.podId, identity);
|
||||
if (existing) {
|
||||
return {
|
||||
...existing,
|
||||
token: await tokenFor(existing.room, identity, existing.displayName, {
|
||||
podId: input.podId,
|
||||
identity,
|
||||
sessionId: existing.sessionId,
|
||||
mode: 'podman-live-conversation',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const room = `podman-live:${cleanPart(input.podId)}:${cleanPart(identity)}:${sessionId.slice(0, 8)}`;
|
||||
const displayName = input.displayName?.trim() || identity;
|
||||
const metadata = { podId: input.podId, identity, sessionId, mode: 'podman-live-conversation' };
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
identity,
|
||||
name: displayName,
|
||||
ttl: '4h',
|
||||
metadata: JSON.stringify(metadata),
|
||||
});
|
||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||
at.roomConfig = new RoomConfiguration({
|
||||
name: room,
|
||||
emptyTimeout: 60,
|
||||
departureTimeout: 15,
|
||||
agents: [
|
||||
new RoomAgentDispatch({
|
||||
agentName: agentName(),
|
||||
metadata: JSON.stringify(metadata),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const session: LiveConversationSession = {
|
||||
sessionId,
|
||||
podId: input.podId,
|
||||
identity,
|
||||
displayName,
|
||||
room,
|
||||
url: env.LIVEKIT_URL,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
sessions.set(sessionKey(input.podId, identity), session);
|
||||
return { ...session, token: await at.toJwt() };
|
||||
}
|
||||
|
||||
export function activeLiveConversation(
|
||||
podId: string,
|
||||
identity: string,
|
||||
): LiveConversationSession | null {
|
||||
const session = sessions.get(sessionKey(podId, identity));
|
||||
return session && !session.endedAt ? session : null;
|
||||
}
|
||||
|
||||
export function listActiveLiveConversations(podId: string): LiveConversationSession[] {
|
||||
return [...sessions.values()].filter((session) => session.podId === podId && !session.endedAt);
|
||||
}
|
||||
|
||||
export async function stopLiveConversation(
|
||||
podId: string,
|
||||
sessionId: string,
|
||||
): Promise<LiveConversationSession | null> {
|
||||
const session = [...sessions.values()].find(
|
||||
(candidate) => candidate.podId === podId && candidate.sessionId === sessionId,
|
||||
);
|
||||
if (!session) return null;
|
||||
session.endedAt = new Date().toISOString();
|
||||
await closeRoom(session.room);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function publishPrivateConversationEvent(
|
||||
roomName: string,
|
||||
event: LiveConversationEvent,
|
||||
): Promise<void> {
|
||||
const room = new LiveKitRoom();
|
||||
try {
|
||||
const token = await tokenFor(roomName, `podman-live-router-${Date.now()}`, 'PodMan live router', {
|
||||
mode: 'podman-live-router',
|
||||
});
|
||||
await room.connect(env.LIVEKIT_URL, token, { autoSubscribe: false, dynacast: false });
|
||||
const data: DataMessage = { type: 'LIVE_CONVERSATION_EVENT', event };
|
||||
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
} finally {
|
||||
await room.disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyCriticalLiveConversations(
|
||||
collision: Collision,
|
||||
intervention: Intervention,
|
||||
voiceLine?: string,
|
||||
): Promise<void> {
|
||||
if (collision.severity !== 'critical') return;
|
||||
const recipients = new Set(collision.engineers.map((name) => name.toLowerCase()));
|
||||
const active = listActiveLiveConversations(collision.podId).filter((session) =>
|
||||
recipients.has(session.identity.toLowerCase()),
|
||||
);
|
||||
if (active.length === 0) return;
|
||||
|
||||
await Promise.allSettled(
|
||||
active.map(async (session) => {
|
||||
const createdAt = new Date().toISOString();
|
||||
session.lastEventAt = createdAt;
|
||||
const summary =
|
||||
voiceLine ||
|
||||
`Critical collision in ${collision.file}. ${collision.engineers.join(
|
||||
' and ',
|
||||
)} should sync before pushing.`;
|
||||
await publishPrivateConversationEvent(session.room, {
|
||||
id: `live_evt_${Date.now()}_${session.sessionId.slice(0, 8)}`,
|
||||
podId: collision.podId,
|
||||
sessionId: session.sessionId,
|
||||
kind: 'critical_collision',
|
||||
severity: 'critical',
|
||||
summary,
|
||||
interrupt: true,
|
||||
createdAt,
|
||||
collisionId: collision.id,
|
||||
interventionId: intervention.id,
|
||||
});
|
||||
await speakInRoom(session.room, summary, { priority: 'critical' });
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -57,8 +57,6 @@ export interface GitState {
|
||||
gitUpdatedAt: Date | null;
|
||||
}
|
||||
|
||||
const GIT_STATE_TTL_MS = Number(process.env.GIT_STATE_TTL_MS ?? '120000');
|
||||
|
||||
/** Fetch latest git state per engineer for a pod from the engineer_states collection.
|
||||
* Returns a map keyed by engineer name (matches --name arg used in podman-agent.mjs). */
|
||||
export async function getGitStates(podId: string): Promise<Map<string, GitState>> {
|
||||
@@ -73,17 +71,7 @@ export async function getGitStates(podId: string): Promise<Map<string, GitState>
|
||||
}>('engineer_states');
|
||||
const docs = await col.find({ podId }).toArray();
|
||||
const map = new Map<string, GitState>();
|
||||
const now = Date.now();
|
||||
for (const doc of docs) {
|
||||
const updatedAt = doc.gitUpdatedAt ? new Date(doc.gitUpdatedAt) : null;
|
||||
if (
|
||||
updatedAt &&
|
||||
!Number.isNaN(updatedAt.getTime()) &&
|
||||
GIT_STATE_TTL_MS > 0 &&
|
||||
now - updatedAt.getTime() > GIT_STATE_TTL_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
map.set(doc.name, {
|
||||
changedFiles: doc.changedFiles ?? [],
|
||||
branch: doc.branch ?? null,
|
||||
@@ -111,18 +99,8 @@ export async function initMemory(): Promise<void> {
|
||||
'collisions.memorySignature',
|
||||
() => c.collisions.createIndex({ podId: 1, memorySignature: 1 }),
|
||||
],
|
||||
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
||||
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
||||
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
||||
['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })],
|
||||
[
|
||||
'hermes_jobs.session',
|
||||
() => db.collection('hermes_jobs').createIndex({ sessionId: 1, status: 1, updatedAt: -1 }),
|
||||
],
|
||||
[
|
||||
'hermes_job_events.job',
|
||||
() => db.collection('hermes_job_events').createIndex({ jobId: 1, createdAt: 1 }),
|
||||
],
|
||||
];
|
||||
for (const [name, make] of indexes) {
|
||||
try {
|
||||
|
||||
@@ -1,42 +1,17 @@
|
||||
import type { Collision, SuggestedActionKind } from '@podman/shared';
|
||||
import type { RecalledCollision } from './vectors.js';
|
||||
|
||||
const lastNudgeByPod = new Map<string, number>();
|
||||
|
||||
function cooldownMs(): number {
|
||||
return Number(process.env.NUDGE_COOLDOWN_MS ?? '180000');
|
||||
/**
|
||||
* Policy gate: decides whether PodMan should intervene.
|
||||
* Stub: always intervene on warn/critical.
|
||||
*/
|
||||
export function shouldIntervene(collision: Collision, _prior: unknown): boolean {
|
||||
return collision.severity !== 'info';
|
||||
}
|
||||
|
||||
/** Policy gate: combines severity, exact recall outcomes, and per-pod cooldown. */
|
||||
export function shouldIntervene(collision: Collision, prior: RecalledCollision | null): boolean {
|
||||
if (collision.severity === 'info') return false;
|
||||
|
||||
const priorOutcome = prior?.priorOutcome;
|
||||
// Suppress when the identical prior was dismissed (accepted === false). The
|
||||
// former `&& !priorOutcome.wasRealCollision` term was dead code: outcomes are
|
||||
// recorded with wasRealCollision hardcoded true, so the gate never fired and
|
||||
// the 85 real dismissals in Atlas were ignored. Dismissals are the negative
|
||||
// signal per continual-learning/policy.md:41 + spec.md:163. (RSI Step 1)
|
||||
if (priorOutcome && !priorOutcome.accepted) return false;
|
||||
|
||||
const cooldown = cooldownMs();
|
||||
const last = lastNudgeByPod.get(collision.podId) ?? 0;
|
||||
if (cooldown > 0 && Date.now() - last < cooldown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastNudgeByPod.set(collision.podId, Date.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Preferred action selection based on collision severity and prior accepted actions. */
|
||||
export function preferredAction(
|
||||
collision: Collision,
|
||||
prior: RecalledCollision | null,
|
||||
): SuggestedActionKind {
|
||||
const acceptedKind = prior?.priorOutcome?.accepted
|
||||
? prior.priorIntervention?.suggestedAction.kind
|
||||
: undefined;
|
||||
if (acceptedKind && acceptedKind !== 'none') return acceptedKind;
|
||||
/**
|
||||
* Preferred action selection based on collision + prior history.
|
||||
* Stub: open sync PR for critical, ping teammate otherwise.
|
||||
*/
|
||||
export function preferredAction(collision: Collision, _prior: unknown): SuggestedActionKind {
|
||||
return collision.severity === 'critical' ? 'open_sync_pr' : 'ping_teammate';
|
||||
}
|
||||
|
||||
+6
-104
@@ -1,34 +1,17 @@
|
||||
import type {
|
||||
EngineerContext,
|
||||
Collision,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
InterventionStatus,
|
||||
} from '@podman/shared';
|
||||
import { collections, getGitStates } from './db.js';
|
||||
import type { EngineerContext, Collision, Intervention, InterventionOutcome } from '@podman/shared';
|
||||
import { collections } from './db.js';
|
||||
import { enrichCollisionMemory } from './vectors.js';
|
||||
|
||||
function comparableFile(raw?: string): string {
|
||||
return (raw ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Continual-learning memory: persist observations, collisions, interventions,
|
||||
* and outcomes to MongoDB so later sessions get sharper. MongoDB is mandatory —
|
||||
* a failed write is surfaced loudly and rethrown, never silently swallowed, so
|
||||
* a broken memory layer can never masquerade as a working one.
|
||||
* and outcomes to MongoDB so later sessions get sharper. Writes are best-effort
|
||||
* — a Mongo hiccup logs a warning rather than crashing the agent/server.
|
||||
*/
|
||||
async function persist(name: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(`[memory] ${name} persist FAILED: ${(err as Error).message}`);
|
||||
throw err;
|
||||
console.warn(`[memory] ${name} persist failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,89 +33,8 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
|
||||
);
|
||||
}
|
||||
|
||||
export async function hasRecentInterventionForCollision(
|
||||
collision: Collision,
|
||||
windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'),
|
||||
): Promise<boolean> {
|
||||
if (windowMs <= 0) return false;
|
||||
const c = await collections();
|
||||
const since = new Date(Date.now() - windowMs).toISOString();
|
||||
const recent = await c.collisions
|
||||
.find({ podId: collision.podId, detectedAt: { $gte: since } })
|
||||
.sort({ detectedAt: -1 })
|
||||
.limit(100)
|
||||
.toArray();
|
||||
|
||||
const targetFile = comparableFile(collision.file);
|
||||
for (const match of recent) {
|
||||
if (match.id === collision.id || comparableFile(match.file) !== targetFile) continue;
|
||||
const existing = await c.interventions.findOne({
|
||||
collisionId: match.id,
|
||||
createdAt: { $gte: since },
|
||||
});
|
||||
if (existing) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function updateInterventionStatus(
|
||||
interventionId: string,
|
||||
status: InterventionStatus,
|
||||
): Promise<void> {
|
||||
await persist('intervention ack', async () =>
|
||||
(await collections()).interventions.updateOne({ id: interventionId }, { $set: { status } }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 — derive whether a flagged collision was REAL from git ground truth,
|
||||
* instead of trusting the client (which historically hardcoded `true`). A
|
||||
* collision counts as real only if BOTH named engineers currently have the
|
||||
* collided file in their git `changedFiles`. Conservative: returns false when
|
||||
* the collision is orphaned/missing or git state is stale/unavailable.
|
||||
* Verifier supervision per docs/continual-learning/spec.md:98-108, policy.md:35-42.
|
||||
*/
|
||||
export async function deriveWasRealCollision(outcome: InterventionOutcome): Promise<boolean> {
|
||||
try {
|
||||
const c = await collections();
|
||||
const collision = await c.collisions.findOne({ id: outcome.collisionId });
|
||||
if (!collision) return false;
|
||||
// Prefer the overlap evidence captured at detection time (fresh git state):
|
||||
// immune to late clicks, stale sidecars, and the engineer_states TTL.
|
||||
if (typeof collision.gitOverlap === 'boolean') return collision.gitOverlap;
|
||||
// Fallback for collisions detected before gitOverlap was captured: re-derive
|
||||
// from latest git state, matching engineers on case/whitespace-canonical names.
|
||||
if (!Array.isArray(collision.engineers) || collision.engineers.length < 2) return false;
|
||||
const target = comparableFile(collision.file);
|
||||
if (!target) return false;
|
||||
const byCanon = new Map<string, string[]>();
|
||||
for (const [name, st] of await getGitStates(outcome.podId)) {
|
||||
byCanon.set(name.trim().toLowerCase(), st.changedFiles);
|
||||
}
|
||||
return collision.engineers.every((e) =>
|
||||
(byCanon.get(e.trim().toLowerCase()) ?? []).some((f) => comparableFile(f) === target),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[memory] wasRealCollision verifier failed: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||
// Backend is authoritative for wasRealCollision: derive it from git overlap
|
||||
// rather than trusting the client-supplied value. (RSI Step 3)
|
||||
const verified: InterventionOutcome = {
|
||||
...outcome,
|
||||
wasRealCollision: await deriveWasRealCollision(outcome),
|
||||
};
|
||||
await persist('outcome', async () => {
|
||||
const c = await collections();
|
||||
await c.outcomes.insertOne({ ...verified });
|
||||
await c.interventions.updateOne(
|
||||
{ id: verified.interventionId },
|
||||
{ $set: { status: verified.accepted ? 'accepted' : 'dismissed' } },
|
||||
);
|
||||
});
|
||||
await persist('outcome', async () => (await collections()).outcomes.insertOne({ ...outcome }));
|
||||
}
|
||||
|
||||
/** Document counts per collection — used by the /api/memory/stats endpoint. */
|
||||
|
||||
+17
-149
@@ -1,4 +1,4 @@
|
||||
import type { Collision, Intervention, InterventionOutcome } from '@podman/shared';
|
||||
import type { Collision } from '@podman/shared';
|
||||
import { env } from '../env.js';
|
||||
import { getDb } from './db.js';
|
||||
|
||||
@@ -6,35 +6,18 @@ type StoredCollision = Collision & {
|
||||
memorySignature?: string;
|
||||
memoryText?: string;
|
||||
embedding?: number[];
|
||||
embeddingProvider?: string;
|
||||
};
|
||||
|
||||
export type RecalledCollision = Collision & {
|
||||
priorIntervention?: Intervention;
|
||||
priorOutcome?: InterventionOutcome;
|
||||
};
|
||||
|
||||
interface VoyageEmbeddingResponse {
|
||||
data?: Array<{ embedding?: number[] }>;
|
||||
}
|
||||
|
||||
interface GeminiEmbeddingResponse {
|
||||
embedding?: { values?: number[] };
|
||||
}
|
||||
|
||||
function normalize(value: string | undefined): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function signature(collision: Collision): string {
|
||||
return [
|
||||
normalize(collision.file),
|
||||
normalize(collision.symbol),
|
||||
[...collision.engineers].sort().map(normalize).join('+'),
|
||||
'collision',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('#');
|
||||
return [normalize(collision.file), normalize(collision.symbol)].filter(Boolean).join('#');
|
||||
}
|
||||
|
||||
function memoryText(collision: Collision): string {
|
||||
@@ -49,30 +32,7 @@ function memoryText(collision: Collision): string {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function cosine(a: number[], b: number[]): number {
|
||||
const n = Math.min(a.length, b.length);
|
||||
let dot = 0;
|
||||
let aNorm = 0;
|
||||
let bNorm = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
dot += av * bv;
|
||||
aNorm += av * av;
|
||||
bNorm += bv * bv;
|
||||
}
|
||||
if (!aNorm || !bNorm) return -1;
|
||||
return dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
|
||||
}
|
||||
|
||||
async function embed(text: string, inputType: 'document' | 'query'): Promise<number[] | null> {
|
||||
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
|
||||
}
|
||||
|
||||
async function embedWithVoyage(
|
||||
text: string,
|
||||
inputType: 'document' | 'query',
|
||||
): Promise<number[] | null> {
|
||||
if (!env.VOYAGE_API_KEY) return null;
|
||||
try {
|
||||
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
|
||||
@@ -99,38 +59,6 @@ async function embedWithVoyage(
|
||||
}
|
||||
}
|
||||
|
||||
async function embedWithGemini(
|
||||
text: string,
|
||||
inputType: 'document' | 'query',
|
||||
): Promise<number[] | null> {
|
||||
try {
|
||||
const taskType = inputType === 'document' ? 'RETRIEVAL_DOCUMENT' : 'RETRIEVAL_QUERY';
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||
env.GEMINI_EMBEDDING_MODEL,
|
||||
)}:embedContent?key=${encodeURIComponent(env.GEMINI_API_KEY)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: { parts: [{ text }] },
|
||||
taskType,
|
||||
outputDimensionality: 768,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn(`[memory] gemini embedding failed: ${res.status} ${await res.text()}`);
|
||||
return null;
|
||||
}
|
||||
const body = (await res.json()) as GeminiEmbeddingResponse;
|
||||
return body.embedding?.values ?? null;
|
||||
} catch (err) {
|
||||
console.warn(`[memory] gemini embedding failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichCollisionMemory(collision: Collision): Promise<StoredCollision> {
|
||||
const text = memoryText(collision);
|
||||
const embedding = await embed(text, 'document');
|
||||
@@ -138,45 +66,16 @@ export async function enrichCollisionMemory(collision: Collision): Promise<Store
|
||||
...collision,
|
||||
memorySignature: signature(collision),
|
||||
memoryText: text,
|
||||
...(embedding
|
||||
? { embedding, embeddingProvider: env.VOYAGE_API_KEY ? 'voyage' : 'gemini' }
|
||||
: {}),
|
||||
...(embedding ? { embedding } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function attachOutcome(match: StoredCollision): Promise<RecalledCollision> {
|
||||
const db = await getDb();
|
||||
const intervention = await db
|
||||
.collection<Intervention>('interventions')
|
||||
.findOne({ collisionId: match.id }, { sort: { createdAt: -1 }, projection: { _id: 0 } });
|
||||
const outcome = intervention
|
||||
? await db
|
||||
.collection<InterventionOutcome>('outcomes')
|
||||
.findOne(
|
||||
{ interventionId: intervention.id },
|
||||
{ sort: { recordedAt: -1 }, projection: { _id: 0 } },
|
||||
)
|
||||
: null;
|
||||
const {
|
||||
memorySignature: _memorySignature,
|
||||
memoryText: _memoryText,
|
||||
embedding: _embedding,
|
||||
embeddingProvider: _embeddingProvider,
|
||||
...collision
|
||||
} = match;
|
||||
return {
|
||||
...collision,
|
||||
...(intervention ? { priorIntervention: intervention } : {}),
|
||||
...(outcome ? { priorOutcome: outcome } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function recallByVector(collision: Collision): Promise<RecalledCollision | null> {
|
||||
async function recallByVector(collision: Collision): Promise<Collision | null> {
|
||||
const queryVector = await embed(memoryText(collision), 'query');
|
||||
if (!queryVector) return null;
|
||||
|
||||
const db = await getDb();
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [match] = await db
|
||||
.collection<StoredCollision>('collisions')
|
||||
.aggregate<StoredCollision>([
|
||||
@@ -194,62 +93,31 @@ async function recallByVector(collision: Collision): Promise<RecalledCollision |
|
||||
{ $project: { _id: 0, embedding: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
return match ? attachOutcome(match) : null;
|
||||
return match ?? null;
|
||||
} catch (err) {
|
||||
console.warn(`[memory] atlas vector recall unavailable: ${(err as Error).message}`);
|
||||
console.warn(`[memory] vector recall unavailable: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = await db
|
||||
.collection<StoredCollision>('collisions')
|
||||
.find(
|
||||
{
|
||||
podId: collision.podId,
|
||||
id: { $ne: collision.id },
|
||||
embedding: { $exists: true },
|
||||
},
|
||||
{ projection: { _id: 0 }, limit: 100 },
|
||||
)
|
||||
.toArray();
|
||||
let best: { match: StoredCollision; score: number } | null = null;
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.embedding?.length) continue;
|
||||
const score = cosine(queryVector, candidate.embedding);
|
||||
if (!best || score > best.score) best = { match: candidate, score };
|
||||
}
|
||||
return best && best.score > 0.5 ? attachOutcome(best.match) : null;
|
||||
}
|
||||
|
||||
async function recallBySignature(collision: Collision): Promise<RecalledCollision | null> {
|
||||
async function recallBySignature(collision: Collision): Promise<Collision | null> {
|
||||
const db = await getDb();
|
||||
const sig = signature(collision);
|
||||
const matches = await db
|
||||
.collection<StoredCollision>('collisions')
|
||||
.find(
|
||||
const match = await db.collection<StoredCollision>('collisions').findOne(
|
||||
{
|
||||
podId: collision.podId,
|
||||
id: { $ne: collision.id },
|
||||
$or: [{ memorySignature: sig }, { file: collision.file }],
|
||||
},
|
||||
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 }, limit: 10 },
|
||||
)
|
||||
.toArray();
|
||||
|
||||
let fallback: RecalledCollision | null = null;
|
||||
for (const match of matches) {
|
||||
const recalled = await attachOutcome(match);
|
||||
if (!fallback) fallback = recalled;
|
||||
if (recalled.priorOutcome?.accepted && recalled.priorOutcome.wasRealCollision) {
|
||||
return recalled;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 } },
|
||||
);
|
||||
return match ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recall prior collision patterns. Atlas Vector Search is preferred when
|
||||
* available; standalone MongoDB falls back to app-side cosine search over
|
||||
* stored embeddings, then exact signature/file matching.
|
||||
* Recall prior collision patterns. Exact Mongo recall is always available;
|
||||
* Voyage + Atlas Vector Search is used first when configured.
|
||||
*/
|
||||
export async function recallSimilar(collision: Collision): Promise<RecalledCollision | null> {
|
||||
export async function recallSimilar(collision: Collision): Promise<Collision | null> {
|
||||
return (await recallByVector(collision)) ?? recallBySignature(collision);
|
||||
}
|
||||
|
||||
+6
-432
@@ -1,18 +1,11 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { AccessToken, RoomAgentDispatch, RoomConfiguration } from 'livekit-server-sdk';
|
||||
import { AccessToken, RoomConfiguration } from 'livekit-server-sdk';
|
||||
import { env } from './env.js';
|
||||
import { createSyncPr } from './github/client.js';
|
||||
import {
|
||||
recordCollision,
|
||||
recordIntervention,
|
||||
recordOutcome,
|
||||
hasRecentInterventionForCollision,
|
||||
memoryStats,
|
||||
} from './memory/store.js';
|
||||
import { recordOutcome, memoryStats } from './memory/store.js';
|
||||
import { closeMemory, initMemory } from './memory/db.js';
|
||||
import {
|
||||
listPods,
|
||||
@@ -26,66 +19,13 @@ import {
|
||||
} from './pods/store.js';
|
||||
import { getPresence, closeRoom } from './livekit/rooms.js';
|
||||
import { loadPodGraph, reachFrom } from './graph/store.js';
|
||||
import { listPodActivity } from './activity/store.js';
|
||||
import { getMemberWorkHistory } from './activity/member-history.js';
|
||||
import { speakInRoom } from './voice/live.js';
|
||||
import { getPodMusic } from './voice/music.js';
|
||||
import { notifyHermesInterventionInRoom } from './action/hermes.js';
|
||||
import {
|
||||
activeLiveConversation,
|
||||
startLiveConversation,
|
||||
stopLiveConversation,
|
||||
} from './live-conversation/sessions.js';
|
||||
import {
|
||||
getLiveConversationContext,
|
||||
recordLiveConversationNote,
|
||||
} from './live-conversation/context.js';
|
||||
import {
|
||||
abortHermesJob,
|
||||
appendHermesJobEvent,
|
||||
createHermesJob,
|
||||
getActiveHermesJobForSession,
|
||||
getHermesJob,
|
||||
getLatestHermesJobForSession,
|
||||
listHermesJobEvents,
|
||||
} from './hermes/jobs.js';
|
||||
import type {
|
||||
Collision,
|
||||
HermesJobEventType,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
SuggestedActionKind,
|
||||
} from '@podman/shared';
|
||||
import type { InterventionOutcome } from '@podman/shared';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function suggestedAction(value: unknown): SuggestedActionKind {
|
||||
return value === 'open_sync_pr' || value === 'ping_teammate' || value === 'none'
|
||||
? value
|
||||
: 'ping_teammate';
|
||||
}
|
||||
|
||||
function hermesJobEventType(value: unknown): HermesJobEventType | null {
|
||||
return value === 'accepted' ||
|
||||
value === 'heartbeat' ||
|
||||
value === 'step_started' ||
|
||||
value === 'step_output' ||
|
||||
value === 'needs_confirmation' ||
|
||||
value === 'step_completed' ||
|
||||
value === 'aborted' ||
|
||||
value === 'failed' ||
|
||||
value === 'completed'
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
// Mint a LiveKit token for an engineer joining a pod.
|
||||
app.post('/api/token', async (req, res) => {
|
||||
const { room, identity, name, githubLogin } = req.body ?? {};
|
||||
@@ -97,22 +37,9 @@ app.post('/api/token', async (req, res) => {
|
||||
metadata: JSON.stringify({ githubLogin: githubLogin ?? name }),
|
||||
});
|
||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||
const agents = env.LIVEKIT_AGENT_NAME
|
||||
? [
|
||||
new RoomAgentDispatch({
|
||||
agentName: env.LIVEKIT_AGENT_NAME,
|
||||
metadata: JSON.stringify({ podId: room }),
|
||||
}),
|
||||
]
|
||||
: undefined;
|
||||
// Auto-clean the room: close 60s after it empties, drop a participant 20s
|
||||
// after they disconnect. Applied when LiveKit auto-creates the room.
|
||||
at.roomConfig = new RoomConfiguration({
|
||||
name: room,
|
||||
emptyTimeout: 60,
|
||||
departureTimeout: 20,
|
||||
agents,
|
||||
});
|
||||
at.roomConfig = new RoomConfiguration({ name: room, emptyTimeout: 60, departureTimeout: 20 });
|
||||
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
||||
});
|
||||
|
||||
@@ -209,306 +136,12 @@ app.post('/api/pods/:id/members', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pods/:id/voice-test', async (req, res) => {
|
||||
const podId = req.params.id;
|
||||
const message =
|
||||
typeof req.body?.message === 'string' && req.body.message.trim()
|
||||
? req.body.message.trim()
|
||||
: 'PodMan voice test. Gemini TTS is playing through LiveKit.';
|
||||
try {
|
||||
await speakInRoom(podId, message);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pods/:id/live-conversation/start', async (req, res) => {
|
||||
try {
|
||||
const identity = typeof req.body?.identity === 'string' ? req.body.identity.trim() : '';
|
||||
const displayName =
|
||||
typeof req.body?.displayName === 'string' ? req.body.displayName.trim() : identity;
|
||||
if (!identity) return res.status(400).json({ error: 'identity is required' });
|
||||
const pod = await getPod(req.params.id);
|
||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||
res.json(await startLiveConversation({ podId: req.params.id, identity, displayName }));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pods/:id/live-conversation/:sessionId/stop', async (req, res) => {
|
||||
try {
|
||||
const session = await stopLiveConversation(req.params.id, req.params.sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'session not found' });
|
||||
res.json({ ok: true, session });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/live-conversation/status', (req, res) => {
|
||||
const identity = typeof req.query.identity === 'string' ? req.query.identity.trim() : '';
|
||||
if (!identity) return res.status(400).json({ error: 'identity is required' });
|
||||
res.json({ active: activeLiveConversation(req.params.id, identity) });
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/live-conversation/:sessionId/hermes-job', async (req, res) => {
|
||||
try {
|
||||
const job = await getLatestHermesJobForSession(req.params.sessionId);
|
||||
if (!job || job.podId !== req.params.id) return res.json({ job: null, events: [] });
|
||||
res.json({ job, events: await listHermesJobEvents(job.id, 12) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pods/:id/live-conversation/:sessionId/hermes-job/abort', async (req, res) => {
|
||||
try {
|
||||
const job = await getActiveHermesJobForSession(req.params.sessionId);
|
||||
if (!job || job.podId !== req.params.id)
|
||||
return res.status(404).json({ error: 'active job not found' });
|
||||
res.json({ job: await abortHermesJob(job.id) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
function requireInternalAgent(req: express.Request, res: express.Response): boolean {
|
||||
const expected = env.INTERNAL_AGENT_TOKEN;
|
||||
if (!expected) {
|
||||
res.status(503).json({ error: 'INTERNAL_AGENT_TOKEN is not configured' });
|
||||
return false;
|
||||
}
|
||||
const header = req.header('authorization') ?? '';
|
||||
const actual = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
|
||||
if (actual !== expected) {
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
app.get('/api/internal/pods/:id/live-context', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
try {
|
||||
const identity = typeof req.query.identity === 'string' ? req.query.identity.trim() : '';
|
||||
if (!identity) return res.status(400).json({ error: 'identity is required' });
|
||||
res.json(await getLiveConversationContext(req.params.id, identity));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/internal/pods/:id/live-conversation/:sessionId/note', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
try {
|
||||
const note = typeof req.body?.note === 'string' ? req.body.note : '';
|
||||
const identity = typeof req.body?.identity === 'string' ? req.body.identity : undefined;
|
||||
const kind = typeof req.body?.kind === 'string' ? req.body.kind : undefined;
|
||||
res.status(201).json(
|
||||
await recordLiveConversationNote({
|
||||
podId: req.params.id,
|
||||
sessionId: req.params.sessionId,
|
||||
identity,
|
||||
kind,
|
||||
note,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/internal/hermes/jobs', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
try {
|
||||
res.status(202).json(await createHermesJob(req.body ?? {}));
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/internal/hermes/jobs/:jobId', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
const job = await getHermesJob(req.params.jobId);
|
||||
if (!job) return res.status(404).json({ error: 'job not found' });
|
||||
res.json(job);
|
||||
});
|
||||
|
||||
app.post('/api/internal/hermes/jobs/:jobId/abort', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
const job = await abortHermesJob(req.params.jobId);
|
||||
if (!job) return res.status(404).json({ error: 'job not found' });
|
||||
res.json(job);
|
||||
});
|
||||
|
||||
app.get('/api/internal/hermes/jobs/:jobId/events', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
const job = await getHermesJob(req.params.jobId);
|
||||
if (!job) return res.status(404).json({ error: 'job not found' });
|
||||
res.json(await listHermesJobEvents(req.params.jobId, 100));
|
||||
});
|
||||
|
||||
app.post('/api/internal/hermes/jobs/:jobId/events', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
try {
|
||||
const { type, message, data } = req.body ?? {};
|
||||
const eventType = hermesJobEventType(type);
|
||||
if (!eventType || typeof message !== 'string') {
|
||||
return res.status(400).json({ error: 'type and message are required' });
|
||||
}
|
||||
res.status(201).json(await appendHermesJobEvent(req.params.jobId, eventType, message, data));
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/internal/hermes/jobs/:jobId/events/stream', async (req, res) => {
|
||||
if (!requireInternalAgent(req, res)) return;
|
||||
const job = await getHermesJob(req.params.jobId);
|
||||
if (!job) return res.status(404).json({ error: 'job not found' });
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders?.();
|
||||
|
||||
let closed = false;
|
||||
let lastIds = new Set<string>();
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const events = await listHermesJobEvents(req.params.jobId, 100);
|
||||
const fresh = events.filter((event) => !lastIds.has(event.id));
|
||||
lastIds = new Set(events.map((event) => event.id));
|
||||
for (const event of fresh) {
|
||||
res.write(`event: job-event\n`);
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
const current = await getHermesJob(req.params.jobId);
|
||||
if (current && ['completed', 'failed', 'aborted'].includes(current.status)) {
|
||||
res.write(`event: done\n`);
|
||||
res.write(`data: ${JSON.stringify(current)}\n\n`);
|
||||
closed = true;
|
||||
res.end();
|
||||
} else {
|
||||
res.write(`: keepalive ${Date.now()}\n\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
res.write(`event: error\n`);
|
||||
res.write(`data: ${JSON.stringify({ error: (e as Error).message })}\n\n`);
|
||||
}
|
||||
};
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 1500);
|
||||
req.on('close', () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
// Per-pod background music (Lyria), generated once and cached. Streams MP3 the
|
||||
// frontend loops as a pod-wide LiveKit track (replaces the synthesized beat).
|
||||
app.get('/api/pods/:id/music', async (req, res) => {
|
||||
try {
|
||||
const pod = await getPod(req.params.id);
|
||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||
const mp3 = await getPodMusic(pod.id, pod.name);
|
||||
res.set('Content-Type', 'audio/mpeg');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(mp3);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/pods/:id/hermes/notify', async (req, res) => {
|
||||
const podId = req.params.id;
|
||||
const pod = await getPod(podId);
|
||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||
|
||||
const body = req.body ?? {};
|
||||
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
||||
if (!message) return res.status(400).json({ error: 'message is required' });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const engineers = stringArray(body.engineers);
|
||||
const recipients = engineers.length ? engineers : pod.members.slice(0, 2);
|
||||
const file =
|
||||
typeof body.file === 'string' && body.file.trim() ? body.file.trim() : 'Hermes signal';
|
||||
const urgent = body.urgency === 'urgent' || body.severity === 'critical';
|
||||
const collision: Collision = {
|
||||
id:
|
||||
typeof body.collisionId === 'string' && body.collisionId
|
||||
? body.collisionId
|
||||
: `col_${Date.now()}`,
|
||||
podId,
|
||||
file,
|
||||
symbol: typeof body.symbol === 'string' && body.symbol ? body.symbol : undefined,
|
||||
engineers: recipients,
|
||||
severity: urgent ? 'critical' : 'warn',
|
||||
githubState: { unpushed: body.unpushed !== false },
|
||||
detectedAt: now,
|
||||
};
|
||||
const intervention: Intervention = {
|
||||
id:
|
||||
typeof body.interventionId === 'string' && body.interventionId
|
||||
? body.interventionId
|
||||
: `int_${Date.now()}`,
|
||||
collisionId: collision.id,
|
||||
podId,
|
||||
kind: urgent ? 'voice' : 'card',
|
||||
message,
|
||||
suggestedAction: {
|
||||
kind: suggestedAction(body.suggestedAction),
|
||||
params: {
|
||||
file,
|
||||
engineers: recipients,
|
||||
source: 'local-hermes',
|
||||
},
|
||||
},
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
};
|
||||
const voiceLine =
|
||||
urgent && body.speak !== false
|
||||
? typeof body.voiceLine === 'string' && body.voiceLine.trim()
|
||||
? body.voiceLine.trim()
|
||||
: message
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (body.force !== true && (await hasRecentInterventionForCollision(collision))) {
|
||||
return res.status(202).json({ ok: true, collision, intervention, livekit: 'suppressed' });
|
||||
}
|
||||
await recordCollision(collision);
|
||||
await recordIntervention(intervention);
|
||||
if (body.dryRun === true) {
|
||||
return res.status(202).json({ ok: true, collision, intervention, livekit: 'dry-run' });
|
||||
}
|
||||
await notifyHermesInterventionInRoom(podId, collision, intervention, voiceLine);
|
||||
res.status(202).json({ ok: true, collision, intervention, livekit: 'notified' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/pods/:id/members/:name', async (req, res) => {
|
||||
const pod = await removeMember(req.params.id, req.params.name);
|
||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||
res.json(pod);
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/members/:name/history', async (req, res) => {
|
||||
try {
|
||||
const hours = Number(req.query.hours ?? 24) || 24;
|
||||
const limit = Number(req.query.limit ?? 80) || 80;
|
||||
res.json(await getMemberWorkHistory(req.params.id, req.params.name, { hours, limit }));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Continual-learning graph (team_model view) ---
|
||||
app.get('/api/pods/:id/graph', async (req, res) => {
|
||||
try {
|
||||
@@ -526,58 +159,7 @@ app.get('/api/pods/:id/graph/reach/:node', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/activity', async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit ?? 80) || 80, 200);
|
||||
res.json(await listPodActivity(req.params.id, limit));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pods/:id/activity/stream', async (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders?.();
|
||||
|
||||
let closed = false;
|
||||
let lastPayload = '';
|
||||
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const events = await listPodActivity(req.params.id, 80);
|
||||
const payload = JSON.stringify(events);
|
||||
if (payload !== lastPayload) {
|
||||
lastPayload = payload;
|
||||
res.write(`event: snapshot\n`);
|
||||
res.write(`data: ${payload}\n\n`);
|
||||
} else {
|
||||
res.write(`: keepalive ${Date.now()}\n\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
res.write(`event: error\n`);
|
||||
res.write(`data: ${JSON.stringify({ error: (e as Error).message })}\n\n`);
|
||||
}
|
||||
};
|
||||
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 1500);
|
||||
|
||||
req.on('close', () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
const http = createServer(app);
|
||||
const sockets = new Set<Socket>();
|
||||
|
||||
http.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
// ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it.
|
||||
const wss = new WebSocketServer({ server: http, path: '/api/events' });
|
||||
@@ -595,11 +177,7 @@ http.listen(env.PORT, '0.0.0.0', () => {
|
||||
console.log(`[server] :${env.PORT}`);
|
||||
initMemory()
|
||||
.then(() => seedDefaultPods())
|
||||
.catch((e) => {
|
||||
// MongoDB is mandatory — do not run a half-dead API against a broken DB.
|
||||
console.error(`[memory] init FAILED, exiting: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
.catch((e) => console.warn(`[memory] init failed: ${(e as Error).message}`));
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
@@ -609,11 +187,7 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
|
||||
console.log(`[server] ${signal} received; shutting down`);
|
||||
for (const client of clients) client.close();
|
||||
wss.close();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => http.close(() => resolve())),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
|
||||
]);
|
||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||
await closeMemory().catch((e) => console.warn(`[memory] close failed: ${(e as Error).message}`));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
+27
-206
@@ -1,48 +1,19 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import {
|
||||
AudioFrame,
|
||||
AudioSource,
|
||||
LocalAudioTrack,
|
||||
Room,
|
||||
TrackPublishOptions,
|
||||
TrackSource,
|
||||
type LocalParticipant,
|
||||
type Room,
|
||||
} from '@livekit/rtc-node';
|
||||
import { GoogleGenAI, Modality, type LiveServerMessage, type Session } from '@google/genai';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { DATA_TOPIC, type DataMessage } from '@podman/shared';
|
||||
import { env } from '../env.js';
|
||||
|
||||
const SAMPLE_RATE = 24_000;
|
||||
const CHANNELS = 1;
|
||||
const FRAME_SAMPLES = SAMPLE_RATE / 10;
|
||||
const SUBSCRIBER_READY_MS = 1_500;
|
||||
const AUDIO_PREROLL_MS = 800;
|
||||
const AUDIO_TAIL_MS = 1_500;
|
||||
const AUDIO_HOLD_MS = 5_000;
|
||||
const VOICE_QUEUE_MS = 60_000;
|
||||
const VOICE_TRACK_PREFIX = 'podman-hermes-voice';
|
||||
const encoder = new TextEncoder();
|
||||
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
||||
let voiceQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
export interface SpeakOptions {
|
||||
priority?: 'normal' | 'critical';
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function ttsPrompt(message: string): string {
|
||||
return [
|
||||
'Speak this PodMan coordination alert as a calm, natural engineering teammate.',
|
||||
'Use warm human pacing, clear pronunciation, and a brief pause after the first sentence.',
|
||||
'Do not add extra words, labels, markdown, or sound effects.',
|
||||
'',
|
||||
message,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function publishVoiceCue(room: Room, message: string): Promise<void> {
|
||||
const cue: DataMessage = { type: 'VOICE_CUE', text: message };
|
||||
@@ -52,24 +23,12 @@ async function publishVoiceCue(room: Room, message: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function unpublishVoiceTracks(localParticipant: LocalParticipant): Promise<void> {
|
||||
const publications = Array.from(localParticipant.trackPublications.values()).filter(
|
||||
(publication) => publication.name?.startsWith(VOICE_TRACK_PREFIX) && publication.sid,
|
||||
);
|
||||
for (const publication of publications) {
|
||||
await localParticipant.unpublishTrack(publication.sid!, true).catch((err) => {
|
||||
console.warn(`[voice] stale track cleanup failed: ${(err as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function audioFrameFromBase64(data: string, mimeType?: string): AudioFrame | null {
|
||||
if (mimeType && !mimeType.includes('audio')) return null;
|
||||
const buf = Buffer.from(data, 'base64');
|
||||
if (buf.byteLength < 2) return null;
|
||||
const bytes = buf.byteLength % 2 === 0 ? buf : buf.subarray(0, buf.byteLength - 1);
|
||||
const samples = new Int16Array(bytes.byteLength / 2);
|
||||
for (let i = 0; i < samples.length; i += 1) samples[i] = bytes.readInt16LE(i * 2);
|
||||
const samples = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
|
||||
return new AudioFrame(samples, SAMPLE_RATE, CHANNELS, samples.length / CHANNELS);
|
||||
}
|
||||
|
||||
@@ -85,89 +44,41 @@ function audioFrames(message: LiveServerMessage): AudioFrame[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] {
|
||||
const frame = audioFrameFromBase64(data, mimeType);
|
||||
if (!frame) return [];
|
||||
/**
|
||||
* Speak a message into the LiveKit room using Gemini Live audio. A data-channel
|
||||
* VOICE_CUE is sent first so clients still get the cue if audio generation or
|
||||
* publishing fails.
|
||||
*/
|
||||
export async function speak(room: Room, message: string): Promise<void> {
|
||||
await publishVoiceCue(room, message);
|
||||
if (!room.localParticipant) return;
|
||||
|
||||
const samples = frame.data;
|
||||
const frames: AudioFrame[] = [];
|
||||
for (let offset = 0; offset < samples.length; offset += FRAME_SAMPLES) {
|
||||
const chunk = samples.slice(offset, Math.min(offset + FRAME_SAMPLES, samples.length));
|
||||
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
const source = new AudioSource(SAMPLE_RATE, CHANNELS);
|
||||
const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source);
|
||||
const options = new TrackPublishOptions();
|
||||
options.source = TrackSource.SOURCE_MICROPHONE;
|
||||
|
||||
async function generateTtsFrames(message: string): Promise<AudioFrame[]> {
|
||||
const res = await ai.models.generateContent({
|
||||
model: env.GEMINI_LIVE_MODEL,
|
||||
contents: [{ parts: [{ text: ttsPrompt(message) }] }],
|
||||
config: {
|
||||
responseModalities: [Modality.AUDIO],
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: env.GEMINI_TTS_VOICE } } },
|
||||
temperature: 0.8,
|
||||
},
|
||||
});
|
||||
const parts = res.candidates?.[0]?.content?.parts ?? [];
|
||||
return parts.flatMap((part) =>
|
||||
framesFromPcmBase64(part.inlineData?.data ?? '', part.inlineData?.mimeType),
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackVoiceLine(message: string): string {
|
||||
const clean = message.replace(/^heads up[.!]?\s*/i, '').trim();
|
||||
if (clean && clean !== message) return clean;
|
||||
return 'PodMan noticed a critical conflict. Please sync with the team before pushing.';
|
||||
}
|
||||
|
||||
async function speakWithTts(source: AudioSource, message: string): Promise<number> {
|
||||
let frames: AudioFrame[];
|
||||
try {
|
||||
frames = await generateTtsFrames(message);
|
||||
} catch (err) {
|
||||
const fallback = fallbackVoiceLine(message);
|
||||
console.warn(`[voice] Gemini TTS retrying with fallback line: ${(err as Error).message}`);
|
||||
frames = await generateTtsFrames(fallback);
|
||||
}
|
||||
if (frames.length === 0) throw new Error('Gemini TTS returned no audio frames');
|
||||
const durationMs = frames.reduce(
|
||||
(sum, frame) => sum + (frame.samplesPerChannel / frame.sampleRate) * 1000,
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
`[voice] publishing Gemini TTS audio frames=${frames.length} durationMs=${Math.round(durationMs)}`,
|
||||
);
|
||||
for (const frame of frames) {
|
||||
await source.captureFrame(frame);
|
||||
}
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
async function speakWithLive(source: AudioSource, message: string): Promise<number> {
|
||||
let durationMs = 0;
|
||||
const publication = await room.localParticipant.publishTrack(track, options);
|
||||
let done: () => void = () => {};
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
done = resolve;
|
||||
});
|
||||
const session: Session = await ai.live.connect({
|
||||
let session: Session | null = null;
|
||||
|
||||
session = await ai.live.connect({
|
||||
model: env.GEMINI_LIVE_MODEL,
|
||||
config: {
|
||||
responseModalities: [Modality.AUDIO],
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: env.GEMINI_TTS_VOICE } } },
|
||||
temperature: 0.8,
|
||||
},
|
||||
config: { responseModalities: [Modality.AUDIO] },
|
||||
callbacks: {
|
||||
onmessage: (event) => {
|
||||
void (async () => {
|
||||
for (const frame of audioFrames(event)) {
|
||||
durationMs += (frame.samplesPerChannel / frame.sampleRate) * 1000;
|
||||
await source.captureFrame(frame);
|
||||
}
|
||||
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete) done();
|
||||
for (const frame of audioFrames(event)) await source.captureFrame(frame);
|
||||
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete)
|
||||
done();
|
||||
})();
|
||||
},
|
||||
onerror: (event) => {
|
||||
console.warn(`[voice] Gemini voice error: ${event.message}`);
|
||||
console.warn(`[voice] Gemini Live error: ${event.message}`);
|
||||
done();
|
||||
},
|
||||
onclose: done,
|
||||
@@ -175,106 +86,16 @@ async function speakWithLive(source: AudioSource, message: string): Promise<numb
|
||||
});
|
||||
|
||||
session.sendClientContent({
|
||||
turns: [{ role: 'user', parts: [{ text: ttsPrompt(message) }] }],
|
||||
turns: [{ role: 'user', parts: [{ text: message }] }],
|
||||
turnComplete: true,
|
||||
});
|
||||
|
||||
await Promise.race([donePromise, new Promise((resolve) => setTimeout(resolve, 15_000))]);
|
||||
session.close();
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
async function waitForVoicePlayout(source: AudioSource): Promise<void> {
|
||||
if (source.queuedDuration <= 0) return;
|
||||
const queuedMs = Math.round(source.queuedDuration);
|
||||
console.log(`[voice] waiting for queued audio playout queuedMs=${queuedMs}`);
|
||||
await Promise.race([
|
||||
source.waitForPlayout(),
|
||||
new Promise((resolve) => setTimeout(resolve, VOICE_QUEUE_MS + 2_000)),
|
||||
]);
|
||||
console.log('[voice] queued audio playout complete');
|
||||
}
|
||||
|
||||
async function captureSilence(source: AudioSource, durationMs: number): Promise<void> {
|
||||
const totalSamples = Math.max(1, Math.round((SAMPLE_RATE * durationMs) / 1000));
|
||||
for (let offset = 0; offset < totalSamples; offset += FRAME_SAMPLES) {
|
||||
const samples = Math.min(FRAME_SAMPLES, totalSamples - offset);
|
||||
await source.captureFrame(new AudioFrame(new Int16Array(samples), SAMPLE_RATE, CHANNELS, samples));
|
||||
}
|
||||
}
|
||||
|
||||
async function speakAudio(room: Room, message: string): Promise<void> {
|
||||
const localParticipant = room.localParticipant;
|
||||
if (!localParticipant) return;
|
||||
await unpublishVoiceTracks(localParticipant);
|
||||
const source = new AudioSource(SAMPLE_RATE, CHANNELS, VOICE_QUEUE_MS);
|
||||
const track = LocalAudioTrack.createAudioTrack(`${VOICE_TRACK_PREFIX}-${Date.now()}`, source);
|
||||
const options = new TrackPublishOptions();
|
||||
options.source = TrackSource.SOURCE_MICROPHONE;
|
||||
let publicationSid: string | undefined;
|
||||
|
||||
try {
|
||||
const publication = await localParticipant.publishTrack(track, options);
|
||||
publicationSid = publication.sid;
|
||||
await delay(SUBSCRIBER_READY_MS);
|
||||
await captureSilence(source, AUDIO_PREROLL_MS);
|
||||
const audioDurationMs = env.GEMINI_LIVE_MODEL.includes('tts')
|
||||
? await speakWithTts(source, message)
|
||||
: await speakWithLive(source, message);
|
||||
await captureSilence(source, AUDIO_TAIL_MS);
|
||||
await waitForVoicePlayout(source);
|
||||
const manualHoldMs = Math.ceil(audioDurationMs + AUDIO_TAIL_MS + AUDIO_HOLD_MS);
|
||||
console.log(`[voice] holding track for subscriber playout holdMs=${manualHoldMs}`);
|
||||
await delay(manualHoldMs);
|
||||
if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true);
|
||||
await source.close();
|
||||
} catch (err) {
|
||||
console.warn(`[voice] Gemini voice publish failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
if (publicationSid) {
|
||||
await localParticipant.unpublishTrack(publicationSid, true).catch((err) => {
|
||||
console.warn(`[voice] track unpublish failed: ${(err as Error).message}`);
|
||||
});
|
||||
}
|
||||
console.warn(`[voice] Gemini Live publish failed: ${(err as Error).message}`);
|
||||
await source.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak a message into the LiveKit room using Gemini audio. A data-channel
|
||||
* VOICE_CUE is sent first so clients still get the cue if audio generation or
|
||||
* publishing fails.
|
||||
*/
|
||||
export async function speak(room: Room, message: string, options: SpeakOptions = {}): Promise<void> {
|
||||
await publishVoiceCue(room, message);
|
||||
if (options.priority === 'critical') {
|
||||
await speakAudio(room, message);
|
||||
return;
|
||||
}
|
||||
voiceQueue = voiceQueue.catch(() => {}).then(() => speakAudio(room, message));
|
||||
await voiceQueue;
|
||||
}
|
||||
|
||||
export async function speakInRoom(
|
||||
roomName: string,
|
||||
message: string,
|
||||
options: SpeakOptions = {},
|
||||
): Promise<void> {
|
||||
const room = new Room();
|
||||
try {
|
||||
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||
identity: `podman-voice-${Date.now()}`,
|
||||
name: 'PodMan voice',
|
||||
ttl: '5m',
|
||||
});
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: roomName,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
await room.connect(env.LIVEKIT_URL, await at.toJwt());
|
||||
await speak(room, message, options);
|
||||
} finally {
|
||||
await room.disconnect().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { env } from '../env.js';
|
||||
|
||||
// Lyria 3 is reached via the Gemini "interactions" endpoint (not :predict, which
|
||||
// is the Vertex path). The clip model returns a ~30s base64 MP3.
|
||||
const MUSIC_MODEL = process.env.GEMINI_MUSIC_MODEL ?? 'lyria-3-clip-preview';
|
||||
const INTERACTIONS_URL = 'https://generativelanguage.googleapis.com/v1beta/interactions';
|
||||
|
||||
interface PodMusicDoc {
|
||||
podId: string;
|
||||
name: string; // pod name the vocal was generated for
|
||||
model: string;
|
||||
mp3Base64: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface InteractionContent {
|
||||
type?: string;
|
||||
data?: string;
|
||||
text?: string;
|
||||
}
|
||||
interface InteractionResponse {
|
||||
steps?: Array<{ content?: InteractionContent[] }>;
|
||||
output_audio?: { data?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Background "hold music" prompt: opens with the pod name sung once, then a calm
|
||||
* instrumental bed that loops. Keep it unobtrusive — this is fill, not a song.
|
||||
*/
|
||||
function musicPrompt(podName: string): string {
|
||||
return [
|
||||
'Calm soothing instrumental background hold music for a tech app, like gentle on-hold lobby music.',
|
||||
`It opens in the first three seconds with a soft gentle voice clearly saying the words "${podName}" one time,`,
|
||||
'and after that opening it is purely instrumental with warm electric piano, gentle synth pads and a soft relaxed beat.',
|
||||
'Unobtrusive, pleasant and steady with no climax, designed to loop seamlessly as quiet background fill.',
|
||||
'No other lyrics or vocals after the opening.',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function extractAudioBase64(data: InteractionResponse): string | null {
|
||||
for (const step of data.steps ?? []) {
|
||||
for (const c of step.content ?? []) {
|
||||
if (c.type === 'audio' && c.data) return c.data;
|
||||
}
|
||||
}
|
||||
return data.output_audio?.data ?? null;
|
||||
}
|
||||
|
||||
async function generate(podName: string): Promise<Buffer> {
|
||||
const res = await fetch(INTERACTIONS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': env.GEMINI_API_KEY },
|
||||
body: JSON.stringify({ model: MUSIC_MODEL, input: musicPrompt(podName) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Lyria ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
const data = (await res.json()) as InteractionResponse;
|
||||
const b64 = extractAudioBase64(data);
|
||||
if (!b64) throw new Error('Lyria returned no audio');
|
||||
return Buffer.from(b64, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* The pod's background-music MP3, generated by Lyria on first request and cached
|
||||
* in the `pod_music` collection. Regenerated if the pod name changes so the sung
|
||||
* name stays correct. Lyria generation is slow (~20s); the cache makes every
|
||||
* call after the first instant.
|
||||
*/
|
||||
export async function getPodMusic(podId: string, podName: string): Promise<Buffer> {
|
||||
const db = await getDb();
|
||||
const col = db.collection<PodMusicDoc>('pod_music');
|
||||
const cached = await col.findOne({ podId });
|
||||
if (cached && cached.name === podName && cached.model === MUSIC_MODEL && cached.mp3Base64) {
|
||||
return Buffer.from(cached.mp3Base64, 'base64');
|
||||
}
|
||||
const mp3 = await generate(podName);
|
||||
await col.updateOne(
|
||||
{ podId },
|
||||
{
|
||||
$set: {
|
||||
podId,
|
||||
name: podName,
|
||||
model: MUSIC_MODEL,
|
||||
mp3Base64: mp3.toString('base64'),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
return mp3;
|
||||
}
|
||||
+724
@@ -0,0 +1,724 @@
|
||||
# PodMan - Canonical Master Plan
|
||||
|
||||
> Source of truth for PodMan product intent, current implementation truth, demo
|
||||
> strategy, public interfaces, risks, sponsor story, and next build order.
|
||||
>
|
||||
> If this file conflicts with `README.md`, `docs/idea.md`, `docs/livekit.md`,
|
||||
> `docs/gemini.md`, `docs/mongodb.md`, `docs/digitalocean.md`,
|
||||
> `docs/demo-setup.md`, or `docs/superpowers/specs/*`, follow this file and
|
||||
> treat the older docs as reference material to reconcile later.
|
||||
|
||||
---
|
||||
|
||||
## 1. Product thesis
|
||||
|
||||
**PodMan sees active work before it becomes visible to GitHub, remembers how the
|
||||
team works, researches better paths in the background, and coordinates teammates
|
||||
without being intrusive.**
|
||||
|
||||
GitHub knows pushed branches, PRs, issues, and comments. It cannot see the most
|
||||
expensive coordination failures while they are still forming on laptops: two
|
||||
engineers editing the same unpushed file, someone blocked on an endpoint a
|
||||
teammate is nearly done with, duplicated work starting silently, or a team
|
||||
walking into a dead-end implementation path.
|
||||
|
||||
PodMan puts engineers in a consented LiveKit pod, watches live IDE/screen
|
||||
context, fuses that with scheduled local git reports, GitHub state, MongoDB team
|
||||
memory, and background research, then routes only useful interventions through
|
||||
Hermes. The default is a small visual card. Hermes can message teammates when
|
||||
the team needs coordination. Voice is reserved for urgent escalation.
|
||||
|
||||
**One-line product definition:** PodMan is a non-intrusive, continual-learning
|
||||
team assistant for active coding.
|
||||
|
||||
**One-line demo promise:** PodMan notices live work, finds a better path,
|
||||
remembers a previous intervention, and escalates only when the team actually
|
||||
needs it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Product contract
|
||||
|
||||
### Inputs
|
||||
|
||||
- **Live IDE/screen context:** engineers join a LiveKit room and publish screen
|
||||
share so the backend agent can sample real work in progress.
|
||||
- **Scheduled local git state:** each laptop should report dirty files,
|
||||
unpushed commits, branch, and latest commit about every minute. This is the
|
||||
deterministic fallback for facts vision cannot reliably infer.
|
||||
- **MongoDB team memory:** ownership, current tasks, blockers, repeated
|
||||
mistakes, preferred tools, decisions, intervention history, and outcomes.
|
||||
- **GitHub repo state:** public repo metadata, branches, PR artifacts, and
|
||||
issue/PR state when it exists.
|
||||
- **Background research signals:** tool, repo, skill, package, docs, and
|
||||
dead-end evidence discovered while teammates are working.
|
||||
|
||||
### Outputs
|
||||
|
||||
- **Default:** small visual intervention card in the PodMan frontend.
|
||||
- **Coordination:** Hermes message to the right teammate(s) or project channel.
|
||||
- **Urgent escalation:** voice only when timing or risk justifies interruption.
|
||||
- **Action path:** optional sync PR, research recommendation, summary, fix
|
||||
suggestion, or teammate notification.
|
||||
|
||||
### Memory rules
|
||||
|
||||
- Remember team-level work patterns, not raw screen recordings.
|
||||
- Store structured observations, collisions, interventions, outcomes, and pod
|
||||
state.
|
||||
- Add exact-signature recall before vector recall: normalized file, symbol,
|
||||
engineer pair, event type, and accepted/dismissed outcome.
|
||||
- Privacy must stay explicit: engineers consent by joining the pod and sharing
|
||||
screen context; do not store raw screenshots, full recordings, or secrets.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Not a dashboard as the product center.
|
||||
- Not a screenshot analyzer with no action loop.
|
||||
- Not sponsor-padding; every sponsor technology must be load-bearing or clearly
|
||||
marked as optional polish.
|
||||
- Not a task manager, Slack clone, full auth system, or general surveillance
|
||||
tool.
|
||||
|
||||
---
|
||||
|
||||
## 3. Track fit: Continual Learning
|
||||
|
||||
PodMan fits **Continual Learning** because the system gets more useful from team
|
||||
history and intervention outcomes.
|
||||
|
||||
- **Team model:** observations build ownership, hotspot, blocker, tool, and
|
||||
decision memory per pod.
|
||||
- **Outcome loop:** accepted, dismissed, and confirmed interventions become
|
||||
supervision for future thresholds and routing.
|
||||
- **Session compounding:** a later similar situation should reference prior
|
||||
memory, choose a better action sooner, or lower the noise level.
|
||||
- **Visible demo proof:** the first intervention writes memory; the second
|
||||
similar situation retrieves it and says, in effect, "I have seen this pattern
|
||||
before."
|
||||
|
||||
The learning proof should not depend on Atlas Vector Search being finished.
|
||||
Exact MongoDB recall is enough for the MVP learning beat.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current implementation truth
|
||||
|
||||
Verified on `2026-06-27` from local repo inspection, authenticated `gh`, and
|
||||
the current remote plan commit.
|
||||
|
||||
### GitHub state
|
||||
|
||||
- Repo: <https://github.com/karti-ai/podman>
|
||||
- Visibility: public
|
||||
- Default branch: `main`
|
||||
- Current local branch: `main`
|
||||
- Local branch state during this rewrite: behind `origin/main` by two commits
|
||||
- Issues: none
|
||||
- PRs: none
|
||||
- `origin/main` latest relevant commits:
|
||||
- `8271188 feat(frontend): live room view, beat connectivity test, session resume`
|
||||
- `65a0791 docs(plan): audit server state + mark tasks 1-5 done, reflect actual arch`
|
||||
|
||||
### Working / started
|
||||
|
||||
- Monorepo packages exist: `frontend`, `backend`, `shared`, `database`, and
|
||||
`infra`.
|
||||
- Backend is split into two processes:
|
||||
- API service in `backend/src/server.ts`.
|
||||
- LiveKit agent worker in `backend/src/agent.ts`.
|
||||
- Backend API exposes:
|
||||
- `GET /health`
|
||||
- `POST /api/token`
|
||||
- `POST /api/sync-pr`
|
||||
- `POST /api/outcome`
|
||||
- `GET /api/memory/stats`
|
||||
- `GET /api/pods`
|
||||
- `POST /api/pods`
|
||||
- `GET /api/pods/:id`
|
||||
- `PATCH /api/pods/:id`
|
||||
- `DELETE /api/pods/:id`
|
||||
- `POST /api/pods/:id/members`
|
||||
- `DELETE /api/pods/:id/members/:name`
|
||||
- Remote API health check returned `{"ok":true}` at
|
||||
`http://165.22.129.249:8787/health` during verification.
|
||||
- The LiveKit agent uses `@livekit/rtc-node` to join as `podman-agent`, subscribe
|
||||
to `TrackSource.SOURCE_SCREENSHARE`, sample frames near 1 fps, convert frames
|
||||
to RGBA, and encode downscaled JPEGs with `sharp`.
|
||||
- Gemini vision is wired in `backend/src/vision/gemini.ts` with JSON structured
|
||||
output, response schema, low media resolution, and model ID from env.
|
||||
- Collision detection exists and groups engineer contexts by normalized file,
|
||||
then fires when 2+ engineers touch the same file and at least one unpushed or
|
||||
dirty signal exists.
|
||||
- Shared LiveKit data topic and wire messages exist:
|
||||
- topic: `podman.intervention`
|
||||
- messages: `COLLISION`, `VOICE_CUE`, `ACK`, `GIT_REPORT`
|
||||
- MongoDB persistence groundwork exists for observations, collisions,
|
||||
interventions, outcomes, and pods.
|
||||
- Frontend has pod selection, pod join, post-join pod view, LiveKit join helper,
|
||||
and dev-mode fallback.
|
||||
- `origin/main` adds live room participants, active-speaker state, session
|
||||
resume, a "Play beat" audio connectivity test, and a deliberate "Share my
|
||||
screen" button that publishes with `Track.Source.ScreenShare`. Merge that
|
||||
remote commit before doing more frontend work on the local checkout.
|
||||
- DigitalOcean infra scaffolding exists:
|
||||
- `infra/.do/app.yaml` is the split App Platform direction.
|
||||
- `infra/app.yaml` is an older single-service backend spec and should be
|
||||
treated as legacy until reconciled.
|
||||
|
||||
### Server snapshot
|
||||
|
||||
From the remote plan snapshot and health check on `2026-06-27`:
|
||||
|
||||
- Backend API: running on `http://165.22.129.249:8787` and `/health` returned
|
||||
`{"ok":true}`.
|
||||
- Frontend: reported running on `:81`; port `80` was already taken.
|
||||
- Agent worker: reported not running; it still needs LiveKit credentials and
|
||||
`pnpm --filter @podman/backend dev:agent`.
|
||||
- Treat this as operational evidence, not architecture truth. Reverify before
|
||||
demo.
|
||||
|
||||
### Partial / stubbed
|
||||
|
||||
- `backend/src/voice/live.ts` logs only; it does not publish real voice/audio
|
||||
into LiveKit yet.
|
||||
- Hermes is a product/action/messaging layer in the plan, but the current repo
|
||||
does not yet implement a complete Hermes notification bridge.
|
||||
- `backend/src/memory/vectors.ts` is not a real Voyage/Atlas Vector Search
|
||||
implementation yet.
|
||||
- Exact-signature recall is the required MVP fallback before vectors.
|
||||
- `backend/src/memory/policy.ts` is a simple gate; it does not learn thresholds
|
||||
from outcomes yet.
|
||||
- `POST /api/sync-pr` creates a PR artifact path but does not yet build a
|
||||
meaningful sync diff.
|
||||
- Frontend `PodView` has only a placeholder intervention area unless/until live
|
||||
intervention rendering is wired.
|
||||
- Browser screen publishing exists, but the active join path must be proven to
|
||||
tag tracks as screen share so the backend agent can filter them correctly. The
|
||||
`origin/main` screen-share button appears to address this; local code remains
|
||||
behind until that commit is merged.
|
||||
- `GIT_REPORT` exists in shared types and agent handling. `scripts/podman-agent.mjs`
|
||||
is the finished per-laptop git sidecar — polls every 15 s, upserts git fields
|
||||
to `engineer_states` collection. Not yet wired to publish a `GIT_REPORT` data
|
||||
channel message into the LiveKit room (agent fusion step still needed).
|
||||
- Background research recommendations are a product requirement and demo goal,
|
||||
not an implemented research agent yet.
|
||||
- Deployment reliability is partial; API health is reachable, but API/static
|
||||
site/worker together must still be reverified before demo.
|
||||
- Env docs are inconsistent: backend defaults are `gemini-3.5-flash` and
|
||||
`gemini-3.1-flash-live-preview`, while `.env.example` still lists older
|
||||
Gemini model names.
|
||||
|
||||
### Not yet proven
|
||||
|
||||
- Real browser -> LiveKit room -> backend agent screen-frame capture end to end.
|
||||
- Real Gemini inference from a live shared IDE frame using the stage key/model.
|
||||
- Real data-channel intervention card rendering in the active frontend.
|
||||
- Hermes message routing to teammates.
|
||||
- Voice escalation heard by participants through LiveKit.
|
||||
- A meaningful real sync PR flow with correct GitHub scopes and artifact.
|
||||
- Atlas Vector Search / Voyage recall path.
|
||||
- DigitalOcean static site + API service + LiveKit agent worker all running
|
||||
together.
|
||||
- Background research recommendation that is both timely and evidence-backed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture to build toward
|
||||
|
||||
```
|
||||
Engineer browser PWA
|
||||
- joins a pod room
|
||||
- publishes screen share and optional mic
|
||||
- receives intervention cards and voice
|
||||
|
|
||||
v
|
||||
LiveKit room
|
||||
- one room per pod
|
||||
- screen-share tracks are the live work signal
|
||||
- small reliable data packets carry interventions
|
||||
|
|
||||
v
|
||||
PodMan backend agent worker
|
||||
- @livekit/rtc-node room participant
|
||||
- screen-track subscription
|
||||
- frame throttle and JPEG encode
|
||||
- Gemini structured vision
|
||||
- scheduled GIT_REPORT fusion
|
||||
- GitHub state fusion
|
||||
- collision, blocker, duplicate-work, and dead-end detection
|
||||
- MongoDB memory recall and policy
|
||||
|
|
||||
v
|
||||
Hermes action layer
|
||||
- visual card routing
|
||||
- teammate messages
|
||||
- urgent voice escalation
|
||||
- optional research/action/sync PR workflows
|
||||
|
|
||||
v
|
||||
Backend API + MongoDB + GitHub
|
||||
- token minting, pod CRUD, outcomes, memory stats
|
||||
- observations, collisions, interventions, outcomes, pod memory
|
||||
- public repo state and PR artifacts
|
||||
```
|
||||
|
||||
The backend must remain split:
|
||||
|
||||
- **API service:** routable HTTP process with `/api/*` endpoints and health
|
||||
checks.
|
||||
- **Agent worker:** outbound LiveKit participant with no HTTP health-check port
|
||||
requirement.
|
||||
|
||||
This split matters for DigitalOcean App Platform: the LiveKit agent should be a
|
||||
worker, not a web service that App Platform expects to health-check over HTTP.
|
||||
|
||||
---
|
||||
|
||||
## 6. Public interfaces to preserve
|
||||
|
||||
Do not rename or reshape these without updating frontend, backend, docs, and demo
|
||||
scripts together.
|
||||
|
||||
### Backend HTTP
|
||||
|
||||
- `GET /health`
|
||||
- `POST /api/token`
|
||||
- `POST /api/sync-pr`
|
||||
- `POST /api/outcome`
|
||||
- `GET /api/memory/stats`
|
||||
- `GET /api/pods`
|
||||
- `POST /api/pods`
|
||||
- `GET /api/pods/:id`
|
||||
- `PATCH /api/pods/:id`
|
||||
- `DELETE /api/pods/:id`
|
||||
- `POST /api/pods/:id/members`
|
||||
- `DELETE /api/pods/:id/members/:name`
|
||||
|
||||
### LiveKit data channel
|
||||
|
||||
- Topic: `podman.intervention`
|
||||
- Core messages:
|
||||
- `COLLISION`: agent -> PWA; contains `collision` and `intervention`.
|
||||
- `ACK`: PWA -> agent/API; intervention response.
|
||||
- `GIT_REPORT`: local git sidecar -> agent; dirty/unpushed ground truth.
|
||||
- `VOICE_CUE`: text cue/fallback for voice escalation.
|
||||
|
||||
### Required environment
|
||||
|
||||
```bash
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_VISION_MODEL=
|
||||
GEMINI_LIVE_MODEL=
|
||||
|
||||
GITHUB_TOKEN=
|
||||
GITHUB_REPO=karti-ai/podman
|
||||
|
||||
MONGODB_URI=
|
||||
VOYAGE_API_KEY=
|
||||
POD_ROOM=demo-pod
|
||||
PORT=8787
|
||||
|
||||
VITE_BACKEND_URL=http://localhost:8787
|
||||
VITE_LIVEKIT_URL=
|
||||
```
|
||||
|
||||
Keep all non-`VITE_` secrets server-side.
|
||||
|
||||
---
|
||||
|
||||
## 7. Critical implementation callouts
|
||||
|
||||
### LiveKit
|
||||
|
||||
- Screen share is a video track. The backend agent should consume raw screen
|
||||
frames through `@livekit/rtc-node`.
|
||||
- The agent must filter screen share, not webcam:
|
||||
`pub.source === TrackSource.SOURCE_SCREENSHARE`.
|
||||
- Frontend publishing must tag the track as screen share; otherwise the agent can
|
||||
miss it.
|
||||
- Throttle aggressively. Screens can arrive near video frame rate; Gemini should
|
||||
receive sampled frames only.
|
||||
- Keep reliable data packets small. Use them for intervention metadata, not
|
||||
screenshots, large diffs, or research dumps. Treat reliable payloads as
|
||||
roughly 15 KiB max.
|
||||
- A historical closed `livekit/node-sdks` issue reported high memory use when
|
||||
consuming video; run memory checks during agent frame tests and stop if the
|
||||
loop leaks.
|
||||
|
||||
### Gemini
|
||||
|
||||
- Use structured output for vision: JSON mime type plus response schema.
|
||||
- Use low media resolution for ambient screen watching; reserve higher
|
||||
resolution for debugging or targeted inspection.
|
||||
- Never expose `GEMINI_API_KEY` to the browser.
|
||||
- Gemini Live API is still a risk for the first demo path. Use card + Hermes
|
||||
message first; add browser TTS or pre-generated voice fallback before relying
|
||||
on Gemini Live for stage audio.
|
||||
- Keep model IDs in env so preview/availability changes do not require code
|
||||
changes.
|
||||
|
||||
### MongoDB
|
||||
|
||||
- Local MongoDB is fine for dev CRUD and memory counts.
|
||||
- Atlas or Atlas Local is needed for the sponsor-grade Vector Search story.
|
||||
- Build exact-signature recall first:
|
||||
normalized file + symbol + engineer pair + event type + outcome.
|
||||
- Writes from the agent should be best-effort. Mongo hiccups should degrade
|
||||
memory, not kill live detection.
|
||||
- Do not store raw screenshots or recordings.
|
||||
|
||||
### GitHub
|
||||
|
||||
- The repo is public and currently has no issue/PR backlog, so do not make the
|
||||
plan issue-driven yet.
|
||||
- GitHub cannot see local dirty files or unpushed commits. That is still a core
|
||||
product moat.
|
||||
- Sync PRs should use deterministic GitHub REST/Octokit flows, not browser
|
||||
automation.
|
||||
- Verify token scopes and demo repo permissions before stage time.
|
||||
|
||||
### DigitalOcean
|
||||
|
||||
- Use App Platform as:
|
||||
- static site for frontend,
|
||||
- HTTP service for API,
|
||||
- worker for the LiveKit agent.
|
||||
- Do not model the agent worker as a health-checked HTTP service.
|
||||
- Keep a local and recorded fallback even if deployment works; venue network is a
|
||||
stage risk.
|
||||
|
||||
### Hermes
|
||||
|
||||
- Treat Hermes as the action and messaging layer, not as a replacement for the
|
||||
current implemented backend agent until code changes make that real.
|
||||
- Hermes should choose the least intrusive channel:
|
||||
card -> message -> voice.
|
||||
- Hermes can own research summaries, teammate notification, sync PR initiation,
|
||||
and urgent escalation once those workflows exist.
|
||||
|
||||
---
|
||||
|
||||
## 8. Build ladder
|
||||
|
||||
Do not mark a rung done until it is proven in logs, UI, or a visible external
|
||||
artifact.
|
||||
|
||||
### P0 - make the live loop undeniable
|
||||
|
||||
1. **Preserve and reconcile the plan**
|
||||
- Merge local `docs/PLAN.md` with `origin/main:docs/PLAN.md`.
|
||||
- Keep both the broad product thesis and concrete server/current-state facts.
|
||||
- After the docs are safe, merge or rebase the two newer `origin/main` commits
|
||||
before implementing frontend work.
|
||||
|
||||
2. **Browser publish proof**
|
||||
- Start backend API and frontend.
|
||||
- Join a real LiveKit room from the browser.
|
||||
- Confirm the browser publishes a screen-share track with the correct source.
|
||||
|
||||
3. **Agent frame proof**
|
||||
- Start `pnpm --filter @podman/backend dev:agent`.
|
||||
- Confirm room join, screen-track subscription, frame sampling, and JPEG
|
||||
encode logs.
|
||||
- Watch process memory while consuming frames.
|
||||
|
||||
4. **Gemini vision proof**
|
||||
- Send one live sampled IDE frame to Gemini.
|
||||
- Log parsed JSON with `currentFile`, `currentSymbol`, `activity`,
|
||||
`hasUnpushedChanges`, and `confidence`.
|
||||
- Add a confidence/logging gate if noisy frames cause bad reads.
|
||||
|
||||
5. **Scheduled git truth** ✅ partial
|
||||
- `scripts/podman-agent.mjs` polls every 15 s: `git status --short`,
|
||||
`git diff --stat HEAD`, `git log --oneline -1`, `git branch --show-current`.
|
||||
- Upserts `changedFiles`, `diffStat`, `recentCommit`, `branch`, `gitUpdatedAt`
|
||||
to `engineer_states` collection in MongoDB (upsert by `podId::name` key).
|
||||
- **Still needed:** fuse `engineer_states` git fields into the collision
|
||||
detector, and/or publish `GIT_REPORT` data channel messages so the agent
|
||||
worker can incorporate git truth into vision-based decisions.
|
||||
|
||||
6. **Intervention card + Hermes notification**
|
||||
- Publish a real intervention on `podman.intervention`.
|
||||
- Render it as a small card in the frontend.
|
||||
- Route a Hermes message to the affected teammate(s) or project channel once
|
||||
the bridge exists.
|
||||
|
||||
7. **Background research recommendation**
|
||||
- When the team is heading into a poor tool/repo/skill choice or dead end,
|
||||
produce a recommendation card with short evidence.
|
||||
- Minimum evidence: why it matters, what to use instead, and who should act.
|
||||
|
||||
8. **Learning proof**
|
||||
- First intervention writes observation/collision/recommendation/outcome
|
||||
memory.
|
||||
- Second similar situation retrieves exact prior memory and changes the
|
||||
message: "I have seen this pattern before."
|
||||
|
||||
9. **Urgency routing**
|
||||
- Default to card.
|
||||
- Escalate to Hermes message when coordination involves other teammates.
|
||||
- Escalate to voice only when urgent.
|
||||
|
||||
10. **Action artifact**
|
||||
- If demo uses same-file collision, click the card to open a real draft sync
|
||||
PR or visible GitHub artifact.
|
||||
- If demo uses research recommendation, show the accepted recommendation and
|
||||
memory outcome instead.
|
||||
|
||||
11. **Deployment or fallback proof**
|
||||
- Prove API/static/worker deployment together, or explicitly run local with a
|
||||
recorded backup.
|
||||
- Keep backup video on a separate device.
|
||||
|
||||
### P1 - polish the money moment
|
||||
|
||||
- Add visible live inference captions in the PWA.
|
||||
- Add a small memory stats panel backed by `/api/memory/stats`.
|
||||
- Add browser-side TTS or pre-generated voice fallback for urgent interventions.
|
||||
- Add Hermes notification bridge once the target channel is chosen.
|
||||
- Improve research cards with compatibility, install effort, docs quality, repo
|
||||
health, and security/trust signals.
|
||||
|
||||
### P2 - sponsor and scale polish
|
||||
|
||||
- Implement Voyage embedding + Atlas Vector Search recall.
|
||||
- Improve policy learning from outcomes.
|
||||
- Deploy DigitalOcean static site + API service + worker as the submission path.
|
||||
- Add optional GitHub issue/PR backlog integration after issues/PRs actually
|
||||
exist.
|
||||
|
||||
### Cut if behind
|
||||
|
||||
- Webcam grid.
|
||||
- Mic transcription.
|
||||
- Full auth/accounts.
|
||||
- Slack/Linear/Jira integrations unless Hermes requires one immediately.
|
||||
- Complex dashboards.
|
||||
- Server-published audio if browser/pre-generated voice proves escalation.
|
||||
- Vector Search if exact Mongo recall demonstrates the learning beat.
|
||||
|
||||
---
|
||||
|
||||
## 9. Critical 3-minute demo script
|
||||
|
||||
**Rule:** open on one active IDE, not a grid. PodMan is an agent, not a
|
||||
dashboard.
|
||||
|
||||
1. **0:00 - Set the scene**
|
||||
- One engineer is actively coding in the IDE.
|
||||
- The presenter says: "This work is not pushed yet. GitHub cannot see it."
|
||||
|
||||
2. **0:20 - Show the live signal**
|
||||
- Show a compact caption: current file, inferred task, git dirty/unpushed
|
||||
state.
|
||||
- Show that PodMan is watching consented screen context, not stored
|
||||
recordings.
|
||||
|
||||
3. **0:40 - Introduce the better-tool moment**
|
||||
- A teammate starts down a weak path: wrong package, dead repo, bad API,
|
||||
duplicated effort, or risky implementation.
|
||||
- PodMan has been researching in the background.
|
||||
|
||||
4. **1:05 - Money moment**
|
||||
- PodMan shows a small card:
|
||||
"This path is likely a dead end. Use X instead; it matches our stack and is
|
||||
actively maintained."
|
||||
- The card names the affected teammate and the suggested action.
|
||||
|
||||
5. **1:25 - Hermes coordination**
|
||||
- Hermes notifies the right teammate(s), not the whole room.
|
||||
- No voice yet unless the situation is urgent.
|
||||
|
||||
6. **1:50 - Learning beat**
|
||||
- A similar issue appears.
|
||||
- PodMan references memory:
|
||||
"I have seen this pattern before. Last time the team accepted the X
|
||||
recommendation."
|
||||
- Show `/api/memory/stats` or the visible memory indicator.
|
||||
|
||||
7. **2:20 - Urgency escalation**
|
||||
- Raise the severity with a same-file collision, blocking dependency, failing
|
||||
test, or imminent bad push.
|
||||
- Hermes escalates to voice only now.
|
||||
|
||||
8. **2:40 - Close**
|
||||
- Show the public repo, deployed/local URL, and memory stats.
|
||||
- Closing line: "PodMan coordinates work while it is still happening."
|
||||
|
||||
### Reliable fallback demo
|
||||
|
||||
If the research recommendation is not reliable by stage time, use the same-file
|
||||
collision fallback:
|
||||
|
||||
1. Two engineers open the same visible file.
|
||||
2. `GIT_REPORT` or vision marks one as dirty/unpushed.
|
||||
3. Agent publishes `COLLISION` on `podman.intervention`.
|
||||
4. Frontend renders the card.
|
||||
5. The card opens a sync PR artifact.
|
||||
6. A second similar collision retrieves prior memory.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sponsor strategy
|
||||
|
||||
### Gemini
|
||||
|
||||
Gemini must be load-bearing for the vision loop:
|
||||
|
||||
- live IDE/screen frame -> structured work context,
|
||||
- optional message/recommendation generation,
|
||||
- optional Live voice only after card/Hermes routing is stable.
|
||||
|
||||
Do not overclaim voice if it is using browser/pre-generated TTS. Say plainly that
|
||||
it is the reliability fallback.
|
||||
|
||||
### LiveKit
|
||||
|
||||
LiveKit is the real-time spine:
|
||||
|
||||
- engineers join one pod room,
|
||||
- screen-share tracks carry active work context,
|
||||
- PodMan joins as a participant,
|
||||
- data packets carry interventions,
|
||||
- voice can be added as urgent escalation.
|
||||
|
||||
Pitch line: "Unpushed work is invisible to GitHub, so real-time presence is the
|
||||
only way to coordinate before the push."
|
||||
|
||||
### MongoDB + Voyage
|
||||
|
||||
MongoDB is the learning proof:
|
||||
|
||||
- observations, collisions, recommendations, interventions, and outcomes persist,
|
||||
- prior memory changes a later intervention,
|
||||
- exact recall is the MVP,
|
||||
- Voyage + Atlas Vector Search is the stronger sponsor-grade version after exact
|
||||
recall works.
|
||||
|
||||
### DigitalOcean
|
||||
|
||||
DigitalOcean earns its place when:
|
||||
|
||||
- frontend runs as a static site,
|
||||
- API runs as an HTTP service,
|
||||
- LiveKit agent runs as a worker,
|
||||
- public URL is shown in submission or demo.
|
||||
|
||||
Local fallback is acceptable for stage reliability, but the submission should
|
||||
include the deployment URL if possible.
|
||||
|
||||
---
|
||||
|
||||
## 11. Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Looks like a dashboard | Keep the UI quiet. Hero is card/message/action, not a grid. |
|
||||
| Looks like a screenshot analyzer | Always show screen signal + git truth + memory + action. |
|
||||
| Interrupts too much | Default to cards, escalate to Hermes messages, reserve voice for urgency. |
|
||||
| Overclaims implemented features | Mark voice, Hermes bridge, vectors, adaptive policy, research agent, real sync PR, and DO worker deploy incomplete until proven. |
|
||||
| Vision misses unpushed state | Use scheduled `GIT_REPORT` for deterministic dirty/unpushed truth. |
|
||||
| Research recommendation lacks evidence | Show only concise evidence: stack fit, repo/tool health, install effort, docs/trust signal. |
|
||||
| No visible learning | Build exact Mongo recall before vector search. |
|
||||
| LiveKit frame loop leaks memory | Monitor agent memory during video consumption; throttle hard. |
|
||||
| GitHub issue/PR backlog absent | Do not invent issue-driven backlog; repo currently has no issues or PRs. |
|
||||
| Venue network failure | Rehearse on hotspot and keep recorded backup. |
|
||||
| DO worker deploy hangs | Deploy agent as worker, not health-checked service. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Documentation reconciliation tasks
|
||||
|
||||
After this plan is accepted, update the supporting docs so they stop conflicting
|
||||
with this file:
|
||||
|
||||
- `README.md`: replace POST-screenshot-first language with LiveKit screen-track
|
||||
agent architecture and Hermes action-layer wording.
|
||||
- `docs/idea.md`: broaden from blocker/dependency voice demo to card/message/
|
||||
urgent-voice coordination plus research and memory.
|
||||
- `docs/livekit.md`: remove "Hermes does NOT subscribe to engineer screen
|
||||
tracks"; current architecture uses backend agent screen subscription.
|
||||
- `docs/gemini.md`: keep structured vision, but mark Gemini Live as P1 and avoid
|
||||
claiming voice is implemented.
|
||||
- `docs/mongodb.md`: align collection names with current code
|
||||
(`observations`, `collisions`, `interventions`, `outcomes`, `pods`) and add
|
||||
exact-signature recall.
|
||||
- `docs/digitalocean.md`: split API service and agent worker; do not deploy the
|
||||
worker as a health-checked HTTP service; mark `infra/app.yaml` legacy or
|
||||
reconcile it with `infra/.do/app.yaml`.
|
||||
- `docs/demo-setup.md`: update the script to include better-tool research,
|
||||
learning recall, Hermes notification, and urgency-based voice.
|
||||
|
||||
---
|
||||
|
||||
## 13. Acceptance checklist
|
||||
|
||||
Before saying PodMan is demo-ready:
|
||||
|
||||
- [ ] `pnpm format:check` passes or all failures are documented as unrelated.
|
||||
- [ ] `pnpm typecheck` passes.
|
||||
- [ ] Browser joins a real LiveKit room.
|
||||
- [ ] Browser publishes a screen-share track with the correct source.
|
||||
- [ ] Backend agent subscribes to the screen-share track.
|
||||
- [ ] Agent logs at least one parsed Gemini context from a real IDE screen.
|
||||
- [x] Local git report supplies dirty/unpushed truth on a schedule (`scripts/podman-agent.mjs` — 15 s poll → MongoDB `engineer_states`). Agent fusion still needed.
|
||||
- [ ] Frontend renders a real intervention card.
|
||||
- [ ] Hermes notification path works for teammate messages.
|
||||
- [ ] Voice is heard only for urgent escalation or a fallback is declared.
|
||||
- [ ] Outcome ACK writes to MongoDB.
|
||||
- [ ] `/api/memory/stats` shows counts increasing.
|
||||
- [ ] Second similar situation uses prior memory in the message.
|
||||
- [ ] Research recommendation card is evidence-backed, or fallback collision demo
|
||||
is used.
|
||||
- [ ] Sync PR action creates a visible GitHub artifact if used in demo.
|
||||
- [ ] DigitalOcean deployment or local fallback is rehearsed.
|
||||
- [ ] Backup recording is ready on a separate device.
|
||||
|
||||
---
|
||||
|
||||
## 14. Evidence appendix
|
||||
|
||||
### Repo and GitHub state
|
||||
|
||||
- Public repo: <https://github.com/karti-ai/podman>
|
||||
- Verified with authenticated `gh` on `2026-06-27`.
|
||||
- Default branch: `main`.
|
||||
- No GitHub issues or PRs existed at verification time.
|
||||
|
||||
### Hackathon / event
|
||||
|
||||
- AI Engineer World's Fair: <https://www.ai.engineer/worldsfair/2026>
|
||||
- Cerebral Valley hackathon page:
|
||||
<https://cerebralvalley.ai/e/aiewf-hackathon-2026>
|
||||
|
||||
### LiveKit
|
||||
|
||||
- Screen share docs: <https://docs.livekit.io/transport/media/screenshare/>
|
||||
- Data packets docs: <https://docs.livekit.io/transport/data/packets/>
|
||||
- Node SDK reference: <https://docs.livekit.io/reference/client-sdk-node/>
|
||||
- Node SDK releases: <https://github.com/livekit/node-sdks/releases>
|
||||
- Node SDK issue risk: <https://github.com/livekit/node-sdks/issues/444>
|
||||
|
||||
### Gemini
|
||||
|
||||
- Structured output:
|
||||
<https://ai.google.dev/gemini-api/docs/structured-output>
|
||||
- Media resolution: <https://ai.google.dev/gemini-api/docs/media-resolution>
|
||||
- Live API: <https://ai.google.dev/gemini-api/docs/live-api>
|
||||
|
||||
### DigitalOcean
|
||||
|
||||
- App Platform app spec:
|
||||
<https://docs.digitalocean.com/products/app-platform/reference/app-spec/>
|
||||
|
||||
### MongoDB
|
||||
|
||||
- Vector Search index type:
|
||||
<https://www.mongodb.com/docs/vector-search/index/vector-search-type/>
|
||||
- Node driver Atlas Vector Search:
|
||||
<https://www.mongodb.com/docs/drivers/node/current/atlas-vector-search/>
|
||||
@@ -0,0 +1,108 @@
|
||||
# Demo Setup
|
||||
|
||||
Pre-stage checklist for the 3-minute live demo. Do this on all 3 laptops before walking on stage.
|
||||
|
||||
---
|
||||
|
||||
## Before demo day
|
||||
|
||||
- [ ] `demo-pod` room created in LiveKit Cloud dashboard
|
||||
- [ ] Hermes deployed on DO (or confirmed running locally as fallback)
|
||||
- [ ] MongoDB Atlas cluster running, `MONGODB_URI` set in Hermes env
|
||||
- [ ] All `.env` vars populated and verified via `GET /health` returning `{ ok: true }`
|
||||
- [ ] Record a backup video of the full demo working end-to-end
|
||||
- [ ] Rehearse the demo script 3× with real audio
|
||||
|
||||
---
|
||||
|
||||
## Laptop setup (all 3 machines)
|
||||
|
||||
### Editor settings
|
||||
|
||||
- Font size: **18pt or larger** — Gemini Vision must read file names and code
|
||||
- Single editor window — no split panes, no overlapping terminals
|
||||
- File tab visible with full file name shown (not truncated)
|
||||
- Light or dark theme is fine — avoid low-contrast themes
|
||||
|
||||
### Browser
|
||||
|
||||
- Chrome (best `getDisplayMedia` support)
|
||||
- PWA tab open and joined to `demo-pod`
|
||||
- Earbuds / headphones plugged in and tested
|
||||
- Volume: medium — PodMan voice should be clearly audible but not startle
|
||||
|
||||
### Screen layout
|
||||
|
||||
- Editor takes 2/3 of screen
|
||||
- Terminal takes bottom 1/3 (always visible)
|
||||
- No other windows on top
|
||||
|
||||
---
|
||||
|
||||
## Demo file setup
|
||||
|
||||
Pre-create these files in the demo repo before the demo:
|
||||
|
||||
**Alice's machine:**
|
||||
|
||||
- Open `auth/middleware.ts` — has visible function stubs
|
||||
- Terminal shows nothing running initially, then `Server running on :3001` at the right moment
|
||||
|
||||
**Bob's machine:**
|
||||
|
||||
- Open `frontend/login.tsx` — has visible form component code
|
||||
- Terminal idle
|
||||
|
||||
**Carol's machine:**
|
||||
|
||||
- Open `frontend/integration.ts` or similar
|
||||
- Terminal shows: `curl http://localhost:3001/auth` → `curl: (7) Failed to connect`
|
||||
|
||||
---
|
||||
|
||||
## Demo script timing
|
||||
|
||||
| Time | Action | Who |
|
||||
| ----- | ----------------------------------------------- | ----------- |
|
||||
| 0:00 | All three join `demo-pod` | All |
|
||||
| 0:05 | PodMan greets by voice | Hermes auto |
|
||||
| 0:20 | Alice opens `auth/middleware.ts`, starts typing | Alice |
|
||||
| 0:45 | Bob opens `frontend/login.tsx` | Bob |
|
||||
| 0:50 | Carol runs `curl` command, sees error | Carol |
|
||||
| ~1:20 | BLOCKER_DETECTED nudge fires | Hermes auto |
|
||||
| 1:50 | Alice starts her server (`node server.js`) | Alice |
|
||||
| ~2:00 | DEPENDENCY_READY nudge fires | Hermes auto |
|
||||
| 2:20 | Optional: show session 2 ownership warm-start | Presenter |
|
||||
| 2:45 | Close | Presenter |
|
||||
|
||||
---
|
||||
|
||||
## Gemini Vision reliability tips
|
||||
|
||||
- Keep font at 18pt+ throughout the demo — do not zoom out
|
||||
- Avoid opening file picker dialogs or overlapping modals during the demo
|
||||
- File names in editor tabs must be fully visible (not `auth/middle...`)
|
||||
- If Hermes logs show `confidence < 0.6` frames: bump font size, ensure file tab is clear
|
||||
- Terminal output must be on a single line — avoid long stack traces during demo
|
||||
|
||||
---
|
||||
|
||||
## Cooldown note
|
||||
|
||||
Hermes has a 3-minute cooldown between nudges per pod. For the demo, if you need to trigger a second event quickly:
|
||||
|
||||
Option 1: restart Hermes between the two demo scenarios (resets cooldown state)
|
||||
Option 2: set `NUDGE_COOLDOWN_MS=0` via env var during demo (add this override to Hermes)
|
||||
|
||||
---
|
||||
|
||||
## Fallback plan
|
||||
|
||||
If any system fails on stage:
|
||||
|
||||
1. **Hermes unreachable:** switch to local (`pnpm --filter backend dev`) — PWA auto-falls back to `localhost:8787`
|
||||
2. **Gemini Vision low confidence:** presenter narrates what PodMan "saw" while playing the backup video
|
||||
3. **LiveKit audio not working:** play backup video — show the nudge text cards on screen instead
|
||||
4. **Full system failure:** play the backup recording, narrate the demo live
|
||||
|
||||
Always have the backup video on a separate device, not the same laptop running Hermes.
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
# PodMan — 4-Minute Demo Script
|
||||
|
||||
**Theme:** Continual Learning. **Hard limit:** 4:00. Practice to land at 3:45.
|
||||
|
||||
**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
|
||||
every member's work in real time, gives everyone live status without anyone having
|
||||
to interrupt anyone, and learns your team's dynamics so it nudges less and helps
|
||||
more over time.
|
||||
|
||||
**The hook to land:** a "quick five-minute question" actually costs ~25 minutes of
|
||||
lost focus — for two people. PodMan removes the reason to ask. Multiply the saved
|
||||
recovery time across every teammate, every day, and that is the value.
|
||||
|
||||
---
|
||||
|
||||
## The script (4:00)
|
||||
|
||||
### 0:00–0:30 — The problem + hook
|
||||
|
||||
> "AI made writing code easy. The thing still slowing teams down is coordination
|
||||
> — checking each other's work, re-planning collisions, and the constant 'what
|
||||
> are you working on?' A five-minute question really costs both people 25 minutes
|
||||
> of lost focus. PodMan is a pair programmer for the whole team: it watches
|
||||
> everyone's work live, so anyone can see another's status without interrupting
|
||||
> them — and it learns your team as it goes."
|
||||
|
||||
*On screen:* the pod view, two teammates joined, screen-share tiles live.
|
||||
|
||||
### 0:30–1:05 — Real-time team awareness (LiveKit + Gemini Vision)
|
||||
|
||||
- Point at the two live screen tiles. "These are real screen shares over
|
||||
**LiveKit**. Our agent subscribes to the tracks and samples frames."
|
||||
- "Each frame goes to **Gemini Vision**, which returns structured context — file,
|
||||
symbol, activity — not a chatbot, a perception layer."
|
||||
- Show the live activity stream filling in (Signals vs Reasoning sections).
|
||||
- Land the value: "This is the part that replaces 'what are you working on?' —
|
||||
every teammate's current work is just *visible*, in real time. Nobody had to
|
||||
ask."
|
||||
|
||||
*Built-by-us callout:* `backend/src/vision/gemini.ts`, the LiveKit agent worker.
|
||||
|
||||
### 1:05–1:50 — The catch (detection + first intervention)
|
||||
|
||||
- Have alice and bob both edit the **same file** with unpushed changes.
|
||||
- "Normally nobody notices until merge time. GitHub can't see this — nothing's
|
||||
pushed. Our detector fuses live screen context with **local git truth** from a
|
||||
watcher on each laptop."
|
||||
- A collision card appears: *"alice + bob both on detector.ts (unpushed)."*
|
||||
- Let the **Gemini TTS** urgent voice fire once over LiveKit: *"alice and bob are
|
||||
both editing detector.ts. Please sync before pushing."*
|
||||
- Land the value: "That's a merge conflict and a wasted afternoon caught before it
|
||||
happened — and neither of them had to be tracking the other."
|
||||
|
||||
*Built-by-us callout:* `collision/detector.ts`, `action/hermes.ts`,
|
||||
`voice/live.ts`.
|
||||
|
||||
### 1:50–2:50 — Continual learning (the theme — the money shot)
|
||||
|
||||
This is the differentiator. Two beats, both from pre-seeded memory:
|
||||
|
||||
1. **It learned to stay quiet.** Trigger a pattern that was dismissed as a false
|
||||
alarm earlier. "Last session a teammate marked this kind of alert as not a
|
||||
real conflict. Watch — PodMan stays silent. No nagging." (No card fires.)
|
||||
2. **It learned to escalate.** Trigger the real-conflict pattern that was
|
||||
accepted before. The card now says **"Seen before."** and goes straight to
|
||||
the spoken urgent cue.
|
||||
|
||||
- "The only input was one accept/dismiss tap. No retraining, no labeling. This is
|
||||
**MongoDB Atlas vector search** recalling similar past events plus a policy
|
||||
that adapts on the recalled outcome."
|
||||
- Optional: show `/api/memory/stats` counts climbing — accumulated experience.
|
||||
|
||||
*Built-by-us callout:* `memory/vectors.ts` ($vectorSearch), `memory/policy.ts`
|
||||
(outcome-conditioned gate), `memory/store.ts`.
|
||||
|
||||
### 2:50–3:30 — The five-minute meeting, killed (Gemini Live API)
|
||||
|
||||
- Frame it: "Instead of breaking a teammate's focus to ask what they're up to,
|
||||
you ask PodMan."
|
||||
- Open the live voice conversation. Ask out loud: *"PodMan, what is everyone
|
||||
working on, and where is the collision detector implemented?"*
|
||||
- It answers with **real tool calls** — `search_repo`, git history, current
|
||||
collisions — not guesses.
|
||||
- "This is the **Gemini Live API**, streaming speech-to-speech over LiveKit, with
|
||||
custom function tools we wrote so it grounds every answer in the actual repo
|
||||
and live state. That's the status sync, answered in seconds, with zero recovery
|
||||
tax on anyone else."
|
||||
|
||||
*Built-by-us callout:* `agents/podman-live-conversation/agent.py`.
|
||||
|
||||
### 3:30–3:50 — Stack + close
|
||||
|
||||
- "All on **DigitalOcean** — static frontend, API, and agent workers, supervised
|
||||
by systemd. The ambient score is **Gemini Lyria** generated per pod through the
|
||||
Interactions API."
|
||||
- Close: "Engineering ability stopped being the bottleneck — coordination is.
|
||||
PodMan gives a whole team real-time awareness without the interruptions, catches
|
||||
collisions before they cost an afternoon, and learns each team's dynamics so it
|
||||
helps more over time. Saved focus, multiplied across every teammate. That's
|
||||
continual learning, shipped."
|
||||
|
||||
### 3:50–4:00 — Buffer / Q&A handoff
|
||||
|
||||
---
|
||||
|
||||
## Sponsor-prize coverage (say each at least once)
|
||||
|
||||
| Prize | Spoken moment | Segment |
|
||||
| --- | --- | --- |
|
||||
| **Gemini** | Vision perception, Live API agent w/ tools, TTS voice, Lyria score | 0:30, 1:05, 2:50, 3:30 |
|
||||
| **LiveKit** | "real screen shares over LiveKit", agent subscribes, TTS audio track, live voice | 0:30, 1:05, 3:30 |
|
||||
| **MongoDB** | "Atlas vector search recalling past events" | 1:50 |
|
||||
| **DigitalOcean** | "all on DigitalOcean, systemd-supervised workers" | 3:30 |
|
||||
|
||||
---
|
||||
|
||||
## If something breaks (live recovery)
|
||||
|
||||
| Failure | Recovery |
|
||||
| --- | --- |
|
||||
| Voice doesn't fire | Cut to the card; say the line aloud; cards are the default path anyway. |
|
||||
| Live conversation drops | Skip 2:50–3:30; lean longer on the learning beat. |
|
||||
| Collision won't trigger | Use the backup recording for that beat; keep narrating. |
|
||||
| Agent flapping | Pre-checked — but if so, `systemctl restart podman-platform-agent`. |
|
||||
|
||||
**Rule:** never debug on stage. Narrate, fall back to recording, keep moving.
|
||||
|
||||
---
|
||||
|
||||
## Tight timing summary
|
||||
|
||||
| Time | Beat |
|
||||
| --- | --- |
|
||||
| 0:00 | Problem (coordination cost) + hook + original-work line |
|
||||
| 0:30 | Real-time team awareness — LiveKit + Gemini Vision |
|
||||
| 1:05 | The catch — collision caught before merge |
|
||||
| 1:50 | **Continual learning — quiet + escalate** |
|
||||
| 2:50 | The five-minute meeting, killed — Gemini Live conversation |
|
||||
| 3:30 | DigitalOcean + Lyria + close |
|
||||
| 3:50 | Buffer |
|
||||
+5
-67
@@ -58,13 +58,6 @@ The mirror at `infra/.do/app.yaml` is kept identical for DO UI/import workflows.
|
||||
- No HTTP route and no HTTP health check
|
||||
- Default room: `POD_ROOM=demo-pod`
|
||||
|
||||
### Worker: live conversation agent (Python)
|
||||
|
||||
- Source: `agents/podman-live-conversation/`
|
||||
- The Gemini Live voice agent (`gemini-3.1-flash-live-preview`), run with `uv`.
|
||||
- On the droplet it runs as `podman-live-conversation-agent.service`
|
||||
(`infra/systemd/`). It is separate from the Node services and the TS agent.
|
||||
|
||||
---
|
||||
|
||||
## Required Runtime Environment
|
||||
@@ -73,15 +66,10 @@ The mirror at `infra/.do/app.yaml` is kept identical for DO UI/import workflows.
|
||||
LIVEKIT_URL=wss://your-livekit-server.livekit.cloud
|
||||
LIVEKIT_API_KEY=...
|
||||
LIVEKIT_API_SECRET=...
|
||||
LIVEKIT_CONVERSATION_AGENT_NAME=podman-live-conversation
|
||||
|
||||
GEMINI_API_KEY=... # GOOGLE_API_KEY also accepted
|
||||
GEMINI_API_KEY=...
|
||||
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview # TTS voice
|
||||
GEMINI_CONVERSATION_MODEL=gemini-3.1-flash-live-preview
|
||||
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
|
||||
GEMINI_TTS_VOICE=Charon
|
||||
# GEMINI_MUSIC_MODEL=lyria-3-clip-preview # optional override
|
||||
GEMINI_LIVE_MODEL=gemini-live-2.5-flash
|
||||
|
||||
GITHUB_TOKEN=...
|
||||
GITHUB_REPO=karti-ai/podman
|
||||
@@ -94,10 +82,8 @@ PORT=8787
|
||||
POD_ROOM=demo-pod
|
||||
```
|
||||
|
||||
`VOYAGE_API_KEY` is optional. Without it, Gemini embeddings provide vector
|
||||
recall; without any embedding provider, recall degrades to exact signature
|
||||
matching. The Lyria background score uses the Gemini Interactions API and the
|
||||
same `GEMINI_API_KEY`.
|
||||
`VOYAGE_API_KEY` is optional for local/demo fallback. Without it, Mongo exact
|
||||
signature recall still works; Atlas Vector Search recall is skipped.
|
||||
|
||||
---
|
||||
|
||||
@@ -109,17 +95,6 @@ Build once:
|
||||
docker build -f infra/Dockerfile -t podman-backend .
|
||||
```
|
||||
|
||||
Or use the repository script and verifier:
|
||||
|
||||
```bash
|
||||
pnpm build:container
|
||||
pnpm verify:containers
|
||||
```
|
||||
|
||||
`verify:containers` uses Docker by default to match `build:container`. To verify
|
||||
against a Podman image store instead, run
|
||||
`VERIFY_CONTAINER_RUNTIME=podman pnpm verify:containers`.
|
||||
|
||||
The image entrypoint runs `node backend/dist/server.js` when
|
||||
`PODMAN_PROCESS=server`, and `node backend/dist/agent.js` when
|
||||
`PODMAN_PROCESS=agent`. Do not run the combined Hermes supervisor inside App
|
||||
@@ -195,51 +170,14 @@ The droplet production fallback uses systemd units from `infra/systemd/`:
|
||||
```bash
|
||||
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent
|
||||
```
|
||||
|
||||
Expected runtime proof:
|
||||
|
||||
```bash
|
||||
systemctl is-active podman-platform-api podman-platform-agent
|
||||
systemctl is-active podman-hermes-watchdog.timer
|
||||
systemctl is-active podman-hermes-sync-deploy.timer
|
||||
curl http://127.0.0.1:8787/health
|
||||
journalctl -u podman-platform-agent -n 20 --no-pager
|
||||
journalctl -u podman-hermes-watchdog -n 40 --no-pager
|
||||
```
|
||||
|
||||
## Hermes-managed operations layer
|
||||
|
||||
The app processes are still supervised by systemd, but Hermes now owns the
|
||||
operations loop around them:
|
||||
|
||||
- `pnpm hermes:watchdog` checks systemd services, public routes, `/health`,
|
||||
`/api/pods`, and `pnpm deploy:doctor`.
|
||||
- `podman-hermes-watchdog.timer` runs that watchdog every five minutes.
|
||||
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes. If
|
||||
the tree is clean and the remote moved, it fast-forwards, installs, builds,
|
||||
publishes `frontend/dist` to `/var/www/podman`, restarts the API/agent/Caddy,
|
||||
and runs the strict watchdog.
|
||||
- Failed URL checks trigger restarts of the PodMan API, PodMan agent, and Caddy.
|
||||
- Failed service checks restart only the unhealthy service.
|
||||
- Caddy is validated and reloaded after public route failures.
|
||||
- Reports are written to `/var/log/podman/hermes-watchdog-latest.json`.
|
||||
- Set `PODMAN_ALERT_WEBHOOK_URL` to send failed reports to Discord, Slack, or a
|
||||
generic webhook receiver.
|
||||
- `pnpm hermes:install` installs the timer units and a local pre-push hook that
|
||||
gates major pushes with typecheck, lint, and a non-remediating watchdog check.
|
||||
|
||||
The strict gate for production readiness is:
|
||||
|
||||
```bash
|
||||
pnpm hermes:watchdog:strict
|
||||
```
|
||||
|
||||
Manual deploy-sync run:
|
||||
|
||||
```bash
|
||||
pnpm hermes:sync-deploy
|
||||
```
|
||||
|
||||
+96
-77
@@ -1,118 +1,137 @@
|
||||
# Gemini Integration Spec
|
||||
|
||||
Status: active / matches code.
|
||||
|
||||
PodMan uses Gemini for five jobs, all through the `@google/genai` SDK
|
||||
(`GoogleGenAI`) with a single `GEMINI_API_KEY` (`GOOGLE_API_KEY` /
|
||||
`GOOGLE_GENERATIVE_AI_API_KEY` also accepted):
|
||||
|
||||
1. **Vision** — turn screen frames into structured work context.
|
||||
2. **Embeddings** — vector recall over past coordination events.
|
||||
3. **TTS voice** — spoken urgent escalations over LiveKit.
|
||||
4. **Live conversation** — a real-time voice agent teammates talk to.
|
||||
5. **GenMedia (Lyria)** — a per-pod background score.
|
||||
|
||||
Collision detection and intervention text are **deterministic in code**, not
|
||||
Gemini calls. PodMan does not ask Gemini "is this a conflict?" — that is decided
|
||||
by `backend/src/collision/detector.ts` from fused vision + git truth. This is a
|
||||
deliberate reliability choice for the live demo.
|
||||
PodMan uses Gemini for two distinct jobs: **vision** (understanding screens) and **voice** (speaking nudges).
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision — screen understanding
|
||||
## 1. Vision — Screen Understanding
|
||||
|
||||
**Model:** `GEMINI_VISION_MODEL` (default `gemini-2.0-flash`)
|
||||
**Code:** `backend/src/vision/gemini.ts` → `analyzeFrame()`
|
||||
**Model:** `gemini-2.0-flash` (fast, cheap, strong multimodal)
|
||||
|
||||
**Trigger:** the LiveKit agent samples a JPEG frame from each engineer's
|
||||
screen-share track (not an HTTP upload — frames arrive over LiveKit).
|
||||
**Trigger:** every 30s per active engineer, when Hermes receives a `POST /ingest` frame
|
||||
|
||||
**Input:** a single base64 JPEG, sampled at low media resolution.
|
||||
**Input:** base64-encoded JPEG, max 1280×720, ~50–80KB after compression
|
||||
|
||||
**Output:** structured JSON via `responseJsonSchema` (no markdown parsing):
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
You are analyzing a software engineer's screen during a coding session.
|
||||
Extract the following JSON. If you cannot determine a field with confidence above 0.7, set it to null.
|
||||
|
||||
```ts
|
||||
{
|
||||
currentFile: string, // open file path, e.g. src/auth/session.ts
|
||||
currentSymbol: string, // function/class under the cursor
|
||||
activity: string, // editing | reading | debugging | terminal | PR review
|
||||
hasUnpushedChanges: boolean, // dirty git gutter / modified markers visible
|
||||
confidence: number // 0..1
|
||||
"currentFile": "string | null", // active file visible in editor tab or title bar
|
||||
"inferredTask": "string | null", // 1 sentence: what the engineer appears to be doing
|
||||
"terminalVisible": true | false, // is a terminal or CLI panel visible
|
||||
"recentTerminalOutput": "string | null", // last meaningful line of terminal output if visible
|
||||
"confidence": 0.0–1.0 // your overall confidence in this extraction
|
||||
}
|
||||
|
||||
Respond with valid JSON only. No explanation. No markdown.
|
||||
```
|
||||
|
||||
**Latency/cost levers (in code):**
|
||||
**Confidence gate:** if `confidence < 0.6`, Hermes discards the frame — no state update, no event detection triggered.
|
||||
|
||||
- `thinkingConfig: { thinkingBudget: 0 }` — minimal thinking for the ambient loop.
|
||||
- `mediaResolution: MEDIA_RESOLUTION_LOW` — smaller image tokens.
|
||||
- Missing `confidence` defaults to `0.5`.
|
||||
**Rate limit:** 1 call per engineer per 30s. With 3 engineers = 6 calls/min ≈ $0.002/min at Flash pricing.
|
||||
|
||||
**Demo reliability:** large editor font, single window, visible file tab. This is
|
||||
the primary lever for clean reads.
|
||||
**Demo setup requirement:** editors must have large font (18pt+), single window, file name clearly visible in tab. This is the primary reliability lever.
|
||||
|
||||
---
|
||||
|
||||
## 2. Embeddings — semantic recall
|
||||
## 2. Event Detection — Coordination Awareness
|
||||
|
||||
**Model:** `GEMINI_EMBEDDING_MODEL` (default `gemini-embedding-001`, 768 dims)
|
||||
**Code:** `backend/src/memory/vectors.ts`
|
||||
**Model:** `gemini-2.0-flash` (text only, fast)
|
||||
|
||||
Each collision is embedded into a short memory text (`file`, `symbol`,
|
||||
`engineers`, `severity`, unpushed flag) and stored on the `collisions` document.
|
||||
On a new collision PodMan embeds the query and runs MongoDB Atlas `$vectorSearch`
|
||||
(index `collision_embedding`) to recall similar past events and their outcomes.
|
||||
**Trigger:** after every successful state write to MongoDB, Hermes runs event detection over all active engineer contexts.
|
||||
|
||||
**Provider order:** Voyage (`VOYAGE_API_KEY`, `voyage-4-lite`) is tried first when
|
||||
present; Gemini embeddings are the fallback. Without either, recall degrades to
|
||||
exact signature/file matching — the demo still works.
|
||||
**Input:** JSON snapshot of all engineers' current states + ownership map
|
||||
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
You are a team coordination agent. Below is the current state of each engineer on the team.
|
||||
|
||||
Engineer states:
|
||||
{{engineerStates}}
|
||||
|
||||
Ownership map (who owns which files):
|
||||
{{ownershipMap}}
|
||||
|
||||
Detect if any of these coordination events are occurring:
|
||||
- DEPENDENCY_READY: an engineer who was blocked or waiting now has what they need because another engineer completed relevant work
|
||||
- BLOCKER_DETECTED: an engineer appears stuck (same file, error in terminal, no progress) and another teammate could help
|
||||
- DUPLICATE_WORK: two or more engineers are working on the same file simultaneously
|
||||
|
||||
If an event is detected, respond with:
|
||||
{
|
||||
"event": "DEPENDENCY_READY" | "BLOCKER_DETECTED" | "DUPLICATE_WORK" | null,
|
||||
"involvedEngineers": ["engineerId", ...],
|
||||
"file": "string | null",
|
||||
"reason": "1 sentence explanation"
|
||||
}
|
||||
|
||||
If no event, respond with { "event": null }.
|
||||
Respond with valid JSON only.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. TTS voice — urgent escalation over LiveKit
|
||||
## 3. Nudge Generation — Voice Message
|
||||
|
||||
**Model:** `GEMINI_LIVE_MODEL` (default `gemini-3.1-flash-tts-preview`)
|
||||
**Default voice:** `GEMINI_TTS_VOICE` (default `Charon`)
|
||||
**Code:** `backend/src/voice/live.ts` → `speak()` / `speakInRoom()`
|
||||
**Model:** `gemini-2.0-flash` (text only)
|
||||
|
||||
Flow: a short, natural voice line is generated for a critical collision, returned
|
||||
as audio, and published as a LiveKit microphone-source audio track. The track is
|
||||
held for the audio duration plus tail/hold so browsers do not cut playout short.
|
||||
Browser audio must be unlocked by a user gesture first. The frontend always
|
||||
renders the `VOICE_CUE` text as a fallback. See `docs/livekit.md` for delivery.
|
||||
**Trigger:** when event detection returns a non-null event
|
||||
|
||||
**Input:** event type + engineer names + file + reason
|
||||
|
||||
**Prompt:**
|
||||
|
||||
```
|
||||
You are PodMan, a friendly AI teammate. Generate a short spoken message (1–2 sentences max) to notify the team about this coordination event.
|
||||
|
||||
Event: {{eventType}}
|
||||
Engineers involved: {{engineerNames}}
|
||||
File: {{file}}
|
||||
Context: {{reason}}
|
||||
|
||||
Rules:
|
||||
- Use first names only
|
||||
- Be direct and specific
|
||||
- Do not use filler words
|
||||
- Sound natural when spoken aloud
|
||||
- Do not start with "Hey" or "Attention"
|
||||
|
||||
Respond with the message text only.
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
|
||||
> "Carol — Alice just got the auth endpoint running. You're clear to integrate."
|
||||
|
||||
---
|
||||
|
||||
## 4. Live conversation — real-time voice agent
|
||||
## 4. Voice Output — Gemini Live 2.5 via LiveKit
|
||||
|
||||
**Model:** `GEMINI_CONVERSATION_MODEL` (default `gemini-3.1-flash-live-preview`)
|
||||
**Code:** `agents/podman-live-conversation/agent.py` (Python LiveKit Agents,
|
||||
`google.realtime.RealtimeModel`)
|
||||
**Model:** `gemini-live-2.5-flash` (confirm exact ID from LiveKit Agents docs)
|
||||
|
||||
A teammate can start a live, streaming speech-to-speech session with PodMan. The
|
||||
agent answers using **function tools** rather than guessing, including:
|
||||
**Integration:** LiveKit Agents framework — Hermes runs as a LiveKit Agent with Gemini Live 2.5 as the voice provider
|
||||
|
||||
- `get_active_pod_context`, `get_recent_changes`, `search_team_memory`
|
||||
- `search_repo`, `repo_recent_commits`, `repo_find_commits` (repo + git history)
|
||||
- `record_conversation_note`
|
||||
- `delegate_to_hermes`, `abort_active_hermes_job` (hands work to the async Hermes
|
||||
job runner — see `docs/hermes.md`)
|
||||
**Flow:**
|
||||
|
||||
Started/stopped via `POST /api/pods/:id/live-conversation/start` and `.../stop`.
|
||||
1. Nudge message text generated (step 3)
|
||||
2. Hermes passes text to Gemini Live via LiveKit Agents
|
||||
3. Gemini Live streams audio back in real-time
|
||||
4. LiveKit publishes audio into the pod room
|
||||
5. All participants hear it through their audio output
|
||||
|
||||
---
|
||||
**Why Gemini Live (not plain TTS):**
|
||||
|
||||
## 5. GenMedia — Lyria background score
|
||||
|
||||
**Model:** `lyria-3-clip-preview` (override with `GEMINI_MUSIC_MODEL`)
|
||||
**Endpoint:** Gemini **Interactions API** (`/v1beta/interactions`)
|
||||
**Code:** `backend/src/voice/music.ts`
|
||||
|
||||
A pod-specific ~30s clip is generated through the Interactions API, cached in
|
||||
MongoDB, and served via `GET /api/pods/:id/music` to play as ambient room audio.
|
||||
- Streams audio directly — no intermediate WAV file conversion
|
||||
- Latency ~300–500ms from text to first audio packet
|
||||
- Natural-sounding voice
|
||||
- Strong prize story: Gemini Live 2.5 is the headline model
|
||||
|
||||
---
|
||||
|
||||
## Cooldown
|
||||
|
||||
Per-pod cooldown (`NUDGE_COOLDOWN_MS`, default 180000 ms / 3 min) gates repeated
|
||||
interventions. Implemented in `backend/src/memory/policy.ts`, not in Gemini.
|
||||
Per-pod cooldown of **3 minutes** between nudges. Prevents spam if multiple events fire simultaneously. Implemented in Hermes, not in Gemini.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# Continual-Learning Graph Spec
|
||||
|
||||
> Owner: graph data + visualization. Status: demo-backed / active.
|
||||
> Owner: graph data + visualization. Status: demo-backed (live `team_model` reads land later).
|
||||
> Satisfies the documentation-first gate for the `backend/src/graph/*` and
|
||||
> `frontend/src/components/GraphView.tsx` files. This file is the canonical
|
||||
> graph spec.
|
||||
> `frontend/src/components/GraphView.tsx` files.
|
||||
|
||||
## What this is (and is NOT)
|
||||
|
||||
@@ -27,9 +26,7 @@ The graph lives in two places, both keyed by `podId`:
|
||||
{ podId, graph: PodGraph, updatedAt }
|
||||
```
|
||||
|
||||
`GET /api/pods/:podId/graph` returns the live materialized graph first, then
|
||||
`team_model.graph`, then a labeled demo graph when neither live nor seeded
|
||||
data exists.
|
||||
`GET /api/pods/:podId/graph` returns `team_model.graph`, or a demo graph when none exists yet.
|
||||
|
||||
2. **Normalized (for traversal):** the same nodes/edges are mirrored into two collections so
|
||||
the model can be walked with MongoDB `$graphLookup` (the graph-database pattern):
|
||||
@@ -79,30 +76,13 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
|
||||
|
||||
- `shared/src/graph.ts` — `PodGraph`, `PodGraphNode/Edge/Metric`, `GraphNodeDoc`, `GraphEdgeDoc`
|
||||
- `backend/src/graph/demo.ts` — `createDemoPodGraph(podId)` (grounded in the demo-pod crew)
|
||||
- `backend/src/graph/live.ts` — **`materializePodGraph(podId)`**: builds the graph from the real
|
||||
collections (pods, engineer_states, observations, collisions, interventions, outcomes)
|
||||
- `backend/src/graph/store.ts` — `loadPodGraph` (live → seeded → demo), `seedGraph`, `reachFrom` (`$graphLookup`)
|
||||
- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
|
||||
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
|
||||
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
|
||||
- `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`)
|
||||
- `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`)
|
||||
|
||||
## Live data → graph mapping
|
||||
## Demo-first plan
|
||||
|
||||
`materializePodGraph` reads the 5 real collections per pod and emits a `PodGraph`:
|
||||
|
||||
| Collection | Produces |
|
||||
| ----------------- | -------------------------------------------------------------------------- |
|
||||
| `pods.members` | baseline **engineer** nodes |
|
||||
| `engineer_states` | engineer `risk` if unpushed; **file** nodes (git paths parsed); `editing` |
|
||||
| `observations` | engineer `active`; **file** from `currentFile`; `editing` (strength=conf.) |
|
||||
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
||||
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
||||
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
||||
|
||||
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
||||
|
||||
## Fallback order (`loadPodGraph`)
|
||||
|
||||
1. **Live** — `materializePodGraph` from the real collections (returns `null` if only bare roster).
|
||||
2. **Seeded** — `team_model.graph` (from `pnpm graph:seed`).
|
||||
3. **Demo** — `createDemoPodGraph()` (stage safety; never an empty canvas).
|
||||
1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path).
|
||||
2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock.
|
||||
3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it.
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
# Hermes Spec
|
||||
|
||||
Status: active / matches code.
|
||||
|
||||
"Hermes" is PodMan's **action layer** — the part that turns a detected problem
|
||||
into something a teammate sees, hears, or gets done. It spans three things:
|
||||
|
||||
1. **Interventions** — cards, messages, and urgent voice in the pod room.
|
||||
2. **Async jobs** — longer tasks delegated from the live conversation agent.
|
||||
3. **Ops watchdog** — keeps the production services healthy.
|
||||
|
||||
The LiveKit identity for the main agent is `podman-hermes`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Interventions
|
||||
|
||||
**Code:** `backend/src/agent/podman.ts`, `backend/src/action/hermes.ts`,
|
||||
`backend/src/voice/live.ts`.
|
||||
|
||||
When the agent detects a collision, it runs the learning loop (recall → policy
|
||||
gate; see `docs/cont_learning.md`) and then publishes the **least intrusive**
|
||||
intervention that fits:
|
||||
|
||||
- **Card / message** — a data-channel packet on the `podman.intervention` topic
|
||||
(`publishHermesIntervention` / `publishHermesMessage`). Default path.
|
||||
- **Urgent voice** — only for `critical` collisions. `speak()` generates Gemini
|
||||
TTS audio and publishes it as a LiveKit audio track.
|
||||
|
||||
Intervention text is short and deterministic (template, not an LLM call):
|
||||
`Conflict: alice + bob both on detector.ts (unpushed). Seen before.` The spoken
|
||||
line is phrased for natural TTS prosody. Each intervention is persisted to the
|
||||
`interventions` collection; the teammate's accept/dismiss returns via
|
||||
`POST /api/outcome`.
|
||||
|
||||
A per-pod cooldown (`NUDGE_COOLDOWN_MS`, default 3 min) and a single-shot
|
||||
"active conflict" guard prevent repeat nagging; a conflict re-arms once it
|
||||
resolves.
|
||||
|
||||
---
|
||||
|
||||
## 2. Async Hermes jobs
|
||||
|
||||
**Code:** `backend/src/hermes/jobs.ts`. **Storage:** `hermes_jobs` +
|
||||
`hermes_job_events` (see `docs/mongodb.md`).
|
||||
|
||||
The live conversation agent can hand a longer task to Hermes via its
|
||||
`delegate_to_hermes` tool. Lifecycle:
|
||||
|
||||
```
|
||||
queued → running → (waiting_for_confirmation) → completed | aborted | failed
|
||||
```
|
||||
|
||||
`createHermesJob()` records the job, emits an `accepted` event, and kicks off
|
||||
`runHermesJob()` in the background. The runner gathers context and runs scoped,
|
||||
read-mostly steps based on the prompt and success criteria:
|
||||
|
||||
- always: `git status --short --branch`, `git diff --stat`
|
||||
- if the ask mentions GitHub: a repo reachability check via the GitHub API
|
||||
- if it mentions Mongo/memory/telemetry: collection counts
|
||||
- if it mentions build/test/typecheck/broken: `pnpm typecheck`
|
||||
|
||||
**Confirmation gate:** if `riskLevel === 'deploy_allowed'` and
|
||||
`requiresConfirmation`, the job parks at `waiting_for_confirmation` instead of
|
||||
acting. **Abort:** `abortHermesJob()` signals the runner's `AbortController`.
|
||||
|
||||
Every step appends a `hermes_job_event` (redacted + truncated), which is both
|
||||
stored and published live to the room as a `HERMES_JOB_EVENT` data message from a
|
||||
short-lived `podman-hermes-job-*` identity. The conversation UI streams these via
|
||||
`GET /api/.../hermes-job/events/stream`.
|
||||
|
||||
**Endpoints:** `POST /api/internal/hermes/jobs`,
|
||||
`GET /api/internal/hermes/jobs/:jobId`, `.../abort`, `.../events`,
|
||||
`.../events/stream`, plus the pod-scoped `.../live-conversation/:sessionId/hermes-job`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ops watchdog
|
||||
|
||||
**Code:** `scripts/hermes-watchdog.mjs`, `scripts/hermes-sync-deploy.mjs`,
|
||||
`scripts/hermes-notify.mjs`. **Detail:** `docs/digitalocean.md`.
|
||||
|
||||
systemd supervises the app processes; Hermes owns the loop around them:
|
||||
|
||||
- `pnpm hermes:watchdog` checks systemd services, public routes, `/health`,
|
||||
`/api/pods`, and `pnpm deploy:doctor`. Failures trigger targeted restarts.
|
||||
- `podman-hermes-watchdog.timer` runs it every 5 minutes.
|
||||
- `podman-hermes-sync-deploy.timer` polls `origin/main` every 2 minutes and, on a
|
||||
clean tree, fast-forwards, builds, publishes `frontend/dist`, restarts
|
||||
API/agent/Caddy, and runs the strict watchdog.
|
||||
- Reports go to `/var/log/podman/hermes-watchdog-latest.json`; set
|
||||
`PODMAN_ALERT_WEBHOOK_URL` to forward failures to Discord/Slack/webhook.
|
||||
|
||||
---
|
||||
|
||||
## What Hermes is NOT
|
||||
|
||||
- Not an autonomous code-writing agent. Job steps are scoped, read-mostly checks;
|
||||
deploy-level actions require explicit confirmation.
|
||||
- Not a second collision detector. Detection is deterministic
|
||||
(`collision/detector.ts`); Hermes only acts on the result.
|
||||
@@ -0,0 +1,93 @@
|
||||
# PodMan — Idea
|
||||
|
||||
## One-line value prop
|
||||
|
||||
PodMan is a real-time AI team coordination agent that watches consented work signals, maintains live project memory, and proactively notifies collaborators when dependencies, blockers, or handoffs emerge — before anyone has to ask.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Teams working on the same project lose time because progress is fragmented across people, editors, terminals, and half-finished messages. Coordination gaps — a completed endpoint, a resolved blocker, two engineers duplicating work — are discovered too late, causing idle time, broken handoffs, and missed dependencies.
|
||||
|
||||
Slack doesn't help. Stand-ups are too slow. GitHub only knows pushed state.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
PodMan is an ambient AI agent that:
|
||||
|
||||
1. Watches each engineer's screen via periodic snapshots (consented, browser-native)
|
||||
2. Extracts structured context using Gemini Vision — current file, inferred task, terminal state
|
||||
3. Maintains a shared live model of the team in MongoDB Atlas — who is doing what, who owns which files
|
||||
4. Detects coordination events: dependency ready, blocker detected, duplicate work
|
||||
5. Speaks proactively into the team's LiveKit room — engineers hear PodMan through their earbuds without leaving their editor
|
||||
|
||||
**The AI's job is not to chat. It is to notice what teammates miss and say so, exactly when it matters.**
|
||||
|
||||
---
|
||||
|
||||
## Target user
|
||||
|
||||
Small software teams: hackathon squads, startup engineering teams, student dev teams collaborating in real time on a shared codebase.
|
||||
|
||||
---
|
||||
|
||||
## Core AI job
|
||||
|
||||
- Maintain per-person live context (file, task, terminal)
|
||||
- Infer shared project state (who owns what, what's blocked, what's ready)
|
||||
- Detect 3 coordination event types:
|
||||
- `DEPENDENCY_READY` — engineer A was waiting on work engineer B just completed
|
||||
- `BLOCKER_DETECTED` — engineer appears stuck; another teammate can unblock
|
||||
- `DUPLICATE_WORK` — 2+ engineers working on the same file simultaneously
|
||||
- Generate a 1–2 sentence proactive voice nudge
|
||||
- Deliver it into the LiveKit room via Gemini Live 2.5
|
||||
|
||||
---
|
||||
|
||||
## How it fits the Continual Learning track
|
||||
|
||||
PodMan builds an **ownership map** in MongoDB that persists across sessions:
|
||||
|
||||
- Session 1: PodMan needs 3–5 minutes of screen observations to infer who owns what
|
||||
- Session 2+: PodMan already knows. First nudge fires in under 30 seconds.
|
||||
|
||||
The system gets demonstrably more useful the more it is used, with no user configuration required. That is the track definition met exactly.
|
||||
|
||||
---
|
||||
|
||||
## Architecture (one paragraph)
|
||||
|
||||
Each engineer opens a browser PWA on their laptop. The PWA captures a screen frame every 30 seconds via `getDisplayMedia` and POSTs it to Hermes, the server-side orchestrator running on DigitalOcean. Hermes calls Gemini Vision to extract structured context, writes it to MongoDB Atlas, updates the ownership map, and runs event detection across all active engineers. When a coordination event fires, Hermes generates a short spoken message and publishes it as audio into the team's LiveKit room via Gemini Live 2.5. Engineers hear PodMan through their earbuds. No Slack. No tab switching. No interruption to the editor flow.
|
||||
|
||||
---
|
||||
|
||||
## Demo wow moment
|
||||
|
||||
> Alice is building the auth endpoint. Carol is visibly blocked — her terminal shows `connection refused`. PodMan detects the blocker and says aloud: "Carol, looks like you're waiting on auth. Alice is actively building it — hang tight."
|
||||
>
|
||||
> Two minutes later, Alice's server starts. PodMan says: "Carol, Bob — Alice just got the auth endpoint running. You're clear to integrate."
|
||||
>
|
||||
> Nobody asked. Nobody pinged anyone on Slack. PodMan just knew.
|
||||
|
||||
---
|
||||
|
||||
## What PodMan is NOT
|
||||
|
||||
- Not a chat interface
|
||||
- Not a dashboard product
|
||||
- Not raw surveillance — engineers consent by joining the room and sharing their screen
|
||||
- Not a task manager
|
||||
- Not a GitHub integration (v1)
|
||||
|
||||
---
|
||||
|
||||
## Prize alignment
|
||||
|
||||
| Prize | How PodMan earns it |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Best Gemini 3.5 / 2.5 | Gemini Vision for screen understanding + Gemini Live 2.5 for voice output |
|
||||
| Best LiveKit | LiveKit is the real-time backbone for room presence and voice delivery — load-bearing, not decorative |
|
||||
| Best DigitalOcean | Hermes deployed on DigitalOcean App Platform; MongoDB Atlas on DO-adjacent infrastructure |
|
||||
@@ -0,0 +1,203 @@
|
||||
# Real Continual-Learning UI — Spec
|
||||
|
||||
> Owner: graph data + visualization (live data). Status: **demo-backed graph shipped (`docs/graph.md`); this spec makes it REAL.**
|
||||
> **Extends `docs/graph.md` — does not duplicate it.** Same files, same contracts (`shared/src/graph.ts`, `PodGraph`, the two collections, the two routes). This spec only adds: a live materializer behind `loadPodGraph`, an outcomes aggregation, a ws push of `GRAPH_DIRTY`, and the per-pod GraphView wiring. Everything in `graph.md` (node/edge kinds, `$graphLookup`, demo fallback) remains the contract.
|
||||
|
||||
## 0. Purpose & framing
|
||||
|
||||
The demo graph already renders the "it learned" story (`createDemoPodGraph` → Karti owns auth, `learned_from` edge, 86% accept rate). The problem: **none of it is real** — `team_model`/`graph_nodes`/`graph_edges` are never written (`seedGraph` has zero callers), so `loadPodGraph` always returns the hardcoded demo. The graph looks alive but is a poster.
|
||||
|
||||
**This spec makes the same poster a live render of the 6 collections the agent actually writes** (`pods`, `engineer_states`, `observations`, `collisions`, `interventions`, `outcomes`), so the continual-learning loop the judges see is backed by data the pipeline produced this session.
|
||||
|
||||
The visible self-improving loop, before → after:
|
||||
|
||||
- **Before:** Two engineers edit `auth.ts`, one unpushed. A `collisions` doc is written, an `interventions` doc (status `pending`). The graph grows a red `collision` triangle and a `warns` edge to the intervention diamond. Copy: _"new — first time PodMan saw this path."_
|
||||
- **After:** The human clicks Accept → `POST /api/outcome` writes `{accepted:true, wasRealCollision:true}`. The materializer turns that outcome into a **`learned_from` edge** (`intervention → engineer`, label `learned: owns auth.ts`), flips the engineer node to `status:'learned'`, and bumps the **Learned owners** metric. Copy on the next similar collision: _"I've seen this before — last time the team accepted sync PR"_ (driven by `collisions.memorySignature` recall, already live).
|
||||
|
||||
**Why this is not a dashboard** (hard constraint): it stays the **secondary, toggle-opened** view behind the pods list (`graph.md`), keeps a single-canvas SVG themed to match the app's shadcn UI (one graph, not a grid of charts), and every visible element is anchored to a live write + an action loop. The metrics rail is 3 numbers derived from real counts, not a wall of KPIs. The hero remains the intervention card in `PodView`; this view exists only to make _"PodMan got better"_ legible in 10 seconds.
|
||||
|
||||
## R1 — Revisions (resolves PR #3 review)
|
||||
|
||||
Revised after review. The five findings and resolutions (the sections below reflect these):
|
||||
|
||||
- **P1 — learned owner was guessed, not supervised → fixed by a contract change.** The Accept flow now carries the confirmed owner: `InterventionOutcome` (`shared/src/messages.ts`) gains `learnedOwner?: string` + `file?: string`, and `frontend/src/livekit/useInterventions.ts` sends `learnedOwner` (the confirming engineer) + `file` (`collision.file`). On `accepted && wasRealCollision`, `POST /api/outcome` writes `team_model.ownership[file] = learnedOwner` — the existing-but-never-written `TeamModel.ownership` type. The materializer draws `owns`/`learned_from` edges from `team_model.ownership` (**authoritative**); the most-recent-observation owner survives only as a clearly-labelled **low-confidence fallback**. Ownership is now **stored, not inferred**.
|
||||
- **P1 — `changedFiles` aren't clean paths → fixed in the materializer.** They are raw `git status --short` lines (`"M src/auth.ts"`, `"?? x"`, `"R a -> b"`). `live.ts` runs `parseGitStatusPath()` (strip the XY code; post-`->` target for renames) then `normalizeFile()` so file ids match `collisions.file`.
|
||||
- **P2 — ws bridge made concrete.** `GRAPH_DIRTY` (must-have) is emitted **inside the Express process** on `POST /api/outcome` (no bridge). The agent is a **separate process** publishing to LiveKit; for the nice-to-have instant `COLLISION`/`VOICE_CUE` push, the agent opens a ws **client** to `ws://127.0.0.1:${PORT}/api/events` and forwards them. If unreachable, the 5s poll covers it. Instant push = nice-to-have; poll = floor.
|
||||
- **P2 — demo metrics shown as live → fixed with a `source` marker.** `PodGraph` gains `source: 'live' | 'demo'` (`createDemoPodGraph`=`'demo'`, materializer=`'live'`). When `source==='demo'` or live-with-zero outcomes, `GraphView` shows a **"demo · waiting for first observation"** badge and renders metric values as `—`, so `5 / 2 / 86%` never reads as live.
|
||||
- **P3 — PLAN-before-code inconsistency → softened.** This PR is **spec-only**; the `docs/PLAN.md` task entry + `docs/graph.md` cross-link land **with the first implementation PR** (PLAN.md is hot/concurrent; the task ships alongside its code).
|
||||
|
||||
**Contract changes (small, additive):** `shared/src/messages.ts` (`InterventionOutcome` +`learnedOwner`/`file`; `TeamModel.ownership` now written), `shared/src/graph.ts` (`PodGraph.source`), `frontend/src/livekit/useInterventions.ts` (Accept sends owner+file).
|
||||
|
||||
## R2 — Theme (matches the app)
|
||||
|
||||
The app shipped as **light shadcn**; the shipped `GraphView` was **hardcoded dark-Bauhaus** and clashed. Resolution: re-theme `GraphView.tsx` to shadcn tokens (`var(--card)` / `--foreground` / `--muted-foreground` / `--border`, `--chart-1..5` for node hues) so it is **theme-aware** — light like the rest of the app today, dark if the app toggles — and reads as native. The functional encoding (geometric node shapes, per-kind colors, the 3-number metrics rail) is unchanged. This re-theme ships as its own small change to the live component (`feat/team-memory-theme`), independent of the live-data work.
|
||||
|
||||
## 1. Live data → graph mapping
|
||||
|
||||
The materializer (`backend/src/graph/live.ts`, §3) reads these collections per `podId` and emits `PodGraph` (`shared/src/graph.ts`). Node ids stay stable so realtime refreshes don't reshuffle: `engineer:<name>`, `file:<normalizedFile>`, `collision:<collision.id>`, `intervention:<intervention.id>`.
|
||||
|
||||
| Source collection | Fields read | Produces |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`pods`** | `members[]`, `name`, `repo` | Baseline `engineer:` nodes for every roster member (so the graph isn't empty pre-activity). `summary` = pod `repo`. |
|
||||
| **`engineer_states`** (via `getGitStates`) | `name`, `changedFiles[]`, `branch`, `recentCommit`, `gitUpdatedAt` | `engineer:<name>` node `status:'risk'` when `changedFiles.length>0`. **`changedFiles[]` are raw `git status --short` lines (e.g. `"M src/auth.ts"`, `"?? x.ts"`, `"R a -> b"`), NOT clean paths (R1)** — `live.ts` runs `parseGitStatusPath()` (strip the XY code; post-`->` target for renames) then `normalizeFile()` so file ids match `collisions.file`. One `file:` node per parsed path; `editing` edge engineer→file, `strength` 0.6. Engineer `summary` = `"N changed files on <branch>"`. |
|
||||
| **`observations`** (`EngineerContext`) | `engineerId`, `currentFile`, `currentSymbol`, `activity`, `confidence`, `observedAt` | `engineer:<engineerId>` node `status:'active'` if a `observedAt` within last 60s exists; `file:<currentFile>` node; `editing` edge engineer→file, `strength` = `confidence` (Gemini meter). Most-recent `observedAt` gives a file's de-facto owner — used **only as the low-confidence fallback** for the `owns` edge when `team_model.ownership` is empty (R1). Edge `label` = `activity`. |
|
||||
| **`collisions`** (`Collision`) | `id`, `file`, `symbol`, `engineers[]`, `severity`, `detectedAt`, `memorySignature` | One `collision:<id>` node, `kind:'collision'`, `status:'risk'`, `weight` by severity (`info`0.4/`warn`0.7/`critical`1.0). `collides` edge per name in `engineers[]` (engineer→collision, `strength` from severity). `touches` edge `file:<file>`→collision. A `summary` badge `"seen before"` when `memorySignature` matched a prior collision (severity escalated to `critical` — already the live recall signal). |
|
||||
| **`interventions`** (`Intervention`) | `id`, `collisionId`, `kind`, `suggestedAction.kind`, `status`, `createdAt` | One `intervention:<id>` diamond. `warns` edge `collision:<collisionId>`→intervention, `label` from `suggestedAction.kind` (`open_sync_pr`→`"sync PR"`, `ping_teammate`→`"ping"`, `none`→`"watch"`). **Color cannot come from `status`** (always `pending` — never updated), so it is joined to `outcomes` (next row). |
|
||||
| **`outcomes`** (`InterventionOutcome`) | `interventionId`, `wasRealCollision`, `accepted`, `recordedAt` | **The supervised learning signal (R1).** On `accepted && wasRealCollision` the outcome carries `learnedOwner` + `file`; the server writes `team_model.ownership[file]=learnedOwner`. The materializer reads `team_model.ownership` (**authoritative**) to draw the `owns` edge and a `learned_from` edge `intervention:<id>`→`engineer:<learnedOwner>` (`label: learned: owns <file>`, `strength` 0.6), flipping that engineer + intervention to `status:'learned'`. `accepted===false` → intervention `status:'stable'` ("dismissed"). Observation-based ownership is **only** a low-confidence fallback when `team_model.ownership` is empty — never the primary signal. |
|
||||
|
||||
**Metrics rail** (`PodGraphMetric[]`, replacing demo's hardcoded `5 / 2 / 86%`), computed live in `live.ts`:
|
||||
|
||||
| `label` | `value` | `detail` | Source |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------- |
|
||||
| `Learned owners` | count of accepted+real outcomes | `"Ownership edges retained from accepted interventions."` | `outcomes` aggregation |
|
||||
| `Open risk paths` | count of `collisions` with `engineers.length>=2` and any colliding engineer has unpushed (`engineer_states.changedFiles` non-empty) | `"Files with converging editors and unpushed work."` | `collisions` × `engineer_states` |
|
||||
| `Accept rate` | `round(accepted / total outcomes * 100)%` (`"—"` when total 0) | `"Interventions accepted this session."` | `outcomes` aggregation |
|
||||
|
||||
`x`/`y` layout: `live.ts` runs a deterministic column layout (engineers x≈78, files x≈300, collisions x≈470, interventions/features x≈620; y spread evenly per column within the `0..472` viewBox) so the existing SVG renders unchanged.
|
||||
|
||||
## 2. The visible workflow (observe → store → predict → outcome → adapt)
|
||||
|
||||
The graph view narrates the same loop the agent runs, mapped to what the viewer sees:
|
||||
|
||||
1. **Observe** (`observations`, ~1fps; `engineer_states`, 15s): engineer nodes light `active`; `editing` edges thicken with Gemini `confidence`. A small caption under the canvas: _"Yahya — editing auth.ts (unpushed)"_ from the latest observation + git chip.
|
||||
2. **Store / Predict** (`collisions` + vector recall): when ≥2 engineers + unpushed, a red `collision` triangle and `collides`/`touches` edges appear. If `memorySignature` recall hit (severity `critical`), the node carries a **"seen before"** badge — the self-improvement signal.
|
||||
3. **Intervene** (`interventions`): a `warns` edge draws to a new `intervention` diamond labeled by `suggestedAction.kind`. The view shows a _"PodMan is speaking"_ pulse on that edge when a `VOICE_CUE` arrives over ws (§5).
|
||||
4. **Outcome** (`POST /api/outcome` from the `PodView` card): the moment the human clicks Accept.
|
||||
5. **Adapt** (`outcomes` → `learned_from`): on the next graph refresh, a dashed purple `learned_from` edge animates in, the engineer node flips to `learned` (gold/locked), and **Learned owners** + **Accept rate** tick up.
|
||||
|
||||
**The live "money moment" (sequence the demo lands):** collision detected → red triangle + edges appear and PodMan speaks (pulse) → human clicks Accept on the card in `PodView` → switch to Team memory → the `learned_from` edge to `engineer:karti` (`learned: owns auth.ts`) is now present, the engineer is gold, metrics rose. A second collision on the same signature renders with the "seen before" badge. That single before→after transition, all from collections written this session, is the continual-learning proof.
|
||||
|
||||
## 3. Backend changes
|
||||
|
||||
All additive / behind existing signatures — `graph.md` contracts unchanged.
|
||||
|
||||
### 3a. Live materializer — `backend/src/graph/live.ts` (NEW)
|
||||
|
||||
`export async function materializePodGraph(podId: string): Promise<PodGraph>`. Reads the 6 collections via `collections()` + `getGitStates(podId)` (`memory/db.ts`), builds nodes/edges/metrics per §1, runs the column layout, returns `PodGraph`. Pure-read; never writes. Wrapped so any Mongo error throws to the caller (which falls back to demo, §3b). Helpers: `parseGitStatusPath()` (strip the `git status --short` XY code; take the post-`->` target for renames) applied to `engineer_states.changedFiles`, then `normalizeFile()` reused from `collision/detector.ts` so file ids match `collisions.file` exactly. Ownership for `owns`/`learned_from` edges is read from `team_model.ownership` (authoritative); observation-based ownership is only a marked low-confidence fallback.
|
||||
|
||||
### 3b. `loadPodGraph` — `backend/src/graph/store.ts` (MODIFY)
|
||||
|
||||
Replace the body so it prefers live, then `team_model`, then demo:
|
||||
|
||||
```
|
||||
1. const graph = await materializePodGraph(podId)
|
||||
2. if (graph.nodes.length > <BASELINE>) return graph // real activity exists
|
||||
3. const doc = team_model.findOne({podId}); if (doc?.graph) return doc.graph
|
||||
4. return createDemoPodGraph(podId) // demo-stability fallback
|
||||
```
|
||||
|
||||
`<BASELINE>` = the count of pure roster `engineer:` nodes (no files/collisions). If only roster nodes exist (no observations/collisions yet) the view shows demo so the canvas is never empty mid-demo (§5). Signature, route, and demo fallback all unchanged.
|
||||
|
||||
### 3c. Keep `$graphLookup` real — mirror into `graph_nodes`/`graph_edges`
|
||||
|
||||
Add `export async function materializeAndSeed(podId)` in `store.ts`: calls `materializePodGraph`, then reuses `seedGraph`'s existing upsert/`deleteMany`/`insertMany` block to write `team_model.graph` + `graph_nodes` + `graph_edges` from the **live** graph (today `seedGraph` writes the demo graph from `createDemoPodGraph`; this variant takes the live one). Called (a) on every `POST /api/outcome` (so `reachFrom` reflects the new `learned_from` edge), and (b) lazily inside the graph route after `loadPodGraph` returns a live graph. This is the only way `reachFrom`/`/graph/reach/:node` stops returning empty.
|
||||
|
||||
### 3d. Routes — `backend/src/server.ts` (MODIFY, additive only)
|
||||
|
||||
- `GET /api/pods/:id/graph` — unchanged signature; now returns live graph via §3b.
|
||||
- `GET /api/pods/:id/graph/reach/:node` — unchanged; now non-empty once §3c runs.
|
||||
- `GET /api/pods/:id/graph/metrics` (NEW, small) — returns just `PodGraphMetric[]` (the outcomes aggregation: `learned owners`, `open risk paths`, `accept rate`) so the rail can poll cheaply without re-sending the whole graph. Backed by a `db.collection('outcomes').aggregate` group on `podId` (count, `$sum accepted`).
|
||||
- `POST /api/outcome` (MODIFY): after `recordOutcome`, when `accepted && wasRealCollision` **write `team_model.ownership[outcome.file] = outcome.learnedOwner`** (the supervised signal — `TeamModel` type, upsert on `podId`), then call `materializeAndSeed(podId)` (best-effort, never throws) **and** broadcast `{type:'GRAPH_DIRTY', podId}` to ws `/api/events` clients (reuse the existing `clients` set / `c.send`). This is the only place the loop closes.
|
||||
|
||||
### 3e. Realtime push — ws `/api/events` (MODIFY)
|
||||
|
||||
The relay already fans out any JSON. **`GRAPH_DIRTY` needs no bridge** — it is emitted from inside the Express process on `POST /api/outcome`, so it reaches ws clients directly. **The agent runs in a SEPARATE process** (`dev:agent` / `agent.ts`) and publishes `COLLISION`/`VOICE_CUE` on the **LiveKit** data channel, which the server's ws clients never see. Concrete bridge for the (nice-to-have) instant push: the agent opens a WebSocket **client** to `ws://127.0.0.1:${PORT}/api/events` and forwards the same `COLLISION`/`VOICE_CUE` JSON; the relay fans it out. If the agent can't reach the API, the **5s poll (§5) covers it** (≤5s). This instant push is explicitly **nice-to-have** (§6); the poll is the floor.
|
||||
|
||||
## 4. Frontend changes
|
||||
|
||||
### 4a. Per-pod entry point — `frontend/src/App.tsx` (MODIFY)
|
||||
|
||||
The "Team memory" button currently passes `pods[0]?.id ?? 'demo-pod'` (line 271) — wrong pod for a multi-pod demo. Change to open the graph for the **pod in context**: add a `BrainCircuitIcon` action on each `PodCard` (`onOpenGraph(pod.id)`) wired to `setGraphPodId(pod.id)`, and keep the header button as a fallback that opens the **selected/first live pod** (the one with presence). The router slot (`if (graphPodId) return <GraphView podId={graphPodId} …/>`, line 237-239) is unchanged.
|
||||
|
||||
### 4b. `GraphView.tsx` — match the app's shadcn theme, add realtime
|
||||
|
||||
**Themed to match the command-center (R2).** The hardcoded dark `.pm-*` palette is replaced with shadcn tokens (`var(--card)` / `--foreground` / `--muted-foreground` / `--border`) so Team Memory is **theme-aware** (renders light like the rest of the app today, follows dark mode if the app toggles) and reads as native, not a dark island. **Unifying-template rule:** the chrome composes from the shadcn/ruixen registry primitives — `Button`, `Badge`, and the app's Tailwind utility patterns (`StatPill`/`BriefLine`-style) from `@/components/ui/*`; add any new primitive with `npx shadcn@latest add "https://ruixen.com/r/[component]"`. **Only the SVG node-link canvas is bespoke** (3 SVG-only CSS rules). The functional encoding stays: geometric node shapes (engineer square / file square-outline / feature circle / collision triangle / intervention diamond) and per-kind light-readable hues. What changes:
|
||||
|
||||
- **Realtime refresh:** open a ws to `/api/events` on mount; on `{type:'GRAPH_DIRTY', podId}` (matching this pod) or `COLLISION`/`VOICE_CUE`, re-call `fetchPodGraph(podId)` (and `fetchGraphMetrics`). Also a **5s poll** of `fetchPodGraph` as the floor (mirrors `App.tsx`'s existing 5s presence/memory poll) so it's live even if ws drops. New incoming nodes/edges fade in (CSS opacity transition on `<line>`/shape); `learned_from` edges animate the dashed stroke.
|
||||
- **"new" vs "seen before" copy** in the detail rail from `collision` node `summary` badge (§1).
|
||||
- **Speaking pulse** on the `warns` edge when a `VOICE_CUE` lands.
|
||||
|
||||
### 4c. `frontend/src/lib/graph.ts` (MODIFY)
|
||||
|
||||
- Fix `BACKEND_URL` to follow `lib/api.ts`'s resolution (empty string in prod) instead of hardcoding `localhost:8787`.
|
||||
- Add `fetchGraphMetrics(podId)` → `GET /api/pods/:id/graph/metrics`.
|
||||
- Add `openGraphEvents(podId, onDirty)` → thin `WebSocket('/api/events')` subscription helper (reused by `GraphView`).
|
||||
|
||||
### 4d. Loading / empty / error states (collections empty is the common real case)
|
||||
|
||||
- **Loading:** existing on-mount spinner; keep.
|
||||
- **Empty (no activity yet):** §3b returns the **demo graph** so the canvas is never blank — but `PodGraph` now carries **`source: 'live' | 'demo'`** (R1): `createDemoPodGraph` sets `'demo'`, the materializer `'live'`. When `graph.source==='demo'` (or `'live'` with metrics total 0), `GraphView` renders a **"demo · waiting for first observation"** badge and **suppresses the metric values (shows `—`)** so the baked `5 / 2 / 86%` never reads as live. No empty-grid placeholder.
|
||||
- **Error / backend down:** `fetchPodGraph` rejects → render the last good graph if any, else the demo graph rendered client-side is not available; show a single-line `.pm-` error chip _"memory offline — retrying"_ and keep polling. ws errors are swallowed (poll covers it).
|
||||
|
||||
## 5. Realtime & data freshness
|
||||
|
||||
| Surface | Transport | Cadence |
|
||||
| --------------------------------- | ---------------------------------------------------- | ------------------------------- |
|
||||
| Engineer `active`/file edits | ws `COLLISION` passthrough + 5s `fetchPodGraph` poll | ~1fps source data, surfaced ≤5s |
|
||||
| Git chip (changed files / branch) | folded into 5s graph poll (`engineer_states`) | 15s underlying write |
|
||||
| Collision / intervention nodes | ws `COLLISION` (instant) → triggers refetch | instant on event |
|
||||
| Speaking pulse | ws `VOICE_CUE` | instant |
|
||||
| `learned_from` edge + metrics | ws `GRAPH_DIRTY` on `POST /api/outcome` → refetch | instant on Accept |
|
||||
|
||||
**Polling is the floor, ws is the accelerator** — never gate the canvas on ws. **Demo-stability fallback:** if the live materializer yields ≤ baseline nodes or Mongo is unreachable, the route serves `createDemoPodGraph` (§3b) so the toggle always shows a coherent graph on stage. The Accept→`learned_from` beat is driven by `POST /api/outcome` → `materializeAndSeed` → `GRAPH_DIRTY`, the one path that must be solid.
|
||||
|
||||
## 6. Demo path
|
||||
|
||||
**Must-have (the 3-min money moment):**
|
||||
|
||||
- `materializePodGraph` reading `collisions` + `interventions` + `outcomes` + `engineer_states` so the graph reflects this session.
|
||||
- `POST /api/outcome` → `materializeAndSeed` + `GRAPH_DIRTY`; GraphView refetches and the `learned_from` edge + gold node + risen metrics appear after Accept.
|
||||
- Live metrics rail (Learned owners / Open risk paths / Accept rate) from the outcomes aggregation.
|
||||
- "seen before" badge from `collisions.memorySignature` (already live) on the second collision.
|
||||
- Demo-graph fallback when collections are empty (stage safety).
|
||||
|
||||
**Nice-to-have:**
|
||||
|
||||
- ws `VOICE_CUE` speaking pulse on the `warns` edge.
|
||||
- `/graph/reach/:node` lit risk-path walk (`$graphLookup`) once `materializeAndSeed` populates `graph_edges`.
|
||||
- Fade/stroke animations on incoming edges.
|
||||
- Per-`PodCard` graph entry button.
|
||||
|
||||
**Cut if behind (per CLAUDE.md 12h box):**
|
||||
|
||||
- Vector-search dependency for recall (exact `memorySignature` recall already covers the learning beat).
|
||||
- Any new chart primitive / recharts — keep the SVG.
|
||||
- Auth/pod-scoping on ws `/api/events`.
|
||||
- Historical/time-scrubbed graph; only "now" is needed.
|
||||
|
||||
**The 3-min beat (extends `PLAN.md` §9, ends in this view):** IDE with unpushed `auth.ts` → live caption → second engineer opens same file → **COLLISION** card in `PodView` (named teammate + sync PR action) → Hermes voice → human clicks **Accept** → toggle **Team memory** → the `learned_from` "learned: Karti owns auth.ts" edge is now real, metrics rose → second collision shows "I've seen this before" → close on the public repo PR URL + the live metrics.
|
||||
|
||||
## 7. Files & tasks (documentation-first)
|
||||
|
||||
> Per CLAUDE.md gate: the spec lands before code. **This PR is spec-only.** The `docs/PLAN.md` task entry (`Pxx — Live continual-learning graph`, Files list below) + the `docs/graph.md` "Live data backing" cross-link land **with the first implementation PR** — `PLAN.md` is a hot, concurrently-edited file, so the task ships alongside the code that satisfies it rather than as a separate doc-only edit that would conflict.
|
||||
|
||||
**Create:**
|
||||
|
||||
- `backend/src/graph/live.ts` — `materializePodGraph(podId)`, `normalizeFile`, metrics aggregation, column layout. _(Task: live materializer)_
|
||||
|
||||
**Modify:**
|
||||
|
||||
- `docs/graph.md` — append a **"Live data backing"** section pointing to this spec (the §1 mapping table + `live.ts`); change the "Demo-first plan" step 3 ("Swap `loadPodGraph`…") to "done via `materializePodGraph`." _(documentation-first)_
|
||||
- `docs/PLAN.md` — add the task + Files list. _(documentation-first)_
|
||||
- `backend/src/graph/store.ts` — `loadPodGraph` prefers live (§3b); add `materializeAndSeed(podId)` (§3c). _(Task: live wiring + $graphLookup)_
|
||||
- `backend/src/server.ts` — `POST /api/outcome` calls `materializeAndSeed` + broadcasts `GRAPH_DIRTY`; new `GET /api/pods/:id/graph/metrics`; ws passthrough of `COLLISION`/`VOICE_CUE`/`GRAPH_DIRTY`. _(Task: routes + realtime)_
|
||||
- `backend/src/agent/podman.ts` — also emit `COLLISION`/`VOICE_CUE` onto ws `/api/events` (mirror of the LiveKit data-channel publish) so the dashboard-level GraphView reacts. _(Task: realtime mirror)_
|
||||
- `frontend/src/lib/graph.ts` — `BACKEND_URL` resolution fix; `fetchGraphMetrics`; `openGraphEvents`. _(Task: client)_
|
||||
- `shared/src/messages.ts` — `InterventionOutcome` +`learnedOwner?`/`file?`; `TeamModel.ownership` now written (R1). _(Task: supervised signal)_
|
||||
- `shared/src/graph.ts` — `PodGraph` +`source: 'live' | 'demo'` (R1). _(Task: live/demo honesty)_
|
||||
- `frontend/src/livekit/useInterventions.ts` — Accept `postOutcome` sends `learnedOwner` + `file` (R1). _(Task: supervised signal)_
|
||||
- `frontend/src/components/GraphView.tsx` — re-theme to shadcn tokens (theme-aware, R2); ws subscription + 5s poll + animations + "new/seen-before" rail copy + speaking pulse. _(Task: client)_
|
||||
- `frontend/src/App.tsx` — per-pod graph entry; header button opens live/selected pod not `pods[0]`. _(Task: entry point)_
|
||||
- `frontend/src/components/PodCard.tsx` — `onOpenGraph(pod.id)` action. _(Task: entry point)_
|
||||
|
||||
**Contract changes (small, additive — R1):** `shared/src/messages.ts` (`InterventionOutcome` +`learnedOwner`/`file`; `TeamModel.ownership` now written), `shared/src/graph.ts` (`PodGraph` +`source`), `frontend/src/livekit/useInterventions.ts` (Accept sends owner+file). **Still unchanged:** the two graph routes' signatures, node/edge kinds, `createDemoPodGraph` (kept as fallback), `seedGraph`/`graph:seed` (kept; `materializeAndSeed` reuses its write block).
|
||||
|
||||
## 8. Risks & open questions
|
||||
|
||||
- **`engineer_states` is keyed by `name`; `observations` by `engineerId`; collisions by names in `engineers[]`.** The materializer must reconcile these to one `engineer:<name>` node. Assumption (from data map): LiveKit identity == `--name` == engineer name. If they diverge, edges will orphan. **Mitigation:** key all engineer nodes off `pods.members` and match case-insensitively; drop unmatched.
|
||||
- **`outcomes` had no owner identity (review P1) — resolved (R1):** the Accept payload now carries `learnedOwner` + `file`, persisted to `team_model.ownership[file]`, so the `learned_from`/`owns` edges are **supervised, not guessed**. The `outcome → intervention → collision` join + most-recent-observation owner remains **only** as a clearly-marked low-confidence fallback when `learnedOwner`/`team_model.ownership` is absent.
|
||||
- **`observations` TTL may not fire** (init.ts indexes `observedAt` as Date but it's stored as ISO string) — so "active" windowing must compare parsed ISO timestamps in `live.ts`, not rely on TTL eviction; old observations could otherwise inflate "active." **Open:** cap to most-recent observation per `(engineerId,file)`.
|
||||
- **`intervention.status` never advances past `pending`** — confirmed; the UI must color interventions from the `outcomes` join, never from `status`. Already handled in §1, but worth a one-line code comment so a future dev doesn't "fix" it by reading `status`.
|
||||
- **ws `/api/events` has no pod-scoping** — `GRAPH_DIRTY` carries `podId` and the client filters; acceptable for the demo, flagged as the known shortcut.
|
||||
- **Open question:** should `materializeAndSeed` run on every `POST /api/outcome` (simple, slightly heavy) or be debounced? For a 3-engineer demo, run inline; revisit only if outcome volume spikes.
|
||||
- **Open question:** when both a live `materializePodGraph` graph and a `team_model.graph` exist, live wins (§3b). Confirm no flow expects `team_model.graph` to be authoritative — currently nothing writes it except the dead `seedGraph`, so live-wins is safe.
|
||||
+57
-57
@@ -1,24 +1,15 @@
|
||||
# LiveKit Integration Spec
|
||||
|
||||
Status: active / matches code.
|
||||
|
||||
LiveKit is the real-time backbone for PodMan. It carries the **screen-share
|
||||
perception input**, the **intervention data channel**, and **all room audio**
|
||||
(Gemini TTS escalations, the Lyria score, and the live conversation agent). It is
|
||||
load-bearing, not decorative.
|
||||
LiveKit is the real-time backbone for PodMan. It handles room presence and voice delivery. It is load-bearing — not decorative.
|
||||
|
||||
---
|
||||
|
||||
## Room structure
|
||||
|
||||
- One LiveKit room per pod: `room = podId`.
|
||||
- Engineers join as named participants (e.g. `alice`, `bob`).
|
||||
- PodMan runs **multiple agent identities** in/around a room:
|
||||
- `podman-hermes` — the main vision + intervention agent (`@livekit/rtc-node`).
|
||||
- `podman-live-conversation` — the Gemini Live voice agent (Python).
|
||||
- short-lived `podman-hermes-job-*` publishers for async job events.
|
||||
- A fixed identity matters: a second `podman-hermes` evicts the first and they
|
||||
flap, dropping interventions. systemd keeps exactly one alive in production.
|
||||
- One LiveKit room per project pod: `room = podId`
|
||||
- Engineers join as named participants (e.g. `alice`, `bob`)
|
||||
- Hermes joins as `podman-hermes`
|
||||
- All participants stay connected for the duration of the session
|
||||
|
||||
---
|
||||
|
||||
@@ -26,79 +17,88 @@ load-bearing, not decorative.
|
||||
|
||||
**Joining:**
|
||||
|
||||
1. PWA calls `POST /api/token` with `{ podId, identity }` → `{ token, url }`.
|
||||
2. LiveKit client connects with the token.
|
||||
3. PWA publishes the screen track via `getDisplayMedia`.
|
||||
4. PWA enables mic for ambient presence (used by the conversation agent).
|
||||
1. PWA calls `POST /pods/:podId/token` → receives `{ token, url }`
|
||||
2. LiveKit client connects to the room with the token
|
||||
3. PWA publishes screen track via `getDisplayMedia`
|
||||
4. PWA sets mic enabled for ambient presence
|
||||
|
||||
**Receiving:**
|
||||
|
||||
- Subscribes to remote agent audio tracks (TTS, Lyria, conversation) and attaches
|
||||
them to a hidden audio sink.
|
||||
- Browser autoplay restrictions apply: the PWA calls `room.startAudio()` from a
|
||||
user gesture (`Enable audio`, `Test PodMan voice`, `Share screen`, first room
|
||||
click).
|
||||
- Listens on the data channel for cards and `VOICE_CUE` fallback text.
|
||||
- LiveKit client automatically receives Hermes audio track
|
||||
- No special subscription needed — LiveKit delivers audio to all participants
|
||||
- PWA also listens for data channel messages from Hermes for UI card updates
|
||||
|
||||
**Data channel listener (PWA):**
|
||||
|
||||
```ts
|
||||
room.on(RoomEvent.DataReceived, (payload, participant) => {
|
||||
if (!participant?.identity.startsWith('podman-')) return;
|
||||
const msg = JSON.parse(new TextDecoder().decode(payload));
|
||||
// msg.type: COLLISION | ACK | GIT_REPORT | VOICE_CUE | HERMES_JOB_EVENT
|
||||
appendInterventionToFeed(msg);
|
||||
if (participant?.identity !== 'podman-hermes') return;
|
||||
const nudge = JSON.parse(new TextDecoder().decode(payload));
|
||||
// nudge: { type, message, involvedEngineers, file, sentAt }
|
||||
appendNudgeToFeed(nudge);
|
||||
});
|
||||
```
|
||||
|
||||
All data messages share the `podman.intervention` topic (`DATA_TOPIC`).
|
||||
|
||||
---
|
||||
|
||||
## Agent side (`podman-hermes`)
|
||||
## Hermes side (LiveKit Agent)
|
||||
|
||||
**Framework:** `@livekit/rtc-node`. **Code:** `backend/src/agent/podman.ts`,
|
||||
`backend/src/action/hermes.ts`, `backend/src/voice/live.ts`.
|
||||
**Framework:** LiveKit Agents (Node.js)
|
||||
|
||||
1. Subscribes to engineers' screen-share tracks and samples frames for Gemini
|
||||
Vision.
|
||||
2. Detects collisions, gates them through the learning policy, then publishes a
|
||||
card/message on the data channel.
|
||||
3. For critical collisions, generates Gemini TTS audio and publishes it as a
|
||||
microphone-source audio track, held for the audio duration plus a tail/hold
|
||||
window so subscribers finish playout. Voice publishing logs frame count,
|
||||
estimated duration, queued playout, and hold time for diagnostics.
|
||||
**Startup:**
|
||||
|
||||
---
|
||||
1. Hermes mints its own token via the same `createPodToken` function with `identity: 'podman-hermes'`
|
||||
2. Connects to the configured room as `podman-hermes`
|
||||
3. Registers as a LiveKit Agent with Gemini Live 2.5 as voice provider
|
||||
|
||||
## Live conversation agent (`podman-live-conversation`)
|
||||
**Voice delivery:**
|
||||
|
||||
**Framework:** LiveKit Agents for Python (`AgentSession`, `function_tool`,
|
||||
`google.realtime.RealtimeModel`). **Code:**
|
||||
`agents/podman-live-conversation/agent.py`.
|
||||
1. Nudge message text is ready (from Gemini text generation)
|
||||
2. Hermes passes text to Gemini Live 2.5 via LiveKit Agents voice pipeline
|
||||
3. Audio streams into the room in real-time
|
||||
4. All participants hear it
|
||||
|
||||
Joins the pod room on demand (`POST /api/pods/:id/live-conversation/start`),
|
||||
streams speech-to-speech with Gemini Live, and answers using repo/git/memory
|
||||
function tools. It can delegate long tasks to the async Hermes job runner and
|
||||
narrate progress. See `docs/hermes.md`.
|
||||
**Data channel message (sent alongside audio):**
|
||||
|
||||
```ts
|
||||
const nudge = {
|
||||
type: 'DEPENDENCY_READY' | 'BLOCKER_DETECTED' | 'DUPLICATE_WORK',
|
||||
message: string, // the spoken text
|
||||
involvedEngineers: string[],
|
||||
file: string | null,
|
||||
sentAt: string, // ISO timestamp
|
||||
};
|
||||
room.localParticipant.publishData(
|
||||
new TextEncoder().encode(JSON.stringify(nudge)),
|
||||
{ reliable: true }
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token endpoint
|
||||
|
||||
`POST /api/token` mints room tokens for engineers and agents alike. Grants:
|
||||
Already implemented at `POST /api/token`.
|
||||
|
||||
Hermes uses the same endpoint. Grants:
|
||||
|
||||
- `roomJoin: true`
|
||||
- `canPublish: true` (audio + screen)
|
||||
- `canPublishData: true` (data channel)
|
||||
- `canPublish: true` (for audio track)
|
||||
- `canPublishData: true` (for data channel)
|
||||
- `canSubscribe: true`
|
||||
|
||||
Short-lived job publishers use `canSubscribe: false`.
|
||||
---
|
||||
|
||||
## Gemini Live 2.5 model
|
||||
|
||||
- Model ID: `gemini-live-2.5-flash` — confirm exact ID from LiveKit Agents + Gemini docs at build time
|
||||
- LiveKit Agents has native Gemini Live integration — no manual audio encoding needed
|
||||
- Hermes passes text string → Agents handles streaming audio publication
|
||||
|
||||
---
|
||||
|
||||
## What LiveKit does NOT do in PodMan
|
||||
|
||||
- No video tracks published by agents.
|
||||
- No mic transcription outside the live conversation agent.
|
||||
- No custom SFU mixing — standard room behavior is sufficient.
|
||||
- No video tracks from Hermes
|
||||
- No mic transcription (not needed for v1)
|
||||
- No SFU mixing — standard room behavior is sufficient
|
||||
|
||||
+114
-161
@@ -1,190 +1,143 @@
|
||||
# MongoDB Atlas Integration Spec
|
||||
|
||||
Status: demo-backed / active
|
||||
|
||||
MongoDB Atlas is PodMan's shared memory. It stores live work observations,
|
||||
collision predictions (with vector embeddings for recall), interventions,
|
||||
outcomes, latest engineer state, the materialized Team memory graph, and async
|
||||
Hermes job runs.
|
||||
|
||||
See also:
|
||||
|
||||
- [`docs/cont_learning.md`](cont_learning.md) for outcome-backed team memory,
|
||||
graph materialization, and `$graphLookup` traversal.
|
||||
MongoDB Atlas is PodMan's shared memory. It stores live engineer state, the ownership map that enables continual learning, coordination events, and nudge history.
|
||||
|
||||
---
|
||||
|
||||
## Current Collections
|
||||
## Collections
|
||||
|
||||
### `engineer_states`
|
||||
|
||||
Latest context per engineer. The local git watcher writes git fields; the vision
|
||||
pipeline may write screen-derived fields. Each writer updates only its own
|
||||
fields so MongoDB upserts merge cleanly.
|
||||
Latest context per engineer. Two writers, one collection — vision pipeline upserts vision fields, git watcher script upserts git fields independently. Hermes reads the merged document for event detection.
|
||||
|
||||
Key fields:
|
||||
```ts
|
||||
{
|
||||
_id: string, // engineerId (stable across sessions)
|
||||
podId: string,
|
||||
name: string, // display name
|
||||
|
||||
- `podId`
|
||||
- `name`
|
||||
- `currentFile`
|
||||
- `inferredTask`
|
||||
- `confidence`
|
||||
- `changedFiles`
|
||||
- `diffStat`
|
||||
- `recentCommit`
|
||||
- `branch`
|
||||
- `visionUpdatedAt`
|
||||
- `gitUpdatedAt`
|
||||
- `updatedAt`
|
||||
// --- Vision fields (written by Hermes via POST /ingest) ---
|
||||
currentFile: string | null, // active file inferred from screen
|
||||
inferredTask: string | null, // what engineer appears to be doing
|
||||
terminalVisible: boolean,
|
||||
recentTerminalOutput: string | null,
|
||||
confidence: number, // Gemini Vision confidence (0–1)
|
||||
visionUpdatedAt: Date,
|
||||
|
||||
Primary use: deterministic dirty/unpushed truth for collision detection and
|
||||
graph discovery.
|
||||
// --- Git fields (written directly by scripts/podman-agent.mjs) ---
|
||||
changedFiles: string[], // files with uncommitted changes (git status)
|
||||
diffStat: string | null, // e.g. "auth/middleware.ts | 24 +++++"
|
||||
recentCommit: string | null, // most recent commit message
|
||||
branch: string | null, // current branch name
|
||||
gitUpdatedAt: Date,
|
||||
|
||||
### `observations`
|
||||
// --- Shared ---
|
||||
updatedAt: Date // most recent write from either source
|
||||
}
|
||||
```
|
||||
|
||||
Structured perception events from consented screen context and agent inference.
|
||||
**Index:** `{ podId: 1, updatedAt: -1 }`
|
||||
|
||||
Key fields:
|
||||
**Two writers, no conflict:** vision upsert uses `$set` on vision fields only; git upsert uses `$set` on git fields only. MongoDB upsert semantics merge them cleanly.
|
||||
|
||||
- `podId`
|
||||
- `engineerId`
|
||||
- `currentFile`
|
||||
- `symbol`
|
||||
- `activity`
|
||||
- `confidence`
|
||||
- `observedAt`
|
||||
|
||||
Primary use: observe/store proof and active editing edges in the Team memory
|
||||
graph.
|
||||
|
||||
### `collisions`
|
||||
|
||||
Predicted coordination risks, with memory enrichment for recall.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`
|
||||
- `podId`
|
||||
- `file`
|
||||
- `symbol`
|
||||
- `engineers`
|
||||
- `severity`
|
||||
- `memorySignature`
|
||||
- `githubState`
|
||||
- `detectedAt`
|
||||
- `memoryText` — short text embedded for recall
|
||||
- `embedding` — vector (Voyage `voyage-4-lite` or Gemini `gemini-embedding-001`)
|
||||
- `embeddingProvider` — `voyage` | `gemini`
|
||||
|
||||
Vector index `collision_embedding` (Atlas Vector Search) powers `$vectorSearch`
|
||||
recall in `backend/src/memory/vectors.ts`. When Atlas vector search is
|
||||
unavailable, recall falls back to app-side cosine, then exact signature/file
|
||||
matching.
|
||||
|
||||
Primary use: collision cards, vector + signature recall, and graph risk paths.
|
||||
|
||||
### `interventions`
|
||||
|
||||
Actions PodMan sent or suggested.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`
|
||||
- `podId`
|
||||
- `collisionId`
|
||||
- `kind`
|
||||
- `message`
|
||||
- `suggestedAction`
|
||||
- `status`
|
||||
- `createdAt`
|
||||
|
||||
Primary use: closing the loop from prediction to a visible card, Hermes message,
|
||||
or urgent voice cue.
|
||||
|
||||
### `outcomes`
|
||||
|
||||
Human or verifier supervision recorded through `POST /api/outcome`.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `interventionId`
|
||||
- `collisionId`
|
||||
- `accepted`
|
||||
- `wasRealCollision`
|
||||
- `recordedAt`
|
||||
|
||||
Primary use: accepted and dismissed outcomes drive exact recall, suppression,
|
||||
and learned graph paths.
|
||||
|
||||
### `team_model`
|
||||
|
||||
Durable per-pod summary memory.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `ownership`
|
||||
- `hotspots`
|
||||
- `graph`
|
||||
- `updatedAt`
|
||||
|
||||
Primary use: stable Team memory, including seeded `graph` snapshots used after
|
||||
live materialization and before demo fallback.
|
||||
|
||||
### `graph_nodes` and `graph_edges`
|
||||
|
||||
Normalized mirror of the Team memory graph for MongoDB traversal.
|
||||
|
||||
Indexes:
|
||||
|
||||
- `graph_nodes`: `{ podId: 1, id: 1 }` unique
|
||||
- `graph_edges`: `{ podId: 1, source: 1 }`
|
||||
|
||||
Primary use: `GET /api/pods/:podId/graph/reach/:id` with `$graphLookup`.
|
||||
|
||||
### `hermes_jobs` and `hermes_job_events`
|
||||
|
||||
Async Hermes task runs delegated from the live conversation agent (see
|
||||
`docs/hermes.md`).
|
||||
|
||||
- `hermes_jobs` — one doc per job (`id` unique; `{ sessionId, status, updatedAt }`
|
||||
index). Fields: `id`, `podId`, `sessionId`, `prompt`, `contextScope`,
|
||||
`riskLevel`, `successCriteria`, `status`, `finalSummary`, timestamps.
|
||||
- `hermes_job_events` — append-only step log (`{ jobId, createdAt }` index):
|
||||
`accepted`, `heartbeat`, `step_started`, `step_output`, `needs_confirmation`,
|
||||
`step_completed`, `completed`, `aborted`, `failed`. Output is redacted +
|
||||
truncated before storage and mirrored to the room over LiveKit.
|
||||
|
||||
Primary use: durable, replayable record of what Hermes did, streamed live to the
|
||||
conversation UI.
|
||||
**Usage:** Hermes reads all documents for a given `podId` after each update to run event detection. Both vision and git context are available in the same document — `changedFiles` provides ground truth, `currentFile` provides screen context.
|
||||
|
||||
---
|
||||
|
||||
## Graph Truth Order
|
||||
### `ownership_map`
|
||||
|
||||
`GET /api/pods/:podId/graph` follows this order:
|
||||
Tracks who works on which files. Built up over the session. **Persists across sessions** — this is the continual learning artifact.
|
||||
|
||||
1. Live graph from real collections.
|
||||
2. Seeded graph from `team_model.graph` and mirrored graph records.
|
||||
3. Demo fallback graph for stage safety.
|
||||
```ts
|
||||
{
|
||||
_id: string, // `${podId}:${file}`
|
||||
podId: string,
|
||||
file: string,
|
||||
primaryOwner: string, // engineerId with most recent activity on this file
|
||||
contributors: string[], // all engineerIds observed on this file
|
||||
observationCount: number, // total frames where this file was seen
|
||||
lastSeenAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
Seeded and fallback graphs are acceptable for demos only when labeled honestly.
|
||||
**Index:** `{ podId: 1, file: 1 }` (unique)
|
||||
|
||||
**Upsert logic:**
|
||||
|
||||
- On each context update where `currentFile` is non-null:
|
||||
- Increment `observationCount`
|
||||
- Update `primaryOwner` to the engineer with the most recent `lastSeenAt` on this file
|
||||
- Add engineerId to `contributors` if not present
|
||||
- Update `lastSeenAt`
|
||||
|
||||
**Continual learning:** Hermes loads this collection on startup for the pod. If history exists, it pre-populates the in-memory ownership cache before the first frame arrives.
|
||||
|
||||
---
|
||||
|
||||
## Demo Proof Path
|
||||
### `events`
|
||||
|
||||
Observe screen/git state -> detect collision -> send intervention -> accept or
|
||||
dismiss outcome -> recall similar event -> show changed graph or changed
|
||||
behavior.
|
||||
Every coordination event detected by Hermes.
|
||||
|
||||
```ts
|
||||
{
|
||||
_id: ObjectId,
|
||||
podId: string,
|
||||
type: 'DEPENDENCY_READY' | 'BLOCKER_DETECTED' | 'DUPLICATE_WORK',
|
||||
involvedEngineers: string[],
|
||||
file: string | null,
|
||||
reason: string, // 1-sentence explanation from Gemini
|
||||
nudgeSent: boolean, // false if suppressed by cooldown
|
||||
detectedAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
**Index:** `{ podId: 1, detectedAt: -1 }`
|
||||
|
||||
---
|
||||
|
||||
## What MongoDB Does Not Store
|
||||
### `nudges`
|
||||
|
||||
- Raw screenshot frames.
|
||||
- Screen recordings.
|
||||
- Secrets or credentials.
|
||||
- Full terminal logs.
|
||||
- Full Gemini response objects beyond extracted fields needed for memory.
|
||||
Every voice nudge sent to the room.
|
||||
|
||||
```ts
|
||||
{
|
||||
_id: ObjectId,
|
||||
podId: string,
|
||||
eventId: ObjectId, // ref to events collection
|
||||
targetEngineers: string[],
|
||||
message: string, // the spoken text
|
||||
sentAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
**Index:** `{ podId: 1, sentAt: -1 }`
|
||||
|
||||
**Cooldown check:** before sending a nudge, Hermes queries this collection for any nudge in the last 3 minutes for the same `podId`. If found, suppresses the new nudge and marks the event as `nudgeSent: false`.
|
||||
|
||||
---
|
||||
|
||||
## Hermes startup sequence
|
||||
|
||||
```
|
||||
1. Connect to Atlas using MONGODB_URI
|
||||
2. Load ownership_map for this podId
|
||||
3. Build in-memory cache: Map<file, { primaryOwner, contributors }>
|
||||
4. Begin accepting /ingest requests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Atlas configuration
|
||||
|
||||
- **Cluster tier:** M0 (free) is sufficient for hackathon scale
|
||||
- **Region:** same as DigitalOcean deployment (e.g. NYC1)
|
||||
- **Auth:** connection string in `MONGODB_URI` env var
|
||||
- **Collections created automatically** on first write (no schema migration needed)
|
||||
|
||||
---
|
||||
|
||||
## What MongoDB does NOT store
|
||||
|
||||
- Raw screenshot frames (too large — frames are processed in-memory by Hermes and discarded)
|
||||
- Full Gemini response objects (only extracted fields are stored)
|
||||
- Session recordings
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: podman-design
|
||||
description: Full system design for PodMan — real-time AI team coordination agent using Gemini Vision, Gemini Live 2.5, LiveKit, and MongoDB Atlas
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
# PodMan — System Design
|
||||
|
||||
## Concept
|
||||
|
||||
PodMan is a real-time AI team coordination agent for software teams. Engineers join a LiveKit room with earbuds. Each engineer's browser PWA captures their screen every 30s and sends it to Hermes (server-side orchestrator on DigitalOcean). Hermes uses Gemini Vision to extract structured context per engineer, detects coordination events, and speaks proactive nudges into the room via Gemini Live 2.5 through LiveKit. MongoDB Atlas stores team state and an ownership map that persists across sessions.
|
||||
|
||||
**Track:** Continual Learning — the ownership map makes PodMan faster and smarter each session with no user configuration.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────── Engineer laptop (Browser PWA) ──────────────────┐
|
||||
│ getDisplayMedia → frame every 30s │
|
||||
│ HTTP POST /ingest → { screenshot, engineerId, podId } │
|
||||
│ LiveKit room joined → receives voice audio from Hermes │
|
||||
│ Earbuds: hears PodMan proactive nudges │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│ POST /ingest
|
||||
▼
|
||||
┌────────────────── HERMES (DigitalOcean) ─────────────────────────┐
|
||||
│ 1. Receive frame → Gemini Vision → EngineerContext │
|
||||
│ 2. Write context to MongoDB (per-user state) │
|
||||
│ 3. Update ownership map (file → engineer) │
|
||||
│ 4. Run event detector over all active contexts │
|
||||
│ 5. If event detected → Gemini generates voice message │
|
||||
│ 6. Push audio into LiveKit room via Gemini Live 2.5 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│ read/write
|
||||
▼
|
||||
MongoDB Atlas
|
||||
(engineer_states, ownership_map,
|
||||
events, nudges)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### PWA (local agent)
|
||||
|
||||
- Joins LiveKit room via existing `joinPod` flow
|
||||
- Captures frame every 30s via `getDisplayMedia`, compresses to JPEG (1280×720, quality 0.7)
|
||||
- POSTs `{ engineerId, podId, screenshotBase64, capturedAt }` to `POST /ingest`
|
||||
- Receives Hermes audio track (automatic via LiveKit)
|
||||
- Listens for data channel messages → renders nudge feed
|
||||
- Two screens: join screen (built), active session screen (to build)
|
||||
|
||||
### Hermes (orchestrator)
|
||||
|
||||
- Express server + LiveKit Agent on DigitalOcean
|
||||
- `POST /ingest`: receives frame, queues for vision
|
||||
- Vision pipeline: Gemini 2.0 Flash → `EngineerContext`
|
||||
- Confidence gate: discard frames with confidence < 0.6
|
||||
- State writer: upsert `engineer_states` + `ownership_map` in MongoDB
|
||||
- Event detector: Gemini text prompt over all active states
|
||||
- Nudge generator: Gemini text → 1–2 sentence spoken message
|
||||
- Voice publisher: Gemini Live 2.5 via LiveKit Agents → audio into room
|
||||
- Data channel: sends structured nudge payload alongside audio
|
||||
- Cooldown: 3 min between nudges per pod
|
||||
|
||||
### Gemini usage
|
||||
|
||||
- **Vision:** `gemini-2.0-flash` — screen → `{ currentFile, inferredTask, terminalVisible, recentTerminalOutput, confidence }`
|
||||
- **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }`
|
||||
- **Nudge generation:** `gemini-2.0-flash` — event → spoken message text
|
||||
- **Voice:** `gemini-live-2.5-flash` via LiveKit Agents — text → streaming audio
|
||||
|
||||
### MongoDB Atlas (4 collections)
|
||||
|
||||
- `engineer_states`: latest context per engineer, upserted each ingest
|
||||
- `ownership_map`: file → primaryOwner + contributors, persists across sessions (continual learning)
|
||||
- `events`: all detected coordination events
|
||||
- `nudges`: all voice nudges sent + cooldown history
|
||||
|
||||
### LiveKit
|
||||
|
||||
- One room per pod
|
||||
- Engineers publish screen track (used client-side for capture — Hermes does not subscribe)
|
||||
- Hermes joins as `podman-hermes`, publishes audio + data channel messages
|
||||
- Engineers receive audio automatically
|
||||
|
||||
---
|
||||
|
||||
## Event types
|
||||
|
||||
| Event | Trigger | Example nudge |
|
||||
| ------------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `BLOCKER_DETECTED` | Engineer stuck (error in terminal, same file N frames) + teammate can help | "Carol, looks like you're waiting on auth. Alice is actively building it — hang tight." |
|
||||
| `DEPENDENCY_READY` | Engineer A completes work that Engineer B was waiting on | "Carol, Bob — Alice just got the auth endpoint running. You're clear to integrate." |
|
||||
| `DUPLICATE_WORK` | 2+ engineers on same file simultaneously | "Alice and Bob — you're both in login.tsx. Coordinate before pushing." |
|
||||
|
||||
---
|
||||
|
||||
## Continual learning story
|
||||
|
||||
The `ownership_map` collection persists across sessions. On Hermes startup:
|
||||
|
||||
1. Load ownership map for this pod from Atlas
|
||||
2. Build in-memory cache: `Map<file, { primaryOwner, contributors }>`
|
||||
3. Event detection uses priors immediately — no ramp-up phase
|
||||
|
||||
**Demo:** Session 1 takes 3 min to first nudge. Session 2 fires in < 30 seconds. That is the learning, visible on stage.
|
||||
|
||||
---
|
||||
|
||||
## Demo flow (3 min)
|
||||
|
||||
1. **(0:00)** Three engineers join pod. PodMan greets by voice.
|
||||
2. **(0:20)** Alice opens `auth/middleware.ts`. Hermes infers ownership.
|
||||
3. **(0:45)** Bob opens `frontend/login.tsx`. Carol's terminal shows connection refused.
|
||||
4. **(1:20) BLOCKER_DETECTED:** "Carol, looks like you're waiting on auth. Alice is actively building it — hang tight."
|
||||
5. **(2:00) DEPENDENCY_READY:** "Carol, Bob — Alice just got the auth endpoint running. You're clear to integrate."
|
||||
6. **(2:20)** Optional: session 2 warm-start comparison.
|
||||
7. **(2:45)** Close: "PodMan — the teammate that sees what Slack can't."
|
||||
|
||||
---
|
||||
|
||||
## Key risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
| -------------------------------------------- | ------------------------------------------------- |
|
||||
| Gemini Vision accuracy | Large font, single editor window, confidence gate |
|
||||
| Gemini Live 2.5 + LiveKit Agents integration | Build together hour 5–7, have TTS fallback |
|
||||
| Frame POST latency | JPEG compression, target < 500ms |
|
||||
| Event false positives | 3-min cooldown, pre-staged demo |
|
||||
| DO deploy failure | Hermes runs local, PWA defaults to localhost:8787 |
|
||||
+1
-7
@@ -3,13 +3,7 @@ import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/node_modules/**',
|
||||
'**/.venv/**',
|
||||
'**/*.config.*',
|
||||
],
|
||||
ignores: ['**/dist/**', '**/build/**', '**/node_modules/**', '**/*.config.*'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
+1
-47
@@ -4,53 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0b0f17" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>PodMan — the teammate that sees what git can’t</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PodMan is an ambient AI teammate that watches your pod's screens in realtime and catches merge collisions before anyone pushes."
|
||||
/>
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="PodMan" />
|
||||
<meta property="og:url" content="https://www.podman.live/" />
|
||||
<meta property="og:title" content="PodMan — the teammate that sees what git can’t" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Ambient AI teammate that watches your pod's screens in realtime and catches merge collisions before anyone pushes."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.podman.live/og.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="PodMan — the teammate that sees what git can’t" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Ambient AI teammate that catches merge collisions before anyone pushes."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://www.podman.live/og.png" />
|
||||
|
||||
<script>
|
||||
// Tear down any service worker we shipped earlier (vite-plugin-pwa).
|
||||
// PodMan runs no PWA in production: we deploy continuously and SW-driven
|
||||
// reloads break the live demo. This unregisters leftover workers and
|
||||
// clears their caches on every load so previously-affected browsers
|
||||
// self-heal — no manual DevTools needed. It does NOT reload the page and
|
||||
// is a no-op once nothing is registered.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.then((regs) => regs.forEach((r) => r.unregister()))
|
||||
.catch(() => {});
|
||||
if (window.caches && caches.keys) {
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => keys.forEach((k) => caches.delete(k)))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<title>PodMan</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0b0f17" />
|
||||
<circle cx="25" cy="6.6" r="2.4" fill="#34d399" />
|
||||
<text x="14.5" y="24.5" text-anchor="middle"
|
||||
font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
font-weight="700" font-size="24" fill="#34d399">P</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 416 B |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
@@ -1,16 +0,0 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="630" fill="#0b0f17" />
|
||||
<g fill="none" stroke="#34d399" opacity="0.12">
|
||||
<circle cx="1050" cy="140" r="70" stroke-width="6" />
|
||||
<circle cx="1050" cy="140" r="140" stroke-width="5" />
|
||||
<circle cx="1050" cy="140" r="215" stroke-width="4" />
|
||||
</g>
|
||||
<rect x="90" y="150" width="104" height="104" rx="24" fill="#0f1629" stroke="#1f2a3a" stroke-width="2" />
|
||||
<circle cx="176" cy="170" r="8.5" fill="#34d399" />
|
||||
<text x="142" y="228" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-weight="bold" font-size="66" fill="#34d399">P</text>
|
||||
<text x="222" y="232" font-family="Helvetica, Arial, sans-serif" font-weight="bold" font-size="68" fill="#e7eaf0">PodMan</text>
|
||||
<text x="92" y="356" font-family="Helvetica, Arial, sans-serif" font-weight="bold" font-size="46" fill="#e7eaf0">The teammate that sees what git can’t.</text>
|
||||
<text x="92" y="416" font-family="Helvetica, Arial, sans-serif" font-size="34" fill="#9aa4b2">Catches merge collisions before anyone pushes.</text>
|
||||
<rect x="92" y="500" width="13" height="13" rx="3" fill="#34d399" />
|
||||
<text x="118" y="512" font-family="Helvetica, Arial, sans-serif" font-weight="bold" font-size="30" fill="#34d399">podman.live</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
+7
-64
@@ -42,26 +42,6 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
const SESSION_KEY = 'podman.session';
|
||||
const fmt = new Intl.NumberFormat('en', { notation: 'compact' });
|
||||
|
||||
function pathPodId(): string | null {
|
||||
const [segment] = window.location.pathname.split('/').filter(Boolean);
|
||||
return segment ? decodeURIComponent(segment) : null;
|
||||
}
|
||||
|
||||
function setPodPath(podId: string, replace = false): void {
|
||||
const next = `/${encodeURIComponent(podId)}`;
|
||||
if (window.location.pathname === next) return;
|
||||
window.history[replace ? 'replaceState' : 'pushState']({}, '', next);
|
||||
}
|
||||
|
||||
function setHomePath(): void {
|
||||
if (window.location.pathname === '/') return;
|
||||
window.history.pushState({}, '', '/');
|
||||
}
|
||||
|
||||
function replacePath(path: string): void {
|
||||
window.history.replaceState({}, '', path || '/');
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [pods, setPods] = useState<Pod[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -105,20 +85,13 @@ export default function App() {
|
||||
return n;
|
||||
});
|
||||
|
||||
async function connectToPod(podId: string, who: string, replaceRoute = false) {
|
||||
const previousPath = window.location.pathname;
|
||||
setPodPath(podId, replaceRoute);
|
||||
try {
|
||||
async function connectToPod(podId: string, who: string) {
|
||||
const result = await joinPod(podId, who, who);
|
||||
setRoom(result.room);
|
||||
setDevMode(result.mode === 'dev');
|
||||
setMember(who);
|
||||
setJoinedPodId(podId);
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ podId, member: who }));
|
||||
} catch (e) {
|
||||
replacePath(previousPath);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -151,14 +124,8 @@ export default function App() {
|
||||
}, [joinedPodId]);
|
||||
|
||||
useEffect(() => {
|
||||
const routedPodId = pathPodId();
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) {
|
||||
if (routedPodId) {
|
||||
setError(`Enter your name to join ${routedPodId}.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!raw) return;
|
||||
let saved: { podId: string; member: string };
|
||||
try {
|
||||
saved = JSON.parse(raw);
|
||||
@@ -166,11 +133,10 @@ export default function App() {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const podId = routedPodId ?? saved.podId;
|
||||
setRestoring(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await connectToPod(podId, saved.member, !!routedPodId);
|
||||
await connectToPod(saved.podId, saved.member);
|
||||
} catch {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
} finally {
|
||||
@@ -179,30 +145,6 @@ export default function App() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
const routedPodId = pathPodId();
|
||||
if (!routedPodId) {
|
||||
room?.disconnect();
|
||||
setRoom(null);
|
||||
setJoinedPodId(null);
|
||||
return;
|
||||
}
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const saved = JSON.parse(raw) as { member: string };
|
||||
if (saved.member && routedPodId !== joinedPodId) {
|
||||
void connectToPod(routedPodId, saved.member, true);
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, [joinedPodId, room]);
|
||||
|
||||
async function run(key: string, fn: () => Promise<void>) {
|
||||
startPending(key);
|
||||
setError(null);
|
||||
@@ -237,7 +179,6 @@ export default function App() {
|
||||
run(id, async () => {
|
||||
await api.deletePod(id);
|
||||
setPods((cur) => cur.filter((x) => x.id !== id));
|
||||
if (pathPodId() === id) setHomePath();
|
||||
});
|
||||
const handleAddMember = (id: string, name: string) =>
|
||||
run(id, async () => upsert(await api.addMember(id, name)));
|
||||
@@ -274,7 +215,6 @@ export default function App() {
|
||||
setRoom(null);
|
||||
setJoinedPodId(null);
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
setHomePath();
|
||||
void refresh();
|
||||
}
|
||||
|
||||
@@ -328,6 +268,10 @@ export default function App() {
|
||||
<ShieldCheckIcon data-icon="inline-start" />
|
||||
Privacy-limited
|
||||
</Badge>
|
||||
<Button variant="outline" onClick={() => setGraphPodId(pods[0]?.id ?? 'demo-pod')}>
|
||||
<BrainCircuitIcon data-icon="inline-start" />
|
||||
Team memory
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Refresh
|
||||
@@ -398,7 +342,6 @@ export default function App() {
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onOpenGraph={setGraphPodId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,95 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { PodGraph } from '@podman/shared';
|
||||
import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { GraphCanvas } from './graph/GraphCanvas.js';
|
||||
import { MetricsRail } from './graph/MetricsRail.js';
|
||||
import { LearningLoop } from './graph/LearningLoop.js';
|
||||
import { ActivityStream } from './graph/ActivityStream.js';
|
||||
import { SelectedNodePanel } from './graph/SelectedNodePanel.js';
|
||||
import { highlightFor, flowNarrative, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js';
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
||||
import { fetchPodGraph } from '../lib/graph.js';
|
||||
|
||||
const POLL_MS = 5000;
|
||||
type Mode = 'risk' | 'learn' | 'all';
|
||||
|
||||
// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
|
||||
// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
|
||||
const GRAPH_CSS = `
|
||||
.pm-node{cursor:grab;transition:opacity .25s ease}
|
||||
.pm-node:active{cursor:grabbing}
|
||||
.pm-edge{transition:opacity .25s ease}
|
||||
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none;
|
||||
paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round}
|
||||
.pm-dim{opacity:.14}
|
||||
.pm-enter{animation:pm-fade .45s ease}
|
||||
.pm-dash{animation:pm-flow 1s linear infinite}
|
||||
.pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
|
||||
@keyframes pm-fade{from{opacity:0}to{opacity:1}}
|
||||
@keyframes pm-flow{to{stroke-dashoffset:-26}}
|
||||
@keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pm-enter,.pm-dash,.pm-pulse{animation:none}
|
||||
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||
engineer: '#3B5BFF',
|
||||
file: '#ECE7DA',
|
||||
feature: '#F6C445',
|
||||
collision: '#E2403A',
|
||||
intervention: '#8b6cff',
|
||||
};
|
||||
|
||||
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
||||
owns: { c: '#3B5BFF', w: 2.6 },
|
||||
editing: { c: '#ECE7DA', w: 2 },
|
||||
touches: { c: '#5d5d66', w: 1.6 },
|
||||
collides: { c: '#E2403A', w: 3.2 },
|
||||
warns: { c: '#F6C445', w: 3.2 },
|
||||
learned_from: { c: '#8b6cff', w: 2.4, dash: true },
|
||||
};
|
||||
|
||||
function NodeShape({ node }: { node: PodGraphNode }) {
|
||||
const c = KIND_COLOR[node.kind];
|
||||
const { x, y } = node;
|
||||
switch (node.kind) {
|
||||
case 'engineer':
|
||||
return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />;
|
||||
case 'file':
|
||||
return (
|
||||
<rect
|
||||
x={x - 15}
|
||||
y={y - 15}
|
||||
width={30}
|
||||
height={30}
|
||||
fill="none"
|
||||
stroke={c}
|
||||
strokeWidth={2.6}
|
||||
/>
|
||||
);
|
||||
case 'feature':
|
||||
return <circle cx={x} cy={y} r={17} fill={c} />;
|
||||
case 'collision':
|
||||
return <polygon points={`${x},${y - 18} ${x + 17},${y + 13} ${x - 17},${y + 13}`} fill={c} />;
|
||||
case 'intervention':
|
||||
return (
|
||||
<polygon points={`${x},${y - 18} ${x + 18},${y} ${x},${y + 18} ${x - 18},${y}`} fill={c} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
interface Highlight {
|
||||
nodes: Set<string>;
|
||||
edges: Set<string>;
|
||||
}
|
||||
|
||||
function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
||||
if (selected) {
|
||||
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
||||
return {
|
||||
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
if (mode === 'all') return null;
|
||||
const kinds: PodGraphEdge['kind'][] =
|
||||
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
||||
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
||||
const es = graph.edges.filter(
|
||||
(e) =>
|
||||
kinds.includes(e.kind) ||
|
||||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
||||
);
|
||||
return {
|
||||
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
|
||||
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||
{ label: 'engineer', swatch: { background: '#3B5BFF' } },
|
||||
{ label: 'file', swatch: { border: '2px solid #ECE7DA' } },
|
||||
{ label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } },
|
||||
{
|
||||
label: 'collision',
|
||||
swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' },
|
||||
},
|
||||
{ label: 'intervention', swatch: { background: '#8b6cff', transform: 'rotate(45deg)' } },
|
||||
];
|
||||
|
||||
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
||||
const [graph, setGraph] = useState<PodGraph | null>(null);
|
||||
@@ -40,67 +99,29 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let nudge: number | null = null;
|
||||
setGraph(null);
|
||||
setError(null);
|
||||
setSelected(null);
|
||||
|
||||
const load = () =>
|
||||
fetchPodGraph(podId)
|
||||
.then((g) => {
|
||||
if (alive) {
|
||||
setGraph(g);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
|
||||
void load();
|
||||
const poll = window.setInterval(() => void load(), POLL_MS);
|
||||
|
||||
// Best-effort realtime nudge: refetch (debounced) when the agent broadcasts.
|
||||
let ws: WebSocket | null = null;
|
||||
try {
|
||||
ws = new WebSocket(backendEventsUrl());
|
||||
ws.onmessage = () => {
|
||||
if (nudge != null) return;
|
||||
nudge = window.setTimeout(() => {
|
||||
nudge = null;
|
||||
void load();
|
||||
}, 800);
|
||||
};
|
||||
} catch {
|
||||
/* event bus is optional */
|
||||
}
|
||||
|
||||
.then((g) => alive && setGraph(g))
|
||||
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(poll);
|
||||
if (nudge != null) window.clearTimeout(nudge);
|
||||
ws?.close();
|
||||
};
|
||||
}, [podId]);
|
||||
|
||||
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
|
||||
// A selected node can vanish across a poll/WS refresh. Ignore a stale id so the
|
||||
// graph doesn't dim entirely (highlightFor would otherwise light only a dead id).
|
||||
const liveSelected = selected && nodeById.has(selected) ? selected : null;
|
||||
useEffect(() => {
|
||||
if (selected && graph && !nodeById.has(selected)) setSelected(null);
|
||||
}, [graph, nodeById, selected]);
|
||||
|
||||
const highlight = useMemo(
|
||||
() => (graph ? highlightFor(graph, mode, liveSelected) : null),
|
||||
[graph, mode, liveSelected],
|
||||
const hi = useMemo(
|
||||
() => (graph ? highlightFor(graph, mode, selected) : null),
|
||||
[graph, mode, selected],
|
||||
);
|
||||
const sel = liveSelected ? nodeById.get(liveSelected) : undefined;
|
||||
const relCount = liveSelected
|
||||
? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected)
|
||||
.length
|
||||
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
|
||||
const sel = selected ? nodeById.get(selected) : undefined;
|
||||
const relCount = selected
|
||||
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
|
||||
: 0;
|
||||
const flow = graph && liveSelected ? flowNarrative(graph, liveSelected) : '';
|
||||
|
||||
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
|
||||
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
|
||||
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
|
||||
|
||||
function pick(next: Mode) {
|
||||
setMode(next);
|
||||
@@ -108,107 +129,200 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<style>{GRAPH_CSS}</style>
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-heading text-base font-medium">Team memory</h2>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
|
||||
Live
|
||||
</span>
|
||||
<div className="pm-graph">
|
||||
<style>{`
|
||||
.pm-graph{--bg:#0c0c0e;--panel:#141417;--line:#2a2a31;--paper:#ECE7DA;--mut:#8d897e;--red:#E2403A;--yel:#F6C445;--vio:#8b6cff;
|
||||
font-family:'Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden}
|
||||
.pm-graph *{box-sizing:border-box}
|
||||
.pm-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:3px solid var(--paper)}
|
||||
.pm-ttl{font-weight:800;font-size:18px;letter-spacing:.14em;text-transform:uppercase;font-family:Archivo,'Space Grotesk',sans-serif}
|
||||
.pm-sub{font-size:10px;letter-spacing:.3em;color:var(--mut);text-transform:uppercase;margin-top:5px}
|
||||
.pm-x{background:transparent;border:1px solid var(--line);color:var(--paper);font-size:11px;letter-spacing:.1em;text-transform:uppercase;padding:7px 12px;border-radius:2px;cursor:pointer}
|
||||
.pm-x:hover{border-color:var(--paper)}
|
||||
.pm-bar{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||
.pm-btn{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--paper);background:transparent;border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:2px}
|
||||
.pm-btn:hover{border-color:var(--paper)}
|
||||
.pm-btn.on{background:var(--red);border-color:var(--red);color:#fff}
|
||||
.pm-grid{display:grid;grid-template-columns:180px 1fr 240px}
|
||||
.pm-col{padding:14px}
|
||||
.pm-railR{border-left:1px solid var(--line);background:#17171b}
|
||||
.pm-st{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin:2px 0 12px}
|
||||
.pm-kpi{border:1px solid var(--line);border-left:5px solid var(--vio);padding:10px 11px;margin-bottom:10px}
|
||||
.pm-num{font-weight:800;font-size:26px;line-height:.9;font-variant-numeric:tabular-nums;font-family:Archivo,sans-serif}
|
||||
.pm-klab{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut);margin-top:6px}
|
||||
.pm-kdet{font-size:10px;color:var(--mut);margin-top:5px;line-height:1.4}
|
||||
.pm-canvas{background:var(--panel);border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:472px}
|
||||
.pm-canvas svg{width:100%;height:auto;display:block}
|
||||
.pm-node{cursor:pointer}
|
||||
.pm-lbl{font-weight:500;font-size:11px;letter-spacing:.06em;fill:var(--paper);text-transform:uppercase}
|
||||
.pm-dim{opacity:.12;transition:opacity .25s}
|
||||
.pm-dkind{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}
|
||||
.pm-dname{font-weight:800;font-size:20px;margin:5px 0 8px;font-family:Archivo,sans-serif}
|
||||
.pm-drow{display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--line);color:var(--mut)}
|
||||
.pm-drow b{color:var(--paper);font-weight:500}
|
||||
.pm-note{font-size:12px;color:var(--mut);line-height:1.5;margin-top:10px}
|
||||
.pm-legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 16px;border-top:1px solid var(--line);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut)}
|
||||
.pm-lg{display:flex;align-items:center;gap:6px}
|
||||
.pm-sw{width:13px;height:13px;display:inline-block}
|
||||
@media(max-width:760px){.pm-grid{grid-template-columns:1fr}.pm-railR{border-left:0;border-top:1px solid var(--line)}.pm-canvas{border:0;border-top:1px solid var(--line)}}
|
||||
`}</style>
|
||||
|
||||
<div className="pm-hd">
|
||||
<div>
|
||||
<div className="pm-ttl">Team memory</div>
|
||||
<div className="pm-sub">What PodMan learned · {podId}</div>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
What PodMan learned · {podId}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<button className="pm-x" onClick={onClose}>
|
||||
← Pods
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(v) => v && pick(v as Mode)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<div className="pm-bar">
|
||||
<button
|
||||
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('risk')}
|
||||
>
|
||||
<ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
|
||||
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
|
||||
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
|
||||
Drag to rearrange · double-click to release · click to inspect
|
||||
</span>
|
||||
Risk path
|
||||
</button>
|
||||
<button
|
||||
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('learn')}
|
||||
>
|
||||
Learning edges
|
||||
</button>
|
||||
<button
|
||||
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('all')}
|
||||
>
|
||||
Whole graph
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
|
||||
{error && (
|
||||
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p>
|
||||
)}
|
||||
{!graph && !error && (
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">Loading graph…</p>
|
||||
<p style={{ padding: '16px', color: '#8d897e', fontSize: 13 }}>Loading graph…</p>
|
||||
)}
|
||||
|
||||
{graph && (
|
||||
<>
|
||||
{/* Metrics · graph · learning loop */}
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
|
||||
<MetricsRail metrics={graph.metrics} />
|
||||
|
||||
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
|
||||
<GraphCanvas
|
||||
graph={graph}
|
||||
highlight={highlight}
|
||||
selected={liveSelected}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
<div className="pm-grid">
|
||||
<div className="pm-col">
|
||||
<div className="pm-st">Workflow metrics</div>
|
||||
{graph.metrics.map((m) => (
|
||||
<div className="pm-kpi" key={m.label}>
|
||||
<div className="pm-num">{m.value}</div>
|
||||
<div className="pm-klab">{m.label}</div>
|
||||
<div className="pm-kdet">{m.detail}</div>
|
||||
</div>
|
||||
|
||||
{graph.loop?.steps?.length ? <LearningLoop loop={graph.loop} /> : <div />}
|
||||
</div>
|
||||
|
||||
{/* Activity stream · selected node */}
|
||||
<div className="grid gap-4 border-t px-4 py-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<ActivityStream events={graph.activity ?? []} />
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/40 p-4">
|
||||
<SelectedNodePanel node={sel} relCount={relCount} flow={flow} mode={mode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{NODE_LEGEND.map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5">
|
||||
<span className="inline-block size-3" style={l.swatch} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
|
||||
{EDGE_LEGEND.map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-[3px] w-3.5"
|
||||
style={
|
||||
l.dash
|
||||
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
|
||||
: { background: l.color }
|
||||
</div>
|
||||
|
||||
<div className="pm-canvas">
|
||||
<svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph">
|
||||
{graph.edges.map((e) => {
|
||||
const a = nodeById.get(e.source);
|
||||
const b = nodeById.get(e.target);
|
||||
if (!a || !b) return null;
|
||||
const s = EDGE[e.kind];
|
||||
return (
|
||||
<line
|
||||
key={e.id}
|
||||
className={dimEdge(e.id) ? 'pm-dim' : undefined}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke={s.c}
|
||||
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
|
||||
strokeDasharray={s.dash ? '7 6' : undefined}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.map((n) => (
|
||||
<g
|
||||
key={n.id}
|
||||
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${n.kind}: ${n.label}`}
|
||||
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
setSelected((cur) => (cur === n.id ? null : n.id));
|
||||
}
|
||||
/>
|
||||
{l.label}
|
||||
</span>
|
||||
}}
|
||||
>
|
||||
<NodeShape node={n} />
|
||||
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
|
||||
{n.label.toUpperCase()}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="pm-col pm-railR">
|
||||
{sel ? (
|
||||
<>
|
||||
<div className="pm-dkind">{sel.kind}</div>
|
||||
<div className="pm-dname">{sel.label}</div>
|
||||
<div className="pm-drow">
|
||||
<span>Status</span>
|
||||
<b
|
||||
style={{
|
||||
color:
|
||||
sel.status === 'risk'
|
||||
? '#E2403A'
|
||||
: sel.status === 'learned'
|
||||
? '#b7a4ff'
|
||||
: '#ECE7DA',
|
||||
}}
|
||||
>
|
||||
{sel.status}
|
||||
</b>
|
||||
</div>
|
||||
<div className="pm-drow">
|
||||
<span>Relationships</span>
|
||||
<b>{relCount}</b>
|
||||
</div>
|
||||
<div className="pm-note">{sel.summary}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="pm-dkind">Continual learning</div>
|
||||
<div className="pm-dname">It learned</div>
|
||||
<div className="pm-note">
|
||||
Violet <b style={{ color: '#b7a4ff' }}>learned_from</b> edges are ownership
|
||||
PodMan retained from accepted interventions — the graph gets sharper every
|
||||
session. Click any node to trace its relationships.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pm-legend">
|
||||
{LEGEND.map((l) => (
|
||||
<span className="pm-lg" key={l.label}>
|
||||
<span className="pm-sw" style={l.swatch} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="pm-lg">
|
||||
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
|
||||
collides
|
||||
</span>
|
||||
<span className="pm-lg">
|
||||
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
|
||||
learned_from
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
MoreHorizontalIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
VideoIcon,
|
||||
} from 'lucide-react';
|
||||
import { MoreHorizontalIcon, PlusIcon, Trash2Icon, UserRoundIcon, VideoIcon } from 'lucide-react';
|
||||
import type { Pod, PodInput } from '@podman/shared';
|
||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -15,6 +9,7 @@ import {
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
@@ -41,13 +36,12 @@ export function PodCard({
|
||||
pod,
|
||||
busy,
|
||||
presence,
|
||||
onJoin: _onJoin,
|
||||
onJoin,
|
||||
onAddAndJoin,
|
||||
onAddMember: _onAddMember,
|
||||
onAddMember,
|
||||
onRemoveMember: _onRemoveMember,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onOpenGraph,
|
||||
}: {
|
||||
pod: Pod;
|
||||
busy: boolean;
|
||||
@@ -58,7 +52,6 @@ export function PodCard({
|
||||
onRemoveMember: (id: string, name: string) => void;
|
||||
onUpdate: (id: string, patch: PodInput) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onOpenGraph: (id: string) => void;
|
||||
}) {
|
||||
const [newMember, setNewMember] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -70,6 +63,7 @@ export function PodCard({
|
||||
|
||||
const inRoom = (name: string) => presence.some((p) => p.toLowerCase() === name.toLowerCase());
|
||||
const active = presence.length > 0;
|
||||
const primaryMember = pod.members[0] ?? '';
|
||||
|
||||
function saveEdit() {
|
||||
onUpdate(pod.id, {
|
||||
@@ -80,12 +74,11 @@ export function PodCard({
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
// One action: enter your name, hit Join → added to the roster (deduped
|
||||
// server-side) and connected to the room in a single step.
|
||||
function join() {
|
||||
function submitMember(join: boolean) {
|
||||
const name = newMember.trim();
|
||||
if (!name) return;
|
||||
onAddAndJoin(pod, name);
|
||||
if (join) onAddAndJoin(pod, name);
|
||||
else onAddMember(pod.id, name);
|
||||
setNewMember('');
|
||||
}
|
||||
|
||||
@@ -107,10 +100,6 @@ export function PodCard({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={() => onOpenGraph(pod.id)}>
|
||||
<BrainCircuitIcon />
|
||||
Team memory
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setEditing(true)}>Edit pod</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
@@ -163,16 +152,39 @@ export function PodCard({
|
||||
value={newMember}
|
||||
onChange={(e) => setNewMember(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') join();
|
||||
if (e.key === 'Enter') submitMember(true);
|
||||
}}
|
||||
/>
|
||||
<Button className="min-w-24" onClick={join} disabled={busy || !newMember.trim()}>
|
||||
<VideoIcon data-icon="inline-start" />
|
||||
Join
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => submitMember(false)}
|
||||
disabled={busy || !newMember.trim()}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="sr-only">Add member</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => primaryMember && onJoin(pod, primaryMember)}
|
||||
disabled={busy || !primaryMember}
|
||||
>
|
||||
<VideoIcon data-icon="inline-start" />
|
||||
Join
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-28"
|
||||
onClick={() => submitMember(true)}
|
||||
disabled={busy || !newMember.trim()}
|
||||
>
|
||||
Add and join
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Dialog open={editing} onOpenChange={setEditing}>
|
||||
|
||||
+55
-1303
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
import type { PodGraphActivity } from '@podman/shared';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ACTIVITY_TAG } from './encoding.js';
|
||||
|
||||
const fmtTime = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
|
||||
function timeOf(at: string): string {
|
||||
const t = new Date(at).getTime();
|
||||
return Number.isFinite(t) ? fmtTime.format(t) : '--:--';
|
||||
}
|
||||
|
||||
export function ActivityStream({ events }: { events: PodGraphActivity[] }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Activity stream
|
||||
</p>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[176px] pr-3">
|
||||
<ul className="space-y-1.5">
|
||||
{events.map((e) => {
|
||||
const tag = ACTIVITY_TAG[e.kind];
|
||||
return (
|
||||
<li key={e.id} className="pm-enter flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{timeOf(e.at)}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide"
|
||||
style={{ color: tag.color, background: `${tag.color}1a` }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 leading-snug">
|
||||
<span className="text-foreground/90">{e.title}</span>
|
||||
{e.detail && (
|
||||
<span className="block text-xs leading-snug text-muted-foreground">
|
||||
{e.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type PointerEvent,
|
||||
} from 'react';
|
||||
import type { PodGraph, PodGraphNode, PodGraphNodeKind } from '@podman/shared';
|
||||
import { ForceSim } from './forceSim.js';
|
||||
import { EDGE, KIND_COLOR, nodeRadius, type Highlight } from './encoding.js';
|
||||
|
||||
const W = 760;
|
||||
const H = 480;
|
||||
const MARGIN = 48;
|
||||
|
||||
/** Map the server's 0..720×0..472 layout into the canvas as a seed position. */
|
||||
function mapX(x: number): number {
|
||||
return MARGIN + (Math.max(0, Math.min(720, x)) / 720) * (W - 2 * MARGIN);
|
||||
}
|
||||
function mapY(y: number): number {
|
||||
return MARGIN + (Math.max(0, Math.min(472, y)) / 472) * (H - 2 * MARGIN);
|
||||
}
|
||||
|
||||
function linkDistance(kind: string): number {
|
||||
if (kind === 'collides') return 122;
|
||||
if (kind === 'owns') return 104;
|
||||
if (kind === 'learned_from') return 150;
|
||||
return 134;
|
||||
}
|
||||
function linkStrength(strength: number): number {
|
||||
return Math.max(0.18, Math.min(0.9, strength));
|
||||
}
|
||||
|
||||
/** Stable +/- so parallel edges between the same pair fan to opposite sides. */
|
||||
function curveSign(id: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h + id.charCodeAt(i)) % 2;
|
||||
return h === 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
function edgePath(ax: number, ay: number, bx: number, by: number, id: string): string {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
const off = curveSign(id) * len * 0.13;
|
||||
const cx = (ax + bx) / 2 + nx * off;
|
||||
const cy = (ay + by) / 2 + ny * off;
|
||||
return `M${ax.toFixed(1)},${ay.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${bx.toFixed(1)},${by.toFixed(1)}`;
|
||||
}
|
||||
|
||||
function nodeShape(
|
||||
kind: PodGraphNodeKind,
|
||||
color: string,
|
||||
cx: number,
|
||||
cy: number,
|
||||
r: number,
|
||||
): ReactElement | null {
|
||||
switch (kind) {
|
||||
case 'engineer':
|
||||
return <rect x={cx - r} y={cy - r} width={r * 2} height={r * 2} rx={4} fill={color} />;
|
||||
case 'file':
|
||||
return (
|
||||
<rect
|
||||
x={cx - r}
|
||||
y={cy - r}
|
||||
width={r * 2}
|
||||
height={r * 2}
|
||||
rx={4}
|
||||
fill="var(--card)"
|
||||
stroke={color}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
);
|
||||
case 'feature':
|
||||
return <circle cx={cx} cy={cy} r={r} fill={color} />;
|
||||
case 'collision':
|
||||
return (
|
||||
<polygon
|
||||
points={`${cx},${cy - r} ${cx + r},${cy + r * 0.78} ${cx - r},${cy + r * 0.78}`}
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case 'intervention':
|
||||
return (
|
||||
<polygon points={`${cx},${cy - r} ${cx + r},${cy} ${cx},${cy + r} ${cx - r},${cy}`} fill={color} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showLabel(
|
||||
node: PodGraphNode,
|
||||
dimmed: boolean,
|
||||
hovered: boolean,
|
||||
selected: boolean,
|
||||
): boolean {
|
||||
if (hovered || selected) return true;
|
||||
if (dimmed) return false;
|
||||
// Collisions cluster and often share a filename — reveal on hover/select only.
|
||||
if (node.kind === 'collision') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
pointerId: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
export function GraphCanvas({
|
||||
graph,
|
||||
highlight,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
graph: PodGraph;
|
||||
highlight: Highlight | null;
|
||||
selected: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const simRef = useRef<ForceSim | null>(null);
|
||||
if (!simRef.current) simRef.current = new ForceSim(W, H);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const sigRef = useRef<string>('');
|
||||
const [, setFrame] = useState(0);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
const loop = useCallback(() => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
const working = sim.tick();
|
||||
setFrame((f) => (f + 1) % 1_000_000);
|
||||
if (working || dragRef.current) {
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
} else {
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureRaf = useCallback(() => {
|
||||
if (rafRef.current == null) rafRef.current = requestAnimationFrame(loop);
|
||||
}, [loop]);
|
||||
|
||||
// Rebuild the simulation when the graph data changes, preserving positions.
|
||||
useEffect(() => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
const nodeInputs = graph.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
radius: nodeRadius(n),
|
||||
seedX: mapX(n.x),
|
||||
seedY: mapY(n.y),
|
||||
}));
|
||||
const linkInputs = graph.edges.map((e) => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
distance: linkDistance(e.kind),
|
||||
strength: linkStrength(e.strength),
|
||||
}));
|
||||
const sig =
|
||||
nodeInputs
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(',') +
|
||||
'|' +
|
||||
graph.edges
|
||||
.map((e) => e.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
const first = sigRef.current === '';
|
||||
const changed = sig !== sigRef.current;
|
||||
sim.setData(nodeInputs, linkInputs);
|
||||
if (changed) {
|
||||
sigRef.current = sig;
|
||||
sim.reheat(first ? 1 : 0.5);
|
||||
}
|
||||
// Always (re)arm the loop — ensureRaf is idempotent via the rafRef==null
|
||||
// guard. This must NOT be gated on `changed`: under React StrictMode the
|
||||
// dev double-invoke cancels the frame between effect passes, and pass 2 sees
|
||||
// an unchanged sig, so a `changed`-gated start would leave the sim frozen.
|
||||
if (sim.nodes.length) ensureRaf();
|
||||
}, [graph, ensureRaf]);
|
||||
|
||||
// Clean up the animation frame on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toSvg(evt: PointerEvent): { x: number; y: number } {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return { x: 0, y: 0 };
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: 0, y: 0 };
|
||||
const p = new DOMPoint(evt.clientX, evt.clientY).matrixTransform(ctm.inverse());
|
||||
return { x: p.x, y: p.y };
|
||||
}
|
||||
|
||||
function onNodePointerDown(evt: PointerEvent, id: string) {
|
||||
evt.stopPropagation();
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
(evt.currentTarget as Element).setPointerCapture(evt.pointerId);
|
||||
dragRef.current = { id, pointerId: evt.pointerId, moved: false };
|
||||
const { x, y } = toSvg(evt);
|
||||
sim.pin(id, x, y);
|
||||
sim.setActive(true);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodePointerMove(evt: PointerEvent) {
|
||||
const drag = dragRef.current;
|
||||
const sim = simRef.current;
|
||||
if (!drag || !sim || drag.pointerId !== evt.pointerId) return;
|
||||
drag.moved = true;
|
||||
const { x, y } = toSvg(evt);
|
||||
sim.pin(drag.id, x, y);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodePointerUp(evt: PointerEvent, id: string) {
|
||||
const drag = dragRef.current;
|
||||
const sim = simRef.current;
|
||||
if (!drag || !sim || drag.pointerId !== evt.pointerId) return;
|
||||
(evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId);
|
||||
sim.setActive(false);
|
||||
// A press that never moved is a click — toggle selection (node stays pinned).
|
||||
if (!drag.moved) onSelect(selected === id ? null : id);
|
||||
dragRef.current = null;
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodeDoubleClick(id: string) {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
sim.unpin(id);
|
||||
sim.reheat(0.5);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
const sim = simRef.current;
|
||||
const dimNode = (id: string) => (highlight ? !highlight.nodes.has(id) : false);
|
||||
const dimEdge = (id: string) => (highlight ? !highlight.edges.has(id) : false);
|
||||
const hotEdge = (id: string) => (highlight ? highlight.edges.has(id) : false);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label="PodMan team-memory graph — drag nodes to rearrange"
|
||||
className="block h-full max-h-[560px] w-full touch-none select-none"
|
||||
onPointerDown={() => onSelect(null)}
|
||||
>
|
||||
<g>
|
||||
{graph.edges.map((e) => {
|
||||
const a = sim?.get(e.source);
|
||||
const b = sim?.get(e.target);
|
||||
if (!a || !b) return null;
|
||||
const style = EDGE[e.kind];
|
||||
const hot = hotEdge(e.id);
|
||||
return (
|
||||
<path
|
||||
key={e.id}
|
||||
className={`pm-edge pm-enter ${dimEdge(e.id) ? 'pm-dim' : ''} ${e.kind === 'learned_from' ? 'pm-dash' : ''}`}
|
||||
d={edgePath(a.x, a.y, b.x, b.y, e.id)}
|
||||
fill="none"
|
||||
stroke={style.c}
|
||||
strokeWidth={hot ? style.w + 1.4 : style.w}
|
||||
strokeOpacity={hot ? 1 : 0.78}
|
||||
strokeDasharray={style.dash ? '7 6' : undefined}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
<g>
|
||||
{graph.nodes.map((n) => {
|
||||
const p = sim?.get(n.id);
|
||||
if (!p) return null;
|
||||
const r = p.radius;
|
||||
const dimmed = dimNode(n.id);
|
||||
const isHover = hovered === n.id;
|
||||
const isSel = selected === n.id;
|
||||
const color = KIND_COLOR[n.kind];
|
||||
const pinned = p.fx != null;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
className={`pm-node pm-enter ${dimmed ? 'pm-dim' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${n.kind}: ${n.label}`}
|
||||
onPointerDown={(ev) => onNodePointerDown(ev, n.id)}
|
||||
onPointerMove={onNodePointerMove}
|
||||
onPointerUp={(ev) => onNodePointerUp(ev, n.id)}
|
||||
onDoubleClick={() => onNodeDoubleClick(n.id)}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
onMouseLeave={() => setHovered((cur) => (cur === n.id ? null : cur))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
onSelect(selected === n.id ? null : n.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(isSel || isHover) && (
|
||||
<circle cx={p.x} cy={p.y} r={r + 7} fill="none" stroke={color} strokeWidth={2} strokeOpacity={0.5} />
|
||||
)}
|
||||
{pinned && !isSel && !isHover && (
|
||||
<circle cx={p.x} cy={p.y} r={r + 4} fill="none" stroke={color} strokeWidth={1} strokeDasharray="2 3" strokeOpacity={0.4} />
|
||||
)}
|
||||
{nodeShape(n.kind, color, p.x, p.y, r)}
|
||||
{showLabel(n, dimmed, isHover, isSel) && (
|
||||
<text className="pm-lbl" x={p.x} y={p.y + r + 13} textAnchor="middle">
|
||||
{n.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { PodLearningLoop } from '@podman/shared';
|
||||
import { BLUE } from './encoding.js';
|
||||
|
||||
/**
|
||||
* The continual-learning loop rail: observe → store → predict → outcome → adapt.
|
||||
* The active stage (most-recent activity) gets a pulsing accent bar + ring.
|
||||
*/
|
||||
export function LearningLoop({ loop }: { loop: PodLearningLoop }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Learning loop
|
||||
</p>
|
||||
{loop.steps.map((s, i) => {
|
||||
const active = s.status === 'active' || s.key === loop.activeStep;
|
||||
return (
|
||||
<div key={s.key}>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-lg border bg-card py-2 pl-3.5 pr-3 shadow-sm transition-colors data-[active=true]:bg-accent/40"
|
||||
data-active={active}
|
||||
style={active ? { boxShadow: `inset 0 0 0 1px ${BLUE}55` } : undefined}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute inset-y-0 left-0 w-1 ${active ? 'pm-pulse' : ''}`}
|
||||
style={{ background: active ? BLUE : 'var(--border)' }}
|
||||
/>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="tabular-nums">{String(i + 1).padStart(2, '0')}</span> {s.label}
|
||||
</p>
|
||||
<p className="font-heading text-sm font-semibold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">{s.detail}</p>
|
||||
</div>
|
||||
{i < loop.steps.length - 1 && (
|
||||
<p
|
||||
aria-hidden
|
||||
className="py-0.5 text-center text-xs leading-none text-muted-foreground/60"
|
||||
>
|
||||
↓
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { PodGraphMetric } from '@podman/shared';
|
||||
import { BLUE, RED, VIOLET, GREEN, AMBER } from './encoding.js';
|
||||
|
||||
const ACCENTS: Array<{ test: RegExp; color: string }> = [
|
||||
{ test: /risk|collision|open/i, color: RED },
|
||||
{ test: /accept/i, color: GREEN },
|
||||
{ test: /learn|owner|adapt/i, color: VIOLET },
|
||||
{ test: /vector|memory|store/i, color: AMBER },
|
||||
];
|
||||
|
||||
function accentFor(label: string, i: number): string {
|
||||
for (const a of ACCENTS) if (a.test.test(label)) return a.color;
|
||||
return [BLUE, RED, VIOLET, GREEN, AMBER][i % 5] ?? BLUE;
|
||||
}
|
||||
|
||||
export function MetricsRail({ metrics }: { metrics: PodGraphMetric[] }) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Workflow metrics
|
||||
</p>
|
||||
{metrics.map((m, i) => (
|
||||
<div
|
||||
key={m.label}
|
||||
className="rounded-lg border bg-card py-2.5 pl-3 pr-3 shadow-sm"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: accentFor(m.label, i) }}
|
||||
>
|
||||
<p className="font-heading text-2xl font-semibold leading-none tabular-nums">{m.value}</p>
|
||||
<p className="mt-1.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{m.label}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { PodGraphNode } from '@podman/shared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { statusColor, modeBlurb, VIOLET, type Mode } from './encoding.js';
|
||||
|
||||
export function SelectedNodePanel({
|
||||
node,
|
||||
relCount,
|
||||
flow,
|
||||
mode,
|
||||
}: {
|
||||
node: PodGraphNode | undefined;
|
||||
relCount: number;
|
||||
flow: string;
|
||||
mode: Mode;
|
||||
}) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{mode === 'learn' ? 'Learning edges' : mode === 'all' ? 'Whole graph' : 'Risk path'}
|
||||
</p>
|
||||
<h3 className="mb-2 font-heading text-base font-medium">What you're looking at</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{modeBlurb(mode)}</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Click any node to trace its{' '}
|
||||
<span className="font-medium" style={{ color: VIOLET }}>
|
||||
flow
|
||||
</span>{' '}
|
||||
— what PodMan saw, flagged, and learned. Drag to rearrange.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{node.kind}
|
||||
</p>
|
||||
<h3 className="mb-2 mt-0.5 font-heading text-lg font-medium">{node.label}</h3>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<span>Status</span>
|
||||
<Badge variant="outline" style={{ color: statusColor(node.status) }}>
|
||||
{node.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<span>Relationships</span>
|
||||
<span className="font-medium text-foreground">{relCount}</span>
|
||||
</div>
|
||||
{flow && (
|
||||
<>
|
||||
<p className="mt-2.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Flow
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{flow}</p>
|
||||
</>
|
||||
)}
|
||||
{node.summary && node.summary !== flow && (
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{node.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type {
|
||||
PodGraph,
|
||||
PodGraphNode,
|
||||
PodGraphEdge,
|
||||
PodGraphNodeKind,
|
||||
PodGraphActivityKind,
|
||||
} from '@podman/shared';
|
||||
|
||||
/**
|
||||
* Fixed, light-readable hues for the node/edge encoding. Kept stable across
|
||||
* light/dark so kinds stay distinguishable; only the chrome uses shadcn tokens.
|
||||
*/
|
||||
export const BLUE = '#2563eb';
|
||||
export const SLATE = '#475569';
|
||||
export const SLATE_EDGE = '#94a3b8';
|
||||
export const SLATE_FAINT = '#cbd5e1';
|
||||
export const AMBER = '#d97706';
|
||||
export const RED = '#dc2626';
|
||||
export const VIOLET = '#7c3aed';
|
||||
export const GREEN = '#16a34a';
|
||||
|
||||
/** Tag color + short label per activity-stream kind. */
|
||||
export const ACTIVITY_TAG: Record<PodGraphActivityKind, { color: string; label: string }> = {
|
||||
editing: { color: SLATE, label: 'EDITING' },
|
||||
collision: { color: RED, label: 'COLLISION' },
|
||||
intervention: { color: AMBER, label: 'NUDGE' },
|
||||
outcome: { color: GREEN, label: 'OUTCOME' },
|
||||
learned: { color: VIOLET, label: 'LEARNED' },
|
||||
agent: { color: BLUE, label: 'AGENT' },
|
||||
};
|
||||
|
||||
export const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||
engineer: BLUE,
|
||||
file: SLATE,
|
||||
feature: AMBER,
|
||||
collision: RED,
|
||||
intervention: VIOLET,
|
||||
};
|
||||
|
||||
export interface EdgeStyle {
|
||||
c: string;
|
||||
w: number;
|
||||
dash?: boolean;
|
||||
}
|
||||
|
||||
export const EDGE: Record<PodGraphEdge['kind'], EdgeStyle> = {
|
||||
owns: { c: BLUE, w: 2.4 },
|
||||
editing: { c: SLATE_EDGE, w: 1.9 },
|
||||
touches: { c: SLATE_FAINT, w: 1.5 },
|
||||
collides: { c: RED, w: 2.8 },
|
||||
warns: { c: AMBER, w: 2.8 },
|
||||
learned_from: { c: VIOLET, w: 2.4, dash: true },
|
||||
};
|
||||
|
||||
/** Collision/drawing radius for a node — scaled by its 0..1 weight. */
|
||||
export function nodeRadius(node: PodGraphNode): number {
|
||||
const base = node.kind === 'collision' || node.kind === 'intervention' ? 14 : 13;
|
||||
return base + Math.max(0, Math.min(1, node.weight)) * 7;
|
||||
}
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
if (status === 'risk') return RED;
|
||||
if (status === 'learned') return VIOLET;
|
||||
if (status === 'active') return BLUE;
|
||||
return 'var(--muted-foreground)';
|
||||
}
|
||||
|
||||
export type Mode = 'risk' | 'learn' | 'all';
|
||||
|
||||
export interface Highlight {
|
||||
nodes: Set<string>;
|
||||
edges: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lit set for the current mode/selection. A selected node lights its
|
||||
* incident edges + neighbors; otherwise the mode lights the risk or learning
|
||||
* chain (collision → intervention → learned_from). `all` lights everything.
|
||||
*/
|
||||
export function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
||||
if (selected) {
|
||||
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
||||
return {
|
||||
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
if (mode === 'all') return null;
|
||||
const kinds: PodGraphEdge['kind'][] =
|
||||
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
||||
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
||||
const es = graph.edges.filter(
|
||||
(e) =>
|
||||
kinds.includes(e.kind) ||
|
||||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
||||
);
|
||||
return {
|
||||
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function joinNames(ids: string[], label: (id: string) => string): string {
|
||||
const u = [...new Set(ids)].map(label);
|
||||
if (u.length <= 1) return u[0] ?? '';
|
||||
if (u.length === 2) return `${u[0]} and ${u[1]}`;
|
||||
return `${u.slice(0, -1).join(', ')} and ${u[u.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain-English walk of the flow through a node — what PodMan saw, flagged,
|
||||
* suggested, and learned — so clicking a node explains the path, not just shows
|
||||
* attributes. Built by traversing the node's incident edges.
|
||||
*/
|
||||
export function flowNarrative(graph: PodGraph, nodeId: string): string {
|
||||
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||
const node = byId.get(nodeId);
|
||||
if (!node) return '';
|
||||
const label = (id: string): string => byId.get(id)?.label ?? id;
|
||||
const out = graph.edges.filter((e) => e.source === nodeId);
|
||||
const inc = graph.edges.filter((e) => e.target === nodeId);
|
||||
|
||||
switch (node.kind) {
|
||||
case 'engineer': {
|
||||
const edits = out.filter((e) => e.kind === 'editing').map((e) => e.target);
|
||||
const collisions = out.filter((e) => e.kind === 'collides');
|
||||
const owns = out.filter((e) => e.kind === 'owns').map((e) => label(e.target));
|
||||
const learned = inc.some((e) => e.kind === 'learned_from');
|
||||
const parts: string[] = [];
|
||||
if (edits.length) parts.push(`${node.label} is working in ${joinNames(edits, label)}.`);
|
||||
if (collisions.length)
|
||||
parts.push(
|
||||
`PodMan flagged ${collisions.length} overlap${collisions.length === 1 ? '' : 's'} involving ${node.label}.`,
|
||||
);
|
||||
if (learned)
|
||||
parts.push(
|
||||
`From an accepted intervention PodMan learned ${node.label} owns ${owns[0] ?? 'this file'} — retained across sessions.`,
|
||||
);
|
||||
else if (owns.length) parts.push(`PodMan has ${node.label} owning ${joinNames(owns, (s) => s)}.`);
|
||||
return parts.join(' ') || `${node.label} has no active flow right now.`;
|
||||
}
|
||||
case 'file': {
|
||||
const editors = inc.filter((e) => e.kind === 'editing' || e.kind === 'owns').map((e) => e.source);
|
||||
const hasCollision = out.some((e) => e.kind === 'touches');
|
||||
const parts: string[] = [];
|
||||
if (editors.length) parts.push(`${node.label} is being edited by ${joinNames(editors, label)}.`);
|
||||
if (hasCollision)
|
||||
parts.push('Two of those edits overlap before push, so PodMan opened a collision on it.');
|
||||
return parts.join(' ') || node.summary || node.label;
|
||||
}
|
||||
case 'collision': {
|
||||
const engineers = inc.filter((e) => e.kind === 'collides').map((e) => e.source);
|
||||
const fileEdge = inc.find((e) => e.kind === 'touches');
|
||||
const file = fileEdge ? label(fileEdge.source) : 'the same file';
|
||||
const intervention = out.find((e) => e.kind === 'warns');
|
||||
let s = `${joinNames(engineers, label) || 'Two engineers'} are both editing ${file} before pushing — the overlap git can't see.`;
|
||||
if (intervention) s += ` PodMan stepped in and suggested a ${label(intervention.target)}.`;
|
||||
return s;
|
||||
}
|
||||
case 'intervention': {
|
||||
const colEdge = inc.find((e) => e.kind === 'warns');
|
||||
const learned = out.find((e) => e.kind === 'learned_from');
|
||||
// Resolve the collision's underlying file via its touches edge (file → collision).
|
||||
let file = '';
|
||||
if (colEdge) {
|
||||
const fileEdge = graph.edges.find((e) => e.kind === 'touches' && e.target === colEdge.source);
|
||||
file = fileEdge ? label(fileEdge.source) : '';
|
||||
}
|
||||
let s = `PodMan offered a ${node.label}${file ? ` for the overlap on ${file}` : ''}.`;
|
||||
if (learned)
|
||||
s += ` The pod accepted it, so PodMan learned ${label(learned.target)} owns ${file || 'the file'} — the graph got sharper.`;
|
||||
return s;
|
||||
}
|
||||
case 'feature': {
|
||||
const contributors = inc.filter((e) => e.kind === 'owns' || e.kind === 'touches').map((e) => e.source);
|
||||
return contributors.length
|
||||
? `${node.label} is built on work by ${joinNames(contributors, label)}.`
|
||||
: node.summary || node.label;
|
||||
}
|
||||
default:
|
||||
return node.summary ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Short explainer for the current view when nothing is selected. */
|
||||
export function modeBlurb(mode: Mode): string {
|
||||
if (mode === 'learn')
|
||||
return 'The violet learned_from links are ownership PodMan kept from accepted interventions — the graph sharpens every session.';
|
||||
if (mode === 'all')
|
||||
return 'Everyone, every file, and every collision and intervention PodMan is tracking for this pod.';
|
||||
return 'The lit path: files where two editors collide before push → the nudge PodMan sent → what it learned.';
|
||||
}
|
||||
|
||||
export const NODE_LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||
{ label: 'engineer', swatch: { background: BLUE } },
|
||||
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
|
||||
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
|
||||
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
|
||||
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
|
||||
];
|
||||
|
||||
export const EDGE_LEGEND: Array<{ label: string; color: string; dash?: boolean }> = [
|
||||
{ label: 'collides', color: RED },
|
||||
{ label: 'warns', color: AMBER },
|
||||
{ label: 'learned_from', color: VIOLET, dash: true },
|
||||
{ label: 'owns', color: BLUE },
|
||||
{ label: 'editing', color: SLATE_EDGE },
|
||||
{ label: 'touches', color: SLATE_FAINT },
|
||||
];
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* A tiny dependency-free force-directed layout — the same family of forces as
|
||||
* d3-force (charge repulsion, link springs, centering, collision) integrated
|
||||
* with velocity-Verlet and an annealing `alpha`. Kept in-house so the dynamic
|
||||
* graph adds no new package / lockfile churn to a fast-moving shared `main`.
|
||||
*
|
||||
* Usage: `setData()` (diff-preserving — existing nodes keep their position),
|
||||
* then drive `tick()` from a requestAnimationFrame loop until `settled()`.
|
||||
*/
|
||||
|
||||
export interface SimNodeInput {
|
||||
id: string;
|
||||
/** Drawing/collision radius. */
|
||||
radius: number;
|
||||
/** Initial position hint (e.g. the server layout), used only for new nodes. */
|
||||
seedX: number;
|
||||
seedY: number;
|
||||
}
|
||||
|
||||
export interface SimLinkInput {
|
||||
source: string;
|
||||
target: string;
|
||||
/** Preferred rest length of the spring. */
|
||||
distance: number;
|
||||
/** 0..1 spring strength. */
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export interface SimNode {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
/** When non-null the node is pinned (dragged) and forces don't move it. */
|
||||
fx: number | null;
|
||||
fy: number | null;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
const ALPHA_MIN = 0.001;
|
||||
const ALPHA_DECAY = 1 - Math.pow(ALPHA_MIN, 1 / 300); // settle in ~300 ticks
|
||||
const FRICTION = 0.62; // velocity retained per tick
|
||||
const REPEL = 4400; // charge repulsion strength — must dominate centering or the graph collapses
|
||||
const LINK_K = 0.45; // spring stiffness multiplier
|
||||
const CENTER_STRENGTH = 0.014; // gentle positional pull — only keeps the cloud roughly centered
|
||||
const RECENTER = 0.5; // per-tick centroid recentering (no compression, keeps graph framed)
|
||||
const COLLIDE_PAD = 12;
|
||||
const COLLIDE_STRENGTH = 1; // hard separation so linked nodes never stack
|
||||
const COLLIDE_ITERS = 2;
|
||||
const BOUND_PAD = 30; // keep nodes this far inside the canvas edges
|
||||
|
||||
export class ForceSim {
|
||||
nodes: SimNode[] = [];
|
||||
links: SimLinkInput[] = [];
|
||||
alpha = 1;
|
||||
private byId = new Map<string, SimNode>();
|
||||
private alphaTarget = 0;
|
||||
private center: { x: number; y: number };
|
||||
private width: number;
|
||||
private height: number;
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.center = { x: width / 2, y: height / 2 };
|
||||
}
|
||||
|
||||
settled(): boolean {
|
||||
return this.alpha < ALPHA_MIN && this.alphaTarget === 0;
|
||||
}
|
||||
|
||||
reheat(a = 0.7): void {
|
||||
this.alpha = Math.max(this.alpha, a);
|
||||
}
|
||||
|
||||
/** Hold the simulation warm while dragging, then release. */
|
||||
setActive(active: boolean): void {
|
||||
this.alphaTarget = active ? 0.18 : 0;
|
||||
if (active) this.reheat(0.25);
|
||||
}
|
||||
|
||||
get(id: string): SimNode | undefined {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
pin(id: string, x: number, y: number): void {
|
||||
const n = this.byId.get(id);
|
||||
if (n) {
|
||||
n.fx = x;
|
||||
n.fy = y;
|
||||
}
|
||||
}
|
||||
|
||||
unpin(id: string): void {
|
||||
const n = this.byId.get(id);
|
||||
if (n) {
|
||||
n.fx = null;
|
||||
n.fy = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the graph, preserving the positions/pins of nodes that persist. */
|
||||
setData(nodeInputs: SimNodeInput[], linkInputs: SimLinkInput[]): { added: string[] } {
|
||||
const prev = this.byId;
|
||||
const next = new Map<string, SimNode>();
|
||||
const added: string[] = [];
|
||||
for (const inp of nodeInputs) {
|
||||
const old = prev.get(inp.id);
|
||||
if (old) {
|
||||
old.radius = inp.radius;
|
||||
next.set(inp.id, old);
|
||||
} else {
|
||||
next.set(inp.id, {
|
||||
id: inp.id,
|
||||
x: inp.seedX + (Math.random() - 0.5) * 14,
|
||||
y: inp.seedY + (Math.random() - 0.5) * 14,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fx: null,
|
||||
fy: null,
|
||||
radius: inp.radius,
|
||||
});
|
||||
added.push(inp.id);
|
||||
}
|
||||
}
|
||||
this.byId = next;
|
||||
this.nodes = [...next.values()];
|
||||
this.links = linkInputs.filter((l) => next.has(l.source) && next.has(l.target));
|
||||
return { added };
|
||||
}
|
||||
|
||||
/** Advance one step. Returns false when already settled (no work done). */
|
||||
tick(): boolean {
|
||||
if (this.settled()) return false;
|
||||
this.alpha += (this.alphaTarget - this.alpha) * ALPHA_DECAY;
|
||||
const a = this.alpha;
|
||||
this.applyCharge(a);
|
||||
this.applyLinks(a);
|
||||
this.applyCenter(a);
|
||||
for (let k = 0; k < COLLIDE_ITERS; k++) this.applyCollide();
|
||||
const maxX = this.width - BOUND_PAD;
|
||||
const maxY = this.height - BOUND_PAD;
|
||||
for (const n of this.nodes) {
|
||||
if (n.fx != null) {
|
||||
n.x = n.fx;
|
||||
n.vx = 0;
|
||||
} else {
|
||||
n.vx *= FRICTION;
|
||||
n.x += n.vx;
|
||||
if (n.x < BOUND_PAD) {
|
||||
n.x = BOUND_PAD;
|
||||
n.vx = 0;
|
||||
} else if (n.x > maxX) {
|
||||
n.x = maxX;
|
||||
n.vx = 0;
|
||||
}
|
||||
}
|
||||
if (n.fy != null) {
|
||||
n.y = n.fy;
|
||||
n.vy = 0;
|
||||
} else {
|
||||
n.vy *= FRICTION;
|
||||
n.y += n.vy;
|
||||
if (n.y < BOUND_PAD) {
|
||||
n.y = BOUND_PAD;
|
||||
n.vy = 0;
|
||||
} else if (n.y > maxY) {
|
||||
n.y = maxY;
|
||||
n.vy = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyCharge(alpha: number): void {
|
||||
const ns = this.nodes;
|
||||
for (let i = 0; i < ns.length; i++) {
|
||||
const a = ns[i];
|
||||
if (!a) continue;
|
||||
for (let j = i + 1; j < ns.length; j++) {
|
||||
const b = ns[j];
|
||||
if (!b) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 === 0) {
|
||||
dx = (j - i) * 0.5;
|
||||
dy = (i + 1) * 0.4;
|
||||
d2 = dx * dx + dy * dy;
|
||||
}
|
||||
const dist = Math.sqrt(d2);
|
||||
const force = (REPEL * alpha) / d2;
|
||||
const ux = dx / dist;
|
||||
const uy = dy / dist;
|
||||
a.vx -= ux * force;
|
||||
a.vy -= uy * force;
|
||||
b.vx += ux * force;
|
||||
b.vy += uy * force;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyLinks(alpha: number): void {
|
||||
for (const link of this.links) {
|
||||
const s = this.byId.get(link.source);
|
||||
const t = this.byId.get(link.target);
|
||||
if (!s || !t) continue;
|
||||
let dx = t.x - s.x;
|
||||
let dy = t.y - s.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 === 0) {
|
||||
dx = 0.5;
|
||||
dy = 0.5;
|
||||
d2 = 0.5;
|
||||
}
|
||||
const dist = Math.sqrt(d2);
|
||||
const k = ((dist - link.distance) / dist) * alpha * link.strength * LINK_K;
|
||||
const mx = dx * k * 0.5;
|
||||
const my = dy * k * 0.5;
|
||||
s.vx += mx;
|
||||
s.vy += my;
|
||||
t.vx -= mx;
|
||||
t.vy -= my;
|
||||
}
|
||||
}
|
||||
|
||||
private applyCenter(alpha: number): void {
|
||||
const n = this.nodes.length;
|
||||
if (!n) return;
|
||||
// Recenter the whole cloud so its centroid sits at canvas center (this does
|
||||
// NOT compress the layout — repulsion/links set the spread), plus a gentle
|
||||
// positional pull so stray/isolated nodes don't park against the edge.
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const nd of this.nodes) {
|
||||
cx += nd.x;
|
||||
cy += nd.y;
|
||||
}
|
||||
cx = (this.center.x - cx / n) * RECENTER;
|
||||
cy = (this.center.y - cy / n) * RECENTER;
|
||||
for (const nd of this.nodes) {
|
||||
if (nd.fx == null) {
|
||||
nd.x += cx;
|
||||
nd.vx += (this.center.x - nd.x) * CENTER_STRENGTH * alpha;
|
||||
}
|
||||
if (nd.fy == null) {
|
||||
nd.y += cy;
|
||||
nd.vy += (this.center.y - nd.y) * CENTER_STRENGTH * alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyCollide(): void {
|
||||
const ns = this.nodes;
|
||||
for (let i = 0; i < ns.length; i++) {
|
||||
const a = ns[i];
|
||||
if (!a) continue;
|
||||
for (let j = i + 1; j < ns.length; j++) {
|
||||
const b = ns[j];
|
||||
if (!b) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
const min = a.radius + b.radius + COLLIDE_PAD;
|
||||
if (d2 >= min * min) continue;
|
||||
let dist = Math.sqrt(d2);
|
||||
if (dist === 0) {
|
||||
dx = j - i;
|
||||
dy = i + 1;
|
||||
dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
}
|
||||
const push = ((min - dist) / dist) * 0.5 * COLLIDE_STRENGTH;
|
||||
const ox = dx * push;
|
||||
const oy = dy * push;
|
||||
if (a.fx == null) a.x -= ox;
|
||||
if (a.fy == null) a.y -= oy;
|
||||
if (b.fx == null) b.x += ox;
|
||||
if (b.fy == null) b.y += oy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { PodActivityEvent } from '@podman/shared';
|
||||
import { getPodActivity, podActivityStreamUrl } from '../lib/api';
|
||||
|
||||
export function usePodActivity(podId: string | null, me: string) {
|
||||
const [events, setEvents] = useState<PodActivityEvent[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!podId) return;
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const snapshot = await getPodActivity(podId);
|
||||
if (alive) {
|
||||
setEvents(snapshot);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
const source = new EventSource(podActivityStreamUrl(podId));
|
||||
source.addEventListener('open', () => {
|
||||
if (alive) setConnected(true);
|
||||
});
|
||||
source.addEventListener('snapshot', (event) => {
|
||||
if (!alive) return;
|
||||
setEvents(JSON.parse((event as MessageEvent<string>).data) as PodActivityEvent[]);
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
});
|
||||
source.addEventListener('error', () => {
|
||||
if (alive) {
|
||||
setConnected(false);
|
||||
setError('Realtime activity stream reconnecting');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
source.close();
|
||||
};
|
||||
}, [podId]);
|
||||
|
||||
return useMemo(() => {
|
||||
const mine = events.filter((event) => belongsTo(event, me));
|
||||
const team = events.filter((event) => !belongsTo(event, me));
|
||||
return { events, mine, team, connected, error };
|
||||
}, [connected, error, events, me]);
|
||||
}
|
||||
|
||||
function belongsTo(event: PodActivityEvent, me: string): boolean {
|
||||
const normalized = me.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
const names = [event.actor, ...(event.actors ?? [])]
|
||||
.filter(Boolean)
|
||||
.map((name) => name!.trim().toLowerCase());
|
||||
return names.includes(normalized);
|
||||
}
|
||||
+1
-125
@@ -1,12 +1,4 @@
|
||||
import type {
|
||||
InterventionOutcome,
|
||||
HermesJob,
|
||||
HermesJobEvent,
|
||||
MemberWorkHistory,
|
||||
Pod,
|
||||
PodActivityEvent,
|
||||
PodInput,
|
||||
} from '@podman/shared';
|
||||
import type { InterventionOutcome, Pod, PodInput } from '@podman/shared';
|
||||
|
||||
const BACKEND_URL =
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
@@ -21,19 +13,6 @@ export interface MemoryStats {
|
||||
outcomes: number;
|
||||
}
|
||||
|
||||
export interface LiveConversationSession {
|
||||
sessionId: string;
|
||||
podId: string;
|
||||
identity: string;
|
||||
displayName: string;
|
||||
room: string;
|
||||
url: string;
|
||||
token: string;
|
||||
startedAt: string;
|
||||
lastEventAt?: string;
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
@@ -67,20 +46,6 @@ export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function createSyncPr(input: {
|
||||
headBranch?: string;
|
||||
file?: string;
|
||||
summary?: string;
|
||||
}): Promise<{ url: string; number: number }> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/sync-pr`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Pods CRUD ---
|
||||
|
||||
export async function listPods(): Promise<Pod[]> {
|
||||
@@ -96,34 +61,6 @@ export async function getMemoryStats(): Promise<MemoryStats> {
|
||||
return json(await fetch(`${BACKEND_URL}/api/memory/stats`));
|
||||
}
|
||||
|
||||
export async function getPodActivity(id: string, limit = 80): Promise<PodActivityEvent[]> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity?limit=${limit}`),
|
||||
);
|
||||
}
|
||||
|
||||
export function podActivityStreamUrl(id: string): string {
|
||||
return `${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity/stream`;
|
||||
}
|
||||
|
||||
/** URL of the pod's generated background-music MP3 (looped client-side). */
|
||||
export function podMusicUrl(id: string): string {
|
||||
return `${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/music`;
|
||||
}
|
||||
|
||||
export async function getMemberWorkHistory(
|
||||
podId: string,
|
||||
member: string,
|
||||
): Promise<MemberWorkHistory> {
|
||||
return json(
|
||||
await fetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/members/${encodeURIComponent(
|
||||
member,
|
||||
)}/history?hours=24&limit=80`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createPod(input: PodInput): Promise<Pod> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods`, {
|
||||
@@ -161,67 +98,6 @@ export async function addMember(id: string, name: string): Promise<Pod> {
|
||||
);
|
||||
}
|
||||
|
||||
export async function testPodVoice(id: string): Promise<void> {
|
||||
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/voice-test`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'PodMan voice test. Gemini TTS is playing through LiveKit.',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`voice test failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function startLiveConversation(
|
||||
podId: string,
|
||||
input: { identity: string; displayName?: string },
|
||||
): Promise<LiveConversationSession> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(podId)}/live-conversation/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopLiveConversation(podId: string, sessionId: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/live-conversation/${encodeURIComponent(sessionId)}/stop`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`live conversation stop failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function getLiveConversationHermesJob(
|
||||
podId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ job: HermesJob | null; events: HermesJobEvent[] }> {
|
||||
return json(
|
||||
await fetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function abortLiveConversationHermesJob(
|
||||
podId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ job: HermesJob | null }> {
|
||||
return json(
|
||||
await fetch(
|
||||
`${BACKEND_URL}/api/pods/${encodeURIComponent(
|
||||
podId,
|
||||
)}/live-conversation/${encodeURIComponent(sessionId)}/hermes-job/abort`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeMember(id: string, name: string): Promise<Pod> {
|
||||
return json(
|
||||
await fetch(
|
||||
|
||||
@@ -75,42 +75,3 @@ export function startBeat(): BeatHandle {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an MP3 from `url` and loop it as an audio MediaStreamTrack to publish into
|
||||
* a LiveKit room (and play on local speakers). Used for pod background music
|
||||
* (Lyria-generated). Pure Web Audio — no asset bundling.
|
||||
*/
|
||||
export async function startMusic(url: string): Promise<BeatHandle> {
|
||||
const ctx = new AudioContext();
|
||||
await ctx.resume();
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`music fetch failed: ${res.status}`);
|
||||
const buffer = await ctx.decodeAudioData(await res.arrayBuffer());
|
||||
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.6;
|
||||
master.connect(dest); // -> published track (remote listeners)
|
||||
master.connect(ctx.destination); // -> local speakers (publisher)
|
||||
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
src.loop = true;
|
||||
src.connect(master);
|
||||
src.start();
|
||||
|
||||
const track = dest.stream.getAudioTracks()[0]!;
|
||||
return {
|
||||
track,
|
||||
stop: () => {
|
||||
try {
|
||||
src.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
track.stop();
|
||||
void ctx.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,8 +8,3 @@ export async function fetchPodGraph(podId: string): Promise<PodGraph> {
|
||||
if (!res.ok) throw new Error(`graph request failed: ${res.status}`);
|
||||
return res.json() as Promise<PodGraph>;
|
||||
}
|
||||
|
||||
/** WebSocket URL for the live event bus — used to nudge the graph to refetch. */
|
||||
export function backendEventsUrl(): string {
|
||||
return `${BACKEND_URL.replace(/^http/, 'ws')}/api/events`;
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RoomEvent, type Room } from 'livekit-client';
|
||||
import { DATA_TOPIC, type DataMessage } from '@podman/shared';
|
||||
import { startBeat, startMusic, type BeatHandle } from '../lib/beat.js';
|
||||
|
||||
/** Name of the test-audio track; its presence in the room IS the shared state. */
|
||||
export const BEAT_TRACK = 'podman-beat';
|
||||
|
||||
export interface BeatState {
|
||||
/** Is the test audio playing anywhere in the pod? */
|
||||
on: boolean;
|
||||
/** Display name of the participant who started it (the owner). */
|
||||
by: string | null;
|
||||
/** Do I own the beat track (so I can stop it directly)? */
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
const OFF: BeatState = { on: false, by: null, mine: false };
|
||||
|
||||
/**
|
||||
* Shared, pod-wide test audio. One participant publishes the `podman-beat`
|
||||
* track; everyone hears it and sees the same on/off state, derived directly
|
||||
* from the track's presence (self-syncing across joins/leaves). Any participant
|
||||
* can stop it: non-owners send BEAT_STOP and the owner unpublishes.
|
||||
*/
|
||||
export function useBeat(room: Room | null, musicUrl?: string) {
|
||||
const [beat, setBeat] = useState<BeatState>(OFF);
|
||||
const beatRef = useRef<BeatHandle | null>(null);
|
||||
|
||||
const stopLocal = useCallback(async () => {
|
||||
const handle = beatRef.current;
|
||||
if (!handle) return;
|
||||
beatRef.current = null;
|
||||
try {
|
||||
await room?.localParticipant.unpublishTrack(handle.track);
|
||||
} finally {
|
||||
handle.stop();
|
||||
}
|
||||
}, [room]);
|
||||
|
||||
// Derive the shared state from the podman-beat track across all participants.
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setBeat(OFF);
|
||||
return;
|
||||
}
|
||||
const recompute = () => {
|
||||
const lp = room.localParticipant;
|
||||
const localPub = [...lp.trackPublications.values()].find((p) => p.trackName === BEAT_TRACK);
|
||||
if (localPub) {
|
||||
setBeat({ on: true, by: lp.name || lp.identity, mine: true });
|
||||
return;
|
||||
}
|
||||
for (const p of room.remoteParticipants.values()) {
|
||||
const pub = [...p.trackPublications.values()].find((tp) => tp.trackName === BEAT_TRACK);
|
||||
if (pub) {
|
||||
setBeat({ on: true, by: p.name || p.identity, mine: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBeat(OFF);
|
||||
};
|
||||
|
||||
recompute();
|
||||
const events = [
|
||||
RoomEvent.LocalTrackPublished,
|
||||
RoomEvent.LocalTrackUnpublished,
|
||||
RoomEvent.TrackPublished,
|
||||
RoomEvent.TrackUnpublished,
|
||||
RoomEvent.TrackSubscribed,
|
||||
RoomEvent.TrackUnsubscribed,
|
||||
RoomEvent.ParticipantConnected,
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
] as const;
|
||||
events.forEach((e) => room.on(e, recompute));
|
||||
return () => {
|
||||
events.forEach((e) => room.off(e, recompute));
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
// The owner honors stop requests from any participant.
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => {
|
||||
if (topic !== DATA_TOPIC) return;
|
||||
let msg: DataMessage;
|
||||
try {
|
||||
msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'BEAT_STOP') void stopLocal();
|
||||
};
|
||||
room.on(RoomEvent.DataReceived, onData);
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, onData);
|
||||
};
|
||||
}, [room, stopLocal]);
|
||||
|
||||
// Tear down my own track on unmount (e.g. leaving the pod).
|
||||
const stopLocalRef = useRef(stopLocal);
|
||||
stopLocalRef.current = stopLocal;
|
||||
const startingRef = useRef(false);
|
||||
const unmountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unmountedRef.current = true;
|
||||
void stopLocalRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleBeat = useCallback(async () => {
|
||||
if (!room) return;
|
||||
// `beatRef` is the synchronous source of truth for "do I own it" — `beat.mine`
|
||||
// lags behind the LiveKit track events that recompute it, so gate on the ref.
|
||||
if (beatRef.current) {
|
||||
await stopLocal();
|
||||
return;
|
||||
}
|
||||
if (beat.on) {
|
||||
// Someone else owns it — can't unpublish their track, so ask them to stop.
|
||||
await room.startAudio().catch(() => {});
|
||||
await room.localParticipant.publishData(
|
||||
new TextEncoder().encode(JSON.stringify({ type: 'BEAT_STOP' } satisfies DataMessage)),
|
||||
{ reliable: true, topic: DATA_TOPIC },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Start it. Guard against rapid double-clicks publishing two tracks before the
|
||||
// LocalTrackPublished event has had a chance to update state.
|
||||
if (startingRef.current) return;
|
||||
startingRef.current = true;
|
||||
try {
|
||||
await room.startAudio().catch(() => {}); // unlock playback from this gesture
|
||||
if (unmountedRef.current) return;
|
||||
const handle = musicUrl ? await startMusic(musicUrl) : startBeat();
|
||||
beatRef.current = handle;
|
||||
await room.localParticipant.publishTrack(handle.track, { name: BEAT_TRACK });
|
||||
if (unmountedRef.current) await stopLocal(); // left mid-publish — clean up
|
||||
} catch (e) {
|
||||
beatRef.current?.stop();
|
||||
beatRef.current = null;
|
||||
throw e;
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
}
|
||||
}, [room, beat, stopLocal, musicUrl]);
|
||||
|
||||
return { beat, toggleBeat };
|
||||
}
|
||||
@@ -1,50 +1,18 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { RoomEvent, type Room } from 'livekit-client';
|
||||
import type { DataMessage, HermesMessage, Intervention, InterventionStatus } from '@podman/shared';
|
||||
import type { DataMessage, Intervention, InterventionStatus } from '@podman/shared';
|
||||
import { DATA_TOPIC } from '@podman/shared';
|
||||
import { createSyncPr, postOutcome } from '../lib/api';
|
||||
|
||||
const browserTtsFallbackEnabled = import.meta.env.VITE_ENABLE_BROWSER_TTS_FALLBACK === 'true';
|
||||
|
||||
/** Speak a cue in the browser only when the explicit fallback flag is enabled. */
|
||||
export function speakInBrowser(text: string): void {
|
||||
if (!browserTtsFallbackEnabled) return;
|
||||
if (typeof window === 'undefined' || !('speechSynthesis' in window) || !text) return;
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.rate = 1.05;
|
||||
window.speechSynthesis.cancel(); // drop any queued cue so the latest wins
|
||||
window.speechSynthesis.speak(u);
|
||||
}
|
||||
|
||||
/** Unlock speechSynthesis from a user gesture when the browser fallback is enabled. */
|
||||
export function primeSpeech(): void {
|
||||
if (!browserTtsFallbackEnabled) return;
|
||||
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return;
|
||||
const u = new SpeechSynthesisUtterance(' ');
|
||||
u.volume = 0;
|
||||
window.speechSynthesis.speak(u);
|
||||
}
|
||||
import { postOutcome } from '../lib/api';
|
||||
|
||||
export function useInterventions(room: Room | null) {
|
||||
const [active, setActive] = useState<Intervention | null>(null);
|
||||
const [hermes, setHermes] = useState<HermesMessage | null>(null);
|
||||
const [voiceCue, setVoiceCue] = useState<string | null>(null);
|
||||
const [actionUrl, setActionUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => {
|
||||
if (topic !== DATA_TOPIC) return;
|
||||
const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
|
||||
if (msg.type === 'COLLISION') {
|
||||
setActive(msg.intervention);
|
||||
setActionUrl(null);
|
||||
}
|
||||
if (msg.type === 'HERMES_MESSAGE') setHermes(msg.message);
|
||||
if (msg.type === 'VOICE_CUE') {
|
||||
setVoiceCue(msg.text);
|
||||
speakInBrowser(msg.text);
|
||||
}
|
||||
if (msg.type === 'COLLISION') setActive(msg.intervention);
|
||||
};
|
||||
room.on(RoomEvent.DataReceived, onData);
|
||||
return () => {
|
||||
@@ -55,34 +23,19 @@ export function useInterventions(room: Room | null) {
|
||||
const respond = useCallback(
|
||||
async (status: InterventionStatus, accepted: boolean) => {
|
||||
if (!active) return;
|
||||
if (accepted && active.suggestedAction.kind === 'open_sync_pr') {
|
||||
const pr = await createSyncPr({
|
||||
file: String(active.suggestedAction.params?.file ?? ''),
|
||||
summary: String(active.suggestedAction.params?.summary ?? active.message),
|
||||
});
|
||||
setActionUrl(pr.url);
|
||||
}
|
||||
await postOutcome({
|
||||
interventionId: active.id,
|
||||
collisionId: active.collisionId,
|
||||
podId: active.podId,
|
||||
// Placeholder only — the backend derives the authoritative value from
|
||||
// git overlap at outcome time (the client cannot know). (RSI Step 3)
|
||||
wasRealCollision: false,
|
||||
wasRealCollision: true,
|
||||
accepted,
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
await room?.localParticipant.publishData(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'ACK', interventionId: active.id, status }),
|
||||
),
|
||||
{ reliable: true, topic: DATA_TOPIC },
|
||||
);
|
||||
setActive(null);
|
||||
return status;
|
||||
},
|
||||
[active, room],
|
||||
[active],
|
||||
);
|
||||
|
||||
return { active, hermes, voiceCue, actionUrl, respond };
|
||||
return { active, respond };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Room, Track, createLocalScreenTracks, VideoPresets } from 'livekit-client';
|
||||
import { fetchToken } from '../lib/api';
|
||||
|
||||
export function useScreenPublish() {
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
|
||||
const join = useCallback(
|
||||
async (pod: string, identity: string, name: string, githubLogin?: string) => {
|
||||
const { token, url } = await fetchToken({ room: pod, identity, name, githubLogin });
|
||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
await room.connect(url, token);
|
||||
roomRef.current = room;
|
||||
setConnected(true);
|
||||
return room;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const startSharing = useCallback(async () => {
|
||||
const room = roomRef.current;
|
||||
if (!room) throw new Error('join the pod first');
|
||||
const tracks = await createLocalScreenTracks({
|
||||
audio: true,
|
||||
resolution: VideoPresets.h1080.resolution,
|
||||
});
|
||||
for (const t of tracks) {
|
||||
await room.localParticipant.publishTrack(t.mediaStreamTrack, {
|
||||
source:
|
||||
t.kind === Track.Kind.Audio ? Track.Source.ScreenShareAudio : Track.Source.ScreenShare,
|
||||
});
|
||||
}
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
await room.localParticipant.setCameraEnabled(true);
|
||||
setSharing(true);
|
||||
}, []);
|
||||
|
||||
return { join, startSharing, connected, sharing, room: roomRef };
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
// No service worker in production. We deploy continuously during the event and
|
||||
// any SW (even vite-plugin-pwa's self-destroying one) forces open tabs to
|
||||
// reload, which breaks the live demo. Existing SWs are torn down by the
|
||||
// cleanup snippet in index.html. Re-add a precaching PWA only post-event.
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
// Self-destroying during active development: unregisters any previously
|
||||
// installed service worker and clears its caches so deploys are always
|
||||
// fresh (no stale UI). Re-enable a precaching PWA before the demo.
|
||||
VitePWA({ selfDestroying: true }),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
|
||||
+2
-6
@@ -48,9 +48,7 @@ services:
|
||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
|
||||
- { key: GEMINI_TTS_VOICE, scope: RUN_TIME, value: Charon }
|
||||
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||
@@ -75,9 +73,7 @@ workers:
|
||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
|
||||
- { key: GEMINI_TTS_VOICE, scope: RUN_TIME, value: Charon }
|
||||
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||
|
||||
@@ -22,23 +22,3 @@ lk.165-22-129-249.sslip.io {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gemini.165-22-129-249.sslip.io {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
podman.live, www.podman.live {
|
||||
route {
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
|
||||
handle /health {
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
|
||||
root * /var/www/podman
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
+5
-42
@@ -4,7 +4,7 @@ Deploy targets for PodMan on DigitalOcean.
|
||||
|
||||
- `Dockerfile` — builds the backend runtime image from the monorepo root
|
||||
- `app.yaml` — DigitalOcean App Platform spec: static site, API service, agent worker
|
||||
- `systemd/` — local droplet service/timer units for the API, agent worker, public healthcheck, and Hermes watchdog
|
||||
- `systemd/` — local droplet service units for the API and agent worker
|
||||
|
||||
Full deploy spec and env var reference in [`docs/digitalocean.md`](../docs/digitalocean.md).
|
||||
|
||||
@@ -24,16 +24,6 @@ docker run --env-file backend/.env -e PODMAN_PROCESS=server -p 8787:8787 podman-
|
||||
docker run --env-file backend/.env -e PODMAN_PROCESS=agent podman-backend
|
||||
```
|
||||
|
||||
The automated check uses Docker by default, matching `pnpm build:container`:
|
||||
|
||||
```bash
|
||||
pnpm build:container
|
||||
pnpm verify:containers
|
||||
```
|
||||
|
||||
Set `VERIFY_CONTAINER_RUNTIME=podman` to run the same verifier against a Podman
|
||||
image store.
|
||||
|
||||
## Local production services
|
||||
|
||||
On the demo droplet, serve the API and worker with systemd instead of tmux:
|
||||
@@ -41,10 +31,9 @@ On the demo droplet, serve the API and worker with systemd instead of tmux:
|
||||
```bash
|
||||
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||
sudo systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent
|
||||
sudo systemctl status podman-platform-api podman-platform-agent
|
||||
```
|
||||
|
||||
The services expect:
|
||||
@@ -58,7 +47,6 @@ Useful checks:
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/health
|
||||
journalctl -u podman-platform-api -u podman-platform-agent -f
|
||||
journalctl -u podman-hermes-watchdog -f
|
||||
```
|
||||
|
||||
## DigitalOcean deploy
|
||||
@@ -85,34 +73,9 @@ local LiveKit host.
|
||||
|
||||
```bash
|
||||
sudo cp infra/systemd/podman-platform-*.service /etc/systemd/system/
|
||||
sudo cp infra/systemd/podman-hermes-watchdog.* /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer
|
||||
systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer
|
||||
```
|
||||
|
||||
## Hermes operations layer
|
||||
|
||||
Hermes is the operations copilot for the droplet. The durable layer is:
|
||||
|
||||
- `podman-hermes-watchdog.timer` runs `pnpm hermes:watchdog` every five minutes.
|
||||
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes and deploys clean fast-forward changes.
|
||||
- `podman-public-healthcheck.timer` keeps the fast public URL restart loop.
|
||||
- `/var/log/podman/hermes-watchdog-latest.json` records the latest watchdog report.
|
||||
- `.git/hooks/pre-push`, installed by `pnpm hermes:install`, gates major pushes with typecheck, lint, and a non-remediating watchdog check.
|
||||
|
||||
Install or refresh all local ops wiring:
|
||||
|
||||
```bash
|
||||
pnpm hermes:install
|
||||
```
|
||||
|
||||
Manual one-shot checks:
|
||||
|
||||
```bash
|
||||
pnpm hermes:watchdog
|
||||
pnpm hermes:watchdog:strict
|
||||
pnpm hermes:sync-deploy
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent
|
||||
systemctl status podman-platform-api podman-platform-agent
|
||||
```
|
||||
|
||||
## Fallback (demo safety)
|
||||
|
||||
+2
-6
@@ -48,9 +48,7 @@ services:
|
||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
|
||||
- { key: GEMINI_TTS_VOICE, scope: RUN_TIME, value: Charon }
|
||||
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||
@@ -75,9 +73,7 @@ workers:
|
||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
|
||||
- { key: GEMINI_TTS_VOICE, scope: RUN_TIME, value: Charon }
|
||||
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=PodMan LiveKit Gemini starter agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/podman/examples/livekit-gemini-hacker-starter/agent
|
||||
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ExecStart=/root/.local/bin/uv run agent.py dev
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=20
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=PodMan LiveKit Gemini starter frontend
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/podman/examples/livekit-gemini-hacker-starter/frontend
|
||||
Environment=NODE_ENV=production
|
||||
Environment=NEXT_TELEMETRY_DISABLED=1
|
||||
ExecStart=/usr/bin/pnpm start --hostname 127.0.0.1 --port 3000
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=20
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,16 +0,0 @@
|
||||
[Unit]
|
||||
Description=PodMan Hermes git sync and deploy
|
||||
After=network-online.target podman-platform-api.service podman-platform-agent.service caddy.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/root/podman
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PODMAN_DEPLOY_REMOTE=origin
|
||||
Environment=PODMAN_DEPLOY_BRANCH=main
|
||||
Environment=PODMAN_HERMES_STATE_DIR=/var/log/podman
|
||||
EnvironmentFile=/root/podman/backend/.env
|
||||
ExecStart=/usr/bin/node scripts/hermes-sync-deploy.mjs
|
||||
Nice=5
|
||||
IOSchedulingClass=best-effort
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Poll origin/main and let Hermes deploy clean fast-forward changes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=90s
|
||||
OnUnitActiveSec=2min
|
||||
AccuracySec=30s
|
||||
Unit=podman-hermes-sync-deploy.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=PodMan Hermes operations watchdog
|
||||
After=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
|
||||
Wants=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/root/podman
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PODMAN_HERMES_STRICT=0
|
||||
Environment=PODMAN_HERMES_REMEDIATE=1
|
||||
Environment=PODMAN_HERMES_STATE_DIR=/var/log/podman
|
||||
Environment=PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
|
||||
EnvironmentFile=/root/podman/backend/.env
|
||||
ExecStart=/usr/bin/node scripts/hermes-watchdog.mjs
|
||||
Nice=5
|
||||
IOSchedulingClass=best-effort
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=Run PodMan Hermes operations watchdog every five minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=45s
|
||||
OnUnitActiveSec=5min
|
||||
AccuracySec=30s
|
||||
Unit=podman-hermes-watchdog.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=PodMan private LiveKit/Gemini live conversation agent
|
||||
After=network-online.target podman-platform-api.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/root/podman/agents/podman-live-conversation
|
||||
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
EnvironmentFile=/root/podman/backend/.env
|
||||
ExecStart=/root/.local/bin/uv run agent.py dev
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
MemoryHigh=1024M
|
||||
MemoryMax=1536M
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=20
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -12,9 +12,6 @@ EnvironmentFile=/root/podman/backend/.env
|
||||
ExecStart=/usr/bin/node dist/agent.js
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
MemoryHigh=1536M
|
||||
MemoryMax=2G
|
||||
OOMPolicy=stop
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=20
|
||||
|
||||
|
||||
+1
-9
@@ -20,20 +20,12 @@
|
||||
"doctor": "node scripts/deploy-doctor.mjs",
|
||||
"doctor:strict": "node scripts/deploy-doctor.mjs --strict",
|
||||
"deploy:static:local": "node scripts/deploy-static-local.mjs",
|
||||
"hermes:watchdog": "node scripts/hermes-watchdog.mjs",
|
||||
"hermes:watchdog:strict": "node scripts/hermes-watchdog.mjs --strict",
|
||||
"hermes:notify": "node scripts/hermes-notify.mjs",
|
||||
"hermes:sync-deploy": "node scripts/hermes-sync-deploy.mjs",
|
||||
"hermes:install": "node scripts/install-hermes-ops.mjs",
|
||||
"healthcheck:public": "node scripts/healthcheck-public.mjs",
|
||||
"livekit:conversation:agent": "cd agents/podman-live-conversation && uv run agent.py dev",
|
||||
"livekit:conversation:test": "cd agents/podman-live-conversation && uv run pytest",
|
||||
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
|
||||
"verify:full": "pnpm verify && pnpm verify:infra && pnpm build:container && pnpm verify:containers",
|
||||
"verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers",
|
||||
"verify:backend": "node scripts/verify-backend.mjs",
|
||||
"verify:containers": "node scripts/verify-containers.mjs",
|
||||
"verify:frontend": "node scripts/verify-frontend.mjs",
|
||||
"verify:infra": "node scripts/verify-infra.mjs",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
REPO="/home/ramis/Programming/podman"
|
||||
LOG="/home/ramis/Programming/podman/scripts/auto-pull.log"
|
||||
|
||||
cd "$REPO" || exit 1
|
||||
|
||||
# Stash any local changes, pull, pop
|
||||
git fetch origin main 2>>"$LOG"
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
|
||||
if [ "$LOCAL" != "$REMOTE" ]; then
|
||||
echo "[$(date)] Pulling: $LOCAL -> $REMOTE" >> "$LOG"
|
||||
git pull --ff-only origin main >> "$LOG" 2>&1
|
||||
else
|
||||
echo "[$(date)] Up to date" >> "$LOG"
|
||||
fi
|
||||
+15
-91
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { MongoClient } from 'mongodb';
|
||||
@@ -16,6 +15,7 @@ const requiredEnv = [
|
||||
'LIVEKIT_URL',
|
||||
'LIVEKIT_API_KEY',
|
||||
'LIVEKIT_API_SECRET',
|
||||
'GEMINI_API_KEY',
|
||||
'GITHUB_TOKEN',
|
||||
'GITHUB_REPO',
|
||||
'MONGODB_URI',
|
||||
@@ -33,19 +33,6 @@ function isSet(name) {
|
||||
return !!process.env[name]?.trim();
|
||||
}
|
||||
|
||||
function configuredGeminiKey() {
|
||||
const candidates = ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY'];
|
||||
for (const name of candidates) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) continue;
|
||||
if (/replace|todo|example|your|xxx/i.test(value) || value.length < 20) {
|
||||
throw new Error(`${name} looks like a placeholder or truncated key`);
|
||||
}
|
||||
return { name, value };
|
||||
}
|
||||
throw new Error('GEMINI_API_KEY is not set');
|
||||
}
|
||||
|
||||
async function check(name, fn) {
|
||||
try {
|
||||
const detail = await fn();
|
||||
@@ -218,12 +205,12 @@ async function checkGitHub() {
|
||||
}
|
||||
|
||||
async function checkGeminiVision() {
|
||||
const key = configuredGeminiKey();
|
||||
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
|
||||
const model = process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash';
|
||||
const res = await doFetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||
model,
|
||||
)}:generateContent?key=${encodeURIComponent(key.value)}`,
|
||||
)}:generateContent?key=${encodeURIComponent(process.env.GEMINI_API_KEY)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -237,72 +224,19 @@ async function checkGeminiVision() {
|
||||
return model;
|
||||
}
|
||||
|
||||
async function checkGeminiVoiceModel() {
|
||||
const key = configuredGeminiKey();
|
||||
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-tts-preview';
|
||||
const voice = process.env.GEMINI_TTS_VOICE ?? 'Charon';
|
||||
async function checkGeminiLiveListed() {
|
||||
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
|
||||
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-live-2.5-flash';
|
||||
const res = await doFetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key.value)}`,
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(
|
||||
process.env.GEMINI_API_KEY,
|
||||
)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(await responseError('Gemini model list', res));
|
||||
const body = await res.json();
|
||||
const names = (body.models ?? []).map((m) => m.name?.replace(/^models\//, ''));
|
||||
if (!names.includes(model)) throw new Error(`${model} not present in Gemini model list`);
|
||||
if (!model.includes('tts')) return model;
|
||||
|
||||
const tts = await doFetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||
model,
|
||||
)}:generateContent?key=${encodeURIComponent(key.value)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
text: 'Speak this as a calm engineering teammate. Say only: PodMan voice check.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseModalities: ['AUDIO'],
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!tts.ok) throw new Error(await responseError('Gemini voice check', tts));
|
||||
const ttsBody = await tts.json();
|
||||
const audio = ttsBody.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
||||
if (!audio) throw new Error('Gemini voice response had no audio');
|
||||
return `${model}/${voice}, generated ${Buffer.from(audio, 'base64').byteLength} audio bytes`;
|
||||
}
|
||||
|
||||
async function checkGeminiEmbeddings() {
|
||||
const key = configuredGeminiKey();
|
||||
const model = process.env.GEMINI_EMBEDDING_MODEL ?? 'gemini-embedding-001';
|
||||
const res = await doFetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||
model,
|
||||
)}:embedContent?key=${encodeURIComponent(key.value)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: { parts: [{ text: 'PodMan vector memory check' }] },
|
||||
taskType: 'RETRIEVAL_DOCUMENT',
|
||||
outputDimensionality: 768,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(await responseError('Gemini embedding check', res));
|
||||
const body = await res.json();
|
||||
const dims = body.embedding?.values?.length;
|
||||
if (!dims) throw new Error('Gemini embedding response had no vector');
|
||||
return `${model}, ${dims} dimensions`;
|
||||
return model;
|
||||
}
|
||||
|
||||
async function checkVoyage() {
|
||||
@@ -328,16 +262,6 @@ await check('workspace', checkWorkspace);
|
||||
for (const name of requiredEnv) {
|
||||
add(`env:${name}`, isSet(name) ? 'ok' : 'fail', isSet(name) ? 'set' : 'missing');
|
||||
}
|
||||
try {
|
||||
const key = configuredGeminiKey();
|
||||
add(
|
||||
'env:GEMINI_API_KEY',
|
||||
'ok',
|
||||
key.name === 'GEMINI_API_KEY' ? 'set' : `using ${key.name} alias`,
|
||||
);
|
||||
} catch (err) {
|
||||
add('env:GEMINI_API_KEY', 'fail', summarizeError(err));
|
||||
}
|
||||
for (const name of optionalEnv) {
|
||||
add(`env:${name}`, isSet(name) ? 'ok' : 'warn', isSet(name) ? 'set' : 'optional');
|
||||
}
|
||||
@@ -357,15 +281,15 @@ await check('livekit room service', checkLiveKitApi);
|
||||
await check('mongo ping', checkMongo);
|
||||
await check('github repo access', checkGitHub);
|
||||
await check('gemini vision model', checkGeminiVision);
|
||||
await check('gemini voice model', checkGeminiVoiceModel);
|
||||
await check('gemini embeddings', checkGeminiEmbeddings);
|
||||
await check('gemini live model listed', checkGeminiLiveListed);
|
||||
|
||||
if (isSet('VOYAGE_API_KEY')) {
|
||||
await check('voyage embeddings', checkVoyage);
|
||||
} else {
|
||||
add('voyage embeddings', 'warn', 'VOYAGE_API_KEY is optional; Gemini embeddings are active');
|
||||
}
|
||||
await check('atlas vector index', checkVectorIndex);
|
||||
} else {
|
||||
add('voyage embeddings', 'warn', 'VOYAGE_API_KEY is optional; exact Mongo recall remains active');
|
||||
add('atlas vector index', 'warn', 'requires VOYAGE_API_KEY and Atlas Search index');
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.status === 'fail');
|
||||
const warnings = results.filter((r) => r.status === 'warn');
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync } from 'node:fs';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
|
||||
const envPath = process.env.DOTENV_CONFIG_PATH ?? (existsSync('.env') ? '.env' : 'backend/.env');
|
||||
loadEnv({ path: envPath, quiet: true });
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
'Usage: node scripts/hermes-notify.mjs --pod <podId> --message <text> [--engineers alice,bob] [--file path] [--urgent]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function arg(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index === -1 ? '' : (process.argv[index + 1] ?? '');
|
||||
}
|
||||
|
||||
const podId = arg('--pod');
|
||||
const message = arg('--message');
|
||||
if (!podId || !message) usage();
|
||||
|
||||
const apiBase = (
|
||||
process.env.PODMAN_API_URL ??
|
||||
process.env.BACKEND_URL ??
|
||||
`http://127.0.0.1:${process.env.PORT ?? '8787'}`
|
||||
).replace(/\/$/, '');
|
||||
|
||||
const engineers = arg('--engineers')
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
const file = arg('--file');
|
||||
const urgent = process.argv.includes('--urgent');
|
||||
|
||||
const res = await globalThis.fetch(`${apiBase}/api/pods/${encodeURIComponent(podId)}/hermes/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
...(engineers.length ? { engineers } : {}),
|
||||
...(file ? { file } : {}),
|
||||
...(urgent ? { urgency: 'urgent' } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
console.error(text);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(text);
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const branch = process.env.PODMAN_DEPLOY_BRANCH ?? 'main';
|
||||
const remote = process.env.PODMAN_DEPLOY_REMOTE ?? 'origin';
|
||||
const services = (
|
||||
process.env.PODMAN_DEPLOY_RESTART_SERVICES ??
|
||||
['podman-platform-api.service', 'podman-platform-agent.service', 'caddy.service'].join(',')
|
||||
)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const report = {
|
||||
ok: false,
|
||||
branch,
|
||||
remote,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: '',
|
||||
changed: false,
|
||||
from: '',
|
||||
to: '',
|
||||
steps: [],
|
||||
};
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr }));
|
||||
child.on('error', (error) =>
|
||||
resolve({ code: 127, signal: null, stdout, stderr: error.message }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function detail(result, max = 1600) {
|
||||
return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n').slice(-max);
|
||||
}
|
||||
|
||||
async function step(name, command, args, options) {
|
||||
const result = await run(command, args, options);
|
||||
const ok = result.code === 0;
|
||||
report.steps.push({ name, ok, detail: detail(result) });
|
||||
if (!ok) throw new Error(`${name} failed`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function gitOutput(args) {
|
||||
const result = await run('git', args);
|
||||
if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed: ${detail(result)}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const currentBranch = await gitOutput(['branch', '--show-current']);
|
||||
if (currentBranch !== branch)
|
||||
throw new Error(`expected branch ${branch}, found ${currentBranch}`);
|
||||
|
||||
await step('fetch', 'git', ['fetch', remote, branch]);
|
||||
const dirty = await gitOutput(['status', '--porcelain']);
|
||||
if (dirty) throw new Error(`working tree is dirty; refusing auto-deploy:\n${dirty}`);
|
||||
|
||||
const local = await gitOutput(['rev-parse', 'HEAD']);
|
||||
const upstream = await gitOutput(['rev-parse', `${remote}/${branch}`]);
|
||||
report.from = local;
|
||||
report.to = upstream;
|
||||
|
||||
if (local === upstream) {
|
||||
report.ok = true;
|
||||
report.completedAt = new Date().toISOString();
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
await step('fast-forward', 'git', ['merge', '--ff-only', `${remote}/${branch}`]);
|
||||
report.changed = true;
|
||||
await step('install', 'pnpm', ['install', '--frozen-lockfile'], {
|
||||
env: { CI: 'true' },
|
||||
});
|
||||
await step('build', 'pnpm', ['build']);
|
||||
await step('deploy static', 'pnpm', ['deploy:static:local']);
|
||||
for (const service of services)
|
||||
await step(`restart ${service}`, 'systemctl', ['restart', service]);
|
||||
await step('hermes watchdog', 'pnpm', ['hermes:watchdog:strict']);
|
||||
|
||||
report.ok = true;
|
||||
report.completedAt = new Date().toISOString();
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
report.ok = false;
|
||||
report.completedAt = new Date().toISOString();
|
||||
report.error = error instanceof Error ? error.message : String(error);
|
||||
console.error(JSON.stringify(report, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
|
||||
const envPath = process.env.DOTENV_CONFIG_PATH ?? (existsSync('.env') ? '.env' : 'backend/.env');
|
||||
loadEnv({ path: envPath, quiet: true });
|
||||
|
||||
const { AbortController, clearTimeout, fetch, setTimeout } = globalThis;
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const remediate = !args.has('--no-remediate') && process.env.PODMAN_HERMES_REMEDIATE !== '0';
|
||||
const strict = args.has('--strict') || process.env.PODMAN_HERMES_STRICT === '1';
|
||||
const jsonOnly = args.has('--json');
|
||||
const rootUrl = process.env.PODMAN_PUBLIC_URL ?? 'https://165-22-129-249.sslip.io/';
|
||||
const apiUrl = process.env.PODMAN_PUBLIC_API_URL ?? new URL('/api/pods', rootUrl).toString();
|
||||
const healthUrl = process.env.PODMAN_PUBLIC_HEALTH_URL ?? new URL('/health', rootUrl).toString();
|
||||
const timeoutMs = Number(process.env.PODMAN_HERMES_TIMEOUT_MS ?? 8000);
|
||||
const stateDir = process.env.PODMAN_HERMES_STATE_DIR ?? '/var/log/podman';
|
||||
const services = (
|
||||
process.env.PODMAN_HERMES_SERVICES ??
|
||||
[
|
||||
'mongod.service',
|
||||
'podman-platform-api.service',
|
||||
'podman-platform-agent.service',
|
||||
'caddy.service',
|
||||
].join(',')
|
||||
)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const report = {
|
||||
ok: false,
|
||||
strict,
|
||||
remediate,
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: '',
|
||||
checks: [],
|
||||
remediation: [],
|
||||
logs: {},
|
||||
};
|
||||
|
||||
function addCheck(name, ok, detail = '') {
|
||||
report.checks.push({ name, ok, detail });
|
||||
return ok;
|
||||
}
|
||||
|
||||
function summarizeOutput(result, max = 1200) {
|
||||
return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n').slice(-max);
|
||||
}
|
||||
|
||||
function run(command, args = [], options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => child.kill('SIGKILL'), 2000).unref();
|
||||
}, options.timeoutMs ?? timeoutMs);
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, signal, stdout, stderr });
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: 127, signal: null, stdout, stderr: error.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
const text = await res.text().catch(() => '');
|
||||
return { ok: res.ok, status: res.status, text: text.slice(0, 300) };
|
||||
} catch (error) {
|
||||
return { ok: false, status: 0, text: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUrls() {
|
||||
for (const [name, url] of [
|
||||
['public root', rootUrl],
|
||||
['public health', healthUrl],
|
||||
['public api', apiUrl],
|
||||
]) {
|
||||
const result = await fetchWithTimeout(url);
|
||||
addCheck(name, result.ok, `${url} -> ${result.status || result.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServices() {
|
||||
for (const service of services) {
|
||||
const active = await run('systemctl', ['is-active', '--quiet', service], { timeoutMs: 5000 });
|
||||
addCheck(`service:${service}`, active.code === 0, `systemctl is-active exit ${active.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDoctor() {
|
||||
const doctorArgs = ['deploy:doctor'];
|
||||
if (strict) doctorArgs[0] = 'deploy:doctor:strict';
|
||||
const result = await run('pnpm', doctorArgs, {
|
||||
timeoutMs: Number(process.env.PODMAN_HERMES_DOCTOR_TIMEOUT_MS ?? 120000),
|
||||
});
|
||||
const ok = result.code === 0 && /"ok":\s*true/.test(result.stdout);
|
||||
addCheck(`pnpm ${doctorArgs[0]}`, ok, summarizeOutput(result, 2000));
|
||||
}
|
||||
|
||||
async function collectLogs(failedServices = services) {
|
||||
for (const service of failedServices) {
|
||||
const result = await run('journalctl', ['-u', service, '-n', '80', '--no-pager'], {
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
report.logs[service] = summarizeOutput(result, 6000);
|
||||
}
|
||||
}
|
||||
|
||||
async function restart(service) {
|
||||
const result = await run('systemctl', ['restart', service], { timeoutMs: 20000 });
|
||||
report.remediation.push({
|
||||
action: `restart ${service}`,
|
||||
ok: result.code === 0,
|
||||
detail: summarizeOutput(result),
|
||||
});
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
async function validateCaddy() {
|
||||
if (!existsSync('/etc/caddy/Caddyfile')) return;
|
||||
const result = await run('caddy', ['validate', '--config', '/etc/caddy/Caddyfile'], {
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
report.remediation.push({
|
||||
action: 'caddy validate',
|
||||
ok: result.code === 0,
|
||||
detail: summarizeOutput(result),
|
||||
});
|
||||
if (result.code === 0) {
|
||||
const reload = await run('systemctl', ['reload', 'caddy.service'], { timeoutMs: 10000 });
|
||||
report.remediation.push({
|
||||
action: 'reload caddy.service',
|
||||
ok: reload.code === 0,
|
||||
detail: summarizeOutput(reload),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function remediateFailures() {
|
||||
const failed = report.checks.filter((c) => !c.ok);
|
||||
if (!failed.length || !remediate) return;
|
||||
|
||||
const failedServiceNames = failed.map((c) => c.name.match(/^service:(.+)$/)?.[1]).filter(Boolean);
|
||||
|
||||
if (failedServiceNames.length) {
|
||||
for (const service of failedServiceNames) await restart(service);
|
||||
} else {
|
||||
for (const service of services.filter((s) => s !== 'mongod.service')) await restart(service);
|
||||
}
|
||||
|
||||
if (failed.some((c) => c.name.includes('public'))) await validateCaddy();
|
||||
await delay(3000);
|
||||
}
|
||||
|
||||
async function writeReport() {
|
||||
report.completedAt = new Date().toISOString();
|
||||
report.ok = report.checks.every((c) => c.ok);
|
||||
await mkdir(stateDir, { recursive: true });
|
||||
const payload = JSON.stringify(report, null, 2);
|
||||
await writeFile(join(stateDir, 'hermes-watchdog-latest.json'), payload);
|
||||
await writeFile(join(stateDir, `hermes-watchdog-${Date.now()}.json`), payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function alert(payload) {
|
||||
const url = process.env.PODMAN_ALERT_WEBHOOK_URL;
|
||||
if (!url || report.ok) return;
|
||||
const failed = report.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
|
||||
const text = `PodMan Hermes watchdog failed ${failed.length} check(s):\n${failed.join('\n')}`;
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
text,
|
||||
username: 'PodMan Hermes',
|
||||
report: JSON.parse(payload),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
report.remediation.push({
|
||||
action: 'send alert',
|
||||
ok: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await checkServices();
|
||||
await checkUrls();
|
||||
await checkDoctor();
|
||||
|
||||
const firstFailed = report.checks.filter((c) => !c.ok);
|
||||
await remediateFailures();
|
||||
|
||||
if (firstFailed.length && remediate) {
|
||||
report.checks.push({ name: 'retry boundary', ok: true, detail: 'after remediation' });
|
||||
await checkServices();
|
||||
await checkUrls();
|
||||
await checkDoctor();
|
||||
}
|
||||
|
||||
await collectLogs(
|
||||
report.checks
|
||||
.filter((c) => !c.ok)
|
||||
.map((c) => c.name.match(/^service:(.+)$/)?.[1])
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const payload = await writeReport();
|
||||
await alert(payload);
|
||||
|
||||
if (!jsonOnly) {
|
||||
for (const check of report.checks) {
|
||||
console.log(
|
||||
`${check.ok ? 'OK ' : 'FAIL'} ${check.name}${check.detail ? ` - ${check.detail}` : ''}`,
|
||||
);
|
||||
}
|
||||
for (const action of report.remediation) {
|
||||
console.log(`${action.ok ? 'OK ' : 'FAIL'} remediate:${action.action}`);
|
||||
}
|
||||
}
|
||||
console.log(payload);
|
||||
process.exit(report.ok || !strict ? 0 : 1);
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, copyFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const root = process.cwd();
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: 'inherit' });
|
||||
child.on('close', (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`${command} exited ${code}`)),
|
||||
);
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function installFile(source, target, mode = 0o644) {
|
||||
console.log(`${dryRun ? 'would install' : 'install'} ${source} -> ${target}`);
|
||||
if (dryRun) return;
|
||||
await copyFile(source, target);
|
||||
await chmod(target, mode);
|
||||
}
|
||||
|
||||
async function installGitHook() {
|
||||
const hookDir = `${root}/.git/hooks`;
|
||||
if (!existsSync(hookDir)) return;
|
||||
const hook = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "${root}"
|
||||
echo "[hermes] running pre-push verification"
|
||||
pnpm -r typecheck
|
||||
pnpm lint
|
||||
pnpm hermes:watchdog -- --no-remediate --json >/tmp/podman-hermes-pre-push.json
|
||||
echo "[hermes] pre-push verification passed"
|
||||
`;
|
||||
console.log(`${dryRun ? 'would write' : 'write'} ${hookDir}/pre-push`);
|
||||
if (dryRun) return;
|
||||
await writeFile(`${hookDir}/pre-push`, hook);
|
||||
await chmod(`${hookDir}/pre-push`, 0o755);
|
||||
}
|
||||
|
||||
await installFile(
|
||||
'infra/systemd/podman-hermes-watchdog.service',
|
||||
'/etc/systemd/system/podman-hermes-watchdog.service',
|
||||
);
|
||||
await installFile(
|
||||
'infra/systemd/podman-hermes-watchdog.timer',
|
||||
'/etc/systemd/system/podman-hermes-watchdog.timer',
|
||||
);
|
||||
await installFile(
|
||||
'infra/systemd/podman-hermes-sync-deploy.service',
|
||||
'/etc/systemd/system/podman-hermes-sync-deploy.service',
|
||||
);
|
||||
await installFile(
|
||||
'infra/systemd/podman-hermes-sync-deploy.timer',
|
||||
'/etc/systemd/system/podman-hermes-sync-deploy.timer',
|
||||
);
|
||||
await installFile(
|
||||
'infra/systemd/podman-public-healthcheck.service',
|
||||
'/etc/systemd/system/podman-public-healthcheck.service',
|
||||
);
|
||||
await installFile(
|
||||
'infra/systemd/podman-public-healthcheck.timer',
|
||||
'/etc/systemd/system/podman-public-healthcheck.timer',
|
||||
);
|
||||
|
||||
await mkdir('/var/log/podman', { recursive: true });
|
||||
await installGitHook();
|
||||
|
||||
if (!dryRun) {
|
||||
await run('systemctl', ['daemon-reload']);
|
||||
await run('systemctl', ['enable', '--now', 'podman-hermes-watchdog.timer']);
|
||||
await run('systemctl', ['enable', '--now', 'podman-hermes-sync-deploy.timer']);
|
||||
await run('systemctl', ['enable', '--now', 'podman-public-healthcheck.timer']);
|
||||
await run('systemctl', [
|
||||
'status',
|
||||
'--no-pager',
|
||||
'podman-hermes-watchdog.timer',
|
||||
'podman-hermes-sync-deploy.timer',
|
||||
'podman-public-healthcheck.timer',
|
||||
]);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, installed: !dryRun }, null, 2));
|
||||
@@ -22,7 +22,6 @@ const env = {
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github',
|
||||
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
|
||||
MONGODB_URI: mongoUri,
|
||||
INTERNAL_AGENT_TOKEN: process.env.INTERNAL_AGENT_TOKEN ?? 'verify-internal-agent-token',
|
||||
};
|
||||
|
||||
function fail(message) {
|
||||
@@ -96,105 +95,6 @@ async function verifyApi() {
|
||||
);
|
||||
if (!withMember.members.includes('Hermes')) fail('member add did not persist');
|
||||
|
||||
const hermesNotify = await json(
|
||||
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}/hermes/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'Hermes verification notification.',
|
||||
engineers: ['Alice', 'Bob'],
|
||||
file: 'src/verify-hermes.ts',
|
||||
dryRun: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
hermesNotify.livekit !== 'dry-run' ||
|
||||
hermesNotify.intervention?.message !== 'Hermes verification notification.'
|
||||
) {
|
||||
fail('Hermes notify endpoint returned unexpected payload');
|
||||
}
|
||||
|
||||
const liveConversation = await json(
|
||||
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}/live-conversation/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ identity: 'Alice', displayName: 'Alice' }),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
typeof liveConversation.token !== 'string' ||
|
||||
liveConversation.token.split('.').length !== 3 ||
|
||||
typeof liveConversation.room !== 'string' ||
|
||||
!liveConversation.room.includes('podman-live:')
|
||||
) {
|
||||
fail('live conversation start did not return a private room JWT');
|
||||
}
|
||||
const liveStatus = await json(
|
||||
await doFetch(
|
||||
`${baseUrl}/api/pods/${encodeURIComponent(
|
||||
created.id,
|
||||
)}/live-conversation/status?identity=Alice`,
|
||||
),
|
||||
);
|
||||
if (liveStatus.active?.sessionId !== liveConversation.sessionId) {
|
||||
fail('live conversation status did not return the active session');
|
||||
}
|
||||
await json(
|
||||
await doFetch(
|
||||
`${baseUrl}/api/pods/${encodeURIComponent(
|
||||
created.id,
|
||||
)}/live-conversation/${encodeURIComponent(liveConversation.sessionId)}/stop`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
);
|
||||
|
||||
const hermesJob = await json(
|
||||
await doFetch(`${baseUrl}/api/internal/hermes/jobs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${env.INTERNAL_AGENT_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: 'Check repository state for backend verification.',
|
||||
contextScope: 'current_repo',
|
||||
riskLevel: 'read_only',
|
||||
successCriteria: ['Git status is inspected.'],
|
||||
podId: created.id,
|
||||
identity: 'Alice',
|
||||
sessionId: liveConversation.sessionId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (!hermesJob.id || hermesJob.status !== 'queued') {
|
||||
fail('Hermes job create returned unexpected payload');
|
||||
}
|
||||
let finalJob = hermesJob;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
finalJob = await json(
|
||||
await doFetch(`${baseUrl}/api/internal/hermes/jobs/${encodeURIComponent(hermesJob.id)}`, {
|
||||
headers: { authorization: `Bearer ${env.INTERNAL_AGENT_TOKEN}` },
|
||||
}),
|
||||
);
|
||||
if (['completed', 'failed', 'aborted'].includes(finalJob.status)) break;
|
||||
await delay(500);
|
||||
}
|
||||
if (finalJob.status !== 'completed') {
|
||||
fail(`Hermes job did not complete: ${JSON.stringify(finalJob)}`);
|
||||
}
|
||||
const hermesEvents = await json(
|
||||
await doFetch(
|
||||
`${baseUrl}/api/internal/hermes/jobs/${encodeURIComponent(hermesJob.id)}/events`,
|
||||
{
|
||||
headers: { authorization: `Bearer ${env.INTERNAL_AGENT_TOKEN}` },
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!Array.isArray(hermesEvents) || hermesEvents.length < 1) {
|
||||
fail('Hermes job events were not persisted');
|
||||
}
|
||||
|
||||
await json(
|
||||
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}`, { method: 'DELETE' }),
|
||||
);
|
||||
@@ -255,21 +155,8 @@ async function verifyMemoryRecall() {
|
||||
detectedAt: new Date().toISOString(),
|
||||
};
|
||||
await recordCollision(seed);
|
||||
const { getDb } = await import('../backend/dist/memory/db.js');
|
||||
const db = await getDb();
|
||||
const stored = await db.collection('collisions').findOne({ id: seed.id });
|
||||
if (!Array.isArray(stored?.embedding) || stored.embedding.length < 1) {
|
||||
fail('memory collision was not enriched with an embedding');
|
||||
}
|
||||
const recalled = await recallSimilar({ ...seed, id: `${seed.id}_query` });
|
||||
if (!recalled) fail('memory recall did not find seeded collision');
|
||||
const vectorRecalled = await recallSimilar({
|
||||
...seed,
|
||||
id: `${seed.id}_vector_query`,
|
||||
file: 'src/nearby-memory.ts',
|
||||
symbol: 'nearbyMemory',
|
||||
});
|
||||
if (!vectorRecalled) fail('vector memory recall did not find semantically similar collision');
|
||||
}
|
||||
|
||||
async function verifyGraph() {
|
||||
@@ -362,9 +249,6 @@ try {
|
||||
'health',
|
||||
'token',
|
||||
'pod-crud',
|
||||
'hermes-notify',
|
||||
'live-conversation-session',
|
||||
'hermes-job-lifecycle',
|
||||
'collision',
|
||||
'memory-recall',
|
||||
'graph',
|
||||
|
||||
@@ -5,7 +5,6 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
|
||||
const image = process.env.VERIFY_CONTAINER_IMAGE ?? 'podman-backend';
|
||||
const runtime = process.env.VERIFY_CONTAINER_RUNTIME ?? 'docker';
|
||||
const port = Number(process.env.VERIFY_CONTAINER_PORT ?? 8799);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const runId = `${process.pid}-${Date.now()}`;
|
||||
@@ -22,20 +21,14 @@ const containerEnv = {
|
||||
LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY ?? 'verify-key',
|
||||
LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET ?? 'verify-secret',
|
||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY ?? 'verify-gemini',
|
||||
GEMINI_VISION_MODEL: process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash',
|
||||
GEMINI_LIVE_MODEL: process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-tts-preview',
|
||||
GEMINI_TTS_VOICE: process.env.GEMINI_TTS_VOICE ?? 'Charon',
|
||||
GEMINI_EMBEDDING_MODEL: process.env.GEMINI_EMBEDDING_MODEL ?? 'gemini-embedding-001',
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github',
|
||||
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
|
||||
MONGODB_URI: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/podman',
|
||||
VOYAGE_API_KEY: process.env.VOYAGE_API_KEY ?? '',
|
||||
VOYAGE_EMBEDDING_MODEL: process.env.VOYAGE_EMBEDDING_MODEL ?? 'voyage-4-lite',
|
||||
};
|
||||
|
||||
function runContainer(args, options = {}) {
|
||||
function runPodman(args, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(runtime, args, {
|
||||
const child = spawn('podman', args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...options,
|
||||
});
|
||||
@@ -68,15 +61,12 @@ function fail(message) {
|
||||
}
|
||||
|
||||
async function assertPodmanAvailable() {
|
||||
const result = await runContainer(['--version']);
|
||||
if (result.code !== 0) fail(`${runtime} is not available: ${result.stderr.trim()}`);
|
||||
const result = await runPodman(['--version']);
|
||||
if (result.code !== 0) fail(`podman is not available: ${result.stderr.trim()}`);
|
||||
}
|
||||
|
||||
async function assertImageExists() {
|
||||
const result =
|
||||
runtime === 'podman'
|
||||
? await runContainer(['image', 'exists', image])
|
||||
: await runContainer(['image', 'inspect', image]);
|
||||
const result = await runPodman(['image', 'exists', image]);
|
||||
if (result.code !== 0) {
|
||||
fail(
|
||||
`container image "${image}" does not exist locally; build it before running this verifier`,
|
||||
@@ -85,23 +75,18 @@ async function assertImageExists() {
|
||||
}
|
||||
|
||||
async function removeContainer(name) {
|
||||
await runContainer(['rm', '-f', name]);
|
||||
await runPodman(['rm', '-f', name]);
|
||||
}
|
||||
|
||||
async function startContainer(name, extraEnv) {
|
||||
await removeContainer(name);
|
||||
const networkArgs =
|
||||
runtime === 'podman'
|
||||
? ['--network', 'host']
|
||||
: extraEnv.PODMAN_PROCESS === 'server'
|
||||
? ['--publish', `127.0.0.1:${port}:${port}`]
|
||||
: [];
|
||||
const result = await runContainer([
|
||||
const result = await runPodman([
|
||||
'run',
|
||||
'--detach',
|
||||
'--name',
|
||||
name,
|
||||
...networkArgs,
|
||||
'--network',
|
||||
'host',
|
||||
...envArgs(extraEnv),
|
||||
image,
|
||||
]);
|
||||
@@ -111,7 +96,7 @@ async function startContainer(name, extraEnv) {
|
||||
}
|
||||
|
||||
async function stopContainer(name) {
|
||||
await runContainer(['stop', '--time', '3', name]);
|
||||
await runPodman(['stop', '--time', '3', name]);
|
||||
await removeContainer(name);
|
||||
}
|
||||
|
||||
@@ -140,7 +125,7 @@ async function waitForApi() {
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
const logs = await runContainer(['logs', apiContainer]);
|
||||
const logs = await runPodman(['logs', apiContainer]);
|
||||
fail(
|
||||
`API container did not become healthy at ${baseUrl}: ${lastError}\n${logs.stdout}${logs.stderr}`,
|
||||
);
|
||||
@@ -169,11 +154,11 @@ async function verifyAgentContainer() {
|
||||
|
||||
let output = '';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const logs = await runContainer(['logs', agentContainer]);
|
||||
const logs = await runPodman(['logs', agentContainer]);
|
||||
output = `${logs.stdout}${logs.stderr}`;
|
||||
if (output.includes('podman-hermes joined room')) return;
|
||||
|
||||
const inspect = await runContainer([
|
||||
const inspect = await runPodman([
|
||||
'inspect',
|
||||
'--format',
|
||||
'{{.State.Running}} {{.State.ExitCode}}',
|
||||
@@ -197,17 +182,10 @@ try {
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
runtime,
|
||||
image,
|
||||
baseUrl,
|
||||
containers: [apiContainer, agentContainer],
|
||||
checks: [
|
||||
'image-exists',
|
||||
'api-health',
|
||||
'api-pods',
|
||||
'agent-joined-room',
|
||||
'gemini-model-envs',
|
||||
],
|
||||
checks: ['image-exists', 'api-health', 'api-pods', 'agent-joined-room'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
+17
-296
@@ -4,10 +4,7 @@ import { createRequire } from 'node:module';
|
||||
import { TextEncoder } from 'node:util';
|
||||
import { chromium } from 'playwright';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { RoomServiceClient } from 'livekit-server-sdk';
|
||||
|
||||
loadEnv({ path: 'backend/.env', quiet: true });
|
||||
const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:4173/';
|
||||
const shouldStartPreview = !process.env.FRONTEND_URL;
|
||||
const doFetch = globalThis.fetch;
|
||||
@@ -21,17 +18,7 @@ const { DATA_TOPIC } = await import('../shared/dist/messages.js').catch(() => ({
|
||||
DATA_TOPIC: 'podman.intervention',
|
||||
}));
|
||||
const backendRequire = createRequire(new URL('../backend/package.json', import.meta.url));
|
||||
const {
|
||||
AudioFrame,
|
||||
AudioSource,
|
||||
LocalAudioTrack,
|
||||
Room,
|
||||
TrackPublishOptions,
|
||||
TrackSource,
|
||||
} = backendRequire('@livekit/rtc-node');
|
||||
const pods = await fetchJson('/api/pods');
|
||||
const verifyPod = pods.find((pod) => pod.id === 'frontend-pod') ?? pods[0];
|
||||
if (!verifyPod) throw new Error('no pods available for frontend verification');
|
||||
const { Room } = backendRequire('@livekit/rtc-node');
|
||||
|
||||
async function stopChild(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||
@@ -86,7 +73,7 @@ async function connectPublisher(roomName) {
|
||||
}),
|
||||
});
|
||||
const room = new Room();
|
||||
await room.connect(url, token, { autoSubscribe: true });
|
||||
await room.connect(url, token);
|
||||
return room;
|
||||
}
|
||||
|
||||
@@ -99,7 +86,7 @@ async function publishIntervention(room, podId) {
|
||||
podId,
|
||||
kind: 'card',
|
||||
message: 'Verification collision: two engineers are editing frontend/src/App.tsx.',
|
||||
suggestedAction: { kind: 'open_sync_pr' },
|
||||
suggestedAction: { kind: 'sync_before_push' },
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
};
|
||||
@@ -120,104 +107,6 @@ async function publishIntervention(room, podId) {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
return intervention;
|
||||
}
|
||||
|
||||
async function publishDataMessage(room, message) {
|
||||
await room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
}
|
||||
|
||||
async function publishAudioProbe(room) {
|
||||
const source = new AudioSource(24_000, 1, 5_000);
|
||||
const track = LocalAudioTrack.createAudioTrack(`verify-audio-${process.pid}`, source);
|
||||
const options = new TrackPublishOptions();
|
||||
options.source = TrackSource.SOURCE_MICROPHONE;
|
||||
const publication = await room.localParticipant.publishTrack(track, options);
|
||||
await source.captureFrame(new AudioFrame(new Int16Array(24_000), 24_000, 1, 24_000));
|
||||
return { source, publication };
|
||||
}
|
||||
|
||||
async function waitForAttachedAudio(page) {
|
||||
const audioSink = page.getByTestId('livekit-audio-sink');
|
||||
await audioSink.waitFor({ timeout: 15_000 });
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const count = await audioSink.locator('audio').count();
|
||||
if (count > 0) return;
|
||||
await delay(250);
|
||||
}
|
||||
throw new Error('LiveKit audio track was not attached to the hidden audio sink');
|
||||
}
|
||||
|
||||
async function waitForInterventionCard(page, room, podId) {
|
||||
const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.';
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const intervention = await publishIntervention(room, podId);
|
||||
try {
|
||||
await page.getByText(cardText).waitFor({ timeout: 5_000 });
|
||||
return intervention;
|
||||
} catch (error) {
|
||||
if (attempt === 3) throw error;
|
||||
await delay(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function liveKitService() {
|
||||
if (!process.env.LIVEKIT_URL || !process.env.LIVEKIT_API_KEY || !process.env.LIVEKIT_API_SECRET) {
|
||||
throw new Error('screen publication verification requires LIVEKIT_* env vars');
|
||||
}
|
||||
const httpUrl = process.env.LIVEKIT_URL.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
|
||||
return new RoomServiceClient(
|
||||
httpUrl,
|
||||
process.env.LIVEKIT_API_KEY,
|
||||
process.env.LIVEKIT_API_SECRET,
|
||||
);
|
||||
}
|
||||
|
||||
function hasScreenShareTrack(participant) {
|
||||
return (participant.tracks ?? []).some((track) => {
|
||||
const source = JSON.stringify(track).toLowerCase();
|
||||
return source.includes('screen') || source.includes('share');
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPublishedScreenShare(roomName) {
|
||||
const service = liveKitService();
|
||||
let lastParticipants = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
lastParticipants = await service.listParticipants(roomName);
|
||||
if (lastParticipants.some(hasScreenShareTrack)) return;
|
||||
await delay(500);
|
||||
}
|
||||
throw new Error(
|
||||
`LiveKit room service did not list a screen-share publication in ${roomName}: ${JSON.stringify(
|
||||
lastParticipants,
|
||||
).slice(0, 1000)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function boxOf(locator, name) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error(`${name} did not have a visible bounding box`);
|
||||
return box;
|
||||
}
|
||||
|
||||
function assertNoOverlap(left, main, right, label) {
|
||||
if (left.x + left.width > main.x + 2) {
|
||||
throw new Error(`${label}: left sidebar overlaps main workspace`);
|
||||
}
|
||||
if (main.x + main.width > right.x + 2) {
|
||||
throw new Error(`${label}: right sidebar overlaps main workspace`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertContained(container, child, label) {
|
||||
if (child.x < container.x - 2 || child.x + child.width > container.x + container.width + 2) {
|
||||
throw new Error(`${label}: body summary is not contained inside the main workspace`);
|
||||
}
|
||||
}
|
||||
|
||||
let preview = null;
|
||||
@@ -235,37 +124,6 @@ if (shouldStartPreview) {
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__podmanVerifyScreens = [];
|
||||
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...(globalThis.navigator.mediaDevices ?? {}),
|
||||
async getDisplayMedia() {
|
||||
const canvas = globalThis.document.createElement('canvas');
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas context unavailable');
|
||||
let frame = 0;
|
||||
const draw = () => {
|
||||
frame += 1;
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.font = '28px sans-serif';
|
||||
ctx.fillText('PodMan verification screen', 32, 72);
|
||||
ctx.fillText(`frame ${frame}`, 32, 120);
|
||||
};
|
||||
draw();
|
||||
const interval = globalThis.setInterval(draw, 200);
|
||||
const stream = canvas.captureStream(5);
|
||||
globalThis.__podmanVerifyScreens.push({ canvas, stream, interval });
|
||||
return stream;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
@@ -279,9 +137,7 @@ page.on('console', (msg) => {
|
||||
});
|
||||
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||
page.on('requestfailed', (req) => {
|
||||
const failure = req.failure()?.errorText ?? '';
|
||||
if (req.url().includes('/activity/stream') && failure.includes('ERR_ABORTED')) return;
|
||||
failedRequests.push(`${req.url()} ${failure}`.trim());
|
||||
failedRequests.push(`${req.url()} ${req.failure()?.errorText ?? ''}`.trim());
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -289,113 +145,23 @@ try {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
const hasPodCards = (await page.getByText(verifyPod.name, { exact: true }).count()) > 0;
|
||||
const hasPodCards =
|
||||
(await page.locator('text=/Frontend Pod|Backend Pod|graph pod/i').count()) > 0;
|
||||
const hasOverlay = (await page.locator('vite-error-overlay, .vite-error-overlay').count()) > 0;
|
||||
|
||||
if (bodyText.length < 100) throw new Error('frontend rendered too little text');
|
||||
if (!hasPodCards) throw new Error('pod cards did not render');
|
||||
if (hasOverlay) throw new Error('Vite error overlay is visible');
|
||||
|
||||
await page.getByRole('button', { name: 'Pod actions' }).first().click();
|
||||
await page.getByRole('menuitem', { name: 'Team memory' }).click();
|
||||
await page.getByRole('button', { name: 'Team memory' }).click();
|
||||
await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 });
|
||||
await page.getByText('Learning edges').waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('img', { name: 'PodMan team-memory graph' }).waitFor({ timeout: 15_000 });
|
||||
await page
|
||||
.getByRole('button', { name: /engineer:/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByText('Relationships').waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: 'Whole graph' }).click();
|
||||
await page.getByRole('button', { name: /Pods/i }).click();
|
||||
await page.getByPlaceholder('Your name').first().waitFor({ timeout: 15_000 });
|
||||
|
||||
const podCard = page
|
||||
.getByText(verifyPod.name, { exact: true })
|
||||
.locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]');
|
||||
await podCard.getByPlaceholder('Your name').first().fill(verifyMember);
|
||||
await podCard.getByRole('button', { name: 'Join' }).first().click();
|
||||
await page.getByPlaceholder('Your name').first().fill(verifyMember);
|
||||
await page.getByRole('button', { name: 'Add and join' }).first().click();
|
||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||
await page.getByTestId('live-conversation-toggle').waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: 'Start Live Conversation' }).waitFor({
|
||||
timeout: 15_000,
|
||||
});
|
||||
if (new URL(page.url()).pathname !== `/${verifyPod.id}`) {
|
||||
throw new Error(`join did not update URL to /${verifyPod.id}: ${page.url()}`);
|
||||
}
|
||||
await page.getByRole('heading', { name: 'My stream' }).waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('heading', { name: 'Team stream' }).waitFor({ timeout: 15_000 });
|
||||
const bodySummary = page.getByTestId('pod-body-summary');
|
||||
const mainWorkspace = page.getByTestId('pod-main-workspace');
|
||||
const mySidebar = page.getByTestId('my-stream-sidebar');
|
||||
const teamSidebar = page.getByTestId('team-stream-sidebar');
|
||||
await bodySummary.waitFor({ timeout: 15_000 });
|
||||
await mainWorkspace.waitFor({ timeout: 15_000 });
|
||||
await mySidebar.waitFor({ timeout: 15_000 });
|
||||
await teamSidebar.waitFor({ timeout: 15_000 });
|
||||
if ((await page.getByTestId('pod-topbar').count()) > 0) {
|
||||
throw new Error('pod detail view still renders a topbar test id');
|
||||
}
|
||||
if ((await bodySummary.getByRole('button', { name: /stream|team/i }).count()) > 0) {
|
||||
throw new Error('pod body summary contains sidebar stream/team controls');
|
||||
}
|
||||
const expandedLayout = {
|
||||
summary: await boxOf(bodySummary, 'body summary expanded'),
|
||||
main: await boxOf(mainWorkspace, 'main workspace expanded'),
|
||||
left: await boxOf(mySidebar, 'my stream sidebar expanded'),
|
||||
right: await boxOf(teamSidebar, 'team stream sidebar expanded'),
|
||||
};
|
||||
assertNoOverlap(
|
||||
expandedLayout.left,
|
||||
expandedLayout.main,
|
||||
expandedLayout.right,
|
||||
'expanded layout',
|
||||
);
|
||||
assertContained(expandedLayout.main, expandedLayout.summary, 'expanded layout');
|
||||
await page.locator('[data-testid="my-stream-toggle"]:visible').click();
|
||||
await page.waitForTimeout(300);
|
||||
const leftCollapsedLayout = {
|
||||
main: await boxOf(mainWorkspace, 'main workspace after left collapse'),
|
||||
left: await boxOf(mySidebar, 'my stream sidebar collapsed'),
|
||||
right: await boxOf(teamSidebar, 'team stream sidebar with left collapsed'),
|
||||
summary: await boxOf(bodySummary, 'body summary after left collapse'),
|
||||
};
|
||||
if (leftCollapsedLayout.left.width >= expandedLayout.left.width - 24) {
|
||||
throw new Error('my stream sidebar did not collapse into a compact rail');
|
||||
}
|
||||
if (leftCollapsedLayout.main.width <= expandedLayout.main.width) {
|
||||
throw new Error('main workspace did not expand after my stream collapsed');
|
||||
}
|
||||
assertNoOverlap(
|
||||
leftCollapsedLayout.left,
|
||||
leftCollapsedLayout.main,
|
||||
leftCollapsedLayout.right,
|
||||
'left collapsed layout',
|
||||
);
|
||||
assertContained(leftCollapsedLayout.main, leftCollapsedLayout.summary, 'left collapsed layout');
|
||||
await page.locator('[data-testid="team-stream-toggle"]:visible').click();
|
||||
await page.waitForTimeout(300);
|
||||
const bothCollapsedLayout = {
|
||||
main: await boxOf(mainWorkspace, 'main workspace after both collapse'),
|
||||
left: await boxOf(mySidebar, 'my stream sidebar with both collapsed'),
|
||||
right: await boxOf(teamSidebar, 'team stream sidebar collapsed'),
|
||||
summary: await boxOf(bodySummary, 'body summary after both collapse'),
|
||||
};
|
||||
if (bothCollapsedLayout.right.width >= expandedLayout.right.width - 24) {
|
||||
throw new Error('team stream sidebar did not collapse into a compact rail');
|
||||
}
|
||||
if (bothCollapsedLayout.main.width <= leftCollapsedLayout.main.width) {
|
||||
throw new Error('main workspace did not expand after team stream collapsed');
|
||||
}
|
||||
assertNoOverlap(
|
||||
bothCollapsedLayout.left,
|
||||
bothCollapsedLayout.main,
|
||||
bothCollapsedLayout.right,
|
||||
'both collapsed layout',
|
||||
);
|
||||
assertContained(bothCollapsedLayout.main, bothCollapsedLayout.summary, 'both collapsed layout');
|
||||
await page.locator('[data-testid="my-stream-toggle"]:visible').click();
|
||||
await page.locator('[data-testid="team-stream-toggle"]:visible').click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const joinedText = await page.locator('body').innerText();
|
||||
const hasPodView =
|
||||
@@ -407,52 +173,12 @@ try {
|
||||
throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Share screen' }).click();
|
||||
await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ timeout: 15_000 });
|
||||
await page.getByText(/Screen\s*published/i).waitFor({ timeout: 15_000 });
|
||||
await waitForPublishedScreenShare(verifyPod.id);
|
||||
await page.getByRole('button', { name: 'Stop sharing' }).click();
|
||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||
|
||||
const publisher = await connectPublisher(verifyPod.id);
|
||||
const publisher = await connectPublisher('frontend-pod');
|
||||
try {
|
||||
const audioProbe = await publishAudioProbe(publisher);
|
||||
try {
|
||||
await waitForAttachedAudio(page);
|
||||
} finally {
|
||||
if (audioProbe.publication.sid) {
|
||||
await publisher.localParticipant
|
||||
.unpublishTrack(audioProbe.publication.sid, true)
|
||||
.catch(() => {});
|
||||
}
|
||||
await audioProbe.source.close().catch(() => {});
|
||||
}
|
||||
|
||||
const intervention = await waitForInterventionCard(page, publisher, verifyPod.id);
|
||||
await publishDataMessage(publisher, {
|
||||
type: 'HERMES_MESSAGE',
|
||||
message: {
|
||||
id: `hermes-${process.pid}`,
|
||||
podId: verifyPod.id,
|
||||
interventionId: intervention.id,
|
||||
recipients: ['Verify'],
|
||||
text: 'Hermes verification message routed to the team.',
|
||||
urgency: 'normal',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await publishDataMessage(publisher, {
|
||||
type: 'VOICE_CUE',
|
||||
text: 'Voice cue verification for urgent escalation.',
|
||||
});
|
||||
await page.getByText('Hermes message').waitFor({ timeout: 15_000 });
|
||||
await page.getByText('Hermes verification message routed to the team.').waitFor({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByText('Voice cue', { exact: true }).waitFor({ timeout: 15_000 });
|
||||
await page.getByText('Voice cue verification for urgent escalation.').waitFor({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await publishIntervention(publisher, 'frontend-pod');
|
||||
await page
|
||||
.getByText('Verification collision: two engineers are editing frontend/src/App.tsx.')
|
||||
.waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await page.getByText('No collision detected').waitFor({ timeout: 15_000 });
|
||||
} finally {
|
||||
@@ -460,7 +186,6 @@ try {
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Leave pod' }).click();
|
||||
await page.waitForURL((url) => url.pathname === '/', { timeout: 15_000 });
|
||||
|
||||
if (consoleErrors.length) throw new Error(`console errors: ${consoleErrors.join(' | ')}`);
|
||||
if (pageErrors.length) throw new Error(`page errors: ${pageErrors.join(' | ')}`);
|
||||
@@ -471,14 +196,10 @@ try {
|
||||
{
|
||||
ok: true,
|
||||
frontendUrl,
|
||||
apiBase,
|
||||
bodyLength: bodyText.length,
|
||||
graph: true,
|
||||
joined: true,
|
||||
screenShare: 'livekit-published',
|
||||
audioSink: 'livekit-attached',
|
||||
intervention: 'collision-hermes-voice',
|
||||
podId: verifyPod.id,
|
||||
intervention: true,
|
||||
member: verifyMember,
|
||||
},
|
||||
null,
|
||||
@@ -486,7 +207,7 @@ try {
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await doFetch(`${apiBase}/api/pods/${verifyPod.id}/members/${encodeURIComponent(verifyMember)}`, {
|
||||
await doFetch(`${apiBase}/api/pods/frontend-pod/members/${encodeURIComponent(verifyMember)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {});
|
||||
await browser.close();
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function requireText(haystack, needle, label) {
|
||||
if (!haystack.includes(needle)) fail(`${label} missing: ${needle}`);
|
||||
}
|
||||
|
||||
function sectionBetween(text, start, end) {
|
||||
const startAt = text.indexOf(start);
|
||||
if (startAt === -1) fail(`section not found: ${start}`);
|
||||
const endAt = end ? text.indexOf(end, startAt + start.length) : -1;
|
||||
return text.slice(startAt, endAt === -1 ? undefined : endAt);
|
||||
}
|
||||
|
||||
function requireEnvKeys(section, keys, label) {
|
||||
for (const key of keys) {
|
||||
requireText(section, `key: ${key}`, label);
|
||||
}
|
||||
}
|
||||
|
||||
const [appSpec, doSpec, dockerfile, digitalOceanDocs] = await Promise.all([
|
||||
readFile('infra/app.yaml', 'utf8'),
|
||||
readFile('infra/.do/app.yaml', 'utf8'),
|
||||
readFile('infra/Dockerfile', 'utf8'),
|
||||
readFile('docs/digitalocean.md', 'utf8'),
|
||||
]);
|
||||
|
||||
if (appSpec !== doSpec) {
|
||||
fail('infra/.do/app.yaml must stay identical to infra/app.yaml');
|
||||
}
|
||||
|
||||
const web = sectionBetween(appSpec, 'static_sites:', 'services:');
|
||||
const api = sectionBetween(appSpec, ' - name: api', 'workers:');
|
||||
const worker = sectionBetween(appSpec, ' - name: podman-agent');
|
||||
|
||||
requireText(web, 'output_dir: frontend/dist', 'web static site');
|
||||
requireText(web, 'value: ${APP_URL}', 'web static site VITE_BACKEND_URL');
|
||||
requireEnvKeys(web, ['VITE_BACKEND_URL', 'VITE_LIVEKIT_URL'], 'web static site envs');
|
||||
|
||||
requireText(api, 'dockerfile_path: infra/Dockerfile', 'api service');
|
||||
requireText(api, 'http_port: 8787', 'api service');
|
||||
requireText(api, 'http_path: /health', 'api service health check');
|
||||
requireText(api, 'path: /api', 'api service route');
|
||||
requireText(api, 'preserve_path_prefix: true', 'api service route');
|
||||
requireText(api, 'value: server', 'api service PODMAN_PROCESS');
|
||||
|
||||
requireText(worker, 'dockerfile_path: infra/Dockerfile', 'worker');
|
||||
requireText(worker, 'value: agent', 'worker PODMAN_PROCESS');
|
||||
requireText(worker, 'value: demo-pod', 'worker POD_ROOM');
|
||||
if (worker.includes('health_check:') || worker.includes('http_port:')) {
|
||||
fail('podman-agent must remain a worker, not a health-checked HTTP service');
|
||||
}
|
||||
|
||||
const runtimeKeys = [
|
||||
'LIVEKIT_URL',
|
||||
'LIVEKIT_API_KEY',
|
||||
'LIVEKIT_API_SECRET',
|
||||
'GEMINI_API_KEY',
|
||||
'GEMINI_VISION_MODEL',
|
||||
'GEMINI_LIVE_MODEL',
|
||||
'GEMINI_TTS_VOICE',
|
||||
'GEMINI_EMBEDDING_MODEL',
|
||||
'GITHUB_TOKEN',
|
||||
'GITHUB_REPO',
|
||||
'MONGODB_URI',
|
||||
];
|
||||
requireEnvKeys(api, ['PODMAN_PROCESS', 'PORT', ...runtimeKeys], 'api service envs');
|
||||
requireEnvKeys(worker, ['PODMAN_PROCESS', 'POD_ROOM', ...runtimeKeys], 'worker envs');
|
||||
|
||||
requireText(dockerfile, 'ENV PODMAN_PROCESS=server', 'Dockerfile');
|
||||
requireText(dockerfile, 'node backend/dist/agent.js', 'Dockerfile');
|
||||
requireText(dockerfile, 'node backend/dist/server.js', 'Dockerfile');
|
||||
requireText(dockerfile, 'EXPOSE 8787', 'Dockerfile');
|
||||
|
||||
requireText(digitalOceanDocs, 'docker run --env-file backend/.env', 'DigitalOcean docs');
|
||||
requireText(digitalOceanDocs, '`/api` with `preserve_path_prefix: true`', 'DigitalOcean docs');
|
||||
requireText(
|
||||
digitalOceanDocs,
|
||||
'`podman-agent`: background LiveKit/Gemini worker',
|
||||
'DigitalOcean docs',
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
checks: [
|
||||
'app-spec-mirror',
|
||||
'static-site-envs',
|
||||
'api-route-preserves-prefix',
|
||||
'worker-split',
|
||||
'runtime-env-keys',
|
||||
'docker-entrypoint',
|
||||
'digitalocean-docs',
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
export type PodActivityKind = 'observation' | 'git' | 'collision' | 'intervention' | 'outcome';
|
||||
|
||||
export type PodActivitySource = 'vision' | 'git' | 'memory' | 'hermes' | 'policy';
|
||||
|
||||
export type PodActivitySeverity = 'info' | 'warn' | 'critical' | 'success';
|
||||
|
||||
export interface PodActivityEvent {
|
||||
id: string;
|
||||
podId: string;
|
||||
kind: PodActivityKind;
|
||||
source: PodActivitySource;
|
||||
title: string;
|
||||
detail?: string;
|
||||
actor?: string;
|
||||
actors?: string[];
|
||||
file?: string;
|
||||
imageUrl?: string;
|
||||
severity: PodActivitySeverity;
|
||||
at: string;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
export type AgentRunStatus =
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'improved'
|
||||
| 'regressed'
|
||||
| 'abandoned';
|
||||
|
||||
export interface AgentRun {
|
||||
runId: string;
|
||||
podId: string;
|
||||
goal: string;
|
||||
trigger: string;
|
||||
strategyVersionId: string;
|
||||
status: AgentRunStatus;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
score?: number;
|
||||
verifierSummary?: string;
|
||||
inputRefs?: string[];
|
||||
outputRefs?: string[];
|
||||
}
|
||||
|
||||
export interface AgentTraceEvent {
|
||||
runId: string;
|
||||
podId: string;
|
||||
step: number;
|
||||
phase: string;
|
||||
eventType: string;
|
||||
inputSummary?: string;
|
||||
outputSummary?: string;
|
||||
toolName?: string;
|
||||
error?: string;
|
||||
metrics?: Record<string, number | string | boolean>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type StrategyVersionKind = 'prompt' | 'policy' | 'detector' | 'verifier' | 'routing';
|
||||
|
||||
export type StrategyVersionStatus = 'candidate' | 'active' | 'retired' | 'rejected';
|
||||
|
||||
export interface StrategyVersion {
|
||||
strategyVersionId: string;
|
||||
podId: string;
|
||||
kind: StrategyVersionKind;
|
||||
name: string;
|
||||
parentVersionId?: string;
|
||||
status: StrategyVersionStatus;
|
||||
summary: string;
|
||||
promptText?: string;
|
||||
policy?: Record<string, unknown>;
|
||||
verifier?: Record<string, unknown>;
|
||||
metrics?: Record<string, number | string | boolean>;
|
||||
createdAt: string;
|
||||
promotedAt?: string;
|
||||
}
|
||||
|
||||
export type LearningProposalStatus = 'open' | 'accepted' | 'rejected' | 'superseded';
|
||||
|
||||
export interface LearningProposal {
|
||||
proposalId: string;
|
||||
podId: string;
|
||||
sourceRunId: string;
|
||||
targetKind: StrategyVersionKind;
|
||||
parentVersionId: string;
|
||||
proposedChange: string;
|
||||
rationale: string;
|
||||
verifierPlan: string;
|
||||
status: LearningProposalStatus;
|
||||
createdAt: string;
|
||||
resolvedAt?: string;
|
||||
}
|
||||
@@ -16,14 +16,6 @@ export interface Collision {
|
||||
severity: CollisionSeverity;
|
||||
/** Snapshot of relevant GitHub state at detection time. */
|
||||
githubState?: GithubStateSnapshot;
|
||||
/**
|
||||
* Git ground-truth overlap captured AT detection time, while engineer_states
|
||||
* are still fresh: true when every involved engineer had `file` in their git
|
||||
* changedFiles. Read as the authoritative wasRealCollision evidence at outcome
|
||||
* time, so a late click, a stale sidecar, or the engineer_states freshness TTL
|
||||
* cannot retroactively zero it out.
|
||||
*/
|
||||
gitOverlap?: boolean;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ export interface EngineerContext {
|
||||
hasUnpushedChanges?: boolean;
|
||||
/** 0–1 confidence in this read of the screen. */
|
||||
confidence: number;
|
||||
/** Small redacted-size JPEG data URL for sidebar preview, not the full frame. */
|
||||
screenshotDataUrl?: string;
|
||||
/** ISO timestamp of the observation this context came from. */
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
+1
-38
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* Served embedded in the `team_model` document; mirrored into `graph_nodes` /
|
||||
* `graph_edges` collections so the model can be walked with MongoDB
|
||||
* `$graphLookup`. See docs/cont_learning.md.
|
||||
* `$graphLookup`. See docs/graph.md.
|
||||
*/
|
||||
|
||||
export type PodGraphNodeKind = 'engineer' | 'feature' | 'file' | 'collision' | 'intervention';
|
||||
@@ -48,41 +48,6 @@ export interface PodGraphMetric {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export type PodLearningLoopStepKey = 'observe' | 'store' | 'predict' | 'outcome' | 'adapt';
|
||||
|
||||
export type PodLearningLoopStepStatus = 'quiet' | 'active' | 'complete' | 'planned';
|
||||
|
||||
export interface PodLearningLoopStep {
|
||||
key: PodLearningLoopStepKey;
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
status: PodLearningLoopStepStatus;
|
||||
}
|
||||
|
||||
export interface PodLearningLoop {
|
||||
activeStep: PodLearningLoopStepKey;
|
||||
steps: PodLearningLoopStep[];
|
||||
}
|
||||
|
||||
export type PodGraphActivityKind =
|
||||
| 'editing'
|
||||
| 'collision'
|
||||
| 'intervention'
|
||||
| 'outcome'
|
||||
| 'learned'
|
||||
| 'agent';
|
||||
|
||||
export interface PodGraphActivity {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: PodGraphActivityKind;
|
||||
title: string;
|
||||
detail: string;
|
||||
nodeId?: string;
|
||||
edgeId?: string;
|
||||
}
|
||||
|
||||
/** A point-in-time render of a pod's team_model. */
|
||||
export interface PodGraph {
|
||||
podId: string;
|
||||
@@ -91,8 +56,6 @@ export interface PodGraph {
|
||||
nodes: PodGraphNode[];
|
||||
edges: PodGraphEdge[];
|
||||
metrics: PodGraphMetric[];
|
||||
loop?: PodLearningLoop;
|
||||
activity?: PodGraphActivity[];
|
||||
}
|
||||
|
||||
/** One node as a standalone document in the `graph_nodes` collection. */
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
export type HermesJobStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'waiting_for_confirmation'
|
||||
| 'aborting'
|
||||
| 'aborted'
|
||||
| 'failed'
|
||||
| 'completed';
|
||||
|
||||
export type HermesJobEventType =
|
||||
| 'accepted'
|
||||
| 'heartbeat'
|
||||
| 'step_started'
|
||||
| 'step_output'
|
||||
| 'needs_confirmation'
|
||||
| 'step_completed'
|
||||
| 'aborted'
|
||||
| 'failed'
|
||||
| 'completed';
|
||||
|
||||
export type HermesContextScope =
|
||||
| 'current_pod'
|
||||
| 'current_repo'
|
||||
| 'current_file'
|
||||
| 'github'
|
||||
| 'mongodb'
|
||||
| 'terminal'
|
||||
| 'full_workspace';
|
||||
|
||||
export type HermesRiskLevel = 'read_only' | 'safe_write' | 'commit_allowed' | 'deploy_allowed';
|
||||
|
||||
export interface HermesJobInput {
|
||||
prompt: string;
|
||||
contextScope: HermesContextScope;
|
||||
targetRepository?: string;
|
||||
riskLevel: HermesRiskLevel;
|
||||
requiresConfirmation?: boolean;
|
||||
successCriteria: string[];
|
||||
podId: string;
|
||||
identity: string;
|
||||
sessionId: string;
|
||||
conversationRoom?: string;
|
||||
parentJobId?: string;
|
||||
}
|
||||
|
||||
export interface HermesJob {
|
||||
id: string;
|
||||
podId: string;
|
||||
identity: string;
|
||||
sessionId: string;
|
||||
conversationRoom?: string;
|
||||
prompt: string;
|
||||
contextScope: HermesContextScope;
|
||||
targetRepository: string;
|
||||
riskLevel: HermesRiskLevel;
|
||||
requiresConfirmation: boolean;
|
||||
successCriteria: string[];
|
||||
parentJobId?: string;
|
||||
status: HermesJobStatus;
|
||||
finalSummary?: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
lastHeartbeatAt?: string;
|
||||
abortRequestedAt?: string;
|
||||
}
|
||||
|
||||
export interface HermesJobEvent {
|
||||
id: string;
|
||||
jobId: string;
|
||||
podId: string;
|
||||
sessionId: string;
|
||||
type: HermesJobEventType;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
export type { Pod, PodInput, Engineer } from './pod.js';
|
||||
export type { EngineerContext } from './engineer.js';
|
||||
export type {
|
||||
PodActivityEvent,
|
||||
PodActivityKind,
|
||||
PodActivitySeverity,
|
||||
PodActivitySource,
|
||||
} from './activity.js';
|
||||
export type { Collision, CollisionSeverity, GithubStateSnapshot } from './collision.js';
|
||||
export type {
|
||||
Intervention,
|
||||
@@ -15,46 +9,14 @@ export type {
|
||||
SuggestedActionKind,
|
||||
} from './intervention.js';
|
||||
export * from './messages.js';
|
||||
export type { HermesMessage, LiveConversationEvent } from './messages.js';
|
||||
export type {
|
||||
HermesContextScope,
|
||||
HermesJob,
|
||||
HermesJobEvent,
|
||||
HermesJobEventType,
|
||||
HermesJobInput,
|
||||
HermesJobStatus,
|
||||
HermesRiskLevel,
|
||||
} from './hermes-job.js';
|
||||
export type {
|
||||
PodGraph,
|
||||
PodGraphNode,
|
||||
PodGraphEdge,
|
||||
PodGraphMetric,
|
||||
PodLearningLoop,
|
||||
PodLearningLoopStep,
|
||||
PodLearningLoopStepKey,
|
||||
PodLearningLoopStepStatus,
|
||||
PodGraphActivity,
|
||||
PodGraphActivityKind,
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
GraphNodeDoc,
|
||||
GraphEdgeDoc,
|
||||
} from './graph.js';
|
||||
export type {
|
||||
AgentRun,
|
||||
AgentRunStatus,
|
||||
AgentTraceEvent,
|
||||
StrategyVersion,
|
||||
StrategyVersionKind,
|
||||
StrategyVersionStatus,
|
||||
LearningProposal,
|
||||
LearningProposalStatus,
|
||||
} from './agent-learning.js';
|
||||
export type {
|
||||
MemberWorkHistory,
|
||||
MemberWorkHistoryEvent,
|
||||
MemberWorkHistoryFile,
|
||||
MemberWorkHistorySource,
|
||||
} from './member-history.js';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user