From d42b0c416a73222c0f147ea1701cbd2269f16487 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 09:06:58 +0700 Subject: [PATCH 1/5] feat: define MsgContext and AgentResponse types for platform abstraction --- packages/agent/src/core/index.ts | 7 +++++++ packages/agent/src/core/types.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 packages/agent/src/core/index.ts create mode 100644 packages/agent/src/core/types.ts diff --git a/packages/agent/src/core/index.ts b/packages/agent/src/core/index.ts new file mode 100644 index 0000000..651fea6 --- /dev/null +++ b/packages/agent/src/core/index.ts @@ -0,0 +1,7 @@ +export type { + Platform, + MsgContext, + ResponseChunk, + AgentResponse, +} from './types.js' +export { AgentCore } from './agent-core.js' diff --git a/packages/agent/src/core/types.ts b/packages/agent/src/core/types.ts new file mode 100644 index 0000000..20152ef --- /dev/null +++ b/packages/agent/src/core/types.ts @@ -0,0 +1,32 @@ +/** Platform a message originated from */ +export type Platform = 'web' | 'telegram' | 'x' + +/** + * Unified inbound message context — platform-agnostic. + * Every adapter constructs this from its native format. + */ +export interface MsgContext { + /** Platform the message came from */ + platform: Platform + /** User identifier — wallet address (web), telegram user ID, X user ID */ + userId: string + /** The user's message text */ + message: string + /** Optional metadata from the platform (thread ID, reply-to, etc.) */ + metadata?: Record +} + +/** A single response chunk for streaming */ +export interface ResponseChunk { + type: 'text' | 'tool_start' | 'tool_end' | 'error' | 'done' + text?: string + toolName?: string + toolId?: string + success?: boolean +} + +/** Full (non-streaming) agent response */ +export interface AgentResponse { + text: string + toolsUsed: string[] +} From b47eb1e644353a6399419766c0ee9cb07ba5fd29 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 09:09:55 +0700 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20implement=20AgentCore=20=E2=80=94?= =?UTF-8?q?=20platform-agnostic=20message=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AgentCore class with processMessage() and streamMessage() methods that wrap the existing chat/chatStream functions with session resolution, conversation persistence, and SSE-to-ResponseChunk mapping. Any platform adapter (web, Telegram, X) can now construct a MsgContext and hand it to AgentCore without knowing about the LLM layer. 4 tests covering: text response, tool extraction, streaming chunks, and repeated session resolution. --- packages/agent/src/core/agent-core.ts | 112 +++++++++++++++ packages/agent/tests/core/agent-core.test.ts | 140 +++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 packages/agent/src/core/agent-core.ts create mode 100644 packages/agent/tests/core/agent-core.test.ts diff --git a/packages/agent/src/core/agent-core.ts b/packages/agent/src/core/agent-core.ts new file mode 100644 index 0000000..52078d8 --- /dev/null +++ b/packages/agent/src/core/agent-core.ts @@ -0,0 +1,112 @@ +import type { MsgContext, ResponseChunk, AgentResponse } from './types.js' +import { chat, chatStream } from '../agent.js' +import { + resolveSession, + getConversation, + appendConversation, +} from '../session.js' + +// ───────────────────────────────────────────────────────────────────────────── +// AgentCore — platform-agnostic message processing +// +// Wraps the LLM chat/stream functions with session management and +// conversation persistence. Any platform adapter (web, Telegram, X) +// constructs a MsgContext and hands it to AgentCore. +// ───────────────────────────────────────────────────────────────────────────── + +export class AgentCore { + /** + * Process a message synchronously (non-streaming). + * + * Resolves the user's session, loads conversation history, calls the LLM, + * extracts text + tool usage, persists the conversation turn, and returns. + */ + async processMessage(ctx: MsgContext): Promise { + const session = resolveSession(ctx.userId) + const history = getConversation(session.id) + + // Build messages: existing history + the new user message + const messages = [ + ...history.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content as string })), + { role: 'user' as const, content: ctx.message }, + ] + + const response = await chat(messages) + + // Extract text from text blocks + const textBlocks = response.content.filter( + (b: { type: string }) => b.type === 'text' + ) as { type: 'text'; text: string }[] + const text = textBlocks.map((b) => b.text).join('') + + // Extract tool names from tool_use blocks + const toolUseBlocks = response.content.filter( + (b: { type: string }) => b.type === 'tool_use' + ) as { type: 'tool_use'; name: string }[] + const toolsUsed = toolUseBlocks.map((b) => b.name) + + // Persist the conversation turn + appendConversation(session.id, [ + { role: 'user', content: ctx.message }, + { role: 'assistant', content: text }, + ]) + + return { text, toolsUsed } + } + + /** + * Process a message with streaming. + * + * Same session/history resolution, but yields ResponseChunk objects as + * SSE events arrive from the LLM. Persists the conversation after the + * stream completes, then yields a final 'done' chunk. + */ + async *streamMessage(ctx: MsgContext): AsyncGenerator { + const session = resolveSession(ctx.userId) + const history = getConversation(session.id) + + const messages = [ + ...history.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content as string })), + { role: 'user' as const, content: ctx.message }, + ] + + let fullText = '' + + for await (const event of chatStream(messages)) { + switch (event.type) { + case 'content_block_delta': + yield { type: 'text', text: event.text } + break + + case 'tool_use': + yield { type: 'tool_start', toolName: event.name, toolId: event.id } + break + + case 'tool_result': + yield { + type: 'tool_end', + toolName: event.name, + toolId: event.id, + success: event.success, + } + break + + case 'error': + yield { type: 'error', text: event.message } + break + + case 'message_complete': + fullText = event.content + break + } + } + + // Persist the completed conversation turn + appendConversation(session.id, [ + { role: 'user', content: ctx.message }, + { role: 'assistant', content: fullText }, + ]) + + yield { type: 'done', text: fullText } + } +} diff --git a/packages/agent/tests/core/agent-core.test.ts b/packages/agent/tests/core/agent-core.test.ts new file mode 100644 index 0000000..daf5846 --- /dev/null +++ b/packages/agent/tests/core/agent-core.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { MsgContext, ResponseChunk } from '../../src/core/types.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Mock agent.ts — prevent real LLM calls +// ───────────────────────────────────────────────────────────────────────────── + +vi.mock('../../src/agent.js', () => ({ + chat: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Mock response' }], + stop_reason: 'end_turn', + }), + chatStream: vi.fn().mockImplementation(async function* () { + yield { type: 'content_block_delta', text: 'Mock ' } + yield { type: 'content_block_delta', text: 'streamed' } + yield { type: 'message_complete', content: 'Mock streamed' } + }), + executeTool: vi.fn().mockResolvedValue({ ok: true }), + TOOLS: [], + SYSTEM_PROMPT: 'Test system prompt', +})) + +// ───────────────────────────────────────────────────────────────────────────── +// Import after mocks are registered +// ───────────────────────────────────────────────────────────────────────────── + +import { AgentCore } from '../../src/core/agent-core.js' +import { chat } from '../../src/agent.js' +import { closeDb } from '../../src/db.js' +import { clearConversation, resolveSession } from '../../src/session.js' + +const WALLET = 'FGSkt8MwXH83daNNW8ZkoqhL1KLcLoZLcdGJz84BWWr' + +beforeEach(() => { + process.env.DB_PATH = ':memory:' + vi.clearAllMocks() +}) + +afterEach(() => { + clearConversation(resolveSession(WALLET).id) + closeDb() + delete process.env.DB_PATH +}) + +// ───────────────────────────────────────────────────────────────────────────── +// processMessage +// ───────────────────────────────────────────────────────────────────────────── + +describe('AgentCore.processMessage', () => { + it('returns text response from chat', async () => { + const core = new AgentCore() + const ctx: MsgContext = { + platform: 'web', + userId: WALLET, + message: 'What is my balance?', + } + + const result = await core.processMessage(ctx) + + expect(result.text).toBe('Mock response') + expect(result.toolsUsed).toEqual([]) + }) + + it('extracts tool names from tool_use blocks', async () => { + const chatMock = vi.mocked(chat) + chatMock.mockResolvedValueOnce({ + content: [ + { type: 'tool_use', id: 'tool_1', name: 'balance', input: {} }, + { type: 'tool_use', id: 'tool_2', name: 'scan', input: {} }, + { type: 'text', text: 'Your balance is 5 SOL' }, + ], + stop_reason: 'end_turn', + } as never) + + const core = new AgentCore() + const ctx: MsgContext = { + platform: 'web', + userId: WALLET, + message: 'Check my balance', + } + + const result = await core.processMessage(ctx) + + expect(result.text).toBe('Your balance is 5 SOL') + expect(result.toolsUsed).toEqual(['balance', 'scan']) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// streamMessage +// ───────────────────────────────────────────────────────────────────────────── + +describe('AgentCore.streamMessage', () => { + it('yields text chunks and a done chunk', async () => { + const core = new AgentCore() + const ctx: MsgContext = { + platform: 'telegram', + userId: WALLET, + message: 'Send 1 SOL', + } + + const chunks: ResponseChunk[] = [] + for await (const chunk of core.streamMessage(ctx)) { + chunks.push(chunk) + } + + // Should have 2 text chunks + 1 done + const textChunks = chunks.filter((c) => c.type === 'text') + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe('Mock ') + expect(textChunks[1].text).toBe('streamed') + + const doneChunk = chunks.find((c) => c.type === 'done') + expect(doneChunk).toBeDefined() + expect(doneChunk!.text).toBe('Mock streamed') + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Session resolution +// ───────────────────────────────────────────────────────────────────────────── + +describe('AgentCore session resolution', () => { + it('resolves session from userId without errors on repeated calls', async () => { + const core = new AgentCore() + const ctx: MsgContext = { + platform: 'x', + userId: WALLET, + message: 'Hello', + } + + // First call — creates session + const result1 = await core.processMessage(ctx) + expect(result1.text).toBe('Mock response') + + // Second call — reuses session, no errors + const result2 = await core.processMessage(ctx) + expect(result2.text).toBe('Mock response') + }) +}) From 30a61a459bd233688b98ccc1b205b7173543bf17 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 09:13:57 +0700 Subject: [PATCH 3/5] feat: create web adapter mapping Express to AgentCore Three handlers (handleCommand, handleChat, handleChatStream) translate HTTP requests into MsgContext and AgentCore responses back to JSON/SSE. Includes chunkToSSE mapping, message validation, and abort tracking. --- packages/agent/src/adapters/web.ts | 164 +++++++++++ packages/agent/tests/adapters/web.test.ts | 320 ++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 packages/agent/src/adapters/web.ts create mode 100644 packages/agent/tests/adapters/web.test.ts diff --git a/packages/agent/src/adapters/web.ts b/packages/agent/src/adapters/web.ts new file mode 100644 index 0000000..f95ec7d --- /dev/null +++ b/packages/agent/src/adapters/web.ts @@ -0,0 +1,164 @@ +import type { Request, Response } from 'express' +import type { ResponseChunk } from '../core/types.js' +import { AgentCore } from '../core/agent-core.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Web Adapter — maps Express HTTP requests to AgentCore +// +// createWebAdapter() returns handlers for: +// POST /api/command → handleCommand (single-turn, JSON response) +// POST /api/chat → handleChat (multi-turn, JSON response) +// POST /api/chat/stream → handleChatStream (multi-turn, SSE stream) +// ───────────────────────────────────────────────────────────────────────────── + +const MAX_MESSAGE_LENGTH = 4000 + +interface ChatMessage { + role: 'user' | 'assistant' | 'system' + content: string +} + +/** + * Map a ResponseChunk to the SSE event format the frontend expects. + */ +function chunkToSSE(chunk: ResponseChunk): Record { + switch (chunk.type) { + case 'text': + return { type: 'content_block_delta', text: chunk.text } + case 'tool_start': + return { type: 'tool_use', name: chunk.toolName, id: chunk.toolId } + case 'tool_end': + return { + type: 'tool_result', + name: chunk.toolName, + id: chunk.toolId, + success: chunk.success, + } + case 'error': + return { type: 'error', message: chunk.text } + case 'done': + return { type: 'message_complete', content: chunk.text } + } +} + +/** + * Extract the wallet address set by JWT middleware. + */ +function getWallet(req: Request): string { + return (req as Record).wallet as string +} + +/** + * Validate the messages array from a chat request body. + * Returns the last user message content or null if invalid. + */ +function extractLastUserMessage( + messages: unknown, +): string | null { + if (!Array.isArray(messages) || messages.length === 0) return null + const userMessages = (messages as ChatMessage[]).filter( + (m) => m.role === 'user', + ) + if (userMessages.length === 0) return null + return userMessages[userMessages.length - 1].content +} + +export function createWebAdapter() { + const core = new AgentCore() + + // ─────────────────────────────────────────────────────────────────────── + // POST /api/command — single-turn command execution + // ─────────────────────────────────────────────────────────────────────── + + async function handleCommand(req: Request, res: Response) { + const wallet = getWallet(req) + const { message } = req.body as { message?: string } + + if (!message || typeof message !== 'string') { + res.status(400).json({ error: 'message is required' }) + return + } + + if (message.length > MAX_MESSAGE_LENGTH) { + res.status(400).json({ error: 'message exceeds 4000 character limit' }) + return + } + + const response = await core.processMessage({ + platform: 'web', + userId: wallet, + message, + }) + + res.json({ status: 'ok', wallet, response }) + } + + // ─────────────────────────────────────────────────────────────────────── + // POST /api/chat — multi-turn chat (JSON response) + // ─────────────────────────────────────────────────────────────────────── + + async function handleChat(req: Request, res: Response) { + const wallet = getWallet(req) + const lastUserMsg = extractLastUserMessage(req.body?.messages) + + if (!lastUserMsg) { + res.status(400).json({ + error: 'messages array is required and must not be empty', + }) + return + } + + const response = await core.processMessage({ + platform: 'web', + userId: wallet, + message: lastUserMsg, + }) + + res.json(response) + } + + // ─────────────────────────────────────────────────────────────────────── + // POST /api/chat/stream — multi-turn chat (SSE stream) + // ─────────────────────────────────────────────────────────────────────── + + async function handleChatStream(req: Request, res: Response) { + const wallet = getWallet(req) + const lastUserMsg = extractLastUserMessage(req.body?.messages) + + if (!lastUserMsg) { + res.status(400).json({ + error: 'messages array is required and must not be empty', + }) + return + } + + // Set SSE headers + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + + // Track client disconnect + let aborted = false + res.on('close', () => { + aborted = true + }) + + try { + const ctx = { platform: 'web' as const, userId: wallet, message: lastUserMsg } + for await (const chunk of core.streamMessage(ctx)) { + if (aborted || res.writableEnded) break + res.write(`data: ${JSON.stringify(chunkToSSE(chunk))}\n\n`) + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Internal error' + res.write(`data: ${JSON.stringify({ type: 'error', message })}\n\n`) + } + + res.write('data: [DONE]\n\n') + res.end() + } + + return { handleCommand, handleChat, handleChatStream, core } +} diff --git a/packages/agent/tests/adapters/web.test.ts b/packages/agent/tests/adapters/web.test.ts new file mode 100644 index 0000000..4e2db61 --- /dev/null +++ b/packages/agent/tests/adapters/web.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { AgentResponse, ResponseChunk } from '../../src/core/types.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Mock AgentCore — prevent real LLM calls +// ───────────────────────────────────────────────────────────────────────────── + +const mockProcessMessage = vi.fn<(ctx: unknown) => Promise>() +const mockStreamMessage = vi.fn() + +vi.mock('../../src/core/agent-core.js', () => ({ + AgentCore: vi.fn().mockImplementation(() => ({ + processMessage: mockProcessMessage, + streamMessage: mockStreamMessage, + })), +})) + +// ───────────────────────────────────────────────────────────────────────────── +// Import after mocks are registered +// ───────────────────────────────────────────────────────────────────────────── + +import { createWebAdapter } from '../../src/adapters/web.js' + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers — minimal Express-like req/res mocks +// ───────────────────────────────────────────────────────────────────────────── + +function mockReq(overrides: Record = {}) { + return { body: {}, wallet: 'TestWallet123', ...overrides } as never +} + +function mockRes() { + const writes: string[] = [] + const headers: Record = {} + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + setHeader: vi.fn((k: string, v: string) => { headers[k] = v }), + flushHeaders: vi.fn(), + write: vi.fn((data: string) => { writes.push(data) }), + end: vi.fn(), + on: vi.fn(), + writableEnded: false, + _writes: writes, + _headers: headers, + } + return res as typeof res & { _writes: string[]; _headers: Record } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks() + mockProcessMessage.mockResolvedValue({ + text: 'adapter response', + toolsUsed: ['balance'], + }) + mockStreamMessage.mockImplementation(async function* () { + yield { type: 'text', text: 'streaming ' } as ResponseChunk + yield { type: 'text', text: 'response' } as ResponseChunk + yield { type: 'done', text: 'streaming response' } as ResponseChunk + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// handleCommand +// ───────────────────────────────────────────────────────────────────────────── + +describe('handleCommand', () => { + it('returns AgentResponse for valid message', async () => { + const adapter = createWebAdapter() + const req = mockReq({ body: { message: 'test' } }) + const res = mockRes() + + await adapter.handleCommand(req, res) + + expect(res.json).toHaveBeenCalledWith({ + status: 'ok', + wallet: 'TestWallet123', + response: { text: 'adapter response', toolsUsed: ['balance'] }, + }) + expect(mockProcessMessage).toHaveBeenCalledWith({ + platform: 'web', + userId: 'TestWallet123', + message: 'test', + }) + }) + + it('rejects missing message with 400', async () => { + const adapter = createWebAdapter() + const req = mockReq({ body: {} }) + const res = mockRes() + + await adapter.handleCommand(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ error: 'message is required' }) + }) + + it('rejects message exceeding 4000 chars with 400', async () => { + const adapter = createWebAdapter() + const req = mockReq({ body: { message: 'x'.repeat(4001) } }) + const res = mockRes() + + await adapter.handleCommand(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: 'message exceeds 4000 character limit', + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// handleChat +// ───────────────────────────────────────────────────────────────────────────── + +describe('handleChat', () => { + it('returns response from last user message', async () => { + const adapter = createWebAdapter() + const req = mockReq({ + body: { + messages: [ + { role: 'user', content: 'first question' }, + { role: 'assistant', content: 'first answer' }, + { role: 'user', content: 'follow up' }, + ], + }, + }) + const res = mockRes() + + await adapter.handleChat(req, res) + + expect(mockProcessMessage).toHaveBeenCalledWith({ + platform: 'web', + userId: 'TestWallet123', + message: 'follow up', + }) + expect(res.json).toHaveBeenCalledWith({ + text: 'adapter response', + toolsUsed: ['balance'], + }) + }) + + it('rejects empty messages array with 400', async () => { + const adapter = createWebAdapter() + const req = mockReq({ body: { messages: [] } }) + const res = mockRes() + + await adapter.handleChat(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: 'messages array is required and must not be empty', + }) + }) + + it('rejects missing messages with 400', async () => { + const adapter = createWebAdapter() + const req = mockReq({ body: {} }) + const res = mockRes() + + await adapter.handleChat(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: 'messages array is required and must not be empty', + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// handleChatStream +// ───────────────────────────────────────────────────────────────────────────── + +describe('handleChatStream', () => { + it('writes SSE events in correct format', async () => { + const adapter = createWebAdapter() + const req = mockReq({ + body: { + messages: [{ role: 'user', content: 'stream test' }], + }, + }) + const res = mockRes() + + await adapter.handleChatStream(req, res) + + // Should have 3 chunk writes + [DONE] + const sseWrites = res._writes.filter((w) => w.startsWith('data: ')) + expect(sseWrites).toHaveLength(4) // 2 text + 1 done + [DONE] + + // Verify text chunks map to content_block_delta + const first = JSON.parse(sseWrites[0].replace('data: ', '').trim()) + expect(first).toEqual({ type: 'content_block_delta', text: 'streaming ' }) + + const second = JSON.parse(sseWrites[1].replace('data: ', '').trim()) + expect(second).toEqual({ type: 'content_block_delta', text: 'response' }) + + // Verify done chunk maps to message_complete + const done = JSON.parse(sseWrites[2].replace('data: ', '').trim()) + expect(done).toEqual({ + type: 'message_complete', + content: 'streaming response', + }) + + // Verify final [DONE] sentinel + expect(sseWrites[3]).toBe('data: [DONE]\n\n') + + // Verify stream ended + expect(res.end).toHaveBeenCalled() + }) + + it('sets correct SSE headers', async () => { + const adapter = createWebAdapter() + const req = mockReq({ + body: { + messages: [{ role: 'user', content: 'headers test' }], + }, + }) + const res = mockRes() + + await adapter.handleChatStream(req, res) + + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/event-stream', + ) + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache') + expect(res.setHeader).toHaveBeenCalledWith('Connection', 'keep-alive') + expect(res.setHeader).toHaveBeenCalledWith('X-Accel-Buffering', 'no') + expect(res.flushHeaders).toHaveBeenCalled() + }) + + it('handles tool_start and tool_end chunks', async () => { + mockStreamMessage.mockImplementation(async function* () { + yield { type: 'tool_start', toolName: 'balance', toolId: 'tool_1' } as ResponseChunk + yield { type: 'tool_end', toolName: 'balance', toolId: 'tool_1', success: true } as ResponseChunk + yield { type: 'done', text: 'done' } as ResponseChunk + }) + + const adapter = createWebAdapter() + const req = mockReq({ + body: { messages: [{ role: 'user', content: 'check' }] }, + }) + const res = mockRes() + + await adapter.handleChatStream(req, res) + + const sseWrites = res._writes.filter((w) => w.startsWith('data: ') && w !== 'data: [DONE]\n\n') + const toolStart = JSON.parse(sseWrites[0].replace('data: ', '').trim()) + expect(toolStart).toEqual({ + type: 'tool_use', + name: 'balance', + id: 'tool_1', + }) + + const toolEnd = JSON.parse(sseWrites[1].replace('data: ', '').trim()) + expect(toolEnd).toEqual({ + type: 'tool_result', + name: 'balance', + id: 'tool_1', + success: true, + }) + }) + + it('writes error event on stream failure', async () => { + mockStreamMessage.mockImplementation(async function* () { + yield { type: 'text', text: 'partial' } as ResponseChunk + throw new Error('LLM connection lost') + }) + + const adapter = createWebAdapter() + const req = mockReq({ + body: { messages: [{ role: 'user', content: 'fail' }] }, + }) + const res = mockRes() + + await adapter.handleChatStream(req, res) + + const errorWrite = res._writes.find((w) => + w.includes('"type":"error"'), + ) + expect(errorWrite).toBeDefined() + const parsed = JSON.parse(errorWrite!.replace('data: ', '').trim()) + expect(parsed.type).toBe('error') + expect(parsed.message).toBe('LLM connection lost') + + // Should still end with [DONE] + expect(res._writes[res._writes.length - 1]).toBe('data: [DONE]\n\n') + expect(res.end).toHaveBeenCalled() + }) + + it('rejects missing messages with 400', async () => { + const adapter = createWebAdapter() + const req = mockReq({ body: {} }) + const res = mockRes() + + await adapter.handleChatStream(req, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: 'messages array is required and must not be empty', + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// core reference +// ───────────────────────────────────────────────────────────────────────────── + +describe('adapter core reference', () => { + it('exposes core instance', () => { + const adapter = createWebAdapter() + expect(adapter.core).toBeDefined() + expect(adapter.core.processMessage).toBeDefined() + expect(adapter.core.streamMessage).toBeDefined() + }) +}) From 419d1ce8a231eee832ded1e471f0eb41868259ab Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 09:18:59 +0700 Subject: [PATCH 4/5] refactor: wire web adapter into Express routes, replacing inline handlers --- packages/agent/src/index.ts | 79 +++++-------------------------------- 1 file changed, 10 insertions(+), 69 deletions(-) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index c796ca2..e133300 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,15 +1,15 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import express, { type Request, type Response } from 'express' -import { chat, chatStream, TOOLS, executeTool } from './agent.js' +import { TOOLS, executeTool } from './agent.js' import { startCrank, stopCrank } from './crank.js' import { getDb, closeDb, expireStaleLinks, getActivity } from './db.js' -import { resolveSession, activeSessionCount, purgeStale } from './session.js' +import { activeSessionCount, purgeStale } from './session.js' import { payRouter } from './routes/pay.js' import { adminRouter } from './routes/admin.js' import { authRouter, verifyJwt, requireOwner } from './routes/auth.js' import { streamHandler } from './routes/stream.js' -import { commandHandler } from './routes/command.js' +import { createWebAdapter } from './adapters/web.js' import { confirmRouter } from './routes/confirm.js' import { vaultRouter } from './routes/vault-api.js' import { squadRouter, isKillSwitchActive } from './routes/squad-api.js' @@ -49,6 +49,10 @@ const sentinel = new SentinelWorker() sentinel.start() console.log(' SENTINEL: started (blockchain monitor, no wallets yet)') +// Platform adapter — maps Express routes to AgentCore +const webAdapter = createWebAdapter() +console.log(' WebAdapter: initialized (command + chat + stream)') + // Purge stale in-memory conversations every 5 minutes setInterval(() => { const purged = purgeStale() @@ -85,7 +89,7 @@ app.post('/api/command', verifyJwt, (req, res, next) => { res.status(503).json({ error: 'operations paused — kill switch active' }) return } - commandHandler(req, res).catch(next) + webAdapter.handleCommand(req, res).catch(next) }) // Fund-movement confirmation resolution — JWT required @@ -120,26 +124,8 @@ app.post('/api/chat', verifyJwt, async (req, res) => { res.status(503).json({ error: 'operations paused — kill switch active' }) return } - - const wallet = (req as unknown as Record).wallet as string - const { messages } = req.body - - if (!messages || !Array.isArray(messages) || messages.length === 0) { - res.status(400).json({ - error: 'messages is required and must be a non-empty array', - }) - return - } - - // Resolve session context from JWT-authenticated wallet - const session = resolveSession(wallet) - if (session) { - console.log(`[session] resolved ${session.id.slice(0, 8)}… for ${wallet.slice(0, 4)}…${wallet.slice(-4)}`) - } - try { - const response = await chat(messages) - res.json(response) + await webAdapter.handleChat(req, res) } catch (error) { const message = error instanceof Error ? error.message : 'Internal server error' console.error('[agent] chat error:', message) @@ -154,52 +140,7 @@ app.post('/api/chat/stream', verifyJwt, async (req, res) => { res.status(503).json({ error: 'operations paused — kill switch active' }) return } - - const wallet = (req as unknown as Record).wallet as string - const { messages } = req.body - - if (!messages || !Array.isArray(messages) || messages.length === 0) { - res.status(400).json({ - error: 'messages is required and must be a non-empty array', - }) - return - } - - // Resolve session context from JWT-authenticated wallet - const session = resolveSession(wallet) - if (session) { - console.log(`[session] resolved ${session.id.slice(0, 8)}… for ${wallet.slice(0, 4)}…${wallet.slice(-4)}`) - } - - // SSE headers — keep the connection open for streaming - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('Connection', 'keep-alive') - res.setHeader('X-Accel-Buffering', 'no') - res.flushHeaders() - - let aborted = false - res.on('close', () => { aborted = true }) - - try { - for await (const event of chatStream(messages)) { - // Check if client disconnected - if (aborted || res.writableEnded) break - res.write(`data: ${JSON.stringify(event)}\n\n`) - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Internal server error' - console.error('[agent] stream error:', message) - - if (!res.writableEnded) { - res.write(`data: ${JSON.stringify({ type: 'error', message })}\n\n`) - } - } finally { - if (!res.writableEnded) { - res.write('data: [DONE]\n\n') - res.end() - } - } + await webAdapter.handleChatStream(req, res) }) // ─── Tool execution endpoint (for direct tool calls from the UI) ──────────── From ef8ea55946d2572a2e48564e39998f35fccbefbb Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 09:20:08 +0700 Subject: [PATCH 5/5] chore: remove dead command handler, fix adapter type cast --- .../2026-04-10-platform-abstraction-layer.md | 711 ++++++++++++++++++ packages/agent/src/adapters/web.ts | 2 +- packages/agent/src/routes/command.ts | 29 - 3 files changed, 712 insertions(+), 30 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-10-platform-abstraction-layer.md delete mode 100644 packages/agent/src/routes/command.ts diff --git a/docs/superpowers/plans/2026-04-10-platform-abstraction-layer.md b/docs/superpowers/plans/2026-04-10-platform-abstraction-layer.md new file mode 100644 index 0000000..6f38df9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-platform-abstraction-layer.md @@ -0,0 +1,711 @@ +# Platform Abstraction Layer — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract a platform-agnostic agent core so SIPHER can serve web, X DMs, and Telegram from the same conversation engine — no duplication. + +**Architecture:** Define a `MsgContext` interface representing any inbound message (web command, X DM, Telegram text). Create an `AgentCore` class that owns the full cycle: session resolution → conversation load → LLM call → tool execution → conversation persist → response. Each platform implements a thin adapter that maps its I/O to `MsgContext`. The existing Express routes become the "web adapter." + +**Tech Stack:** TypeScript, Anthropic SDK (via OpenRouter), Express 5, SQLite (better-sqlite3), Vitest + +--- + +## File Structure + +| File | Responsibility | +|------|---------------| +| **Create:** `packages/agent/src/core/types.ts` | `MsgContext`, `AgentResponse`, `PlatformAdapter` interfaces | +| **Create:** `packages/agent/src/core/agent-core.ts` | Platform-agnostic message processing — session, conversation, LLM, tools | +| **Create:** `packages/agent/src/core/index.ts` | Barrel export | +| **Create:** `packages/agent/src/adapters/web.ts` | Express req/res → MsgContext → AgentCore → Express res | +| **Create:** `packages/agent/tests/core/agent-core.test.ts` | Unit tests for AgentCore | +| **Create:** `packages/agent/tests/adapters/web.test.ts` | Integration tests for web adapter | +| **Modify:** `packages/agent/src/agent.ts` | Remove session/conversation concerns (stays as LLM-only module) | +| **Modify:** `packages/agent/src/index.ts` | Replace inline chat/stream route handlers with web adapter | + +--- + +### Task 1: Define core types + +**Files:** +- Create: `packages/agent/src/core/types.ts` +- Create: `packages/agent/src/core/index.ts` + +- [ ] **Step 1: Create the types file** + +```typescript +// packages/agent/src/core/types.ts + +/** Platform a message originated from */ +export type Platform = 'web' | 'telegram' | 'x' + +/** + * Unified inbound message context — platform-agnostic. + * Every adapter constructs this from its native format. + */ +export interface MsgContext { + /** Platform the message came from */ + platform: Platform + /** User identifier — wallet address (web), telegram user ID, X user ID */ + userId: string + /** The user's message text */ + message: string + /** Optional metadata from the platform (thread ID, reply-to, etc.) */ + metadata?: Record +} + +/** A single response chunk for streaming */ +export interface ResponseChunk { + type: 'text' | 'tool_start' | 'tool_end' | 'error' | 'done' + text?: string + toolName?: string + toolId?: string + success?: boolean +} + +/** Full (non-streaming) agent response */ +export interface AgentResponse { + text: string + toolsUsed: string[] +} +``` + +- [ ] **Step 2: Create the barrel export** + +```typescript +// packages/agent/src/core/index.ts +export type { + Platform, + MsgContext, + ResponseChunk, + AgentResponse, +} from './types.js' +export { AgentCore } from './agent-core.js' +``` + +Note: `AgentCore` export will error until Task 2 creates the file. That's fine — we commit types first. + +- [ ] **Step 3: Commit** + +```bash +git add packages/agent/src/core/types.ts packages/agent/src/core/index.ts +git commit -m "feat: define MsgContext and AgentResponse types for platform abstraction" +``` + +--- + +### Task 2: Build AgentCore + +**Files:** +- Create: `packages/agent/src/core/agent-core.ts` +- Create: `packages/agent/tests/core/agent-core.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/agent/tests/core/agent-core.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the LLM — we don't call OpenRouter in unit tests +vi.mock('../../src/agent.js', () => ({ + chat: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Mock response' }], + stop_reason: 'end_turn', + }), + chatStream: vi.fn().mockImplementation(async function* () { + yield { type: 'content_block_delta', text: 'Mock ' } + yield { type: 'content_block_delta', text: 'stream' } + yield { type: 'message_complete', content: 'Mock stream' } + }), + executeTool: vi.fn(), + TOOLS: [], + SYSTEM_PROMPT: 'test', +})) + +const { AgentCore } = await import('../../src/core/agent-core.js') + +describe('AgentCore', () => { + let core: InstanceType + + beforeEach(() => { + core = new AgentCore() + }) + + it('processMessage returns text response', async () => { + const result = await core.processMessage({ + platform: 'web', + userId: 'wallet123', + message: 'hello', + }) + + expect(result.text).toBe('Mock response') + expect(result.toolsUsed).toEqual([]) + }) + + it('streamMessage yields chunks', async () => { + const chunks = [] + for await (const chunk of core.streamMessage({ + platform: 'web', + userId: 'wallet456', + message: 'hello stream', + })) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThanOrEqual(2) + expect(chunks.some(c => c.type === 'text')).toBe(true) + expect(chunks[chunks.length - 1].type).toBe('done') + }) + + it('resolves session from userId', async () => { + await core.processMessage({ + platform: 'web', + userId: 'walletABC', + message: 'test session', + }) + + // Calling again should reuse session (no error) + const result = await core.processMessage({ + platform: 'web', + userId: 'walletABC', + message: 'test session again', + }) + expect(result.text).toBeDefined() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/agent && npx vitest run tests/core/agent-core.test.ts` +Expected: FAIL — `agent-core.js` does not exist + +- [ ] **Step 3: Implement AgentCore** + +```typescript +// packages/agent/src/core/agent-core.ts +import { chat, chatStream } from '../agent.js' +import { resolveSession, getConversation, appendConversation } from '../session.js' +import type { MsgContext, AgentResponse, ResponseChunk } from './types.js' +import type Anthropic from '@anthropic-ai/sdk' + +/** + * Platform-agnostic agent core. + * + * Owns the full message lifecycle: + * 1. Resolve session from userId + * 2. Load conversation history + * 3. Call LLM (chat or stream) + * 4. Persist conversation + * 5. Return response + */ +export class AgentCore { + /** + * Process a message and return the full response (non-streaming). + */ + async processMessage(ctx: MsgContext): Promise { + const session = resolveSession(ctx.userId) + const history = getConversation(session.id) + + const messages: Anthropic.MessageParam[] = [ + ...history.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content as string })), + { role: 'user' as const, content: ctx.message }, + ] + + const response = await chat(messages) + + // Extract text from content blocks + const text = response.content + .filter((b): b is Anthropic.TextBlock => b.type === 'text') + .map(b => b.text) + .join('') + + // Extract tool names used + const toolsUsed = response.content + .filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use') + .map(b => b.name) + + // Persist conversation + appendConversation(session.id, [ + { role: 'user', content: ctx.message }, + { role: 'assistant', content: text }, + ]) + + return { text, toolsUsed } + } + + /** + * Process a message and yield streaming chunks. + */ + async *streamMessage(ctx: MsgContext): AsyncGenerator { + const session = resolveSession(ctx.userId) + const history = getConversation(session.id) + + const messages: Anthropic.MessageParam[] = [ + ...history.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content as string })), + { role: 'user' as const, content: ctx.message }, + ] + + let fullText = '' + const toolsUsed: string[] = [] + + for await (const event of chatStream(messages)) { + switch (event.type) { + case 'content_block_delta': + fullText += event.text + yield { type: 'text', text: event.text } + break + case 'tool_use': + toolsUsed.push(event.name) + yield { type: 'tool_start', toolName: event.name, toolId: event.id } + break + case 'tool_result': + yield { type: 'tool_end', toolName: event.name, toolId: event.id, success: event.success } + break + case 'error': + yield { type: 'error', text: event.message } + break + case 'message_complete': + fullText = event.content + break + } + } + + // Persist conversation + appendConversation(session.id, [ + { role: 'user', content: ctx.message }, + { role: 'assistant', content: fullText }, + ]) + + yield { type: 'done', text: fullText } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/agent && npx vitest run tests/core/agent-core.test.ts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add packages/agent/src/core/agent-core.ts packages/agent/tests/core/agent-core.test.ts +git commit -m "feat: implement AgentCore — platform-agnostic message processing" +``` + +--- + +### Task 3: Build the web adapter + +**Files:** +- Create: `packages/agent/src/adapters/web.ts` +- Create: `packages/agent/tests/adapters/web.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/agent/tests/adapters/web.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the AgentCore +vi.mock('../../src/core/agent-core.js', () => ({ + AgentCore: vi.fn().mockImplementation(() => ({ + processMessage: vi.fn().mockResolvedValue({ + text: 'adapter response', + toolsUsed: ['balance'], + }), + streamMessage: vi.fn().mockImplementation(async function* () { + yield { type: 'text', text: 'streaming ' } + yield { type: 'text', text: 'response' } + yield { type: 'done', text: 'streaming response' } + }), + })), +})) + +const { createWebAdapter } = await import('../../src/adapters/web.js') + +describe('Web Adapter', () => { + it('creates command handler that returns AgentResponse', async () => { + const adapter = createWebAdapter() + const mockReq = { + body: { message: 'check balance' }, + wallet: 'walletXYZ', + } + const mockRes = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } + + await adapter.handleCommand(mockReq as any, mockRes as any) + + expect(mockRes.json).toHaveBeenCalledWith({ + status: 'ok', + wallet: 'walletXYZ', + response: { text: 'adapter response', toolsUsed: ['balance'] }, + }) + }) + + it('creates chat handler that returns full response', async () => { + const adapter = createWebAdapter() + const mockReq = { + body: { messages: [{ role: 'user', content: 'hello' }] }, + wallet: 'walletXYZ', + } + const mockRes = { + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } + + await adapter.handleChat(mockReq as any, mockRes as any) + + expect(mockRes.json).toHaveBeenCalledWith({ + text: 'adapter response', + toolsUsed: ['balance'], + }) + }) + + it('creates stream handler that writes SSE events', async () => { + const adapter = createWebAdapter() + const writes: string[] = [] + const mockReq = { + body: { messages: [{ role: 'user', content: 'stream test' }] }, + wallet: 'walletABC', + } + const mockRes = { + setHeader: vi.fn(), + flushHeaders: vi.fn(), + write: vi.fn((data: string) => writes.push(data)), + writableEnded: false, + on: vi.fn(), + end: vi.fn(), + } + + await adapter.handleChatStream(mockReq as any, mockRes as any) + + expect(mockRes.setHeader).toHaveBeenCalledWith('Content-Type', 'text/event-stream') + expect(writes.some(w => w.includes('streaming '))).toBe(true) + expect(writes.some(w => w.includes('[DONE]'))).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/agent && npx vitest run tests/adapters/web.test.ts` +Expected: FAIL — `web.js` does not exist + +- [ ] **Step 3: Implement the web adapter** + +```typescript +// packages/agent/src/adapters/web.ts +import type { Request, Response } from 'express' +import { AgentCore } from '../core/agent-core.js' +import type { ResponseChunk } from '../core/types.js' + +/** + * Web adapter — maps Express HTTP requests to AgentCore. + * + * Provides handlers for: + * - handleCommand: POST /api/command (single message → full response) + * - handleChat: POST /api/chat (messages array → full response) + * - handleChatStream: POST /api/chat/stream (messages → SSE stream) + */ +export function createWebAdapter() { + const core = new AgentCore() + + /** POST /api/command — single message in, full response out */ + async function handleCommand(req: Request, res: Response): Promise { + const wallet = (req as unknown as Record).wallet as string + const { message } = req.body as { message?: string } + + if (!message || typeof message !== 'string') { + res.status(400).json({ error: 'message is required' }) + return + } + + if (message.length > 4000) { + res.status(400).json({ error: 'message too long (max 4000 chars)' }) + return + } + + const response = await core.processMessage({ + platform: 'web', + userId: wallet, + message, + }) + + res.json({ status: 'ok', wallet, response }) + } + + /** POST /api/chat — messages array in, full response out */ + async function handleChat(req: Request, res: Response): Promise { + const wallet = (req as unknown as Record).wallet as string + const { messages } = req.body + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + res.status(400).json({ error: 'messages is required and must be a non-empty array' }) + return + } + + // Use the last user message for AgentCore processing + const lastUserMsg = [...messages].reverse().find((m: { role: string }) => m.role === 'user') + if (!lastUserMsg) { + res.status(400).json({ error: 'messages must contain at least one user message' }) + return + } + + const response = await core.processMessage({ + platform: 'web', + userId: wallet, + message: typeof lastUserMsg.content === 'string' ? lastUserMsg.content : JSON.stringify(lastUserMsg.content), + }) + + res.json(response) + } + + /** POST /api/chat/stream — messages in, SSE events out */ + async function handleChatStream(req: Request, res: Response): Promise { + const wallet = (req as unknown as Record).wallet as string + const { messages } = req.body + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + res.status(400).json({ error: 'messages is required and must be a non-empty array' }) + return + } + + const lastUserMsg = [...messages].reverse().find((m: { role: string }) => m.role === 'user') + if (!lastUserMsg) { + res.status(400).json({ error: 'messages must contain at least one user message' }) + return + } + + // SSE headers + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.flushHeaders() + + let aborted = false + res.on('close', () => { aborted = true }) + + try { + for await (const chunk of core.streamMessage({ + platform: 'web', + userId: wallet, + message: typeof lastUserMsg.content === 'string' ? lastUserMsg.content : JSON.stringify(lastUserMsg.content), + })) { + if (aborted || res.writableEnded) break + res.write(`data: ${JSON.stringify(chunkToSSE(chunk))}\n\n`) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Internal server error' + if (!res.writableEnded) { + res.write(`data: ${JSON.stringify({ type: 'error', message })}\n\n`) + } + } finally { + if (!res.writableEnded) { + res.write('data: [DONE]\n\n') + res.end() + } + } + } + + return { handleCommand, handleChat, handleChatStream, core } +} + +/** Map internal ResponseChunk to the SSE event format the frontend expects */ +function chunkToSSE(chunk: ResponseChunk): Record { + switch (chunk.type) { + case 'text': + return { type: 'content_block_delta', text: chunk.text } + case 'tool_start': + return { type: 'tool_use', name: chunk.toolName, id: chunk.toolId } + case 'tool_end': + return { type: 'tool_result', name: chunk.toolName, id: chunk.toolId, success: chunk.success } + case 'error': + return { type: 'error', message: chunk.text } + case 'done': + return { type: 'message_complete', content: chunk.text } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/agent && npx vitest run tests/adapters/web.test.ts` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add packages/agent/src/adapters/web.ts packages/agent/tests/adapters/web.test.ts +git commit -m "feat: create web adapter mapping Express to AgentCore" +``` + +--- + +### Task 4: Wire web adapter into index.ts + +**Files:** +- Modify: `packages/agent/src/index.ts` + +- [ ] **Step 1: Add web adapter import and replace inline handlers** + +In `packages/agent/src/index.ts`, add import at the top: + +```typescript +import { createWebAdapter } from './adapters/web.js' +``` + +After the existing initialization block (after `console.log(' SENTINEL: started...')`), add: + +```typescript +const webAdapter = createWebAdapter() +``` + +Replace the `/api/command` route (lines ~83-89): + +```typescript +// Before: +app.post('/api/command', verifyJwt, (req, res, next) => { + if (isKillSwitchActive()) { + res.status(503).json({ error: 'operations paused — kill switch active' }) + return + } + commandHandler(req, res).catch(next) +}) + +// After: +app.post('/api/command', verifyJwt, (req, res, next) => { + if (isKillSwitchActive()) { + res.status(503).json({ error: 'operations paused — kill switch active' }) + return + } + webAdapter.handleCommand(req, res).catch(next) +}) +``` + +Replace the `/api/chat` route (lines ~118-148): + +```typescript +// After: +app.post('/api/chat', verifyJwt, async (req, res) => { + if (isKillSwitchActive()) { + res.status(503).json({ error: 'operations paused — kill switch active' }) + return + } + try { + await webAdapter.handleChat(req, res) + } catch (error) { + const message = error instanceof Error ? error.message : 'Internal server error' + console.error('[agent] chat error:', message) + res.status(500).json({ error: message }) + } +}) +``` + +Replace the `/api/chat/stream` route (lines ~152-203): + +```typescript +// After: +app.post('/api/chat/stream', verifyJwt, async (req, res) => { + if (isKillSwitchActive()) { + res.status(503).json({ error: 'operations paused — kill switch active' }) + return + } + await webAdapter.handleChatStream(req, res) +}) +``` + +Remove the `commandHandler` import (line 12) since it's no longer used: + +```typescript +// Remove this line: +import { commandHandler } from './routes/command.js' +``` + +- [ ] **Step 2: Run full test suite** + +Run: `pnpm test -- --run` +Expected: All 497+ tests pass. The web adapter is a drop-in replacement — same request/response shape. + +- [ ] **Step 3: Commit** + +```bash +git add packages/agent/src/index.ts +git commit -m "refactor: wire web adapter into Express routes, replacing inline handlers" +``` + +--- + +### Task 5: Verify no regressions + cleanup + +**Files:** +- Possibly modify: `packages/agent/src/routes/command.ts` (delete if fully replaced) + +- [ ] **Step 1: Check if command.ts is still imported anywhere** + +```bash +grep -r "command.js\|commandHandler" packages/agent/src/ --include="*.ts" +``` + +If only in `routes/command.ts` itself (self-reference), it's dead code. Delete it. + +- [ ] **Step 2: Run full test suite** + +```bash +pnpm test -- --run +``` + +Expected: All tests pass. If `command.ts` was referenced in tests, update accordingly. + +- [ ] **Step 3: Run the app build** + +```bash +cd app && pnpm build +``` + +Expected: Clean build (frontend unchanged). + +- [ ] **Step 4: Type check** + +```bash +cd packages/agent && npx tsc --noEmit +``` + +Expected: No type errors. + +- [ ] **Step 5: Commit cleanup** + +```bash +git add -A +git commit -m "chore: remove dead command handler, verify platform abstraction" +``` + +--- + +## Summary + +After these 5 tasks, the architecture becomes: + +``` + ┌──────────────┐ + │ MsgContext │ ← Platform-agnostic message + └──────┬───────┘ + │ + ┌──────▼───────┐ + │ AgentCore │ ← Session + Conversation + LLM + Tools + └──────┬───────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ┌──────▼──┐ ┌──────▼──┐ ┌──────▼──┐ + │ Web │ │Telegram │ │ X │ + │ Adapter │ │ Adapter │ │ Adapter │ + └─────────┘ └─────────┘ └─────────┘ + (Task 3-4) (Phase 2.2) (Phase 2.3) +``` + +Web adapter is built and wired. Telegram and X adapters follow in subsequent plans — they just implement the same pattern: construct `MsgContext`, call `core.processMessage()` or `core.streamMessage()`. diff --git a/packages/agent/src/adapters/web.ts b/packages/agent/src/adapters/web.ts index f95ec7d..2da254f 100644 --- a/packages/agent/src/adapters/web.ts +++ b/packages/agent/src/adapters/web.ts @@ -45,7 +45,7 @@ function chunkToSSE(chunk: ResponseChunk): Record { * Extract the wallet address set by JWT middleware. */ function getWallet(req: Request): string { - return (req as Record).wallet as string + return (req as unknown as Record).wallet as string } /** diff --git a/packages/agent/src/routes/command.ts b/packages/agent/src/routes/command.ts deleted file mode 100644 index 30cb11c..0000000 --- a/packages/agent/src/routes/command.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type Request, type Response } from 'express' -import { chat } from '../agent.js' - -/** - * POST /api/command - * Command bar → SIPHER agent. - */ -export async function commandHandler(req: Request, res: Response): Promise { - const wallet = (req as unknown as Record).wallet as string - const { message } = req.body as { message?: string } - - if (!message || typeof message !== 'string') { - res.status(400).json({ error: 'message is required' }) - return - } - - if (message.length > 4000) { - res.status(400).json({ error: 'message too long (max 4000 chars)' }) - return - } - - try { - const response = await chat([{ role: 'user', content: message }]) - res.json({ status: 'ok', wallet, response }) - } catch (error) { - const msg = error instanceof Error ? error.message : 'Command execution failed' - res.status(500).json({ error: msg }) - } -}