Skip to content

Commit bacdf49

Browse files
feat: engram-bridge with chat-first web UI
Add engram-bridge — a multi-agent orchestration layer with web channel that serves as the primary user interface for Engram memory. Web UI (React + Vite + TypeScript + Tailwind v4 + shadcn/ui): - Chat view (/) as primary screen — users communicate through chat, agent creates tasks from conversation - Board view (/board) — kanban with drag-drop, filters, issue panel, "Open Chat" button and double-click to navigate to task - Task chat view (/task/:id) — 3-column layout with task list, live conversation stream, and right panel (changes/processes/files/sub-tasks) - Memory view (/memory) — browse and search Engram memory items - Real-time WebSocket with auto-reconnect, central message routing to Zustand stores (useChatStore for global chat, useTaskConversationStore for per-task live data) - Light B&W theme, no hardcoded oklch colors Backend: - engram-bridge package with agent framework (Claude, Codex, custom) - FastAPI + WebSocket web channel with task execution - Project/status/tag/issue management (SQLite-backed) - Memory API endpoints for stats, search, categories - Supporting changes to engram core (configs, db, memory, mcp_server) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f1c7701 commit bacdf49

93 files changed

Lines changed: 22765 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

engram-bridge/README.md

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# engram-bridge
2+
3+
Channel adapters for Engram — talk to your coding agents from Telegram or a browser without opening a terminal.
4+
5+
**Architecture**: User on Telegram/Web → thin Python bridge (NO LLM) → doer agent (Claude Code/Codex) directly. 1x token cost, not 2x.
6+
7+
## Quick Start
8+
9+
### Web Channel (Browser)
10+
11+
```bash
12+
# Install with web dependencies
13+
pip install -e "./Engram/engram-bridge[web]"
14+
15+
# Create config
16+
mkdir -p ~/.engram
17+
cat > ~/.engram/bridge.json << 'EOF'
18+
{
19+
"channel": "web",
20+
"web": {
21+
"host": "127.0.0.1",
22+
"port": 8200,
23+
"auth_token": ""
24+
},
25+
"default_agent": "claude-code",
26+
"agents": {
27+
"claude-code": {
28+
"type": "claude",
29+
"model": "claude-opus-4-6",
30+
"allowed_tools": ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
31+
},
32+
"codex": {
33+
"type": "codex",
34+
"model": "gpt-5-codex"
35+
}
36+
},
37+
"memory": {
38+
"provider": "gemini",
39+
"auto_store": true
40+
}
41+
}
42+
EOF
43+
44+
# Run
45+
engram-bridge --channel web
46+
47+
# Open browser
48+
open http://127.0.0.1:8200
49+
```
50+
51+
### Telegram Channel
52+
53+
```bash
54+
# Install with Telegram dependencies
55+
pip install -e "./Engram/engram-bridge[telegram]"
56+
57+
# Set token (get one from @BotFather on Telegram)
58+
export TELEGRAM_BOT_TOKEN="your-token"
59+
60+
# Create config (use "channel": "telegram" or omit — it's the default)
61+
mkdir -p ~/.engram
62+
cat > ~/.engram/bridge.json << 'EOF'
63+
{
64+
"telegram": {
65+
"token": "env:TELEGRAM_BOT_TOKEN",
66+
"allowed_users": []
67+
},
68+
"default_agent": "claude-code",
69+
"agents": {
70+
"claude-code": {
71+
"type": "claude",
72+
"model": "claude-opus-4-6"
73+
}
74+
},
75+
"memory": {
76+
"provider": "gemini",
77+
"auto_store": true
78+
}
79+
}
80+
EOF
81+
82+
# Run
83+
engram-bridge
84+
```
85+
86+
## Commands
87+
88+
Available in both Telegram and Web channels:
89+
90+
| Command | Description |
91+
|---------|-------------|
92+
| `/start [agent] [repo]` | Start agent session on a repo |
93+
| `/switch <agent>` | Switch active agent (saves session) |
94+
| `/status` | Show active agent, repo, session info |
95+
| `/agents` | List available agents |
96+
| `/stop` | Stop active agent and save session |
97+
| `/sessions` | List recent handoff sessions |
98+
| `/memory [query]` | Search Engram memory or show stats |
99+
100+
## Web Channel
101+
102+
The web channel serves a React chat UI over WebSocket at `http://127.0.0.1:8200` (default).
103+
104+
- Real-time streaming of tool-use updates and agent responses
105+
- Command button bar for quick access to `/start`, `/switch`, `/status`, etc.
106+
- Markdown rendering with code block support
107+
- Auto-reconnect on disconnect
108+
- Optional token auth: set `web.auth_token` in config, pass `?token=...` in the URL
109+
110+
### Web Config Options
111+
112+
```json
113+
{
114+
"channel": "web",
115+
"web": {
116+
"host": "127.0.0.1",
117+
"port": 8200,
118+
"auth_token": "env:BRIDGE_WEB_TOKEN"
119+
}
120+
}
121+
```
122+
123+
## Agent Types
124+
125+
### Claude Code (`type: "claude"`)
126+
Uses the `claude` CLI. Requires [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed.
127+
128+
### Codex (`type: "codex"`)
129+
Uses the `codex` CLI. Requires [OpenAI Codex](https://github.com/openai/codex) installed.
130+
131+
### Custom (`type: "custom"`)
132+
Wraps any CLI tool. Use `{prompt}` placeholder in the command template:
133+
```json
134+
{
135+
"aider": {
136+
"type": "custom",
137+
"command": ["aider", "--message", "{prompt}", "--yes"],
138+
"cwd_flag": "--cwd"
139+
}
140+
}
141+
```
142+
143+
## How It Works
144+
145+
1. User sends message (on Telegram or Web)
146+
2. Bridge routes to the active agent (no LLM orchestrator in between)
147+
3. Agent processes the message (Claude Code, Codex, etc.)
148+
4. Bridge streams tool-use updates and final response back to the channel
149+
5. Exchange is auto-stored in Engram memory
150+
6. Session state is checkpointed to engram-bus
151+
152+
### Rate Limit Handling
153+
154+
When an agent hits a rate limit, the bridge:
155+
1. Saves the session digest to engram-bus
156+
2. Notifies you on your channel
157+
3. You can `/switch` to another agent to continue immediately
158+
159+
## Configuration
160+
161+
Config lives at `~/.engram/bridge.json`. Token values support `env:VAR_NAME` syntax to read from environment variables.
162+
163+
The `--channel` CLI flag overrides the `channel` field in the config file.
164+
165+
### Security
166+
167+
- **Telegram**: `allowed_users` restricts access by Telegram user ID. Empty list = allow all.
168+
- **Web**: `auth_token` protects the WebSocket endpoint. Empty = no auth (local dev only).
169+
- Agents run with whatever permissions the CLI tool has on the host machine.
170+
- Claude Code supports `--permission-mode` for sandboxing.
171+
172+
## Dependencies
173+
174+
- `engram-bus` — session handoffs, pub/sub
175+
- `engram-memory` — conversation memory (FadeMem + EchoMem)
176+
- `python-telegram-bot` — Telegram bot API (optional, install with `[telegram]`)
177+
- `fastapi` + `uvicorn` — Web channel server (optional, install with `[web]`)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from engram_bridge.bridge import Bridge
2+
from engram_bridge.config import BridgeConfig, load_config
3+
4+
__all__ = ["Bridge", "BridgeConfig", "load_config"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from engram_bridge.agents.base import BaseAgent, AgentMessage
2+
from engram_bridge.agents.claude import ClaudeAgent
3+
from engram_bridge.agents.codex import CodexAgent
4+
from engram_bridge.agents.custom import CustomAgent
5+
6+
__all__ = ["BaseAgent", "AgentMessage", "ClaudeAgent", "CodexAgent", "CustomAgent"]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Base agent interface for all agent adapters."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
from dataclasses import dataclass, field
7+
from typing import AsyncIterator
8+
9+
10+
@dataclass
11+
class AgentMessage:
12+
"""A single message/event from an agent."""
13+
type: str # "text", "tool_use", "tool_result", "error", "rate_limited"
14+
content: str # display text
15+
session_id: str # for resume
16+
metadata: dict = field(default_factory=dict) # tool name, file paths, etc.
17+
18+
19+
class BaseAgent(ABC):
20+
"""Abstract base for agent adapters (Claude Code, Codex, custom CLI)."""
21+
22+
@abstractmethod
23+
async def send(
24+
self, message: str, cwd: str, session_id: str | None = None
25+
) -> AsyncIterator[AgentMessage]:
26+
"""Send message to agent, yield streaming responses."""
27+
28+
@abstractmethod
29+
async def stop(self) -> None:
30+
"""Stop the agent process."""
31+
32+
@property
33+
@abstractmethod
34+
def name(self) -> str:
35+
"""Agent display name."""
36+
37+
@property
38+
@abstractmethod
39+
def is_running(self) -> bool:
40+
"""Whether the agent process is currently active."""
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Claude Code agent adapter — uses the `claude` CLI with JSON streaming."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import json
7+
import uuid
8+
from typing import AsyncIterator
9+
10+
from engram_bridge.agents.base import AgentMessage, BaseAgent
11+
from engram_bridge.config import AgentConfig
12+
13+
14+
class ClaudeAgent(BaseAgent):
15+
"""Runs Claude Code via subprocess, reading NDJSON streaming output."""
16+
17+
def __init__(self, config: AgentConfig):
18+
self._model = config.model or "claude-opus-4-6"
19+
self._allowed_tools = config.allowed_tools
20+
self._session_id: str | None = None
21+
self._proc: asyncio.subprocess.Process | None = None
22+
23+
@property
24+
def name(self) -> str:
25+
return "claude-code"
26+
27+
@property
28+
def is_running(self) -> bool:
29+
return self._proc is not None and self._proc.returncode is None
30+
31+
async def send(
32+
self, message: str, cwd: str, session_id: str | None = None
33+
) -> AsyncIterator[AgentMessage]:
34+
sid = session_id or self._session_id or uuid.uuid4().hex[:12]
35+
self._session_id = sid
36+
37+
cmd = [
38+
"claude", "--json",
39+
"--model", self._model,
40+
"--print", # non-interactive mode
41+
"--output-format", "stream-json",
42+
]
43+
if self._allowed_tools:
44+
cmd += ["--allowedTools", ",".join(self._allowed_tools)]
45+
if session_id:
46+
cmd += ["--resume", session_id]
47+
cmd += ["-p", message]
48+
49+
try:
50+
self._proc = await asyncio.create_subprocess_exec(
51+
*cmd,
52+
stdout=asyncio.subprocess.PIPE,
53+
stderr=asyncio.subprocess.PIPE,
54+
cwd=cwd,
55+
)
56+
except FileNotFoundError:
57+
yield AgentMessage(
58+
"error",
59+
"Claude CLI not found. Install it: npm install -g @anthropic-ai/claude-code",
60+
sid, {},
61+
)
62+
return
63+
64+
collected_text: list[str] = []
65+
66+
async for line in self._proc.stdout:
67+
line = line.decode("utf-8", errors="replace").strip()
68+
if not line:
69+
continue
70+
try:
71+
event = json.loads(line)
72+
except json.JSONDecodeError:
73+
# Plain text fallback
74+
collected_text.append(line)
75+
continue
76+
77+
msg = self._parse_event(event, sid)
78+
if msg:
79+
if msg.type == "text":
80+
collected_text.append(msg.content)
81+
yield msg
82+
83+
await self._proc.wait()
84+
85+
# If process failed, read stderr
86+
if self._proc.returncode and self._proc.returncode != 0:
87+
stderr = await self._proc.stderr.read()
88+
err = stderr.decode("utf-8", errors="replace").strip()
89+
if "rate" in err.lower() or "429" in err:
90+
yield AgentMessage("rate_limited", err, sid, {})
91+
else:
92+
yield AgentMessage("error", err or f"Exit code {self._proc.returncode}", sid, {})
93+
94+
# Emit final combined text if we got plain output
95+
if collected_text and not any(True for _ in []):
96+
# The text messages were already yielded above
97+
pass
98+
99+
self._proc = None
100+
101+
def _parse_event(self, event: dict, sid: str) -> AgentMessage | None:
102+
"""Parse a JSON streaming event from Claude CLI."""
103+
etype = event.get("type", "")
104+
105+
if etype == "assistant" or etype == "result":
106+
# Final or intermediate text
107+
content = event.get("result", "") or event.get("content", "")
108+
if isinstance(content, list):
109+
# content blocks
110+
texts = [b.get("text", "") for b in content if b.get("type") == "text"]
111+
content = "\n".join(texts)
112+
new_sid = event.get("session_id", sid)
113+
if new_sid:
114+
self._session_id = new_sid
115+
if content:
116+
return AgentMessage("text", content, self._session_id or sid, {})
117+
118+
elif etype == "tool_use":
119+
tool = event.get("name", event.get("tool", "unknown"))
120+
inp = event.get("input", {})
121+
display = f"Using {tool}..."
122+
if "file_path" in inp:
123+
display = f"{tool}: {inp['file_path']}"
124+
elif "command" in inp:
125+
cmd_str = inp["command"]
126+
if len(cmd_str) > 80:
127+
cmd_str = cmd_str[:77] + "..."
128+
display = f"{tool}: {cmd_str}"
129+
return AgentMessage("tool_use", display, self._session_id or sid, {"tool": tool, "input": inp})
130+
131+
elif etype == "tool_result":
132+
content = event.get("content", "")
133+
if isinstance(content, list):
134+
content = "\n".join(b.get("text", "") for b in content if isinstance(b, dict))
135+
return AgentMessage("tool_result", content[:500], self._session_id or sid, {})
136+
137+
elif etype == "error":
138+
msg = event.get("error", {}).get("message", str(event))
139+
if "rate" in msg.lower() or "429" in msg:
140+
return AgentMessage("rate_limited", msg, self._session_id or sid, {})
141+
return AgentMessage("error", msg, self._session_id or sid, {})
142+
143+
return None
144+
145+
async def stop(self) -> None:
146+
if self._proc and self._proc.returncode is None:
147+
self._proc.terminate()
148+
try:
149+
await asyncio.wait_for(self._proc.wait(), timeout=5.0)
150+
except asyncio.TimeoutError:
151+
self._proc.kill()
152+
self._proc = None

0 commit comments

Comments
 (0)