feat: communication spine — gateway, TUI, Discord
Gateway: - Agent service wrapping Pi SDK createAgentSession (in-process) - Chat WebSocket gateway (Socket.IO) streaming agent events - Chat REST controller for synchronous requests - NestJS module structure: AgentModule (global), ChatModule CLI: - Ink-based TUI client connecting to gateway via WebSocket - Commander-based CLI with `mosaic tui` command - Streaming message display with React components Discord: - Discord.js bot with mention-based activation + DM support - Routes messages through gateway WebSocket - Chunked response delivery (2000-char Discord limit) - Single-guild binding for v0.1.0 Architecture: All channels → Gateway WebSocket → Pi SDK → LLM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
@@ -14,12 +14,19 @@
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-fastify": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"@mariozechner/pi-coding-agent": "~0.57.1",
|
||||
"fastify": "^5.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0"
|
||||
"rxjs": "^7.8.0",
|
||||
"socket.io": "^4.8.0",
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
9
apps/gateway/src/agent/agent.module.ts
Normal file
9
apps/gateway/src/agent/agent.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AgentService } from './agent.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AgentService],
|
||||
exports: [AgentService],
|
||||
})
|
||||
export class AgentModule {}
|
||||
94
apps/gateway/src/agent/agent.service.ts
Normal file
94
apps/gateway/src/agent/agent.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
createAgentSession,
|
||||
SessionManager,
|
||||
type AgentSession as PiAgentSession,
|
||||
type AgentSessionEvent,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
|
||||
export interface AgentSession {
|
||||
id: string;
|
||||
piSession: PiAgentSession;
|
||||
listeners: Set<(event: AgentSessionEvent) => void>;
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AgentService.name);
|
||||
private readonly sessions = new Map<string, AgentSession>();
|
||||
|
||||
async createSession(sessionId: string): Promise<AgentSession> {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
return this.sessions.get(sessionId)!;
|
||||
}
|
||||
|
||||
this.logger.log(`Creating agent session: ${sessionId}`);
|
||||
|
||||
const { session: piSession } = await createAgentSession({
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const listeners = new Set<(event: AgentSessionEvent) => void>();
|
||||
|
||||
const unsubscribe = piSession.subscribe((event) => {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
this.logger.error(`Event listener error in session ${sessionId}`, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const session: AgentSession = {
|
||||
id: sessionId,
|
||||
piSession,
|
||||
listeners,
|
||||
unsubscribe,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
this.logger.log(`Agent session ${sessionId} ready`);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): AgentSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, message: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
await session.piSession.prompt(message);
|
||||
}
|
||||
|
||||
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
session.listeners.add(listener);
|
||||
return () => session.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async destroySession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||
session.unsubscribe();
|
||||
session.listeners.clear();
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log('Shutting down all agent sessions');
|
||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
||||
await Promise.allSettled(stops);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health/health.controller.js';
|
||||
import { AgentModule } from './agent/agent.module.js';
|
||||
import { ChatModule } from './chat/chat.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [AgentModule, ChatModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
55
apps/gateway/src/chat/chat.controller.ts
Normal file
55
apps/gateway/src/chat/chat.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface ChatRequest {
|
||||
conversationId?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ChatResponse {
|
||||
conversationId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Controller('api/chat')
|
||||
export class ChatController {
|
||||
private readonly logger = new Logger(ChatController.name);
|
||||
|
||||
constructor(private readonly agentService: AgentService) {}
|
||||
|
||||
@Post()
|
||||
async chat(@Body() body: ChatRequest): Promise<ChatResponse> {
|
||||
const conversationId = body.conversationId ?? uuid();
|
||||
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
agentSession = await this.agentService.createSession(conversationId);
|
||||
}
|
||||
|
||||
let responseText = '';
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
await this.agentService.prompt(conversationId, body.content);
|
||||
await done;
|
||||
|
||||
return { conversationId, text: responseText };
|
||||
}
|
||||
}
|
||||
139
apps/gateway/src/chat/chat.gateway.ts
Normal file
139
apps/gateway/src/chat/chat.gateway.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
type OnGatewayInit,
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface ChatMessage {
|
||||
conversationId?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: { origin: '*' },
|
||||
namespace: '/chat',
|
||||
})
|
||||
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
private readonly clientSessions = new Map<
|
||||
string,
|
||||
{ conversationId: string; cleanup: () => void }
|
||||
>();
|
||||
|
||||
constructor(private readonly agentService: AgentService) {}
|
||||
|
||||
afterInit(): void {
|
||||
this.logger.log('Chat WebSocket gateway initialized');
|
||||
}
|
||||
|
||||
handleConnection(client: Socket): void {
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket): void {
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
const session = this.clientSessions.get(client.id);
|
||||
if (session) {
|
||||
session.cleanup();
|
||||
this.clientSessions.delete(client.id);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('message')
|
||||
async handleMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: ChatMessage,
|
||||
): Promise<void> {
|
||||
const conversationId = data.conversationId ?? uuid();
|
||||
|
||||
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
||||
|
||||
// Ensure agent session exists for this conversation
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
agentSession = await this.agentService.createSession(conversationId);
|
||||
}
|
||||
|
||||
// Clean up any previous event subscription for this client
|
||||
const existing = this.clientSessions.get(client.id);
|
||||
if (existing && existing.conversationId !== conversationId) {
|
||||
existing.cleanup();
|
||||
}
|
||||
|
||||
// Subscribe to agent events and relay to client
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
this.relayEvent(client, conversationId, event);
|
||||
});
|
||||
|
||||
this.clientSessions.set(client.id, { conversationId, cleanup });
|
||||
|
||||
// Send acknowledgment
|
||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||
|
||||
// Dispatch to agent
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, data.content);
|
||||
} catch (err) {
|
||||
this.logger.error(`Agent prompt failed: ${err}`);
|
||||
client.emit('error', { conversationId, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
client.emit('agent:start', { conversationId });
|
||||
break;
|
||||
|
||||
case 'agent_end':
|
||||
client.emit('agent:end', { conversationId });
|
||||
break;
|
||||
|
||||
case 'message_update': {
|
||||
const assistantEvent = event.assistantMessageEvent;
|
||||
if (assistantEvent.type === 'text_delta') {
|
||||
client.emit('agent:text', {
|
||||
conversationId,
|
||||
text: assistantEvent.delta,
|
||||
});
|
||||
} else if (assistantEvent.type === 'thinking_delta') {
|
||||
client.emit('agent:thinking', {
|
||||
conversationId,
|
||||
text: assistantEvent.delta,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_execution_start':
|
||||
client.emit('agent:tool:start', {
|
||||
conversationId,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool_execution_end':
|
||||
client.emit('agent:tool:end', {
|
||||
conversationId,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
isError: event.isError,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/gateway/src/chat/chat.module.ts
Normal file
9
apps/gateway/src/chat/chat.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { ChatController } from './chat.controller.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
})
|
||||
export class ChatModule {}
|
||||
Reference in New Issue
Block a user