Compare commits
8 Commits
feat/multi
...
feature/pi
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ed59c0334 | |||
| d9e3baaa36 | |||
| 9c71a5d431 | |||
| 77e3e8ac52 | |||
| bb90b237f4 | |||
| e41edea745 | |||
| 8f1352013c | |||
| 5443cdddbf |
52
.env.example
52
.env.example
@@ -1,4 +1,19 @@
|
||||
# ── Ollama (local / self-hosted) ────────────────────────────────────────────────
|
||||
# ── Run mode ────────────────────────────────────────────────────────────────
|
||||
# gateway (default) starts HTTP API + streaming endpoint + simple web UI
|
||||
# single runs one-shot mode (PROMPT/stdin) and exits
|
||||
RUN_MODE=gateway
|
||||
|
||||
# ── Gateway server ───────────────────────────────────────────────────────────
|
||||
GATEWAY_HOST=0.0.0.0
|
||||
GATEWAY_PORT=8787
|
||||
# Set to * or a specific origin for browser apps hosted elsewhere
|
||||
# GATEWAY_CORS_ORIGIN=*
|
||||
# Optional bearer token required on every API request
|
||||
# GATEWAY_AUTH_TOKEN=replace-me
|
||||
# Disable built-in web UI at /
|
||||
GATEWAY_ENABLE_WEB_UI=true
|
||||
|
||||
# ── Ollama (local / self-hosted) ────────────────────────────────────────────
|
||||
# To use a local Ollama instance, override PROVIDER and MODEL:
|
||||
# PROVIDER=ollama
|
||||
# MODEL=llama3.2 # or qwen2.5-coder, mistral, deepseek-r1:14b …
|
||||
@@ -12,7 +27,7 @@
|
||||
# Locally (no Docker): change to http://localhost:11434/v1
|
||||
#
|
||||
# OLLAMA_BASE_URL=http://host.docker.internal:11434/v1
|
||||
# OLLAMA_CONTEXT_WINDOW=32768 # tokens passed as num_ctx
|
||||
# OLLAMA_CONTEXT_WINDOW=32768
|
||||
# OLLAMA_MAX_TOKENS=8192
|
||||
|
||||
# ── Model ────────────────────────────────────────────────────────────────────
|
||||
@@ -22,7 +37,7 @@
|
||||
PROVIDER=anthropic
|
||||
MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# ── Authentication ───────────────────────────────────────────────────────────
|
||||
# Generic API key for the selected PROVIDER (injected at runtime, not stored).
|
||||
API_KEY=sk-ant-...
|
||||
|
||||
@@ -36,36 +51,37 @@ API_KEY=sk-ant-...
|
||||
# XAI_API_KEY=...
|
||||
# OPENROUTER_API_KEY=...
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────────────────
|
||||
# The user message to send. Alternatively pipe via stdin.
|
||||
PROMPT=List all .ts files in the current directory.
|
||||
# ── Prompt (single mode only) ───────────────────────────────────────────────
|
||||
# Used only when RUN_MODE=single.
|
||||
# PROMPT=List all .ts files in the current directory.
|
||||
|
||||
# ── System prompt ─────────────────────────────────────────────────────────────
|
||||
# ── System prompt ────────────────────────────────────────────────────────────
|
||||
# Completely replaces the default system prompt (optional).
|
||||
# SYSTEM_PROMPT=You are a helpful assistant. Work inside /app only.
|
||||
|
||||
# Appended to the (possibly overridden) system prompt (optional).
|
||||
# APPEND_SYSTEM_PROMPT=Always answer in English.
|
||||
|
||||
# ── Thinking ──────────────────────────────────────────────────────────────────
|
||||
# off | minimal | low | medium | high | xhigh (default: off)
|
||||
# ── Thinking ─────────────────────────────────────────────────────────────────
|
||||
# off | minimal | low | medium | high | xhigh
|
||||
THINKING_LEVEL=off
|
||||
|
||||
# ── Tools ─────────────────────────────────────────────────────────────────────
|
||||
# all → read, bash, edit, write (default)
|
||||
# readonly → read only
|
||||
# ── Tools ────────────────────────────────────────────────────────────────────
|
||||
# all → read, bash, edit, write (default)
|
||||
# readonly → read, grep, find, ls
|
||||
# none → no built-in tools
|
||||
# Or a comma-separated list: read,bash
|
||||
# Or a comma-separated subset of coding tools: read,bash,edit,write
|
||||
TOOLS=all
|
||||
|
||||
# ── Working directory ─────────────────────────────────────────────────────────
|
||||
# ── Working directory ────────────────────────────────────────────────────────
|
||||
# Directory the agent reads/writes. Maps to the Docker volume mount point.
|
||||
CWD=/app
|
||||
|
||||
# ── Session persistence ───────────────────────────────────────────────────────
|
||||
# true → persist session files under CWD; false/unset → in-memory (default)
|
||||
SESSION_PERSIST=false
|
||||
# ── Session persistence ──────────────────────────────────────────────────────
|
||||
# true → persist agent sessions and gateway conversation index under CWD/.gateway
|
||||
# false → in-memory sessions only
|
||||
SESSION_PERSIST=true
|
||||
|
||||
# ── Verbose tool logging ──────────────────────────────────────────────────────
|
||||
# ── Verbose tool logging ─────────────────────────────────────────────────────
|
||||
# true → log tool start/end events to stderr
|
||||
VERBOSE_TOOLS=false
|
||||
|
||||
@@ -43,7 +43,9 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix={{branch}}-
|
||||
# Use a stable non-empty prefix. {{branch}} can be empty on PR events,
|
||||
# which would otherwise generate an invalid tag like ":-<sha>".
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
@@ -51,6 +53,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
# push: ${{ gitea.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/**
|
||||
.pi/
|
||||
.DS_Store
|
||||
.env
|
||||
@@ -34,5 +34,11 @@ VOLUME ["/app"]
|
||||
ENV NODE_ENV=production
|
||||
# Default working directory for the agent (overridable via CWD env var)
|
||||
ENV CWD=/app
|
||||
# Gateway defaults
|
||||
ENV RUN_MODE=gateway
|
||||
ENV GATEWAY_HOST=0.0.0.0
|
||||
ENV GATEWAY_PORT=8787
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
139
README.md
Normal file
139
README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# MoA Agent Gateway
|
||||
|
||||
This project now runs as a **chat gateway** in front of `@mariozechner/pi-coding-agent`.
|
||||
|
||||
It supports:
|
||||
- long-lived agent conversations
|
||||
- HTTP JSON API
|
||||
- streaming responses via Server-Sent Events (SSE)
|
||||
- a built-in browser chat UI at `/`
|
||||
|
||||
You can also keep one-shot mode (`RUN_MODE=single`) for script usage.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- Gateway internals: `docs/gateway.md`
|
||||
- Web UI internals: `docs/web-ui.md`
|
||||
- Channel integrations (Web UI, Slack, Matrix, custom): `docs/channels.md`
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# set provider/model/key in .env
|
||||
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Gateway default URL:
|
||||
- `http://localhost:8787`
|
||||
|
||||
Health check:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8787/health
|
||||
```
|
||||
|
||||
One-shot mode (legacy behavior):
|
||||
|
||||
```bash
|
||||
RUN_MODE=single PROMPT="List files" npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### Create/list conversations
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/v1/conversations \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"conversationId":"web:user-42:chat-a"}'
|
||||
|
||||
curl http://localhost:8787/v1/conversations
|
||||
```
|
||||
|
||||
### Chat (non-streaming)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/v1/chat \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"conversationId":"web:user-42:chat-a","message":"Summarize this repo"}'
|
||||
```
|
||||
|
||||
### Chat (streaming SSE)
|
||||
|
||||
```bash
|
||||
curl -N -X POST http://localhost:8787/v1/chat/stream \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"conversationId":"web:user-42:chat-a","message":"List TypeScript files"}'
|
||||
```
|
||||
|
||||
### Adapter-friendly endpoint (Slack/Matrix/etc)
|
||||
|
||||
Instead of constructing `conversationId` in your adapter, you can call:
|
||||
|
||||
- `POST /v1/adapters/chat`
|
||||
- `POST /v1/adapters/chat/stream`
|
||||
|
||||
Body fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "slack",
|
||||
"workspaceId": "T123",
|
||||
"channelId": "C456",
|
||||
"threadId": "1712233.991",
|
||||
"userId": "U789",
|
||||
"message": "Summarize the thread"
|
||||
}
|
||||
```
|
||||
|
||||
The gateway derives a stable conversation id:
|
||||
`source:workspaceId:channelId:threadId`
|
||||
|
||||
SSE events include:
|
||||
- `assistant_text_delta`
|
||||
- `assistant_thinking_delta`
|
||||
- `tool_start`, `tool_update`, `tool_end`
|
||||
- `agent_start`, `agent_end`
|
||||
- `done` (final response payload)
|
||||
- `error`
|
||||
|
||||
---
|
||||
|
||||
## Built-in Web UI
|
||||
|
||||
Open:
|
||||
|
||||
- `http://localhost:8787/`
|
||||
|
||||
The UI stores `conversationId` in local storage and reuses it to keep context.
|
||||
|
||||
---
|
||||
|
||||
## Auth and CORS
|
||||
|
||||
Optional env vars:
|
||||
- `GATEWAY_AUTH_TOKEN`: require `Authorization: Bearer <token>`
|
||||
- `GATEWAY_CORS_ORIGIN`: e.g. `*` or `https://my-ui.example`
|
||||
|
||||
---
|
||||
|
||||
## Multi-client strategy (Slack / Matrix / custom UI)
|
||||
|
||||
The gateway is transport-agnostic. For each upstream client, map its thread identity to a stable `conversationId`, for example:
|
||||
|
||||
- Web: `web:<userId>:<chatId>`
|
||||
- Slack: `slack:<teamId>:<channelId>:<threadTs>`
|
||||
- Matrix: `matrix:<roomId>:<threadRootEventId>`
|
||||
|
||||
Then post messages to `/v1/chat` or `/v1/chat/stream` with that `conversationId`.
|
||||
|
||||
This keeps one agent session per thread across transports.
|
||||
For detailed setup guidance per transport, see `docs/channels.md`.
|
||||
19
compose.yaml
19
compose.yaml
@@ -2,7 +2,21 @@ services:
|
||||
agent:
|
||||
# image: agent:latest
|
||||
build: .
|
||||
ports:
|
||||
- "${GATEWAY_PORT:-8787}:${GATEWAY_PORT:-8787}"
|
||||
environment:
|
||||
# ── Run mode ───────────────────────────────────────────────────────────
|
||||
# gateway (default) starts the HTTP gateway + web UI
|
||||
# single runs one-shot prompt mode (PROMPT/stdin) and exits
|
||||
- RUN_MODE=${RUN_MODE:-gateway}
|
||||
|
||||
# ── Gateway server ─────────────────────────────────────────────────────
|
||||
- GATEWAY_HOST=0.0.0.0
|
||||
- GATEWAY_PORT=${GATEWAY_PORT:-8787}
|
||||
- GATEWAY_CORS_ORIGIN=${GATEWAY_CORS_ORIGIN:-}
|
||||
- GATEWAY_AUTH_TOKEN=${GATEWAY_AUTH_TOKEN:-}
|
||||
- GATEWAY_ENABLE_WEB_UI=${GATEWAY_ENABLE_WEB_UI:-true}
|
||||
|
||||
# ── Model ──────────────────────────────────────────────────────────────
|
||||
# Both PROVIDER and MODEL must be set together to select a specific model.
|
||||
# If omitted, the agent uses the first available / settings model.
|
||||
@@ -32,7 +46,8 @@ services:
|
||||
- OLLAMA_CONTEXT_WINDOW=${OLLAMA_CONTEXT_WINDOW:-32768}
|
||||
- OLLAMA_MAX_TOKENS=${OLLAMA_MAX_TOKENS:-8192}
|
||||
|
||||
# ── Prompt ─────────────────────────────────────────────────────────────
|
||||
# ── Prompt (single mode only) ─────────────────────────────────────────
|
||||
# Used only when RUN_MODE=single.
|
||||
- PROMPT=${PROMPT:-}
|
||||
|
||||
# ── System prompt ──────────────────────────────────────────────────────
|
||||
@@ -51,7 +66,7 @@ services:
|
||||
- CWD=/app
|
||||
|
||||
# ── Session persistence ────────────────────────────────────────────────
|
||||
- SESSION_PERSIST=${SESSION_PERSIST:-false}
|
||||
- SESSION_PERSIST=${SESSION_PERSIST:-true}
|
||||
|
||||
# ── Verbose tool logging ───────────────────────────────────────────────
|
||||
- VERBOSE_TOOLS=${VERBOSE_TOOLS:-false}
|
||||
|
||||
5
docs/README.md
Normal file
5
docs/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Documentation
|
||||
|
||||
- [Gateway: how it works](./gateway.md)
|
||||
- [Web UI: how it works](./web-ui.md)
|
||||
- [Channels: Web UI, Slack, Matrix, custom adapters](./channels.md)
|
||||
231
docs/channels.md
Normal file
231
docs/channels.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# Channels: Web UI, Slack, Matrix, and custom adapters
|
||||
|
||||
This document explains how **channels** work in the gateway and how to integrate transports like Web UI, Slack, Matrix, or your own chat source.
|
||||
|
||||
---
|
||||
|
||||
## What is a "channel" in this project?
|
||||
|
||||
In this gateway, a channel is not a separate server object you provision.
|
||||
|
||||
A channel is simply an upstream conversation context (for example: a Slack thread, a Matrix room thread, or a browser chat tab) that maps to one stable `conversationId`.
|
||||
|
||||
The gateway uses that `conversationId` to keep one long-lived agent session.
|
||||
|
||||
---
|
||||
|
||||
## How channels are represented internally
|
||||
|
||||
You have two integration options:
|
||||
|
||||
1. **Direct chat endpoints**
|
||||
- `POST /v1/chat`
|
||||
- `POST /v1/chat/stream`
|
||||
- You provide `conversationId` directly.
|
||||
|
||||
2. **Adapter endpoints** (recommended for Slack/Matrix/etc.)
|
||||
- `POST /v1/adapters/chat`
|
||||
- `POST /v1/adapters/chat/stream`
|
||||
- You provide channel/thread fields; the gateway derives `conversationId` for you.
|
||||
|
||||
Adapter ID format:
|
||||
|
||||
- `conversationId = source:workspaceId:channelId:threadId`
|
||||
- `adapterKey = source:workspaceId:channelId:threadId:userId`
|
||||
|
||||
Field behavior on adapter routes:
|
||||
|
||||
- `source` (optional, default: `generic`)
|
||||
- `workspaceId` (optional, default: `default`)
|
||||
- `channelId` (**required**)
|
||||
- `threadId` (optional, default: `root`)
|
||||
- `userId` (optional, default: `anonymous`)
|
||||
|
||||
Important constraints:
|
||||
|
||||
- segment values must be strings
|
||||
- `channelId` must not be empty
|
||||
- segment values must not contain `:`
|
||||
|
||||
If your upstream IDs contain `:` (common in Matrix), sanitize/encode them before sending to adapter routes.
|
||||
|
||||
---
|
||||
|
||||
## Do I need to "create" a channel first?
|
||||
|
||||
Usually: **no**.
|
||||
|
||||
A conversation/session is auto-created on first message. You can optionally pre-create one via:
|
||||
|
||||
- `POST /v1/conversations`
|
||||
|
||||
But for most channel integrations, you just start sending messages to chat endpoints with stable IDs.
|
||||
|
||||
---
|
||||
|
||||
## Requirements checklist (all channels)
|
||||
|
||||
1. Gateway is running (`RUN_MODE=gateway`)
|
||||
2. Model/provider credentials are configured (`PROVIDER`, `MODEL`, `API_KEY` or provider-specific key)
|
||||
3. Your adapter/client can reach the gateway URL
|
||||
4. If `GATEWAY_AUTH_TOKEN` is set, send `Authorization: Bearer <token>`
|
||||
5. For browser apps on another origin, set `GATEWAY_CORS_ORIGIN`
|
||||
6. If you want restart-safe sessions, set `SESSION_PERSIST=true`
|
||||
|
||||
---
|
||||
|
||||
## Web UI channel
|
||||
|
||||
### Built-in UI
|
||||
|
||||
The built-in UI is available at `GET /` when:
|
||||
|
||||
- `GATEWAY_ENABLE_WEB_UI=true`
|
||||
|
||||
How it works:
|
||||
|
||||
- sends messages to `/v1/chat/stream`
|
||||
- stores `conversationId` in browser local storage
|
||||
- "New session" clears local `conversationId` and starts a new thread
|
||||
|
||||
### Custom web app
|
||||
|
||||
If you build your own web frontend, call `/v1/chat` or `/v1/chat/stream` and choose your own ID format, e.g.:
|
||||
|
||||
- `web:<userId>:<chatId>`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"conversationId": "web:user-42:chat-main",
|
||||
"message": "Summarize the last response"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slack channel integration
|
||||
|
||||
There is no built-in Slack bot in this repository; you run a small Slack adapter service.
|
||||
|
||||
Typical adapter needs:
|
||||
|
||||
- Slack bot token + app configuration (events/interactions)
|
||||
- webhook/event receiver in your adapter
|
||||
- code that forwards messages to gateway and posts replies back to Slack
|
||||
|
||||
Recommended mapping from Slack event payload:
|
||||
|
||||
- `source`: `"slack"`
|
||||
- `workspaceId`: Slack team/workspace ID (e.g. `T123`)
|
||||
- `channelId`: Slack channel ID (e.g. `C456`) **required**
|
||||
- `threadId`: Slack `thread_ts` (or fallback strategy)
|
||||
- `userId`: Slack user ID (e.g. `U789`)
|
||||
|
||||
Example request to gateway:
|
||||
|
||||
```bash
|
||||
curl -N -X POST http://localhost:8787/v1/adapters/chat/stream \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{
|
||||
"source": "slack",
|
||||
"workspaceId": "T123",
|
||||
"channelId": "C456",
|
||||
"threadId": "1712233.991",
|
||||
"userId": "U789",
|
||||
"message": "Please summarize this thread"
|
||||
}'
|
||||
```
|
||||
|
||||
Threading strategy tip:
|
||||
|
||||
- use a stable `threadId` per Slack thread to keep context isolated per thread
|
||||
- if you omit `threadId`, gateway uses `root` (all messages for that channel collapse into one thread context)
|
||||
|
||||
---
|
||||
|
||||
## Matrix channel integration
|
||||
|
||||
There is no built-in Matrix bot in this repository; use a Matrix bot/bridge process as adapter.
|
||||
|
||||
Typical adapter needs:
|
||||
|
||||
- Matrix bot account + access token
|
||||
- event listener for room messages
|
||||
- logic to send assistant output back into room/thread
|
||||
|
||||
Recommended mapping:
|
||||
|
||||
- `source`: `"matrix"`
|
||||
- `workspaceId`: homeserver or deployment identifier (optional but useful)
|
||||
- `channelId`: Matrix `room_id` (**required**, encoded/sanitized for adapter route)
|
||||
- `threadId`: Matrix thread root event ID (encoded/sanitized, or omit for `root`)
|
||||
- `userId`: Matrix sender ID (encoded/sanitized)
|
||||
|
||||
Because adapter segments cannot contain `:`, Matrix IDs should be encoded in the adapter, e.g.:
|
||||
|
||||
```ts
|
||||
const encodeSegment = (value: string) => encodeURIComponent(value);
|
||||
```
|
||||
|
||||
Example payload (encoded values):
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "matrix",
|
||||
"workspaceId": "matrix.org",
|
||||
"channelId": "%21roomid%3Amatrix.org",
|
||||
"threadId": "%24rootEventId%3Amatrix.org",
|
||||
"userId": "%40alice%3Amatrix.org",
|
||||
"message": "What did we decide in this thread?"
|
||||
}
|
||||
```
|
||||
|
||||
Alternative: call `/v1/chat` directly and provide your own `conversationId` format if you do not want per-segment adapter normalization.
|
||||
|
||||
---
|
||||
|
||||
## Custom channel template (Discord, Telegram, email, etc.)
|
||||
|
||||
For any source, map your upstream identifiers into adapter fields and keep that mapping stable.
|
||||
|
||||
Minimal payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"channelId": "your-channel-id",
|
||||
"message": "Hello"
|
||||
}
|
||||
```
|
||||
|
||||
Full payload template:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "custom",
|
||||
"workspaceId": "tenant-a",
|
||||
"channelId": "channel-123",
|
||||
"threadId": "thread-456",
|
||||
"userId": "user-789",
|
||||
"message": "Hello"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best practices
|
||||
|
||||
- Keep `threadId` stable per thread (do not generate random values per message)
|
||||
- Include `userId` for audit/telemetry (`adapterKey`) even though it does not change `conversationId`
|
||||
- Avoid `:` in any adapter segment value
|
||||
- Use streaming endpoints for better UX (`/stream`)
|
||||
- Persist sessions in production (`SESSION_PERSIST=true`)
|
||||
- Add retry/backoff logic in your adapter for network failures
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- Gateway internals/API: `docs/gateway.md`
|
||||
- Built-in browser UI: `docs/web-ui.md`
|
||||
228
docs/gateway.md
Normal file
228
docs/gateway.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Gateway: how it works
|
||||
|
||||
This document explains how the HTTP gateway in this repository works.
|
||||
|
||||
## Overview
|
||||
|
||||
The gateway is a thin HTTP layer around `@mariozechner/pi-coding-agent` sessions.
|
||||
|
||||
Main goals:
|
||||
|
||||
- expose chat over HTTP (`/v1/chat`, `/v1/chat/stream`)
|
||||
- keep long-lived conversation state per `conversationId`
|
||||
- support adapter-friendly IDs (Slack/Matrix/etc.)
|
||||
- optionally expose a built-in browser UI at `/`
|
||||
|
||||
Key source files:
|
||||
|
||||
- `src/index.ts`
|
||||
- `src/gateway/server.ts`
|
||||
- `src/conversation-manager.ts`
|
||||
- `src/agent-session-factory.ts`
|
||||
- `src/gateway/events.ts`
|
||||
|
||||
---
|
||||
|
||||
## Startup flow
|
||||
|
||||
1. `src/index.ts` loads env config via `loadConfig()`.
|
||||
2. If `RUN_MODE=single`, one-shot mode is executed and exits.
|
||||
3. Otherwise (`RUN_MODE=gateway`), it:
|
||||
- creates `ConversationManager`
|
||||
- initializes persisted conversation metadata (if enabled)
|
||||
- starts `GatewayHttpServer`
|
||||
4. On `SIGINT`/`SIGTERM`, it stops the HTTP server and disposes sessions.
|
||||
|
||||
---
|
||||
|
||||
## Core components
|
||||
|
||||
### 1) `GatewayHttpServer` (`src/gateway/server.ts`)
|
||||
|
||||
Responsible for:
|
||||
|
||||
- request routing
|
||||
- auth and CORS handling
|
||||
- request validation
|
||||
- SSE streaming responses
|
||||
- JSON/HTML responses
|
||||
|
||||
### 2) `ConversationManager` (`src/conversation-manager.ts`)
|
||||
|
||||
Responsible for:
|
||||
|
||||
- creating and tracking conversation records
|
||||
- loading/opening/creating agent sessions
|
||||
- serializing prompts per conversation (queue)
|
||||
- persisting conversation index + session metadata
|
||||
- aborting/deleting sessions
|
||||
|
||||
### 3) `AgentSessionFactory` (`src/agent-session-factory.ts`)
|
||||
|
||||
Responsible for constructing agent sessions with:
|
||||
|
||||
- model/provider selection (including Ollama support)
|
||||
- tool selection (`all`, `readonly`, `none`, or subset)
|
||||
- optional system prompt override/append
|
||||
- auth storage and model registry wiring
|
||||
|
||||
---
|
||||
|
||||
## Conversation model
|
||||
|
||||
A conversation is identified by `conversationId`.
|
||||
|
||||
- If client provides no ID, a UUID is generated.
|
||||
- Each conversation maps to one `AgentSession`.
|
||||
- Multiple requests for the same conversation are queued and processed in order.
|
||||
- Metadata is exposed via `/v1/conversations` endpoints.
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `conversationId` max length: 200
|
||||
- `conversationId` must not contain `\n`/`\r`
|
||||
- `message` must be a non-empty string
|
||||
- `images` must be an array when provided
|
||||
- `streamingBehavior` must be `"steer"` or `"followUp"` when provided
|
||||
|
||||
---
|
||||
|
||||
## Persistence behavior
|
||||
|
||||
Controlled by `SESSION_PERSIST`.
|
||||
|
||||
### `SESSION_PERSIST=true`
|
||||
|
||||
Data is stored under:
|
||||
|
||||
- `<CWD>/.gateway/conversations.json` (conversation index)
|
||||
- `<CWD>/.gateway/sessions/...` (session files)
|
||||
|
||||
At startup, the index is loaded and conversations are restored as unloaded records.
|
||||
The actual `AgentSession` is lazily opened when that conversation is used.
|
||||
|
||||
### `SESSION_PERSIST=false`
|
||||
|
||||
Everything is in memory and lost on process exit.
|
||||
|
||||
---
|
||||
|
||||
## API routes
|
||||
|
||||
### Health/UI
|
||||
|
||||
- `GET /health` → `{ "ok": true }`
|
||||
- `GET /` → built-in Web UI HTML (if `GATEWAY_ENABLE_WEB_UI=true`)
|
||||
|
||||
### Conversation management
|
||||
|
||||
- `GET /v1/conversations`
|
||||
- `POST /v1/conversations`
|
||||
- `GET /v1/conversations/:id`
|
||||
- `DELETE /v1/conversations/:id`
|
||||
- `POST /v1/conversations/:id/abort`
|
||||
|
||||
### Chat
|
||||
|
||||
- `POST /v1/chat` (JSON response)
|
||||
- `POST /v1/chat/stream` (SSE response)
|
||||
|
||||
### Adapter endpoints
|
||||
|
||||
- `POST /v1/adapters/chat`
|
||||
- `POST /v1/adapters/chat/stream`
|
||||
|
||||
Adapter request fields (`source`, `workspaceId`, `channelId`, `threadId`, `userId`) are normalized into:
|
||||
|
||||
- `conversationId = source:workspaceId:channelId:threadId`
|
||||
- `adapterKey = source:workspaceId:channelId:threadId:userId`
|
||||
|
||||
`channelId` is required. `:` is not allowed inside segment values.
|
||||
|
||||
For practical setup patterns per transport (Web UI, Slack, Matrix, custom), see `docs/channels.md`.
|
||||
|
||||
---
|
||||
|
||||
## Streaming (SSE) behavior
|
||||
|
||||
For `/v1/chat/stream` and `/v1/adapters/chat/stream`:
|
||||
|
||||
1. Response starts with SSE headers.
|
||||
2. A `ready` event is emitted.
|
||||
3. Agent session events are mapped to gateway events (`src/gateway/events.ts`).
|
||||
4. A final `done` event is emitted with summary payload.
|
||||
5. On failure, an `error` event is emitted and stream ends.
|
||||
|
||||
Common emitted event types:
|
||||
|
||||
- `assistant_text_delta`
|
||||
- `assistant_thinking_delta`
|
||||
- `assistant_message_update`
|
||||
- `tool_start`, `tool_update`, `tool_end`
|
||||
- `agent_start`, `agent_end`
|
||||
- `retry_start`, `retry_end`
|
||||
- `compaction_start`, `compaction_end`
|
||||
- `done`
|
||||
- `error`
|
||||
|
||||
`done` includes:
|
||||
|
||||
- `conversationId`
|
||||
- `sessionId`
|
||||
- `sessionFile`
|
||||
- `assistantText`
|
||||
- plus `adapterKey` on adapter streaming routes
|
||||
|
||||
Disconnect behavior:
|
||||
|
||||
- if client disconnects mid-stream **and** the request had a `conversationId`, the server attempts to abort that conversation.
|
||||
|
||||
---
|
||||
|
||||
## Auth and CORS
|
||||
|
||||
### Bearer auth
|
||||
|
||||
If `GATEWAY_AUTH_TOKEN` is set, requests must include:
|
||||
|
||||
`Authorization: Bearer <token>`
|
||||
|
||||
Otherwise server returns `401`.
|
||||
|
||||
Note: auth is checked before route handling, so this applies to all routes (including `GET /` and `GET /health`).
|
||||
|
||||
### CORS
|
||||
|
||||
If `GATEWAY_CORS_ORIGIN` is set, server adds:
|
||||
|
||||
- `Access-Control-Allow-Origin`
|
||||
- `Access-Control-Allow-Headers: Content-Type, Authorization`
|
||||
- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS`
|
||||
|
||||
`OPTIONS` preflight returns `204`.
|
||||
|
||||
---
|
||||
|
||||
## Request limits and errors
|
||||
|
||||
- JSON body max size: 1 MiB (413 if exceeded)
|
||||
- invalid JSON: 400
|
||||
- invalid payload field types: 400
|
||||
- unknown route: 404
|
||||
- unexpected errors: 500
|
||||
|
||||
---
|
||||
|
||||
## Environment variables (gateway-relevant)
|
||||
|
||||
- `RUN_MODE` (`gateway` | `single`)
|
||||
- `GATEWAY_HOST`
|
||||
- `GATEWAY_PORT`
|
||||
- `GATEWAY_CORS_ORIGIN`
|
||||
- `GATEWAY_AUTH_TOKEN`
|
||||
- `GATEWAY_ENABLE_WEB_UI`
|
||||
- `SESSION_PERSIST`
|
||||
- `VERBOSE_TOOLS`
|
||||
- `CWD`
|
||||
|
||||
See `.env.example` for complete defaults and comments.
|
||||
157
docs/web-ui.md
Normal file
157
docs/web-ui.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Web UI: how it works
|
||||
|
||||
This document explains the built-in browser UI served by the gateway.
|
||||
|
||||
Source file:
|
||||
|
||||
- `src/gateway/web-ui.ts`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Web UI is a single HTML page returned by `GET /` (when enabled).
|
||||
|
||||
It is intentionally simple:
|
||||
|
||||
- plain HTML/CSS/JS (no framework)
|
||||
- sends requests to `/v1/chat/stream`
|
||||
- renders streamed assistant text in real time
|
||||
- stores and reuses `conversationId` in `localStorage`
|
||||
|
||||
---
|
||||
|
||||
## Availability
|
||||
|
||||
The UI route is controlled by `GATEWAY_ENABLE_WEB_UI`:
|
||||
|
||||
- `true` (default): `GET /` returns UI
|
||||
- `false`: `GET /` returns `404` with `{ "error": "Web UI disabled" }`
|
||||
|
||||
If `GATEWAY_AUTH_TOKEN` is enabled, `GET /` also requires an `Authorization` header, because auth is global in the gateway.
|
||||
|
||||
---
|
||||
|
||||
## UI sections
|
||||
|
||||
### 1) Session/header card
|
||||
|
||||
- **Conversation ID input** (`#conversationId`)
|
||||
- if empty, server auto-creates one during first message
|
||||
- persisted locally under `pi_gateway_conversation_id`
|
||||
- **Auth token input** (`#token`)
|
||||
- optional bearer token included in API requests from the page
|
||||
- this affects `fetch` calls only; it does not add auth headers to the initial page load
|
||||
|
||||
### 2) Messages card
|
||||
|
||||
- container `#messages`
|
||||
- each message is appended as a `.msg.user` or `.msg.assistant` block
|
||||
- text is rendered as plain text (`textContent`), not Markdown/HTML
|
||||
|
||||
### 3) Composer card
|
||||
|
||||
- textarea `#message`
|
||||
- status text `#status`
|
||||
- buttons:
|
||||
- `Send`
|
||||
- `New session`
|
||||
|
||||
---
|
||||
|
||||
## Local state
|
||||
|
||||
The page keeps only minimal browser-side state:
|
||||
|
||||
- `conversationId` in input + local storage
|
||||
- rendered message list in DOM
|
||||
- current request state via button disabled/enabled
|
||||
|
||||
Storage key:
|
||||
|
||||
- `pi_gateway_conversation_id`
|
||||
|
||||
On load, if this key exists, it pre-fills the conversation input.
|
||||
|
||||
---
|
||||
|
||||
## Send flow
|
||||
|
||||
When user presses **Send** (or Cmd/Ctrl + Enter):
|
||||
|
||||
1. Trim textarea value; ignore empty input.
|
||||
2. Disable `Send` and `New session` buttons.
|
||||
3. Append user message bubble.
|
||||
4. Append empty assistant bubble.
|
||||
5. Build payload:
|
||||
- required: `message`
|
||||
- optional: `conversationId` (if input non-empty)
|
||||
6. POST to `/v1/chat/stream` with JSON body.
|
||||
7. Parse SSE stream incrementally.
|
||||
8. Update assistant bubble and status based on events.
|
||||
9. Re-enable buttons when request finishes/fails.
|
||||
|
||||
---
|
||||
|
||||
## SSE event handling in UI
|
||||
|
||||
Handled events:
|
||||
|
||||
- `assistant_text_delta`
|
||||
- appends `data.delta` to assistant message bubble
|
||||
- `done`
|
||||
- reads `data.conversationId`
|
||||
- updates conversation input
|
||||
- writes `pi_gateway_conversation_id`
|
||||
- status becomes `Done • conversation <id>`
|
||||
- `error`
|
||||
- status becomes error text
|
||||
- writes fallback error into assistant bubble if empty
|
||||
|
||||
Other event types are currently ignored by the UI.
|
||||
|
||||
---
|
||||
|
||||
## New session button behavior
|
||||
|
||||
Clicking **New session**:
|
||||
|
||||
- does nothing if a request is currently streaming (`Send` disabled)
|
||||
- clears conversation ID input
|
||||
- removes `pi_gateway_conversation_id` from local storage
|
||||
- clears rendered message list
|
||||
- sets status to `New session ready`
|
||||
- focuses the message textarea
|
||||
|
||||
This starts a fresh client-side chat thread. The next send will create a new conversation on the server.
|
||||
|
||||
---
|
||||
|
||||
## Keyboard shortcut
|
||||
|
||||
In the message textarea:
|
||||
|
||||
- `Cmd + Enter` (macOS) or `Ctrl + Enter` (Windows/Linux)
|
||||
- triggers the same send flow as the Send button
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
Current UI is intentionally minimal:
|
||||
|
||||
- no server-side message history loading
|
||||
- no cancel/abort button for in-flight response
|
||||
- no rendering for tool events/thinking events
|
||||
- no Markdown formatting
|
||||
- no multi-conversation sidebar
|
||||
|
||||
It is best used as a lightweight test/debug interface.
|
||||
|
||||
---
|
||||
|
||||
## Related API docs
|
||||
|
||||
For full gateway/API details, see:
|
||||
|
||||
- `docs/gateway.md`
|
||||
@@ -7,6 +7,8 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"start:single": "RUN_MODE=single node dist/index.js",
|
||||
"start:gateway": "RUN_MODE=gateway node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
158
src/agent-session-factory.ts
Normal file
158
src/agent-session-factory.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { getModel, type Model } from "@mariozechner/pi-ai";
|
||||
import type { AgentTool, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
createBashTool,
|
||||
createCodingTools,
|
||||
createEditTool,
|
||||
createReadOnlyTools,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
DefaultResourceLoader,
|
||||
ModelRegistry,
|
||||
type AgentSession,
|
||||
type SessionManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentConfig } from "./config.js";
|
||||
|
||||
function resolveModel(config: AgentConfig): Model<any> | undefined {
|
||||
const providerName = config.providerName;
|
||||
const modelId = config.modelId;
|
||||
|
||||
if (providerName?.toLowerCase() === "ollama") {
|
||||
if (!modelId) {
|
||||
throw new Error("PROVIDER=ollama requires MODEL to be set.");
|
||||
}
|
||||
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-completions",
|
||||
provider: "ollama",
|
||||
baseUrl: config.ollamaBaseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: config.ollamaContextWindow,
|
||||
maxTokens: config.ollamaMaxTokens,
|
||||
compat: {
|
||||
supportsStore: false,
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
supportsStrictMode: false,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
} satisfies Model<"openai-completions">;
|
||||
}
|
||||
|
||||
if (providerName && modelId) {
|
||||
const model = (getModel as (provider: string, model: string) => Model<any> | undefined)(
|
||||
providerName,
|
||||
modelId,
|
||||
);
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`Model "${providerName}/${modelId}" not found. Run \"pi --list-models\" to inspect available models.`,
|
||||
);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
if (providerName || modelId) {
|
||||
console.warn(
|
||||
"[agent] PROVIDER and MODEL must be set together. Falling back to default model resolution.",
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveTools(cwd: string, toolsEnv: string): AgentTool[] {
|
||||
const normalized = toolsEnv.toLowerCase().trim();
|
||||
|
||||
if (normalized === "all") {
|
||||
return createCodingTools(cwd);
|
||||
}
|
||||
|
||||
if (normalized === "readonly" || normalized === "read-only") {
|
||||
return createReadOnlyTools(cwd);
|
||||
}
|
||||
|
||||
if (normalized === "none") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const customFactories: Record<string, (dir: string) => AgentTool<any, any>> = {
|
||||
read: createReadTool,
|
||||
bash: createBashTool,
|
||||
edit: createEditTool,
|
||||
write: createWriteTool,
|
||||
};
|
||||
|
||||
const tools = normalized
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => {
|
||||
const factory = customFactories[name];
|
||||
if (!factory) {
|
||||
console.warn(`[agent] Unknown tool \"${name}\", ignoring.`);
|
||||
return undefined;
|
||||
}
|
||||
return factory(cwd);
|
||||
})
|
||||
.filter((tool): tool is AgentTool<any, any> => tool !== undefined);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
export class AgentSessionFactory {
|
||||
constructor(private readonly config: AgentConfig) {}
|
||||
|
||||
async createSession(sessionManager: SessionManager): Promise<AgentSession> {
|
||||
const authStorage = AuthStorage.create();
|
||||
|
||||
if (this.config.providerName && this.config.apiKey) {
|
||||
authStorage.setRuntimeApiKey(this.config.providerName, this.config.apiKey);
|
||||
}
|
||||
|
||||
if (this.config.providerName?.toLowerCase() === "ollama" && !this.config.apiKey) {
|
||||
authStorage.setRuntimeApiKey("ollama", "ollama");
|
||||
}
|
||||
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const model = resolveModel(this.config);
|
||||
const tools = resolveTools(this.config.cwd, this.config.toolsEnv);
|
||||
|
||||
let resourceLoader: DefaultResourceLoader | undefined;
|
||||
if (this.config.systemPrompt !== undefined || this.config.appendSystemPrompt !== undefined) {
|
||||
resourceLoader = new DefaultResourceLoader({
|
||||
cwd: this.config.cwd,
|
||||
systemPromptOverride: (base: string | undefined) => {
|
||||
let result = this.config.systemPrompt !== undefined ? this.config.systemPrompt : base;
|
||||
if (this.config.appendSystemPrompt) {
|
||||
result = result
|
||||
? `${result}\n\n${this.config.appendSystemPrompt}`
|
||||
: this.config.appendSystemPrompt;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
}
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
...(model ? { model } : {}),
|
||||
...(resourceLoader ? { resourceLoader } : {}),
|
||||
thinkingLevel: this.config.thinkingLevel as ThinkingLevel,
|
||||
tools,
|
||||
sessionManager,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
cwd: this.config.cwd,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
129
src/config.ts
Normal file
129
src/config.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from "node:path";
|
||||
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export interface AgentConfig {
|
||||
providerName?: string;
|
||||
modelId?: string;
|
||||
apiKey?: string;
|
||||
ollamaBaseUrl: string;
|
||||
ollamaContextWindow: number;
|
||||
ollamaMaxTokens: number;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
toolsEnv: string;
|
||||
systemPrompt?: string;
|
||||
appendSystemPrompt?: string;
|
||||
promptText?: string;
|
||||
cwd: string;
|
||||
sessionPersist: boolean;
|
||||
verboseTools: boolean;
|
||||
}
|
||||
|
||||
export interface GatewayConfig {
|
||||
runMode: "gateway" | "single";
|
||||
host: string;
|
||||
port: number;
|
||||
corsOrigin?: string;
|
||||
authToken?: string;
|
||||
enableWebUi: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
agent: AgentConfig;
|
||||
gateway: GatewayConfig;
|
||||
}
|
||||
|
||||
function env(name: string): string | undefined {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseIntEnv(name: string, fallback: number): number {
|
||||
const value = env(name);
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`Environment variable ${name} must be a valid integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoolEnv(name: string, fallback: boolean): boolean {
|
||||
const value = env(name);
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
switch (value.toLowerCase()) {
|
||||
case "1":
|
||||
case "true":
|
||||
case "yes":
|
||||
case "on":
|
||||
return true;
|
||||
case "0":
|
||||
case "false":
|
||||
case "no":
|
||||
case "off":
|
||||
return false;
|
||||
default:
|
||||
throw new Error(`Environment variable ${name} must be a boolean (true/false).`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveThinkingLevel(value: string | undefined): ThinkingLevel {
|
||||
const normalized = (value ?? "off").toLowerCase();
|
||||
const valid: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||
if (!valid.includes(normalized as ThinkingLevel)) {
|
||||
throw new Error(
|
||||
`Invalid THINKING_LEVEL "${value}". Expected one of: ${valid.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return normalized as ThinkingLevel;
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const cwd = path.resolve(env("CWD") ?? "/app");
|
||||
|
||||
const runModeRaw = (env("RUN_MODE") ?? "gateway").toLowerCase();
|
||||
const runMode = runModeRaw === "single" ? "single" : "gateway";
|
||||
|
||||
return {
|
||||
agent: {
|
||||
providerName: env("PROVIDER"),
|
||||
modelId: env("MODEL"),
|
||||
apiKey: env("API_KEY"),
|
||||
ollamaBaseUrl: env("OLLAMA_BASE_URL") ?? "http://host.docker.internal:11434/v1",
|
||||
ollamaContextWindow: parseIntEnv("OLLAMA_CONTEXT_WINDOW", 32768),
|
||||
ollamaMaxTokens: parseIntEnv("OLLAMA_MAX_TOKENS", 8192),
|
||||
thinkingLevel: resolveThinkingLevel(env("THINKING_LEVEL")),
|
||||
toolsEnv: env("TOOLS") ?? "all",
|
||||
systemPrompt: env("SYSTEM_PROMPT"),
|
||||
appendSystemPrompt: env("APPEND_SYSTEM_PROMPT"),
|
||||
promptText: env("PROMPT"),
|
||||
cwd,
|
||||
sessionPersist: parseBoolEnv("SESSION_PERSIST", false),
|
||||
verboseTools: parseBoolEnv("VERBOSE_TOOLS", false),
|
||||
},
|
||||
gateway: {
|
||||
runMode,
|
||||
host: env("GATEWAY_HOST") ?? "0.0.0.0",
|
||||
port: parseIntEnv("GATEWAY_PORT", 8787),
|
||||
corsOrigin: env("GATEWAY_CORS_ORIGIN"),
|
||||
authToken: env("GATEWAY_AUTH_TOKEN"),
|
||||
enableWebUi: parseBoolEnv("GATEWAY_ENABLE_WEB_UI", true),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
||||
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8").trim()));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
410
src/conversation-manager.ts
Normal file
410
src/conversation-manager.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
SessionManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentConfig } from "./config.js";
|
||||
import { AgentSessionFactory } from "./agent-session-factory.js";
|
||||
|
||||
export interface ChatRequest {
|
||||
conversationId?: string;
|
||||
message: string;
|
||||
images?: ImageContent[];
|
||||
streamingBehavior?: "steer" | "followUp";
|
||||
}
|
||||
|
||||
export interface ChatExecutionOptions {
|
||||
includeEvents?: boolean;
|
||||
onEvent?: (event: AgentSessionEvent) => void;
|
||||
}
|
||||
|
||||
export interface ChatResult {
|
||||
conversationId: string;
|
||||
sessionId: string;
|
||||
sessionFile?: string;
|
||||
assistantText: string;
|
||||
eventCount: number;
|
||||
events?: AgentSessionEvent[];
|
||||
}
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sessionId?: string;
|
||||
sessionFile?: string;
|
||||
isLoaded: boolean;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
interface PersistedConversation {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sessionFile?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface PersistedConversationIndex {
|
||||
version: 1;
|
||||
conversations: PersistedConversation[];
|
||||
}
|
||||
|
||||
interface ConversationRecord extends PersistedConversation {
|
||||
queue: Promise<void>;
|
||||
session?: AgentSession;
|
||||
sessionInit?: Promise<AgentSession>;
|
||||
}
|
||||
|
||||
export class ConversationManager {
|
||||
private readonly sessionFactory: AgentSessionFactory;
|
||||
private readonly conversations = new Map<string, ConversationRecord>();
|
||||
private initialized = false;
|
||||
private persistQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private readonly gatewayRootDir: string;
|
||||
private readonly sessionDir: string;
|
||||
private readonly indexPath: string;
|
||||
|
||||
constructor(private readonly config: AgentConfig) {
|
||||
this.sessionFactory = new AgentSessionFactory(config);
|
||||
this.gatewayRootDir = path.join(this.config.cwd, ".gateway");
|
||||
this.sessionDir = path.join(this.gatewayRootDir, "sessions");
|
||||
this.indexPath = path.join(this.gatewayRootDir, "conversations.json");
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.sessionPersist) {
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(this.indexPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as PersistedConversationIndex;
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.conversations)) {
|
||||
throw new Error("Unsupported conversation index format.");
|
||||
}
|
||||
|
||||
for (const item of parsed.conversations) {
|
||||
if (!item.id || typeof item.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
this.conversations.set(item.id, {
|
||||
id: item.id,
|
||||
createdAt: item.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: item.updatedAt ?? new Date().toISOString(),
|
||||
sessionFile: item.sessionFile,
|
||||
sessionId: item.sessionId,
|
||||
queue: Promise.resolve(),
|
||||
});
|
||||
}
|
||||
|
||||
console.error(`[gateway] Restored ${this.conversations.size} persisted conversation(s).`);
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code !== "ENOENT") {
|
||||
console.warn(`[gateway] Failed to load conversation index: ${nodeError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
listConversations(): ConversationSummary[] {
|
||||
return [...this.conversations.values()]
|
||||
.map((record) => this.toSummary(record))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
getConversation(conversationId: string): ConversationSummary | undefined {
|
||||
const record = this.conversations.get(conversationId);
|
||||
return record ? this.toSummary(record) : undefined;
|
||||
}
|
||||
|
||||
async createConversation(preferredId?: string): Promise<ConversationSummary> {
|
||||
await this.init();
|
||||
const id = this.resolveConversationId(preferredId);
|
||||
const record = this.getOrCreateRecord(id);
|
||||
await this.persistIndex();
|
||||
return this.toSummary(record);
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<boolean> {
|
||||
await this.init();
|
||||
const record = this.conversations.get(conversationId);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (record.session) {
|
||||
record.session.dispose();
|
||||
record.session = undefined;
|
||||
}
|
||||
|
||||
if (record.sessionFile && this.config.sessionPersist) {
|
||||
try {
|
||||
await fs.unlink(record.sessionFile);
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code !== "ENOENT") {
|
||||
console.warn(`[gateway] Failed to delete session file ${record.sessionFile}: ${nodeError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.conversations.delete(conversationId);
|
||||
await this.persistIndex();
|
||||
return true;
|
||||
}
|
||||
|
||||
async abortConversation(conversationId: string): Promise<void> {
|
||||
await this.init();
|
||||
const record = this.conversations.get(conversationId);
|
||||
if (!record?.session) {
|
||||
return;
|
||||
}
|
||||
await record.session.abort();
|
||||
}
|
||||
|
||||
async chat(request: ChatRequest, options: ChatExecutionOptions = {}): Promise<ChatResult> {
|
||||
await this.init();
|
||||
|
||||
const message = request.message.trim();
|
||||
if (!message) {
|
||||
throw new Error("message must be a non-empty string.");
|
||||
}
|
||||
|
||||
const conversationId = this.resolveConversationId(request.conversationId);
|
||||
const record = this.getOrCreateRecord(conversationId);
|
||||
|
||||
return this.enqueue(record, async () => {
|
||||
const session = await this.ensureSession(record);
|
||||
const collectedEvents: AgentSessionEvent[] | undefined = options.includeEvents ? [] : undefined;
|
||||
const assistantTextChunks: string[] = [];
|
||||
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
if (collectedEvents) {
|
||||
collectedEvents.push(event);
|
||||
}
|
||||
options.onEvent?.(event);
|
||||
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
assistantTextChunks.push(event.assistantMessageEvent.delta);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const streamingBehavior = request.streamingBehavior ?? (session.isStreaming ? "followUp" : undefined);
|
||||
|
||||
await session.prompt(message, {
|
||||
...(request.images ? { images: request.images } : {}),
|
||||
...(streamingBehavior ? { streamingBehavior } : {}),
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
record.updatedAt = new Date().toISOString();
|
||||
record.sessionFile = session.sessionFile;
|
||||
record.sessionId = session.sessionId;
|
||||
await this.persistIndex();
|
||||
|
||||
const assistantText = assistantTextChunks.join("") || this.getLastAssistantText(session);
|
||||
|
||||
return {
|
||||
conversationId: record.id,
|
||||
sessionId: session.sessionId,
|
||||
sessionFile: session.sessionFile,
|
||||
assistantText,
|
||||
eventCount: collectedEvents?.length ?? 0,
|
||||
...(collectedEvents ? { events: collectedEvents } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
for (const record of this.conversations.values()) {
|
||||
if (record.session) {
|
||||
record.session.dispose();
|
||||
record.session = undefined;
|
||||
}
|
||||
}
|
||||
await this.persistIndex();
|
||||
}
|
||||
|
||||
private toSummary(record: ConversationRecord): ConversationSummary {
|
||||
return {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
sessionId: record.session?.sessionId ?? record.sessionId,
|
||||
sessionFile: record.session?.sessionFile ?? record.sessionFile,
|
||||
isLoaded: record.session !== undefined,
|
||||
isStreaming: record.session?.isStreaming ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveConversationId(preferredId?: string): string {
|
||||
if (!preferredId) {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
const normalized = preferredId.trim();
|
||||
if (!normalized) {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
if (normalized.length > 200) {
|
||||
throw new Error("conversationId must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
if (/\r|\n/.test(normalized)) {
|
||||
throw new Error("conversationId must not contain newlines.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private getOrCreateRecord(conversationId: string): ConversationRecord {
|
||||
const existing = this.conversations.get(conversationId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const created: ConversationRecord = {
|
||||
id: conversationId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
queue: Promise.resolve(),
|
||||
};
|
||||
|
||||
this.conversations.set(conversationId, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private enqueue<T>(record: ConversationRecord, task: () => Promise<T>): Promise<T> {
|
||||
const run = record.queue.then(task, task);
|
||||
record.queue = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async ensureSession(record: ConversationRecord): Promise<AgentSession> {
|
||||
if (record.session) {
|
||||
return record.session;
|
||||
}
|
||||
|
||||
if (!record.sessionInit) {
|
||||
record.sessionInit = this.createSessionForRecord(record)
|
||||
.then((session) => {
|
||||
record.session = session;
|
||||
return session;
|
||||
})
|
||||
.finally(() => {
|
||||
record.sessionInit = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return record.sessionInit;
|
||||
}
|
||||
|
||||
private async createSessionForRecord(record: ConversationRecord): Promise<AgentSession> {
|
||||
let sessionManager: SessionManager;
|
||||
|
||||
if (!this.config.sessionPersist) {
|
||||
sessionManager = SessionManager.inMemory();
|
||||
} else if (record.sessionFile) {
|
||||
try {
|
||||
sessionManager = SessionManager.open(record.sessionFile);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[gateway] Failed to open persisted session ${record.sessionFile}, creating a new session.`,
|
||||
error,
|
||||
);
|
||||
sessionManager = SessionManager.create(this.config.cwd, this.sessionDir);
|
||||
}
|
||||
} else {
|
||||
sessionManager = SessionManager.create(this.config.cwd, this.sessionDir);
|
||||
}
|
||||
|
||||
const session = await this.sessionFactory.createSession(sessionManager);
|
||||
|
||||
if (this.config.verboseTools) {
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
process.stderr.write(`\n[${record.id}] [tool:start] ${event.toolName}\n`);
|
||||
} else if (event.type === "tool_execution_end") {
|
||||
process.stderr.write(`[${record.id}] [tool:end] ${event.toolName}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
record.sessionFile = session.sessionFile;
|
||||
record.sessionId = session.sessionId;
|
||||
|
||||
await this.persistIndex();
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private getLastAssistantText(session: AgentSession): string {
|
||||
for (let i = session.messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = session.messages[i];
|
||||
if (message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
|
||||
return message.content
|
||||
.filter((contentPart): contentPart is { type: "text"; text: string } => contentPart.type === "text")
|
||||
.map((contentPart) => contentPart.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private async persistIndex(): Promise<void> {
|
||||
if (!this.config.sessionPersist) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.persistQueue = this.persistQueue
|
||||
.catch((error) => {
|
||||
console.warn("[gateway] Previous persistence operation failed:", error);
|
||||
})
|
||||
.then(async () => {
|
||||
await fs.mkdir(this.gatewayRootDir, { recursive: true });
|
||||
await fs.mkdir(this.sessionDir, { recursive: true });
|
||||
|
||||
const payload: PersistedConversationIndex = {
|
||||
version: 1,
|
||||
conversations: [...this.conversations.values()].map((record) => ({
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
sessionFile: record.session?.sessionFile ?? record.sessionFile,
|
||||
sessionId: record.session?.sessionId ?? record.sessionId,
|
||||
})),
|
||||
};
|
||||
|
||||
const serialized = `${JSON.stringify(payload, null, 2)}\n`;
|
||||
const tempPath = `${this.indexPath}.tmp`;
|
||||
await fs.writeFile(tempPath, serialized, "utf8");
|
||||
await fs.rename(tempPath, this.indexPath);
|
||||
});
|
||||
|
||||
await this.persistQueue;
|
||||
}
|
||||
}
|
||||
119
src/gateway/events.ts
Normal file
119
src/gateway/events.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export interface GatewayEvent {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function toGatewayEvents(event: AgentSessionEvent): GatewayEvent[] {
|
||||
switch (event.type) {
|
||||
case "message_update": {
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
return [
|
||||
{
|
||||
type: "assistant_text_delta",
|
||||
data: { delta: event.assistantMessageEvent.delta },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (event.assistantMessageEvent.type === "thinking_delta") {
|
||||
return [
|
||||
{
|
||||
type: "assistant_thinking_delta",
|
||||
data: { delta: event.assistantMessageEvent.delta },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "assistant_message_update",
|
||||
data: { updateType: event.assistantMessageEvent.type },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case "tool_execution_start":
|
||||
return [
|
||||
{
|
||||
type: "tool_start",
|
||||
data: {
|
||||
toolName: event.toolName,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
case "tool_execution_update":
|
||||
return [
|
||||
{
|
||||
type: "tool_update",
|
||||
data: {
|
||||
toolName: event.toolName,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
case "tool_execution_end":
|
||||
return [
|
||||
{
|
||||
type: "tool_end",
|
||||
data: {
|
||||
toolName: event.toolName,
|
||||
toolCallId: event.toolCallId,
|
||||
isError: event.isError,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
case "agent_start":
|
||||
return [{ type: "agent_start", data: {} }];
|
||||
|
||||
case "agent_end":
|
||||
return [{ type: "agent_end", data: {} }];
|
||||
|
||||
case "auto_retry_start":
|
||||
return [
|
||||
{
|
||||
type: "retry_start",
|
||||
data: {
|
||||
attempt: event.attempt,
|
||||
maxAttempts: event.maxAttempts,
|
||||
delayMs: event.delayMs,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
case "auto_retry_end":
|
||||
return [
|
||||
{
|
||||
type: "retry_end",
|
||||
data: {
|
||||
success: event.success,
|
||||
attempt: event.attempt,
|
||||
finalError: event.finalError,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
case "auto_compaction_start":
|
||||
return [{ type: "compaction_start", data: { reason: event.reason } }];
|
||||
|
||||
case "auto_compaction_end":
|
||||
return [
|
||||
{
|
||||
type: "compaction_end",
|
||||
data: {
|
||||
aborted: event.aborted,
|
||||
willRetry: event.willRetry,
|
||||
errorMessage: event.errorMessage,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
default:
|
||||
return [{ type: event.type, data: {} }];
|
||||
}
|
||||
}
|
||||
432
src/gateway/server.ts
Normal file
432
src/gateway/server.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { URL } from "node:url";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { GatewayConfig } from "../config.js";
|
||||
import type { ChatRequest } from "../conversation-manager.js";
|
||||
import { ConversationManager } from "../conversation-manager.js";
|
||||
import { toGatewayEvents } from "./events.js";
|
||||
import { getWebUiHtml } from "./web-ui.js";
|
||||
|
||||
interface ChatRequestBody {
|
||||
conversationId?: unknown;
|
||||
message?: unknown;
|
||||
images?: unknown;
|
||||
streamingBehavior?: unknown;
|
||||
includeEvents?: unknown;
|
||||
}
|
||||
|
||||
interface AdapterChatRequestBody extends ChatRequestBody {
|
||||
source?: unknown;
|
||||
workspaceId?: unknown;
|
||||
channelId?: unknown;
|
||||
threadId?: unknown;
|
||||
userId?: unknown;
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayHttpServer {
|
||||
private readonly server: Server;
|
||||
|
||||
constructor(
|
||||
private readonly manager: ConversationManager,
|
||||
private readonly config: GatewayConfig,
|
||||
) {
|
||||
this.server = createServer((req, res) => {
|
||||
this.handleRequest(req, res).catch((error) => {
|
||||
if (error instanceof HttpError) {
|
||||
this.sendJson(res, error.statusCode, { error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[gateway] Unhandled request error", error);
|
||||
if (!res.headersSent) {
|
||||
this.sendJson(res, 500, { error: "Internal server error" });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.once("error", reject);
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
this.server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
console.error(`[gateway] Listening on http://${this.config.host}:${this.config.port}`);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (this.handleCors(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isAuthorized(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const { pathname } = url;
|
||||
const method = req.method ?? "GET";
|
||||
|
||||
if (method === "GET" && pathname === "/health") {
|
||||
this.sendJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && pathname === "/") {
|
||||
if (!this.config.enableWebUi) {
|
||||
this.sendJson(res, 404, { error: "Web UI disabled" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendHtml(res, 200, getWebUiHtml());
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && pathname === "/v1/conversations") {
|
||||
this.sendJson(res, 200, { conversations: this.manager.listConversations() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && pathname === "/v1/conversations") {
|
||||
const body = (await this.readJsonBody(req)) as { conversationId?: unknown };
|
||||
if (body.conversationId !== undefined && typeof body.conversationId !== "string") {
|
||||
throw new HttpError(400, "conversationId must be a string when provided.");
|
||||
}
|
||||
|
||||
const created = await this.manager.createConversation(body.conversationId as string | undefined);
|
||||
this.sendJson(res, 201, created);
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationMatch = pathname.match(/^\/v1\/conversations\/([^/]+)$/);
|
||||
if (method === "GET" && conversationMatch) {
|
||||
const conversationId = this.decodePathSegment(conversationMatch[1]);
|
||||
const conversation = this.manager.getConversation(conversationId);
|
||||
if (!conversation) {
|
||||
this.sendJson(res, 404, { error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
this.sendJson(res, 200, conversation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "DELETE" && conversationMatch) {
|
||||
const conversationId = this.decodePathSegment(conversationMatch[1]);
|
||||
const deleted = await this.manager.deleteConversation(conversationId);
|
||||
this.sendJson(res, deleted ? 200 : 404, { deleted });
|
||||
return;
|
||||
}
|
||||
|
||||
const abortMatch = pathname.match(/^\/v1\/conversations\/([^/]+)\/abort$/);
|
||||
if (method === "POST" && abortMatch) {
|
||||
const conversationId = this.decodePathSegment(abortMatch[1]);
|
||||
await this.manager.abortConversation(conversationId);
|
||||
this.sendJson(res, 200, { aborted: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && pathname === "/v1/adapters/chat") {
|
||||
const body = (await this.readJsonBody(req)) as AdapterChatRequestBody;
|
||||
const { chatRequest, adapterKey } = this.parseAdapterChatRequest(body);
|
||||
const includeEvents = body.includeEvents === true;
|
||||
|
||||
const result = await this.manager.chat(chatRequest, { includeEvents });
|
||||
this.sendJson(res, 200, { adapterKey, ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && pathname === "/v1/adapters/chat/stream") {
|
||||
const body = (await this.readJsonBody(req)) as AdapterChatRequestBody;
|
||||
const { chatRequest, adapterKey } = this.parseAdapterChatRequest(body);
|
||||
await this.handleChatStream(req, res, chatRequest, { adapterKey });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && pathname === "/v1/chat") {
|
||||
const body = (await this.readJsonBody(req)) as ChatRequestBody;
|
||||
const chatRequest = this.parseChatRequest(body);
|
||||
const includeEvents = body.includeEvents === true;
|
||||
|
||||
const result = await this.manager.chat(chatRequest, { includeEvents });
|
||||
this.sendJson(res, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && pathname === "/v1/chat/stream") {
|
||||
const body = (await this.readJsonBody(req)) as ChatRequestBody;
|
||||
const chatRequest = this.parseChatRequest(body);
|
||||
await this.handleChatStream(req, res, chatRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendJson(res, 404, { error: "Not found" });
|
||||
}
|
||||
|
||||
private async handleChatStream(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
chatRequest: ChatRequest,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
this.writeSseHeaders(res);
|
||||
this.writeSse(res, "ready", { ok: true });
|
||||
|
||||
let finished = false;
|
||||
const providedConversationId = chatRequest.conversationId;
|
||||
|
||||
req.on("close", () => {
|
||||
if (!finished && providedConversationId) {
|
||||
void this.manager.abortConversation(providedConversationId).catch((error) => {
|
||||
console.warn("[gateway] Failed to abort conversation after client disconnect", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.manager.chat(chatRequest, {
|
||||
onEvent: (event) => {
|
||||
for (const gatewayEvent of toGatewayEvents(event)) {
|
||||
this.writeSse(res, gatewayEvent.type, gatewayEvent.data);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.writeSse(res, "done", {
|
||||
...metadata,
|
||||
conversationId: result.conversationId,
|
||||
sessionId: result.sessionId,
|
||||
sessionFile: result.sessionFile,
|
||||
assistantText: result.assistantText,
|
||||
});
|
||||
|
||||
finished = true;
|
||||
res.end();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.writeSse(res, "error", { message });
|
||||
finished = true;
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
private parseChatRequest(body: ChatRequestBody): ChatRequest {
|
||||
if (typeof body.message !== "string") {
|
||||
throw new HttpError(400, "message is required and must be a string.");
|
||||
}
|
||||
|
||||
if (!body.message.trim()) {
|
||||
throw new HttpError(400, "message must not be empty.");
|
||||
}
|
||||
|
||||
const conversationId =
|
||||
typeof body.conversationId === "string" ? body.conversationId : undefined;
|
||||
|
||||
if (conversationId) {
|
||||
if (conversationId.length > 200) {
|
||||
throw new HttpError(400, "conversationId must be 200 characters or fewer.");
|
||||
}
|
||||
if (/\r|\n/.test(conversationId)) {
|
||||
throw new HttpError(400, "conversationId must not contain newlines.");
|
||||
}
|
||||
}
|
||||
|
||||
let images: ImageContent[] | undefined;
|
||||
if (body.images !== undefined) {
|
||||
if (!Array.isArray(body.images)) {
|
||||
throw new HttpError(400, "images must be an array when provided.");
|
||||
}
|
||||
images = body.images as ImageContent[];
|
||||
}
|
||||
|
||||
let streamingBehavior: "steer" | "followUp" | undefined;
|
||||
if (body.streamingBehavior !== undefined) {
|
||||
if (body.streamingBehavior === "steer" || body.streamingBehavior === "followUp") {
|
||||
streamingBehavior = body.streamingBehavior;
|
||||
} else {
|
||||
throw new HttpError(400, 'streamingBehavior must be "steer" or "followUp".');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversationId,
|
||||
message: body.message,
|
||||
...(images ? { images } : {}),
|
||||
...(streamingBehavior ? { streamingBehavior } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private parseAdapterChatRequest(body: AdapterChatRequestBody): {
|
||||
chatRequest: ChatRequest;
|
||||
adapterKey: string;
|
||||
} {
|
||||
const source = this.parseOptionalSegment(body.source, "source") ?? "generic";
|
||||
const workspaceId = this.parseOptionalSegment(body.workspaceId, "workspaceId") ?? "default";
|
||||
const channelId = this.parseRequiredSegment(body.channelId, "channelId");
|
||||
const threadId = this.parseOptionalSegment(body.threadId, "threadId") ?? "root";
|
||||
const userId = this.parseOptionalSegment(body.userId, "userId") ?? "anonymous";
|
||||
|
||||
const normalizedBody: ChatRequestBody = {
|
||||
...body,
|
||||
conversationId: `${source}:${workspaceId}:${channelId}:${threadId}`,
|
||||
};
|
||||
|
||||
return {
|
||||
adapterKey: `${source}:${workspaceId}:${channelId}:${threadId}:${userId}`,
|
||||
chatRequest: this.parseChatRequest(normalizedBody),
|
||||
};
|
||||
}
|
||||
|
||||
private parseOptionalSegment(value: unknown, fieldName: string): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
throw new HttpError(400, `${fieldName} must be a string when provided.`);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (trimmed.includes(":")) {
|
||||
throw new HttpError(400, `${fieldName} must not contain ':'.`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private parseRequiredSegment(value: unknown, fieldName: string): string {
|
||||
const segment = this.parseOptionalSegment(value, fieldName);
|
||||
if (!segment) {
|
||||
throw new HttpError(400, `${fieldName} is required.`);
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
private async readJsonBody(req: IncomingMessage, maxBytes = 1024 * 1024): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buffer.length;
|
||||
if (total > maxBytes) {
|
||||
throw new HttpError(413, `Request body exceeds ${maxBytes} bytes.`);
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
throw new HttpError(400, "Request body must be valid JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
private handleCors(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!this.config.corsOrigin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
res.setHeader("Access-Control-Allow-Origin", this.config.corsOrigin);
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private isAuthorized(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!this.config.authToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader === `Bearer ${this.config.authToken}`) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.sendJson(res, 401, { error: "Unauthorized" });
|
||||
return false;
|
||||
}
|
||||
|
||||
private decodePathSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
throw new HttpError(400, "Invalid path segment encoding.");
|
||||
}
|
||||
}
|
||||
|
||||
private sendJson(res: ServerResponse, statusCode: number, body: unknown): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
private sendHtml(res: ServerResponse, statusCode: number, html: string): void {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
private writeSseHeaders(res: ServerResponse): void {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
}
|
||||
|
||||
private writeSse(res: ServerResponse, event: string, data: unknown): void {
|
||||
if (res.writableEnded || res.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = typeof data === "string" ? data : JSON.stringify(data);
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${payload}\n\n`);
|
||||
}
|
||||
}
|
||||
318
src/gateway/web-ui.ts
Normal file
318
src/gateway/web-ui.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
export function getWebUiHtml(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pi Gateway Chat</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1115;
|
||||
--card: #171a21;
|
||||
--muted: #9da6b7;
|
||||
--accent: #7cc6ff;
|
||||
--user: #1b3146;
|
||||
--assistant: #1d2a1e;
|
||||
--border: #283042;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: #f6f8ff;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input, textarea, button {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #10131b;
|
||||
color: #f6f8ff;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #24415f;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #1f2734;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#messages {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 65vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.msg {
|
||||
white-space: pre-wrap;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.msg.user { background: var(--user); }
|
||||
.msg.assistant { background: var(--assistant); }
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #0b0f14;
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="card grid">
|
||||
<div class="meta">Gateway endpoint: <code>/v1/chat/stream</code></div>
|
||||
<div>
|
||||
<label for="conversationId">Conversation ID (reuse this across clients to continue context)</label>
|
||||
<input id="conversationId" placeholder="auto-generated when empty" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="token">Auth token (optional; only if gateway enforces Bearer auth)</label>
|
||||
<input id="token" type="password" placeholder="Bearer token" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="messages" class="card"></section>
|
||||
|
||||
<section class="card grid">
|
||||
<div>
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" placeholder="Ask the coding agent..."></textarea>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="meta" id="status">Idle</div>
|
||||
<button id="newSession" type="button" class="secondary">New session</button>
|
||||
<button id="send">Send</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const conversationInput = document.getElementById("conversationId");
|
||||
const tokenInput = document.getElementById("token");
|
||||
const messageInput = document.getElementById("message");
|
||||
const sendButton = document.getElementById("send");
|
||||
const newSessionButton = document.getElementById("newSession");
|
||||
const messagesEl = document.getElementById("messages");
|
||||
const statusEl = document.getElementById("status");
|
||||
|
||||
const storedConversationId = localStorage.getItem("pi_gateway_conversation_id");
|
||||
if (storedConversationId) {
|
||||
conversationInput.value = storedConversationId;
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
}
|
||||
|
||||
function addMessage(role, text = "") {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg " + role;
|
||||
el.textContent = text;
|
||||
messagesEl.appendChild(el);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return el;
|
||||
}
|
||||
|
||||
function startNewSession() {
|
||||
if (sendButton.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
conversationInput.value = "";
|
||||
localStorage.removeItem("pi_gateway_conversation_id");
|
||||
messagesEl.textContent = "";
|
||||
setStatus("New session ready");
|
||||
messageInput.focus();
|
||||
}
|
||||
|
||||
async function consumeSse(body, onEvent) {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (true) {
|
||||
const boundary = buffer.indexOf("\\n\\n");
|
||||
if (boundary === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const raw = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
let eventName = "message";
|
||||
const dataLines = [];
|
||||
|
||||
for (const line of raw.split("\\n")) {
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join("\\n");
|
||||
const payload = payloadText ? JSON.parse(payloadText) : null;
|
||||
onEvent(eventName, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const message = messageInput.value.trim();
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendButton.disabled = true;
|
||||
newSessionButton.disabled = true;
|
||||
setStatus("Streaming response...");
|
||||
|
||||
addMessage("user", message);
|
||||
const assistantEl = addMessage("assistant", "");
|
||||
|
||||
const payload = { message };
|
||||
const conversationId = conversationInput.value.trim();
|
||||
if (conversationId) {
|
||||
payload.conversationId = conversationId;
|
||||
}
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const token = tokenInput.value.trim();
|
||||
if (token) {
|
||||
headers.Authorization = "Bearer " + token;
|
||||
}
|
||||
|
||||
messageInput.value = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/v1/chat/stream", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
const text = await response.text();
|
||||
assistantEl.textContent = "Request failed (" + response.status + "): " + text;
|
||||
setStatus("Request failed");
|
||||
return;
|
||||
}
|
||||
|
||||
await consumeSse(response.body, (eventName, data) => {
|
||||
switch (eventName) {
|
||||
case "assistant_text_delta":
|
||||
assistantEl.textContent += data?.delta ?? "";
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
break;
|
||||
|
||||
case "done": {
|
||||
const id = data?.conversationId;
|
||||
if (id) {
|
||||
conversationInput.value = id;
|
||||
localStorage.setItem("pi_gateway_conversation_id", id);
|
||||
}
|
||||
setStatus("Done • conversation " + (id ?? "(unknown)"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
setStatus("Error: " + (data?.message ?? "unknown"));
|
||||
if (!assistantEl.textContent) {
|
||||
assistantEl.textContent = data?.message ?? "Unknown error";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
assistantEl.textContent = String(error);
|
||||
setStatus("Request failed");
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
newSessionButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
newSessionButton.addEventListener("click", startNewSession);
|
||||
sendButton.addEventListener("click", sendMessage);
|
||||
messageInput.addEventListener("keydown", (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
335
src/index.ts
335
src/index.ts
@@ -1,306 +1,45 @@
|
||||
/**
|
||||
* MoA Agent – pi coding agent SDK wrapper
|
||||
*
|
||||
* All behaviour is driven by environment variables so the container
|
||||
* works as a drop-in Docker service without any code changes.
|
||||
*
|
||||
* Environment variables
|
||||
* ─────────────────────
|
||||
* PROVIDER Provider name (anthropic | openai | google | mistral |
|
||||
* groq | cerebras | xai | openrouter | ollama | …)
|
||||
* When set together with MODEL, the exact model is resolved.
|
||||
* If omitted the session uses whatever model is already in
|
||||
* settings / the first available one.
|
||||
*
|
||||
* ── Ollama (local) ──────────────────────────────────────
|
||||
* Set PROVIDER=ollama and MODEL=<model-name> (e.g. llama3.2,
|
||||
* qwen2.5-coder, mistral, deepseek-r1:14b …).
|
||||
* No API key is needed.
|
||||
* OLLAMA_BASE_URL URL of the Ollama HTTP API.
|
||||
* Default in Docker:
|
||||
* http://host.docker.internal:11434/v1
|
||||
* Default locally:
|
||||
* http://localhost:11434/v1
|
||||
* OLLAMA_CONTEXT_WINDOW Context size in tokens (default: 32768)
|
||||
* OLLAMA_MAX_TOKENS Max output tokens (default: 8192)
|
||||
*
|
||||
* MODEL Model ID as understood by getModel(), e.g.
|
||||
* "claude-sonnet-4-20250514" or "gpt-4o".
|
||||
*
|
||||
* API_KEY Generic API key for the selected PROVIDER.
|
||||
* Stored as a runtime (non-persisted) key.
|
||||
* Provider-specific env vars (ANTHROPIC_API_KEY,
|
||||
* OPENAI_API_KEY, GEMINI_API_KEY, …) are also honoured
|
||||
* automatically by the underlying pi-ai library.
|
||||
* Not required when PROVIDER=ollama.
|
||||
*
|
||||
* THINKING_LEVEL off | minimal | low | medium | high | xhigh (default: off)
|
||||
*
|
||||
* TOOLS all – read, bash, edit, write (default)
|
||||
* readonly – read only
|
||||
* none – no built-in tools
|
||||
* Or a comma-separated list: read,bash,edit,write
|
||||
*
|
||||
* SYSTEM_PROMPT Completely replaces the default system prompt.
|
||||
* APPEND_SYSTEM_PROMPT Appended to the (possibly overridden) system prompt.
|
||||
*
|
||||
* PROMPT The user prompt to send. If omitted the agent reads from
|
||||
* stdin. Exactly one of the two must be provided.
|
||||
*
|
||||
* CWD Working directory the agent operates in.
|
||||
* Defaults to /app (the Docker volume mount point).
|
||||
*
|
||||
* SESSION_PERSIST Set to "true" to persist the session under CWD.
|
||||
* Defaults to in-memory (ephemeral).
|
||||
*
|
||||
* VERBOSE_TOOLS Set to "true" to log tool start/end events to stderr.
|
||||
*/
|
||||
import { loadConfig } from "./config.js";
|
||||
import { runSingleShot } from "./single-shot.js";
|
||||
import { ConversationManager } from "./conversation-manager.js";
|
||||
import { GatewayHttpServer } from "./gateway/server.js";
|
||||
|
||||
import { getModel, type Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
codingTools,
|
||||
readOnlyTools,
|
||||
readTool,
|
||||
bashTool,
|
||||
editTool,
|
||||
writeTool,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
async function run(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function env(name: string): string | undefined {
|
||||
const v = process.env[name];
|
||||
return v === "" ? undefined : v;
|
||||
}
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const v = env(name);
|
||||
if (!v) {
|
||||
console.error(`[agent] Required environment variable ${name} is not set.`);
|
||||
process.exit(1);
|
||||
if (config.gateway.runMode === "single") {
|
||||
await runSingleShot(config.agent);
|
||||
return;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
||||
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8").trim()));
|
||||
process.stdin.on("error", reject);
|
||||
const manager = new ConversationManager(config.agent);
|
||||
await manager.init();
|
||||
|
||||
const server = new GatewayHttpServer(manager, config.gateway);
|
||||
await server.start();
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
|
||||
console.error(`\n[gateway] Received ${signal}, shutting down...`);
|
||||
await server.stop();
|
||||
await manager.shutdown();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
// ─── configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
const providerName = env("PROVIDER");
|
||||
const modelId = env("MODEL");
|
||||
const apiKey = env("API_KEY");
|
||||
const ollamaBaseUrl = env("OLLAMA_BASE_URL") ?? "http://host.docker.internal:11434/v1";
|
||||
const ollamaCtxWin = parseInt(env("OLLAMA_CONTEXT_WINDOW") ?? "32768", 10);
|
||||
const ollamaMaxTok = parseInt(env("OLLAMA_MAX_TOKENS") ?? "8192", 10);
|
||||
const thinkingLevel = (env("THINKING_LEVEL") ?? "off") as
|
||||
"off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
const toolsEnv = env("TOOLS") ?? "all";
|
||||
const systemPrompt = env("SYSTEM_PROMPT");
|
||||
const appendPrompt = env("APPEND_SYSTEM_PROMPT");
|
||||
const promptText = env("PROMPT");
|
||||
const cwd = env("CWD") ?? "/app";
|
||||
const sessionPersist = env("SESSION_PERSIST") === "true";
|
||||
const verboseTools = env("VERBOSE_TOOLS") === "true";
|
||||
|
||||
// ─── auth & model registry ──────────────────────────────────────────────────
|
||||
|
||||
const authStorage = AuthStorage.create();
|
||||
|
||||
// Inject the generic API_KEY for the chosen provider at runtime so it is
|
||||
// never written to disk inside the container.
|
||||
if (providerName && apiKey) {
|
||||
authStorage.setRuntimeApiKey(providerName, apiKey);
|
||||
}
|
||||
|
||||
// Ollama doesn't require a real key; set a dummy to satisfy the auth layer.
|
||||
if (providerName?.toLowerCase() === "ollama" && !apiKey) {
|
||||
authStorage.setRuntimeApiKey("ollama", "ollama");
|
||||
}
|
||||
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
// ─── model resolution ───────────────────────────────────────────────────────
|
||||
|
||||
let model: Model<any> | undefined;
|
||||
|
||||
if (providerName?.toLowerCase() === "ollama") {
|
||||
// Ollama is OpenAI-compatible; build a custom model object.
|
||||
if (!modelId) {
|
||||
console.error("[agent] PROVIDER=ollama requires MODEL to be set (e.g. MODEL=llama3.2).");
|
||||
process.exit(1);
|
||||
}
|
||||
model = {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-completions",
|
||||
provider: "ollama",
|
||||
baseUrl: ollamaBaseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: ollamaCtxWin,
|
||||
maxTokens: ollamaMaxTok,
|
||||
compat: {
|
||||
supportsStore: false,
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
supportsStrictMode: false,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
} satisfies Model<"openai-completions">;
|
||||
console.error(`[agent] Using Ollama model "${modelId}" at ${ollamaBaseUrl}`);
|
||||
|
||||
} else if (providerName && modelId) {
|
||||
model = (getModel as (provider: string, model: string) => Model<any> | undefined)(providerName, modelId);
|
||||
if (!model) {
|
||||
console.error(
|
||||
`[agent] Model "${providerName}/${modelId}" not found. ` +
|
||||
`Run "pi --list-models" to check available models.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`[agent] Using model: ${model.provider}/${model.id}`);
|
||||
|
||||
} else if (providerName || modelId) {
|
||||
console.error(
|
||||
"[agent] Both PROVIDER and MODEL must be set together. " +
|
||||
"Falling back to default model."
|
||||
);
|
||||
}
|
||||
|
||||
// ─── tools ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const toolMap: Record<string, any> = {
|
||||
read: readTool,
|
||||
bash: bashTool,
|
||||
edit: editTool,
|
||||
write: writeTool,
|
||||
};
|
||||
|
||||
let tools: typeof codingTools;
|
||||
|
||||
switch (toolsEnv.toLowerCase()) {
|
||||
case "all":
|
||||
tools = codingTools;
|
||||
break;
|
||||
case "readonly":
|
||||
case "read-only":
|
||||
tools = readOnlyTools;
|
||||
break;
|
||||
case "none":
|
||||
tools = [];
|
||||
break;
|
||||
default:
|
||||
tools = toolsEnv
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.map((name) => {
|
||||
const tool = toolMap[name];
|
||||
if (!tool) {
|
||||
console.error(`[agent] Unknown tool "${name}", ignoring.`);
|
||||
return null;
|
||||
}
|
||||
return tool;
|
||||
})
|
||||
.filter((t): t is typeof readTool => t !== null);
|
||||
}
|
||||
|
||||
// ─── resource loader (system prompt) ────────────────────────────────────────
|
||||
|
||||
let resourceLoader: DefaultResourceLoader | undefined;
|
||||
|
||||
if (systemPrompt !== undefined || appendPrompt !== undefined) {
|
||||
resourceLoader = new DefaultResourceLoader({
|
||||
systemPromptOverride: (base: string | undefined) => {
|
||||
let result = systemPrompt !== undefined ? systemPrompt : base;
|
||||
if (appendPrompt) {
|
||||
result = result ? `${result}\n\n${appendPrompt}` : appendPrompt;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
}
|
||||
|
||||
// ─── session manager ────────────────────────────────────────────────────────
|
||||
|
||||
const sessionManager = sessionPersist
|
||||
? SessionManager.create(cwd)
|
||||
: SessionManager.inMemory();
|
||||
|
||||
// ─── create session ──────────────────────────────────────────────────────────
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
...(model ? { model } : {}),
|
||||
...(resourceLoader ? { resourceLoader } : {}),
|
||||
thinkingLevel,
|
||||
tools,
|
||||
sessionManager,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
cwd,
|
||||
});
|
||||
|
||||
// ─── event handling ──────────────────────────────────────────────────────────
|
||||
|
||||
session.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "message_update":
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_execution_start":
|
||||
if (verboseTools) {
|
||||
process.stderr.write(`\n[tool:start] ${event.toolName}\n`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_execution_end":
|
||||
if (verboseTools) {
|
||||
process.stderr.write(`[tool:end] ${event.toolName}\n`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "agent_end":
|
||||
process.stdout.write("\n");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── run prompt ──────────────────────────────────────────────────────────────
|
||||
|
||||
let userPrompt: string;
|
||||
|
||||
if (promptText) {
|
||||
userPrompt = promptText;
|
||||
} else if (!process.stdin.isTTY) {
|
||||
userPrompt = await readStdin();
|
||||
if (!userPrompt) {
|
||||
console.error("[agent] Stdin was empty. Set PROMPT or pipe a non-empty message.");
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"[agent] No prompt provided.\n" +
|
||||
" Set the PROMPT environment variable, or pipe input via stdin."
|
||||
);
|
||||
run().catch((error) => {
|
||||
console.error("[app] Fatal error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await session.prompt(userPrompt);
|
||||
});
|
||||
|
||||
54
src/single-shot.ts
Normal file
54
src/single-shot.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentConfig } from "./config.js";
|
||||
import { readStdin } from "./config.js";
|
||||
import { AgentSessionFactory } from "./agent-session-factory.js";
|
||||
|
||||
export async function runSingleShot(config: AgentConfig): Promise<void> {
|
||||
const sessionManager = config.sessionPersist
|
||||
? SessionManager.create(config.cwd)
|
||||
: SessionManager.inMemory();
|
||||
|
||||
const factory = new AgentSessionFactory(config);
|
||||
const session = await factory.createSession(sessionManager);
|
||||
|
||||
session.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "message_update":
|
||||
if (event.assistantMessageEvent.type === "text_delta") {
|
||||
process.stdout.write(event.assistantMessageEvent.delta);
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_execution_start":
|
||||
if (config.verboseTools) {
|
||||
process.stderr.write(`\n[tool:start] ${event.toolName}\n`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_execution_end":
|
||||
if (config.verboseTools) {
|
||||
process.stderr.write(`[tool:end] ${event.toolName}\n`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "agent_end":
|
||||
process.stdout.write("\n");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
let userPrompt: string;
|
||||
|
||||
if (config.promptText) {
|
||||
userPrompt = config.promptText;
|
||||
} else if (!process.stdin.isTTY) {
|
||||
userPrompt = await readStdin();
|
||||
if (!userPrompt) {
|
||||
throw new Error("Stdin was empty. Set PROMPT or pipe a non-empty message.");
|
||||
}
|
||||
} else {
|
||||
throw new Error("No prompt provided. Set PROMPT or pipe input via stdin.");
|
||||
}
|
||||
|
||||
await session.prompt(userPrompt);
|
||||
}
|
||||
Reference in New Issue
Block a user